* chore(#604): rename get-shit-done/ runtime directory to gsd-core/ Renames the installed runtime directory `get-shit-done/` to `gsd-core/` so the on-disk name matches the package (`@opengsd/gsd-core`), repo, and binary (`gsd-tools`). The npm package name and binary are unchanged; npx/npm consumers are unaffected. Mechanical (bulk, ~90% of the diff): - `git mv get-shit-done gsd-core` - Swept path/identifier references across the repo via `perl -pe 's/get-shit-done(?!-\w)/gsd-core/g'`. The negative lookahead preserves the five legitimate slug variants that are NOT the directory: get-shit-done-{OLD,cc,classic,cli,redux} (old package/repo names). - Build/manifest wiring: package.json (bin, files, coverage globs), tsconfig.build.json (outDir), ~86 .gitignore build-output entries, stryker.config.mjs, scan-ignore files, install.js path strings. - Frozen (not rewritten): CHANGELOG.md history; translated docs (README.<locale>.md and docs/{ja-JP,ko-KR,pt-BR,zh-CN}/). New logic (review here): - src/installer-migrations/003-rename-get-shit-done-to-gsd-core.cts: a proper ADR-0008 installer migration. On upgrade it walks the legacy `~/.claude/get-shit-done/` tree, classifies each file via the prior install manifest, and emits remove-managed / backup-and-remove for managed files while PRESERVING unknown user-added files. Symlink-safe (skips a symlinked root and symlinked entries; bounds-checks every path under configDir). The framework rolls back on install failure. Emptied dirs may remain (framework has no recursive dir-removal primitive) — documented. - scripts/lint-legacy-dir-name.cjs: CI regression guard forbidding the bare `get-shit-done` directory token (split token to avoid self-match; case- insensitive; `(?!-\w)` lookahead allows the slug variants; allowlists CHANGELOG, translated docs, and `gsd-allow-legacy-name` marker lines). Wired into the lint-tests CI job. - Restored scripts/lint-package-identity-drift.cjs detection regexes (the mechanical sweep had wrongly rewritten the old-name patterns it exists to detect) and marked them as intentional legacy references. - TDD tests for the migration and the guard; do.md slash-command guard regex tightened so a `/gsd-core/bin` path segment is not mistaken for a command; changeset + docs/installer-migrations.md row added. Breaking: the installed runtime path moves `~/.claude/get-shit-done/` -> `~/.claude/gsd-core/`. Migration 003 removes the stale legacy dir's managed files (preserving user files) on upgrade. Users with custom hooks/configs hardcoding the old path must update them. Closes #604 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unsweep pending changesets + allowlist injection-example docs CI fixes for the rename PR: - Do not sweep pending .changeset/*.md (ephemeral release-note fragments, like CHANGELOG); reverted those body edits so 5 pre-existing malformed fragments (missing type/pr) no longer enter the PR diff and trip docs-lint. Allowlisted .changeset/ in the legacy-name guard accordingly. - Allowlisted TEST-EXAMPLES.md and docs/explanation/security-model.md in prompt-injection-scan.sh: they contain intentional injection examples / security-model prose; the path-reference rewrites are kept. CodeQL alerts on this PR are pre-existing (alert lines unchanged by this PR; none in the new migration/guard) and are out of scope for the rename. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): resolve CodeQL alerts surfaced on this PR The rename diff touched files carrying pre-existing CodeQL findings; per the no-pre-existing-dismissal rule, fixing every surfaced alert rather than waving them off. All behavior-preserving: - scripts/ci-test-scope.cjs: build the config-path match from string .includes() instead of a RegExp over an arg-derived value (js/regex-injection). - src/profile-output.cts: escape backslashes before pipe-escaping desc/safeName so the table-cell escape is complete (js/incomplete-sanitization). - tests/{bug-2643,bug-2808,docs-parity-live-registry}: two-pass HTML-comment strip so a bare/unclosed `<!--` cannot survive (js/incomplete-multi-character-sanitization). - tests/inline-plan-threshold: drop the no-op `\s`->`\s` identity replace, keep the meaningful POSIX-class conversion (js/identity-replacement). Verified: build:lib green; the touched test files + ci-test-scope + profile-output suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): correctly resolve remaining CodeQL alerts (regex-injection + sanitization) The prior commit's fixes for two alerts were ineffective: - ci-test-scope.cjs js/regex-injection: the alert is the CLI-arg-derived `file` reaching static regex `.test(file)` calls (not the config rule). Removed ALL regex over file/t — startsWith/includes/=== string checks + an isWindowsHint helper — so there is no regex sink for the tainted value. - js/incomplete-multi-character-sanitization (3 test files): a single `.replace(/<!--...-->/g,'')` can let `<!--` re-form. Replaced with a fixpoint loop (replace until stable) plus a final bare-opener strip. Verified: no regex over file/t remains; ci-test-scope + the 3 test suites pass; lint:legacy-name clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): make ci-test-scope + comment-strippers regex-free to clear CodeQL CodeQL flags the regex PATTERNS syntactically (regex-injection on the --files arg split; incomplete-multi-character-sanitization on the <!--...--> replace), so loop fixes do not satisfy it. Made these paths regex-free: - ci-test-scope.cjs splitFiles: char-by-char separator tokenizer (no /[,\\s]+/). - 3 test files: indexOf/slice HTML-comment stripper (no .replace(/<!--/)). Behavior preserved; ci-test-scope + the 3 suites pass; guard clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): unblock security base64 scan on the large rename diff The security job hit its 10m timeout: base64-scan.sh choked on the binary test fixture tests/feat-3594-parser-property-style.test.cjs (embedded NUL/ non-UTF8 bytes -> thousands of bogus blobs + "ignored null byte" warnings), and the ~800-file rename diff is slow to scan regardless. - scripts/base64-scan.sh: skip binary-by-content files (grep -Iq .) — they can't carry base64-obfuscated *text* and feeding NUL bytes through the per-line scanner is pathologically slow. collect_files already filtered binary *extensions*; this catches binary *content* in text extensions. - .github/workflows/security-scan.yml: raise the security job timeout 10m->30m to accommodate very large diffs (the scan itself is unchanged). Verified locally: scan skips the fixture, 0 "ignored null byte" warnings, 0 findings, exit 0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): sweep get-shit-done refs introduced by merging next The branch was updated with next (#614/#384/#618 etc.), which reference the get-shit-done/ dir (still named that on next). Swept the stale references in the merged files to gsd-core so the rename stays consistent and lint:legacy-name passes: - commands/gsd/discuss-phase.md (runtime-launcher shim paths) - src/core.cts (getAgentsDir layout comments) - tests/bug-384-agents-runtime-aware.test.cjs (require path to runtime lib) Verified: guard 0 violations; build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): exclude gsd-core/ path segments from bug-3683 command cross-ref invariant The #614 runtime-launcher shim added to discuss-phase.md references `${_GSD_RUNTIME_ROOT}/gsd-core/bin/...`. bug-3683's REF_PATTERN excluded path-y refs only via lookbehind, but `}` precedes `/gsd-core/` in the shim, so it mis-read the directory path as a dangling `/gsd-core` command ref (same class as the #604 bug-2954 fix). Added a trailing `(?![\w-]*\/)` so `/gsd-<x>/...` path segments are not treated as slash-command references. Verified locally on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22 image) full suite: 0 failures - bug-3683 + bug-2954 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): lazily resolve findProjectRoot in gsd-tools (harden flaky CI) CI intermittently failed state.test's gsd-tools subprocess with "findProjectRoot is not a function" (flip-flopping across legs; not reproducible on mac full suite, gsd-test linux full suite, test:unit, or state.test x8). findProjectRoot is a re-export from core.cjs (sourced from project-root.cjs); binding it via destructure at module-load can be undefined under a load-ordering edge. Resolve it lazily at call time via a small wrapper so the lookup happens after core.cjs is fully initialized. Verified green on BOTH platforms before pushing: - mac (node 26) full suite: 0 failures - gsd-test-runner (linux, node22) full suite: 0 failures - state.test.cjs: 106/106; gsd-tools loads cleanly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#604): allowlist verification-patterns.md placeholder examples in secret scan The rename git-mv'd references/verification-patterns.md into gsd-core/, pulling it into the secret-scan diff. It documents stub/placeholder RED-FLAG env-var examples (illustrative Stripe test-key / database-URL / API-key placeholders) — not real credentials. Added it to .secretscanignore with the strict annotation, mirroring the existing gsd-core/workflows/plan-phase.md exception. Verified locally: secret-scan-lint --strict OK; secret-scan --diff origin/next exits 0 with 0 findings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
252 lines
11 KiB
JavaScript
252 lines
11 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// Three of the assertions in this file (A1, A2, C) inspect agent / workflow
|
|
// `.md` bodies. Those files ARE the runtime contract that GSD loads into agent
|
|
// prompts at run time, so source-text inspection is exactly what the
|
|
// `source-text-is-the-product` exception covers.
|
|
//
|
|
// The remaining assertions (B1, B2, B3) are behavioral — they invoke
|
|
// `gsd-tools commit` against a temp project and assert on its structured
|
|
// JSON return envelope plus the git index state. No raw-text matching on
|
|
// rendered output.
|
|
|
|
/**
|
|
* Regression for #3678 — gsd-executor force-commits .planning/ files when
|
|
* commit_docs is false.
|
|
*
|
|
* Root cause: the executor agent prompt (agents/gsd-executor.md) tells the
|
|
* agent to call `gsd-sdk query commit "docs(...)" --files .planning/...`
|
|
* in the per-plan final_commit block, but the prompt says nothing about
|
|
* what to do when the SDK returns `{committed: false, skipped: true,
|
|
* reason: 'skipped_commit_docs_false'}`. With no explicit instruction, the
|
|
* agent improvises raw `git add` / `git commit` against `.planning/` paths
|
|
* (and uses `-f` to bypass gitignore), which is exactly the leakage the
|
|
* reporter observed.
|
|
*
|
|
* Fix surface:
|
|
* 1. Agent prompt: explicit handling text in the final_commit section.
|
|
* 2. SDK envelope: add `skipped: true` field so agents see "skipped" as a
|
|
* first-class success signal, not "committed is missing, must improvise."
|
|
* 3. Structural guard: ban `git add -f` / `git add --force` from agent and
|
|
* workflow bodies entirely (no GSD-managed surface should force-stage
|
|
* gitignored content).
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const { execFileSync } = require('node:child_process');
|
|
const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
|
|
// Repo root resolution. This test file lives in `<repo>/tests/`. Use a single
|
|
// parent reference (the established repo-wide pattern, e.g. tests/helpers.cjs
|
|
// `path.resolve(__dirname, '..', 'gsd-core', ...)`). A `.git`-anchored
|
|
// walker is not portable because the docker test mirror at `/work` strips the
|
|
// `.git/` directory before running tests.
|
|
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
|
|
const EXECUTOR_AGENT = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
|
|
|
|
// Frozen reason enum mirrors the SDK source — keep in sync with
|
|
// `cmdCommit` in gsd-core/bin/lib/commands.cjs.
|
|
const COMMIT_REASON = Object.freeze({
|
|
SKIPPED_COMMIT_DOCS_FALSE: 'skipped_commit_docs_false',
|
|
SKIPPED_GITIGNORED: 'skipped_gitignored',
|
|
});
|
|
|
|
function git(args, cwd) {
|
|
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] });
|
|
}
|
|
|
|
describe('bug #3678 — executor must respect commit_docs:false', () => {
|
|
|
|
describe('A — agent prompt teaches the agent how to handle commit_docs:false', () => {
|
|
test('A1: agent body explicitly references the SDK skipped envelope', () => {
|
|
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
|
// The prompt must contain at least one literal mention of the skipped
|
|
// reason code OR the `committed: false` envelope so the agent knows
|
|
// that skipping is an intentional control flow, not a failure to work
|
|
// around.
|
|
const mentionsSkipReason = body.includes(COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE);
|
|
const mentionsCommittedFalse = /committed:\s*false/i.test(body);
|
|
const mentionsSkippedTrue = /skipped:\s*true/i.test(body);
|
|
assert.ok(
|
|
mentionsSkipReason || mentionsCommittedFalse || mentionsSkippedTrue,
|
|
'agents/gsd-executor.md must teach the agent how to recognize the '
|
|
+ 'skipped envelope from `gsd-sdk query commit` (one of: '
|
|
+ `'${COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE}', 'committed: false', `
|
|
+ "'skipped: true').",
|
|
);
|
|
});
|
|
|
|
test('A2: agent body explicitly forbids raw git fallback when SDK skips', () => {
|
|
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
|
// Look for an explicit instruction tying the SDK-skipped signal to the
|
|
// forbidden-fallback rule. Accept any of three shapes the doc writer
|
|
// might use: "do not", "must not", or "never" + a verb that names the
|
|
// forbidden action.
|
|
const forbidsFallbackText = /(do not|must not|never)\s+(fall back|fallback|use .*git add|run .*git commit|force[- ]?add)/i;
|
|
assert.ok(
|
|
forbidsFallbackText.test(body),
|
|
'agents/gsd-executor.md must contain an explicit "do not fall back to '
|
|
+ 'raw git" instruction tied to the commit_docs:false / skipped envelope. '
|
|
+ 'Without it, the agent improvises raw `git add` / `git add -f` to '
|
|
+ 'fulfill its "complete plan" goal.',
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('B — SDK behavior: commit_docs:false leaves repo state untouched', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempGitProject();
|
|
// .planning/ already exists from createTempGitProject's setup.
|
|
// Set commit_docs to false on the config.
|
|
const configPath = path.join(tmpDir, '.planning', 'config.json');
|
|
let config = {};
|
|
if (fs.existsSync(configPath)) {
|
|
config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
}
|
|
config.commit_docs = false;
|
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
|
// Make a token edit to .planning/STATE.md so there IS something the SDK
|
|
// could in principle stage (or that an improvising agent could leak).
|
|
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
|
if (!fs.existsSync(statePath)) {
|
|
fs.writeFileSync(statePath, '---\nproject: test\n---\n# State\n');
|
|
}
|
|
fs.appendFileSync(statePath, '\n<!-- token edit for #3678 repro -->\n');
|
|
});
|
|
|
|
afterEach(() => cleanup(tmpDir));
|
|
|
|
test('B1: commit returns committed:false with skipped envelope', () => {
|
|
const result = runGsdTools(
|
|
'commit "docs(test): noop" --files .planning/STATE.md',
|
|
tmpDir,
|
|
);
|
|
assert.ok(result.success, `gsd-tools commit should exit 0 even when skipped: ${result.error || ''}`);
|
|
const envelope = JSON.parse(result.output);
|
|
assert.strictEqual(envelope.committed, false, 'committed must be false when commit_docs is false');
|
|
assert.strictEqual(
|
|
envelope.skipped,
|
|
true,
|
|
'envelope must carry skipped:true so agents see skip as a first-class signal (envelope contract for #3678)',
|
|
);
|
|
assert.strictEqual(
|
|
envelope.reason,
|
|
COMMIT_REASON.SKIPPED_COMMIT_DOCS_FALSE,
|
|
'reason must be the canonical skipped_commit_docs_false code (frozen enum)',
|
|
);
|
|
});
|
|
|
|
test('B2: commit_docs:false leaves the git index empty (no .planning/ staged)', () => {
|
|
runGsdTools(
|
|
'commit "docs(test): noop" --files .planning/STATE.md',
|
|
tmpDir,
|
|
);
|
|
const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir);
|
|
const stagedPlanning = stagedAll
|
|
.split('\n')
|
|
.map(s => s.trim())
|
|
.filter(s => s.startsWith('.planning/'));
|
|
assert.deepStrictEqual(
|
|
stagedPlanning,
|
|
[],
|
|
'no .planning/ files should be staged when commit_docs is false',
|
|
);
|
|
});
|
|
|
|
test('B3: commit_docs:false produces no new commits', () => {
|
|
const headBefore = git(['rev-parse', 'HEAD'], tmpDir).trim();
|
|
runGsdTools(
|
|
'commit "docs(test): noop" --files .planning/STATE.md',
|
|
tmpDir,
|
|
);
|
|
const headAfter = git(['rev-parse', 'HEAD'], tmpDir).trim();
|
|
assert.strictEqual(
|
|
headAfter,
|
|
headBefore,
|
|
'HEAD must not advance when commit_docs is false',
|
|
);
|
|
});
|
|
});
|
|
|
|
test('checklist carve-out preserved for intentional skip', () => {
|
|
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
|
const checklistLine = body
|
|
.split('\n')
|
|
.find(line => /Final metadata commit made/.test(line));
|
|
assert.ok(
|
|
checklistLine,
|
|
'agents/gsd-executor.md must contain a "Final metadata commit made" checklist line',
|
|
);
|
|
assert.ok(
|
|
checklistLine.includes('Final metadata commit'),
|
|
'checklist line must reference "Final metadata commit"',
|
|
);
|
|
assert.ok(
|
|
checklistLine.includes('skipped_commit_docs_false'),
|
|
'checklist line must carve out the intentional-skip case by referencing '
|
|
+ '"skipped_commit_docs_false" — prevents executor from treating an '
|
|
+ 'unchecked mandatory box as a raw-git TODO (regression guard for #3679)',
|
|
);
|
|
});
|
|
|
|
describe('C — structural ban on raw force-add in GSD-managed bodies', () => {
|
|
function scanForForceAdd(rootDir) {
|
|
const offenders = [];
|
|
function walk(dir) {
|
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) { walk(full); continue; }
|
|
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
|
const body = fs.readFileSync(full, 'utf-8');
|
|
const lines = body.split('\n');
|
|
const danger = lines.filter((line) => {
|
|
if (!/git\s+add\s+(-f|--force)\b/.test(line)) return false;
|
|
// Allow prohibition / warning sentences and code-fence prose that
|
|
// frames `git add -f` AS the bug (so an audit comment doesn't
|
|
// create a false positive).
|
|
if (/(do not|don'?t|must not|never|forbidden|prohibited)/i.test(line)) return false;
|
|
if (/(bug|wrong|incorrect|antipattern|anti-pattern|forces?\s+gitignored|leak)/i.test(line)) return false;
|
|
return true;
|
|
});
|
|
if (danger.length > 0) {
|
|
offenders.push({
|
|
file: full.replace(REPO_ROOT + '/', ''),
|
|
lines: danger.map(l => l.trim().slice(0, 120)),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
walk(rootDir);
|
|
return offenders;
|
|
}
|
|
|
|
test('C1: no agent body contains `git add -f` / `git add --force`', () => {
|
|
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'agents'));
|
|
assert.deepStrictEqual(
|
|
offenders,
|
|
[],
|
|
'no agent body may use `git add -f` / `git add --force` outside a '
|
|
+ 'prohibition sentence — agents must never force-stage gitignored '
|
|
+ 'content (regression guard for #3678).',
|
|
);
|
|
});
|
|
|
|
test('C2: no workflow body contains `git add -f` / `git add --force`', () => {
|
|
const offenders = scanForForceAdd(path.join(REPO_ROOT, 'gsd-core', 'workflows'));
|
|
assert.deepStrictEqual(
|
|
offenders,
|
|
[],
|
|
'no workflow body may use `git add -f` / `git add --force` outside a '
|
|
+ 'prohibition sentence (regression guard for #3678).',
|
|
);
|
|
});
|
|
});
|
|
});
|