* fix(#4482): strip Copilot notes from OpenCode artifacts * chore: add changeset for #4532 * fix(#4482): filter runtime notes across emitted surfaces * fix(#4482): make runtime note filtering runtime-neutral * test(#4482): keep ack fixture outside registered provenance --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -618,17 +618,18 @@ test('a ripple names the unexplained path and not the explained one', () => {
|
||||
assert.ok(!r.ok);
|
||||
});
|
||||
|
||||
test('a converter change fails without an ack and passes with one', () => {
|
||||
test('an unregistered converter change fails without an ack and passes with one', () => {
|
||||
// #2723 AC: "simulate a legitimate converter change: assert it fails without an ack
|
||||
// entry and passes with one." A converter edit moves emitted bytes for files whose
|
||||
// sources nobody touched — ADR-2264's "~5% git cannot review".
|
||||
// entry and passes with one." Registered transforms are now first-class provenance
|
||||
// (covered above), so use a converter path the rule does not declare to retain this
|
||||
// guard for ADR-2264's "~5% git cannot review" rather than contradicting that model.
|
||||
const moved = {};
|
||||
const base = {};
|
||||
for (let i = 0; i < 25; i++) {
|
||||
base[`skills/gsd-cmd-${i}/SKILL.md`] = `h${i}`;
|
||||
moved[`skills/gsd-cmd-${i}/SKILL.md`] = `x${i}`;
|
||||
}
|
||||
const changedPaths = ['src/runtime-artifact-conversion.cts'];
|
||||
const changedPaths = ['src/unregistered-runtime-converter.cts'];
|
||||
|
||||
const without = diffEmitted({ baseline: mf(base), current: mf(moved), changedPaths });
|
||||
assert.equal(without.unattributable.length, 25);
|
||||
|
||||
@@ -42,6 +42,7 @@ const {
|
||||
HOOKS_WINDOWS_SHIM_SRC,
|
||||
KIMI_ROOT_AGENT_SRC,
|
||||
AGENT_TRANSFORM_SRCS,
|
||||
RUNTIME_NOTE_FILTER_TRANSFORM_SRCS,
|
||||
stripSkillPrefix,
|
||||
matchRules,
|
||||
attributeEmittedPath,
|
||||
@@ -359,8 +360,22 @@ test('agents-verbatim is reclassified to derived with the same transforms, sourc
|
||||
assert.deepEqual(got.transforms, AGENT_TRANSFORM_SRCS);
|
||||
});
|
||||
|
||||
test('#4482 runtime-note-filtered command and skill surfaces declare their converter transform', () => {
|
||||
const command = attributeEmittedPath('commands/gsd-plan-phase.md', 'opencode');
|
||||
const skill = attributeEmittedPath('skills/gsd-plan-phase/SKILL.md', 'opencode');
|
||||
const workflow = attributeEmittedPath('gsd-core/workflows/mvp-phase.md', 'opencode');
|
||||
assert.deepEqual(command.transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS);
|
||||
assert.deepEqual(skill.transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS);
|
||||
assert.deepEqual(workflow.transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS);
|
||||
|
||||
assert.deepEqual(attributeEmittedPath('commands/gsd-plan-phase.md', 'kilo').transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS);
|
||||
assert.deepEqual(attributeEmittedPath('skills/gsd-plan-phase/SKILL.md', 'cursor').transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS);
|
||||
assert.deepEqual(attributeEmittedPath('gsd-core/workflows/mvp-phase.md', 'claude-local').transforms, RUNTIME_NOTE_FILTER_TRANSFORM_SRCS);
|
||||
assert.deepEqual(attributeEmittedPath('gsd-core/workflows/mvp-phase.md', 'copilot').transforms, []);
|
||||
});
|
||||
|
||||
test('a rule with no transforms field still returns an empty array, never undefined', () => {
|
||||
const got = attributeEmittedPath('gsd-core/workflows/plan-phase.md', 'claude');
|
||||
const got = attributeEmittedPath('agents/gsd-planner.agent.md', 'copilot');
|
||||
assert.deepEqual(got.transforms, [], 'absence of transforms must be a stable empty array, not undefined');
|
||||
});
|
||||
|
||||
|
||||
@@ -182,6 +182,18 @@ const ZCODE_BODY_TRANSFORM_SRCS = [
|
||||
'bin/install.js',
|
||||
];
|
||||
|
||||
// #4482: every non-Copilot runtime filters audience-specific notes through the
|
||||
// conversion module and the published installer copy path.
|
||||
const RUNTIME_NOTE_FILTER_TRANSFORM_SRCS = [
|
||||
'src/runtime-artifact-conversion.cts',
|
||||
'bin/install.js',
|
||||
];
|
||||
const RUNTIME_NOTE_FILTERED_FAMILIES = new Set(
|
||||
MANIFEST_FAMILIES
|
||||
.filter(({ runtime }) => runtime !== 'copilot')
|
||||
.map(({ name }) => name),
|
||||
);
|
||||
|
||||
/**
|
||||
* A `sources` entry ending in `/` is a PREFIX, not a file: it means "any repo path
|
||||
* under this directory legitimately explains this emitted path". Used where an
|
||||
@@ -266,13 +278,22 @@ const PROVENANCE_RULES = [
|
||||
// ── Verbatim engine payload ────────────────────────────────────────────────
|
||||
{
|
||||
id: 'gsd-core-verbatim',
|
||||
kind: 'identity',
|
||||
// Markdown payloads pass through copyWithPathReplacement's audience
|
||||
// filter for non-Copilot runtimes. Non-Markdown payloads remain byte-for-
|
||||
// byte copies, but one rule has one kind; the match-specific transform
|
||||
// list below keeps the causal attribution precise.
|
||||
kind: 'derived',
|
||||
roots: ['gsd-core'],
|
||||
// Enumerated subdirs, NOT `.+`: a new gsd-core/<subdir> must fail totality
|
||||
// loudly rather than being absorbed silently. Also keeps this mutually
|
||||
// exclusive with the two synthesized gsd-core top-level files below.
|
||||
pattern: /^(workflows|references|templates|contexts|bin)\/.+$/,
|
||||
sources: (m) => [`gsd-core/${m[0]}`],
|
||||
transforms: (m, ctx) => (
|
||||
m[0].endsWith('.md') && RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime)
|
||||
? RUNTIME_NOTE_FILTER_TRANSFORM_SRCS
|
||||
: []
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'gsd-core-commands-corpus',
|
||||
@@ -525,6 +546,7 @@ const PROVENANCE_RULES = [
|
||||
// ZCODE_BODY_TRANSFORM_SRCS), so the converter change explains theirs too.
|
||||
transforms: (_m, ctx) => {
|
||||
if (ctx.runtime === 'antigravity') return ANTIGRAVITY_SKILL_TRANSFORM_SRCS;
|
||||
if (RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime)) return RUNTIME_NOTE_FILTER_TRANSFORM_SRCS;
|
||||
if (ctx.runtime === 'zcode') return ZCODE_BODY_TRANSFORM_SRCS;
|
||||
return [];
|
||||
},
|
||||
@@ -540,7 +562,11 @@ const PROVENANCE_RULES = [
|
||||
sources: (m) => [`${COMMANDS_SRC}/${stripSkillPrefix(m[2])}.md`],
|
||||
// #4002: zcode's nested router children pass through the same rewrite pass
|
||||
// as its flat skills — see ZCODE_BODY_TRANSFORM_SRCS.
|
||||
transforms: (_m, ctx) => (ctx.runtime === 'zcode' ? ZCODE_BODY_TRANSFORM_SRCS : []),
|
||||
transforms: (_m, ctx) => {
|
||||
if (RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime)) return RUNTIME_NOTE_FILTER_TRANSFORM_SRCS;
|
||||
if (ctx.runtime === 'zcode') return ZCODE_BODY_TRANSFORM_SRCS;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'flat-commands-from-commands',
|
||||
@@ -548,9 +574,14 @@ const PROVENANCE_RULES = [
|
||||
roots: ['commands', 'command'],
|
||||
pattern: /^gsd-([^/]+)\.md$/,
|
||||
sources: (m) => [`${COMMANDS_SRC}/${m[1]}.md`],
|
||||
// #4482: OpenCode command bodies pass through its command converter.
|
||||
// #4002: zcode command bodies pass through _applyRuntimeRewrites with
|
||||
// converter: null — see ZCODE_BODY_TRANSFORM_SRCS above.
|
||||
transforms: (_m, ctx) => (ctx.runtime === 'zcode' ? ZCODE_BODY_TRANSFORM_SRCS : []),
|
||||
transforms: (_m, ctx) => {
|
||||
if (RUNTIME_NOTE_FILTERED_FAMILIES.has(ctx.runtime)) return RUNTIME_NOTE_FILTER_TRANSFORM_SRCS;
|
||||
if (ctx.runtime === 'zcode') return ZCODE_BODY_TRANSFORM_SRCS;
|
||||
return [];
|
||||
},
|
||||
},
|
||||
|
||||
// ── Descriptor-declared native plugin / extension ─────────────────────────
|
||||
@@ -920,6 +951,7 @@ module.exports = {
|
||||
SKILLS_ROOTS,
|
||||
KIMI_ROOT_AGENT_SRC,
|
||||
AGENT_TRANSFORM_SRCS,
|
||||
RUNTIME_NOTE_FILTER_TRANSFORM_SRCS,
|
||||
SOURCE_PREFIX_SUFFIX,
|
||||
HOOKS_ROOTS,
|
||||
COMMANDS_SRC,
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
const {
|
||||
@@ -73,6 +75,70 @@ const flatRuntimeSuites = [
|
||||
},
|
||||
];
|
||||
|
||||
describe('#4482: OpenCode conversion strips Copilot-only runtime notes', () => {
|
||||
const convert = (body) => liveConversion.convertClaudeToOpencodeFrontmatter(
|
||||
['---', 'name: gsd-test', 'description: test', '---', body].join('\n'),
|
||||
);
|
||||
|
||||
test('removes a Copilot-only note without leaving an empty wrapper', () => {
|
||||
const out = convert([
|
||||
'<runtime_note>',
|
||||
'**Copilot (VS Code):** Use `vscode_askquestions` wherever this workflow calls `AskUserQuestion`.',
|
||||
'</runtime_note>',
|
||||
'',
|
||||
'<objective>Keep me.</objective>',
|
||||
].join('\n'));
|
||||
|
||||
assert.ok(!out.includes('vscode_askquestions'));
|
||||
assert.ok(!out.includes('<runtime_note>'));
|
||||
assert.ok(out.includes('<objective>Keep me.</objective>'));
|
||||
});
|
||||
|
||||
test('preserves runtime-neutral fallback text in a mixed note', () => {
|
||||
const out = convert([
|
||||
'<runtime_note>',
|
||||
'**Copilot (VS Code):** Use `vscode_askquestions` instead of `AskUserQuestion`.',
|
||||
'',
|
||||
'**TEXT_MODE fallback:** Present a numbered list when interactive tools are unavailable.',
|
||||
'</runtime_note>',
|
||||
].join('\n'));
|
||||
|
||||
assert.ok(!out.includes('vscode_askquestions'));
|
||||
assert.match(out, /<runtime_note>\n\*\*TEXT_MODE fallback:\*\*/);
|
||||
assert.ok(out.includes('</runtime_note>'));
|
||||
});
|
||||
|
||||
test('leaves unrelated runtime notes intact', () => {
|
||||
const note = '<runtime_note>\n**OpenCode:** Keep this runtime-specific guidance.\n</runtime_note>';
|
||||
assert.ok(convert(note).includes(note));
|
||||
});
|
||||
|
||||
test('the shared audience filter covers every non-Copilot runtime and preserves Copilot', () => {
|
||||
const note = '<runtime_note>\n**Copilot (VS Code):** Use `vscode_askquestions`.\n\n**TEXT_MODE fallback:** Keep me.\n</runtime_note>';
|
||||
const runtimes = [
|
||||
'antigravity', 'augment', 'claude', 'cline', 'codebuddy', 'codex', 'cursor', 'hermes',
|
||||
'kilo', 'kimi', 'kimi-code', 'opencode', 'pi', 'qwen', 'trae', 'windsurf', 'zcode',
|
||||
];
|
||||
for (const runtime of runtimes) {
|
||||
const out = liveConversion.filterRuntimeNotesForTarget(note, runtime);
|
||||
assert.ok(!out.includes('vscode_askquestions'), `${runtime} must not receive the Copilot note`);
|
||||
assert.ok(out.includes('TEXT_MODE fallback'), `${runtime} must retain neutral fallback guidance`);
|
||||
}
|
||||
assert.strictEqual(liveConversion.filterRuntimeNotesForTarget(note, 'copilot'), note);
|
||||
});
|
||||
|
||||
test('a real OpenCode install filters mvp-phase workflow assets too', (t) => {
|
||||
const { runMinimalInstall } = require('./helpers/install-shared.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { configDir, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' });
|
||||
t.after(() => cleanup(root));
|
||||
const installed = fs.readFileSync(path.join(configDir, 'gsd-core', 'workflows', 'mvp-phase.md'), 'utf8');
|
||||
assert.ok(!installed.includes('vscode_askquestions'));
|
||||
assert.ok(installed.includes('TEXT_MODE fallback'));
|
||||
assert.ok(installed.includes('<runtime_note>'));
|
||||
});
|
||||
});
|
||||
|
||||
for (const { label, convert, configDir } of flatRuntimeSuites) {
|
||||
describe(`${label} agent conversion (isAgent: true)`, () => {
|
||||
test('keeps name: field for agents', () => {
|
||||
@@ -273,6 +339,19 @@ describe('convertClaudeToKiloFrontmatter output parity: bin/install.js vs runtim
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertClaudeToOpencodeFrontmatter output parity: bin/install.js vs runtime-artifact-conversion.cjs (#4482)', () => {
|
||||
const { convertClaudeToOpencodeFrontmatter: convertViaConversionModule } =
|
||||
require('../gsd-core/bin/lib/runtime-artifact-conversion.cjs');
|
||||
const input = `${SAMPLE_COMMAND}\n\n<runtime_note>\n**Copilot (VS Code):** Use vscode_askquestions.\n</runtime_note>`;
|
||||
|
||||
test('published and module converters filter runtime notes identically', () => {
|
||||
assert.equal(
|
||||
convertClaudeToOpencodeFrontmatter(input),
|
||||
convertViaConversionModule(input),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DEFECT.GENERATIVE-FIX output-parity guard: convertClaudeCommandToTraeSkill is
|
||||
// defined TWICE — once in bin/install.js (dead for the live skills-install
|
||||
|
||||
Reference in New Issue
Block a user