* test(#2874): add failing-first gate for the executed-plan return Four rows from the matrix's red-first order. E3 pins the one early return, for the opencode family, where a void-shaped hole would otherwise survive unnoticed. E13 sweeps every runtime in the registry - enumerated from the registry rather than hardcoded, so a runtime added later cannot slip past. F2 proves absence of real filesystem contact rather than merely that the happy path ran, which is the difference between a complete seam and a partial one. G1 and G3 are the additive guard and must be green before and after. G3 deliberately leaves the two existing adapter test doubles untouched: if this change required editing them it would not be additive, and the acceptance criterion would be unmet. No production code. All 19 runtimes install without throwing today, so E3 and E13 fail on the undefined comparison alone. Refs #2874 * feat(#2874): return the executed plan and route install IO through a seam installRuntimeArtifacts returned void, so its correctness was observable only by re-reading disk. It now returns what it executed - per kind, per scope - including on the combinedFamilyInstall path, which was the one early return where a void-shaped hole would have survived unnoticed. Failure still throws rather than becoming an ok:false return, so control flow is unchanged for both existing callers. A best-effort cleanup that fails is still swallowed, but is now visible in the returned value rather than silently absent. The fs seam is ambient rather than threaded. Explicit deps through install-profiles and the 3000-line conversion module was impractical; the tradeoff, the synchronous-only re-entrancy assumption, the restore guarantee and the partial-adapter fallback trap are all documented at the seam. findInstallSourceRoot and its sibling stay unrouted by design - they locate the package's own source, not the install destination. readCmdNames keeps a second implementation because the standalone CLI that owns the original cannot require the compiled adapter without a build-order dependency on its own output. A parity test fails if the two ever disagree. Refs #2874 * chore(#2874): gitignore the new build artifact install-fs-adapter.cjs is tsc output from src/install-fs-adapter.cts, not a tracked source file. It was added to eslint's ignore list but not to .gitignore, so it landed as a tracked file - the third time this step of the new-.cts ripple has been missed on this epic. Refs #2874 * fix(#2874): close two seam leaks and correct a false comment A correctness review found the seam still leaked in two places, both subtler than the three already closed. readGsdCommandNames was routed when it should not have been: it reads the package's own commands directory, which a destination-fake is never seeded with, so under a fake adapter it returned an empty or wrong roster instead of failing loudly. It now reads real fs, matching the precedent already documented for findInstallSourceRoot. cleanupStagedSkills ran raw rmSync from a process exit handler, which is real filesystem work deferred past the point where withInstallFs has restored - the one thing the synchronous-only contract exists to exclude. Staging now captures the adapter that created each directory and cleanup replays it, so a real install cleans up exactly as before and a fake-staged path never reaches the real filesystem. Also corrected a comment claiming the migration reads were an unrouted, untested residual gap. They are routed and exercised; a comment understating the seam is as corrosive as one overstating it in a module whose trust rests on being honestly documented. Refs #2874 * test(#2874): migrate the exemplar group and cover the matrix AC3's exemplar migration lands in place: the qwen install group now asserts skills and agents destinations from the returned plan in one deepStrictEqual instead of probing the filesystem for each. Nine facts the old probes established were enumerated first. Two moved to the value assertion; seven were retained deliberately - per-file SKILL.md existence, the VERSION file written outside this function, the manifest content, and the post-uninstall absence checks all sit outside the plan's per-kind contract. A migration that quietly asserts less looks like a win and is a regression, so the enumeration is the guard rather than the line count. Also implements the rest of the matrix: the executed-plan shape, adapter failure modes, the security-boundary rows including a fake that cannot certify an install the real filesystem would refuse, cleanup visibility, and two seeded property tests. Only the two external CI gates are left unticked, because self-certifying them would be a claim rather than a check. Refs #2874 * fix(#2874): restore streaming hashes and derive F2 from the boundary rule The checkpoint found three things reasoning had missed. sha256File had been converted from raw-fd streaming to a single readFileSync on the assumption that GSD artifacts are never large. A test named for exactly that contract already existed and went red. Streaming is restored, now routed through the adapter, which gains openSync, readSync and closeSync. The contract was the specification; the assumption was not. Three existing tests inject faults by monkeypatching real fs. They broke because mkInstallTempDir stopped calling real mkdtempSync, not because of any binding subtlety - the real adapter was already late-bound. It now calls the real function when no fake is injected, so a monkeypatch applied after import is still seen and the additive contract holds. F2 poisoned real fs by method, so a deliberately unrouted package-source read failed a correct design. It now poisons by path: destination IO is forbidden, package-source IO is allowed and positively asserted. The claim was always zero real destination IO, and the test now derives from that rule instead of coincidentally matching it. Refs #2874 * docs(#2874): add the contributor how-to for plan-based test migration The phase gate caught a real gap. The docs plan was Reference plus Explanation only, and every CI check would have passed, because the docs-required lint only verifies that some file under docs/ moved. But this phase exists to demonstrate a pattern for follow-on work, and that work is other contributors migrating probing test groups. The sequence has two live traps - a partial fake silently falls back to real fs, and the seam is ambient and synchronous-only - plus one discipline nobody infers: enumerate the facts before converting, or you assert less and call it a win. The page carries the qwen migration's arithmetic, nine facts enumerated and only two converted, because a reader seeing only the diff would reasonably conclude the pattern is to replace probes wholesale. No locale mirrors: none of the four carries any contributor-only how-to, so a single translated file would manufacture parity rather than provide it. Refs #2874 * chore(#2874): backfill changeset pr number * test(#2874): normalize both sides of the G1 tree comparison G1 failed on Windows only, deterministically on both shards. The defect was in the test helper, not production. _computePathPrefix posix-normalizes the resolved config dir unconditionally, so on Windows the path embedded in every emitted SKILL.md body is forward-slash form. hashDirTree stripped against the raw backslash path from mkdtempSync, so the substring never matched and each install's unique temp suffix stayed baked into every file - all fifteen skill bodies hashed differently for two runs that had written identical bytes. Both sides are now normalized unconditionally rather than gated on path.sep, matching the rule this repo already records: backslash paths arrive on Linux too. Production code is untouched and was verified correct. Normalizing this away on the production side would have hidden a real portability bug if one had existed. Refs #2874 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
1285
tests/executed-plan.test.cjs
Normal file
1285
tests/executed-plan.test.cjs
Normal file
File diff suppressed because it is too large
Load Diff
211
tests/install-fs-adapter-seam.test.cjs
Normal file
211
tests/install-fs-adapter-seam.test.cjs
Normal file
@@ -0,0 +1,211 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Install fs seam — two real-fs leaks that AC2 ("an install can be exercised
|
||||
* end-to-end against an injected fs adapter with no real filesystem") does
|
||||
* not tolerate.
|
||||
*
|
||||
* #2874 (epic #2866 Phase 5), governed by ADR-58
|
||||
* (docs/adr/58-runtime-install-policy-module.md).
|
||||
*
|
||||
* (a) command-roster.cts's `readGsdCommandNames` reads the PACKAGE'S OWN
|
||||
* `commands/gsd/` tree — not an install destination — so it must stay on
|
||||
* real `node:fs`, unrouted, even while a fake install adapter is active
|
||||
* for the surrounding call (mirrors findInstallSourceRoot /
|
||||
* findAgentsSourceRoot's documented precedent in
|
||||
* runtime-artifact-layout.cts).
|
||||
*
|
||||
* (b) install-profiles.cts's `cleanupStagedSkills` runs from a
|
||||
* `process.on('exit'/'SIGINT'/…)` handler — AFTER `withInstallFs` has
|
||||
* already restored the real adapter — so a dir staged during a
|
||||
* fake-adapter call must be cleaned up with the SAME fake adapter that
|
||||
* staged it, never with real fs.
|
||||
*/
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const { createTempDir, cleanup } = require('./helpers.cjs');
|
||||
|
||||
const commandRoster = require('../gsd-core/bin/lib/command-roster.cjs');
|
||||
const {
|
||||
withInstallFs,
|
||||
} = require('../gsd-core/bin/lib/install-fs-adapter.cjs');
|
||||
const {
|
||||
stageSkillsForRuntimeAsSkills,
|
||||
cleanupStagedSkills,
|
||||
STAGED_DIRS,
|
||||
} = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
|
||||
const REAL_COMMANDS_DIR = path.join(__dirname, '..', 'commands', 'gsd');
|
||||
|
||||
/**
|
||||
* A minimal in-memory fake install adapter. Every method that
|
||||
* `stageSkillsForRuntimeAsSkills` (nested=false path) can reach is
|
||||
* implemented against one flat Map store — deliberately NOT seeded with
|
||||
* anything under the real `commands/gsd/` tree, so a call that accidentally
|
||||
* routes a package-source read through this fake would either throw or
|
||||
* return the wrong (empty) stems instead of the real package's own names.
|
||||
*/
|
||||
function createFakeFs(seed = []) {
|
||||
const store = new Map();
|
||||
for (const [p, entry] of seed) store.set(path.normalize(String(p)), entry);
|
||||
const norm = (p) => path.normalize(String(p));
|
||||
const childPrefix = (dir) => {
|
||||
const n = norm(dir);
|
||||
return n.endsWith(path.sep) ? n : n + path.sep;
|
||||
};
|
||||
const enoent = (p) => {
|
||||
const err = new Error(`ENOENT: no such file or directory, '${p}'`);
|
||||
err.code = 'ENOENT';
|
||||
return err;
|
||||
};
|
||||
return {
|
||||
_store: store,
|
||||
existsSync: (p) => store.has(norm(p)),
|
||||
mkdirSync: (p) => { store.set(norm(p), { type: 'dir' }); return undefined; },
|
||||
rmSync: (p) => {
|
||||
const n = norm(p);
|
||||
store.delete(n);
|
||||
const prefix = childPrefix(n);
|
||||
for (const k of [...store.keys()]) if (k.startsWith(prefix)) store.delete(k);
|
||||
},
|
||||
readdirSync: (p, opts) => {
|
||||
const e = store.get(norm(p));
|
||||
if (!e) throw enoent(p);
|
||||
const prefix = childPrefix(p);
|
||||
const names = new Set();
|
||||
for (const k of store.keys()) {
|
||||
if (!k.startsWith(prefix)) continue;
|
||||
const rest = k.slice(prefix.length);
|
||||
const sepIdx = rest.indexOf(path.sep);
|
||||
const name = sepIdx === -1 ? rest : rest.slice(0, sepIdx);
|
||||
if (name) names.add(name);
|
||||
}
|
||||
const arr = [...names];
|
||||
if (opts && opts.withFileTypes) {
|
||||
return arr.map((name) => {
|
||||
const full = norm(path.join(String(p), name));
|
||||
const fe = store.get(full);
|
||||
return { name, isFile: () => (fe ? fe.type === 'file' : false) };
|
||||
});
|
||||
}
|
||||
return arr;
|
||||
},
|
||||
readFileSync: (p, encoding) => {
|
||||
const e = store.get(norm(p));
|
||||
if (!e || e.type !== 'file') throw enoent(p);
|
||||
const buf = Buffer.isBuffer(e.content) ? e.content : Buffer.from(e.content ?? '', 'utf8');
|
||||
return encoding ? buf.toString(encoding) : buf;
|
||||
},
|
||||
writeFileSync: (p, data) => { store.set(norm(p), { type: 'file', content: data }); },
|
||||
copyFileSync: (src, dest) => {
|
||||
const e = store.get(norm(src));
|
||||
store.set(norm(dest), { type: 'file', content: e ? e.content : Buffer.alloc(0) });
|
||||
},
|
||||
lstatSync: (p) => {
|
||||
const e = store.get(norm(p));
|
||||
if (!e) throw enoent(p);
|
||||
return { isFile: () => e.type === 'file', isDirectory: () => e.type === 'dir', isSymbolicLink: () => false };
|
||||
},
|
||||
realpathSync: (p) => norm(p),
|
||||
unlinkSync: (p) => { store.delete(norm(p)); },
|
||||
rmdirSync: (p) => { store.delete(norm(p)); },
|
||||
};
|
||||
}
|
||||
|
||||
// ─── (a) command-roster reads the package's OWN source, unrouted ───────────
|
||||
|
||||
describe('command-roster readGsdCommandNames — package-source read stays unrouted', () => {
|
||||
test('returns the real package command stems even while a poisoning fake adapter is active', () => {
|
||||
const expectedStems = fs.readdirSync(REAL_COMMANDS_DIR)
|
||||
.filter((f) => f.endsWith('.md'))
|
||||
.map((f) => f.replace(/\.md$/, ''))
|
||||
.sort();
|
||||
assert.ok(expectedStems.length > 0, 'REAL_COMMANDS_DIR must contain real .md command files (fixture drift)');
|
||||
|
||||
// A fake whose readdirSync/existsSync ALWAYS throw or lie — if
|
||||
// readGsdCommandNames routed its read through installFs(), this would
|
||||
// either throw (poisoned) or return the wrong (empty) set instead of the
|
||||
// real package's own stems.
|
||||
const poisonFs = {
|
||||
existsSync: () => { throw new Error('leak (a): installFs().existsSync reached for the package-own commands dir'); },
|
||||
readdirSync: () => { throw new Error('leak (a): installFs().readdirSync reached for the package-own commands dir'); },
|
||||
readFileSync: () => { throw new Error('leak (a): installFs().readFileSync reached for the package-own commands dir'); },
|
||||
mkdirSync: () => { throw new Error('leak (a): installFs().mkdirSync reached'); },
|
||||
writeFileSync: () => { throw new Error('leak (a): installFs().writeFileSync reached'); },
|
||||
copyFileSync: () => { throw new Error('leak (a): installFs().copyFileSync reached'); },
|
||||
rmSync: () => { throw new Error('leak (a): installFs().rmSync reached'); },
|
||||
};
|
||||
|
||||
const actualStems = withInstallFs(poisonFs, () => commandRoster.readGsdCommandNames()).sort();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
actualStems,
|
||||
expectedStems,
|
||||
'readGsdCommandNames must return the real package command stems, reading real fs directly, ' +
|
||||
'not the injected (poisoning) fake install adapter',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── (b) cleanupStagedSkills must not perform real IO on fake-staged dirs ──
|
||||
|
||||
describe('install-profiles cleanupStagedSkills — deferred cleanup does not leak past the restore', () => {
|
||||
test('a fake-adapter install followed by the exit handler performs zero real fs.rmSync calls', (t) => {
|
||||
const fakeSrcDir = path.join(os.tmpdir(), `gsd-fake-src-${crypto.randomUUID()}`);
|
||||
const fakeFs = createFakeFs([
|
||||
[fakeSrcDir, { type: 'dir' }],
|
||||
[path.join(fakeSrcDir, 'alpha.md'), { type: 'file', content: '# alpha\n' }],
|
||||
]);
|
||||
|
||||
// Poison real fs.rmSync for the duration of this test — auto-restored by
|
||||
// node:test's mock tracker when the test ends (no try/finally needed).
|
||||
let realRmSyncCalls = 0;
|
||||
t.mock.method(fs, 'rmSync', () => {
|
||||
realRmSyncCalls++;
|
||||
throw new Error('leak (b): real fs.rmSync() was reached for a dir staged under a fake adapter');
|
||||
});
|
||||
|
||||
const converter = (content, _skillName) => content;
|
||||
const stagedDir = withInstallFs(
|
||||
fakeFs,
|
||||
() => stageSkillsForRuntimeAsSkills(fakeSrcDir, { skills: '*' }, converter, 'gsd-'),
|
||||
);
|
||||
|
||||
assert.ok(STAGED_DIRS.has(stagedDir), 'stageSkillsForRuntimeAsSkills must register the staged dir for cleanup');
|
||||
assert.ok(fakeFs._store.has(path.normalize(stagedDir)), 'staged dir must exist in the fake store');
|
||||
|
||||
// Simulate the exit handler: `current` (install-fs-adapter.cts) is back
|
||||
// to the real adapter here — withInstallFs already restored it above.
|
||||
cleanupStagedSkills();
|
||||
|
||||
assert.strictEqual(realRmSyncCalls, 0, 'cleanupStagedSkills must never call real fs.rmSync for a fake-staged dir');
|
||||
assert.strictEqual(fakeFs._store.has(path.normalize(stagedDir)), false, 'the fake-staged dir must be removed via the fake adapter');
|
||||
assert.strictEqual(STAGED_DIRS.has(stagedDir), false, 'STAGED_DIRS must be cleared after cleanup');
|
||||
});
|
||||
|
||||
test('negative proof: a REAL install still cleans up its own staged dirs', (t) => {
|
||||
const srcDir = createTempDir('gsd-real-src-');
|
||||
t.after(() => cleanup(srcDir));
|
||||
fs.writeFileSync(path.join(srcDir, 'alpha.md'), '# alpha\n');
|
||||
|
||||
const converter = (content, _skillName) => content;
|
||||
const stagedDir = stageSkillsForRuntimeAsSkills(srcDir, { skills: '*' }, converter, 'gsd-');
|
||||
t.after(() => { if (fs.existsSync(stagedDir)) cleanup(stagedDir); });
|
||||
|
||||
assert.ok(fs.existsSync(stagedDir), 'real staged dir must exist on real fs before cleanup');
|
||||
assert.ok(STAGED_DIRS.has(stagedDir), 'real staged dir must be registered');
|
||||
|
||||
cleanupStagedSkills();
|
||||
|
||||
assert.strictEqual(fs.existsSync(stagedDir), false, 'a REAL install must still remove its staged dir on cleanup — no temp-dir leak');
|
||||
assert.strictEqual(STAGED_DIRS.has(stagedDir), false, 'STAGED_DIRS must be cleared after cleanup');
|
||||
});
|
||||
});
|
||||
@@ -7014,3 +7014,119 @@ describe('#2873 C1-C6 — install-time shadow report (spawned installer wiring)'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─── #2874 (epic #2866 Phase 5) — G1/G3: the additive-contract guard ────────
|
||||
// Governed by ADR-58 (docs/adr/58-runtime-install-policy-module.md).
|
||||
// Design: .gsd/phase/feat-2874-executed-plan-return/40-design.md
|
||||
// Test matrix: .gsd/phase/feat-2874-executed-plan-return/50-test-matrix.md
|
||||
//
|
||||
// G1 and G3 must be GREEN both BEFORE and AFTER the executed-plan return
|
||||
// lands — they are the guard proving the return value is additive (AC4),
|
||||
// never a behavior change. No production code is touched by this file.
|
||||
|
||||
/**
|
||||
* Recursively hash a directory tree into a stable, order-independent digest.
|
||||
* `stripDir`, if given, is textually removed from each UTF-8-decodable
|
||||
* file's content before hashing, so two installs into DIFFERENT temp
|
||||
* directories (whose absolute paths get baked into rewritten skill bodies)
|
||||
* can still be compared for content-identity.
|
||||
*
|
||||
* Both `stripDir` and the file content are normalized to forward slashes
|
||||
* before the strip, unconditionally (never gated on `path.sep`) — production
|
||||
* (`posixNormalize` in shell-command-projection.cts) rewrites `\` -> `/` in
|
||||
* the resolved configDir before baking it into skill bodies, so on Windows
|
||||
* `stripDir` (a raw fs.mkdtempSync path, backslash-separated) would never
|
||||
* match the posix-normalized text actually written, leaving each install's
|
||||
* unique temp-dir suffix embedded and making every file's hash diverge.
|
||||
*/
|
||||
function hashDirTree(rootDir, stripDir) {
|
||||
const entries = [];
|
||||
const stripDirPosix = stripDir ? stripDir.replace(/\\/g, '/') : stripDir;
|
||||
const walk = (relPath, absPath) => {
|
||||
for (const entry of fs.readdirSync(absPath, { withFileTypes: true })
|
||||
.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const childRel = relPath ? `${relPath}/${entry.name}` : entry.name;
|
||||
const childAbs = path.join(absPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
walk(childRel, childAbs);
|
||||
} else if (entry.isFile()) {
|
||||
const buf = fs.readFileSync(childAbs);
|
||||
const normalized = stripDirPosix
|
||||
? buf.toString('utf8').replace(/\\/g, '/').split(stripDirPosix).join('<CONFIGDIR>')
|
||||
: buf;
|
||||
entries.push(`${childRel}:${crypto.createHash('sha256').update(normalized).digest('hex')}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (fs.existsSync(rootDir)) walk('', rootDir);
|
||||
return entries.sort().join('\n');
|
||||
}
|
||||
|
||||
describe('installRuntimeArtifacts — G1: void-ignoring caller is unaffected (AC4)', () => {
|
||||
test('writes are byte-identical whether or not the caller uses the return value', (t) => {
|
||||
const configDirIgnored = createTempDir('gsd-g1-ignored-');
|
||||
const configDirCaptured = createTempDir('gsd-g1-captured-');
|
||||
t.after(() => cleanup(configDirIgnored));
|
||||
t.after(() => cleanup(configDirCaptured));
|
||||
|
||||
// Caller A: discards the return value entirely — today's every call site
|
||||
// (bin/install.js, both existing adapter test doubles).
|
||||
installRuntimeArtifacts('claude', configDirIgnored, 'global', RESOLVED_CORE);
|
||||
|
||||
// Caller B: captures the return value. Its shape is not asserted here —
|
||||
// section E owns that — only that capturing it changes nothing about
|
||||
// what gets written, and that capturing never itself throws.
|
||||
const captured = installRuntimeArtifacts('claude', configDirCaptured, 'global', RESOLVED_CORE);
|
||||
assert.ok(
|
||||
captured === undefined || (captured !== null && typeof captured === 'object'),
|
||||
'G1: the return value, whatever its shape, must be undefined (today) or a plain object ' +
|
||||
'(after) — never something a capturing caller could not safely ignore',
|
||||
);
|
||||
|
||||
assert.strictEqual(
|
||||
hashDirTree(configDirCaptured, configDirCaptured),
|
||||
hashDirTree(configDirIgnored, configDirIgnored),
|
||||
'G1: writes must be byte-identical regardless of whether the caller captures the return value',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('installRuntimeArtifacts — G3: adapter calling-convention regression guard', () => {
|
||||
// tests/adapter-declarative-equivalence.test.cjs:52 and
|
||||
// tests/adapter-imperative.test.cjs:80 pin their OWN behavior via a
|
||||
// module-ref monkeypatch of installRuntimeArtifacts — neither file ever
|
||||
// invokes the real function, and neither is read or modified here. This
|
||||
// row proves those two files' SUBJECT — the real installRuntimeArtifacts,
|
||||
// called with the exact positional shape each adapter uses — still
|
||||
// behaves: a real, successful, byte-on-disk install.
|
||||
|
||||
test('declarative-adapter call shape (5 positional args, no capabilityRegistry) still installs', (t) => {
|
||||
const configDir = createTempDir('gsd-g3-declarative-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// Matches tests/adapter-declarative-equivalence.test.cjs:62-66's
|
||||
// captured shape: [runtime, configDir, scope, resolvedProfile, resolveAttribution].
|
||||
const resolveAttribution = () => 'attr-claude';
|
||||
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE, resolveAttribution);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')),
|
||||
'G3: the declarative adapter\'s calling convention must still produce a real install',
|
||||
);
|
||||
});
|
||||
|
||||
test('imperative-adapter call shape (6 positional args incl. composed capability registry) still installs', (t) => {
|
||||
const configDir = createTempDir('gsd-g3-imperative-');
|
||||
t.after(() => cleanup(configDir));
|
||||
|
||||
// Matches tests/adapter-imperative.test.cjs:88's captured shape:
|
||||
// [runtime, configDir, scope, resolvedProfile, resolveAttribution, capabilityRegistry].
|
||||
const capabilityRegistry = { capabilityClusters: {} };
|
||||
installRuntimeArtifacts('claude', configDir, 'global', RESOLVED_CORE, undefined, capabilityRegistry);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(configDir, 'skills', 'gsd-help', 'SKILL.md')),
|
||||
'G3: the imperative adapter\'s calling convention must still produce a real install',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,9 +51,22 @@ const {
|
||||
normalizeNodePath,
|
||||
GSD_CHANGESET_FILES,
|
||||
GSD_SCRIPTS_LIB_FILES,
|
||||
installRuntimeArtifacts,
|
||||
} = require('../bin/install.js');
|
||||
|
||||
const { getGlobalConfigDir } = require('../gsd-core/bin/lib/runtime-homes.cjs');
|
||||
// #2874 AC3 exemplar (see the qwen install/uninstall group below): resolves
|
||||
// the SAME 'full' profile install(false, <runtime>) resolves by default
|
||||
// (bin/install.js's _activeProfileName falls back to 'full' when no
|
||||
// --profile/marker is present), so a direct installRuntimeArtifacts() call
|
||||
// against an already-installed targetDir reproduces the same executed-plan
|
||||
// shape the production install() call just wrote, without re-deriving
|
||||
// install()'s own profile-resolution logic in this test file.
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const RESOLVED_FULL = resolveProfile({
|
||||
modes: ['full'],
|
||||
manifest: loadSkillsManifest(path.join(__dirname, '..', 'commands', 'gsd')),
|
||||
});
|
||||
|
||||
const {
|
||||
RUNTIME_META,
|
||||
@@ -460,12 +473,47 @@ describe('install/uninstall — qwen (nested skills/gsd-<router>/skills/<stem>/
|
||||
assert.strictEqual(result.runtime, 'qwen');
|
||||
assert.strictEqual(result.configDir, fs.realpathSync(targetDir));
|
||||
|
||||
// qwen nests: skills/gsd-<router>/skills/<stem>/SKILL.md
|
||||
// #2874 AC3 exemplar: install(false, 'qwen') above already wrote the
|
||||
// skills/ and agents/ dirs, but its own internal installRuntimeArtifacts
|
||||
// call (bin/install.js) discards the executed plan it returns. Calling
|
||||
// installRuntimeArtifacts directly here — same runtime/targetDir/scope,
|
||||
// same 'full' profile install() resolved by default — is an idempotent
|
||||
// re-run over the already-installed tree (prune + rewrite converges to
|
||||
// the same on-disk result) that surfaces the SAME plan value production
|
||||
// discarded. What replaces fs.existsSync probing below is this ONE
|
||||
// deepStrictEqual against that plan: previously each destination
|
||||
// directory's existence was checked with a separate fs.existsSync() call
|
||||
// (a probe of a SIDE EFFECT); now both are read off the single typed
|
||||
// value the call contractually returns (40-design.md: "returns a plan
|
||||
// naming every kind with its sourceDir, destDir", never undefined).
|
||||
const plan = installRuntimeArtifacts('qwen', targetDir, 'global', RESOLVED_FULL);
|
||||
const kindsByName = new Map(plan.kinds.map((k) => [k.kind, k]));
|
||||
assert.deepStrictEqual(
|
||||
{
|
||||
skillsDestDir: kindsByName.get('skills') && kindsByName.get('skills').destDir,
|
||||
agentsDestDir: kindsByName.get('agents') && kindsByName.get('agents').destDir,
|
||||
},
|
||||
{
|
||||
skillsDestDir: path.join(targetDir, 'skills'),
|
||||
agentsDestDir: path.join(targetDir, 'agents'),
|
||||
},
|
||||
'qwen executed plan must record a skills-kind write to skills/ and an agents-kind write to agents/',
|
||||
);
|
||||
|
||||
// qwen nests: skills/gsd-<router>/skills/<stem>/SKILL.md. The plan above
|
||||
// proves the skills-kind DESTINATION ROOT; which concrete stem (e.g.
|
||||
// "help") landed under it is finer-grained than the plan's per-kind
|
||||
// contract (one destDir per kind, not a file list), so that specific
|
||||
// fact still needs an fs probe — nothing here is a regression from the
|
||||
// pre-migration test, only the destDir-existence checks moved to the
|
||||
// plan value above.
|
||||
const qwenHelpPath = nestedSkillPath(path.join(targetDir, 'skills'), 'gsd-', 'help');
|
||||
assert.ok(fs.existsSync(qwenHelpPath),
|
||||
`help SKILL.md must exist at nested path: ${path.relative(targetDir, qwenHelpPath)}`);
|
||||
// gsd-core/VERSION is written by install()'s own gsd-core copy step, not
|
||||
// by installRuntimeArtifacts (outside the executed-plan contract) — stays
|
||||
// an fs probe.
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION')));
|
||||
assert.ok(fs.existsSync(path.join(targetDir, 'agents')));
|
||||
|
||||
const manifest = writeManifest(targetDir, 'qwen');
|
||||
assert.ok(
|
||||
|
||||
Reference in New Issue
Block a user