* test(#3337): fold the manifest & package-identity issue-* cluster — Wave 5 Folds 6 legacy issue-*.test.cjs regression files (120 test() blocks) into their module's main suite, per H3 (#3315) of the test-hygiene epic (#3053). Second of 4 issue-* waves. - issue-766-plugin-manifest.test.cjs (50 tests): pure rename (git mv) into plugin-manifest.test.cjs, sole comprehensive suite for its module. - issue-844-manifest-version-sync.test.cjs (14 tests, rename basis) + issue-1855-marketplace-manifest.test.cjs (17 tests, merged in): both target scripts/sync-manifest-versions.cjs via non-overlapping describe blocks (generic engine vs. marketplace.json-specific), now manifest-version-sync.test.cjs, 31 tests, 0 dropped. - issue-498-identity-drift-lint.test.cjs (8 tests, rename basis) + issue-498-package-identity.test.cjs (17 tests, merged in): both target package-identity.cjs's surface from different angles (lint-side drift detection vs. derive/slugify), now package-identity.test.cjs, 25 tests, 0 dropped. - issue-607-cache-lineage.test.cjs (14 tests) merged into the existing gsd-statusline.test.cjs, matching its already-established fold-wrapper convention from prior waves. Learned from Wave 4: fold agents checked src/** (not just docs/ and gsd-core/references/) for stale filename references — found and fixed 5 across 3 ADR docs (766, 2121, 457), zero in src/ this time. Zero net test-coverage loss. No production code changed. * test(#3337): fix orthogonal-review findings — Wave 5 fold Standards-axis review found real issues in the just-folded manifest-version-sync.test.cjs, both fixed here: - The folded:issue-1855-marketplace-manifest block omitted its own test/describe/assert/fs/path/os requires, silently closing over the #844 basis section's outer-scope bindings instead of declaring its own dependencies — inconsistent with every other fold block in this wave. Restored the local requires the original issue-1855 file declared. - Merging two independently-lettered legacy files (each A-F) left duplicate top-level describe() labels (two "A:", two "B:", two "C:"). Renamed the folded-in #1855 labels (A2/B4/C2) to make every top-level label in the file unique. No test() count changed (31). No production code touched. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -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: <reason>` 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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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-<stem>/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`).
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})`
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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)', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user