mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
* 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
243 lines
8.4 KiB
JavaScript
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');
|
|
});
|
|
});
|