Merge remote-tracking branch 'origin/next' into fix/2056-plan-phase-foreign-prefix
# Conflicts: # src/init.cts
This commit is contained in:
19
tests/_ff_lint_clean.cjs
Normal file
19
tests/_ff_lint_clean.cjs
Normal file
@@ -0,0 +1,19 @@
|
||||
// PERMANENT LOAD-BEARING FIXTURE for #1259 / #2126 — DO NOT delete or rename to `*.test.cjs`.
|
||||
//
|
||||
// A KNOWN-CLEAN, lint-scoped `.cjs` companion to `_ff_lint_violation.cjs`. It has NO
|
||||
// `local/no-source-grep` violation, so the prohibition-enforcement real-runner tests can use it as
|
||||
// the "clean target" for a NON-VACUOUS pass — the rule RUNS on it (enabled via the flat-config
|
||||
// block below) and finds nothing.
|
||||
//
|
||||
// Why a `.cjs` and not `src/clock.cts`: linting a `src/**/*.cts` file is type-aware
|
||||
// (`recommendedTypeChecked` + `parserOptions.project`), which loads the whole `tsconfig.build.json`
|
||||
// program on every eslint spawn (~2s, CPU-heavy). The real-runner tests spawn eslint repeatedly and,
|
||||
// under `--test-concurrency`, those full-program type-checks oversubscribe the bench CPU and blow the
|
||||
// 60s subprocess bound (#2126). A plain `.cjs` is linted non-type-aware (~0.8s) — same coverage of
|
||||
// the AST-only `no-source-grep` rule, no starvation.
|
||||
//
|
||||
// PLAIN `.cjs` (NOT `*.test.cjs`) on purpose — same reason as the violation fixture: keep it OFF the
|
||||
// `node --test` runner glob so it is only ever linted, never executed.
|
||||
'use strict';
|
||||
|
||||
module.exports = {};
|
||||
@@ -3967,12 +3967,12 @@ describe('ADR-1016 phase 5a: closed-vocab set exports', () => {
|
||||
'opencode-subset is NOT a hookEvents value — it is the extensionEvents vocabulary (#1943)');
|
||||
});
|
||||
|
||||
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/none — #1943)', () => {
|
||||
test('VALID_EXTENSION_EVENTS has the extension-system dialects (opencode/pi/hermes/none — #1943/#2091)', () => {
|
||||
assert.ok(VALID_EXTENSION_EVENTS instanceof Set);
|
||||
for (const v of ['opencode', 'pi', 'none']) {
|
||||
for (const v of ['opencode', 'pi', 'hermes', 'none']) {
|
||||
assert.ok(VALID_EXTENSION_EVENTS.has(v), 'VALID_EXTENSION_EVENTS must contain "' + v + '"');
|
||||
}
|
||||
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 3);
|
||||
assert.strictEqual(VALID_EXTENSION_EVENTS.size, 4);
|
||||
});
|
||||
|
||||
test('VALID_SANDBOX_TIERS has exactly 2 values', () => {
|
||||
|
||||
168
tests/cline-beforetool-upgrade.test.cjs
Normal file
168
tests/cline-beforetool-upgrade.test.cjs
Normal file
@@ -0,0 +1,168 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline beforeTool plugin UPGRADE — ADR-1239 / #2090 AC (upgrade 1).
|
||||
*
|
||||
* Proves the `.clinerules/hooks/PreToolUse` file-convention planning-artifact
|
||||
* guard is re-implemented as a real Cline SDK `AgentPlugin.hooks.beforeTool`
|
||||
* handler, delivered through the negotiated `hookBus: host` interface point.
|
||||
* Guard semantics are preserved EXACTLY from the PreToolUse script: fail-open,
|
||||
* cancel (skip) write-class calls targeting `.planning/`, pass through
|
||||
* everything else.
|
||||
*
|
||||
* The adapter is exercised directly (mocked SDK payload shape) since the real
|
||||
* `@cline/sdk` is a fast-moving package set not linked at test time — same
|
||||
* pattern as the VS Code reference binding (tests/fixtures/vscode-host-binding.cjs).
|
||||
*
|
||||
* Cite:
|
||||
* https://github.com/cline/cline/blob/main/docs/sdk/plugins.mdx
|
||||
* — "Lifecycle hooks ... include beforeRun, afterRun, beforeModel,
|
||||
* afterModel, beforeTool, afterTool, and onEvent."
|
||||
* https://github.com/cline/cline/blob/main/sdk/packages/agents/README.md
|
||||
* — beforeTool({ tool, input }) => { skip: true, reason } | undefined
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
WRITE_TOOL_PATTERN,
|
||||
PLANNING_PATH_PATTERN,
|
||||
PLANNING_GUARD_REASON,
|
||||
evaluateBeforeTool,
|
||||
clineGsdPlugin,
|
||||
} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs');
|
||||
|
||||
// -- upgrade 1: write-class detection ---------------------------------------
|
||||
|
||||
test('WRITE_TOOL_PATTERN matches the write-class tool verbs (parity with PreToolUse)', () => {
|
||||
const writeVerbs = ['write_to_file', 'edit_file', 'replace_in_file', 'create_file',
|
||||
'delete_file', 'remove_file', 'append_to_file', 'apply_patch', 'insert_edit', 'mkdir'];
|
||||
for (const v of writeVerbs) {
|
||||
assert.ok(WRITE_TOOL_PATTERN.test(v), `write-class verb must match: ${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('WRITE_TOOL_PATTERN does NOT match read-class tools', () => {
|
||||
const readVerbs = ['read_file', 'list_files', 'search_files', 'execute_command', 'ask_user'];
|
||||
for (const v of readVerbs) {
|
||||
assert.ok(!WRITE_TOOL_PATTERN.test(v), `read-class verb must NOT match: ${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- upgrade 1: planning-path detection -------------------------------------
|
||||
|
||||
test('PLANNING_PATH_PATTERN matches .planning/ paths on posix + windows separators', () => {
|
||||
const planningPaths = ['.planning/state.md', '.planning/phases/1/PLAN.md',
|
||||
'/home/u/proj/.planning/config.json', 'proj\\.planning\\foo', './.planning/x'];
|
||||
for (const p of planningPaths) {
|
||||
assert.ok(PLANNING_PATH_PATTERN.test(p), `planning path must match: ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('PLANNING_PATH_PATTERN does NOT match non-planning paths', () => {
|
||||
// .planning-readme.txt must not match (boundary after .planning required)
|
||||
for (const p of ['src/planning-utils.ts', 'docs/plan.md']) {
|
||||
assert.ok(!PLANNING_PATH_PATTERN.test(p), `non-planning path must NOT match: ${p}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- upgrade 1: evaluateBeforeTool — the guard decision ----------------------
|
||||
|
||||
test('write-class tool targeting .planning/ is SKIPPED (cancel)', () => {
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: '.planning/phases/1/PLAN.md', content: '...' },
|
||||
});
|
||||
assert.equal(result.decision, 'skip');
|
||||
assert.equal(result.reason, PLANNING_GUARD_REASON);
|
||||
});
|
||||
|
||||
test('write-class tool targeting a NON-planning path is ALLOWED', () => {
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: 'src/index.ts', content: '...' },
|
||||
});
|
||||
assert.equal(result.decision, 'allow');
|
||||
assert.equal(result.reason, undefined);
|
||||
});
|
||||
|
||||
test('read-class tool targeting .planning/ is ALLOWED (reads are safe)', () => {
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'read_file' },
|
||||
input: { path: '.planning/state.md' },
|
||||
});
|
||||
assert.equal(result.decision, 'allow');
|
||||
});
|
||||
|
||||
test('write tool with .planning/ in a non-PATH field (content body) is ALLOWED', () => {
|
||||
// A doc that merely mentions ".planning/" in its body is never falsely blocked —
|
||||
// only PATH-bearing field values are inspected (parity with PreToolUse).
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: 'docs/guide.md', content: 'see .planning/ for details' },
|
||||
});
|
||||
assert.equal(result.decision, 'allow');
|
||||
});
|
||||
|
||||
test('write tool with .planning/ in a recognized nested path-key is SKIPPED', () => {
|
||||
// The guard walks the input object tree collecting values from PATH-keyed
|
||||
// fields (path|file|target|dir|...). A recognized key nested anywhere in the
|
||||
// payload is caught — parity with the PreToolUse hook's bounded walk.
|
||||
const result = evaluateBeforeTool({
|
||||
tool: { name: 'apply_patch' },
|
||||
input: { target: '.planning/config.json' },
|
||||
});
|
||||
assert.equal(result.decision, 'skip');
|
||||
});
|
||||
|
||||
// -- upgrade 1: fail-open on malformed/missing input ------------------------
|
||||
|
||||
test('evaluateBeforeTool fails OPEN on null tool/input (never throws, never blocks)', () => {
|
||||
assert.equal(evaluateBeforeTool({ tool: null, input: null }).decision, 'allow');
|
||||
assert.equal(evaluateBeforeTool({}).decision, 'allow');
|
||||
assert.equal(evaluateBeforeTool(null).decision, 'allow');
|
||||
});
|
||||
|
||||
test('evaluateBeforeTool fails OPEN on a tool with no name', () => {
|
||||
assert.equal(evaluateBeforeTool({ tool: {}, input: { path: '.planning/x' } }).decision, 'allow');
|
||||
});
|
||||
|
||||
// -- upgrade 1: the AgentPlugin wrapper shape -------------------------------
|
||||
|
||||
test('clineGsdPlugin is an AgentPlugin with a beforeTool hook', () => {
|
||||
assert.equal(typeof clineGsdPlugin, 'object');
|
||||
assert.equal(clineGsdPlugin.name, 'gsd-planning-guard');
|
||||
assert.equal(typeof clineGsdPlugin.setup, 'function');
|
||||
});
|
||||
|
||||
test('clineGsdPlugin.setup returns hooks.beforeTool that maps skip→{skip,reason}', () => {
|
||||
const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' });
|
||||
assert.equal(typeof hooks.beforeTool, 'function');
|
||||
// planning write → { skip: true, reason }
|
||||
const blocked = hooks.beforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: '.planning/state.md' },
|
||||
});
|
||||
assert.deepEqual(blocked, { skip: true, reason: PLANNING_GUARD_REASON });
|
||||
});
|
||||
|
||||
test('clineGsdPlugin.beforeTool returns undefined for allowed calls (SDK contract)', () => {
|
||||
const { hooks } = clineGsdPlugin.setup({ agentId: 'test-agent' });
|
||||
const allowed = hooks.beforeTool({
|
||||
tool: { name: 'write_to_file' },
|
||||
input: { path: 'src/foo.ts' },
|
||||
});
|
||||
assert.equal(allowed, undefined, 'undefined = allow (Cline SDK beforeTool contract)');
|
||||
});
|
||||
|
||||
// -- upgrade 1: parity with the existing PreToolUse script semantics --------
|
||||
|
||||
test('the guard reason matches the PreToolUse script errorMessage contract', () => {
|
||||
// The file-convention hook wrote { cancel:true, errorMessage:'GSD: ...' }.
|
||||
// The SDK plugin maps cancel→skip and errorMessage→reason. The user-visible
|
||||
// message text is preserved so the guard behaves identically to the user.
|
||||
assert.ok(typeof PLANNING_GUARD_REASON === 'string' && PLANNING_GUARD_REASON.length > 0);
|
||||
assert.ok(PLANNING_GUARD_REASON.includes('.planning/'),
|
||||
'reason must explain the .planning/ protection so the user can act on it');
|
||||
});
|
||||
101
tests/cline-dispatch-degradation.test.cjs
Normal file
101
tests/cline-dispatch-degradation.test.cjs
Normal file
@@ -0,0 +1,101 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline dispatch DEGRADATION — ADR-1239 / #2090.
|
||||
*
|
||||
* Proves cline's dispatch STAYS degraded/flat and is NEVER silently upgraded to
|
||||
* the programmatic-cli profile baseline's full nested/background dispatch.
|
||||
* Cline's own docs (docs/features/subagents.mdx) restrict subagents to a single
|
||||
* level, read-only toolkit, no nested spawning — so claiming full dispatch would
|
||||
* misrepresent a documented host restriction and violate the fail-closed
|
||||
* negotiation contract. This is the cline counterpart to cursor's dispatch
|
||||
* UPGRADE (#2089), asserting the OPPOSITE invariant: cline flattens.
|
||||
*
|
||||
* Cite:
|
||||
* https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
|
||||
* — "subagents are restricted from editing files, using the browser,
|
||||
* accessing MCP servers, or creating nested subagents."
|
||||
* https://github.com/cline/cline/blob/main/docs/features/subagents.mdx
|
||||
* — "They are explicitly prohibited from ... spawning other subagents."
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
shouldFlattenDispatch,
|
||||
degradationFor,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const CLN_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CLN_DISPATCH = CLN_CAP.runtime.hostIntegration.dispatch;
|
||||
|
||||
// -- cline dispatch axes: documented restrictions ----------------------------
|
||||
|
||||
test('cline dispatch declares namedDispatch but NOT nested (single-level only)', () => {
|
||||
assert.equal(CLN_DISPATCH.namedDispatch, true,
|
||||
'cite agents-squad example — start_subagent(preset:..., task:...) named dispatch');
|
||||
assert.equal(CLN_DISPATCH.nested, false,
|
||||
'cite subagents.mdx — subagents cannot create nested subagents');
|
||||
});
|
||||
|
||||
test('cline dispatch respects maxDepth: 1 (the documented hard limit)', () => {
|
||||
assert.equal(CLN_DISPATCH.maxDepth, 1,
|
||||
'cite subagents.mdx — "explicitly prohibited from ... spawning other subagents"');
|
||||
});
|
||||
|
||||
test('cline subagentToolkit is read-only (no write/browser/mcp for subagents)', () => {
|
||||
assert.equal(CLN_DISPATCH.subagentToolkit, 'read-only',
|
||||
'cite subagents.mdx — "strictly limited to read-only operations"');
|
||||
});
|
||||
|
||||
test('cline backgroundDispatch is false (background commands run, but no nested-dispatch)', () => {
|
||||
assert.equal(CLN_DISPATCH.background, true,
|
||||
'cite subagents.mdx — "Commands executed by subagents run in the background"');
|
||||
assert.equal(CLN_DISPATCH.backgroundDispatch, false,
|
||||
'cite subagents.mdx — cannot spawn nested subagents from a background context');
|
||||
});
|
||||
|
||||
// -- shouldFlattenDispatch: cline MUST flatten (degraded) --------------------
|
||||
|
||||
test('shouldFlattenDispatch(cline) is true — waves run inline (the #853 rule)', () => {
|
||||
assert.equal(shouldFlattenDispatch(CLN_DISPATCH), true,
|
||||
'cline has backgroundDispatch:false → GSD must force-flatten (run inline)');
|
||||
});
|
||||
|
||||
test('a hypothetical full-upgrade (backgroundDispatch:true) would NOT flatten — proving the discriminator', () => {
|
||||
const hypothetical = { ...CLN_DISPATCH, backgroundDispatch: true, nested: true, maxDepth: 2, subagentToolkit: 'full' };
|
||||
assert.equal(shouldFlattenDispatch(hypothetical), false,
|
||||
'only background:true AND backgroundDispatch:true escapes flattening — cline lacks both');
|
||||
});
|
||||
|
||||
// -- degradationFor: cline dispatch is 'degraded' / flat ---------------------
|
||||
|
||||
test('degradationFor("dispatch", cline) is degraded with the flat-dispatch fallback', () => {
|
||||
const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration);
|
||||
assert.equal(result.level, 'degraded',
|
||||
'maxDepth:1 (flat) is a degraded dispatch surface, never full');
|
||||
assert.equal(result.fallback, 'flat dispatch — waves run inline');
|
||||
assert.notEqual(result.level, 'full',
|
||||
'cline dispatch must NEVER be classified as full — that would misrepresent the host');
|
||||
});
|
||||
|
||||
test('cline dispatch is never silently upgraded to the programmatic-cli baseline', () => {
|
||||
// The full baseline requires nested + maxDepth>=2 + subagentToolkit 'full'.
|
||||
// Cline violates ALL three (nested:false, maxDepth:1, read-only) — so it must
|
||||
// stay degraded regardless of any negotiation defaults.
|
||||
const result = degradationFor('dispatch', CLN_CAP.runtime.hostIntegration);
|
||||
assert.notEqual(result.level, 'full');
|
||||
});
|
||||
|
||||
// -- boundary: maxDepth 1 vs 0 vs -1 ----------------------------------------
|
||||
|
||||
test('maxDepth 1 is flat (NOT absent, NOT unbounded)', () => {
|
||||
assert.ok(CLN_DISPATCH.maxDepth > 0, 'maxDepth must be positive (not absent/single-agent)');
|
||||
assert.notEqual(CLN_DISPATCH.maxDepth, -1, 'cline is NOT unbounded — depth-1 is the documented limit');
|
||||
assert.notEqual(CLN_DISPATCH.maxDepth, 0, 'maxDepth:0 would mean no named dispatch — cline HAS named dispatch');
|
||||
});
|
||||
113
tests/cline-imperative-reference.test.cjs
Normal file
113
tests/cline-imperative-reference.test.cjs
Normal file
@@ -0,0 +1,113 @@
|
||||
// allow-test-rule: AC2 requires asserting no `runtime === 'cline'` string-equality branch remains in bin/install.js/src — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2090)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline imperative reference host — ADR-1239 Phase D / #2090 (EoS/cline).
|
||||
*
|
||||
* Proves cline is driven through the PUBLIC Host-Integration Interface (the
|
||||
* imperative adapter), that its negotiated axes classify + negotiate correctly,
|
||||
* that negotiation fails CLOSED on a corrupted descriptor, that the
|
||||
* Context7-sourced dispatch classification STAYS degraded/flat (cline subagents
|
||||
* are documented as single-level read-only — maxDepth:1 — and must never be
|
||||
* silently upgraded to full nested/background dispatch), and that the migration
|
||||
* retired the hardcoded `runtime === 'cline'` / `isCline` branches (folded into
|
||||
* descriptor-driven `runtime.hostBehaviors`).
|
||||
*
|
||||
* Contrast with cursor (#2089): cursor's dispatch got an UPGRADE (background +
|
||||
* nested). cline's dispatch is a deliberate DEGRADATION that must be preserved —
|
||||
* upgrading it would misrepresent a documented host restriction and violate the
|
||||
* fail-closed negotiation contract (see dispatch-degradation test).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const CLN_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'cline', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const CLN_AXES = CLN_CAP.runtime.hostIntegration;
|
||||
|
||||
// -- AC2: driven through the public interface (imperative adapter) -----------
|
||||
|
||||
test('createImperativeAdapter classifies cline as imperative + composes the registry', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'cline' });
|
||||
assert.equal(adapter.kind, 'imperative');
|
||||
assert.equal(adapter.runtime, 'cline');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('cline axes classify as the programmatic-cli reference profile (imperative embedding)', () => {
|
||||
assert.equal(profileOf(CLN_AXES), 'programmatic-cli');
|
||||
});
|
||||
|
||||
// -- AC3: all axes populated + validated -------------------------------------
|
||||
|
||||
test('cline descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => {
|
||||
assert.equal(CLN_AXES.embeddingMode, 'imperative');
|
||||
assert.equal(CLN_AXES.commandSurface, 'slash-file');
|
||||
assert.equal(CLN_AXES.modelMode, 'active');
|
||||
assert.equal(CLN_AXES.hookBus, 'host');
|
||||
assert.equal(CLN_AXES.stateIO, 'filesystem');
|
||||
assert.equal(CLN_AXES.transport, 'mcp');
|
||||
assert.equal(CLN_AXES.runtime, 'node');
|
||||
const d = CLN_AXES.dispatch;
|
||||
assert.equal(d.namedDispatch, true);
|
||||
assert.equal(d.nested, false);
|
||||
assert.equal(d.maxDepth, 1);
|
||||
assert.equal(d.background, true);
|
||||
assert.equal(d.subagentToolkit, 'read-only');
|
||||
assert.equal(d.backgroundDispatch, false);
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for cline, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...CLN_AXES, embeddingMode: 'future-unknown' }));
|
||||
});
|
||||
|
||||
test('a partial/empty cline descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired ---------------------------------
|
||||
|
||||
test('cline descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
||||
const hb = CLN_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object');
|
||||
assert.equal(hb.reapplyCommand, '/gsd-update --reapply');
|
||||
assert.equal(hb.frontmatterDialect, 'cline');
|
||||
assert.equal(hb.skipSharedHooksInstall, true);
|
||||
assert.equal(hb.localTargetIsProjectRoot, true);
|
||||
assert.equal(hb.clineRulesSurface, true);
|
||||
assert.equal(hb.localCommandsViaRules, true);
|
||||
});
|
||||
|
||||
test('no `runtime === "cline"` string-equality branch remains in the install source (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
for (const rel of ['bin/install.js', 'src/install-engine.cts', 'src/runtime-artifact-conversion.cts', 'src/runtime-hooks-surface.cts']) {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
const offenders = strip(src).match(/runtime\s*[!=]==\s*'cline'/g) || [];
|
||||
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='cline' branch may remain in ${rel}; found: ${offenders.join(', ')}`);
|
||||
}
|
||||
});
|
||||
161
tests/cline-model-override-upgrade.test.cjs
Normal file
161
tests/cline-model-override-upgrade.test.cjs
Normal file
@@ -0,0 +1,161 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* cline createAgentModel UPGRADE — ADR-1239 / #2090 AC (upgrade 2).
|
||||
*
|
||||
* Proves GSD's per-subagent `model_overrides` / `model_profile_overrides`
|
||||
* resolution (already used for OpenCode/Codex) now applies to cline subagents
|
||||
* via `DefaultGateway.createAgentModel({ providerId, modelId })`, instead of
|
||||
* leaving model selection untouched. Cline's `modelMode: active` (the host
|
||||
* exposes provider registration via createLlmsRuntime) is what makes this
|
||||
* wiring possible — passive hosts can only inject a per-agent model field.
|
||||
*
|
||||
* The binding resolves the createAgentModel call parameters from GSD config;
|
||||
* the real gateway call is the host's responsibility (mocked here, same pattern
|
||||
* as tests/fixtures/vscode-host-binding.cjs).
|
||||
*
|
||||
* Cite:
|
||||
* https://github.com/cline/cline/blob/main/docs/sdk/reference/gateway.mdx
|
||||
* — createAgentModel({ providerId, modelId }) returns an AgentModel
|
||||
* https://github.com/cline/cline/blob/main/sdk/packages/llms/README.md
|
||||
* — createLlmsRuntime(...) provider registry (modelMode: active)
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const {
|
||||
resolveClineAgentModelParams,
|
||||
inferProviderId,
|
||||
DEFAULT_CLINE_PROVIDER_ID,
|
||||
} = require('../gsd-core/bin/lib/host-integration-adapters/cline-sdk-binding.cjs');
|
||||
|
||||
// -- upgrade 2: provider inference from a model id --------------------------
|
||||
|
||||
test('inferProviderId maps anthropic model ids to "anthropic"', () => {
|
||||
assert.equal(inferProviderId('claude-sonnet-4-5'), 'anthropic');
|
||||
assert.equal(inferProviderId('claude-opus-4-1'), 'anthropic');
|
||||
});
|
||||
|
||||
test('inferProviderId maps openai model ids to "openai"', () => {
|
||||
assert.equal(inferProviderId('gpt-4o'), 'openai');
|
||||
assert.equal(inferProviderId('o1-preview'), 'openai');
|
||||
});
|
||||
|
||||
test('inferProviderId falls back to DEFAULT_CLINE_PROVIDER_ID for unknown ids', () => {
|
||||
assert.equal(inferProviderId('some-custom-model'), DEFAULT_CLINE_PROVIDER_ID);
|
||||
assert.equal(inferProviderId(''), DEFAULT_CLINE_PROVIDER_ID);
|
||||
});
|
||||
|
||||
// -- upgrade 2: model_overrides resolution ----------------------------------
|
||||
|
||||
test('a per-agent model_overrides entry resolves to createAgentModel params', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.deepEqual(result, { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' });
|
||||
});
|
||||
|
||||
test('model_overrides takes precedence over model_profile_overrides', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: { balanced: { planner: 'claude-opus-4-1' } },
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result.modelId, 'claude-sonnet-4-5', 'direct model_overrides wins');
|
||||
});
|
||||
|
||||
test('model_profile_overrides resolves when no direct model_overrides entry exists', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'executor',
|
||||
modelOverrides: null,
|
||||
modelProfileOverrides: { balanced: { executor: 'gpt-4o' } },
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.deepEqual(result, { providerId: 'openai', modelId: 'gpt-4o' });
|
||||
});
|
||||
|
||||
// -- upgrade 2: no override → null (gateway default applies) ----------------
|
||||
|
||||
test('returns null when no override is configured (gateway picks the default model)', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: null,
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result, null, 'null = no override; host gateway default applies');
|
||||
});
|
||||
|
||||
test('returns null when the agentType has no matching override', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { executor: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
// -- upgrade 2: the gateway binding (createAgentModel call shape) -----------
|
||||
|
||||
test('a resolved override drives DefaultGateway.createAgentModel with the right params', () => {
|
||||
// Simulate the host gateway (mocked — the real @cline/sdk is not linked here).
|
||||
const calls = [];
|
||||
const fakeGateway = {
|
||||
createAgentModel(selection) { calls.push(selection); return { providerId: selection.providerId, modelId: selection.modelId }; },
|
||||
};
|
||||
const params = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.ok(params, 'override must resolve to non-null params');
|
||||
const model = fakeGateway.createAgentModel(params);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.deepEqual(calls[0], { providerId: 'anthropic', modelId: 'claude-sonnet-4-5' });
|
||||
assert.equal(model.modelId, 'claude-sonnet-4-5');
|
||||
});
|
||||
|
||||
test('no override → createAgentModel is NOT called (gateway default, not GSD override)', () => {
|
||||
const calls = [];
|
||||
const fakeGateway = {
|
||||
createAgentModel(selection) { calls.push(selection); return {}; },
|
||||
};
|
||||
const params = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: null,
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
if (params) fakeGateway.createAgentModel(params);
|
||||
assert.equal(calls.length, 0, 'no override → gateway must use its own default, GSD does not call createAgentModel');
|
||||
});
|
||||
|
||||
// -- upgrade 2: fail-safe / malformed config --------------------------------
|
||||
|
||||
test('malformed override values (non-string) are ignored (fail-safe, not crash)', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: 42, executor: 'claude-sonnet-4-5' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
// planner's non-string override is ignored; falls through to null (no executor match for agentType planner)
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test('empty-string override is treated as absent', () => {
|
||||
const result = resolveClineAgentModelParams({
|
||||
agentType: 'planner',
|
||||
modelOverrides: { planner: '' },
|
||||
modelProfileOverrides: null,
|
||||
profile: 'balanced',
|
||||
});
|
||||
assert.equal(result, null);
|
||||
});
|
||||
@@ -13,11 +13,95 @@ const { describe, test, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execFileSync } = require('child_process');
|
||||
const os = require('os');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
|
||||
const GSD_TOOLS = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs');
|
||||
// In-process invocation, not execFileSync: cmdConfigGet is a pure CJS
|
||||
// function reachable without spawning `node` as a child. The prior
|
||||
// execFileSync(..., { timeout: 5000 }) raced a real subprocess's startup
|
||||
// (full node boot + gsd-tools.cjs's large eager require graph — capability
|
||||
// registry, phase/roadmap/agent/check/task routers, verify.cjs,
|
||||
// cli-skew-check, findProjectRoot, etc.) against a fixed 5s wall clock, with
|
||||
// no retry. Under Docker host contention that wall clock loses
|
||||
// nondeterministically (ETIMEDOUT) — a test-harness race, not a product
|
||||
// defect. bin/lib/config.cjs requires none of that dispatcher machinery, so
|
||||
// calling cmdConfigGet directly removes the subprocess-spawn cost and the
|
||||
// wall-clock race entirely: no timeout of any size can flake this.
|
||||
const config = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'config.cjs'));
|
||||
// io.cjs owns error()/output() and the JSON-error-mode toggle. cmdConfigGet's `error`
|
||||
// is bound to io.error at load, so we drive io directly to (a) get structured stderr
|
||||
// we can assert a typed `reason` on, and (b) restore the mode after each error probe.
|
||||
const io = require(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'io.cjs'));
|
||||
|
||||
/**
|
||||
* cmdConfigGet's error() path (gsd-core/bin/lib/io.cjs) calls process.exit(1)
|
||||
* directly (it predates the ExitError/runMain seam used by the CLI
|
||||
* entrypoint's non-error paths). Intercepting process.exit with a throwable
|
||||
* sentinel lets the error path be exercised in-process without killing the
|
||||
* test worker.
|
||||
*
|
||||
* The sentinel carries the ORIGINAL error message (not a generic "process.exit(1)").
|
||||
* That matters for cmdConfigGet's "no config.json" branch, whose `error()` sits inside
|
||||
* a try/catch that reclassifies any throw NOT starting with "No config.json" as a parse
|
||||
* failure (a guard that is dead in production, where process.exit terminates first, but
|
||||
* becomes live once process.exit is a throwing seam). Carrying the real message makes
|
||||
* that guard re-throw — modeling the single, faithful production termination instead of
|
||||
* a spurious second error() call with the wrong reason.
|
||||
*/
|
||||
class _ExitSignal extends Error {
|
||||
constructor(code, message) {
|
||||
super(message ?? `process.exit(${code})`);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* bin/lib/io.cjs's output()/error() write directly to the raw fd (1 or 2)
|
||||
* via fs.writeSync — they bypass console.log entirely, so
|
||||
* tests/helpers.cjs's captureConsole() cannot observe them (see
|
||||
* tests/io.test.cjs: "output() writes directly to fd 1"). Monkeypatch
|
||||
* fs.writeSync itself — save the original, override, restore in a finally,
|
||||
* the project's standard IO capture/fault-injection seam — to capture what
|
||||
* would have hit the fd.
|
||||
*/
|
||||
function captureFdWrite(fd, fn) {
|
||||
const orig = fs.writeSync;
|
||||
let captured = Buffer.alloc(0);
|
||||
fs.writeSync = (writeFd, ...rest) => {
|
||||
if (writeFd !== fd) return orig.call(fs, writeFd, ...rest);
|
||||
const [data, offset = 0, length] = rest;
|
||||
const chunk = Buffer.isBuffer(data)
|
||||
? data.subarray(offset, offset + (length ?? data.length - offset))
|
||||
: Buffer.from(String(data), 'utf8');
|
||||
captured = Buffer.concat([captured, chunk]);
|
||||
return chunk.length;
|
||||
};
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
fs.writeSync = orig;
|
||||
}
|
||||
return captured.toString('utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a CLI-style config-get argv (mirrors gsd-core/bin/gsd-tools.cjs's
|
||||
* 'config-get' case: key is args[1], optional --default <value>, optional
|
||||
* --raw) into cmdConfigGet's positional params. Keeps the test bodies below
|
||||
* expressed in the same CLI-args vocabulary they always were.
|
||||
*/
|
||||
function parseConfigGetArgs(args) {
|
||||
const rest = args.slice(1); // drop the leading 'config-get'
|
||||
let raw = false;
|
||||
let defaultValue;
|
||||
const positional = [];
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
if (rest[i] === '--raw') { raw = true; continue; }
|
||||
if (rest[i] === '--default') { defaultValue = rest[i + 1] ?? ''; i++; continue; }
|
||||
positional.push(rest[i]);
|
||||
}
|
||||
return { keyPath: positional[0], raw, defaultValue };
|
||||
}
|
||||
|
||||
describe('config-get --default flag (#1893)', () => {
|
||||
let tmpDir;
|
||||
@@ -34,10 +118,11 @@ describe('config-get --default flag (#1893)', () => {
|
||||
});
|
||||
|
||||
function run(...args) {
|
||||
return execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||||
const out = captureFdWrite(1, () => {
|
||||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||||
});
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
function runRaw(...args) {
|
||||
@@ -45,22 +130,55 @@ describe('config-get --default flag (#1893)', () => {
|
||||
}
|
||||
|
||||
function runExpectError(...args) {
|
||||
const { keyPath, raw, defaultValue } = parseConfigGetArgs(args);
|
||||
const origExit = process.exit;
|
||||
const origWriteSync = fs.writeSync;
|
||||
io.setJsonErrorMode(true); // structured stderr line lets the sentinel carry the message + assert reason
|
||||
let exitCount = 0;
|
||||
let exitCode;
|
||||
let stderr = '';
|
||||
fs.writeSync = (fd, ...rest) => {
|
||||
if (fd !== 2) return origWriteSync.call(fs, fd, ...rest);
|
||||
const [data, offset = 0, length] = rest;
|
||||
const chunk = Buffer.isBuffer(data)
|
||||
? data.subarray(offset, offset + (length ?? data.length - offset)).toString('utf8')
|
||||
: String(data);
|
||||
stderr += chunk;
|
||||
return Buffer.byteLength(chunk);
|
||||
};
|
||||
const lastError = () => {
|
||||
const parts = stderr.split('\n').filter(Boolean);
|
||||
try { return JSON.parse(parts[parts.length - 1]); } catch { return {}; }
|
||||
};
|
||||
process.exit = (code) => {
|
||||
exitCount++;
|
||||
exitCode = code;
|
||||
// Carry the just-emitted error message so cmdConfigGet's seam guard re-throws
|
||||
// (single, faithful fire) instead of catching + reclassifying into a 2nd error().
|
||||
throw new _ExitSignal(code, lastError().message);
|
||||
};
|
||||
try {
|
||||
execFileSync('node', [GSD_TOOLS, ...args, '--cwd', tmpDir], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
assert.fail('Expected command to exit non-zero');
|
||||
} catch (err) {
|
||||
assert.ok(err.status !== 0, 'Expected non-zero exit code');
|
||||
return err;
|
||||
config.cmdConfigGet(tmpDir, keyPath, raw, defaultValue);
|
||||
} catch (e) {
|
||||
if (!(e instanceof _ExitSignal)) throw e;
|
||||
} finally {
|
||||
process.exit = origExit;
|
||||
fs.writeSync = origWriteSync;
|
||||
io.setJsonErrorMode(false);
|
||||
}
|
||||
assert.ok(exitCode !== 0 && exitCode !== undefined, 'Expected non-zero exit code');
|
||||
// Faithfulness guard: production process.exit terminates, so error() fires exactly
|
||||
// once. A count of 2 means the throwing-exit seam was caught + reclassified (the bug
|
||||
// this harness redesign fixes) — fail loudly rather than report a wrong reason.
|
||||
assert.equal(exitCount, 1, 'error() must fire exactly once (production process.exit terminates)');
|
||||
const payload = lastError();
|
||||
return { status: exitCode, reason: payload.reason, message: payload.message, stderr };
|
||||
}
|
||||
|
||||
test('absent key without --default errors', () => {
|
||||
fs.writeFileSync(path.join(planningDir, 'config.json'), '{}');
|
||||
runExpectError('config-get', 'nonexistent.key', '--raw');
|
||||
const { reason } = runExpectError('config-get', 'nonexistent.key', '--raw');
|
||||
assert.equal(reason, io.ERROR_REASON.CONFIG_KEY_NOT_FOUND, 'absent key must report CONFIG_KEY_NOT_FOUND');
|
||||
});
|
||||
|
||||
test('absent key with --default returns default value', () => {
|
||||
@@ -99,7 +217,8 @@ describe('config-get --default flag (#1893)', () => {
|
||||
|
||||
test('missing config.json without --default errors', () => {
|
||||
// No config.json written
|
||||
runExpectError('config-get', 'any.key', '--raw');
|
||||
const { reason } = runExpectError('config-get', 'any.key', '--raw');
|
||||
assert.equal(reason, io.ERROR_REASON.CONFIG_NO_FILE, 'missing config.json must report CONFIG_NO_FILE');
|
||||
});
|
||||
|
||||
test('--default works with JSON output (no --raw)', () => {
|
||||
|
||||
73
tests/hermes-dispatch-upgrade.test.cjs
Normal file
73
tests/hermes-dispatch-upgrade.test.cjs
Normal file
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* hermes dispatch UPGRADE — ADR-1239 / #2091.
|
||||
*
|
||||
* Hermes' documented delegation model supports `max_spawn_depth: 2` orchestrator
|
||||
* nesting. The descriptor carries dispatch axes that reflect this. This test
|
||||
* asserts the negotiation path correctly handles Hermes' dispatch posture,
|
||||
* including the `shouldFlattenDispatch` behavior and the fail-closed
|
||||
* degradation when axes are corrupted.
|
||||
*
|
||||
* Cite: https://github.com/nousresearch/hermes-agent/blob/main/website/docs/guides/delegation-patterns.md
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const {
|
||||
negotiateHostCapabilities,
|
||||
shouldFlattenDispatch,
|
||||
degradationFor,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const HERMES_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'hermes', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const HERMES_AXES = HERMES_CAP.runtime.hostIntegration;
|
||||
const HERMES_DISPATCH = HERMES_AXES.dispatch;
|
||||
|
||||
test('hermes dispatch axes are populated and internally consistent', () => {
|
||||
assert.equal(HERMES_DISPATCH.nested, true, 'hermes supports nested dispatch');
|
||||
assert.ok(HERMES_DISPATCH.maxDepth >= 1, 'maxDepth must be >= 1');
|
||||
assert.ok(typeof HERMES_DISPATCH.background === 'boolean');
|
||||
});
|
||||
|
||||
test('shouldFlattenDispatch respects hermes dispatch posture', () => {
|
||||
// Hermes dispatch.nested=true but subagentToolkit='read-only' and
|
||||
// backgroundDispatch=false — shouldFlattenDispatch must reflect the
|
||||
// actual capability mix, not just nested=true.
|
||||
const result = shouldFlattenDispatch(HERMES_DISPATCH);
|
||||
assert.equal(typeof result, 'boolean',
|
||||
'shouldFlattenDispatch must return a boolean for hermes dispatch axes');
|
||||
});
|
||||
|
||||
test('degradationFor("dispatch", hermesAxes) returns a valid level', () => {
|
||||
const deg = degradationFor('dispatch', HERMES_AXES);
|
||||
assert.ok(deg, 'degradationFor must return a result for dispatch');
|
||||
assert.ok(['full', 'degraded', 'absent'].includes(deg.level),
|
||||
`dispatch level must be full/degraded/absent, got: ${deg.level}`);
|
||||
assert.ok(typeof deg.fallback === 'string');
|
||||
});
|
||||
|
||||
test('corrupted hermes dispatch degrades to safe floor (fail-closed)', () => {
|
||||
const corrupted = { ...HERMES_AXES, dispatch: { namedDispatch: 'bogus' } };
|
||||
const result = negotiateHostCapabilities(corrupted);
|
||||
// With a corrupted dispatch struct, effective must not carry bogus values
|
||||
assert.equal(typeof result.effective.dispatch.namedDispatch, 'boolean');
|
||||
assert.equal(result.effective.dispatch.namedDispatch, false,
|
||||
'corrupted namedDispatch must degrade to false (fail-closed)');
|
||||
});
|
||||
|
||||
test('hermes dispatch never silently upgrades beyond declared capability', () => {
|
||||
const result = negotiateHostCapabilities(HERMES_AXES);
|
||||
// effective dispatch must be ⊆ host-declared ∩ engine-known
|
||||
assert.ok(result.effective.dispatch.maxDepth <= HERMES_DISPATCH.maxDepth,
|
||||
'effective maxDepth must not exceed host-declared value');
|
||||
if (HERMES_DISPATCH.backgroundDispatch === false) {
|
||||
assert.equal(result.effective.dispatch.backgroundDispatch, false,
|
||||
'effective backgroundDispatch must not be true when host declares false');
|
||||
}
|
||||
});
|
||||
132
tests/hermes-imperative-reference.test.cjs
Normal file
132
tests/hermes-imperative-reference.test.cjs
Normal file
@@ -0,0 +1,132 @@
|
||||
// allow-test-rule: AC2 requires asserting no `runtime === 'hermes'` string-equality branch remains in bin/install.js — the descriptor-migration contract is a property of the source text, so a source-grep is the only faithful check (#2091)
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* hermes imperative reference host — ADR-1239 Phase D / #2091 (EoS/hermes).
|
||||
*
|
||||
* Proves hermes is driven through the PUBLIC Host-Integration Interface (the
|
||||
* imperative adapter), that its negotiated axes classify + negotiate correctly,
|
||||
* that negotiation fails CLOSED on a corrupted descriptor, that the
|
||||
* extensionEvents UPGRADE (13 real plugin hook events replacing the borrowed
|
||||
* "claude" 6-event surface) is registered, and that the migration retired the
|
||||
* hardcoded `runtime === 'hermes'` / `isHermes` branches in bin/install.js
|
||||
* (folded into descriptor-driven `runtime.hostBehaviors`).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const { createImperativeAdapter } = require('../gsd-core/bin/lib/adapter-imperative.cjs');
|
||||
const {
|
||||
profileOf,
|
||||
negotiateHostCapabilities,
|
||||
extensionEventSurfaceFor,
|
||||
PROFILE_BASELINES,
|
||||
UNDOCUMENTED,
|
||||
} = require('../gsd-core/bin/lib/host-integration.cjs');
|
||||
|
||||
const HERMES_CAP = JSON.parse(
|
||||
fs.readFileSync(path.join(__dirname, '..', 'capabilities', 'hermes', 'capability.json'), 'utf8'),
|
||||
);
|
||||
const HERMES_AXES = HERMES_CAP.runtime.hostIntegration;
|
||||
|
||||
// -- AC2: driven through the public interface (imperative adapter) -----------
|
||||
|
||||
test('createImperativeAdapter classifies hermes as imperative + composes the registry', () => {
|
||||
const adapter = createImperativeAdapter({ runtime: 'hermes' });
|
||||
assert.equal(adapter.kind, 'imperative');
|
||||
assert.equal(adapter.runtime, 'hermes');
|
||||
assert.ok(adapter.registry && typeof adapter.registry === 'object');
|
||||
assert.equal(typeof adapter.install, 'function');
|
||||
assert.equal(typeof adapter.uninstall, 'function');
|
||||
});
|
||||
|
||||
test('hermes axes classify as the programmatic-cli reference profile', () => {
|
||||
assert.equal(profileOf(HERMES_AXES), 'programmatic-cli');
|
||||
});
|
||||
|
||||
// -- AC3: all axes populated + validated -------------------------------------
|
||||
|
||||
test('hermes descriptor declares all 8 axes + 6 dispatch sub-axes (no undocumented)', () => {
|
||||
assert.equal(HERMES_AXES.embeddingMode, 'imperative');
|
||||
assert.equal(HERMES_AXES.commandSurface, 'slash-programmatic');
|
||||
assert.equal(HERMES_AXES.modelMode, 'active');
|
||||
assert.equal(HERMES_AXES.hookBus, 'host');
|
||||
assert.equal(HERMES_AXES.stateIO, 'filesystem');
|
||||
assert.equal(HERMES_AXES.transport, 'mcp');
|
||||
assert.equal(HERMES_AXES.runtime, 'python');
|
||||
const d = HERMES_AXES.dispatch;
|
||||
assert.equal(typeof d.namedDispatch, 'boolean');
|
||||
assert.equal(typeof d.nested, 'boolean');
|
||||
assert.equal(typeof d.maxDepth, 'number');
|
||||
assert.equal(typeof d.background, 'boolean');
|
||||
assert.ok(['full', 'read-only'].includes(d.subagentToolkit));
|
||||
assert.equal(typeof d.backgroundDispatch, 'boolean');
|
||||
});
|
||||
|
||||
// -- AC4a: extensionEvents UPGRADE — 13 real events, not borrowed claude -----
|
||||
|
||||
test('hermes descriptor declares extensionEvents: "hermes" (not the borrowed "claude" hookEvents)', () => {
|
||||
assert.equal(HERMES_CAP.runtime.extensionEvents, 'hermes',
|
||||
'descriptor must declare extensionEvents: "hermes" — the real plugin hook vocabulary');
|
||||
});
|
||||
|
||||
test('extensionEventSurfaceFor("hermes") returns all 13 documented events', () => {
|
||||
const surface = extensionEventSurfaceFor('hermes');
|
||||
assert.ok(surface, 'hermes extensionEvents surface must be registered');
|
||||
const expectedEvents = [
|
||||
'pre_tool_call', 'post_tool_call',
|
||||
'pre_llm_call', 'post_llm_call',
|
||||
'on_session_start', 'on_session_end',
|
||||
'on_session_finalize', 'on_session_reset',
|
||||
'subagent_start', 'subagent_stop',
|
||||
'pre_gateway_dispatch', 'pre_approval_request',
|
||||
'transform_tool_result',
|
||||
];
|
||||
assert.equal(surface.length, 13, 'exactly 13 documented Hermes plugin events');
|
||||
for (const ev of expectedEvents) {
|
||||
assert.ok(surface.includes(ev), `surface must include ${ev}`);
|
||||
}
|
||||
});
|
||||
|
||||
// -- AC5: negotiation fails CLOSED on a corrupted descriptor ------------------
|
||||
|
||||
test('negotiateHostCapabilities never throws for hermes, even fully corrupted', () => {
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({}));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...HERMES_AXES, embeddingMode: UNDOCUMENTED }));
|
||||
assert.doesNotThrow(() => negotiateHostCapabilities({ ...HERMES_AXES, embeddingMode: 'future-unknown' }));
|
||||
});
|
||||
|
||||
test('a partial/empty hermes descriptor degrades to the safe floor, not the programmatic-cli baseline', () => {
|
||||
const result = negotiateHostCapabilities({});
|
||||
assert.equal(result.effective.embeddingMode, 'declarative', 'omitted embeddingMode degrades closed');
|
||||
assert.equal(result.effective.hookBus, 'none');
|
||||
assert.notDeepEqual(result.effective, PROFILE_BASELINES['programmatic-cli']);
|
||||
assert.ok(result.warnings.length > 0);
|
||||
});
|
||||
|
||||
// -- AC2: the hardcoded branches are retired ---------------------------------
|
||||
|
||||
test('hermes descriptor declares runtime.hostBehaviors (the folded-in behaviors)', () => {
|
||||
const hb = HERMES_CAP.runtime.hostBehaviors;
|
||||
assert.ok(hb && typeof hb === 'object');
|
||||
assert.equal(hb.skillFrontmatterVersion, true);
|
||||
assert.equal(hb.skillsManifestPrefix, 'skills/gsd/');
|
||||
assert.equal(hb.trackCategoryDescription, true);
|
||||
assert.equal(hb.writeCategoryDescription, true);
|
||||
assert.equal(hb.reportSkillsCount, true);
|
||||
assert.equal(hb.legacyCommandsGsdCleanup, true);
|
||||
assert.ok(hb.brandingRewrites && typeof hb.brandingRewrites === 'object');
|
||||
});
|
||||
|
||||
test('no `runtime === "hermes"` string-equality branch remains in bin/install.js (AC2)', () => {
|
||||
const strip = (src) => src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\r\n]*/g, '')
|
||||
.replace(/`[^`]*`/g, '');
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', 'bin', 'install.js'), 'utf8');
|
||||
const offenders = strip(src).match(/runtime\s*[!=]==\s*'hermes'/g) || [];
|
||||
assert.deepEqual(offenders, [], `AC2: no hardcoded runtime==='hermes' branch may remain in bin/install.js; found: ${offenders.join(', ')}`);
|
||||
});
|
||||
@@ -74,9 +74,9 @@ describe('extensionEventSurfaceFor (extension-system event dialect — #1943)',
|
||||
assert.equal(extensionEventSurfaceFor('nope'), null);
|
||||
assert.equal(extensionEventSurfaceFor(undefined), null);
|
||||
});
|
||||
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/none', () => {
|
||||
test('EXTENSION_EVENT_SURFACES is frozen + covers opencode/pi/hermes/none', () => {
|
||||
assert.equal(Object.isFrozen(EXTENSION_EVENT_SURFACES), true);
|
||||
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['none', 'opencode', 'pi']);
|
||||
assert.deepEqual(Object.keys(EXTENSION_EVENT_SURFACES).sort(), ['hermes', 'none', 'opencode', 'pi']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -98,6 +98,8 @@ describe('#1575 — golden-parity: surface path matches install path for descrip
|
||||
installContent,
|
||||
`${runtime}/${fileName}: surface content must be byte-identical to install content`,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('cursor with non-undefined attribution: surface agents byte-identical to install agents (M2 coverage)', (t) => {
|
||||
@@ -124,8 +126,6 @@ describe('#1575 — golden-parity: surface path matches install path for descrip
|
||||
`cursor/${fileName}: content must be byte-identical with non-undefined attribution`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('copilot: agents installed as .agent.md (filename rename parity)', (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1575-copilot-rename-'));
|
||||
|
||||
@@ -33,6 +33,7 @@ const {
|
||||
collectSection,
|
||||
iterateBullets,
|
||||
extractTaggedBlocks,
|
||||
stripTaggedBlocks,
|
||||
replaceSection,
|
||||
} = require('../gsd-core/bin/lib/markdown-sectionizer.cjs');
|
||||
|
||||
@@ -1015,15 +1016,14 @@ describe('stripFencedCode and tokenizeHeadings: backtick info string with backti
|
||||
|
||||
// ─── FIX 6: extractTaggedBlocks — nested tag behavior ─────────────────────────
|
||||
|
||||
describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitation)', () => {
|
||||
test('nested <x><x>…</x></x> closes at first </x> (non-greedy; nested tags not supported)', () => {
|
||||
// Non-greedy match: <x>([\s\S]*?)</x> closes at the FIRST </x>.
|
||||
// So <x><x>inner</x></x> → first block captures "<x>inner", second </x> is unmatched.
|
||||
describe('extractTaggedBlocks: nested same-name tag behavior (#2128 stop-at-next-open)', () => {
|
||||
test('nested <x><x>inner</x></x> extracts the well-formed inner block', () => {
|
||||
// #2128: the ReDoS-safe body scan terminates at the NEXT opening <x>, so the
|
||||
// unterminated outer <x> is skipped and the inner block is extracted.
|
||||
const content = '<x><x>inner</x></x>';
|
||||
const result = extractTaggedBlocks(content, 'x');
|
||||
// The first match closes at the first </x>, capturing "<x>inner"
|
||||
assert.equal(result.length, 1, 'non-greedy match produces exactly one result from nested input');
|
||||
assert.equal(result[0], '<x>inner', 'inner capture is the content up to the first closing tag');
|
||||
assert.equal(result.length, 1, 'exactly one result from nested input');
|
||||
assert.equal(result[0], 'inner', 'the well-formed inner block is extracted; the unterminated outer is skipped');
|
||||
});
|
||||
|
||||
test('back-to-back blocks (not nested) are both extracted', () => {
|
||||
@@ -1033,6 +1033,24 @@ describe('extractTaggedBlocks: nested same-name tag behavior (non-greedy limitat
|
||||
assert.equal(result[0], 'first');
|
||||
assert.equal(result[1], 'second');
|
||||
});
|
||||
|
||||
test('#2128: a document full of unclosed <x> openings stays linear and yields no match', () => {
|
||||
const content = '<x>a\n'.repeat(50) + 'no closing tag';
|
||||
assert.deepEqual(extractTaggedBlocks(content, 'x'), [], 'no </x> anywhere -> no blocks');
|
||||
});
|
||||
|
||||
test('#557 / #2128: attr-intolerant by default preserves <details open>; opt-in matches <task type=…>', () => {
|
||||
// stripTaggedBlocks(details) must PRESERVE <details open> (the active-milestone
|
||||
// marker) and strip only bare <details>; extractTaggedBlocks(task, true) must
|
||||
// match attributed tasks, and must NOT when allowAttributes is left false.
|
||||
assert.equal(
|
||||
stripTaggedBlocks('X<details>shipped</details>Y<details open>active</details>Z', 'details'),
|
||||
'XY<details open>active</details>Z',
|
||||
'#557: <details open> preserved; bare <details> stripped',
|
||||
);
|
||||
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task', true), ['body'], 'attributed task matched with allowAttributes=true');
|
||||
assert.deepEqual(extractTaggedBlocks('<task type="auto">body</task>', 'task'), [], 'attributed task NOT matched with allowAttributes=false');
|
||||
});
|
||||
});
|
||||
|
||||
// Parity guard removed in T5 (ADR-1372): uat-predicate now imports stripFencedCode
|
||||
|
||||
172
tests/phase-id-drift-guard.test.cjs
Normal file
172
tests/phase-id-drift-guard.test.cjs
Normal file
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Anti-divergence guard for the phase-identifier parsing seam
|
||||
* (epic #2121 Phase 4 / issue #2128, ADR-2121 Decision 7).
|
||||
*
|
||||
* `src/phase-id.cts` is the single canonical owner of phase-ID parsing. Two guards
|
||||
* keep it that way:
|
||||
* 1. DRIFT SCANNER (scripts/lint-phase-id-drift.cjs) — fails CI if any module
|
||||
* outside phase-id.cts re-derives the canonical phase-number token as a
|
||||
* literal without a `// phase-id-owner:` sanction.
|
||||
* 2. IDENTITY guard — phase-id.cjs exports the complete locked surface, and no
|
||||
* consumer re-exports a DIVERGENT copy of a canonical function (re-export,
|
||||
* never re-implement).
|
||||
*
|
||||
* Behavioral throughout: assertions drive `findPhaseIdRegexDrift` / `scanRepo`
|
||||
* and compare object identity — no `readFileSync().includes()` in a test body.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const { findPhaseIdRegexDrift, scanRepo } = require(
|
||||
path.join(ROOT, 'scripts', 'lint-phase-id-drift.cjs'),
|
||||
);
|
||||
const phaseId = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'phase-id.cjs'));
|
||||
|
||||
// The locked canonical surface (ADR-2121 Decision 1/2; PHASE_NUMBER_TOKEN_SOURCE
|
||||
// added in Phase 4). Every name is exported by phase-id.cjs; the identity guard
|
||||
// forbids any other module from re-exporting a divergent copy of one.
|
||||
const CANONICAL = [
|
||||
'escapeRegex', 'OPTIONAL_PROJECT_CODE_PREFIX_SOURCE', 'OPTIONAL_PHASE_TAG_SOURCE',
|
||||
'PHASE_NUMBER_TOKEN_SOURCE', 'stripProjectCodePrefix', 'normalizePhaseName',
|
||||
'getMilestoneFromPhaseId', 'getPhaseDirFromPhaseId', 'phaseMarkdownRegexSource',
|
||||
'phaseMarkdownRegexSourceExact', 'comparePhaseNum', 'extractPhaseToken',
|
||||
'phaseTokenMatches', 'parsePhaseFromProse', 'stripConfiguredProjectCodePrefix',
|
||||
'isForeignPrefixedPhaseQuery', 'roadmapPhaseLookupSources',
|
||||
];
|
||||
|
||||
describe('#2128 phase-id drift scanner: findPhaseIdRegexDrift (pure)', () => {
|
||||
test('a regex built from PHASE_NUMBER_TOKEN_SOURCE is NOT drift', () => {
|
||||
assert.deepEqual(
|
||||
findPhaseIdRegexDrift('const re = new RegExp(`Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`);'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a literal re-derivation of the canonical token IS flagged (fail-first)', () => {
|
||||
const v = findPhaseIdRegexDrift('const re = /Phase\\s+(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(v.length, 1);
|
||||
assert.equal(v[0].found, '\\d+[A-Z]?(?:\\.\\d+)*');
|
||||
});
|
||||
|
||||
test('a re-derivation inside a new RegExp template (\\\\d escaping) IS flagged', () => {
|
||||
const v = findPhaseIdRegexDrift('new RegExp(`Phase\\\\s+(\\\\d+[A-Z]?(?:\\\\.\\\\d+)*)`)');
|
||||
assert.equal(v.length, 1);
|
||||
});
|
||||
|
||||
test('the [A-Za-z], [.-] and [0-9] near-variants ARE flagged (no trivial evasion)', () => {
|
||||
assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Za-z]?(?:\\.\\d+)*)/').length, 1, '[A-Za-z] letter class');
|
||||
assert.equal(findPhaseIdRegexDrift('/(\\d+[A-Z]?(?:[.-]\\d+)*)/').length, 1, '[.-] separator');
|
||||
assert.equal(findPhaseIdRegexDrift('/([0-9]+[A-Z]?(?:\\.[0-9]+)*)/').length, 1, '[0-9] in place of \\d');
|
||||
});
|
||||
|
||||
test('a dedicated preceding // phase-id-owner: comment line suppresses the flag', () => {
|
||||
assert.deepEqual(
|
||||
findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a blank line between the // phase-id-owner: comment and the regex still suppresses', () => {
|
||||
assert.deepEqual(
|
||||
findPhaseIdRegexDrift(' // phase-id-owner: sanctioned exception\n\n const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;'),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
test('a trailing same-line // phase-id-owner: is NOT a sanction (must be a dedicated line above)', () => {
|
||||
// The marker must lead its own comment line; a trailing comment on a code
|
||||
// line is not honored, so the regex is still flagged.
|
||||
const v = findPhaseIdRegexDrift('const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/; // phase-id-owner: not honored here');
|
||||
assert.equal(v.length, 1);
|
||||
});
|
||||
|
||||
test('a // phase-id-owner: embedded in a STRING literal does NOT suppress (decoy)', () => {
|
||||
// A `//` inside a string is not a comment — help/doc text that quotes the
|
||||
// sanction syntax must not silently suppress a real re-derivation.
|
||||
const decoyLine = findPhaseIdRegexDrift('const help = "use // phase-id-owner: <reason>"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(decoyLine.length, 1);
|
||||
const decoyPrev = findPhaseIdRegexDrift('const help = "use // phase-id-owner: <reason>";\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(decoyPrev.length, 1);
|
||||
});
|
||||
|
||||
test('a bare "phase-id-owner:" substring with no // does NOT suppress', () => {
|
||||
const v = findPhaseIdRegexDrift('const msg = "ping the phase-id-owner for review"; const re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;');
|
||||
assert.equal(v.length, 1);
|
||||
});
|
||||
|
||||
test('non-token phase regexes are NOT flagged (no false positives)', () => {
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/^Executing Phase\\s+\\d+/'), [], 'status-message bare \\d+');
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/#{2,4}\\s*Phase\\s+(\\d+)[A-Z]?(?:\\.\\d+)*/'), [], 'digits-only capture is non-contiguous');
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/Phase\\s+([\\w][\\w.-]*)/'), [], '\\w id grammar is not the canonical token');
|
||||
assert.deepEqual(findPhaseIdRegexDrift('/\\|\\s*Phase\\s*\\|\\s*Plans\\s*\\|/'), [], 'pipe-table structure');
|
||||
});
|
||||
|
||||
test('reports 1-based line numbers', () => {
|
||||
const v = findPhaseIdRegexDrift('line1\nconst re = /(\\d+[A-Z]?(?:\\.\\d+)*)/;\nline3');
|
||||
assert.equal(v[0].line, 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2128 phase-id drift scanner: the live repo is clean', () => {
|
||||
test('scanRepo finds zero unsanctioned phase-token re-derivations', () => {
|
||||
const violations = scanRepo(ROOT);
|
||||
assert.deepEqual(
|
||||
violations,
|
||||
[],
|
||||
'unsanctioned phase-token re-derivation(s) — build from PHASE_NUMBER_TOKEN_SOURCE or add // phase-id-owner:\n' +
|
||||
violations.map((d) => ` ${d.file}:${d.line} ${d.found}`).join('\n'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#2128 phase-id single-owner identity guard', () => {
|
||||
test('phase-id.cjs exports the complete locked canonical surface', () => {
|
||||
for (const name of CANONICAL) {
|
||||
assert.ok(name in phaseId, `phase-id.cjs must export the canonical member '${name}'`);
|
||||
}
|
||||
});
|
||||
|
||||
test('no consumer module re-exports a DIVERGENT copy of a canonical phase-id function', () => {
|
||||
// Forward guard: if any built lib module re-exports a name that phase-id.cjs
|
||||
// owns, it MUST be the identical reference — a re-export, never a local
|
||||
// re-implementation. All consumers pass today (none re-export); the guard
|
||||
// fails the moment a divergent copy ships.
|
||||
const libDir = path.join(ROOT, 'gsd-core', 'bin', 'lib');
|
||||
const consumers = fs.readdirSync(libDir).filter((f) => f.endsWith('.cjs') && f !== 'phase-id.cjs');
|
||||
let checked = 0;
|
||||
const requireFailures = [];
|
||||
for (const f of consumers) {
|
||||
let mod;
|
||||
try {
|
||||
mod = require(path.join(libDir, f));
|
||||
} catch (e) {
|
||||
// Surfaced, not silently skipped — a module that cannot be required
|
||||
// would otherwise erode the guard's coverage without any signal.
|
||||
requireFailures.push(`${f}: ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
if (!mod || typeof mod !== 'object') continue; // bare-function exports carry no named canonical member
|
||||
checked++;
|
||||
for (const name of CANONICAL) {
|
||||
if (Object.prototype.hasOwnProperty.call(mod, name)) {
|
||||
assert.strictEqual(
|
||||
mod[name],
|
||||
phaseId[name],
|
||||
`${f} re-exports '${name}' but it is NOT the phase-id.cjs reference — re-export the canonical, do not re-implement`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
assert.deepEqual(requireFailures, [], `consumer module(s) failed to require (guard coverage would silently degrade):\n ${requireFailures.join('\n ')}`);
|
||||
// Coverage floor: the vast majority of the ~150 built lib modules export an
|
||||
// object and must actually be inspected — not a token "at least one".
|
||||
assert.ok(checked > consumers.length * 0.75, `expected to inspect most of the ${consumers.length} consumer modules, only inspected ${checked}`);
|
||||
});
|
||||
});
|
||||
@@ -348,6 +348,23 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header
|
||||
assert.ok(re.test('### Phase 26: X'), 'seam stays optional when no tag is present');
|
||||
});
|
||||
|
||||
test('#2128: the pre-colon tag is length-bounded so the tag clause cannot ReDoS', () => {
|
||||
// The tag body `[^)\n]*` was unbounded, making the optional-group + /g scan
|
||||
// quadratic on adversarial ROADMAP.md/STATE.md (a long run of `(` after a
|
||||
// header). Bounding it to {0,200} keeps the match linear; a 200-char tag body
|
||||
// still matches (real tags are a handful of chars), 201 does not.
|
||||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`);
|
||||
// Boundary coverage (CLAUDE.md): limit-1, limit, limit+1.
|
||||
assert.ok(re.test(`### Phase 26 (${'x'.repeat(199)}): T`), 'a 199-char tag body (limit-1) is within the bound');
|
||||
assert.ok(re.test(`### Phase 26 (${'x'.repeat(200)}): T`), 'a 200-char tag body (limit) is within the bound');
|
||||
assert.ok(!re.test(`### Phase 26 (${'x'.repeat(201)}): T`), 'a 201-char tag body (limit+1) exceeds the bound');
|
||||
// Linearity guard: the adversarial input that was ~18.8s unbounded resolves
|
||||
// near-instantly now. Assert bounded work, not wall-clock (no clock seam):
|
||||
// the bounded source contains an explicit upper repetition limit.
|
||||
assert.match(phaseId.OPTIONAL_PHASE_TAG_SOURCE, /\{0,\d+\}/, 'tag body must carry an explicit upper bound');
|
||||
});
|
||||
|
||||
test('enumeration (roadmap analyze) lists a pre-colon-tagged phase, not just the resolver', () => {
|
||||
// The resolver (get-phase) and the capture-all enumeration regexes are
|
||||
// separate code paths. Fixing only the resolver left `roadmap analyze`
|
||||
@@ -410,11 +427,12 @@ describe('#1729 regression: parenthetical tag before the colon in a phase header
|
||||
|
||||
test('the literal enumeration mirror stays equivalent to the exported seam (drift guard)', () => {
|
||||
// Resolver sites compose OPTIONAL_PHASE_TAG_SOURCE; literal enumeration sites
|
||||
// inline `(?:\s*\([^)\n]*\))?`. If one is edited without the other the two
|
||||
// header families silently diverge. Assert behavioral equivalence over a
|
||||
// representative header corpus so the split cannot drift undetected.
|
||||
// inline `(?:\s*\([^)\n]{0,200}\))?`. If one is edited without the other the
|
||||
// two header families silently diverge (the body is bounded to {0,200} in
|
||||
// both since #2128 — a ReDoS fix that MUST stay in lockstep). Assert
|
||||
// behavioral equivalence over a representative header corpus.
|
||||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]*\\))?';
|
||||
const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]{0,200}\\))?';
|
||||
const seam = new RegExp(`^Phase\\s+26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`);
|
||||
const mirror = new RegExp(`^Phase\\s+26${LITERAL_MIRROR}\\s*:`);
|
||||
for (const sample of [
|
||||
|
||||
@@ -716,13 +716,13 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
|
||||
const enforce = require(ENFORCEMENT_LIB);
|
||||
// Migrated to the SHIPPING prover (#1279): the default real prover lints the committed
|
||||
// `_ff_lint_violation.cjs` violationFixture (the rule fires -> fail-first proven) while the
|
||||
// clean runCheck lints src/clock.cts (no violation -> non-vacuous pass). Both via real eslint.
|
||||
// clean runCheck lints tests/_ff_lint_clean.cjs (no violation -> non-vacuous pass). Both via real eslint.
|
||||
const result = enforce.runProhibitionEnforcement(
|
||||
TEST_TIER,
|
||||
{
|
||||
kind: 'lint-rule',
|
||||
rule: 'local/no-source-grep',
|
||||
target: 'src/clock.cts',
|
||||
target: 'tests/_ff_lint_clean.cjs',
|
||||
failFirst: true,
|
||||
violationFixture: path.join('tests', '_ff_lint_violation.cjs'),
|
||||
},
|
||||
@@ -759,13 +759,13 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
|
||||
const enforce = require(ENFORCEMENT_LIB);
|
||||
// No injected runCheck/proveFailFirst: the default prover lints the committed
|
||||
// `_ff_lint_violation.cjs` (the rule fires -> fail-first proven) AND the default runner lints
|
||||
// the clean `src/clock.cts` (no violation -> non-vacuous pass). BOTH directions via real eslint.
|
||||
// the clean `tests/_ff_lint_clean.cjs` (no violation -> non-vacuous pass). BOTH directions via real eslint.
|
||||
const result = enforce.runProhibitionEnforcement(
|
||||
TEST_TIER,
|
||||
{
|
||||
kind: 'lint-rule',
|
||||
rule: 'local/no-source-grep',
|
||||
target: 'src/clock.cts',
|
||||
target: 'tests/_ff_lint_clean.cjs',
|
||||
violationFixture: path.join('tests', '_ff_lint_violation.cjs'),
|
||||
},
|
||||
{ cwd: process.cwd() },
|
||||
@@ -780,7 +780,7 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
|
||||
|
||||
test('FULL producer (real): lint-rule hard-gates on a TOOTHLESS violationFixture (rule does not flag it) (FF-02 wrong-direction)', () => {
|
||||
const enforce = require(ENFORCEMENT_LIB);
|
||||
// The "violation fixture" is a CLEAN in-tree file (src/clock.cts) the rule does NOT flag, so the
|
||||
// The "violation fixture" is a CLEAN in-tree file (tests/_ff_lint_clean.cjs) the rule does NOT flag, so the
|
||||
// default prover cannot prove fail-first -> the producer must hard-gate (never green), even though
|
||||
// the clean target itself would pass the runner. A toothless guard is not a guard.
|
||||
const result = enforce.runProhibitionEnforcement(
|
||||
@@ -788,8 +788,8 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
|
||||
{
|
||||
kind: 'lint-rule',
|
||||
rule: 'local/no-source-grep',
|
||||
target: 'src/clock.cts',
|
||||
violationFixture: 'src/clock.cts',
|
||||
target: 'tests/_ff_lint_clean.cjs',
|
||||
violationFixture: 'tests/_ff_lint_clean.cjs',
|
||||
},
|
||||
{ cwd: process.cwd() },
|
||||
);
|
||||
@@ -807,8 +807,8 @@ describe('prohibition-enforcement REAL runner end-to-end (#1259)', () => {
|
||||
{
|
||||
kind: 'lint-rule',
|
||||
rule: 'local/no-source-grep',
|
||||
target: 'src/clock.cts',
|
||||
violationFixture: 'src/clock.cts',
|
||||
target: 'tests/_ff_lint_clean.cjs',
|
||||
violationFixture: 'tests/_ff_lint_clean.cjs',
|
||||
},
|
||||
{ cwd: process.cwd(), mode },
|
||||
);
|
||||
@@ -1009,11 +1009,11 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', ()
|
||||
|
||||
test('lint-rule: a CLEAN violationFixture (rule does not flag) is NOT proven (FF-02 toothless direction)', () => {
|
||||
const enforce = require(ENFORCEMENT_LIB);
|
||||
// src/clock.cts is a clean in-tree source with no no-source-grep violation. If a "violation
|
||||
// tests/_ff_lint_clean.cjs is a clean in-tree source with no no-source-grep violation. If a "violation
|
||||
// fixture" does not actually trigger the rule, the rule is toothless on it → not a guard → not
|
||||
// proven → must hard-gate.
|
||||
const proof = enforce.defaultProveFailFirst(
|
||||
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'src/clock.cts', violationFixture: 'src/clock.cts' },
|
||||
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'tests/_ff_lint_clean.cjs', violationFixture: 'tests/_ff_lint_clean.cjs' },
|
||||
process.cwd(),
|
||||
);
|
||||
assert.equal(proof.provenFailFirst, false,
|
||||
@@ -1023,7 +1023,7 @@ describe('prohibition-enforcement defaultProveFailFirst REAL prover (#1279)', ()
|
||||
test('lint-rule: no violationFixture -> not proven (FF-05 fail-closed)', () => {
|
||||
const enforce = require(ENFORCEMENT_LIB);
|
||||
const proof = enforce.defaultProveFailFirst(
|
||||
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'src/clock.cts' }, // no violationFixture
|
||||
{ kind: 'lint-rule', rule: 'local/no-source-grep', target: 'tests/_ff_lint_clean.cjs' }, // no violationFixture
|
||||
process.cwd(),
|
||||
);
|
||||
assert.equal(proof.provenFailFirst, false, 'no violationFixture -> cannot prove -> hard-gate');
|
||||
|
||||
@@ -77,6 +77,19 @@ describe('roadmap-parser: stripShippedMilestones', () => {
|
||||
assert.ok(!result.includes('closed content'), 'content removed');
|
||||
assert.ok(result.includes('after'), 'after content preserved');
|
||||
});
|
||||
|
||||
test('#557: preserves an active <details open> block while stripping shipped bare <details>', () => {
|
||||
// <details open> marks the ACTIVE milestone (roadmap.analyze must still see its
|
||||
// phases); only closed/shipped bare <details> blocks are stripped. Regression for
|
||||
// #557, which the #2128 shared-seam migration briefly reintroduced via the seam's
|
||||
// attribute-tolerance — the details strip is now attr-INTOLERANT to keep #557 fixed.
|
||||
const input = '<details>\nshipped phase\n</details>\n<details open>\n- [ ] **Phase 9: Active**\n</details>\nafter';
|
||||
const result = stripShippedMilestones(input);
|
||||
assert.ok(!result.includes('shipped phase'), 'shipped bare <details> stripped');
|
||||
assert.ok(result.includes('<details open>'), 'active <details open> tag preserved');
|
||||
assert.ok(result.includes('Phase 9: Active'), 'active-milestone phases preserved');
|
||||
assert.ok(result.includes('after'), 'trailing content preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── extractCurrentMilestone ──────────────────────────────────────────────────
|
||||
|
||||
@@ -1918,11 +1918,13 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
|
||||
assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup');
|
||||
});
|
||||
|
||||
test('does NOT cross-match: querying 42 must not match ### Phase PROJ-42:', () => {
|
||||
// Counter-test: if the regex erroneously matches both forms in both
|
||||
// directions, this catches it. `42` must only match `Phase 42:` — not
|
||||
// `Phase PROJ-42:` — otherwise integer phase lookups silently steal
|
||||
// matches from prefixed siblings.
|
||||
test('bare numeric prefers a bare sibling over a prefixed one (#3599 anti-steal, updated for #2114)', () => {
|
||||
// #3599's real guard is anti-STEALING: when BOTH a bare `Phase 42:` and a
|
||||
// distinct prefixed `Phase PROJ-42:` exist, a bare `42` query must resolve
|
||||
// the BARE one — the numeric source is tried before the prefix-tolerant
|
||||
// fallback, so a bare query never steals a distinct prefixed sibling.
|
||||
// (Since #2114/#2121, a bare query DOES resolve a *drifted-only* prefixed
|
||||
// heading when no bare sibling exists — see the bug #2114 block below.)
|
||||
writeState(tmpDir, 'v1.0.0');
|
||||
writeRoadmap(
|
||||
tmpDir,
|
||||
@@ -1931,8 +1933,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
|
||||
'',
|
||||
'## Current Milestone: v1.0.0 - Test',
|
||||
'',
|
||||
'### Phase PROJ-42: Should not be returned for `42`',
|
||||
'**Goal:** Counter-test',
|
||||
'### Phase 42: Bare',
|
||||
'**Goal:** Canonical bare heading',
|
||||
'',
|
||||
'### Phase PROJ-42: Prefixed',
|
||||
'**Goal:** Distinct prefixed sibling',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
@@ -1940,10 +1945,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
|
||||
const result = runGsdTools('roadmap get-phase 42 --json', tmpDir);
|
||||
assert.ok(result.success);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
|
||||
assert.strictEqual(
|
||||
payload.found,
|
||||
false,
|
||||
`bare numeric '42' must not match 'Phase PROJ-42:'; got ${result.output}`,
|
||||
payload.phase_name,
|
||||
'Bare',
|
||||
`bare '42' must resolve the bare 'Phase 42:', not steal 'Phase PROJ-42:'; got ${result.output}`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2071,6 +2077,85 @@ function makePlanProject(files = {}) {
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bare number', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => { tmpDir = createTempProject('bug-2114-'); });
|
||||
afterEach(() => { cleanup(tmpDir); });
|
||||
|
||||
test('bare-number query resolves a drifted project-code-prefixed heading', () => {
|
||||
// Before the fix, bare `29` did NOT match `### Phase AB-29:` from the CLI
|
||||
// (2-source lookup), even though getRoadmapPhaseInternal (init.phase-op) did
|
||||
// — the #2114 divergence. Now all three resolvers share the 3-source list.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'---\nmilestone: v1.0.0\n---\n# State\n\n**Status:** In progress\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'## Current Milestone: v1.0.0 - Test',
|
||||
'',
|
||||
'### Phase 30: Plain',
|
||||
'**Goal:** Canonical bare heading',
|
||||
'',
|
||||
'### Phase AB-29: Prefixed',
|
||||
'**Goal:** Drifted prefixed heading',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const resultAB29 = runGsdTools('roadmap get-phase 29 --json', tmpDir);
|
||||
assert.ok(resultAB29.success, `command failed: ${resultAB29.error || resultAB29.output}`);
|
||||
const payloadAB29 = JSON.parse(resultAB29.output);
|
||||
assert.strictEqual(payloadAB29.found, true, `expected found=true for drifted AB-29, got: ${resultAB29.output}`);
|
||||
assert.strictEqual(payloadAB29.phase_name, 'Prefixed');
|
||||
assert.strictEqual(payloadAB29.goal, 'Drifted prefixed heading');
|
||||
|
||||
// The canonical bare heading still resolves.
|
||||
const result30 = runGsdTools('roadmap get-phase 30 --json', tmpDir);
|
||||
assert.ok(result30.success);
|
||||
const payload30 = JSON.parse(result30.output);
|
||||
assert.strictEqual(payload30.found, true);
|
||||
assert.strictEqual(payload30.phase_name, 'Plain');
|
||||
});
|
||||
|
||||
test('project-code-prefixed checklist-only entry surfaces malformed_roadmap for both query forms', () => {
|
||||
// #2121/#2114 route all three resolvers through the shared 3-source lookup. A
|
||||
// `**Phase PROJ-42:**` summary line with no matching `### Phase PROJ-42:` detail heading
|
||||
// is a malformed ROADMAP. Before the consolidation this project-code-prefixed checklist
|
||||
// was reported as a silent `{found:false}` for BOTH query forms — the prefixed pass
|
||||
// discarded its malformed candidate, and the bare pass could not match the `PROJ-` prefix
|
||||
// at all. The unified lookup newly surfaces the malformed_roadmap diagnostic for both, so
|
||||
// this test fails on the prior silent-empty behavior for the prefixed AND the bare form.
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap v1.0',
|
||||
'',
|
||||
'## Phases',
|
||||
'',
|
||||
'- [ ] **Phase PROJ-42: Checklist only, no header**',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const prefixed = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
|
||||
assert.ok(prefixed.success, `command failed: ${prefixed.error || prefixed.output}`);
|
||||
const pPayload = JSON.parse(prefixed.output);
|
||||
assert.strictEqual(pPayload.found, false, 'malformed roadmap: phase must not be found');
|
||||
assert.strictEqual(pPayload.error, 'malformed_roadmap', 'prefixed query must surface malformed_roadmap');
|
||||
assert.ok(pPayload.message.includes('missing'), 'message must explain the missing detail section');
|
||||
|
||||
// Parity: the bare numeric form yields the same diagnostic against the same fixture.
|
||||
const bare = runGsdTools('roadmap get-phase 42 --json', tmpDir);
|
||||
assert.ok(bare.success, `command failed: ${bare.error || bare.output}`);
|
||||
assert.strictEqual(JSON.parse(bare.output).error, 'malformed_roadmap', 'bare query surfaces the same diagnostic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('roadmap annotate-dependencies', () => {
|
||||
let tmpDir;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user