* test(#2871): failing-first suite for trigger-surface resolution 23 tests over the 50-test-matrix rows. RED by construction: resolveTriggerSurface and DEFAULT_TRIGGER_PRECEDENCE do not exist yet, and the validator silently ignores triggerPrecedence today. Written in the per-runtime describe idiom the other four runtime-artifact-layout suites use, not a table. The rows that carry the weight: windsurf must NOT report a shadow it does not have, since its global scope emits only agents and agents are not trigger-bearing; agents and kimi-agents must be absent from the output for every runtime; and reordering a runtime's triggerPrecedence must flip the winner, which is the only assertion that proves the axis is read rather than decorative. Stems are injected, never scanned, so the surface is assertable with no filesystem. * feat(#2871): resolve triggers and host precedence, not just placement resolveTriggerSurface(runtime, scopes) returns every /gsd-<name> trigger a runtime emits, with the scope and kind that produced it, whether the host registers it directly or only through a router, and which artifact shadows it. resolveRuntimeArtifactLayout is untouched -- its 7 callers need placement only and the issue requires them unchanged. AGENTS ARE NOT TRIGGER-BEARING, and ADR-2866 said they were. The host-integration matrix models command and dispatch as separate interface points: an agent is invoked through the Agent tool's subagent_type, not by typing a slash trigger, and _copyStaged never applies the kind prefix to an agents entry. So agents and kimi-agents are excluded from the surface entirely, and this commit amends ADR-2866 with a dated correction. #2218's conclusion is unchanged -- the collision is strictly commands-vs-skills, and claude's local /gsd-* trigger surface is still fully shadowed -- but the ADR implied the local agents surface was lost too, and it is not. That correction is what makes windsurf come out right. Its global scope emits only agents, so it has no global trigger and its local commands are unshadowed. Model agents as trigger-bearing and windsurf falsely reports a full shadow. The triggerPrecedence axis lands on all 19 descriptors as an ordered kind list, one value with one owner, rather than a numeric rank spread across N kind entries with nothing keeping them consistent. Validation uses a required-with-default shape that has no precedent in this validator -- every existing axis is hard-required -- so a third-party capability.json omitting the field still validates, which is what ADR-894's additive-only contract promises. Winner resolution reads Phase 1's scope rank first, then the kind ordering. A test reorders the axis and asserts the winner flips, since an axis that is added, validated and never consulted would pass every other assertion. shadowedBy ships unread. Phase 4 (#2873) is its first consumer, per this issue's out-of-scope note. Verified via the remote runner. * fix(#2871): single-source namespacedByDir and close two test gaps Four findings from the isolated adversarial review. The namespacedByDir rule had reached three copies -- install-engine, surface, and the new trigger resolver -- one of which carried a hand-written keep-in-sync comment and no assertion. That is this repo's generative-fix-divergence class. Extracted to one exported predicate all three now call. Verified by diverging one copy deliberately: the existing #816 parity test failed, and passes again on revert. The omission test was vacuous. Row 16 asserted that a descriptor without triggerPrecedence still validates, but built its fixture from claude's shipped descriptor -- which this PR had just added the axis to. It now clones and deletes the key, following the shippedDescriptorWithout pattern, and asserts both that validation passes and that the resolver still picks the right winner from the default. The second half is what makes it prove anything. resolveTriggerSurface silently dropped an unrecognized scope while every sibling in this epic throws. Two phases of one epic should not disagree about whether an invalid scope is an error, so it now rejects through the same shared validator; an empty scope list still returns empty rather than throwing. The ADR amendment had been spliced into the middle of the References list, orphaning its last bullet. Moved to the top, after the header block, which is where ADR-3660 and ADR-1016 both put dated amendments. No lint checks markdown structure, so this was green while malformed. * fix(#2871): single-source the command filename composition too The earlier fix shared the namespacedByDir boolean but left the filename composition around it written twice -- once in _copyStaged as what actually gets written, once in resolveTriggerSurface as what gets predicted. The predictor could go stale silently. One exported helper now composes it for both. The entry.name asymmetry that looked like it would block extraction does not: entry.name is filtered to end in .md and stem is entry.name minus those three characters, so the two branches are the same string by construction. Divergence proven to fail: injecting a marker into the helper broke the trigger-surface suite; reverting restored 25/25. The four sibling layout suites hold at 227 unchanged. * docs(#2871): correct the ADR timing notes that this phase makes stale The Amended by back-links on ADR-3660 and ADR-1016 were written in Phase 0, when the widenings they describe had not shipped. Each carried a forward-looking clause -- "the module changes at Phase 2, not before, until then this module resolves placement only" -- which becomes false the moment this PR merges. ADR-2866's own Amends header and its reciprocal-notes section carried the same tense. All four now describe what shipped. This is a tense and status correction on Accepted ADRs, not a change to any decision. Worth stating because it is the failure mode this epic keeps meeting: gen-adr-index.cjs tracks only Supersedes and Subsumes, so nothing in CI would have caught either the missing back-link in Phase 0 or these stale clauses now. They stay correct only because someone checks. * chore(#2871): backfill changeset PR number --------- Co-authored-by: sim <sim@local>
495 lines
25 KiB
JavaScript
495 lines
25 KiB
JavaScript
'use strict';
|
|
/**
|
|
* Failing-first suite for `resolveTriggerSurface` (#2871 Phase 2).
|
|
*
|
|
* `resolveTriggerSurface` does NOT exist yet in src/runtime-artifact-layout.cts
|
|
* (nor in its built gsd-core/bin/lib/runtime-artifact-layout.cjs counterpart).
|
|
* Every test below is expected to FAIL until it lands — see
|
|
* .gsd/phase/feat-2871-trigger-resolution/50-test-matrix.md (rows 1-23) and
|
|
* .gsd/phase/feat-2871-trigger-resolution/40-design.md for the contract this
|
|
* suite locks.
|
|
*
|
|
* Idiom: per-runtime `describe` + explicit field assertions, matching the
|
|
* other four runtime-artifact-layout*.test.cjs files. No table-driven format.
|
|
*
|
|
* ── Stem-injection shape (design call made by this suite) ──────────────────
|
|
* `resolveTriggerSurface` is pure — no filesystem, no `configDir`. Stems are
|
|
* injected via `opts`:
|
|
*
|
|
* opts = {
|
|
* stems: string[], // source command/skill stems
|
|
* // present for this call, shared
|
|
* // across every trigger-bearing
|
|
* // kind entry (mirrors ResolvedProfile's
|
|
* // flat stem membership at staging
|
|
* // time — see install-profiles.cts).
|
|
* routerStems?: string[], // subset of `stems` that are
|
|
* // namespace routers (nested-router
|
|
* // runtimes only — #69).
|
|
* childToRouters?: Record<string, string[]>, // concrete stem -> owning
|
|
* // router stem(s); mirrors
|
|
* // buildNamespaceBundleMap's
|
|
* // childToRouters shape.
|
|
* registry?: { runtimes: {...} }, // full registry override —
|
|
* // the SAME seam
|
|
* // resolveRuntimeArtifactLayoutFromRegistry
|
|
* // already exposes in this file. Lets a
|
|
* // synthetic descriptor (row 12's
|
|
* // namespacedByDir fixture, row 22's
|
|
* // reordered triggerPrecedence) be
|
|
* // exercised without touching the real
|
|
* // capability-registry.
|
|
* }
|
|
*
|
|
* destPath (also a design call, since no in-tree fixture exercises row 12
|
|
* today): `${destSubpath}/${prefix}${stem}` for skills (the skill directory);
|
|
* `${destSubpath}/${prefix}${stem}.md` for a flat commands entry; and, when
|
|
* `_copyStaged`'s namespacedByDir branch applies (destSubpath's basename ===
|
|
* prefix minus its trailing '-'), `${destSubpath}/${stem}.md` — bare, no
|
|
* prefix on the filename, matching _copyStaged's actual write (row 12). The
|
|
* `trigger` string itself is ALWAYS `${prefix}${stem}` regardless of branch —
|
|
* it is what the user types, not a filesystem detail.
|
|
*/
|
|
|
|
const { test, describe } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const runtimeArtifactLayout = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
|
const { resolveTriggerSurface } = runtimeArtifactLayout;
|
|
const capabilityRegistry = require('../gsd-core/bin/lib/capability-registry.cjs');
|
|
const capValidator = require('../gsd-core/bin/lib/capability-validator.cjs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
|
|
/** A real shipped capability.json, parsed fresh (fixture-provenance rule #2371 —
|
|
* see tests/effort-surface-axis.test.cjs's shippedDescriptorWithout for the
|
|
* precedent this mirrors: a hand-built descriptor only ever encodes the
|
|
* author's mental model, which is how required-axis defects reach the runner). */
|
|
function shippedCap(runtimeId) {
|
|
return JSON.parse(
|
|
fs.readFileSync(path.join(REPO_ROOT, 'capabilities', runtimeId, 'capability.json'), 'utf8'),
|
|
);
|
|
}
|
|
|
|
/** A real shipped capability.json with one `runtime` field genuinely deleted —
|
|
* mirrors tests/effort-surface-axis.test.cjs's shippedDescriptorWithout idiom
|
|
* (fixture-provenance rule #2371), adapted for a top-level `runtime.<field>`
|
|
* rather than a nested `runtime.hostIntegration.<axis>`. */
|
|
function shippedCapWithout(runtimeId, field) {
|
|
const cap = shippedCap(runtimeId);
|
|
delete cap.runtime[field];
|
|
return cap;
|
|
}
|
|
|
|
const VALID_KINDS = new Set(['commands', 'skills']);
|
|
const VALID_SCOPES = new Set(['global', 'local']);
|
|
const VALID_REGISTRATIONS = new Set(['direct', 'via-router']);
|
|
|
|
/** Structural + referential shape-check for a resolveTriggerSurface() result. */
|
|
function assertValidSurfaceArray(surfaces, label) {
|
|
assert.ok(Array.isArray(surfaces), `${label}: must return an array`);
|
|
for (const s of surfaces) {
|
|
assert.strictEqual(typeof s.trigger, 'string', `${label}: trigger must be a string`);
|
|
assert.ok(s.trigger.length > 0, `${label}: trigger must be non-empty`);
|
|
assert.ok(VALID_KINDS.has(s.kind), `${label}: kind must be commands|skills, got ${s.kind}`);
|
|
assert.ok(VALID_SCOPES.has(s.scope), `${label}: scope must be global|local, got ${s.scope}`);
|
|
assert.strictEqual(typeof s.destPath, 'string', `${label}: destPath must be a string`);
|
|
assert.ok(s.destPath.length > 0, `${label}: destPath must be non-empty`);
|
|
assert.ok(VALID_REGISTRATIONS.has(s.registration), `${label}: registration must be direct|via-router, got ${s.registration}`);
|
|
if (s.registration === 'via-router') {
|
|
assert.strictEqual(typeof s.routerTrigger, 'string', `${label}: via-router entries must name routerTrigger`);
|
|
} else {
|
|
assert.strictEqual(s.routerTrigger, null, `${label}: direct entries must have routerTrigger === null`);
|
|
}
|
|
if (s.shadowedBy !== null) {
|
|
assert.ok(VALID_KINDS.has(s.shadowedBy.kind), `${label}: shadowedBy.kind invalid`);
|
|
assert.ok(VALID_SCOPES.has(s.shadowedBy.scope), `${label}: shadowedBy.scope invalid`);
|
|
const sibling = surfaces.find(
|
|
(o) => o !== s && o.trigger === s.trigger && o.kind === s.shadowedBy.kind && o.scope === s.shadowedBy.scope,
|
|
);
|
|
assert.ok(sibling, `${label}: shadowedBy must name a real sibling entry for trigger ${s.trigger}`);
|
|
assert.strictEqual(sibling.shadowedBy, null, `${label}: the named shadowedBy sibling must itself be a winner`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Behavior table (matrix rows 1-15) ──────────────────────────────────────
|
|
|
|
describe('resolveTriggerSurface — claude', () => {
|
|
test('claude reports every local command trigger as shadowed by the global skill', () => {
|
|
const stems = ['plan-phase', 'help'];
|
|
const result = resolveTriggerSurface('claude', ['global', 'local'], { stems });
|
|
assert.strictEqual(result.length, stems.length * 2);
|
|
for (const stem of stems) {
|
|
const trigger = `gsd-${stem}`;
|
|
const winner = result.find((s) => s.trigger === trigger && s.kind === 'skills' && s.scope === 'global');
|
|
const loser = result.find((s) => s.trigger === trigger && s.kind === 'commands' && s.scope === 'local');
|
|
assert.ok(winner, `missing global skills entry for ${trigger}`);
|
|
assert.ok(loser, `missing local commands entry for ${trigger}`);
|
|
assert.strictEqual(winner.shadowedBy, null, `${trigger}: global skills entry must be unshadowed`);
|
|
assert.deepStrictEqual(loser.shadowedBy, { kind: 'skills', scope: 'global' }, `${trigger}: local commands entry must name the global skill as its shadower`);
|
|
assert.strictEqual(winner.destPath, `skills/${trigger}`);
|
|
assert.strictEqual(loser.destPath, `commands/${trigger}.md`);
|
|
assert.strictEqual(winner.registration, 'direct');
|
|
assert.strictEqual(loser.registration, 'direct');
|
|
assert.strictEqual(winner.routerTrigger, null);
|
|
assert.strictEqual(loser.routerTrigger, null);
|
|
}
|
|
});
|
|
|
|
test('claude global alone shadows nothing', () => {
|
|
const stems = ['plan-phase', 'help'];
|
|
const result = resolveTriggerSurface('claude', ['global'], { stems });
|
|
assert.strictEqual(result.length, stems.length);
|
|
for (const s of result) {
|
|
assert.strictEqual(s.kind, 'skills');
|
|
assert.strictEqual(s.scope, 'global');
|
|
assert.strictEqual(s.shadowedBy, null);
|
|
}
|
|
});
|
|
|
|
test('claude local alone shadows nothing', () => {
|
|
// #2218's healthy state: local reachable when nothing shadows it.
|
|
const stems = ['plan-phase', 'help'];
|
|
const result = resolveTriggerSurface('claude', ['local'], { stems });
|
|
assert.strictEqual(result.length, stems.length);
|
|
for (const s of result) {
|
|
assert.strictEqual(s.kind, 'commands');
|
|
assert.strictEqual(s.scope, 'local');
|
|
assert.strictEqual(s.shadowedBy, null);
|
|
}
|
|
});
|
|
|
|
test('no scopes yields no triggers', () => {
|
|
assert.deepStrictEqual(resolveTriggerSurface('claude', [], { stems: ['plan-phase'] }), []);
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — both-scope runtimes', () => {
|
|
test('both-scope runtimes report same-kind shadowing', () => {
|
|
// The 12 both-scope trigger-bearing runtimes excluding claude (claude has
|
|
// its own dedicated rows above). windsurf is excluded too — its global
|
|
// scope emits agents only, so it is NOT a both-scope trigger-bearing
|
|
// runtime (see the dedicated windsurf test below).
|
|
const BOTH_SCOPE_RUNTIMES = [
|
|
'antigravity', 'augment', 'codebuddy', 'codex', 'copilot',
|
|
'cursor', 'hermes', 'kilo', 'opencode', 'qwen', 'trae', 'zcode',
|
|
];
|
|
for (const runtime of BOTH_SCOPE_RUNTIMES) {
|
|
const result = resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] });
|
|
const group = result.filter((s) => s.trigger === 'gsd-plan-phase');
|
|
assert.ok(group.length >= 2, `${runtime}: expected at least 2 candidate entries, got ${group.length}`);
|
|
const winners = group.filter((s) => s.shadowedBy === null);
|
|
assert.strictEqual(winners.length, 1, `${runtime}: expected exactly one unshadowed winner, got ${winners.length}`);
|
|
assert.strictEqual(winners[0].scope, 'global', `${runtime}: the winner must be the global entry`);
|
|
for (const s of group) {
|
|
if (s === winners[0]) continue;
|
|
assert.deepStrictEqual(
|
|
s.shadowedBy, { kind: winners[0].kind, scope: 'global' },
|
|
`${runtime}: every non-winner must name the global winner's kind`,
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — global-only runtimes', () => {
|
|
test('global-only runtimes report unshadowed triggers', () => {
|
|
// cline/kimi/kimi-code declare local: [] — no special-casing, it falls
|
|
// out of an empty local kind set even when 'local' is in `scopes`.
|
|
for (const runtime of ['cline', 'kimi', 'kimi-code']) {
|
|
const result = resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] });
|
|
assert.ok(result.length > 0, `${runtime}: expected at least one trigger`);
|
|
for (const s of result) {
|
|
assert.strictEqual(s.scope, 'global', `${runtime}: unexpected non-global entry`);
|
|
assert.strictEqual(s.shadowedBy, null, `${runtime}: global-only entry must be unshadowed`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — no-artifact-surface runtimes', () => {
|
|
test('runtimes with no artifact surface yield no triggers', () => {
|
|
for (const runtime of ['pi', 'vscode']) {
|
|
assert.deepStrictEqual(
|
|
resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] }), [],
|
|
`${runtime}: expected no triggers`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — windsurf', () => {
|
|
test('windsurf does not report a shadow it does not have', () => {
|
|
// Global emits agents only (excluded) -> no global trigger. Local commands
|
|
// (workflows) triggers must therefore be UNSHADOWED, not falsely reported
|
|
// as shadowed by a global counterpart that doesn't exist.
|
|
const result = resolveTriggerSurface('windsurf', ['global', 'local'], { stems: ['plan-phase'] });
|
|
assert.strictEqual(result.length, 1, `expected exactly one trigger, got ${JSON.stringify(result)}`);
|
|
const [entry] = result;
|
|
assert.strictEqual(entry.trigger, 'gsd-plan-phase');
|
|
assert.strictEqual(entry.kind, 'commands');
|
|
assert.strictEqual(entry.scope, 'local');
|
|
assert.strictEqual(entry.shadowedBy, null, 'windsurf must not report a shadow it does not have');
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — agents exclusion', () => {
|
|
test('agents are excluded from the trigger surface', () => {
|
|
// claude local emits both commands and agents from the same source tree.
|
|
const result = resolveTriggerSurface('claude', ['local'], { stems: ['plan-phase'] });
|
|
assert.ok(!result.some((s) => s.kind === 'agents'), 'agents must never appear in the trigger surface');
|
|
assert.ok(result.some((s) => s.kind === 'commands'), 'commands must still be present');
|
|
});
|
|
|
|
test('kimi-agents are excluded from the trigger surface', () => {
|
|
const result = resolveTriggerSurface('kimi', ['global'], { stems: ['plan-phase'] });
|
|
assert.ok(!result.some((s) => s.kind === 'kimi-agents'), 'kimi-agents must never appear in the trigger surface');
|
|
assert.strictEqual(result.length, 1);
|
|
assert.strictEqual(result[0].kind, 'skills');
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — nested-router runtimes (#69)', () => {
|
|
test('nested runtimes distinguish direct from router-reachable registration', () => {
|
|
const NESTED_ROUTER_RUNTIMES = ['cline', 'qwen', 'hermes', 'augment', 'trae'];
|
|
const opts = {
|
|
stems: ['ns-plan', 'plan-phase'],
|
|
routerStems: ['ns-plan'],
|
|
childToRouters: { 'plan-phase': ['ns-plan'] },
|
|
};
|
|
for (const runtime of NESTED_ROUTER_RUNTIMES) {
|
|
const result = resolveTriggerSurface(runtime, ['global'], opts);
|
|
const skillsEntries = result.filter((s) => s.kind === 'skills');
|
|
const router = skillsEntries.find((s) => s.trigger === 'gsd-ns-plan');
|
|
const child = skillsEntries.find((s) => s.trigger === 'gsd-plan-phase');
|
|
assert.ok(router, `${runtime}: missing router skills entry`);
|
|
assert.ok(child, `${runtime}: missing child skills entry`);
|
|
assert.strictEqual(router.registration, 'direct', `${runtime}: the router itself registers direct`);
|
|
assert.strictEqual(router.routerTrigger, null, `${runtime}: a router has no routerTrigger of its own`);
|
|
assert.strictEqual(child.registration, 'via-router', `${runtime}: the child is only reachable via its router`);
|
|
assert.strictEqual(child.routerTrigger, 'gsd-ns-plan', `${runtime}: the child must name its owning router`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — namespacedByDir (hostile fixture, #row 12)', () => {
|
|
test('trigger honors the namespaced-by-directory layout', () => {
|
|
// No in-tree descriptor trips _copyStaged's namespacedByDir branch today
|
|
// (destSubpath's basename === prefix minus its trailing '-'). Synthetic
|
|
// fixture via the registry-override seam, injected the same way
|
|
// resolveRuntimeArtifactLayoutFromRegistry already accepts a registry.
|
|
const registry = {
|
|
runtimes: {
|
|
'fixture-namespaced': {
|
|
runtime: {
|
|
artifactLayout: {
|
|
global: [
|
|
{ kind: 'commands', destSubpath: 'commands/gsd', prefix: 'gsd-', nesting: 'flat', recursive: false, converter: null },
|
|
],
|
|
local: [],
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = resolveTriggerSurface('fixture-namespaced', ['global'], { stems: ['plan-phase'], registry });
|
|
assert.strictEqual(result.length, 1);
|
|
const [entry] = result;
|
|
// The trigger (what the user types) is unaffected by the destPath branch.
|
|
assert.strictEqual(entry.trigger, 'gsd-plan-phase');
|
|
// destPath must go through the namespacedByDir branch: bare filename, no
|
|
// double-applied prefix — NOT the naive `${destSubpath}/${prefix}${stem}.md`.
|
|
assert.strictEqual(entry.destPath, 'commands/gsd/plan-phase.md');
|
|
assert.notStrictEqual(entry.destPath, 'commands/gsd/gsd-plan-phase.md');
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — unknown runtime', () => {
|
|
test('rejects an unknown runtime with the established error contract', () => {
|
|
assert.throws(
|
|
() => resolveTriggerSurface('zorptron', ['global'], { stems: ['plan-phase'] }),
|
|
(err) => {
|
|
assert.ok(err instanceof TypeError);
|
|
assert.ok(err.message.includes('zorptron'), 'error message must contain the runtime name');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — unrecognized scope', () => {
|
|
test('rejects an unrecognized scope entry with the shared validateScopeId contract', () => {
|
|
// Sibling parity (#2871 Phase 2 review finding): scopeRank/resolveScope/
|
|
// isGlobalScope (install-scope.cts) already throw TypeError via
|
|
// validateScopeId for a bad scope id. resolveTriggerSurface must not
|
|
// fail open on the same input.
|
|
assert.throws(
|
|
() => resolveTriggerSurface('claude', ['bogus-scope'], { stems: ['plan-phase'] }),
|
|
(err) => {
|
|
assert.ok(err instanceof TypeError);
|
|
assert.ok(err.message.includes('bogus-scope'), 'error message must name the offending scope');
|
|
return true;
|
|
},
|
|
);
|
|
});
|
|
|
|
test('an empty scopes array still returns [] rather than throwing', () => {
|
|
assert.deepStrictEqual(resolveTriggerSurface('claude', [], { stems: ['plan-phase'] }), []);
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — purity', () => {
|
|
test('is pure and does not mutate its input', () => {
|
|
const scopes = ['global', 'local'];
|
|
const opts = { stems: ['plan-phase', 'help'] };
|
|
const scopesSnapshot = JSON.stringify(scopes);
|
|
const optsSnapshot = JSON.stringify(opts);
|
|
|
|
const first = resolveTriggerSurface('claude', scopes, opts);
|
|
assert.strictEqual(JSON.stringify(scopes), scopesSnapshot, 'scopes array must not be mutated');
|
|
assert.strictEqual(JSON.stringify(opts), optsSnapshot, 'opts object must not be mutated');
|
|
|
|
const pristine = JSON.parse(JSON.stringify(first));
|
|
// A caller mutating the returned array/objects must not corrupt a later call.
|
|
first[0].trigger = 'HACKED';
|
|
first.push({ trigger: 'INJECTED' });
|
|
|
|
const second = resolveTriggerSurface('claude', scopes, opts);
|
|
assert.deepStrictEqual(second, pristine, 'a second call must be unaffected by mutation of the first result');
|
|
});
|
|
});
|
|
|
|
describe('resolveTriggerSurface — exhaustive sweep', () => {
|
|
test('resolves for every runtime at every scope combination', () => {
|
|
const runtimes = Object.keys(capabilityRegistry.runtimes);
|
|
const scopeSubsets = [[], ['global'], ['local'], ['global', 'local']];
|
|
for (const runtime of runtimes) {
|
|
for (const scopes of scopeSubsets) {
|
|
let result;
|
|
assert.doesNotThrow(() => {
|
|
result = resolveTriggerSurface(runtime, scopes, { stems: ['plan-phase', 'help'] });
|
|
}, `${runtime} @ [${scopes.join(',')}] must not throw`);
|
|
assertValidSurfaceArray(result, `${runtime} @ [${scopes.join(',')}]`);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Precedence axis rows (matrix rows 16-23) ───────────────────────────────
|
|
|
|
describe('triggerPrecedence axis — validator', () => {
|
|
test('a descriptor without the axis remains valid', () => {
|
|
// claude's shipped descriptor now DECLARES triggerPrecedence (this PR added
|
|
// it), so the fixture must genuinely omit the key to exercise the omission
|
|
// path rather than merely echoing what is already on disk.
|
|
const cap = shippedCapWithout('claude', 'triggerPrecedence');
|
|
assert.ok(!('triggerPrecedence' in cap.runtime), 'fixture must not carry triggerPrecedence');
|
|
assert.deepStrictEqual(capValidator.validateRuntimeBody(cap), []);
|
|
assert.ok(Array.isArray(capValidator.DEFAULT_TRIGGER_PRECEDENCE), 'DEFAULT_TRIGGER_PRECEDENCE must be exported');
|
|
assert.deepStrictEqual(capValidator.DEFAULT_TRIGGER_PRECEDENCE, ['skills', 'commands']);
|
|
|
|
// Non-vacuous half: resolution must still pick the DEFAULT_TRIGGER_PRECEDENCE
|
|
// winner (skills over commands) when the axis is absent from the registry
|
|
// entry actually consulted by resolveTriggerSurface — same registry-override
|
|
// seam the 'reordering the axis changes which artifact wins' test below uses.
|
|
const registry = { runtimes: { claude: cap } };
|
|
const stems = ['plan-phase'];
|
|
const result = resolveTriggerSurface('claude', ['global', 'local'], { stems, registry });
|
|
const trigger = 'gsd-plan-phase';
|
|
const winner = result.find((s) => s.trigger === trigger && s.shadowedBy === null);
|
|
const loser = result.find((s) => s.trigger === trigger && s.shadowedBy !== null);
|
|
assert.ok(winner, `missing unshadowed winner for ${trigger}`);
|
|
assert.strictEqual(winner.kind, 'skills', 'default precedence (skills > commands) must pick skills');
|
|
assert.strictEqual(winner.scope, 'global');
|
|
assert.ok(loser, `missing shadowed loser for ${trigger}`);
|
|
assert.deepStrictEqual(loser.shadowedBy, { kind: 'skills', scope: 'global' });
|
|
});
|
|
|
|
test('every shipped descriptor validates with the new axis', () => {
|
|
assert.ok(Array.isArray(capValidator.DEFAULT_TRIGGER_PRECEDENCE), 'DEFAULT_TRIGGER_PRECEDENCE must be exported');
|
|
for (const runtime of Object.keys(capabilityRegistry.runtimes)) {
|
|
const cap = shippedCap(runtime);
|
|
const errors = capValidator.validateRuntimeBody(cap);
|
|
assert.deepStrictEqual(errors, [], `${runtime}: expected clean validation, got ${JSON.stringify(errors)}`);
|
|
}
|
|
});
|
|
|
|
test('rejects an empty precedence list', () => {
|
|
const cap = shippedCap('claude');
|
|
cap.runtime.triggerPrecedence = [];
|
|
const errors = capValidator.validateRuntimeBody(cap);
|
|
assert.ok(
|
|
errors.some((e) => String(e).includes('triggerPrecedence')),
|
|
`expected a triggerPrecedence error, got ${JSON.stringify(errors)}`,
|
|
);
|
|
});
|
|
|
|
test('rejects an unknown kind in the precedence list', () => {
|
|
const cap = shippedCap('claude');
|
|
cap.runtime.triggerPrecedence = ['skills', 'bogus'];
|
|
const errors = capValidator.validateRuntimeBody(cap);
|
|
assert.ok(
|
|
errors.some((e) => String(e).includes('triggerPrecedence') && String(e).includes('bogus')),
|
|
`expected the offending kind named in the error, got ${JSON.stringify(errors)}`,
|
|
);
|
|
});
|
|
|
|
test('rejects a duplicate kind in the precedence list', () => {
|
|
const cap = shippedCap('claude');
|
|
cap.runtime.triggerPrecedence = ['skills', 'skills'];
|
|
const errors = capValidator.validateRuntimeBody(cap);
|
|
assert.ok(
|
|
errors.some((e) => String(e).includes('triggerPrecedence')),
|
|
`expected a triggerPrecedence error, got ${JSON.stringify(errors)}`,
|
|
);
|
|
});
|
|
|
|
test('rejects a non-array precedence value', () => {
|
|
for (const bad of ['skills-then-commands', {}, null]) {
|
|
const cap = shippedCap('claude');
|
|
cap.runtime.triggerPrecedence = bad;
|
|
const errors = capValidator.validateRuntimeBody(cap);
|
|
assert.ok(
|
|
errors.some((e) => String(e).includes('triggerPrecedence')),
|
|
`bad value ${JSON.stringify(bad)}: expected a triggerPrecedence error, got ${JSON.stringify(errors)}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('triggerPrecedence axis — resolution', () => {
|
|
test('reordering the axis changes which artifact wins', () => {
|
|
// codebuddy declares BOTH commands and skills at the SAME scope from the
|
|
// same source stems — the one runtime shape where kind precedence (not
|
|
// scope rank) decides the winner.
|
|
const defaultResult = resolveTriggerSurface('codebuddy', ['global'], { stems: ['plan-phase'] });
|
|
const defaultGroup = defaultResult.filter((s) => s.trigger === 'gsd-plan-phase');
|
|
const defaultWinner = defaultGroup.find((s) => s.shadowedBy === null);
|
|
assert.ok(defaultWinner, 'expected a winner under the default precedence');
|
|
assert.strictEqual(defaultWinner.kind, 'skills', 'default precedence (skills > commands) must pick skills');
|
|
|
|
const overriddenCap = JSON.parse(JSON.stringify(capabilityRegistry.runtimes.codebuddy));
|
|
overriddenCap.runtime.triggerPrecedence = ['commands', 'skills'];
|
|
const overriddenRegistry = { runtimes: { codebuddy: overriddenCap } };
|
|
|
|
const overriddenResult = resolveTriggerSurface('codebuddy', ['global'], { stems: ['plan-phase'], registry: overriddenRegistry });
|
|
const overriddenGroup = overriddenResult.filter((s) => s.trigger === 'gsd-plan-phase');
|
|
const overriddenWinner = overriddenGroup.find((s) => s.shadowedBy === null);
|
|
assert.ok(overriddenWinner, 'expected a winner under the overridden precedence');
|
|
assert.strictEqual(overriddenWinner.kind, 'commands', 'reordered precedence (commands > skills) must flip the winner');
|
|
});
|
|
|
|
test('the default precedence matches what claude declares', () => {
|
|
assert.ok(Array.isArray(capValidator.DEFAULT_TRIGGER_PRECEDENCE), 'DEFAULT_TRIGGER_PRECEDENCE must be exported');
|
|
assert.deepStrictEqual(capValidator.DEFAULT_TRIGGER_PRECEDENCE, ['skills', 'commands']);
|
|
const claudeCap = shippedCap('claude');
|
|
assert.deepStrictEqual(
|
|
claudeCap.runtime.triggerPrecedence, capValidator.DEFAULT_TRIGGER_PRECEDENCE,
|
|
"claude's declared triggerPrecedence must equal the validator's default",
|
|
);
|
|
});
|
|
});
|