* test(#1854): failing-first coverage for user-files-backup restore Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(#1854): offer restore for user-added files backed up on update Adds a restore-custom-files gsd-tools verb and wires it into update.md as a restore_custom_files step: plan, compatibility-check against the newly installed release, then restore only on explicit opt-in. The backup is never deleted, a shipped path is never overwritten, and a single unwritable entry does not abort the rest. Also drops the jq pipe from update-context field extraction (#2589 class, missed by that sweep) and repairs a broken code fence in docs/CLI-TOOLS.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1854): reject symlinked restore destinations and backup roots Self-review of the restore path found two write-through holes: copyFileSync follows a symlinked destination, so a link planted at the restore target wrote outside the config dir with every ancestor still a real directory; and statSync on the backup root followed a link, letting the walk read arbitrary files and present them as the user's own backup. Both now lstat. Also marks the report's path/detail strings as untrusted data in update.md so the rendered step cannot carry instructions into the runtime model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1854): move the update-context jq guard into the #2589 sweep update.md joins the AUDITED list rather than carrying a duplicate assertion in the backup-restore suite, and the guard gains a negative-proof companion so 'no jq pipe' cannot pass by the fields simply no longer being read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1854): validate manifest files map shape before trusting it Security review flagged that Object.keys on a non-plain-object files field yields numeric-index keys matching nothing, so the managed-path check dies silently while manifest_found still reports true. Shape, not just type (ADR-227): an array or scalar files map is now an unusable manifest. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1854): size the restore prompt by eligible_count Spec review found the prompt was driven by entries.length, so a backup holding only blocked entries asked "Restore 1 file(s)?" when accepting would restore zero. The question now reads eligible_count, and an all-blocked backup reports its reasons instead of offering a choice that cannot be honored. The decline path names the resolved backup_dir rather than the bare directory name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#1854): use t.skip on hosts without symlink support A bare return in a node:test body registers as a PASS, so the four symlink guards silently reported green on unprivileged Windows instead of skipping. Adds the dangling-link destination case the security review called out, and moves outside-dir teardown to t.after so a failing assert cannot leak it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#1854): unfence restore hint, regen goldens, widen install timeout Three gate failures from the c99d612a5 run, all root-caused: 1. capability-registry (3): update.md's decline message put an instructional 'gsd-tools ...' line in an UNTAGGED fence, and the guard treats untagged fences as shell blocks. Retagged both display blocks as text and switched the hint to the resolved 'node <config-dir>/.../gsd-tools.cjs' form users can actually paste. 2. golden-install-parity (19): update.md and gsd-tools.cjs ship, so every runtime fixture moved. Regenerated; the diff is exactly those two hashes per fixture, no other drift. 3. install.test.cjs (5): one real failure, four cascades. The Cursor suite's before hook died on 'spawnSync ETIMEDOUT' at the 60s cap while the node22 lane passed the SAME commit in 12.7s. A full install measures 13-30s idle, so 60s was under 2x headroom and shrinks with every file added to the payload. Raised to 120s, matching the heavy case already in this file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#1854): backfill changeset pr number to 2679 --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
241 lines
10 KiB
JavaScript
241 lines
10 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* #2589 — config-get / resolve-model / verification.status lookups must not
|
|
* depend on jq.
|
|
*
|
|
* The workflows resolved configured scalars / model ids / verify status with a
|
|
* `gsd_run query <cmd> … | jq … 2>/dev/null || <default>` shape. On any machine
|
|
* without jq (the default on Windows / Git-Bash) the jq stage fails with exit
|
|
* 127, that failure is swallowed by `2>/dev/null` and the trailing `|| default`,
|
|
* so the variable comes back EMPTY — the configured value is silently dropped
|
|
* and the lane falls back to CLI defaults with no diagnostic.
|
|
*
|
|
* gsd-tools ships native flags that do the same job with no external dep:
|
|
* config-get <key> --raw (strips JSON quotes off a scalar)
|
|
* resolve-model <id> --pick model (descends an object)
|
|
* verification.status <dir> --pick status
|
|
*
|
|
* This is a SOURCE-INVARIANT test over the shipped workflow documents: the bug
|
|
* lives in the workflow text, so the regression guard asserts the jq-dependent
|
|
* shapes never return. Workflow .md files are runtime-loaded config documents,
|
|
* so reading them with readFileSync + regex is the sanctioned seam here (the
|
|
* no-source-grep lint targets src/ + bin/lib/, not config/state docs).
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const ROOT = path.join(__dirname, '..');
|
|
const WF = path.join(ROOT, 'gsd-core', 'workflows');
|
|
|
|
// The workflows that #2589 audited. Scoped explicitly so a new workflow
|
|
// introducing the same smell is caught by the general guard below, while these
|
|
// known-fixed files are checked for the exact offending shapes.
|
|
const AUDITED = [
|
|
'review.md',
|
|
'plan-phase.md',
|
|
'ship.md',
|
|
'debug.md',
|
|
'autonomous.md',
|
|
'ai-integration-phase.md',
|
|
'eval-review.md',
|
|
// #1854: update.md was missed by the original sweep — its get_installed_version
|
|
// step piped `update-context --json` through `jq -r`, the same silently-empty
|
|
// shape on a jq-less host, with the whole install context as the blast radius.
|
|
'update.md',
|
|
];
|
|
|
|
function readWorkflow(name) {
|
|
const p = path.join(WF, name);
|
|
return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : null;
|
|
}
|
|
|
|
describe('#2589: config/model/verify lookups do not depend on jq', () => {
|
|
test('config-get lookups use --raw, not a jq pipe', () => {
|
|
// Matches: config-get <key> ... | jq (any key, any jq program).
|
|
// The native --raw flag strips JSON quotes off a scalar with no jq.
|
|
const re = /config-get\b[^|\n]*\|\s*jq\b/;
|
|
for (const name of AUDITED) {
|
|
const content = readWorkflow(name);
|
|
if (content == null) continue;
|
|
const matches = content.match(new RegExp(re.source, 'g'));
|
|
assert.deepEqual(
|
|
matches || [],
|
|
[],
|
|
`${name}: config-get lookups must use --raw, not a jq pipe (found ${JSON.stringify(matches)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('resolve-model lookups use --pick, not a jq pipe', () => {
|
|
// resolve-model returns an object; the native --pick <field> descends it.
|
|
const re = /resolve-model\b[^|\n]*\|\s*jq\b/;
|
|
for (const name of AUDITED) {
|
|
const content = readWorkflow(name);
|
|
if (content == null) continue;
|
|
const matches = content.match(new RegExp(re.source, 'g'));
|
|
assert.deepEqual(
|
|
matches || [],
|
|
[],
|
|
`${name}: resolve-model lookups must use --pick, not a jq pipe (found ${JSON.stringify(matches)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('verification.status lookups use --pick, not a jq pipe', () => {
|
|
const re = /verification\.status\b[^|\n]*\|\s*jq\b/;
|
|
for (const name of AUDITED) {
|
|
const content = readWorkflow(name);
|
|
if (content == null) continue;
|
|
const matches = content.match(new RegExp(re.source, 'g'));
|
|
assert.deepEqual(
|
|
matches || [],
|
|
[],
|
|
`${name}: verification.status lookups must use --pick, not a jq pipe (found ${JSON.stringify(matches)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('general guard: no audited workflow pipes any gsd_run query to jq', () => {
|
|
// Belt-and-suspenders: the jq-replaceable query command families. This
|
|
// catches a future regression on any of them (or a sibling like
|
|
// resolve-execution, which also supports --pick) without enumerating keys.
|
|
const re = /gsd_run\s+query\s+(config-get|resolve-model|resolve-execution|verification\.status\b)[^|\n]*\|\s*jq\b/;
|
|
for (const name of AUDITED) {
|
|
const content = readWorkflow(name);
|
|
if (content == null) continue;
|
|
const matches = content.match(new RegExp(re.source, 'g'));
|
|
assert.deepEqual(
|
|
matches || [],
|
|
[],
|
|
`${name}: no gsd_run query (config-get|resolve-model|resolve-execution|verification.status) may pipe to jq (found ${JSON.stringify(matches)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('ship.md decides the verification gate on a single verification.status read', () => {
|
|
// Pre-fix, ship.md captured verification.status ONCE and picked three fields
|
|
// off the cached JSON with jq. --pick takes a single field, so a naive
|
|
// conversion issues three queries up front — 3x the spawn cost on the common
|
|
// passing path, and three reads that are not guaranteed to observe the same
|
|
// state. The gate must read `status` once and decide; the two message-only
|
|
// fields belong on the blocking path.
|
|
const content = readWorkflow('ship.md');
|
|
assert.ok(content, 'ship.md must exist');
|
|
|
|
const statusIdx = content.indexOf('STATUS=$(gsd_run query verification.status');
|
|
assert.notEqual(statusIdx, -1, 'ship.md must read verification.status --pick status');
|
|
|
|
const picks = content.match(/gsd_run query verification\.status "\$\{PHASE_DIR\}" --pick (\w+)/g) || [];
|
|
assert.deepEqual(
|
|
picks.length,
|
|
3,
|
|
`ship.md should read status once plus the two message fields, found ${picks.length}`,
|
|
);
|
|
|
|
// The gate's own read must come first, and the message-only reads must both
|
|
// sit after the blocking prose — i.e. they are not on the passing path.
|
|
const blockIdx = content.indexOf('PHASE_VERIFICATION_INCOMPLETE');
|
|
assert.ok(blockIdx > statusIdx, 'the block decision must follow the status read');
|
|
for (const field of ['next_action', 'next_command']) {
|
|
const idx = content.indexOf(`--pick ${field}`);
|
|
assert.notEqual(idx, -1, `ship.md must still surface ${field}`);
|
|
assert.ok(
|
|
idx > blockIdx,
|
|
`${field} must be read only on the blocking path, not before the gate decides`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('review.md still declares jq a prerequisite for the lanes that genuinely need it', () => {
|
|
// Dropping the jq pipes from the CONFIG lookups must not drop the project's
|
|
// jq-prerequisite declaration: the ollama / lm_studio / llama_cpp / opencode
|
|
// / agy lanes parse HTTP + CLI JSON that gsd-tools does not emit, so they
|
|
// still hard-require jq. Removing the declaration (as the first cut of this
|
|
// fix did) leaves those lanes silently degrading on a jq-less host — the
|
|
// exact defect class #2589 exists to close.
|
|
const content = readWorkflow('review.md');
|
|
assert.ok(content, 'review.md must exist');
|
|
assert.match(
|
|
content,
|
|
/command -v jq >\/dev\/null 2>&1 && echo "jq:available" \|\| echo "jq:missing"/,
|
|
'review.md detect_clis must probe jq alongside the other reviewer prerequisites',
|
|
);
|
|
for (const lane of ['ollama', 'lm_studio', 'llama_cpp', 'opencode', 'antigravity']) {
|
|
assert.ok(
|
|
new RegExp(`jq-dependent reviewer lanes[\\s\\S]*\\b${lane}\\b`).test(content),
|
|
`review.md must name ${lane} as a jq-dependent lane`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('no workflow cites the jq prerequisite by a stale review.md line number', () => {
|
|
// plan-review-convergence.md used to cite "review.md:244". That anchor moved
|
|
// when the jq pipes were replaced, so cross-references must be by name.
|
|
for (const name of fs.readdirSync(WF).filter((f) => f.endsWith('.md'))) {
|
|
const content = readWorkflow(name);
|
|
if (content == null) continue;
|
|
const stale = content.match(/review\.md:\d+/g);
|
|
assert.deepEqual(
|
|
stale || [],
|
|
[],
|
|
`${name}: cite review.md by section name, not a line number (found ${JSON.stringify(stale)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('update-context lookups do not pipe to jq (#1854)', () => {
|
|
// update-context returns {installedVersion, scope, runtime, gsdDir}. The
|
|
// original sweep did not cover it, so update.md kept four `| jq -r '.field'`
|
|
// reads. On a jq-less host all four come back EMPTY, which does not fail
|
|
// loudly — it silently reproduces the fresh-install fallback
|
|
// (INSTALLED_VERSION=0.0.0, scope UNKNOWN), so `/gsd:update` re-installs
|
|
// over a working install and targets the wrong runtime directory.
|
|
const re = /update-context\b[^|\n]*\|\s*jq\b/;
|
|
for (const name of AUDITED) {
|
|
const content = readWorkflow(name);
|
|
if (content == null) continue;
|
|
const matches = content.match(new RegExp(re.source, 'g'));
|
|
assert.deepEqual(
|
|
matches || [],
|
|
[],
|
|
`${name}: update-context lookups must not pipe to jq (found ${JSON.stringify(matches)})`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('update.md still resolves all four update-context fields', () => {
|
|
// Negative proof for the test above: asserting the jq pipe is gone is
|
|
// vacuous if the fields stopped being read at all. The install context is
|
|
// only correct when every field still lands.
|
|
const content = readWorkflow('update.md');
|
|
assert.ok(content, 'update.md must exist');
|
|
for (const field of ['installedVersion', 'scope', 'runtime', 'gsdDir']) {
|
|
assert.match(
|
|
content,
|
|
new RegExp(`uc_field\\s+${field}\\b`),
|
|
`update.md must still resolve ${field} from the update-context projection`,
|
|
);
|
|
}
|
|
});
|
|
|
|
test('resolve-execution lookups use --pick, not a jq pipe (sibling of resolve-model)', () => {
|
|
// resolve-execution returns an object (model/profile/effort/effort_argv_string);
|
|
// the native --pick <field> descends it — same defect class as resolve-model.
|
|
const re = /resolve-execution\b[^|\n]*\|\s*jq\b/;
|
|
for (const name of AUDITED) {
|
|
const content = readWorkflow(name);
|
|
if (content == null) continue;
|
|
const matches = content.match(new RegExp(re.source, 'g'));
|
|
assert.deepEqual(
|
|
matches || [],
|
|
[],
|
|
`${name}: resolve-execution lookups must use --pick, not a jq pipe (found ${JSON.stringify(matches)})`,
|
|
);
|
|
}
|
|
});
|
|
});
|