'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 msd-core/bin/lib/runtime-artifact-layout.cjs counterpart). * Every test below is expected to FAIL until it lands — see * .msd/phase/feat-2871-trigger-resolution/50-test-matrix.md (rows 1-23) and * .msd/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, // 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('../msd-core/bin/lib/runtime-artifact-layout.cjs'); const { resolveTriggerSurface } = runtimeArtifactLayout; const capabilityRegistry = require('../msd-core/bin/lib/capability-registry.cjs'); const capValidator = require('../msd-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.` * rather than a nested `runtime.hostIntegration.`. */ 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 = `msd-${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 both-scope trigger-bearing runtimes excluding claude (claude has // its own dedicated rows above). const BOTH_SCOPE_RUNTIMES = ['antigravity', 'codex', 'cursor', 'opencode', 'zcode']; for (const runtime of BOTH_SCOPE_RUNTIMES) { const result = resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] }); const group = result.filter((s) => s.trigger === 'msd-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 — no-artifact-surface runtimes', () => { test('runtimes with no artifact surface yield no triggers', () => { for (const runtime of ['vscode']) { assert.deepStrictEqual( resolveTriggerSurface(runtime, ['global', 'local'], { stems: ['plan-phase'] }), [], `${runtime}: expected no triggers`, ); } }); }); 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'); }); }); describe('resolveTriggerSurface — nested-router runtimes (#69)', () => { test('nested runtimes distinguish direct from router-reachable registration', () => { const NESTED_ROUTER_RUNTIMES = ['zcode']; 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 === 'msd-ns-plan'); const child = skillsEntries.find((s) => s.trigger === 'msd-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, 'msd-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/msd', prefix: 'msd-', 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, 'msd-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/msd/plan-phase.md'); assert.notStrictEqual(entry.destPath, 'commands/msd/msd-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 = 'msd-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', () => { // opencode 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('opencode', ['global'], { stems: ['plan-phase'] }); const defaultGroup = defaultResult.filter((s) => s.trigger === 'msd-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.opencode)); overriddenCap.runtime.triggerPrecedence = ['commands', 'skills']; const overriddenRegistry = { runtimes: { opencode: overriddenCap } }; const overriddenResult = resolveTriggerSurface('opencode', ['global'], { stems: ['plan-phase'], registry: overriddenRegistry }); const overriddenGroup = overriddenResult.filter((s) => s.trigger === 'msd-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", ); }); });