diff --git a/docs/adr/2121-phase-identifier-parsing-consolidation.md b/docs/adr/2121-phase-identifier-parsing-consolidation.md index 6dd67b024..ba6824fca 100644 --- a/docs/adr/2121-phase-identifier-parsing-consolidation.md +++ b/docs/adr/2121-phase-identifier-parsing-consolidation.md @@ -112,13 +112,13 @@ Each phase is its own small PR, opened under a fresh `chore(#2121): … — Phas ### 7. The anti-divergence contract (the parity guard) -**Locked mechanism**, modeled on the repo's two proven single-source patterns — `tests/capability-precedence-parity.test.cjs` (identity guard) and `scripts/lint-package-identity-drift.cjs` + `tests/issue-498-identity-drift-lint.test.cjs` (drift scanner): +**Locked mechanism**, modeled on the repo's two proven single-source patterns — `tests/capability-precedence-parity.test.cjs` (identity guard) and `scripts/lint-package-identity-drift.cjs` + `tests/package-identity.test.cjs` (drift scanner): 1. **Identity guard test** — for every consumer that re-exports a canonical phase-ID function, assert reference identity: `assert.strictEqual(consumer.fn, phaseId.fn)`. A pasted re-implementation is a different function object and fails instantly (the mechanism `capability-precedence-parity.test.cjs:44-51` uses). 2. **Drift scanner** — `scripts/lint-phase-id-drift.cjs` exports a **pure** `findPhaseIdRegexDrift(text, opts)` that flags phase-ID-shaped regex literals (`\d+[A-Z]?(?:\.\d+)*`, `[A-Z][A-Z0-9_]*-`, and `Phase\s+…:` heading builders) defined in any `src/*.cts` other than `phase-id.cts`. It is wired to `npm run check:phase-id-drift` and asserted zero via a `scanRepo(ROOT)` integration test. A narrow allowlist keyed by an explicit `// phase-id-owner: ` comment covers sanctioned exceptions (e.g. Cluster-1 / #2104 init sites, `// phase-id-owner: cluster-1-#2104`) until they migrate. **Locked constraints on the guard's own implementation** (so it does not become new tech debt): -- It must be **behavioral**, not a `readFileSync(path).includes(...)` inside a `tests/**/*.test.cjs` file — that trips `eslint-rules/no-source-grep.cjs` (bound `local/no-source-grep`, `error` in tests). Text-scanning lives in the `scripts/` pure function; the test `require()`s it and calls it with **inline string literals**, per `tests/issue-498-identity-drift-lint.test.cjs:21-25`. +- It must be **behavioral**, not a `readFileSync(path).includes(...)` inside a `tests/**/*.test.cjs` file — that trips `eslint-rules/no-source-grep.cjs` (bound `local/no-source-grep`, `error` in tests). Text-scanning lives in the `scripts/` pure function; the test `require()`s it and calls it with **inline string literals**, per `tests/package-identity.test.cjs:22-25`. - It must **not** be modeled on `tests/package-name-single-source.test.cjs`, which only *appears* to satisfy `no-source-grep` because the rule's taint-tracking loses the variable after `.split()` — an evasion, not an exemption. *Rejected:* (B) an ESLint `no-restricted-syntax` rule — the repo has exactly one such rule (test-timing hygiene, `eslint.config.mjs:363`) and no single-ownership lint precedent; a `node:test` behavioral contract is the established, proven pattern. (C) outcome-parity only (run two paths, diff outputs, as `tests/phase.test.cjs:6881` `expectParity` does for #3537) — necessary but insufficient: it proves two paths *agree today*, not that only one *implementation* exists, so it cannot catch a third divergent site added tomorrow. diff --git a/docs/adr/457-generated-cjs-single-source.md b/docs/adr/457-generated-cjs-single-source.md index fb28f9013..a1ce937c4 100644 --- a/docs/adr/457-generated-cjs-single-source.md +++ b/docs/adr/457-generated-cjs-single-source.md @@ -26,7 +26,7 @@ - There is **no** `gsd-core/src/` or `sdk/src/` TypeScript tree. There is **no** TS→CJS transpilation pipeline. There is **no** `tests/cjs-ts-parity.test.cjs`. The only parity test is - `tests/issue-498-package-identity.test.cjs`, scoped to the one baked file: it + `tests/package-identity.test.cjs`, scoped to the one baked file: it regenerates from `package.json` and asserts the committed output is not stale. - `tsconfig.lint.json` exists with `allowJs` + `checkJs`, but it is **not** wired into `eslint.config.mjs`. The `.cjs` config block (`eslint.config.mjs` @@ -174,4 +174,4 @@ build artifact?** Three models: - Superseded shared-module seam: `3524-cjs-sdk-hard-seam.md` - Tracking issue: [#457](https://github.com/open-gsd/gsd-core/issues/457) - Value-baking precedent (distinct technique): `scripts/generate-package-identity.cjs`, - `tests/issue-498-package-identity.test.cjs` + `tests/package-identity.test.cjs` diff --git a/docs/adr/766-claude-code-plugin-manifest-module.md b/docs/adr/766-claude-code-plugin-manifest-module.md index 1bef17fe6..972546afa 100644 --- a/docs/adr/766-claude-code-plugin-manifest-module.md +++ b/docs/adr/766-claude-code-plugin-manifest-module.md @@ -32,7 +32,7 @@ The mapping is **defined, not incidental**: The hook projection is the load-bearing part of this Module, because of the external constraint: a plugin's agents cannot carry hook frontmatter, so **all plugin-path hook wiring must live in `hooks/hooks.json`**. The Module projects *only the always-on subset* of the Installer Module's Claude hook wiring — `gsd-check-update` (SessionStart), `gsd-context-monitor` (PostToolUse), and the security guards `gsd-prompt-guard` / `gsd-read-guard` / `gsd-worktree-path-guard` / `gsd-read-injection-scanner` / `gsd-write-guard` (PreToolUse, #2255) — preserving each event, matcher, and timeout. The installer's **config-gated opt-in** hooks (workflow-guard, validate-commit, graphify-update, session-state, phase-boundary, update-banner) are deliberately excluded: a static manifest cannot read a project's `.planning/config.json` to honor those gates, so projecting them would run them unconditionally — a behavior change the Module must not introduce. Hook commands reference bundled scripts through Claude Code's `${CLAUDE_PLUGIN_ROOT}` variable. -The interface of this Module is therefore a **conformance contract**, validated two ways: `claude plugin validate --strict` (the external tool's view) and an in-repo drift-guard test (`tests/issue-766-plugin-manifest.test.cjs`) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the **plugin root** (the directory containing `.claude-plugin/`), which is the repository root. +The interface of this Module is therefore a **conformance contract**, validated two ways: `claude plugin validate --strict` (the external tool's view) and an in-repo drift-guard test (`tests/plugin-manifest.test.cjs`) that locks the identity mapping, the version sync, the always-on hook contract, and the absence of opt-in hooks. Manifest component paths are resolved relative to the **plugin root** (the directory containing `.claude-plugin/`), which is the repository root. This is **additive**. The file-copy path — Runtime Artifact Layout Module, Runtime Install Policy Module, Installer Module — is unchanged. The plugin manifest is a parallel Adapter, the fallback for users on older Claude Code versions that predate the plugin contract. @@ -77,4 +77,4 @@ The original mapping table projected commands + hooks but omitted skills. Phase |---|---|---| | Skill surface (`commands/gsd/*.md` → build-converted) | `skills: "./skills/"` | A `skills/` dir of build-generated `gsd-/SKILL.md` files, produced by `scripts/gen-plugin-skills.cjs` running `convertClaudeCommandToClaudeSkill` (the same converter the file-copy installer uses). Generated at build time (`npm run build`) and committed (consistent with ADR-457's generated-committed-output pattern). This closes the gap where plugin-only installs lacked the skill surface because `bin/install.js` never ran. Methodology defined by ADR-1593 §5. | -The `skills/` dir is **generated, not hand-authored** — `scripts/gen-plugin-skills.cjs --check` verifies staleness. The conformance test (`tests/issue-766-plugin-manifest.test.cjs` Section H) asserts the manifest field, dir presence, frontmatter validity, and count parity with `commands/gsd/*.md` (`DEFECT.GENERATIVE-FIX`). +The `skills/` dir is **generated, not hand-authored** — `scripts/gen-plugin-skills.cjs --check` verifies staleness. The conformance test (`tests/plugin-manifest.test.cjs` Section H) asserts the manifest field, dir presence, frontmatter validity, and count parity with `commands/gsd/*.md` (`DEFECT.GENERATIVE-FIX`). diff --git a/tests/gsd-statusline.test.cjs b/tests/gsd-statusline.test.cjs index 6a90992d9..5fbc4a16c 100644 --- a/tests/gsd-statusline.test.cjs +++ b/tests/gsd-statusline.test.cjs @@ -2228,3 +2228,190 @@ test('config-set statusline.show_context_tokens yes → rejected', () => { }); }); } + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-607-cache-lineage.test.cjs — H3 Wave 5 (#3337) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-607-cache-lineage (H3 Wave 5 #3337)', () => { +'use strict'; + +/** + * Tests for cache lineage validation (issue #607). + * + * Verifies that per-package cache filenames and package_name lineage guards + * are correctly enforced across gsd-update-banner.js, gsd-statusline.js, + * and the worker result shape. + */ + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); + +const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); +const { buildBannerOutput } = require('../hooks/gsd-update-banner.js'); +const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js'); + +// ─── Package identity constants ────────────────────────────────────────────── + +describe('package-identity exports', () => { + test('PACKAGE_NAME is @opengsd/gsd-core', () => { + assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); + }); + + test('updateCacheFileName is per-package filename', () => { + assert.equal(updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json'); + }); +}); + +// ─── Worker result shape: package_name field ───────────────────────────────── +// The worker writes { ..., package_name: PACKAGE_NAME } to the cache. +// We assert the documented contract by confirming PACKAGE_NAME is correct +// and that it equals the value that the worker will embed. + +describe('worker result shape contract', () => { + test('PACKAGE_NAME value matches the expected installed package', () => { + // The worker adds package_name: PACKAGE_NAME to its result object. + // This test asserts the value that will appear in the cache. + assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); + }); +}); + +// ─── buildBannerOutput: lineage guard ──────────────────────────────────────── + +describe('buildBannerOutput lineage guard', () => { + test('returns null when package_name is present but foreign', () => { + const out = buildBannerOutput({ + cache: { + update_available: true, + installed: '1.2.0', + latest: '1.42.3', + package_name: 'get-shit-done-cc', + }, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null, 'foreign lineage must be rejected'); + }); + + test('returns banner when package_name matches PACKAGE_NAME', () => { + const out = buildBannerOutput({ + cache: { + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + }, + parseError: false, + suppressFailureWarning: false, + }); + assert.ok(out, 'expected banner envelope for matching lineage'); + assert.equal(typeof out.systemMessage, 'string'); + assert.ok(out.systemMessage.includes('1.2.0')); + assert.ok(out.systemMessage.includes('1.3.0')); + assert.ok(out.systemMessage.includes('/gsd:update')); + }); + + test('returns null when package_name is absent (untrusted cache)', () => { + const out = buildBannerOutput({ + cache: { + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + // no package_name field + }, + parseError: false, + suppressFailureWarning: false, + }); + assert.equal(out, null, 'absent package_name must be treated as untrusted → null'); + }); +}); + +// ─── evaluateUpdateCache: lineage guard in statusline ──────────────────────── + +describe('evaluateUpdateCache lineage guard', () => { + test('returns showUpdate=false when cache is null', () => { + const r = evaluateUpdateCache(null); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=false when package_name is absent (untrusted)', () => { + const r = evaluateUpdateCache({ + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + }); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=false when package_name is foreign', () => { + const r = evaluateUpdateCache({ + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + package_name: 'some-other-package', + }); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=true when update_available and package_name matches', () => { + const r = evaluateUpdateCache({ + update_available: true, + installed: '1.2.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + }); + assert.equal(r.showUpdate, true); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns showUpdate=false when update_available=false', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '1.3.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + }); + assert.equal(r.showUpdate, false); + assert.equal(r.staleWarning, 'none'); + }); + + test('returns staleWarning=stale when stale_hooks present and matching package_name', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '1.3.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }], + }); + assert.equal(r.staleWarning, 'stale'); + }); + + test('returns staleWarning=dev when installed > latest (dev install) and matching package_name', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '2.0.0', + latest: '1.3.0', + package_name: '@opengsd/gsd-core', + stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '2.0.0' }], + }); + assert.equal(r.staleWarning, 'dev'); + }); + + test('returns staleWarning=none when stale_hooks present but package_name is foreign', () => { + const r = evaluateUpdateCache({ + update_available: false, + installed: '1.3.0', + latest: '1.2.0', + package_name: 'foreign-pkg', + stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }], + }); + assert.equal(r.staleWarning, 'none'); + }); +}); + }); +} diff --git a/tests/issue-1855-marketplace-manifest.test.cjs b/tests/issue-1855-marketplace-manifest.test.cjs deleted file mode 100644 index 545a3bc2f..000000000 --- a/tests/issue-1855-marketplace-manifest.test.cjs +++ /dev/null @@ -1,231 +0,0 @@ -'use strict'; - -/** - * Regression tests for issue #1855: Claude plugin marketplace manifest. - * - * Asserts structural and semantic correctness of: - * .claude-plugin/marketplace.json — Claude plugin marketplace manifest - * - * This manifest lets Claude-plugin-compatible runtimes (ZCODE et al.) discover - * and install gsd-core from a custom marketplace source. It is the - * marketplace-discovery sibling of .claude-plugin/plugin.json (issue #766), - * which remains the Claude Code single-plugin manifest and is unchanged. - * - * The version that runtimes read lives at plugins[0].version (the canonical - * marketplace schema location), and is kept in sync with package.json by - * scripts/sync-manifest-versions.cjs via a nested versionKey descriptor. - */ - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); - -const ROOT = path.resolve(__dirname, '..'); -const pkg = require(path.join(ROOT, 'package.json')); -const pluginJson = require(path.join(ROOT, '.claude-plugin', 'plugin.json')); -const { - VERSIONED_MANIFESTS, - VERSIONED_MANIFEST_PATHS, - getByPath, - setByPath, - syncManifestVersions, -} = require(path.join(ROOT, 'scripts', 'sync-manifest-versions.cjs')); -const helpers = require(path.join(__dirname, 'helpers.cjs')); - -const MARKETPLACE_JSON_PATH = path.join(ROOT, '.claude-plugin', 'marketplace.json'); -const MARKETPLACE_REL = '.claude-plugin/marketplace.json'; - -// ─── Section A: marketplace.json structure ─────────────────────────────────── -describe('A: .claude-plugin/marketplace.json', () => { - - let manifest; - - test('exists and is valid JSON', () => { - assert.ok(fs.existsSync(MARKETPLACE_JSON_PATH), '.claude-plugin/marketplace.json must exist'); - const raw = fs.readFileSync(MARKETPLACE_JSON_PATH, 'utf-8'); - manifest = JSON.parse(raw); // throws on invalid JSON - assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object'); - }); - - test('top-level name is a non-empty string', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - assert.ok(typeof manifest.name === 'string' && manifest.name.trim().length > 0, 'marketplace name must be a non-empty string'); - }); - - test('top-level description is a non-empty string', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - assert.ok(typeof manifest.description === 'string' && manifest.description.trim().length > 0, 'marketplace description must be a non-empty string'); - }); - - test('owner.{name,url} are non-empty strings', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - assert.ok(manifest.owner && typeof manifest.owner.name === 'string' && manifest.owner.name.trim().length > 0, 'owner.name must be a non-empty string'); - assert.ok(typeof manifest.owner.url === 'string' && /^https?:\/\//.test(manifest.owner.url), 'owner.url must be an http(s) URL'); - }); - - test('plugins[] is a non-empty array', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - assert.ok(Array.isArray(manifest.plugins) && manifest.plugins.length > 0, 'plugins must be a non-empty array'); - }); - - test('plugins[0] has the gsd-core entry with source "./"', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - const entry = manifest.plugins[0]; - assert.ok(entry && typeof entry === 'object', 'plugins[0] must be an object'); - assert.equal(entry.name, pluginJson.name, `plugins[0].name (${entry && entry.name}) must equal plugin.json name (${pluginJson.name})`); - assert.equal(entry.source, './', 'plugins[0].source must be "./" (repo root, same as plugin.json relative refs)'); - assert.ok(typeof entry.description === 'string' && entry.description.trim().length > 0, 'plugins[0].description must be a non-empty string'); - }); - - test('plugins[0].author.{name,url} match plugin.json author / owner', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - const entry = manifest.plugins[0]; - assert.ok(entry.author && typeof entry.author.name === 'string' && entry.author.name.trim().length > 0, 'plugins[0].author.name must be a non-empty string'); - assert.equal(entry.author.name, pluginJson.author && pluginJson.author.name, 'plugins[0].author.name must match plugin.json author.name'); - }); - - test('plugins[0].version matches package.json version (synced)', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - const entry = manifest.plugins[0]; - assert.equal( - entry.version, - pkg.version, - `plugins[0].version (${entry.version}) must match package.json version (${pkg.version}). ` + - 'Run `node scripts/sync-manifest-versions.cjs` to fix — the marketplace plugin version is stamped via a nested versionKey descriptor. (#1855)' - ); - }); - - test('no plugins[0].$schema key (intentionally omitted, parity with plugin.json)', (t) => { - if (!manifest) { t.skip('manifest could not be parsed'); return; } - const entry = manifest.plugins[0]; - assert.ok(!Object.prototype.hasOwnProperty.call(entry, '$schema'), 'plugins[0] must NOT contain a $schema key'); - }); -}); - -// ─── Section B: registration in the version-sync registry ──────────────────── -describe('B: marketplace.json is registered for version sync', () => { - - test('marketplace.json path appears in VERSIONED_MANIFESTS', () => { - const paths = VERSIONED_MANIFESTS.map((e) => (typeof e === 'string' ? e : e && e.path)); - assert.ok( - paths.includes(MARKETPLACE_REL), - `VERSIONED_MANIFESTS must register ${MARKETPLACE_REL} so 'npm version' keeps plugins[0].version in sync (issue #844 / #1855). Got: ${JSON.stringify(paths)}` - ); - }); - - test('marketplace.json entry uses the nested plugins.0.version key', () => { - const entry = VERSIONED_MANIFESTS.find((e) => (typeof e === 'string' ? e : e && e.path) === MARKETPLACE_REL); - // Nested dot-path is what makes the canonical marketplace version (plugins[0].version) the stamped field. - const versionKey = typeof entry === 'string' ? 'version' : entry && entry.versionKey; - assert.equal( - versionKey, - 'plugins.0.version', - `${MARKETPLACE_REL} must be registered with versionKey 'plugins.0.version' (the schema-canonical location runtimes read). Got: ${JSON.stringify(entry)}` - ); - }); - - test('marketplace.json is in the staging list (stageManifests derives from VERSIONED_MANIFEST_PATHS)', () => { - // stageManifests() git-adds [...VERSIONED_MANIFEST_PATHS, ...capabilities]. If - // marketplace.json were dropped from the paths list, `npm version` would stage - // every other manifest but silently skip it — so pin the path here too. - assert.ok( - VERSIONED_MANIFEST_PATHS.includes(MARKETPLACE_REL), - `VERSIONED_MANIFEST_PATHS must include ${MARKETPLACE_REL} so stageManifests() stages it on npm version. Got: ${JSON.stringify(VERSIONED_MANIFEST_PATHS)}` - ); - }); -}); - -// ─── Section D: nested-path helpers are prototype-pollution-safe ────────────── -describe('D: getByPath / setByPath reject reserved properties', () => { - - test('plugins.0.version resolves a nested array-index path', () => { - const doc = { plugins: [{ version: '1.2.3' }] }; - assert.equal(getByPath(doc, 'plugins.0.version'), '1.2.3'); - }); - - test('getByPath returns undefined for a missing intermediate', () => { - assert.equal(getByPath({ plugins: [] }, 'plugins.0.version'), undefined); - }); - - for (const reserved of ['__proto__', 'constructor', 'prototype']) { - test(`getByPath refuses to traverse "${reserved}"`, () => { - assert.throws( - () => getByPath({}, `${reserved}.x`), - /refusing to traverse reserved property/, - `getByPath must reject the reserved "${reserved}" segment` - ); - }); - test(`setByPath refuses to assign through "${reserved}" (no prototype pollution)`, () => { - const target = {}; - assert.throws( - () => setByPath(target, `${reserved}.polluted`, 'yes'), - /refusing to traverse reserved property/, - `setByPath must reject the reserved "${reserved}" segment` - ); - // Confirm nothing leaked onto Object.prototype. - assert.ok(({}).polluted === undefined, 'Object.prototype must not be polluted'); - }); - } -}); - -// ─── Section C: sync stamps the nested version (temp fixture, red→green) ───── -describe('C: syncManifestVersions stamps plugins[0].version (temp fixture)', () => { - - test('stamps a stale marketplace plugins[0].version to the package version, then is idempotent', () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1855-')); - try { - fs.writeFileSync( - path.join(tmpRoot, 'package.json'), - JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n' - ); - // Seed a stale marketplace.json with a nested plugins[0].version. - const destAbs = path.join(tmpRoot, MARKETPLACE_REL); - fs.mkdirSync(path.dirname(destAbs), { recursive: true }); - const stale = JSON.parse(fs.readFileSync(MARKETPLACE_JSON_PATH, 'utf8')); - stale.plugins[0].version = '0.0.0'; - fs.writeFileSync(destAbs, JSON.stringify(stale, null, 2) + '\n'); - - // Only sync the marketplace manifest in this fixture (other registered - // manifests are absent under tmpRoot). syncManifestVersions tolerates a - // missing manifest file by... it does NOT — it readJson-throws. So seed - // the other registered manifests too (stale) so the sync loop is happy. - for (const e of VERSIONED_MANIFESTS) { - const rel = typeof e === 'string' ? e : e.path; - if (rel === MARKETPLACE_REL) continue; - const realAbs = path.join(ROOT, rel); - if (!fs.existsSync(realAbs)) continue; - const other = JSON.parse(fs.readFileSync(realAbs, 'utf8')); - const vk = typeof e === 'string' ? 'version' : (e.versionKey || 'version'); - setNested(other, vk, '0.0.0'); - const d = path.join(tmpRoot, rel); - fs.mkdirSync(path.dirname(d), { recursive: true }); - fs.writeFileSync(d, JSON.stringify(other, null, 2) + '\n'); - } - - const changed = syncManifestVersions({ root: tmpRoot }); - assert.ok(changed.includes(MARKETPLACE_REL), `sync should report ${MARKETPLACE_REL} as changed`); - - const synced = JSON.parse(fs.readFileSync(destAbs, 'utf8')); - assert.equal(synced.plugins[0].version, '9.9.9-test.0', 'plugins[0].version should be stamped to the package version'); - - // Idempotent second run does not re-report the marketplace manifest. - const changed2 = syncManifestVersions({ root: tmpRoot }); - assert.ok(!changed2.includes(MARKETPLACE_REL), 'second sync should not re-report an already-synced marketplace manifest'); - } finally { - helpers.cleanup(tmpRoot); - } - }); -}); - -// Minimal nested dot-path setter mirroring the sync script's helper, for fixture seeding. -function setNested(obj, dotPath, value) { - const parts = String(dotPath).split('.'); - let cur = obj; - for (let i = 0; i < parts.length - 1; i++) { - const k = parts[i]; - cur = cur[k]; - } - cur[parts[parts.length - 1]] = value; -} diff --git a/tests/issue-498-identity-drift-lint.test.cjs b/tests/issue-498-identity-drift-lint.test.cjs deleted file mode 100644 index 705aef82b..000000000 --- a/tests/issue-498-identity-drift-lint.test.cjs +++ /dev/null @@ -1,124 +0,0 @@ -'use strict'; -process.env.GSD_TEST_MODE = '1'; - -// Issue #498: the drift-guard lint. Every GSD package/repo coordinate that -// appears as a literal anywhere in the runtime/code surface must equal the -// value the Package Identity seam derives from package.json. This is what -// makes a repoint a one-line change: rename package.json, regenerate the seam, -// and any stale literal fails CI until it is updated. - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); -const path = require('node:path'); - -const ROOT = path.join(__dirname, '..'); -const { findCoordinateDrift } = require( - path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs'), -); - -const SEAM = { packageName: '@opengsd/get-shit-done-redux', repoSlug: 'open-gsd/get-shit-done-redux' }; - -describe('Issue #498: findCoordinateDrift (pure)', () => { - test('a correct package literal is not drift', () => { - const v = findCoordinateDrift('run npx -y @opengsd/get-shit-done-redux@latest', SEAM); - assert.deepEqual(v, []); - }); - - test('a stale package literal (post-rename) is flagged', () => { - const v = findCoordinateDrift('npx @opengsd/get-shit-done-classic@latest', SEAM); - assert.equal(v.length, 1); - assert.equal(v[0].found, '@opengsd/get-shit-done-classic'); - assert.equal(v[0].expected, SEAM.packageName); - assert.equal(v[0].kind, 'package'); - }); - - test('a different package (@opengsd/gsd-sdk) is NOT a gsd-core coordinate', () => { - assert.deepEqual(findCoordinateDrift("require('@opengsd/gsd-sdk')", SEAM), []); - }); - - test('a correct github repo slug is not drift', () => { - const v = findCoordinateDrift('https://github.com/open-gsd/get-shit-done-redux/issues', SEAM); - assert.deepEqual(v, []); - }); - - test('a stale repo slug in a github url is flagged', () => { - const v = findCoordinateDrift('https://github.com/tches/get-shit-done-classic.git', SEAM); - assert.equal(v.length, 1); - assert.equal(v[0].kind, 'slug'); - assert.equal(v[0].found, 'tches/get-shit-done-classic'); - }); - - test('reports 1-based line numbers', () => { - const text = 'line1\nnpx @opengsd/get-shit-done-OLD@latest\nline3'; - const v = findCoordinateDrift(text, SEAM); - assert.equal(v[0].line, 2); - }); -}); - -describe('Issue #498: the live repo passes the drift lint', () => { - test('scanRepo finds zero drift against the current seam', () => { - const { scanRepo } = require(path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs')); - const violations = scanRepo(ROOT); - assert.deepEqual( - violations, - [], - 'stale GSD coordinate literal(s) found:\n' + - violations.map((d) => ` ${d.file}:${d.line} ${d.kind} '${d.found}' != '${d.expected}'`).join('\n'), - ); - }); -}); - - -// ──────────────────────────────────────────────────────────────────────── -// Folded from tests/bug-170-workflow-fallback-install-hint.test.cjs — consolidation epic #1969 (B4 #1973) -// ──────────────────────────────────────────────────────────────────────── -{ - const { describe: __foldDescribe } = require('node:test'); - __foldDescribe("folded:bug-170-workflow-fallback-install-hint (consolidation epic #1969 B4 #1973)", () => { -'use strict'; -// allow-test-rule: source-text-is-the-product (see #170) -// Workflow markdown is shipped product text; this test validates fallback -// hint literals across all workflow files. - -const { test } = require('node:test'); -const assert = require('node:assert/strict'); -const fs = require('node:fs'); -const path = require('node:path'); - -const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); -const LEGACY_HINT = 'npx get-shit-done-cc@latest --claude --local'; -const CURRENT_HINT = 'npx -y @opengsd/gsd-core@latest --claude --local'; - -function findMarkdownFiles(dir) { - const out = []; - for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { - const full = path.join(dir, entry.name); - if (entry.isDirectory()) out.push(...findMarkdownFiles(full)); - else if (entry.isFile() && full.endsWith('.md')) out.push(full); - } - return out; -} - -test('bug #170: workflow fallback hints do not reference get-shit-done-cc', () => { - const files = findMarkdownFiles(WORKFLOWS_DIR); - let legacyCount = 0; - let currentCount = 0; - - for (const file of files) { - const src = fs.readFileSync(file, 'utf8'); - if (src.includes(LEGACY_HINT)) legacyCount += 1; - if (src.includes(CURRENT_HINT)) currentCount += 1; - } - - assert.equal( - legacyCount, - 0, - `workflow fallback hints must not reference legacy package (${LEGACY_HINT})` - ); - assert.ok( - currentCount > 0, - `expected at least one workflow fallback hint to use current package (${CURRENT_HINT})` - ); -}); - }); -} diff --git a/tests/issue-607-cache-lineage.test.cjs b/tests/issue-607-cache-lineage.test.cjs deleted file mode 100644 index 655135902..000000000 --- a/tests/issue-607-cache-lineage.test.cjs +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Tests for cache lineage validation (issue #607). - * - * Verifies that per-package cache filenames and package_name lineage guards - * are correctly enforced across gsd-update-banner.js, gsd-statusline.js, - * and the worker result shape. - */ - -'use strict'; - -const { test, describe } = require('node:test'); -const assert = require('node:assert/strict'); - -const { PACKAGE_NAME, updateCacheFileName } = require('../gsd-core/bin/lib/package-identity.cjs'); -const { buildBannerOutput } = require('../hooks/gsd-update-banner.js'); -const { evaluateUpdateCache } = require('../hooks/gsd-statusline.js'); - -// ─── Package identity constants ────────────────────────────────────────────── - -describe('package-identity exports', () => { - test('PACKAGE_NAME is @opengsd/gsd-core', () => { - assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); - }); - - test('updateCacheFileName is per-package filename', () => { - assert.equal(updateCacheFileName, 'gsd-update-check-opengsd-gsd-core.json'); - }); -}); - -// ─── Worker result shape: package_name field ───────────────────────────────── -// The worker writes { ..., package_name: PACKAGE_NAME } to the cache. -// We assert the documented contract by confirming PACKAGE_NAME is correct -// and that it equals the value that the worker will embed. - -describe('worker result shape contract', () => { - test('PACKAGE_NAME value matches the expected installed package', () => { - // The worker adds package_name: PACKAGE_NAME to its result object. - // This test asserts the value that will appear in the cache. - assert.equal(PACKAGE_NAME, '@opengsd/gsd-core'); - }); -}); - -// ─── buildBannerOutput: lineage guard ──────────────────────────────────────── - -describe('buildBannerOutput lineage guard', () => { - test('returns null when package_name is present but foreign', () => { - const out = buildBannerOutput({ - cache: { - update_available: true, - installed: '1.2.0', - latest: '1.42.3', - package_name: 'get-shit-done-cc', - }, - parseError: false, - suppressFailureWarning: false, - }); - assert.equal(out, null, 'foreign lineage must be rejected'); - }); - - test('returns banner when package_name matches PACKAGE_NAME', () => { - const out = buildBannerOutput({ - cache: { - update_available: true, - installed: '1.2.0', - latest: '1.3.0', - package_name: '@opengsd/gsd-core', - }, - parseError: false, - suppressFailureWarning: false, - }); - assert.ok(out, 'expected banner envelope for matching lineage'); - assert.equal(typeof out.systemMessage, 'string'); - assert.ok(out.systemMessage.includes('1.2.0')); - assert.ok(out.systemMessage.includes('1.3.0')); - assert.ok(out.systemMessage.includes('/gsd:update')); - }); - - test('returns null when package_name is absent (untrusted cache)', () => { - const out = buildBannerOutput({ - cache: { - update_available: true, - installed: '1.2.0', - latest: '1.3.0', - // no package_name field - }, - parseError: false, - suppressFailureWarning: false, - }); - assert.equal(out, null, 'absent package_name must be treated as untrusted → null'); - }); -}); - -// ─── evaluateUpdateCache: lineage guard in statusline ──────────────────────── - -describe('evaluateUpdateCache lineage guard', () => { - test('returns showUpdate=false when cache is null', () => { - const r = evaluateUpdateCache(null); - assert.equal(r.showUpdate, false); - assert.equal(r.staleWarning, 'none'); - }); - - test('returns showUpdate=false when package_name is absent (untrusted)', () => { - const r = evaluateUpdateCache({ - update_available: true, - installed: '1.2.0', - latest: '1.3.0', - }); - assert.equal(r.showUpdate, false); - assert.equal(r.staleWarning, 'none'); - }); - - test('returns showUpdate=false when package_name is foreign', () => { - const r = evaluateUpdateCache({ - update_available: true, - installed: '1.2.0', - latest: '1.3.0', - package_name: 'some-other-package', - }); - assert.equal(r.showUpdate, false); - assert.equal(r.staleWarning, 'none'); - }); - - test('returns showUpdate=true when update_available and package_name matches', () => { - const r = evaluateUpdateCache({ - update_available: true, - installed: '1.2.0', - latest: '1.3.0', - package_name: '@opengsd/gsd-core', - }); - assert.equal(r.showUpdate, true); - assert.equal(r.staleWarning, 'none'); - }); - - test('returns showUpdate=false when update_available=false', () => { - const r = evaluateUpdateCache({ - update_available: false, - installed: '1.3.0', - latest: '1.3.0', - package_name: '@opengsd/gsd-core', - }); - assert.equal(r.showUpdate, false); - assert.equal(r.staleWarning, 'none'); - }); - - test('returns staleWarning=stale when stale_hooks present and matching package_name', () => { - const r = evaluateUpdateCache({ - update_available: false, - installed: '1.3.0', - latest: '1.3.0', - package_name: '@opengsd/gsd-core', - stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }], - }); - assert.equal(r.staleWarning, 'stale'); - }); - - test('returns staleWarning=dev when installed > latest (dev install) and matching package_name', () => { - const r = evaluateUpdateCache({ - update_available: false, - installed: '2.0.0', - latest: '1.3.0', - package_name: '@opengsd/gsd-core', - stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '2.0.0' }], - }); - assert.equal(r.staleWarning, 'dev'); - }); - - test('returns staleWarning=none when stale_hooks present but package_name is foreign', () => { - const r = evaluateUpdateCache({ - update_available: false, - installed: '1.3.0', - latest: '1.2.0', - package_name: 'foreign-pkg', - stale_hooks: [{ file: 'gsd-statusline.js', hookVersion: '1.2.0', installedVersion: '1.3.0' }], - }); - assert.equal(r.staleWarning, 'none'); - }); -}); diff --git a/tests/issue-844-manifest-version-sync.test.cjs b/tests/manifest-version-sync.test.cjs similarity index 57% rename from tests/issue-844-manifest-version-sync.test.cjs rename to tests/manifest-version-sync.test.cjs index 7d645d155..3b3d10bbb 100644 --- a/tests/issue-844-manifest-version-sync.test.cjs +++ b/tests/manifest-version-sync.test.cjs @@ -374,3 +374,222 @@ describe('F: npm version script includes gen-capability-registry --write (#1498) }); }); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-1855-marketplace-manifest.test.cjs — H3 wave 5 (#3337) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-1855-marketplace-manifest', () => { +// Regression tests for issue #1855: Claude plugin marketplace manifest. +// +// Asserts structural and semantic correctness of .claude-plugin/marketplace.json +// — the marketplace-discovery sibling of .claude-plugin/plugin.json (#766). The +// version that runtimes read lives at plugins[0].version (the canonical +// marketplace schema location), kept in sync with package.json by +// scripts/sync-manifest-versions.cjs via a nested versionKey descriptor. +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const pkg = require(path.join(ROOT, 'package.json')); +const pluginJson = require(path.join(ROOT, '.claude-plugin', 'plugin.json')); +const MARKETPLACE_JSON_PATH = path.join(ROOT, '.claude-plugin', 'marketplace.json'); +const MARKETPLACE_REL = '.claude-plugin/marketplace.json'; + +// ─── Section A2: marketplace.json structure ────────────────────────────────── +describe('A2: .claude-plugin/marketplace.json', () => { + + let manifest; + + test('exists and is valid JSON', () => { + assert.ok(fs.existsSync(MARKETPLACE_JSON_PATH), '.claude-plugin/marketplace.json must exist'); + const raw = fs.readFileSync(MARKETPLACE_JSON_PATH, 'utf-8'); + manifest = JSON.parse(raw); // throws on invalid JSON + assert.ok(typeof manifest === 'object' && manifest !== null, 'manifest must be a JSON object'); + }); + + test('top-level name is a non-empty string', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.ok(typeof manifest.name === 'string' && manifest.name.trim().length > 0, 'marketplace name must be a non-empty string'); + }); + + test('top-level description is a non-empty string', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.ok(typeof manifest.description === 'string' && manifest.description.trim().length > 0, 'marketplace description must be a non-empty string'); + }); + + test('owner.{name,url} are non-empty strings', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.ok(manifest.owner && typeof manifest.owner.name === 'string' && manifest.owner.name.trim().length > 0, 'owner.name must be a non-empty string'); + assert.ok(typeof manifest.owner.url === 'string' && /^https?:\/\//.test(manifest.owner.url), 'owner.url must be an http(s) URL'); + }); + + test('plugins[] is a non-empty array', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + assert.ok(Array.isArray(manifest.plugins) && manifest.plugins.length > 0, 'plugins must be a non-empty array'); + }); + + test('plugins[0] has the gsd-core entry with source "./"', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + const entry = manifest.plugins[0]; + assert.ok(entry && typeof entry === 'object', 'plugins[0] must be an object'); + assert.equal(entry.name, pluginJson.name, `plugins[0].name (${entry && entry.name}) must equal plugin.json name (${pluginJson.name})`); + assert.equal(entry.source, './', 'plugins[0].source must be "./" (repo root, same as plugin.json relative refs)'); + assert.ok(typeof entry.description === 'string' && entry.description.trim().length > 0, 'plugins[0].description must be a non-empty string'); + }); + + test('plugins[0].author.{name,url} match plugin.json author / owner', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + const entry = manifest.plugins[0]; + assert.ok(entry.author && typeof entry.author.name === 'string' && entry.author.name.trim().length > 0, 'plugins[0].author.name must be a non-empty string'); + assert.equal(entry.author.name, pluginJson.author && pluginJson.author.name, 'plugins[0].author.name must match plugin.json author.name'); + }); + + test('plugins[0].version matches package.json version (synced)', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + const entry = manifest.plugins[0]; + assert.equal( + entry.version, + pkg.version, + `plugins[0].version (${entry.version}) must match package.json version (${pkg.version}). ` + + 'Run `node scripts/sync-manifest-versions.cjs` to fix — the marketplace plugin version is stamped via a nested versionKey descriptor. (#1855)' + ); + }); + + test('no plugins[0].$schema key (intentionally omitted, parity with plugin.json)', (t) => { + if (!manifest) { t.skip('manifest could not be parsed'); return; } + const entry = manifest.plugins[0]; + assert.ok(!Object.prototype.hasOwnProperty.call(entry, '$schema'), 'plugins[0] must NOT contain a $schema key'); + }); +}); + +// ─── Section B4: registration in the version-sync registry ─────────────────── +describe('B4: marketplace.json is registered for version sync', () => { + + test('marketplace.json path appears in VERSIONED_MANIFESTS', () => { + const paths = VERSIONED_MANIFESTS.map((e) => (typeof e === 'string' ? e : e && e.path)); + assert.ok( + paths.includes(MARKETPLACE_REL), + `VERSIONED_MANIFESTS must register ${MARKETPLACE_REL} so 'npm version' keeps plugins[0].version in sync (issue #844 / #1855). Got: ${JSON.stringify(paths)}` + ); + }); + + test('marketplace.json entry uses the nested plugins.0.version key', () => { + const entry = VERSIONED_MANIFESTS.find((e) => (typeof e === 'string' ? e : e && e.path) === MARKETPLACE_REL); + // Nested dot-path is what makes the canonical marketplace version (plugins[0].version) the stamped field. + const versionKey = typeof entry === 'string' ? 'version' : entry && entry.versionKey; + assert.equal( + versionKey, + 'plugins.0.version', + `${MARKETPLACE_REL} must be registered with versionKey 'plugins.0.version' (the schema-canonical location runtimes read). Got: ${JSON.stringify(entry)}` + ); + }); + + test('marketplace.json is in the staging list (stageManifests derives from VERSIONED_MANIFEST_PATHS)', () => { + // stageManifests() git-adds [...VERSIONED_MANIFEST_PATHS, ...capabilities]. If + // marketplace.json were dropped from the paths list, `npm version` would stage + // every other manifest but silently skip it — so pin the path here too. + assert.ok( + VERSIONED_MANIFEST_PATHS.includes(MARKETPLACE_REL), + `VERSIONED_MANIFEST_PATHS must include ${MARKETPLACE_REL} so stageManifests() stages it on npm version. Got: ${JSON.stringify(VERSIONED_MANIFEST_PATHS)}` + ); + }); +}); + +// ─── Section D: nested-path helpers are prototype-pollution-safe ────────────── +describe('D: getByPath / setByPath reject reserved properties', () => { + + test('plugins.0.version resolves a nested array-index path', () => { + const doc = { plugins: [{ version: '1.2.3' }] }; + assert.equal(getByPath(doc, 'plugins.0.version'), '1.2.3'); + }); + + test('getByPath returns undefined for a missing intermediate', () => { + assert.equal(getByPath({ plugins: [] }, 'plugins.0.version'), undefined); + }); + + for (const reserved of ['__proto__', 'constructor', 'prototype']) { + test(`getByPath refuses to traverse "${reserved}"`, () => { + assert.throws( + () => getByPath({}, `${reserved}.x`), + /refusing to traverse reserved property/, + `getByPath must reject the reserved "${reserved}" segment` + ); + }); + test(`setByPath refuses to assign through "${reserved}" (no prototype pollution)`, () => { + const target = {}; + assert.throws( + () => setByPath(target, `${reserved}.polluted`, 'yes'), + /refusing to traverse reserved property/, + `setByPath must reject the reserved "${reserved}" segment` + ); + // Confirm nothing leaked onto Object.prototype. + assert.ok(({}).polluted === undefined, 'Object.prototype must not be polluted'); + }); + } +}); + +// ─── Section C2: sync stamps the nested version (temp fixture, red→green) ──── +describe('C2: syncManifestVersions stamps plugins[0].version (temp fixture)', () => { + + test('stamps a stale marketplace plugins[0].version to the package version, then is idempotent', () => { + const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1855-')); + try { + fs.writeFileSync( + path.join(tmpRoot, 'package.json'), + JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n' + ); + // Seed a stale marketplace.json with a nested plugins[0].version. + const destAbs = path.join(tmpRoot, MARKETPLACE_REL); + fs.mkdirSync(path.dirname(destAbs), { recursive: true }); + const stale = JSON.parse(fs.readFileSync(MARKETPLACE_JSON_PATH, 'utf8')); + stale.plugins[0].version = '0.0.0'; + fs.writeFileSync(destAbs, JSON.stringify(stale, null, 2) + '\n'); + + // Only sync the marketplace manifest in this fixture (other registered + // manifests are absent under tmpRoot). syncManifestVersions tolerates a + // missing manifest file by... it does NOT — it readJson-throws. So seed + // the other registered manifests too (stale) so the sync loop is happy. + for (const e of VERSIONED_MANIFESTS) { + const rel = typeof e === 'string' ? e : e.path; + if (rel === MARKETPLACE_REL) continue; + const realAbs = path.join(ROOT, rel); + if (!fs.existsSync(realAbs)) continue; + const other = JSON.parse(fs.readFileSync(realAbs, 'utf8')); + const vk = typeof e === 'string' ? 'version' : (e.versionKey || 'version'); + __foldSetNested(other, vk, '0.0.0'); + const d = path.join(tmpRoot, rel); + fs.mkdirSync(path.dirname(d), { recursive: true }); + fs.writeFileSync(d, JSON.stringify(other, null, 2) + '\n'); + } + + const changed = syncManifestVersions({ root: tmpRoot }); + assert.ok(changed.includes(MARKETPLACE_REL), `sync should report ${MARKETPLACE_REL} as changed`); + + const synced = JSON.parse(fs.readFileSync(destAbs, 'utf8')); + assert.equal(synced.plugins[0].version, '9.9.9-test.0', 'plugins[0].version should be stamped to the package version'); + + // Idempotent second run does not re-report the marketplace manifest. + const changed2 = syncManifestVersions({ root: tmpRoot }); + assert.ok(!changed2.includes(MARKETPLACE_REL), 'second sync should not re-report an already-synced marketplace manifest'); + } finally { + helpers.cleanup(tmpRoot); + } + }); +}); + +// Minimal nested dot-path setter mirroring the sync script's helper, for fixture seeding. +function __foldSetNested(obj, dotPath, value) { + const parts = String(dotPath).split('.'); + let cur = obj; + for (let i = 0; i < parts.length - 1; i++) { + const k = parts[i]; + cur = cur[k]; + } + cur[parts[parts.length - 1]] = value; +} + }); +} diff --git a/tests/issue-498-package-identity.test.cjs b/tests/package-identity.test.cjs similarity index 50% rename from tests/issue-498-package-identity.test.cjs rename to tests/package-identity.test.cjs index 7331e8894..6b68b5e6c 100644 --- a/tests/issue-498-package-identity.test.cjs +++ b/tests/package-identity.test.cjs @@ -1,6 +1,136 @@ 'use strict'; process.env.GSD_TEST_MODE = '1'; +// Issue #498: the drift-guard lint. Every GSD package/repo coordinate that +// appears as a literal anywhere in the runtime/code surface must equal the +// value the Package Identity seam derives from package.json. This is what +// makes a repoint a one-line change: rename package.json, regenerate the seam, +// and any stale literal fails CI until it is updated. + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { findCoordinateDrift } = require( + path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs'), +); + +const SEAM = { packageName: '@opengsd/get-shit-done-redux', repoSlug: 'open-gsd/get-shit-done-redux' }; + +describe('Issue #498: findCoordinateDrift (pure)', () => { + test('a correct package literal is not drift', () => { + const v = findCoordinateDrift('run npx -y @opengsd/get-shit-done-redux@latest', SEAM); + assert.deepEqual(v, []); + }); + + test('a stale package literal (post-rename) is flagged', () => { + const v = findCoordinateDrift('npx @opengsd/get-shit-done-classic@latest', SEAM); + assert.equal(v.length, 1); + assert.equal(v[0].found, '@opengsd/get-shit-done-classic'); + assert.equal(v[0].expected, SEAM.packageName); + assert.equal(v[0].kind, 'package'); + }); + + test('a different package (@opengsd/gsd-sdk) is NOT a gsd-core coordinate', () => { + assert.deepEqual(findCoordinateDrift("require('@opengsd/gsd-sdk')", SEAM), []); + }); + + test('a correct github repo slug is not drift', () => { + const v = findCoordinateDrift('https://github.com/open-gsd/get-shit-done-redux/issues', SEAM); + assert.deepEqual(v, []); + }); + + test('a stale repo slug in a github url is flagged', () => { + const v = findCoordinateDrift('https://github.com/tches/get-shit-done-classic.git', SEAM); + assert.equal(v.length, 1); + assert.equal(v[0].kind, 'slug'); + assert.equal(v[0].found, 'tches/get-shit-done-classic'); + }); + + test('reports 1-based line numbers', () => { + const text = 'line1\nnpx @opengsd/get-shit-done-OLD@latest\nline3'; + const v = findCoordinateDrift(text, SEAM); + assert.equal(v[0].line, 2); + }); +}); + +describe('Issue #498: the live repo passes the drift lint', () => { + test('scanRepo finds zero drift against the current seam', () => { + const { scanRepo } = require(path.join(ROOT, 'scripts', 'lint-package-identity-drift.cjs')); + const violations = scanRepo(ROOT); + assert.deepEqual( + violations, + [], + 'stale GSD coordinate literal(s) found:\n' + + violations.map((d) => ` ${d.file}:${d.line} ${d.kind} '${d.found}' != '${d.expected}'`).join('\n'), + ); + }); +}); + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/bug-170-workflow-fallback-install-hint.test.cjs — consolidation epic #1969 (B4 #1973) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe("folded:bug-170-workflow-fallback-install-hint (consolidation epic #1969 B4 #1973)", () => { +'use strict'; +// allow-test-rule: source-text-is-the-product (see #170) +// Workflow markdown is shipped product text; this test validates fallback +// hint literals across all workflow files. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); +const LEGACY_HINT = 'npx get-shit-done-cc@latest --claude --local'; +const CURRENT_HINT = 'npx -y @opengsd/gsd-core@latest --claude --local'; + +function findMarkdownFiles(dir) { + const out = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...findMarkdownFiles(full)); + else if (entry.isFile() && full.endsWith('.md')) out.push(full); + } + return out; +} + +test('bug #170: workflow fallback hints do not reference get-shit-done-cc', () => { + const files = findMarkdownFiles(WORKFLOWS_DIR); + let legacyCount = 0; + let currentCount = 0; + + for (const file of files) { + const src = fs.readFileSync(file, 'utf8'); + if (src.includes(LEGACY_HINT)) legacyCount += 1; + if (src.includes(CURRENT_HINT)) currentCount += 1; + } + + assert.equal( + legacyCount, + 0, + `workflow fallback hints must not reference legacy package (${LEGACY_HINT})` + ); + assert.ok( + currentCount > 0, + `expected at least one workflow fallback hint to use current package (${CURRENT_HINT})` + ); +}); + }); +} + + +// ──────────────────────────────────────────────────────────────────────── +// Folded from tests/issue-498-package-identity.test.cjs — H3 Wave 5 (#3337) +// ──────────────────────────────────────────────────────────────────────── +{ + const { describe: __foldDescribe } = require('node:test'); + __foldDescribe('folded:issue-498-package-identity (H3 Wave 5 #3337)', () => { +'use strict'; // Issue #498: single Package Identity seam. // The package coordinates (npm name, bin name, repo slug, changelog URL) are // DERIVED from package.json, not re-typed. deriveIdentity is the pure core; @@ -136,3 +266,5 @@ describe('Issue #498: generated runtime module (baked)', () => { ); }); }); + }); +} diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/plugin-manifest.test.cjs similarity index 100% rename from tests/issue-766-plugin-manifest.test.cjs rename to tests/plugin-manifest.test.cjs