* chore: rename npm package + bin to @opengsd/gsd-core (functional) - package.json: name @opengsd/get-shit-done-redux → @opengsd/gsd-core, bin key get-shit-done-redux → gsd-core, repository/homepage/bugs URLs - package-lock.json: regenerated (npm install --package-lock-only) - tests/**, scripts/**, bin/**, .github/**, agents/**, commands/**, get-shit-done/bin/**, get-shit-done/workflows/**: applied the 4-rule replacement (scoped npm ref, GitHub repo path, bin/clone invocations) per #505 single-source refactor Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: sweep live references to @opengsd/gsd-core Update all live documentation (README.md + translations, docs/**, CONTRIBUTING.md, VERSIONING.md, SECURITY.md, CONTEXT.md, docs/CANARY.md) to reflect the renamed package and repository. Rules applied: - @opengsd/get-shit-done-redux → @opengsd/gsd-core (scoped npm name) - open-gsd/get-shit-done-redux → open-gsd/gsd-core (GitHub repo) - GSD-redux/get-shit-done-redux → open-gsd/gsd-core (stale badge org) - bare bin/clone refs → gsd-core CHANGELOG.md, docs/adr/**, docs/RELEASE-*.md, docs/research/**, and .changeset/** are preserved byte-identical. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: add negative lookbehind to slash-command regex in bug-2954 test The extractSlashReferences regex matched /gsd-core inside npm package URLs (@opengsd/gsd-core), producing a false /gsd:core command reference. Adding a negative lookbehind (?<![a-z]) excludes matches preceded by a letter, so only standalone /gsd-<cmd> and /gsd:<cmd> tokens are found. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#518): add changeset for package rename Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#518): update package-identity expectations to the renamed coordinates The rebase regenerated the seam to @opengsd/gsd-core (bin gsd-core, repo open-gsd/gsd-core). The #498 seam tests assert deriveIdentity against the REAL package.json, so their expected literals must follow the rename. The drift-lint unit test is left as-is — its SEAM is a self-consistent fixture and its stale-literal detection cases would shift if altered; the live-repo scan in it already passes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
192 lines
8.0 KiB
JavaScript
192 lines
8.0 KiB
JavaScript
/**
|
|
* Bug #2876: SKILL.md frontmatter parse failure when `description` begins
|
|
* with a YAML flow indicator like `[BETA]`.
|
|
*
|
|
* description: [BETA] Offload plan phase to Claude Code's ultraplan…
|
|
*
|
|
* YAML 1.2 treats a leading `[` as the start of a flow sequence, so any
|
|
* downstream parser (gh-copilot, JetBrains' kit, etc.) fails with
|
|
* "Unexpected scalar at node end". The Copilot/Antigravity/Trae/Codebuddy
|
|
* skill+agent converters in `bin/install.js` re-emit the description
|
|
* unquoted; the Claude variant `yamlQuote(...)`s it. Bring the others
|
|
* in line so any value is round-trip-safe regardless of leading char.
|
|
*
|
|
* The test is structural: it parses each emitted frontmatter into lines
|
|
* and asserts the `description` value is a quoted YAML scalar (double or
|
|
* single quoted) when the source description starts with a flow indicator.
|
|
* It does not regex the bytes for substrings.
|
|
*/
|
|
'use strict';
|
|
|
|
process.env.GSD_TEST_MODE = '1';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const pkg = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf-8'));
|
|
const installPath = path.resolve(REPO_ROOT, pkg.bin['gsd-core']);
|
|
const install = require(installPath);
|
|
|
|
// Build a minimal Claude command source whose description starts with the
|
|
// reporter's exact flow-indicator prefix. Apostrophe in the body forces
|
|
// any naive single-quoting to also escape correctly — the canonical
|
|
// safe form is `JSON.stringify(...)` (used by yamlQuote).
|
|
const REPORTER_DESCRIPTION =
|
|
"[BETA] Offload plan phase to Claude Code's ultraplan cloud — drafts remotely while terminal stays free, review in browser with inline comments, import back via /gsd-import. Claude Code only.";
|
|
|
|
// Use unquoted description in the source frontmatter — that's exactly the
|
|
// shape that ships in commands/gsd/*.md when authors paste a description
|
|
// without quoting it (see commands/gsd/ultraplan-phase.md). The bug is
|
|
// triggered when the converter re-emits this same value to the destination
|
|
// runtime without quoting. `extractFrontmatterField` strips a single outer
|
|
// quote pair but does not unescape internal characters, so quoting the
|
|
// fixture input would actually mask the bug.
|
|
function buildClaudeCommand(description) {
|
|
return [
|
|
'---',
|
|
'name: gsd:ultraplan-phase',
|
|
`description: ${description}`,
|
|
'argument-hint: "[phase-number]"',
|
|
'allowed-tools:',
|
|
' - Read',
|
|
' - Bash',
|
|
'---',
|
|
'',
|
|
'# body',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function buildClaudeAgent(description) {
|
|
return [
|
|
'---',
|
|
'name: gsd-extract-learnings',
|
|
`description: ${description}`,
|
|
'tools: Read, Bash',
|
|
'---',
|
|
'',
|
|
'# body',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function extractFrontmatter(content) {
|
|
// Leading delimiter is `---\n`; closing is the next standalone `---`
|
|
// on its own line. Tests parse line-structurally so the assertion
|
|
// doesn't drift on whitespace/order changes (per project test-rigor).
|
|
assert.ok(content.startsWith('---'), `output must begin with frontmatter, got: ${content.slice(0, 40)}`);
|
|
const lines = content.split('\n');
|
|
let openIdx = -1;
|
|
let closeIdx = -1;
|
|
for (let i = 0; i < lines.length; i += 1) {
|
|
if (lines[i] === '---') {
|
|
if (openIdx === -1) openIdx = i;
|
|
else { closeIdx = i; break; }
|
|
}
|
|
}
|
|
assert.ok(openIdx !== -1 && closeIdx !== -1, `output must have a closed frontmatter block, got:\n${content}`);
|
|
return lines.slice(openIdx + 1, closeIdx);
|
|
}
|
|
|
|
function findDescriptionLine(frontmatterLines) {
|
|
for (const line of frontmatterLines) {
|
|
if (line.startsWith('description:')) return line;
|
|
}
|
|
assert.fail(`no description line found in frontmatter:\n${frontmatterLines.join('\n')}`);
|
|
return ''; // unreachable
|
|
}
|
|
|
|
function isQuotedYamlScalar(valueText) {
|
|
// YAML safe-quoted scalar: starts with `"` and ends with `"`, OR
|
|
// starts with `'` and ends with `'`. This is what `yamlQuote()`
|
|
// (JSON.stringify) and the Claude variant of these converters emit.
|
|
const trimmed = valueText.trim();
|
|
if (trimmed.startsWith('"') && trimmed.endsWith('"')) return true;
|
|
if (trimmed.startsWith("'") && trimmed.endsWith("'")) return true;
|
|
return false;
|
|
}
|
|
|
|
function parseQuotedYamlValue(valueText) {
|
|
const trimmed = valueText.trim();
|
|
if (trimmed.startsWith('"')) return JSON.parse(trimmed);
|
|
if (trimmed.startsWith("'")) return trimmed.slice(1, -1).replace(/''/g, "'");
|
|
return trimmed;
|
|
}
|
|
|
|
function assertDescriptionRoundTrips(emitted, expected, label) {
|
|
const fmLines = extractFrontmatter(emitted);
|
|
const descLine = findDescriptionLine(fmLines);
|
|
const valueText = descLine.slice('description:'.length);
|
|
assert.ok(
|
|
isQuotedYamlScalar(valueText),
|
|
`(${label}) description must be a quoted YAML scalar (parser-safe for leading flow indicators). Got line: ${descLine}`,
|
|
);
|
|
assert.strictEqual(
|
|
parseQuotedYamlValue(valueText),
|
|
expected,
|
|
`(${label}) description must round-trip through YAML quoting unchanged.`,
|
|
);
|
|
}
|
|
|
|
const COMMAND_CONVERTERS = [
|
|
{ label: 'convertClaudeCommandToCopilotSkill', fn: (src) => install.convertClaudeCommandToCopilotSkill(src, 'gsd-ultraplan-phase') },
|
|
{ label: 'convertClaudeCommandToAntigravitySkill', fn: (src) => install.convertClaudeCommandToAntigravitySkill(src, 'gsd-ultraplan-phase') },
|
|
{ label: 'convertClaudeCommandToTraeSkill', fn: (src) => install.convertClaudeCommandToTraeSkill(src, 'gsd-ultraplan-phase') },
|
|
{ label: 'convertClaudeCommandToCodebuddySkill', fn: (src) => install.convertClaudeCommandToCodebuddySkill(src, 'gsd-ultraplan-phase') },
|
|
];
|
|
|
|
const AGENT_CONVERTERS = [
|
|
{ label: 'convertClaudeAgentToCopilotAgent', fn: (src) => install.convertClaudeAgentToCopilotAgent(src) },
|
|
{ label: 'convertClaudeAgentToAntigravityAgent', fn: (src) => install.convertClaudeAgentToAntigravityAgent(src) },
|
|
];
|
|
|
|
// A grab-bag of leading characters that all break unquoted YAML scalar
|
|
// parsing per YAML 1.2 §7.3.3 / §6.9. The reporter's case is `[`; the
|
|
// rest defend against neighbouring drift.
|
|
const FLOW_HOSTILE_PREFIXES = ['[', '{', '*', '&', '!', '|', '>', '%', '@', '`'];
|
|
|
|
// Some converters (Trae, CodeBuddy) deliberately rewrite "Claude Code"
|
|
// in body content to their target runtime name, and the rewrite cuts
|
|
// across the description too. That's correct behavior — out of scope for
|
|
// the YAML-quoting fix — so for the reporter case we assert only the
|
|
// quoting requirement, not byte-equality of the round-tripped value.
|
|
function assertDescriptionIsQuoted(emitted, label) {
|
|
const fmLines = extractFrontmatter(emitted);
|
|
const descLine = findDescriptionLine(fmLines);
|
|
const valueText = descLine.slice('description:'.length);
|
|
assert.ok(
|
|
isQuotedYamlScalar(valueText),
|
|
`(${label}) description must be a quoted YAML scalar (parser-safe for leading flow indicators). Got line: ${descLine}`,
|
|
);
|
|
}
|
|
|
|
describe('bug-2876: skill+agent converters emit YAML-quoted description', () => {
|
|
for (const { label, fn } of COMMAND_CONVERTERS) {
|
|
test(`${label}: reporter's "[BETA] ..." description is quoted`, () => {
|
|
const out = fn(buildClaudeCommand(REPORTER_DESCRIPTION));
|
|
assertDescriptionIsQuoted(out, label);
|
|
});
|
|
for (const prefix of FLOW_HOSTILE_PREFIXES) {
|
|
test(`${label}: leading ${JSON.stringify(prefix)} is quoted`, () => {
|
|
// Avoid leading/trailing `'` or `"` in the payload — `extractFrontmatterField`
|
|
// strips a single outer quote char of either kind regardless of whether
|
|
// the value was actually quoted, which would obscure the round-trip
|
|
// assertion. Pre-existing behavior, out of scope for #2876.
|
|
const desc = `${prefix} edge-case payload — flow indicator at start`;
|
|
const out = fn(buildClaudeCommand(desc));
|
|
assertDescriptionRoundTrips(out, desc, `${label} prefix=${prefix}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
for (const { label, fn } of AGENT_CONVERTERS) {
|
|
test(`${label}: reporter-shape "[BETA] ..." description is quoted`, () => {
|
|
const out = fn(buildClaudeAgent(REPORTER_DESCRIPTION));
|
|
assertDescriptionIsQuoted(out, label);
|
|
});
|
|
}
|
|
});
|