feat(#1855): add Claude plugin marketplace manifest
Add .claude-plugin/marketplace.json so Claude-plugin-compatible runtimes
(ZCODE et al.) discover gsd-core from a custom marketplace source. The
canonical version lives at plugins[0].version and tracks package.json via
the release version-sync.
Refactor scripts/sync-manifest-versions.cjs so VERSIONED_MANIFESTS entries
are {path, versionKey} dot-path descriptors (default 'version'); register
marketplace.json with versionKey 'plugins.0.version'. getByPath/setByPath
reject __proto__/constructor/prototype (prototype-pollution guard).
plugin.json / gemini-extension.json behavior is unchanged.
- tests/issue-1855-marketplace-manifest.test.cjs: schema + version-sync guard
- tests/issue-844-manifest-version-sync.test.cjs: updated for descriptor shape
- VERSIONING.md + auto-backmerge VERSION_STAMP_MANIFESTS: include marketplace.json
- docs/how-to/install-on-your-runtime.md: marketplace discovery how-to
This commit is contained in:
20
.claude-plugin/marketplace.json
Normal file
20
.claude-plugin/marketplace.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"description": "Marketplace for GSD Core — meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"owner": {
|
||||
"name": "open-gsd",
|
||||
"url": "https://github.com/open-gsd"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "gsd-core",
|
||||
"description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
|
||||
"version": "1.7.0-rc.1",
|
||||
"source": "./",
|
||||
"author": {
|
||||
"name": "open-gsd",
|
||||
"url": "https://github.com/open-gsd"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
2
.github/workflows/auto-backmerge.yml
vendored
2
.github/workflows/auto-backmerge.yml
vendored
@@ -115,7 +115,7 @@ jobs:
|
||||
# filter those out. A substantive change still parks: it leaves
|
||||
# non-"version" lines (deps in package.json; resolved/integrity in the
|
||||
# lockfile when a dependency actually changes).
|
||||
VERSION_STAMP_MANIFESTS='package.json package-lock.json .claude-plugin/plugin.json gemini-extension.json'
|
||||
VERSION_STAMP_MANIFESTS='package.json package-lock.json .claude-plugin/plugin.json .claude-plugin/marketplace.json gemini-extension.json'
|
||||
DROPPED=$(printf '%s\n' "$DROPPED" | while IFS= read -r f; do
|
||||
[ -n "$f" ] || continue
|
||||
case " $VERSION_STAMP_MANIFESTS " in
|
||||
|
||||
@@ -131,13 +131,17 @@ match `package.json`:
|
||||
|
||||
- `.claude-plugin/plugin.json` — Claude Code plugin manifest (issue #766)
|
||||
- `gemini-extension.json` — Gemini CLI extension manifest (issue #775)
|
||||
- `.claude-plugin/marketplace.json` — Claude plugin marketplace manifest; its
|
||||
version lives at `plugins[0].version` and is stamped via a nested versionKey
|
||||
descriptor (issue #1855)
|
||||
|
||||
The `version` npm lifecycle script (`scripts/sync-manifest-versions.cjs --stage`)
|
||||
stamps these files automatically on every `npm version` call, and stages them so
|
||||
they are included in the release commit alongside `package.json`.
|
||||
|
||||
To add a new manifest that must track the package version, register its path in
|
||||
the `VERSIONED_MANIFESTS` array in `scripts/sync-manifest-versions.cjs`. A
|
||||
To add a new manifest that must track the package version, register its path
|
||||
(and, if its version field is not top-level, its dotted `versionKey`) in the
|
||||
`VERSIONED_MANIFESTS` array in `scripts/sync-manifest-versions.cjs`. A
|
||||
regression test (`tests/issue-844-manifest-version-sync.test.cjs`) enforces this:
|
||||
it scans all committed JSON files for a matching `version` field and fails if any
|
||||
are missing from the registry.
|
||||
|
||||
@@ -102,6 +102,15 @@ The `gsd-tools` binary (installed as part of the `@opengsd/gsd-core` npm package
|
||||
|
||||
Node.js (`node`) must also be available on your `PATH`. The plugin's always-on guard hooks (wired in `hooks/hooks.json`) are invoked as `node "${CLAUDE_PLUGIN_ROOT}/hooks/<script>"`. Some Claude Code distributions ship as a standalone binary and do not expose a `node` executable on `PATH`; in those environments the plugin's hooks will not run. Verify with `node --version` before relying on the plugin hooks.
|
||||
|
||||
#### Claude plugin marketplace discovery (ZCODE and compatible runtimes)
|
||||
|
||||
GSD Core also ships a `.claude-plugin/marketplace.json` marketplace manifest (sibling to `plugin.json`). Runtimes that implement the Claude plugin marketplace contract — such as ZCODE — can discover and install GSD Core from a custom marketplace source without a manual clone:
|
||||
|
||||
1. In your runtime's plugin UI, add a custom marketplace source pointing at `open-gsd/gsd-core` (GitHub `owner/repo` form).
|
||||
2. GSD Core appears in the catalog and can be installed directly from the UI.
|
||||
|
||||
This path is **additive** and changes nothing about the Claude Code plugin install above (`.claude-plugin/plugin.json` is unchanged). The marketplace entry's `source` is `./`, so it reuses `plugin.json`'s `commands` / `skills` / `hooks` mapping. The catalog version tracks `package.json` (it lives at `plugins[0].version` and is stamped by the release version-sync), so the version you see in the marketplace matches the npm release.
|
||||
|
||||
---
|
||||
|
||||
### Gemini CLI
|
||||
|
||||
@@ -21,18 +21,65 @@ const { execFileSync } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
|
||||
// Single source of truth: runtime-integration manifests whose top-level `version`
|
||||
// MUST track package.json. Add a new manifest here so `npm version` keeps it in
|
||||
// sync — the regression guard test (issue 844) fails if you forget.
|
||||
// Single source of truth: runtime-integration manifests whose `version` field
|
||||
// MUST track package.json. Each entry names the manifest path plus the dotted
|
||||
// path to the version field inside that JSON document. Top-level `version` is
|
||||
// the default (plugin.json, gemini-extension.json); the Claude plugin
|
||||
// marketplace manifest carries its canonical version at plugins[0].version
|
||||
// (the schema-canonical location runtimes read — issue #1855).
|
||||
//
|
||||
// Add a new manifest here so `npm version` keeps it in sync — the regression
|
||||
// guard test (issue 844) fails if you forget.
|
||||
const VERSIONED_MANIFESTS = [
|
||||
'.claude-plugin/plugin.json',
|
||||
'gemini-extension.json',
|
||||
{ path: '.claude-plugin/plugin.json', versionKey: 'version' },
|
||||
{ path: 'gemini-extension.json', versionKey: 'version' },
|
||||
{ path: '.claude-plugin/marketplace.json', versionKey: 'plugins.0.version' },
|
||||
];
|
||||
|
||||
// Convenience: just the registered paths, for consumers that only need to
|
||||
// iterate files (e.g. the issue-844 regression-guard ALLOWED set).
|
||||
const VERSIONED_MANIFEST_PATHS = VERSIONED_MANIFESTS.map((e) => e.path);
|
||||
|
||||
function readJson(p) {
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
}
|
||||
|
||||
// Property names that must never be traversed/assigned through a dotted path —
|
||||
// the prototype-pollution triple. Mirrors the _isSafePropKey CodeQL barrier used
|
||||
// elsewhere in the repo. Versions are hand-authored descriptors, but the helpers
|
||||
// are exported, so guard them before reuse across a trust boundary.
|
||||
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
||||
function assertSafePath(dotPath) {
|
||||
for (const seg of String(dotPath).split('.')) {
|
||||
if (UNSAFE_KEYS.has(seg)) {
|
||||
throw new Error(`refusing to traverse reserved property "${seg}" in version path "${dotPath}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read a dotted path ('plugins.0.version') from a parsed JSON document.
|
||||
// Returns undefined if any intermediate is missing. Array indices are plain
|
||||
// numeric keys, so 'plugins.0.version' resolves obj.plugins[0].version.
|
||||
// Reserved properties (__proto__/constructor/prototype) are rejected.
|
||||
function getByPath(obj, dotPath) {
|
||||
assertSafePath(dotPath);
|
||||
return String(dotPath).split('.').reduce((cur, key) => (cur == null ? cur : cur[key]), obj);
|
||||
}
|
||||
|
||||
// Write a value at a dotted path. Intermediate nodes must already exist (every
|
||||
// registered manifest is hand-authored with its version slot present, so this
|
||||
// never needs to materialize a path). Reserved properties are rejected. Kept
|
||||
// trivial — no eval, no creation.
|
||||
function setByPath(obj, dotPath, value) {
|
||||
assertSafePath(dotPath);
|
||||
const parts = String(dotPath).split('.');
|
||||
let cur = obj;
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
cur = cur[parts[i]];
|
||||
}
|
||||
cur[parts[parts.length - 1]] = value;
|
||||
}
|
||||
|
||||
function getPackageVersion(root) {
|
||||
const r = root || ROOT;
|
||||
return readJson(path.join(r, 'package.json')).version;
|
||||
@@ -44,13 +91,13 @@ function syncManifestVersions(opts) {
|
||||
const root = (opts && opts.root) || ROOT;
|
||||
const v = (opts && opts.version) != null ? opts.version : getPackageVersion(root);
|
||||
const changed = [];
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const abs = path.join(root, rel);
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const abs = path.join(root, entry.path);
|
||||
const manifest = readJson(abs);
|
||||
if (manifest.version !== v) {
|
||||
manifest.version = v;
|
||||
if (getByPath(manifest, entry.versionKey) !== v) {
|
||||
setByPath(manifest, entry.versionKey, v);
|
||||
fs.writeFileSync(abs, JSON.stringify(manifest, null, 2) + '\n');
|
||||
changed.push(rel);
|
||||
changed.push(entry.path);
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
@@ -61,9 +108,10 @@ function findDrift(opts) {
|
||||
const root = (opts && opts.root) || ROOT;
|
||||
const v = (opts && opts.version) != null ? opts.version : getPackageVersion(root);
|
||||
const drift = [];
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const found = readJson(path.join(root, rel)).version;
|
||||
if (found !== v) drift.push({ manifest: rel, found, expected: v });
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const manifest = readJson(path.join(root, entry.path));
|
||||
const found = getByPath(manifest, entry.versionKey);
|
||||
if (found !== v) drift.push({ manifest: entry.path, found, expected: v });
|
||||
}
|
||||
return drift;
|
||||
}
|
||||
@@ -143,7 +191,7 @@ function stageManifests(opts) {
|
||||
console.warn('sync-manifest-versions: not a git work tree; skipping staging.');
|
||||
return;
|
||||
}
|
||||
const toStage = [...VERSIONED_MANIFESTS, ...listCapabilityManifests({ root })];
|
||||
const toStage = [...VERSIONED_MANIFEST_PATHS, ...listCapabilityManifests({ root })];
|
||||
try {
|
||||
execFileSync('git', ['add', '--', ...toStage], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
} catch (err) {
|
||||
@@ -154,6 +202,9 @@ function stageManifests(opts) {
|
||||
|
||||
module.exports = {
|
||||
VERSIONED_MANIFESTS,
|
||||
VERSIONED_MANIFEST_PATHS,
|
||||
getByPath,
|
||||
setByPath,
|
||||
syncManifestVersions,
|
||||
findDrift,
|
||||
getPackageVersion,
|
||||
|
||||
231
tests/issue-1855-marketplace-manifest.test.cjs
Normal file
231
tests/issue-1855-marketplace-manifest.test.cjs
Normal file
@@ -0,0 +1,231 @@
|
||||
'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;
|
||||
}
|
||||
@@ -24,6 +24,9 @@ const ROOT = path.resolve(__dirname, '..');
|
||||
const helpers = require(path.join(__dirname, 'helpers.cjs'));
|
||||
const {
|
||||
VERSIONED_MANIFESTS,
|
||||
VERSIONED_MANIFEST_PATHS,
|
||||
getByPath,
|
||||
setByPath,
|
||||
syncManifestVersions,
|
||||
getPackageVersion,
|
||||
stageManifests,
|
||||
@@ -51,12 +54,12 @@ describe('A: syncManifestVersions — temp fixture', () => {
|
||||
|
||||
// Copy real manifests into tmp, stamped at OLD version so tests can
|
||||
// verify the pre-sync (stale) state and post-sync (updated) state.
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const realAbs = path.join(ROOT, rel);
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const realAbs = path.join(ROOT, entry.path);
|
||||
const manifest = JSON.parse(fs.readFileSync(realAbs, 'utf8'));
|
||||
manifest.version = '0.0.0';
|
||||
setByPath(manifest, entry.versionKey, '0.0.0');
|
||||
|
||||
const destAbs = path.join(tmpRoot, rel);
|
||||
const destAbs = path.join(tmpRoot, entry.path);
|
||||
const destDir = path.dirname(destAbs);
|
||||
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(destAbs, JSON.stringify(manifest, null, 2) + '\n');
|
||||
@@ -89,13 +92,13 @@ describe('A: syncManifestVersions — temp fixture', () => {
|
||||
const pkgVersion = getPackageVersion(tmpRoot);
|
||||
assert.equal(pkgVersion, '9.9.9-test.0');
|
||||
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const abs = path.join(tmpRoot, rel);
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const abs = path.join(tmpRoot, entry.path);
|
||||
const m = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
||||
assert.equal(
|
||||
m.version,
|
||||
getByPath(m, entry.versionKey),
|
||||
'9.9.9-test.0',
|
||||
`${rel} version should be 9.9.9-test.0 after sync`
|
||||
`${entry.path} version (${entry.versionKey}) should be 9.9.9-test.0 after sync`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -109,10 +112,10 @@ describe('A: syncManifestVersions — temp fixture', () => {
|
||||
path.join(sub, 'package.json'),
|
||||
JSON.stringify({ name: 'x', version: '9.9.9-test.0' }, null, 2) + '\n'
|
||||
);
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
manifest.version = '0.0.0';
|
||||
const destAbs = path.join(sub, rel);
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, entry.path), 'utf8'));
|
||||
setByPath(manifest, entry.versionKey, '0.0.0');
|
||||
const destAbs = path.join(sub, entry.path);
|
||||
const destDir = path.dirname(destAbs);
|
||||
if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true });
|
||||
fs.writeFileSync(destAbs, JSON.stringify(manifest, null, 2) + '\n');
|
||||
@@ -125,12 +128,15 @@ describe('A: syncManifestVersions — temp fixture', () => {
|
||||
});
|
||||
|
||||
test('non-version fields are preserved after sync', () => {
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const rel = entry.path;
|
||||
const real = JSON.parse(fs.readFileSync(path.join(ROOT, rel), 'utf8'));
|
||||
const tmp = JSON.parse(fs.readFileSync(path.join(tmpRoot, rel), 'utf8'));
|
||||
// Check that every non-version key from the real manifest exists in tmp
|
||||
// Check that every non-version key from the real manifest exists in tmp.
|
||||
// For nested versionKey manifests (e.g. marketplace plugins[0].version),
|
||||
// the comparison is structural at the top level only — the version slot
|
||||
// itself is the one field sync is allowed to change.
|
||||
for (const key of Object.keys(real)) {
|
||||
if (key === 'version') continue;
|
||||
assert.ok(
|
||||
Object.prototype.hasOwnProperty.call(tmp, key),
|
||||
`${rel}: field "${key}" should be preserved after sync`
|
||||
@@ -140,7 +146,8 @@ describe('A: syncManifestVersions — temp fixture', () => {
|
||||
});
|
||||
|
||||
test('each synced file ends with a single trailing newline', () => {
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const rel = entry.path;
|
||||
const raw = fs.readFileSync(path.join(tmpRoot, rel), 'utf8');
|
||||
assert.ok(raw.endsWith('\n'), `${rel} must end with a trailing newline`);
|
||||
assert.ok(!raw.endsWith('\n\n'), `${rel} must not end with a double newline`);
|
||||
@@ -159,15 +166,16 @@ describe('B: real manifests match package.json version', () => {
|
||||
|
||||
const pkgVersion = getPackageVersion(ROOT);
|
||||
|
||||
for (const rel of VERSIONED_MANIFESTS) {
|
||||
test(`${rel} version === ${pkgVersion}`, () => {
|
||||
for (const entry of VERSIONED_MANIFESTS) {
|
||||
const rel = entry.path;
|
||||
test(`${rel} version (${entry.versionKey}) === ${pkgVersion}`, () => {
|
||||
const abs = path.join(ROOT, rel);
|
||||
assert.ok(fs.existsSync(abs), `${rel} must exist at ${abs}`);
|
||||
const m = JSON.parse(fs.readFileSync(abs, 'utf8'));
|
||||
assert.equal(
|
||||
m.version,
|
||||
getByPath(m, entry.versionKey),
|
||||
pkgVersion,
|
||||
`${rel} version (${m.version}) must match package.json version (${pkgVersion}). ` +
|
||||
`${rel} version (${entry.versionKey} = ${getByPath(m, entry.versionKey)}) must match package.json version (${pkgVersion}). ` +
|
||||
'Run `node scripts/sync-manifest-versions.cjs` to fix.'
|
||||
);
|
||||
});
|
||||
@@ -254,7 +262,7 @@ describe('C: regression guard — version-bearing JSON files must be registered'
|
||||
// version-swept by syncCapabilityVersions (ADR-1244 D6) — discovered by glob,
|
||||
// so every one is "registered" without an explicit entry here.
|
||||
const ALLOWED = new Set([
|
||||
...VERSIONED_MANIFESTS,
|
||||
...VERSIONED_MANIFEST_PATHS,
|
||||
...listCapabilityManifests({ root: ROOT }),
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
|
||||
@@ -107,14 +107,15 @@ describe('Require Issue Link back-merge automation carve-out', () => {
|
||||
describe('Auto-backmerge needs_review version-manifest carve-out (#1404)', () => {
|
||||
const workflow = readWorkflow('.github/workflows/auto-backmerge.yml');
|
||||
|
||||
test('all four version manifests are filtered via version-only detection', () => {
|
||||
// package.json / package-lock.json / plugin.json / gemini-extension.json
|
||||
// diverge every release; a drop that is ONLY "version" lines must not park
|
||||
// (parking is what lets the back-merge go stale). A substantive change still
|
||||
// parks. (#1404)
|
||||
test('all version-bearing manifests are filtered via version-only detection', () => {
|
||||
// package.json / package-lock.json / plugin.json / marketplace.json /
|
||||
// gemini-extension.json diverge every release; a drop that is ONLY
|
||||
// "version" lines must not park (parking is what lets the back-merge go
|
||||
// stale). A substantive change still parks. The grep matches the indented
|
||||
// "version": line for marketplace.json's plugins[0].version too. (#1404 / #1855)
|
||||
assert.ok(
|
||||
workflow.includes("VERSION_STAMP_MANIFESTS='package.json package-lock.json .claude-plugin/plugin.json gemini-extension.json'"),
|
||||
'auto-backmerge.yml must version-only-filter all four version manifests'
|
||||
workflow.includes("VERSION_STAMP_MANIFESTS='package.json package-lock.json .claude-plugin/plugin.json .claude-plugin/marketplace.json gemini-extension.json'"),
|
||||
'auto-backmerge.yml must version-only-filter all version-bearing manifests (incl. marketplace.json #1855)'
|
||||
);
|
||||
assert.ok(
|
||||
workflow.includes(`grep -vE '^[+-][[:space:]]*"version":'`),
|
||||
|
||||
Reference in New Issue
Block a user