Files
get-shit-done/tests/read-guard.test.cjs
Rezolv 86fb9c85c3 docs(sdk): registry docs and gsd-sdk query call sites (#2302 Track B) (#2340)
* feat(sdk): golden parity harness and query handler CJS alignment (#2302 Track A)

Golden/read-only parity tests and registry alignment, query handler fixes
(check-completion, state-mutation, commit, validate, summary, etc.), and
WAITING.json dual-write for .gsd/.planning readers.

Refs gsd-build/get-shit-done#2341

* fix(sdk): getMilestoneInfo matches GSD ROADMAP (🟡, last bold, STATE fallback)

- Recognize in-flight 🟡 milestone bullets like 🚧.
- Derive from last **vX.Y Title** before ## Phases when emoji absent.
- Fall back to STATE.md milestone when ROADMAP is missing; use last bare vX.Y
  in cleaned text instead of first (avoids v1.0 from shipped list).
- Fixes init.execute-phase milestone_version and buildStateFrontmatter after
  state.begin-phase (syncStateFrontmatter).

* feat(sdk): phase list, plan task structure, requirements extract handlers

- Register phase.list-plans, phase.list-artifacts, plan.task-structure,
  requirements.extract-from-plans (SDK-only; golden-policy exceptions).
- Add unit tests; document in QUERY-HANDLERS.md.
- writeProfile: honor --output, render dimensions, return profile_path and dimensions_scored.

* feat(sdk): centralize getGsdAgentsDir in query helpers

Extract agent directory resolution to helpers (GSD_AGENTS_DIR, primary
~/.claude/agents, legacy path). Use from init and docs-init init bundles.

docs(15): add 15-CONTEXT for autonomous phase-15 run.

* feat(sdk): query CLI CJS fallback and session correlation

- createRegistry(eventStream, sessionId) threads correlation into mutation events
- gsd-sdk query falls back to gsd-tools.cjs when no native handler matches
  (disable with GSD_QUERY_FALLBACK=off); stderr bridge warnings
- Export createRegistry from @gsd-build/sdk; add sdk/README.md
- Update QUERY-HANDLERS.md and registry module docs for fallback + sessionId
- Agents: prefer node dist/cli.js query over cat/grep for STATE and plans

* fix(sdk): init phase_found parity, docs-init agents path, state field extract

- Normalize findPhase not-found to null before roadmap fallback (matches findPhaseInternal)

- docs-init: use detectRuntime + resolveAgentsDir for checkAgentsInstalled

- state.cjs stateExtractField: horizontal whitespace only after colon (YAML progress guard)

- Tests: commit_docs default true; config-get golden uses temp config; golden integration green

Refs: #2302

* refactor(sdk): share SessionJsonlRecord in profile-extract-messages

CodeRabbit nit: dedupe JSONL record shape for isGenuineUserMessage and streamExtractMessages.

* fix(sdk): address CodeRabbit major threads (paths, gates, audit, verify)

- Resolve @file: and CLI JSON indirection relative to projectDir; guard empty normalized query command

- plan.task-structure + intel extract/patch-meta: resolvePathUnderProject containment

- check.config-gates: safe string booleans; plan_checker alias precedence over plan_check default

- state.validate/sync: phaseTokenMatches + comparePhaseNum ordering

- verify.schema-drift: token match phase dirs; files_modified from parsed frontmatter

- audit-open: has_scan_errors, unreadable rows, human report when scans fail

- requirements PLANNED key PLAN for root PLAN.md; gsd-tools timeout note

- ingest-docs: repo-root path containment; classifier output slug-hash

Golden parity test strips has_scan_errors until CJS adds field.

* fix: Resolve CodeRabbit security and quality findings
- Secure intel.ts and cli.ts against path traversal
- Catch and validate git add status in commit.ts
- Expand roadmap milestone marker extraction
- Fix parsing array-of-objects in frontmatter YAML
- Fix unhandled config evaluations
- Improve coverage test parity mapping

* docs(sdk): registry docs and gsd-sdk query call sites (#2302 Track B)

Update CHANGELOG, architecture and user guides, workflow call sites, and read-guard tests for gsd-sdk query; sync ARCHITECTURE.md command/workflow counts and directory-tree totals with the repo (80 commands, 77 workflows).

Address CodeRabbit: fix markdown tables and emphasis; align CLI-TOOLS GSDTools and state.read docs with implementation; correct roadmap handler name in universal-anti-patterns; resolve settings workflow config path without relying on config_path from state.load.

Refs gsd-build/get-shit-done#2340

* test: raise planner character extraction limit to 48K

* fix(sdk): resolve build TS error and doc conflict markers
2026-04-20 18:09:21 -04:00

243 lines
8.4 KiB
JavaScript

/**
* Tests for gsd-read-guard.js PreToolUse hook.
*
* The read guard intercepts Write/Edit tool calls on existing files and injects
* advisory guidance telling the model to Read the file first. This prevents
* infinite retry loops when non-Claude models (e.g. MiniMax M2.5 on OpenCode)
* attempt to edit files without reading them, hitting the runtime's
* "You must read file before overwriting it" error repeatedly.
*
* The hook is advisory-only (does not block) so Claude Code behavior is unaffected.
*/
process.env.GSD_TEST_MODE = '1';
const { test, describe, 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 { createTempDir, cleanup } = require('./helpers.cjs');
const HOOK_PATH = path.join(__dirname, '..', 'hooks', 'gsd-read-guard.js');
/**
* Run the read guard hook with a given tool input payload.
* Returns { exitCode, stdout, stderr }.
*/
function runHook(payload, envOverrides = {}) {
const input = JSON.stringify(payload);
// Sanitize CLAUDE_SESSION_ID so positive-path tests work inside Claude Code sessions
const env = { ...process.env, CLAUDE_SESSION_ID: '', CLAUDECODE: '', ...envOverrides };
try {
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
input,
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
env,
});
return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
} catch (err) {
return {
exitCode: err.status ?? 1,
stdout: (err.stdout || '').toString().trim(),
stderr: (err.stderr || '').toString().trim(),
};
}
}
describe('gsd-read-guard hook', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempDir('gsd-read-guard-');
});
afterEach(() => {
cleanup(tmpDir);
});
// ─── Core: advisory on Write to existing file ───────────────────────────
test('injects read-first guidance when Write targets an existing file', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'console.log("hello");\n');
const result = runHook({
tool_name: 'Write',
tool_input: { file_path: filePath, content: 'console.log("world");\n' },
});
assert.equal(result.exitCode, 0);
assert.ok(result.stdout.length > 0, 'should produce output');
const output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput, 'should have hookSpecificOutput');
assert.ok(output.hookSpecificOutput.additionalContext, 'should have additionalContext');
assert.ok(
output.hookSpecificOutput.additionalContext.includes('Read'),
'guidance should mention Read tool'
);
});
test('injects read-first guidance when Edit targets an existing file', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' },
});
assert.equal(result.exitCode, 0);
assert.ok(result.stdout.length > 0, 'should produce output');
const output = JSON.parse(result.stdout);
assert.ok(output.hookSpecificOutput.additionalContext.includes('Read'));
});
// ─── No-op cases: should NOT inject guidance ────────────────────────────
test('does nothing for Write to a new file (file does not exist)', () => {
const filePath = path.join(tmpDir, 'brand-new.js');
// File does NOT exist
const result = runHook({
tool_name: 'Write',
tool_input: { file_path: filePath, content: 'new content' },
});
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'should produce no output for new files');
});
test('does nothing for non-Write/Edit tools', () => {
const result = runHook({
tool_name: 'Bash',
tool_input: { command: 'echo hello' },
});
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '');
});
test('does nothing for Read tool', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'content');
const result = runHook({
tool_name: 'Read',
tool_input: { file_path: filePath },
});
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '');
});
// ─── Error resilience ──────────────────────────────────────────────────
test('exits cleanly on invalid JSON input', () => {
try {
const stdout = execFileSync(process.execPath, [HOOK_PATH], {
input: 'not json',
encoding: 'utf-8',
timeout: 5000,
stdio: ['pipe', 'pipe', 'pipe'],
});
// Should exit 0 silently
assert.equal(stdout.trim(), '');
} catch (err) {
assert.equal(err.status, 0, 'should exit 0 on parse error');
}
});
test('exits cleanly when tool_input is missing', () => {
const result = runHook({ tool_name: 'Write' });
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '');
});
// ─── Guidance content quality ──────────────────────────────────────────
test('guidance message includes the filename', () => {
const filePath = path.join(tmpDir, 'myfile.ts');
fs.writeFileSync(filePath, 'export const foo = 1;\n');
const result = runHook({
tool_name: 'Write',
tool_input: { file_path: filePath, content: 'export const foo = 2;\n' },
});
const output = JSON.parse(result.stdout);
assert.ok(
output.hookSpecificOutput.additionalContext.includes('myfile.ts'),
'guidance should include the filename being edited'
);
});
test('guidance message instructs to use Read tool before editing', () => {
const filePath = path.join(tmpDir, 'target.py');
fs.writeFileSync(filePath, 'x = 1\n');
const result = runHook({
tool_name: 'Edit',
tool_input: { file_path: filePath, old_string: 'x = 1', new_string: 'x = 2' },
});
const output = JSON.parse(result.stdout);
const ctx = output.hookSpecificOutput.additionalContext;
assert.ok(ctx.includes('Read'), 'must mention Read tool');
assert.ok(
ctx.includes('before') || ctx.includes('first'),
'must indicate Read should come before the edit'
);
});
// ─── Build / install integration ───────────────────────────────────────
test('hook is registered in build-hooks.js HOOKS_TO_COPY', () => {
const buildHooksPath = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const content = fs.readFileSync(buildHooksPath, 'utf8');
assert.ok(
content.includes('gsd-read-guard.js'),
'gsd-read-guard.js must be in HOOKS_TO_COPY so it ships in hooks/dist/'
);
});
test('hook is registered in install.js uninstall hook list', () => {
const installPath = path.join(__dirname, '..', 'bin', 'install.js');
const content = fs.readFileSync(installPath, 'utf8');
assert.ok(
content.includes("'gsd-read-guard.js'"),
'gsd-read-guard.js must be in the uninstall gsdHooks list'
);
});
test('exits cleanly when tool_input.file_path is non-string', () => {
const result = runHook({
tool_name: 'Write',
tool_input: { file_path: 12345, content: 'data' },
});
// file_path is a number — || '' yields '' — hook exits silently
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '');
});
// ─── Claude Code runtime skip (#1984) ─────────────────────────────────
test('skips advisory on Claude Code runtime (CLAUDE_SESSION_ID set)', () => {
const filePath = path.join(tmpDir, 'existing.js');
fs.writeFileSync(filePath, 'const x = 1;\n');
const result = runHook(
{ tool_name: 'Edit', tool_input: { file_path: filePath, old_string: 'const x = 1;', new_string: 'const x = 2;' } },
{ CLAUDE_SESSION_ID: 'test-session-123' }
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, '', 'should produce no output on Claude Code');
});
});