diff --git a/docs/client-configuration.md b/docs/client-configuration.md index 00e83e179..e72ca191f 100644 --- a/docs/client-configuration.md +++ b/docs/client-configuration.md @@ -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: diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 1e9e7fe19..041015b9c 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -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. diff --git a/src/application/ReferenceSourceImportEntries.ts b/src/application/ReferenceSourceImportEntries.ts index 36dae9949..49c6f82f5 100644 --- a/src/application/ReferenceSourceImportEntries.ts +++ b/src/application/ReferenceSourceImportEntries.ts @@ -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" } diff --git a/src/application/SupportedClients.ts b/src/application/SupportedClients.ts index 488dda49d..5839409d4 100644 --- a/src/application/SupportedClients.ts +++ b/src/application/SupportedClients.ts @@ -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", diff --git a/src/artifacts/ZipArtifactReader.ts b/src/artifacts/ZipArtifactReader.ts index dfbee8b27..066b005b8 100644 --- a/src/artifacts/ZipArtifactReader.ts +++ b/src/artifacts/ZipArtifactReader.ts @@ -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 = + 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( + source: AsyncIterable, +): AsyncIterable { + try { + yield* source; + } catch (cause: unknown) { + throw typedZipFailure(cause); + } +} + const isSymlink = (entry: Entry): boolean => entry.unixMode !== undefined && (entry.unixMode & 0o170000) === 0o120000; diff --git a/src/cliOutput.ts b/src/cliOutput.ts index 5119eebc3..ccfaf6b62 100644 --- a/src/cliOutput.ts +++ b/src/cliOutput.ts @@ -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") diff --git a/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts index 3b83bf482..c3b38842a 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.getter_receivers.test.ts @@ -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;", diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index c2f7efba3..35b63c815 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -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; + 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; } diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 387ba09d2..4d1bf3e76 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -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; } => { - const references: ReferencedValue[] = []; + const references: JavaScriptSemanticObjectReference[] = []; const pending = [{ klass: root, excluded: new Set() }]; const selections = new Map>(); let definiteNames: ReadonlySet | undefined; @@ -208,7 +210,7 @@ export const semanticClassPropertyReferences = ( selection: PropertyPath, state: JavaScriptSemanticAnalysisState, activeGetters: ReadonlyMap = 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(); - 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 = 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 = 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(); const dataOverwritten = new Set(); const accessorNames = new Set(); diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 71562e6ca..f2045a1eb 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -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])) diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index e54122440..30bbc317f 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -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) => diff --git a/src/domain/native/nativeInvestigationGraph.test.ts b/src/domain/native/nativeInvestigationGraph.test.ts index 015458319..684b2a02e 100644 --- a/src/domain/native/nativeInvestigationGraph.test.ts +++ b/src/domain/native/nativeInvestigationGraph.test.ts @@ -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"]); diff --git a/src/domain/native/nativeInvestigationGraph.ts b/src/domain/native/nativeInvestigationGraph.ts index 0dd0dbe8b..9b9efc412 100644 --- a/src/domain/native/nativeInvestigationGraph.ts +++ b/src/domain/native/nativeInvestigationGraph.ts @@ -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; diff --git a/tests/acceptance/applications/getterReceiverMutationsCli.test.ts b/tests/acceptance/applications/getterReceiverMutationsCli.test.ts index a5ba0bfeb..95b6e4862 100644 --- a/tests/acceptance/applications/getterReceiverMutationsCli.test.ts +++ b/tests/acceptance/applications/getterReceiverMutationsCli.test.ts @@ -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 } : {}), + }, + ], + }, ]); } }, diff --git a/tests/acceptance/setup/dshClientSetup.test.ts b/tests/acceptance/setup/dshClientSetup.test.ts index 4010369cd..b544c5816 100644 --- a/tests/acceptance/setup/dshClientSetup.test.ts +++ b/tests/acceptance/setup/dshClientSetup.test.ts @@ -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 }) => { diff --git a/tests/boundary/cli/cliOutput.test.ts b/tests/boundary/cli/cliOutput.test.ts index 43d517e16..44b3ac234 100644 --- a/tests/boundary/cli/cliOutput.test.ts +++ b/tests/boundary/cli/cliOutput.test.ts @@ -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", diff --git a/tests/boundary/cli/referenceSourceDirectoryImports.test.ts b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts new file mode 100644 index 000000000..6270855cb --- /dev/null +++ b/tests/boundary/cli/referenceSourceDirectoryImports.test.ts @@ -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([ + ["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); + }, + ); + }, +); diff --git a/tests/boundary/filesystem/artifactZipIntegrity.test.ts b/tests/boundary/filesystem/artifactZipIntegrity.test.ts index 41397c520..cf7dcd595 100644 --- a/tests/boundary/filesystem/artifactZipIntegrity.test.ts +++ b/tests/boundary/filesystem/artifactZipIntegrity.test.ts @@ -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 => { 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, diff --git a/tests/boundary/filesystem/dshClientConfiguration.test.ts b/tests/boundary/filesystem/dshClientConfiguration.test.ts index 72dbded9e..c0315f67b 100644 --- a/tests/boundary/filesystem/dshClientConfiguration.test.ts +++ b/tests/boundary/filesystem/dshClientConfiguration.test.ts @@ -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[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") }, + ]); }); });