From 8951f2907ed9ad274ab33907fd718c736aa01f7f Mon Sep 17 00:00:00 2001 From: Joe <44273333+jslitzkerttcu@users.noreply.github.com> Date: Sun, 7 Jun 2026 09:53:00 -0500 Subject: [PATCH] test(#339): regression guard for gsd-sdk refs in runtime surfaces (#691) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../bug-3810-no-gsd-sdk-runtime-refs.test.cjs | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs diff --git a/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs b/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs new file mode 100644 index 000000000..bd72b35e0 --- /dev/null +++ b/tests/bug-3810-no-gsd-sdk-runtime-refs.test.cjs @@ -0,0 +1,133 @@ +// 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.' + ); + } + } + }); +});