chore: merge latest main after source review

This commit is contained in:
morluto 2026-10-13 20:29:41 +08:00
commit 70435de2c6
19 changed files with 917 additions and 97 deletions

View File

@ -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:

View File

@ -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.

View File

@ -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" }

View File

@ -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",

View File

@ -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;

View File

@ -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")

View File

@ -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;",

View File

@ -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;
}

View File

@ -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>();

View File

@ -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]))

View File

@ -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) =>

View File

@ -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"]);

View File

@ -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;

View File

@ -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 } : {}),
},
],
},
]);
}
},

View File

@ -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 }) => {

View File

@ -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",

View File

@ -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);
},
);
},
);

View File

@ -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,

View File

@ -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") },
]);
});
});