chore: merge latest main after source review
This commit is contained in:
commit
70435de2c6
|
|
@ -209,8 +209,15 @@ replaces) the REA entry in the home patch `$DSH_HOME/cordis.patch.yml`
|
|||
patches and comments. `rea doctor --client dsh` checks that same entry id.
|
||||
`rea uninstall` removes it by scanning supported clients; uninstall takes no
|
||||
`--client` flag. The home patch applies to every Harness profile, so the
|
||||
registration is written once and no per-profile edit is needed. The written
|
||||
entry is:
|
||||
registration is written once and no per-profile edit is needed.
|
||||
|
||||
Setup, doctor and uninstall resolve `DSH_HOME` and `DSH_AGENTS_HOME` as Harness
|
||||
does: unset, empty or whitespace-only values use `~/.dsh` and `~/.agents`,
|
||||
respectively. Bare `~`, `~/` and `~\` expand to the user home; other relative
|
||||
values resolve against the command's working directory. Nonblank values retain
|
||||
their original spaces, and the agents root is independent of the Harness home.
|
||||
Run REA and Harness from the same working directory when using relative overrides.
|
||||
The written entry is:
|
||||
|
||||
```yaml
|
||||
- insert:
|
||||
|
|
|
|||
|
|
@ -208,6 +208,13 @@ arrays, and later explicit data properties and methods replace earlier object
|
|||
spread references. A known array prefix does not expose values from a following
|
||||
spread. Accessors, custom or uncertain iteration, unknown computed keys, and
|
||||
unknown spread lengths retain conservative uncertainty.
|
||||
Writes through an object getter's returned receiver retain that uncertainty
|
||||
when the object is an array element, nested inside mixed object/array
|
||||
containers, or assigned to a previously declared identifier. Saving the getter
|
||||
result in an alias does not make its old field values immutable. Known array
|
||||
indices include holes; an element after a spread has only a minimum possible
|
||||
index. Unrelated properties and known array prefixes remain precise, and a
|
||||
getter read without a write does not invalidate the receiver's fields.
|
||||
Nested callable returns are not assigned to their parent callable. Projected
|
||||
graph observations carry source ranges but never source text.
|
||||
|
||||
|
|
|
|||
|
|
@ -97,18 +97,22 @@ const resolveInternalSpecifier = (
|
|||
);
|
||||
if (logicalPathEscapesRoot(normalized))
|
||||
return { to: normalized, resolution: "unresolved" };
|
||||
// Preserve directory intent before normalization removes terminal dot segments.
|
||||
const directoryReference =
|
||||
specifier.endsWith("/") || [".", ".."].includes(posix.basename(specifier));
|
||||
const suffixes = ["", ".ts", ".js", ".mjs", ".cjs", ".tsx", ".jsx"];
|
||||
const candidates = suffixes.flatMap((suffix) => [
|
||||
`${normalized}${suffix}`,
|
||||
`${normalized}/index${suffix}`,
|
||||
...(directoryReference ? [] : [`${normalized}${suffix}`]),
|
||||
posix.join(normalized, `index${suffix}`),
|
||||
]);
|
||||
// Retain present runtime files first; substitute only a missing counterpart.
|
||||
// Counterparts cover explicit filenames, not directory/index resolution.
|
||||
candidates.push(
|
||||
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
|
||||
({ path }) => path,
|
||||
),
|
||||
);
|
||||
if (!directoryReference)
|
||||
candidates.push(
|
||||
...typescriptEmittedSourceCandidates(fromPath, normalized).map(
|
||||
({ path }) => path,
|
||||
),
|
||||
);
|
||||
const match = lookupPath.resolveCandidates(candidates);
|
||||
return match === undefined
|
||||
? { to: normalized, resolution: "unresolved" }
|
||||
|
|
|
|||
|
|
@ -157,8 +157,27 @@ const qwenCodeDirectory = ({ home, env }: ClientPathContext): string => {
|
|||
const grokDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.GROK_HOME || join(home, ".grok");
|
||||
|
||||
/** Match Harness home-paths for both independent roots; trim only to detect blanks. */
|
||||
const dshDirectory = (
|
||||
home: string,
|
||||
override: string | undefined,
|
||||
fallback: string,
|
||||
): string => {
|
||||
const selected =
|
||||
override !== undefined && override.trim().length > 0
|
||||
? override
|
||||
: join(home, fallback);
|
||||
const expanded =
|
||||
selected === "~"
|
||||
? home
|
||||
: selected.startsWith("~/") || selected.startsWith("~\\")
|
||||
? join(home, selected.slice(2))
|
||||
: selected;
|
||||
return resolve(expanded);
|
||||
};
|
||||
|
||||
const dshHomeDirectory = ({ home, env }: ClientPathContext): string =>
|
||||
env.DSH_HOME ?? join(home, ".dsh");
|
||||
dshDirectory(home, env.DSH_HOME, ".dsh");
|
||||
|
||||
const dshPatchDirectory = (context: ClientPathContext): string =>
|
||||
join(dshHomeDirectory(context), "cordis.patch.yml");
|
||||
|
|
@ -555,7 +574,7 @@ export const SUPPORTED_CLIENT_DEFINITIONS = [
|
|||
// The home Cordis patch applies to every profile; the skill directory is
|
||||
// shared agent storage Harness discovers.
|
||||
skillPath: ({ home, env }: ClientPathContext) =>
|
||||
join(env.DSH_AGENTS_HOME ?? join(home, ".agents"), "skills"),
|
||||
join(dshDirectory(home, env.DSH_AGENTS_HOME, ".agents"), "skills"),
|
||||
configPath: dshPatchDirectory,
|
||||
markerPath: dshHomeDirectory,
|
||||
format: "dsh",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,29 @@ import type { FileHandle } from "node:fs/promises";
|
|||
import { PassThrough, type Readable } from "node:stream";
|
||||
import { once } from "node:events";
|
||||
|
||||
import { Reader, ZipReader, type Entry, type FileEntry } from "@zip.js/zip.js";
|
||||
import {
|
||||
ERR_AMBIGUOUS_ARCHIVE,
|
||||
ERR_BAD_FORMAT,
|
||||
ERR_CENTRAL_DIRECTORY_NOT_FOUND,
|
||||
ERR_ENCRYPTED_CENTRAL_DIRECTORY,
|
||||
ERR_ENTRY_DATA_OUT_OF_BOUNDS,
|
||||
ERR_EOCDR_LOCATOR_ZIP64_NOT_FOUND,
|
||||
ERR_EOCDR_NOT_FOUND,
|
||||
ERR_EXTRAFIELD_ZIP64_NOT_FOUND,
|
||||
ERR_INVALID_COMPRESSED_DATA,
|
||||
ERR_INVALID_CRC32,
|
||||
ERR_INVALID_UNCOMPRESSED_SIZE,
|
||||
ERR_LOCAL_FILE_HEADER_NOT_FOUND,
|
||||
ERR_OVERLAPPING_ENTRY,
|
||||
ERR_SPLIT_ZIP_FILE,
|
||||
ERR_UNSUPPORTED_COMPRESSION,
|
||||
ERR_UNSUPPORTED_ENCRYPTION,
|
||||
ERR_UNSUPPORTED_UINT64,
|
||||
Reader,
|
||||
ZipReader,
|
||||
type Entry,
|
||||
type FileEntry,
|
||||
} from "@zip.js/zip.js";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
|
|
@ -159,9 +181,9 @@ export class ZipArtifactReader implements ArtifactReader {
|
|||
let index = 0;
|
||||
// A traversal owns its accounting, including overlapping or repeated passes.
|
||||
let declaredFileBytes = 0;
|
||||
for await (const entry of this.#reader.getEntriesGenerator({
|
||||
decodeText: decodeZipEntryText,
|
||||
})) {
|
||||
for await (const entry of typedZipFailures(
|
||||
this.#reader.getEntriesGenerator({ decodeText: decodeZipEntryText }),
|
||||
)) {
|
||||
abortIfNeeded(signal);
|
||||
// ZIP member identity is its directory occurrence, not its filename.
|
||||
// Logical path uniqueness belongs to the artifact inventory boundary.
|
||||
|
|
@ -327,7 +349,7 @@ const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => {
|
|||
output.end();
|
||||
},
|
||||
abort: (reason) => {
|
||||
output.destroy(toError(reason));
|
||||
output.destroy(toError(typedZipFailure(reason)));
|
||||
},
|
||||
});
|
||||
void entry
|
||||
|
|
@ -337,10 +359,61 @@ const extractStream = (entry: FileEntry, signal?: AbortSignal): Readable => {
|
|||
checkOverlappingEntry: true,
|
||||
onprogress: () => abortIfNeeded(signal),
|
||||
})
|
||||
.catch((cause: unknown) => output.destroy(toError(cause)));
|
||||
.catch((cause: unknown) => output.destroy(toError(typedZipFailure(cause))));
|
||||
return output;
|
||||
};
|
||||
|
||||
/** zip.js reports archive faults as plain errors identified by message. */
|
||||
const ZIP_FAILURE_REASONS: ReadonlyMap<string, "format" | "unavailable"> =
|
||||
new Map([
|
||||
...[
|
||||
ERR_AMBIGUOUS_ARCHIVE,
|
||||
ERR_BAD_FORMAT,
|
||||
ERR_CENTRAL_DIRECTORY_NOT_FOUND,
|
||||
ERR_ENTRY_DATA_OUT_OF_BOUNDS,
|
||||
ERR_EOCDR_LOCATOR_ZIP64_NOT_FOUND,
|
||||
ERR_EOCDR_NOT_FOUND,
|
||||
ERR_EXTRAFIELD_ZIP64_NOT_FOUND,
|
||||
ERR_INVALID_COMPRESSED_DATA,
|
||||
ERR_INVALID_CRC32,
|
||||
ERR_INVALID_UNCOMPRESSED_SIZE,
|
||||
ERR_LOCAL_FILE_HEADER_NOT_FOUND,
|
||||
ERR_OVERLAPPING_ENTRY,
|
||||
ERR_UNSUPPORTED_UINT64,
|
||||
].map((message) => [message, "format"] as const),
|
||||
...[
|
||||
ERR_ENCRYPTED_CENTRAL_DIRECTORY,
|
||||
ERR_SPLIT_ZIP_FILE,
|
||||
ERR_UNSUPPORTED_COMPRESSION,
|
||||
ERR_UNSUPPORTED_ENCRYPTION,
|
||||
].map((message) => [message, "unavailable"] as const),
|
||||
]);
|
||||
|
||||
/** Type a zip.js archive fault so consumers do not report it as file I/O. */
|
||||
const typedZipFailure = (cause: unknown): unknown => {
|
||||
if (!(cause instanceof Error) || cause instanceof ArtifactReaderFailure)
|
||||
return cause;
|
||||
const reason = ZIP_FAILURE_REASONS.get(cause.message);
|
||||
if (reason === undefined) return cause;
|
||||
return new ArtifactReaderFailure(
|
||||
reason,
|
||||
reason === "format"
|
||||
? `ZIP archive is malformed: ${cause.message}`
|
||||
: `ZIP archive uses an unsupported feature: ${cause.message}`,
|
||||
{ cause },
|
||||
);
|
||||
};
|
||||
|
||||
async function* typedZipFailures<T>(
|
||||
source: AsyncIterable<T>,
|
||||
): AsyncIterable<T> {
|
||||
try {
|
||||
yield* source;
|
||||
} catch (cause: unknown) {
|
||||
throw typedZipFailure(cause);
|
||||
}
|
||||
}
|
||||
|
||||
const isSymlink = (entry: Entry): boolean =>
|
||||
entry.unixMode !== undefined && (entry.unixMode & 0o170000) === 0o120000;
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export type CliOutputArgumentValidation =
|
|||
| {
|
||||
readonly ok: false;
|
||||
readonly format: StructuredOutputFormat;
|
||||
readonly code: "UNSUPPORTED_OUTPUT_COMBINATION";
|
||||
readonly code: "UNSUPPORTED_OUTPUT_COMBINATION" | "INVALID_OUTPUT_FILTER";
|
||||
readonly message: string;
|
||||
};
|
||||
|
||||
|
|
@ -89,13 +89,39 @@ export const parseCliOutputArguments = (
|
|||
return parsed(false);
|
||||
};
|
||||
|
||||
/** Reject text-window controls that would corrupt a structured document. */
|
||||
/** Reject slices that cannot advance within Incur's comma-delimited paths. */
|
||||
const hasUnclosedFilterSlice = (expression: string): boolean => {
|
||||
let depth = 0;
|
||||
let unclosed = false;
|
||||
for (const character of expression) {
|
||||
if (character === "[") {
|
||||
depth += 1;
|
||||
unclosed = true;
|
||||
} else if (character === "]") {
|
||||
depth -= 1;
|
||||
// Incur consumes everything through the next closing bracket, including
|
||||
// nested opening brackets. Do not impose a different filter grammar.
|
||||
unclosed = false;
|
||||
} else if (character === "," && depth === 0 && unclosed) return true;
|
||||
}
|
||||
return unclosed;
|
||||
};
|
||||
|
||||
/** Reject output controls that cannot safely produce a complete result. */
|
||||
export const validateCliOutputArguments = (
|
||||
arguments_: readonly string[],
|
||||
): CliOutputArgumentValidation => {
|
||||
const { format, parseError, tokenWindow } =
|
||||
const { filterOutput, format, parseError, tokenWindow } =
|
||||
parseCliOutputArguments(arguments_);
|
||||
if (parseError) return { ok: true };
|
||||
if (filterOutput !== undefined && hasUnclosedFilterSlice(filterOutput))
|
||||
return {
|
||||
ok: false,
|
||||
format: format === "yaml" || format === "jsonl" ? format : "json",
|
||||
code: "INVALID_OUTPUT_FILTER",
|
||||
message:
|
||||
"The --filter-output expression has a slice without a closing ]. Close the slice or remove --filter-output.",
|
||||
};
|
||||
if (
|
||||
tokenWindow &&
|
||||
(format === "json" || format === "jsonl" || format === "yaml")
|
||||
|
|
|
|||
|
|
@ -33,6 +33,38 @@ const getterReceivers = [
|
|||
"nested owner",
|
||||
"const outer = { box: { x: 1, get self() { return this; } } }; outer.box.self.x = 2; return outer.box.x;",
|
||||
],
|
||||
[
|
||||
"array element",
|
||||
"const arr = [{ x: 1, get self() { return this; } }]; arr[0].self.x = 2; return arr[0].x;",
|
||||
],
|
||||
[
|
||||
"array element after a hole",
|
||||
"const arr = [, { x: 1, get self() { return this; } }]; arr[1].self.x = 2; return arr[1].x;",
|
||||
],
|
||||
[
|
||||
"mixed object and array owners",
|
||||
"const outer = { rows: [{ box: { x: 1, get self() { return this; } } }] }; outer.rows[0].box.self.x = 2; return outer.rows[0].box.x;",
|
||||
],
|
||||
[
|
||||
"assigned owner",
|
||||
"let box; box = { x: 1, get self() { return this; } }; box.self.x = 2; return box.x;",
|
||||
],
|
||||
[
|
||||
"saved array receiver alias",
|
||||
"const arr = [{ x: 1, get self() { return this; } }]; const saved = arr[0].self; saved.x = 2; return arr[0].x;",
|
||||
],
|
||||
[
|
||||
"old receiver captured inside its replacement initializer",
|
||||
"const old = { x: 1 }; let box = old; box = { old: box, get self() { return this; } }; box.self.old.x = 2; return old.x;",
|
||||
],
|
||||
[
|
||||
"array receiver after an unknown spread",
|
||||
"const arr = [...items, { x: 1, get self() { return this; } }]; arr[index].self.x = 2; return arr[index].x;",
|
||||
],
|
||||
[
|
||||
"saved receiver before its array alias is reassigned",
|
||||
"const original = [{ x: 1, get self() { return this; } }]; let arr = original; const saved = arr[0].self; arr = [{ x: 1 }]; saved.x = 2; return original[0].x;",
|
||||
],
|
||||
[
|
||||
"inherited receiver field",
|
||||
"const shared = { x: 1 }; class A { get v() { return this.s; } } class B extends A { s = shared; } new B().v.x = 2; return shared.x;",
|
||||
|
|
@ -75,7 +107,7 @@ const getterReceivers = [
|
|||
],
|
||||
] as const;
|
||||
|
||||
describe("getter receiver mutation aliases (#1665)", () => {
|
||||
describe("getter receiver mutation aliases (#1665, #2654)", () => {
|
||||
it.each(getterReceivers)(
|
||||
"does not report a stale literal through %s",
|
||||
(_shape, body) => {
|
||||
|
|
@ -103,6 +135,42 @@ describe("getter receiver mutation aliases (#1665)", () => {
|
|||
"unrelated sibling",
|
||||
"const box = { x: 1, y: 1, get self() { return this; } }; box.self.y = 2; return box.x;",
|
||||
],
|
||||
[
|
||||
"array receiver read without write",
|
||||
"const arr = [{ x: 1, get self() { return this; } }]; const saved = arr[0].self; return arr[0].x;",
|
||||
],
|
||||
[
|
||||
"assigned receiver read without write",
|
||||
"let box; box = { x: 1, get self() { return this; } }; const saved = box.self; return box.x;",
|
||||
],
|
||||
[
|
||||
"unrelated array receiver property",
|
||||
"const arr = [{ x: 1, y: 1, get self() { return this; } }]; arr[0].self.y = 2; return arr[0].x;",
|
||||
],
|
||||
[
|
||||
"unrelated array element",
|
||||
"const arr = [{ x: 1, get self() { return this; } }, { x: 1 }]; arr[0].self.x = 2; return arr[1].x;",
|
||||
],
|
||||
[
|
||||
"array replacement after a receiver was captured",
|
||||
"const original = [{ x: 1, get self() { return this; } }]; const replacement = [{ x: 1 }]; let arr = original; const saved = arr[0].self; arr = replacement; saved.x = 2; return replacement[0].x;",
|
||||
],
|
||||
[
|
||||
"known array prefix before a spread receiver",
|
||||
"const arr = [{ x: 1 }, ...items, { x: 1, get self() { return this; } }]; arr[1].self.x = 2; return arr[0].x;",
|
||||
],
|
||||
[
|
||||
"outer binding shadowed by an assigned receiver",
|
||||
"let box; box = { x: 1 }; { let box; box = { x: 1, get self() { return this; } }; box.self.x = 2; } return box.x;",
|
||||
],
|
||||
[
|
||||
"old object before an unconditional receiver assignment",
|
||||
"const old = { x: 1 }; let box = old; box = { x: 1, get self() { return this; } }; box.self.x = 2; return old.x;",
|
||||
],
|
||||
[
|
||||
"unmodified old receiver captured inside its replacement initializer",
|
||||
"const old = { x: 1 }; let box = old; box = { x: 1, old: box, get self() { return this; } }; box.self.x = 2; return old.x;",
|
||||
],
|
||||
[
|
||||
"replace getter",
|
||||
"const shared = { x: 1 }; const box = { s: shared, get v() { return this.s; } }; box.v = {}; return shared.x;",
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
semanticClassCandidates,
|
||||
semanticClassPropertyReferences,
|
||||
semanticObjectPropertyReferences,
|
||||
type JavaScriptSemanticObjectReference,
|
||||
} from "./javascriptSemanticObjectReferences.js";
|
||||
import { semanticIterationSources } from "./javascriptSemanticIterationSources.js";
|
||||
import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js";
|
||||
|
|
@ -98,8 +99,20 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
effect: ValueEffect = "write",
|
||||
mutation?: t.Node,
|
||||
originAt: t.Node | undefined = mutation,
|
||||
ownerInitializer?: t.Node,
|
||||
): void => {
|
||||
const pending = [{ node, path, bindings, originAt }];
|
||||
const pending: (ReferencedValue & {
|
||||
readonly bindings: ReadonlySet<string>;
|
||||
readonly originAt: t.Node | undefined;
|
||||
})[] = [
|
||||
{
|
||||
node,
|
||||
path,
|
||||
bindings,
|
||||
originAt,
|
||||
...(ownerInitializer === undefined ? {} : { ownerInitializer }),
|
||||
},
|
||||
];
|
||||
// Visit shallower alias traversals first so their exact prefixes suppress
|
||||
// longer loop paths before those paths enumerate every assignment order.
|
||||
for (let cursor = 0; cursor < pending.length; cursor++) {
|
||||
|
|
@ -132,6 +145,8 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
binding.bindingId,
|
||||
current.originAt?.start,
|
||||
current.originAt?.end,
|
||||
current.ownerInitializer?.start,
|
||||
current.ownerInitializer?.end,
|
||||
];
|
||||
const lifetime = JSON.stringify([...capture, current.path]);
|
||||
if (current.bindings.has(lifetime)) continue;
|
||||
|
|
@ -165,6 +180,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
"escape",
|
||||
mutation,
|
||||
current.originAt,
|
||||
current.ownerInitializer,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
|
@ -195,6 +211,8 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
mutation?.end,
|
||||
current.originAt?.start,
|
||||
current.originAt?.end,
|
||||
current.ownerInitializer?.start,
|
||||
current.ownerInitializer?.end,
|
||||
]);
|
||||
const previous = selections.get(selectionIdentity);
|
||||
const excludedKeys =
|
||||
|
|
@ -220,6 +238,8 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
mutation?.end,
|
||||
current.originAt?.start,
|
||||
current.originAt?.end,
|
||||
current.ownerInitializer?.start,
|
||||
current.ownerInitializer?.end,
|
||||
]);
|
||||
let covering = coveringPaths.get(pathState);
|
||||
if (covering === undefined) {
|
||||
|
|
@ -235,6 +255,8 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
mutation?.end,
|
||||
current.originAt?.start,
|
||||
current.originAt?.end,
|
||||
current.ownerInitializer?.start,
|
||||
current.ownerInitializer?.end,
|
||||
]);
|
||||
if (recordedEffects.has(identity)) continue;
|
||||
// A recorded write invalidates the binding to a non-primitive value, so
|
||||
|
|
@ -248,10 +270,23 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
value?.status === "literal" || value?.status === "union";
|
||||
recordedEffects.add(identity);
|
||||
const nested = new Set([...current.bindings, lifetime, captureKey]);
|
||||
// Returning a literal's receiver reaches that allocation, not the
|
||||
// binding's older values read while evaluating its initializer.
|
||||
const origins =
|
||||
current.originAt === undefined
|
||||
? binding
|
||||
: semanticMutationInitializers(binding, current.originAt, parents);
|
||||
current.ownerInitializer !== undefined
|
||||
? {
|
||||
initializers: binding.initializers.filter(
|
||||
({ node }) => node === current.ownerInitializer,
|
||||
),
|
||||
referenceInitializers: [],
|
||||
}
|
||||
: current.originAt === undefined
|
||||
? binding
|
||||
: semanticMutationInitializers(
|
||||
binding,
|
||||
current.originAt,
|
||||
parents,
|
||||
);
|
||||
if (!primitiveWrite) {
|
||||
if (effect === "escape")
|
||||
binding.escapedPaths.push({ path, node: mutation });
|
||||
|
|
@ -367,6 +402,8 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
binding === undefined ? null : binding.bindingId,
|
||||
binding === undefined ? node.name : null,
|
||||
pathKey,
|
||||
value.ownerInitializer?.start,
|
||||
value.ownerInitializer?.end,
|
||||
]);
|
||||
if (leafIdentities.has(identity)) continue;
|
||||
leafIdentities.add(identity);
|
||||
|
|
@ -551,9 +588,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
clearSemanticValueCaches(state);
|
||||
}
|
||||
|
||||
interface ReferencedValue {
|
||||
readonly node: t.Node;
|
||||
readonly path: PropertyPath;
|
||||
interface ReferencedValue extends JavaScriptSemanticObjectReference {
|
||||
readonly iterableFallbackPath?: PropertyPath;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,11 @@ import {
|
|||
} from "./javascriptSemanticPropertyPaths.js";
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
|
||||
interface ReferencedValue {
|
||||
export interface JavaScriptSemanticObjectReference {
|
||||
readonly node: t.Node;
|
||||
readonly path: PropertyPath;
|
||||
/** The allocation owned by this binding, rather than a read of its prior value. */
|
||||
readonly ownerInitializer?: t.Node;
|
||||
}
|
||||
|
||||
export const semanticClassCandidates = (
|
||||
|
|
@ -72,10 +74,10 @@ const instanceFieldReferences = (
|
|||
path: PropertyPath,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): {
|
||||
readonly references: readonly ReferencedValue[];
|
||||
readonly references: readonly JavaScriptSemanticObjectReference[];
|
||||
readonly definiteNames: ReadonlySet<string>;
|
||||
} => {
|
||||
const references: ReferencedValue[] = [];
|
||||
const references: JavaScriptSemanticObjectReference[] = [];
|
||||
const pending = [{ klass: root, excluded: new Set<string>() }];
|
||||
const selections = new Map<t.Node, ReadonlySet<string>>();
|
||||
let definiteNames: ReadonlySet<string> | undefined;
|
||||
|
|
@ -208,7 +210,7 @@ export const semanticClassPropertyReferences = (
|
|||
selection: PropertyPath,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
activeGetters: ReadonlyMap<t.Node, number> = new Map(),
|
||||
): readonly ReferencedValue[] => {
|
||||
): readonly JavaScriptSemanticObjectReference[] => {
|
||||
const root = lookup.owner;
|
||||
const receiver = lookup.receiver ?? root;
|
||||
const [key, ...path] = selection;
|
||||
|
|
@ -216,7 +218,7 @@ export const semanticClassPropertyReferences = (
|
|||
key == null || typeof key === "object" ? null : String(key);
|
||||
const pending: (t.ClassDeclaration | t.ClassExpression)[] = [root];
|
||||
const seen = new Set<t.Node>();
|
||||
const possibleResults: ReferencedValue[] = [];
|
||||
const possibleResults: JavaScriptSemanticObjectReference[] = [];
|
||||
// Instance fields are own properties, including fields initialized by base
|
||||
// constructors. They take precedence over every prototype descriptor.
|
||||
let instanceFields: ReadonlySet<string> = new Set();
|
||||
|
|
@ -267,11 +269,11 @@ const objectReceiverReference = (
|
|||
object: t.ObjectExpression,
|
||||
path: PropertyPath,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly ReferencedValue[] => {
|
||||
): readonly JavaScriptSemanticObjectReference[] => {
|
||||
// A literal's enclosing binding owns its slots. Returning the literal
|
||||
// itself would bypass invalidation of that binding's observed properties.
|
||||
let current: t.Node = object;
|
||||
const projection: (string | number | null)[] = [];
|
||||
const projection: PropertyPath[number][] = [];
|
||||
while (true) {
|
||||
const parent = state.parentsByNode.get(current);
|
||||
if (
|
||||
|
|
@ -279,7 +281,43 @@ const objectReceiverReference = (
|
|||
parent.init === current &&
|
||||
t.isIdentifier(parent.id)
|
||||
)
|
||||
return [{ node: parent.id, path: [...projection, ...path] }];
|
||||
return [
|
||||
{
|
||||
node: parent.id,
|
||||
path: [...projection, ...path],
|
||||
ownerInitializer: current,
|
||||
},
|
||||
];
|
||||
if (
|
||||
t.isAssignmentExpression(parent, { operator: "=" }) &&
|
||||
parent.right === current &&
|
||||
t.isIdentifier(parent.left)
|
||||
)
|
||||
return [
|
||||
{
|
||||
node: parent.left,
|
||||
path: [...projection, ...path],
|
||||
ownerInitializer: current,
|
||||
},
|
||||
];
|
||||
if (t.isArrayExpression(parent)) {
|
||||
let minimumIndex = 0;
|
||||
let uncertainIndex = false;
|
||||
for (const element of parent.elements) {
|
||||
if (element === current) break;
|
||||
if (t.isSpreadElement(element)) uncertainIndex = true;
|
||||
else minimumIndex++;
|
||||
}
|
||||
// A spread contributes an unknown number of slots. Retain the known
|
||||
// prefix without treating the element's syntax position as its index.
|
||||
projection.unshift(
|
||||
uncertainIndex
|
||||
? { excludedKeys: [], startIndex: minimumIndex }
|
||||
: minimumIndex,
|
||||
);
|
||||
current = parent;
|
||||
continue;
|
||||
}
|
||||
if (t.isObjectProperty(parent) && parent.value === current) {
|
||||
projection.unshift(
|
||||
semanticStaticPropertyKey(parent.key, parent.computed),
|
||||
|
|
@ -312,7 +350,7 @@ const getterResultReferences = (
|
|||
path: PropertyPath,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
activeGetters: ReadonlyMap<t.Node, number> = new Map(),
|
||||
): readonly ReferencedValue[] => {
|
||||
): readonly JavaScriptSemanticObjectReference[] => {
|
||||
const { getter, owner, receiver = owner } = invocation;
|
||||
const previousLength = activeGetters.get(getter);
|
||||
if (previousLength !== undefined && previousLength <= path.length) return [];
|
||||
|
|
@ -323,7 +361,7 @@ const getterResultReferences = (
|
|||
const resolve = (
|
||||
node: t.Node,
|
||||
remaining: PropertyPath,
|
||||
): readonly ReferencedValue[] => {
|
||||
): readonly JavaScriptSemanticObjectReference[] => {
|
||||
const expression = unwrapJavaScriptExpression(node).node;
|
||||
if (t.isThisExpression(expression)) {
|
||||
if (t.isClass(receiver)) {
|
||||
|
|
@ -405,9 +443,9 @@ export const semanticObjectPropertyReferences = (
|
|||
node: t.ObjectExpression,
|
||||
path: PropertyPath,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): readonly ReferencedValue[] => {
|
||||
): readonly JavaScriptSemanticObjectReference[] => {
|
||||
const [key, ...remaining] = path;
|
||||
const references: ReferencedValue[] = [];
|
||||
const references: JavaScriptSemanticObjectReference[] = [];
|
||||
const overwritten = new Set<string>();
|
||||
const dataOverwritten = new Set<string>();
|
||||
const accessorNames = new Set<string>();
|
||||
|
|
|
|||
|
|
@ -181,18 +181,37 @@ export const staticPath = (node: t.Node): string | undefined => {
|
|||
if (t.isStringLiteral(node)) return node.value;
|
||||
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
|
||||
return stringValue(node);
|
||||
if (t.isBinaryExpression(node, { operator: "+" })) {
|
||||
const left = staticPath(node.left);
|
||||
const right = staticPath(node.right);
|
||||
return left === undefined || right === undefined
|
||||
? undefined
|
||||
: `${left}${right}`;
|
||||
}
|
||||
if (t.isBinaryExpression(node, { operator: "+" }))
|
||||
return staticConcatenation(node);
|
||||
if (t.isCallExpression(node) || t.isNewExpression(node))
|
||||
return staticCallPath(node);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Concatenate inert text. A leading source directory contributes no text:
|
||||
* `__dirname + "/preload.js"` names a file inside it, while text without a
|
||||
* leading separator extends the directory's own name and stays unknown.
|
||||
*/
|
||||
const staticConcatenation = (node: t.BinaryExpression): string | undefined => {
|
||||
const operands: t.Node[] = [];
|
||||
let current: t.Node = node;
|
||||
while (t.isBinaryExpression(current, { operator: "+" })) {
|
||||
operands.push(current.right);
|
||||
current = current.left;
|
||||
}
|
||||
const anchored = isDirectoryPath(current);
|
||||
if (!anchored) operands.push(current);
|
||||
let text = "";
|
||||
for (const operand of operands.reverse()) {
|
||||
const value = staticPath(operand);
|
||||
if (value === undefined) return undefined;
|
||||
text += value;
|
||||
}
|
||||
if (!anchored) return text;
|
||||
return text.startsWith("/") ? posix.join(".", text) : undefined;
|
||||
};
|
||||
|
||||
const staticCallPath = (
|
||||
node: t.CallExpression | t.NewExpression,
|
||||
): string | undefined => {
|
||||
|
|
@ -204,11 +223,6 @@ const staticCallPath = (
|
|||
if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) {
|
||||
return staticFileUrlPath(argumentNode(node.arguments[0]));
|
||||
}
|
||||
if (
|
||||
(name === "dirname" || name.endsWith(".dirname")) &&
|
||||
isFileIdentity(argumentNode(node.arguments[0]))
|
||||
)
|
||||
return "";
|
||||
if (name.endsWith(".resolve"))
|
||||
return name === "win32.resolve" || name.endsWith(".win32.resolve")
|
||||
? undefined
|
||||
|
|
@ -216,7 +230,12 @@ const staticCallPath = (
|
|||
if (!name.endsWith(".join") && name !== "join") return undefined;
|
||||
const parts: string[] = [];
|
||||
for (const argument of node.arguments) {
|
||||
if (isDirectoryIdentity(argumentNode(argument))) continue;
|
||||
if (isDirectoryIdentity(argumentNode(argument))) {
|
||||
// join never resets at an absolute segment, so `/preload.js` after the
|
||||
// source directory stays inside it.
|
||||
parts.push(".");
|
||||
continue;
|
||||
}
|
||||
const value = t.isNode(argument) ? staticPath(argument) : undefined;
|
||||
if (value === undefined) return undefined;
|
||||
parts.push(value);
|
||||
|
|
@ -331,11 +350,20 @@ const isFilesystemPathExpression = (node: t.Node): boolean => {
|
|||
};
|
||||
|
||||
const isDirectoryIdentity = (node: t.Node | undefined): boolean => {
|
||||
if (t.isIdentifier(node, { name: "__dirname" })) return true;
|
||||
if (isDirectoryPath(node)) return true;
|
||||
if (!t.isCallExpression(node) && !t.isNewExpression(node)) return false;
|
||||
const name = calleeName(node.callee);
|
||||
if (name === "URL" || name.endsWith(".URL"))
|
||||
return isFileIdentity(argumentNode(node.arguments[1]));
|
||||
return (
|
||||
(name === "URL" || name.endsWith(".URL")) &&
|
||||
isFileIdentity(argumentNode(node.arguments[1]))
|
||||
);
|
||||
};
|
||||
|
||||
/** `__dirname` or the dirname of the source file, as a string. */
|
||||
const isDirectoryPath = (node: t.Node | undefined): boolean => {
|
||||
if (t.isIdentifier(node, { name: "__dirname" })) return true;
|
||||
if (!t.isCallExpression(node)) return false;
|
||||
const name = calleeName(node.callee);
|
||||
return (
|
||||
(name === "dirname" || name.endsWith(".dirname")) &&
|
||||
isFileIdentity(argumentNode(node.arguments[0]))
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ const anchoredExpressions = [
|
|||
["path.resolve(__dirname)", "."],
|
||||
['path.posix.resolve(`/app`, `/shared`, "preload.js")', "/shared/preload.js"],
|
||||
['path.posix.resolve("" + "/app", "preload.js")', "/app/preload.js"],
|
||||
['__dirname + "/preload.js"', "preload.js"],
|
||||
['__dirname + "/" + "preload.js"', "preload.js"],
|
||||
['path.dirname(__filename) + "/lib" + "/preload.js"', "lib/preload.js"],
|
||||
] as const;
|
||||
|
||||
const unknownExpressions = [
|
||||
|
|
@ -58,6 +61,8 @@ const unknownExpressions = [
|
|||
'path.win32.resolve(__dirname, "preload.js")',
|
||||
'win32.resolve("/app", "preload.js")',
|
||||
'resolve("/app", "preload.js")',
|
||||
'__dirname + "preload.js"',
|
||||
'path.dirname(__filename) + "preload.js"',
|
||||
];
|
||||
|
||||
describe("inert JavaScript path resolution", () => {
|
||||
|
|
@ -100,6 +105,16 @@ describe("inert JavaScript path resolution", () => {
|
|||
"preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'path.join(__dirname, "/preload.js")',
|
||||
"preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'path.join(dirname(fileURLToPath(import.meta.url)), "/lib", "/preload.js")',
|
||||
"lib/preload.js",
|
||||
"filesystem-expression",
|
||||
],
|
||||
[
|
||||
'path.join("ignored", __dirname, "preload.js")',
|
||||
"ignored/preload.js",
|
||||
|
|
@ -111,6 +126,7 @@ describe("inert JavaScript path resolution", () => {
|
|||
"filesystem-expression",
|
||||
],
|
||||
['join("sub", "preload.js")', "sub/preload.js", "module-specifier"],
|
||||
["path.join(__dirname)", ".", "filesystem-expression"],
|
||||
] as const)("preserves join behavior in %s", (expression, path, context) => {
|
||||
assertPathProjection(expression, path, context);
|
||||
});
|
||||
|
|
@ -182,7 +198,6 @@ describe("inert JavaScript path resolution", () => {
|
|||
|
||||
it.each([
|
||||
"path.join()",
|
||||
"path.join(__dirname)",
|
||||
'path.join("/app", runtimeFile)',
|
||||
"path.join(...parts)",
|
||||
])("preserves unresolved join syntax in %s", (expression) =>
|
||||
|
|
|
|||
|
|
@ -83,6 +83,61 @@ const graph = nativeInvestigationGraphSchema.parse({
|
|||
});
|
||||
|
||||
describe("native investigation graph traces", () => {
|
||||
it.each(["forward", "backward"] as const)(
|
||||
"keeps both endpoints of retained edges within the %s node budget",
|
||||
(direction) => {
|
||||
const trace = traceNativeInvestigationGraph(graph, {
|
||||
start: direction === "forward" ? "a" : "c",
|
||||
direction,
|
||||
limits: { max_nodes: 1 },
|
||||
});
|
||||
expect(trace.nodes).toHaveLength(1);
|
||||
expect(trace.edges).toEqual([]);
|
||||
expect(trace).toMatchObject({
|
||||
truncated: true,
|
||||
reason: "max_nodes_reached",
|
||||
reached_depth: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([{ max_nodes: 2 }, { max_edges: 1 }])(
|
||||
"reports retained node depth when a sibling hits the budget %j",
|
||||
(limits) => {
|
||||
const branching = {
|
||||
...graph,
|
||||
edges: graph.edges
|
||||
.filter((edge) => edge.resolution !== "unresolved")
|
||||
.map((edge) => ({ ...edge, from: "a" })),
|
||||
};
|
||||
const trace = traceNativeInvestigationGraph(branching, {
|
||||
start: "a",
|
||||
limits,
|
||||
});
|
||||
expect(trace.nodes.map(({ id }) => id)).toEqual(["a", "b"]);
|
||||
expect(trace.edges.map(({ id }) => id)).toEqual(["1"]);
|
||||
expect(trace.reached_depth).toBe(1);
|
||||
expect(trace.truncated).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("retains cycle edges to admitted nodes when the node budget is full", () => {
|
||||
const cycle = {
|
||||
...graph,
|
||||
nodes: graph.nodes.slice(0, 2),
|
||||
edges: [
|
||||
graph.edges[0],
|
||||
{ ...graph.edges[0], id: "back", from: "b", to: "a" },
|
||||
],
|
||||
};
|
||||
const trace = traceNativeInvestigationGraph(
|
||||
nativeInvestigationGraphSchema.parse(cycle),
|
||||
{ start: "a", limits: { max_nodes: 2 } },
|
||||
);
|
||||
expect(trace.edges.map(({ id }) => id)).toEqual(["1", "back"]);
|
||||
expect(trace).toMatchObject({ reached_depth: 1, truncated: false });
|
||||
});
|
||||
|
||||
it("retains observed and inferred route edges plus unresolved dispatch", () => {
|
||||
const trace = traceNativeInvestigationGraph(graph, { start: "a" });
|
||||
expect(trace.nodes.map(({ id }) => id)).toEqual(["a", "b", "c"]);
|
||||
|
|
|
|||
|
|
@ -207,7 +207,6 @@ export const traceNativeInvestigationGraph = (
|
|||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (current === undefined) break;
|
||||
reachedDepth = Math.max(reachedDepth, current.depth);
|
||||
const adjacent = adjacency.get(current.id) ?? [];
|
||||
|
||||
for (const edge of adjacent) {
|
||||
|
|
@ -227,19 +226,24 @@ export const traceNativeInvestigationGraph = (
|
|||
reason ??= "max_depth_reached";
|
||||
continue;
|
||||
}
|
||||
selectedEdges.set(edge.id, edge);
|
||||
if (alreadyVisited) continue;
|
||||
if (visited.size >= limits.max_nodes) {
|
||||
// Check destination capacity before retaining its edge. Otherwise the node
|
||||
// bound leaves a resolved edge pointing outside the returned trace.
|
||||
if (!alreadyVisited && visited.size >= limits.max_nodes) {
|
||||
truncated = true;
|
||||
reason = "max_nodes_reached";
|
||||
break;
|
||||
}
|
||||
selectedEdges.set(edge.id, edge);
|
||||
if (alreadyVisited) continue;
|
||||
if (!nodeById.has(nextId)) {
|
||||
truncated = true;
|
||||
reason ??= "edge_target_missing_from_graph";
|
||||
continue;
|
||||
}
|
||||
visited.add(nextId);
|
||||
// A retained node has already been reached even if a later sibling
|
||||
// exhausts a budget before this node is dequeued for expansion.
|
||||
reachedDepth = Math.max(reachedDepth, current.depth + 1);
|
||||
queue.push({ id: nextId, depth: current.depth + 1 });
|
||||
}
|
||||
if (reason === "max_edges_reached" || reason === "max_nodes_reached") break;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,47 @@ export function superclass() {
|
|||
new B().v.x = 2;
|
||||
return shared.x;
|
||||
}
|
||||
export function arrayReceiver() {
|
||||
const arr = [{ x: 1, get self() { return this; } }];
|
||||
arr[0].self.x = 2;
|
||||
return arr[0].x;
|
||||
}
|
||||
export function nestedArrayReceiver() {
|
||||
const outer = { arr: [{ x: 1, get self() { return this; } }] };
|
||||
outer.arr[0].self.x = 2;
|
||||
return outer.arr[0].x;
|
||||
}
|
||||
export function assignedReceiver() {
|
||||
let box;
|
||||
box = { x: 1, get self() { return this; } };
|
||||
box.self.x = 2;
|
||||
return box.x;
|
||||
}
|
||||
export function savedArrayReceiver() {
|
||||
const arr = [{ x: 1, get self() { return this; } }];
|
||||
const saved = arr[0].self;
|
||||
saved.x = 2;
|
||||
return arr[0].x;
|
||||
}
|
||||
export function arrayReadOnly() {
|
||||
const arr = [{ x: 1, get self() { return this; } }];
|
||||
const saved = arr[0].self;
|
||||
return arr[0].x;
|
||||
}
|
||||
export function replacementKeepsOldObject() {
|
||||
const old = { x: 1 };
|
||||
let box = old;
|
||||
box = { x: 1, old: box, get self() { return this; } };
|
||||
box.self.x = 2;
|
||||
return old.x;
|
||||
}
|
||||
export function replacementMutatesCapturedObject() {
|
||||
const old = { x: 1 };
|
||||
let box = old;
|
||||
box = { old: box, get self() { return this; } };
|
||||
box.self.old.x = 2;
|
||||
return old.x;
|
||||
}
|
||||
export function untouched() {
|
||||
const box = { x: 1, y: 1, get self() { return this; } };
|
||||
box.self.y = 2;
|
||||
|
|
@ -66,13 +107,20 @@ export function untouched() {
|
|||
const { graph } = z
|
||||
.object({ normalized_result: javascriptApplicationAnalysisResultSchema })
|
||||
.parse(document).normalized_result;
|
||||
for (const name of [
|
||||
"self",
|
||||
"property",
|
||||
"privateField",
|
||||
"superclass",
|
||||
"untouched",
|
||||
]) {
|
||||
for (const [name, status] of [
|
||||
["self", "unknown"],
|
||||
["property", "unknown"],
|
||||
["privateField", "unknown"],
|
||||
["superclass", "unknown"],
|
||||
["arrayReceiver", "unknown"],
|
||||
["nestedArrayReceiver", "unknown"],
|
||||
["assignedReceiver", "unknown"],
|
||||
["savedArrayReceiver", "unknown"],
|
||||
["arrayReadOnly", "literal"],
|
||||
["replacementKeepsOldObject", "literal"],
|
||||
["replacementMutatesCapturedObject", "unknown"],
|
||||
["untouched", "literal"],
|
||||
] as const) {
|
||||
const projection = graph.nodes
|
||||
.flatMap(({ observations }) => observations)
|
||||
.find(
|
||||
|
|
@ -82,7 +130,16 @@ export function untouched() {
|
|||
);
|
||||
expect(projection, name).toBeDefined();
|
||||
expect(projection?.properties.static_return_shapes).toMatchObject([
|
||||
{ value_status: name === "untouched" ? "literal" : "unknown" },
|
||||
{
|
||||
value_status: status,
|
||||
fields: [
|
||||
{
|
||||
path: "",
|
||||
state: status,
|
||||
...(status === "literal" ? { value: 1 } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { dirname, join } from "node:path";
|
||||
import { Client } from "@modelcontextprotocol/client";
|
||||
import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
|
||||
import { expect } from "vitest";
|
||||
|
|
@ -8,6 +8,111 @@ import { z } from "zod";
|
|||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for([
|
||||
{
|
||||
label: "blank defaults",
|
||||
harness: "",
|
||||
agents: " \t ",
|
||||
dshRoot: "home/.dsh",
|
||||
agentsRoot: "home/.agents",
|
||||
},
|
||||
{
|
||||
label: "tilde roots",
|
||||
harness: "~/selected harness",
|
||||
agents: "~\\shared agents",
|
||||
dshRoot: "home/selected harness",
|
||||
agentsRoot: "home/shared agents",
|
||||
},
|
||||
{
|
||||
label: "relative roots",
|
||||
harness: "./selected harness",
|
||||
agents: "../shared agents",
|
||||
dshRoot: "workspace/selected harness",
|
||||
agentsRoot: "shared agents",
|
||||
},
|
||||
])(
|
||||
"DSH CLI uses Harness path semantics through setup, doctor and uninstall for $label",
|
||||
async ({ harness, agents, dshRoot, agentsRoot }, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-dsh-paths-");
|
||||
const home = join(root, "home");
|
||||
const cwd = join(root, "workspace");
|
||||
const config = join(root, dshRoot, "cordis.patch.yml");
|
||||
const skillRoot = join(
|
||||
root,
|
||||
agentsRoot,
|
||||
"skills",
|
||||
"reverse-engineer-anything",
|
||||
);
|
||||
const skill = join(skillRoot, "SKILL.md");
|
||||
await mkdir(home);
|
||||
await mkdir(cwd);
|
||||
await mkdir(dirname(config), { recursive: true });
|
||||
const original =
|
||||
"# caller patch\n- insert: [{id: caller, name: caller-plugin}]\n";
|
||||
await writeFile(config, original);
|
||||
const run = (args: readonly string[]) =>
|
||||
cli.run({
|
||||
arguments: [...args, "--json"],
|
||||
cwd,
|
||||
environment: {
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
DSH_HOME: harness,
|
||||
DSH_AGENTS_HOME: agents,
|
||||
},
|
||||
});
|
||||
const planned = await run(["setup", "--client", "dsh", "--dry-run"]);
|
||||
expect(planned.exitCode).toBe(0);
|
||||
expect(planned.json).toMatchObject({
|
||||
status: "planned",
|
||||
appliedActions: [],
|
||||
plannedActions: expect.arrayContaining([
|
||||
expect.objectContaining({ id: "configure_client:dsh", target: config }),
|
||||
expect.objectContaining({ kind: "install_skill", target: skillRoot }),
|
||||
]),
|
||||
});
|
||||
expect(await readFile(config, "utf8")).toBe(original);
|
||||
await expect(readFile(skill)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
const configured = await run(["setup", "--client", "dsh", "--yes"]);
|
||||
expect(configured.exitCode).toBe(0);
|
||||
expect(configured.json).toMatchObject({
|
||||
status: "ready",
|
||||
clients: { dsh: { status: "configured" } },
|
||||
});
|
||||
const text = await readFile(config, "utf8");
|
||||
expect(text).toContain("# caller patch");
|
||||
expect(text).toContain("rea-mcp");
|
||||
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
|
||||
expect(await readFile(skill, "utf8")).toContain(
|
||||
"name: reverse-engineer-anything",
|
||||
);
|
||||
const doctor = await run(["doctor", "--client", "dsh", "--skill"]);
|
||||
expect(doctor.exitCode).toBe(0);
|
||||
expect(doctor.json).toMatchObject({ healthy: true });
|
||||
const repeated = await run(["setup", "--client", "dsh", "--yes"]);
|
||||
expect(repeated.exitCode).toBe(0);
|
||||
expect(repeated.json).toMatchObject({
|
||||
status: "ready",
|
||||
plannedActions: [],
|
||||
appliedActions: [],
|
||||
});
|
||||
expect(await readFile(config, "utf8")).toBe(text);
|
||||
const removed = await run(["uninstall"]);
|
||||
expect(removed.exitCode).toBe(0);
|
||||
expect(removed.json).toMatchObject({
|
||||
status: "complete",
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({ name: "dsh", status: "removed" }),
|
||||
]),
|
||||
});
|
||||
expect(parse(await readFile(config, "utf8"))).toEqual([
|
||||
{ insert: [{ id: "caller", name: "caller-plugin" }] },
|
||||
]);
|
||||
expect(await readFile(`${config}.rea.backup`, "utf8")).toBe(original);
|
||||
await expect(readFile(skill)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"DSH CLI previews, approves, repairs, launches MCP, repeats and uninstalls while preserving caller YAML",
|
||||
async ({ cli }) => {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,95 @@ describe("CLI output argument boundary", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("unclosed CLI output filter slices", () => {
|
||||
it.each([
|
||||
"providers[",
|
||||
"providers[0,1].name[",
|
||||
"name,providers[",
|
||||
"][,]",
|
||||
"providers],name[0,1]",
|
||||
])("rejects %s before the filter parser runs", (filter) => {
|
||||
expect(
|
||||
validateCliOutputArguments([
|
||||
"providers",
|
||||
"--json",
|
||||
"--filter-output",
|
||||
filter,
|
||||
]),
|
||||
).toMatchObject({ ok: false, code: "INVALID_OUTPUT_FILTER" });
|
||||
});
|
||||
|
||||
it("preserves effective-filter and earlier flag-error precedence", () => {
|
||||
for (const arguments_ of [
|
||||
[
|
||||
"--filter-output",
|
||||
"providers[",
|
||||
"--filter-output",
|
||||
"capabilities[0,1].provider.id,open",
|
||||
],
|
||||
["--filter-output", "capabilities[-1,].provider.id"],
|
||||
["--filter-output", "capabilities[[0,1]"],
|
||||
["--format", "invalid", "--filter-output", "providers["],
|
||||
["--token-limit", "invalid", "--filter-output", "providers["],
|
||||
])
|
||||
expect(validateCliOutputArguments(arguments_)).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
workspaceCliTest(
|
||||
"fails promptly across output formats without invoking the command",
|
||||
async ({ cli }) => {
|
||||
for (const format of ["json", "jsonl", "yaml", "toon", "md"]) {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"analyze",
|
||||
"/absent/target",
|
||||
"--format",
|
||||
format,
|
||||
"--filter-output",
|
||||
"result[0,1].name[",
|
||||
],
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stdout).toContain("INVALID_OUTPUT_FILTER");
|
||||
expect(result.stdout).toContain("closing ]");
|
||||
expect(result.stderr).toBe("");
|
||||
}
|
||||
const splitPath = await cli.run({
|
||||
arguments: ["providers", "--json", "--filter-output", "][,]"],
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(splitPath.exitCode).toBe(1);
|
||||
expect(splitPath.json).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: "INVALID_OUTPUT_FILTER" },
|
||||
});
|
||||
},
|
||||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
workspaceCliTest(
|
||||
"retains closed slices and multiple filter paths",
|
||||
async ({ cli }) => {
|
||||
const result = await cli.run({
|
||||
arguments: [
|
||||
"providers",
|
||||
"--json",
|
||||
"--filter-output",
|
||||
"capabilities[0,1].provider.id,open",
|
||||
],
|
||||
timeoutMs: 3000,
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.json).toEqual({
|
||||
capabilities: [{ provider: { id: expect.any(String) } }],
|
||||
open: false,
|
||||
});
|
||||
},
|
||||
CLI_INTEGRATION_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
|
||||
describe("CLI output valued-flag parsing", () => {
|
||||
workspaceCliTest(
|
||||
"preserves complete JSON errors when native builtin flags follow unusual arguments",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { historicalSourceGraphSchema } from "../../../src/domain/referenceSourceGraph.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
// Reference-source reads require no-follow opens; Windows reports unsupported.
|
||||
describe.skipIf(process.platform === "win32")(
|
||||
"compiled reference-source directory imports",
|
||||
() => {
|
||||
cliTest(
|
||||
"resolves directory spellings without selecting similarly named files",
|
||||
async ({ cli }) => {
|
||||
const workspace = await createTestTempDirectory(
|
||||
"rea-reference-directory-imports-",
|
||||
);
|
||||
const root = join(workspace, "source");
|
||||
const imports = [
|
||||
["slash.cjs", "./lib/", "lib/index.js"],
|
||||
["dot.cjs", "./lib/.", "lib/index.js"],
|
||||
["self.cjs", ".", "index.js"],
|
||||
["self-slash.cjs", "./", "index.js"],
|
||||
["nested/parent.cjs", "..", "index.js"],
|
||||
["nested/parent-slash.cjs", "../", "index.js"],
|
||||
["direct.cjs", "./lib.js", "lib.js"],
|
||||
["fallback.cjs", "./fallback", "fallback/index.js"],
|
||||
["legacy-order.cjs", "./dep", "dep/index.js"],
|
||||
] as const;
|
||||
const unresolved = [
|
||||
["missing.cjs", "./missing/", "missing/"],
|
||||
["escape.cjs", "../outside/", "../outside/"],
|
||||
["main.ts", "./folder.js/.", "folder.js"],
|
||||
] as const;
|
||||
const files = new Map<string, string>([
|
||||
["index.js", 'module.exports = "root index";\n'],
|
||||
["lib/index.js", 'module.exports = "library index";\n'],
|
||||
["lib.js", 'module.exports = "sibling JavaScript file";\n'],
|
||||
["lib.ts", 'export const value = "sibling TypeScript file";\n'],
|
||||
["fallback/index.js", 'module.exports = "fallback index";\n'],
|
||||
["dep/index.js", 'module.exports = "existing index priority";\n'],
|
||||
["dep.cjs", 'module.exports = "later CommonJS candidate";\n'],
|
||||
[
|
||||
"folder.ts",
|
||||
'export const value = "emitted filename counterpart";\n',
|
||||
],
|
||||
["missing.js", 'module.exports = "not a directory";\n'],
|
||||
...[...imports, ...unresolved].map(
|
||||
([path, specifier]) =>
|
||||
[path, `require(${JSON.stringify(specifier)});\n`] as const,
|
||||
),
|
||||
]);
|
||||
await Promise.all(
|
||||
[...files].map(async ([path, contents]) => {
|
||||
const target = join(root, path);
|
||||
await mkdir(dirname(target), { recursive: true });
|
||||
await writeFile(target, contents);
|
||||
}),
|
||||
);
|
||||
const outside = join(workspace, "outside", "index.js");
|
||||
await mkdir(dirname(outside), { recursive: true });
|
||||
await writeFile(outside, 'module.exports = "outside import root";\n');
|
||||
|
||||
// Node's loader establishes each fixture's meaning independently.
|
||||
for (const [path, specifier, target] of imports)
|
||||
expect(createRequire(join(root, path)).resolve(specifier)).toBe(
|
||||
join(root, target),
|
||||
);
|
||||
expect(() =>
|
||||
createRequire(join(root, "missing.cjs")).resolve("./missing/"),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
createRequire(join(root, "main.ts")).resolve("./folder.js/."),
|
||||
).toThrow();
|
||||
expect(
|
||||
createRequire(join(root, "escape.cjs")).resolve("../outside/"),
|
||||
).toBe(outside);
|
||||
|
||||
const result = await cli.run({
|
||||
arguments: ["import-reference-source", root, "--json"],
|
||||
environment: {
|
||||
HOME: workspace,
|
||||
XDG_CONFIG_HOME: workspace,
|
||||
XDG_CACHE_HOME: workspace,
|
||||
},
|
||||
});
|
||||
expect(result.exitCode).toBe(0);
|
||||
const graph = historicalSourceGraphSchema.parse(result.json);
|
||||
expect(graph.parse_failures).toEqual([]);
|
||||
expect(graph.relationships).toHaveLength(
|
||||
imports.length + unresolved.length,
|
||||
);
|
||||
for (const [from, , to] of imports)
|
||||
expect.soft(graph.relationships, from).toContainEqual({
|
||||
from_path: from,
|
||||
to,
|
||||
kind: "requires",
|
||||
resolution: "internal",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
for (const [from, , to] of unresolved)
|
||||
expect.soft(graph.relationships, from).toContainEqual({
|
||||
from_path: from,
|
||||
to,
|
||||
kind: "requires",
|
||||
resolution: "unresolved",
|
||||
parse_state: "parsed",
|
||||
});
|
||||
expect(
|
||||
graph.entries.some((entry) => entry.path.includes("outside")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -366,16 +366,11 @@ it("rejects overlapping ZIP member data ranges through artifact inventory", asyn
|
|||
bytes.writeUInt32LE(firstLocalOffset, second + 42);
|
||||
await writeFile(path, bytes);
|
||||
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const result = await withArtifactClient(target.value, (client) =>
|
||||
client.execute("inventory_artifact", {}),
|
||||
await expectInventoryFailure(
|
||||
path,
|
||||
"format",
|
||||
"ZIP archive is malformed: Overlapping entry found",
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "io" },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves encrypted-member and corrupt-CRC observations", async () => {
|
||||
|
|
@ -438,16 +433,11 @@ it("preserves encrypted-member and corrupt-CRC observations", async () => {
|
|||
bytes[dataOffset] = firstByte ^ 1;
|
||||
await writeFile(corruptPath, bytes);
|
||||
|
||||
const corruptTarget = await parseBinaryTarget(corruptPath);
|
||||
if (!corruptTarget.ok) throw corruptTarget.error;
|
||||
const corruptResult = await withArtifactClient(
|
||||
corruptTarget.value,
|
||||
(client) => client.execute("inventory_artifact", {}),
|
||||
await expectInventoryFailure(
|
||||
corruptPath,
|
||||
"format",
|
||||
"ZIP archive is malformed: Invalid compressed data",
|
||||
);
|
||||
expect(corruptResult).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "io" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a truncated ZIP through artifact inventory", async () => {
|
||||
|
|
@ -455,17 +445,51 @@ it("rejects a truncated ZIP through artifact inventory", async () => {
|
|||
const path = join(root, "truncated.zip");
|
||||
await writeFile(path, Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
||||
|
||||
await expectInventoryFailure(
|
||||
path,
|
||||
"format",
|
||||
"ZIP archive is malformed: File format is not recognized",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports an unsupported ZIP compression method as unavailable", async () => {
|
||||
const root = await createTestTempDirectory("rea-zip-method-");
|
||||
const path = join(root, "lzma.zip");
|
||||
const writer = new ZipWriter(new Uint8ArrayWriter());
|
||||
await writer.add("data.txt", new TextReader("member bytes"), { level: 0 });
|
||||
const bytes = Buffer.from(await writer.close());
|
||||
// Declare LZMA (14) in both headers; REA reads only stored and Deflate data.
|
||||
const localHeader = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x03, 0x04]));
|
||||
const centralHeader = bytes.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]));
|
||||
if (localHeader < 0 || centralHeader < 0)
|
||||
throw new Error("Expected ZIP local and central headers");
|
||||
bytes.writeUInt16LE(14, localHeader + 8);
|
||||
bytes.writeUInt16LE(14, centralHeader + 10);
|
||||
await writeFile(path, bytes);
|
||||
|
||||
await expectInventoryFailure(
|
||||
path,
|
||||
"unavailable",
|
||||
"ZIP archive uses an unsupported feature: Compression method not supported",
|
||||
);
|
||||
});
|
||||
|
||||
/** Inventory one archive through the provider and expect a typed failure. */
|
||||
const expectInventoryFailure = async (
|
||||
path: string,
|
||||
reason: string,
|
||||
detail: string,
|
||||
): Promise<void> => {
|
||||
const target = await parseBinaryTarget(path);
|
||||
if (!target.ok) throw target.error;
|
||||
const result = await withArtifactClient(target.value, (client) =>
|
||||
client.execute("inventory_artifact", {}),
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ArtifactOperationError", reason: "io" },
|
||||
error: { _tag: "ArtifactOperationError", reason, detail },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const writeSingleEntryZip = async (
|
||||
path: string,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
|
|
@ -37,28 +37,76 @@ const dshClient = (
|
|||
env: Parameters<typeof supportedClients>[2],
|
||||
platform: NodeJS.Platform = "linux",
|
||||
) =>
|
||||
supportedClients(join("/home/a"), platform, env).find(
|
||||
supportedClients(resolve("/home/a"), platform, env).find(
|
||||
({ name }) => name === "dsh",
|
||||
);
|
||||
|
||||
describe("DeepSeek Harness configuration paths", () => {
|
||||
it.each([
|
||||
[undefined, ".dsh", ".agents", "shared"],
|
||||
["", ".dsh", ".agents", "shared"],
|
||||
[" \t ", ".dsh", ".agents", "shared"],
|
||||
["~", "", "", "dsh"],
|
||||
["~/selected home", "selected home", "selected home", "dsh"],
|
||||
["~\\selected home", "selected home", "selected home", "dsh"],
|
||||
] as const)(
|
||||
"resolves Harness and agents roots for %j",
|
||||
(override, dshRoot, agentsRoot, client) => {
|
||||
const home = resolve("/home/a");
|
||||
const environment = { DSH_HOME: override, DSH_AGENTS_HOME: override };
|
||||
expect(
|
||||
supportedClients(home, process.platform, environment).find(
|
||||
({ name }) => name === "dsh",
|
||||
),
|
||||
).toMatchObject({
|
||||
configPath: join(home, dshRoot, "cordis.patch.yml"),
|
||||
markerPath: join(home, dshRoot),
|
||||
});
|
||||
expect(clientSkillDirectories(home, ["dsh"], environment)).toEqual([
|
||||
{ client, directory: join(home, agentsRoot, "skills") },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["relative home", " literal home ", "~someone"])(
|
||||
"resolves a literal root %j relative to cwd without trimming it",
|
||||
(override) => {
|
||||
const home = resolve("/home/a");
|
||||
const root = resolve(override);
|
||||
const environment = { DSH_HOME: override, DSH_AGENTS_HOME: override };
|
||||
expect(
|
||||
supportedClients(home, process.platform, environment).find(
|
||||
({ name }) => name === "dsh",
|
||||
),
|
||||
).toMatchObject({
|
||||
configPath: join(root, "cordis.patch.yml"),
|
||||
markerPath: root,
|
||||
});
|
||||
expect(clientSkillDirectories(home, ["dsh"], environment)).toEqual([
|
||||
{ client: "dsh", directory: join(root, "skills") },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("resolves the home Cordis patch under the Harness home", () => {
|
||||
expect(dshClient({})).toEqual({
|
||||
name: "dsh",
|
||||
displayName: "DeepSeek Harness",
|
||||
configPath: join("/home/a", ".dsh", "cordis.patch.yml"),
|
||||
markerPath: join("/home/a", ".dsh"),
|
||||
configPath: resolve("/home/a", ".dsh", "cordis.patch.yml"),
|
||||
markerPath: resolve("/home/a", ".dsh"),
|
||||
format: "dsh",
|
||||
});
|
||||
// The home patch applies to every profile; DSH_HOME relocates it.
|
||||
expect(dshClient({ DSH_HOME: "/custom/harness" })?.configPath).toBe(
|
||||
join("/custom/harness", "cordis.patch.yml"),
|
||||
resolve("/custom/harness", "cordis.patch.yml"),
|
||||
);
|
||||
expect(
|
||||
clientSkillDirectories(join("/home/a"), ["dsh"], {
|
||||
DSH_AGENTS_HOME: "/custom/agents",
|
||||
}),
|
||||
).toEqual([{ client: "dsh", directory: join("/custom/agents", "skills") }]);
|
||||
).toEqual([
|
||||
{ client: "dsh", directory: resolve("/custom/agents", "skills") },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue