Files
msd-core/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs
Joe 8951f2907e test(#339): regression guard for gsd-sdk refs in runtime surfaces (#691)
* test(#339): add regression guard for gsd-sdk refs in runtime surfaces

Lock in the already-clean runtime surface so a retired `gsd-sdk`/`GSD_SDK`
reference cannot creep back into a shipped prompt or hook. Scans
gsd-core/workflows, gsd-core/references, commands/gsd, agents, and hooks
(excluding the gitignored dist/ build artifact).

Complements gsd-tools-path-refs.test.cjs, which only catches the
`gsd-sdk query` binary form; this catches any runtime reference. A second
test guards against an empty sweep so a future dir rename can't silently
turn the guard into a no-op.

Intentionally does NOT touch bin/install.js (live stale-package-detection
mechanics per #339 triage) or CI/lint scripts (legitimate stale detection).

Refs #339

* test(#339): split file content on /\r?\n/ for Windows CRLF parity

The windows-test-parity-guard lint requires test files that readFileSync +
split to use /\r?\n/, not '\n', so CRLF files don't leave a trailing \r.
New test files are not in the PR #3649 allowlist.

* test(#339): cover .sh/.json runtime files in workflows surface

Address #691 review: the gsd-core/workflows surface scanned only .md,
silently skipping two deployed runtime files — _runtime-launcher.snippet.sh
(synced into every hook) and discuss-phase/templates/checkpoint.json. Add
.sh/.json so the guard covers all 290 deployed runtime files (was 288/290),
not just the .md subset.

* test(#339): cover templates/contexts surfaces + per-ext empty-sweep guard

Address PR #691 review (trek-e):
- Add gsd-core/templates and gsd-core/contexts to RUNTIME_SURFACES —
  both are deep-copied by the installer and runtime-loaded via
  @~/.claude/gsd-core/templates/*.md anchors, so a reintroduced gsd-sdk
  ref there would have slipped past the guard. (major)
- Reword the bin/install.js exclusion rationale: it has zero gsd-sdk refs
  today (subsystem removed in #515, shim retired in #522); the real reason
  it is excluded is that it is installer code, not a deployed prompt/hook
  surface. (minor)
- Make the empty-sweep guard assert coverage per configured extension, not
  per surface — .md files alone kept gsd-core/workflows green even if
  .sh/.json were dropped, silently un-covering _runtime-launcher.snippet.sh
  and discuss-phase/templates/*.json. (low)

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
2026-06-07 10:53:00 -04:00

134 lines
6.0 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// Runtime prompt/hook files are deployed verbatim — their text IS what the
// runtime loads and executes. Asserting that text carries no retired `gsd-sdk`
// reference tests the deployed contract, which no behavioral seam can observe
// (there is no runtime API that enumerates "did any shipped prompt name the
// removed SDK binary").
/**
* Regression guard: no `gsd-sdk` references in runtime-facing surfaces (#339).
*
* The `@opengsd/gsd-sdk` package and its `gsd-sdk` binary were retired (ADR 0174,
* #191). The bulk runtime cleanup is already done — this test locks it in so a
* `gsd-sdk` / `GSD_SDK` reference cannot creep back into a shipped prompt or hook
* and re-introduce drift between the documented surface and the supported
* `gsd-tools` binary.
*
* Scope: runtime surfaces only — the prompts and hooks the installer ships into
* a user's runtime config dir. Explicitly NOT covered here:
* - `bin/install.js` — installer code, not a runtime-deployed prompt/hook
* surface. (It carries zero `gsd-sdk` references today; the SDK-shim
* verification subsystem was removed in #515 and the shim retired in #522.)
* - `gsd-core/bin/` — executable library code, not deployed prompt text; it
* may legitimately reference the SDK retirement in comments.
* - `tests/`, `docs/`, `.changeset/`, CI/lint scripts — legitimately reference
* the SDK retirement as history or detect its stale artifacts.
*
* Complements `tests/gsd-tools-path-refs.test.cjs`, which only catches the
* `gsd-sdk query` binary-invocation form; this catches ANY runtime reference.
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const REPO_ROOT = path.join(__dirname, '..');
// Runtime surfaces the installer ships. Each entry is { dir, exts } — dir is
// repo-relative, exts is the set of file extensions whose text is deployed.
const RUNTIME_SURFACES = [
// .md prompts plus the non-.md runtime artifacts this dir also ships:
// _runtime-launcher.snippet.sh (the canonical launcher synced into every hook
// by scripts/sync-runtime-launcher.cjs) and discuss-phase/templates/*.json
// (loaded at runtime by discuss-phase.md). Scanning only .md left these two
// deployed files uncovered. (#691 review)
{ dir: path.join('gsd-core', 'workflows'), exts: ['.md', '.sh', '.json'] },
{ dir: path.join('gsd-core', 'references'), exts: ['.md'] },
// Prompt surfaces the installer deep-copies and the runtime loads via
// `@~/.claude/gsd-core/templates/*.md` anchors in workflows/commands; the
// lone config.json under templates/ ships too. (#691 review)
{ dir: path.join('gsd-core', 'templates'), exts: ['.md', '.json'] },
{ dir: path.join('gsd-core', 'contexts'), exts: ['.md'] },
{ dir: path.join('commands', 'gsd'), exts: ['.md'] },
{ dir: 'agents', exts: ['.md'] },
// Hooks ship as executable text (.js/.cjs/.sh). `hooks/dist/` is a gitignored
// build artifact regenerated from these sources, so scanning the sources is
// sufficient and avoids asserting against generated copies.
{ dir: 'hooks', exts: ['.js', '.cjs', '.sh'], skipDirs: ['dist'] },
];
// Matches every casing/separator variant of the retired SDK token:
// gsd-sdk, gsd_sdk, GSD-SDK, GSD_SDK, etc.
const SDK_REF = /gsd[-_]sdk/i;
/**
* Recursively collect files under `absDir` whose extension is in `exts`,
* skipping any directory name listed in `skipDirs`.
*/
function collectFiles(absDir, exts, skipDirs) {
if (!fs.existsSync(absDir)) return [];
const out = [];
for (const entry of fs.readdirSync(absDir, { withFileTypes: true })) {
if (entry.isDirectory()) {
if (skipDirs.includes(entry.name)) continue;
out.push(...collectFiles(path.join(absDir, entry.name), exts, skipDirs));
} else if (entry.isFile() && exts.includes(path.extname(entry.name))) {
out.push(path.join(absDir, entry.name));
}
}
return out;
}
function rel(file) {
return path.relative(REPO_ROOT, file).split(path.sep).join('/');
}
describe('#339 no gsd-sdk references in runtime surfaces', () => {
test('shipped prompts and hooks carry no retired gsd-sdk reference', () => {
const violations = [];
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
const files = collectFiles(path.join(REPO_ROOT, dir), exts, skipDirs);
for (const file of files) {
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (SDK_REF.test(lines[i])) {
violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`);
}
}
}
}
assert.strictEqual(
violations.length,
0,
'Runtime surfaces must not reference the retired gsd-sdk binary/package — ' +
'use gsd-tools instead.\nViolations:\n' + violations.join('\n')
);
});
test('at least one file per configured extension is scanned (guards against an empty sweep)', () => {
// A path typo, directory rename, or stale extension could silently make
// collectFiles() return [] for part of a surface, turning the guard above
// into a no-op that always passes. Checking per-surface isn't enough: for
// gsd-core/workflows the .md files alone keep a per-surface count > 0, so
// dropping .sh/.json would stop covering _runtime-launcher.snippet.sh and
// discuss-phase/templates/*.json while the test stayed green. Assert each
// configured extension actually resolves to scanned files. (#691 review)
for (const { dir, exts, skipDirs = [] } of RUNTIME_SURFACES) {
for (const ext of exts) {
const count = collectFiles(path.join(REPO_ROOT, dir), [ext], skipDirs).length;
assert.ok(
count > 0,
`Runtime surface "${dir}" resolved to 0 "${ext}" files — the path may ` +
'have moved or the extension is stale; update RUNTIME_SURFACES so the ' +
'guard keeps covering it.'
);
}
}
});
});