* test(#3763): guard every shipped config-get substitution on --raw * fix(#3763): pass --raw at every shipped config-get bash call site config-get without --raw prints JSON.stringify(value), so string-typed values reach bash with literal quotes and every string comparison silently never matches (#3763). --raw added at 75 command-substitution sites across shipped content; four JSON consumers (default_reviewers, sub_repos, pr_body_sections, code_review_depth_overrides) deliberately keep default JSON output. Emitted-Drift-Ack-Growth: ai-integration-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: audit-fix.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: autonomous.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: cleanup.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: code-review.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: complete-milestone.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: discuss-phase-assumptions.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: do.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: eval-review.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: execute-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: execute-plan.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: fast.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: graduation.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: gsd-executor.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: health.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: import.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: inbox.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: ingest-docs.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: mvp-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: new-milestone.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: next.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: plan-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: plan-review-convergence.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: plant-seed.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: profile-user.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: progress.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: quick.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: remove-workspace.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: secure-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: settings-integrations.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: settings.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: ship.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: sketch-wrap-up.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: sketch.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: smart-entry.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: spike-wrap-up.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: spike.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: ui-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: ui-review.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: undo.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted Emitted-Drift-Ack-Growth: validate-phase.md — #3763: bytes from '--raw' at config-get call sites so string-typed config values reach bash comparisons unquoted * chore(#3763): changeset fragment (pr number backfilled after PR creation) * chore(#3763): backfill changeset PR number (3961) --------- Co-authored-by: sim <sim@local>
92 lines
4.2 KiB
JavaScript
92 lines
4.2 KiB
JavaScript
'use strict';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// #3763 — every `config-get` command substitution in shipped content passes
|
|
// `--raw` (or is an exempt JSON consumer).
|
|
//
|
|
// `query config-get <key>` without `--raw` prints `JSON.stringify(value)`, so
|
|
// a STRING-typed value reaches a bash variable with embedded literal quotes
|
|
// (`RUNTIME='"claude"'`) and every downstream `[ "$X" = "y" ]` / `case "$X"`
|
|
// comparison silently never matches. Boolean and numeric values are identical
|
|
// either way, which is exactly why the string sites survived testing.
|
|
//
|
|
// Exempt shapes (consumers that WANT JSON — an object/array value):
|
|
// * the receiving variable's name ends in `_JSON`
|
|
// * the call carries a JSON-array default (`--default '[]'` / `--default "[]"`)
|
|
// Anything else that command-substitutes config-get must pass `--raw`.
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
const { test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const REPO_ROOT = path.join(__dirname, '..');
|
|
const SCAN_ROOTS = [
|
|
'gsd-core/workflows',
|
|
'commands',
|
|
'agents',
|
|
'skills',
|
|
];
|
|
|
|
function walk(dir, out) {
|
|
let entries;
|
|
try {
|
|
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
} catch {
|
|
return;
|
|
}
|
|
for (const entry of entries) {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) walk(full, out);
|
|
else if (entry.isFile() && entry.name.endsWith('.md')) out.push(full);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// A config-get COMMAND SUBSTITUTION: any command substitution containing
|
|
// config-get — `$(gsd_run query config-get ...)`, `$(gsd-tools ... config-get
|
|
// ...)`, etc. Prose mentions (no `$(`) do not match; nested `$( )` inside the
|
|
// substitution is not a shape the shipped trees use for config-get calls.
|
|
const SUBSTITUTION_RE = /\$\([^)]*config-get[^)]*\)/g;
|
|
|
|
test('#3763: every config-get command substitution in shipped content passes --raw (or is an exempt JSON consumer)', () => {
|
|
const files = [];
|
|
for (const root of SCAN_ROOTS) walk(path.join(REPO_ROOT, root), files);
|
|
|
|
const offenders = [];
|
|
let scannedSubstitutions = 0;
|
|
for (const file of files) {
|
|
const rel = path.relative(REPO_ROOT, file);
|
|
const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/);
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (!line.includes('config-get')) continue;
|
|
const subs = line.match(SUBSTITUTION_RE) || [];
|
|
for (const sub of subs) {
|
|
scannedSubstitutions++;
|
|
// --raw must sit in the config-get command itself, before any `||`
|
|
// fallback — an `echo "" --raw` fallback arg would otherwise
|
|
// false-pass the check while config-get still lacks the flag.
|
|
const cmd = sub.slice(0, sub.indexOf('||') >= 0 ? sub.indexOf('||') : sub.length);
|
|
if (cmd.includes('--raw')) continue;
|
|
// Exempt shapes: a JSON-consuming variable name, or an explicit
|
|
// JSON-array default — the caller wants JSON.stringify output.
|
|
const varMatch = /^\s*[A-Za-z_][A-Za-z0-9_]*=/.exec(line);
|
|
const varName = varMatch ? varMatch[0].trimEnd().slice(0, -1) : '';
|
|
if (varName.endsWith('_JSON')) continue;
|
|
if (/--default\s+('\[\]'|"\[\]")/.test(sub)) continue;
|
|
offenders.push(`${rel}:${i + 1}: ${line.trim()}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
assert.ok(scannedSubstitutions > 20,
|
|
`guard self-check: expected to scan dozens of config-get substitutions, found ${scannedSubstitutions} — the scan roots or matcher rotted`);
|
|
assert.deepEqual(
|
|
offenders,
|
|
[],
|
|
`#3763: config-get command substitutions without --raw feed JSON.stringify output into bash string comparisons (they silently never match for string values). Add --raw, or rename the receiving variable to *_JSON / pass a '[]' default if the consumer parses JSON. Offenders:\n${offenders.join('\n')}`,
|
|
);
|
|
});
|