diff --git a/plugins/praxis/.claude-plugin/plugin.json b/plugins/praxis/.claude-plugin/plugin.json index ec4bf1d..6435c66 100644 --- a/plugins/praxis/.claude-plugin/plugin.json +++ b/plugins/praxis/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "praxis", - "version": "1.4.1", + "version": "1.4.2", "description": "Development workflow -- issue planning, implementation, PR creation, code review with specialized reviewers, and project conventions", "author": { "name": "Jartan LLC", diff --git a/plugins/praxis/.codex-plugin/plugin.json b/plugins/praxis/.codex-plugin/plugin.json index 7c63210..e0e4484 100644 --- a/plugins/praxis/.codex-plugin/plugin.json +++ b/plugins/praxis/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "praxis", - "version": "1.4.1", + "version": "1.4.2", "description": "Development workflow -- issue planning, implementation, PR creation, code review with specialized reviewers, and project conventions", "interface": { "displayName": "Praxis", diff --git a/plugins/praxis/hooks/scripts/lib/transcript-context.js b/plugins/praxis/hooks/scripts/lib/transcript-context.js index cf82dbd..886177d 100644 --- a/plugins/praxis/hooks/scripts/lib/transcript-context.js +++ b/plugins/praxis/hooks/scripts/lib/transcript-context.js @@ -176,6 +176,8 @@ function resolveContextInterval(env) { module.exports = { MAX_TOKEN_SETTING, + readFileTail, + extractUsageTokens, readLatestContextTokens, resolveContextThreshold, resolveContextInterval diff --git a/plugins/praxis/hooks/scripts/lib/utils.js b/plugins/praxis/hooks/scripts/lib/utils.js index a5d7d65..9192466 100644 --- a/plugins/praxis/hooks/scripts/lib/utils.js +++ b/plugins/praxis/hooks/scripts/lib/utils.js @@ -499,6 +499,7 @@ module.exports = { getProjectName, // File operations + filterByPatterns, findFiles, readFile, writeFile, diff --git a/tests/plugins/praxis/hooks/scripts/check-console-log.test.js b/tests/plugins/praxis/hooks/scripts/check-console-log.test.js new file mode 100644 index 0000000..98e21f0 --- /dev/null +++ b/tests/plugins/praxis/hooks/scripts/check-console-log.test.js @@ -0,0 +1,192 @@ +// Tests for the console.log hook: the report/carry-forward split, and the +// process contract every shipped hook entry point owes its caller. +// +// applyCooldown is where a wrong answer is silent -- a finding suppressed +// forever, or one repeated on every edit. Whether the advice is good advice, and +// what the default cooldown should be, are judgment calls and deliberately not +// asserted here. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const SCRIPT = path.resolve( + __dirname, + '../../../../../plugins/praxis/hooks/scripts/check-console-log.js' +); +const { applyCooldown } = require(SCRIPT); + +const COOLDOWN = 10; + +const finding = (file, text) => ({ file, lineNumber: 1, text, key: `${file}:${text}` }); +const A = finding('src/a.js', 'console.log(a)'); +const B = finding('src/b.js', 'console.log(b)'); + +const GIT_ENV = { + GIT_AUTHOR_NAME: 'Grimoire Fixture', + GIT_AUTHOR_EMAIL: 'fixture@example.invalid', + GIT_COMMITTER_NAME: 'Grimoire Fixture', + GIT_COMMITTER_EMAIL: 'fixture@example.invalid' +}; + +/** + * Spawn the hook and assert the contract it owes its caller: exit 0, and either + * nothing on stdout or exactly one well-formed JSON object. JSON.parse is the + * "exactly one" half -- two concatenated objects do not parse. + */ +function spawnHook({ stdin = '', cwd, env = {} } = {}) { + const r = spawnSync(process.execPath, [SCRIPT], { + input: stdin, + cwd, + encoding: 'utf8', + env: { ...process.env, ...env } + }); + + assert.equal(r.status, 0, `exited ${r.status}, signal ${r.signal}; stderr: ${r.stderr}`); + const out = r.stdout.trim(); + if (out === '') return null; + + const payload = JSON.parse(out); + assert.equal(typeof payload, 'object'); + assert.notEqual(payload, null); + return payload; +} + +/** A throwaway repository plus an isolated temp directory for the hook's state. */ +function withFixture(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grimoire-console-')); + const repo = path.join(dir, 'repo'); + const state = path.join(dir, 'state'); + fs.mkdirSync(repo); + fs.mkdirSync(state); + + const git = args => { + const r = spawnSync('git', args, { cwd: repo, encoding: 'utf8', env: { ...process.env, ...GIT_ENV } }); + assert.equal(r.status, 0, `git ${args.join(' ')} failed: ${r.stderr}`); + }; + git(['init', '-q']); + git(['config', 'commit.gpgsign', 'false']); + fs.writeFileSync(path.join(repo, 'README.md'), 'fixture\n'); + git(['add', '-A']); + git(['commit', '-qm', 'fixture']); + + try { + return fn({ repo, env: { TMPDIR: state, TMP: state, TEMP: state } }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('applyCooldown reports a finding new to the session immediately', () => { + const { report, state } = applyCooldown([A], { seen: [], cooldown: 0 }, COOLDOWN); + assert.deepEqual(report, [A]); + assert.deepEqual(state, { seen: [A.key], cooldown: COOLDOWN }); +}); + +test('applyCooldown holds a repeat back until the cooldown elapses', () => { + const { report, state } = applyCooldown([A], { seen: [A.key], cooldown: 3 }, COOLDOWN); + assert.deepEqual(report, []); + assert.deepEqual(state, { seen: [A.key], cooldown: 2 }); +}); + +test('applyCooldown releases a repeat once the cooldown reaches zero', () => { + const { report, state } = applyCooldown([A], { seen: [A.key], cooldown: 0 }, COOLDOWN); + assert.deepEqual(report, [A]); + assert.deepEqual(state, { seen: [A.key], cooldown: COOLDOWN }); +}); + +test('applyCooldown restarts the cooldown whenever it reports something', () => { + // Otherwise a finding is announced and then immediately repeated on the next + // edit. + const { state } = applyCooldown([A], { seen: [], cooldown: 4 }, COOLDOWN); + assert.equal(state.cooldown, COOLDOWN); +}); + +test('applyCooldown reports the fresh finding while the repeat is still cooling', () => { + const { report, state } = applyCooldown([A, B], { seen: [B.key], cooldown: 5 }, COOLDOWN); + assert.deepEqual(report, [A]); + assert.deepEqual(state.seen.sort(), [A.key, B.key].sort()); + assert.equal(state.cooldown, COOLDOWN); +}); + +test('applyCooldown stops decrementing at zero', () => { + const { report, state } = applyCooldown([A], { seen: [A.key], cooldown: 0 }, COOLDOWN); + assert.deepEqual(report, [A]); + + const quiet = applyCooldown([], { seen: [], cooldown: 0 }, COOLDOWN); + assert.deepEqual(quiet.report, []); + assert.equal(quiet.state.cooldown, 0); +}); + +test('applyCooldown drops a finding that has disappeared out of seen', () => { + const { state } = applyCooldown([], { seen: [A.key, B.key], cooldown: 3 }, COOLDOWN); + assert.deepEqual(state.seen, []); + assert.equal(state.cooldown, 2); +}); + +test('applyCooldown counts a reintroduced statement as new again', () => { + // Keyed on the line's text rather than its number, so an edit above a debug + // statement is not a new finding -- but removing and re-adding it is. + let state = applyCooldown([A], { seen: [], cooldown: 0 }, COOLDOWN).state; + assert.deepEqual(state.seen, [A.key]); + + state = applyCooldown([], state, COOLDOWN).state; + assert.deepEqual(state.seen, []); + + const again = applyCooldown([A], state, COOLDOWN); + assert.deepEqual(again.report, [A]); + assert.equal(again.state.cooldown, COOLDOWN); +}); + +test('applyCooldown rebuilds seen from what is present, never growing it', () => { + const { state } = applyCooldown([A], { seen: [A.key, 'src/gone.js:console.log(gone)'], cooldown: 0 }, COOLDOWN); + assert.deepEqual(state.seen, [A.key]); +}); + +test('applyCooldown honours the cooldown length it is handed', () => { + assert.equal(applyCooldown([A], { seen: [], cooldown: 0 }, 3).state.cooldown, 3); +}); + +test('contract: malformed JSON on stdin', () => { + withFixture(({ repo, env }) => { + assert.equal(spawnHook({ stdin: '{not json', cwd: repo, env }), null); + }); +}); + +test('contract: empty stdin', () => { + withFixture(({ repo, env }) => { + assert.equal(spawnHook({ stdin: '', cwd: repo, env }), null); + }); +}); + +test('contract: a payload with no transcript_path and no session_id', () => { + withFixture(({ repo, env }) => { + assert.equal(spawnHook({ stdin: JSON.stringify({ hook_event_name: 'PostToolUse' }), cwd: repo, env }), null); + }); +}); + +test('contract: outside a git repository', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grimoire-console-bare-')); + try { + assert.equal( + spawnHook({ stdin: '{}', cwd: dir, env: { TMPDIR: dir, TMP: dir, TEMP: dir } }), + null + ); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +test('contract: a real finding travels as exactly one JSON object', () => { + withFixture(({ repo, env }) => { + fs.mkdirSync(path.join(repo, 'src')); + fs.writeFileSync(path.join(repo, 'src', 'app.js'), 'console.log("debug");\n'); + + const payload = spawnHook({ stdin: JSON.stringify({ session_id: 'contract-test' }), cwd: repo, env }); + assert.equal(payload.hookSpecificOutput.hookEventName, 'PostToolUse'); + assert.match(payload.hookSpecificOutput.additionalContext, /console\.log found in src\/app\.js/); + }); +}); diff --git a/tests/plugins/praxis/hooks/scripts/lib/session-state.test.js b/tests/plugins/praxis/hooks/scripts/lib/session-state.test.js new file mode 100644 index 0000000..e4e28d8 Binary files /dev/null and b/tests/plugins/praxis/hooks/scripts/lib/session-state.test.js differ diff --git a/tests/plugins/praxis/hooks/scripts/lib/transcript-context.test.js b/tests/plugins/praxis/hooks/scripts/lib/transcript-context.test.js new file mode 100644 index 0000000..a185340 --- /dev/null +++ b/tests/plugins/praxis/hooks/scripts/lib/transcript-context.test.js @@ -0,0 +1,227 @@ +// Tests for the strategic-compact hook's token accounting and setting resolvers. +// +// The silent-wrong failure modes here are a context size reported at twice its +// real value, and a threshold that reads as disabled when it is not. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + MAX_TOKEN_SETTING, + readFileTail, + extractUsageTokens, + readLatestContextTokens, + resolveContextThreshold, + resolveContextInterval +} = require('../../../../../../plugins/praxis/hooks/scripts/lib/transcript-context'); + +const DEFAULT_THRESHOLD = 160000; +const DEFAULT_INTERVAL = 60000; + +/** Write `contents` into a throwaway directory and hand back the path. */ +function tempFile(contents, name = 'transcript.jsonl') { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grimoire-transcript-')); + const file = path.join(dir, name); + fs.writeFileSync(file, contents); + return { dir, file }; +} + +function withTempFile(contents, fn) { + const { dir, file } = tempFile(contents); + try { + return fn(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const usageRecord = usage => JSON.stringify({ message: { usage } }); + +test('extractUsageTokens sums the fields that partition the prompt', () => { + const tokens = extractUsageTokens({ + message: { usage: { input_tokens: 1000, cache_read_input_tokens: 20000, cache_creation_input_tokens: 300 } } + }); + assert.equal(tokens, 21300); +}); + +test('extractUsageTokens treats an absent or non-numeric field as zero', () => { + assert.equal(extractUsageTokens({ message: { usage: { input_tokens: 500 } } }), 500); + assert.equal( + extractUsageTokens({ message: { usage: { input_tokens: 500, cache_read_input_tokens: 'lots' } } }), + 500 + ); +}); + +test('extractUsageTokens returns zero when there is no usable usage block', () => { + assert.equal(extractUsageTokens(null), 0); + assert.equal(extractUsageTokens({}), 0); + assert.equal(extractUsageTokens({ message: {} }), 0); + assert.equal(extractUsageTokens({ message: { usage: null } }), 0); + assert.equal(extractUsageTokens({ message: { usage: 'nope' } }), 0); + assert.equal(extractUsageTokens({ message: { usage: {} } }), 0); +}); + +test('extractUsageTokens takes the largest iteration, not the aggregate', () => { + // On a multi-iteration turn the top-level fields aggregate ACROSS iterations, + // so summing them reports a context that was never that large -- 2.00x here. + // Each iteration re-sends the prompt, so the largest single one is the truth. + const tokens = extractUsageTokens({ + message: { + usage: { + input_tokens: 200000, + iterations: [{ input_tokens: 100000 }, { input_tokens: 100000 }] + } + } + }); + assert.equal(tokens, 100000); +}); + +test('extractUsageTokens sums each iteration across its own prompt fields', () => { + const tokens = extractUsageTokens({ + message: { + usage: { + input_tokens: 999999, + iterations: [ + { input_tokens: 10, cache_read_input_tokens: 20 }, + { input_tokens: 100, cache_read_input_tokens: 200, cache_creation_input_tokens: 5 } + ] + } + } + }); + assert.equal(tokens, 305); +}); + +test('extractUsageTokens falls back to the top level when no iteration is usable', () => { + const usage = { input_tokens: 4200, iterations: [null, {}, 'junk'] }; + assert.equal(extractUsageTokens({ message: { usage } }), 4200); + assert.equal(extractUsageTokens({ message: { usage: { input_tokens: 4200, iterations: [] } } }), 4200); + assert.equal(extractUsageTokens({ message: { usage: { input_tokens: 4200, iterations: 'no' } } }), 4200); +}); + +test('readFileTail returns null for a file it cannot open', () => { + assert.equal(readFileTail(path.join(os.tmpdir(), 'grimoire-does-not-exist-98217'), 1024), null); +}); + +test('readFileTail reads a whole file that fits, and reports it untruncated', () => { + withTempFile('one\ntwo\n', file => { + assert.deepEqual(readFileTail(file, 1024), { text: 'one\ntwo\n', truncated: false }); + }); +}); + +test('readFileTail reads only the tail of a larger file, and says so', () => { + withTempFile('0123456789', file => { + assert.deepEqual(readFileTail(file, 4), { text: '6789', truncated: true }); + }); +}); + +test('readFileTail reports an empty file as empty and untruncated', () => { + withTempFile('', file => { + assert.deepEqual(readFileTail(file, 1024), { text: '', truncated: false }); + }); +}); + +test('readLatestContextTokens rejects a path that is not a usable string', () => { + assert.equal(readLatestContextTokens(''), null); + assert.equal(readLatestContextTokens(undefined), null); + assert.equal(readLatestContextTokens(42), null); +}); + +test('readLatestContextTokens returns null for a missing transcript', () => { + assert.equal(readLatestContextTokens(path.join(os.tmpdir(), 'grimoire-no-transcript-31337.jsonl')), null); +}); + +test('readLatestContextTokens takes the most recent usable record', () => { + const lines = [ + usageRecord({ input_tokens: 111 }), + usageRecord({ input_tokens: 222 }), + usageRecord({ input_tokens: 333 }) + ]; + withTempFile(lines.join('\n') + '\n', file => { + assert.deepEqual(readLatestContextTokens(file), { tokens: 333 }); + }); +}); + +test('readLatestContextTokens scans past blank, unparsable and usage-free records', () => { + const lines = [ + usageRecord({ input_tokens: 777 }), + '{"message":{"usage":{}}}', + 'not json at all', + '{"type":"user","message":{"content":"hi"}}', + '' + ]; + withTempFile(lines.join('\n') + '\n', file => { + assert.deepEqual(readLatestContextTokens(file), { tokens: 777 }); + }); +}); + +test('readLatestContextTokens returns null when no record carries usage', () => { + withTempFile('{"type":"user"}\nnot json\n\n', file => { + assert.equal(readLatestContextTokens(file), null); + }); +}); + +test('readLatestContextTokens distrusts the first line of a truncated tail', () => { + // The first line of a tail read is almost certainly partial JSON. The guard is + // unconditional, so a complete record sitting exactly on the cut is dropped + // too -- deliberately, since the read cannot tell the two apart. + const record = usageRecord({ input_tokens: 999999 }); + const contents = `noise\n${record}\nnot json\n`; + withTempFile(contents, file => { + const size = Buffer.byteLength(contents); + // Cut exactly after "noise\n", so the record is the tail's first line. + assert.equal(readLatestContextTokens(file, { tailBytes: size - 'noise\n'.length }), null); + // Read whole, the same record is trusted and found. + assert.deepEqual(readLatestContextTokens(file, { tailBytes: size }), { tokens: 999999 }); + }); +}); + +test('readLatestContextTokens falls back to the default tail for an unusable tailBytes', () => { + withTempFile(usageRecord({ input_tokens: 42 }) + '\n', file => { + for (const tailBytes of [0, -1, 1.5, 'big', null]) { + assert.deepEqual(readLatestContextTokens(file, { tailBytes }), { tokens: 42 }, `tailBytes=${tailBytes}`); + } + }); +}); + +test('resolveContextThreshold defaults when the setting is absent', () => { + assert.equal(resolveContextThreshold({}), DEFAULT_THRESHOLD); + assert.equal(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: '' }), DEFAULT_THRESHOLD); + assert.equal(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: null }), DEFAULT_THRESHOLD); + assert.equal(resolveContextThreshold(undefined), DEFAULT_THRESHOLD); +}); + +test('resolveContextThreshold treats zero as a disable switch', () => { + assert.equal(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: '0' }), 0); +}); + +test('resolveContextThreshold accepts an in-range value up to the maximum', () => { + assert.equal(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: '90000' }), 90000); + assert.equal(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: String(MAX_TOKEN_SETTING) }), MAX_TOKEN_SETTING); +}); + +test('resolveContextThreshold falls back for anything invalid or out of range', () => { + for (const raw of ['-1', 'lots', String(MAX_TOKEN_SETTING + 1)]) { + assert.equal(resolveContextThreshold({ COMPACT_CONTEXT_THRESHOLD: raw }), DEFAULT_THRESHOLD, raw); + } +}); + +test('resolveContextInterval defaults when the setting is absent or invalid', () => { + assert.equal(resolveContextInterval({}), DEFAULT_INTERVAL); + assert.equal(resolveContextInterval(undefined), DEFAULT_INTERVAL); + assert.equal(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: 'often' }), DEFAULT_INTERVAL); + assert.equal(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: '-5' }), DEFAULT_INTERVAL); + assert.equal(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: String(MAX_TOKEN_SETTING + 1) }), DEFAULT_INTERVAL); +}); + +test('resolveContextInterval has no disable switch, unlike the threshold', () => { + // The interval only spaces out repeats; zero is not an off state for it. + assert.equal(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: '0' }), DEFAULT_INTERVAL); +}); + +test('resolveContextInterval accepts an in-range value up to the maximum', () => { + assert.equal(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: '25000' }), 25000); + assert.equal(resolveContextInterval({ COMPACT_CONTEXT_INTERVAL: String(MAX_TOKEN_SETTING) }), MAX_TOKEN_SETTING); +}); diff --git a/tests/plugins/praxis/hooks/scripts/lib/utils.test.js b/tests/plugins/praxis/hooks/scripts/lib/utils.test.js new file mode 100644 index 0000000..122cf6e --- /dev/null +++ b/tests/plugins/praxis/hooks/scripts/lib/utils.test.js @@ -0,0 +1,239 @@ +// Tests for the shared hook helpers that do real work on their arguments: the +// pattern filter, the two regex-flag guards, and the glob-to-regex conversion. +// +// The thin wrappers over fs and child_process in the same module are deliberately +// untested -- the assertion would be that Node works. CONTRIBUTING.md has the +// full list and why. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { + filterByPatterns, + findFiles, + countInFile, + grepFile +} = require('../../../../../../plugins/praxis/hooks/scripts/lib/utils'); + +const FILES = ['src/app.ts', 'src/app.tsx', 'src/app.js', 'docs/readme.md']; + +function withTempDir(fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grimoire-utils-')); + try { + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +function write(dir, relative, contents = 'x\n', ageMs = 0) { + const file = path.join(dir, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, contents); + if (ageMs > 0) { + const when = (Date.now() - ageMs) / 1000; + fs.utimesSync(file, when, when); + } + return file; +} + +const missing = () => path.join(os.tmpdir(), 'grimoire-utils-absent-55123.txt'); + +test('filterByPatterns leaves the list alone when there are no patterns', () => { + assert.deepEqual(filterByPatterns(FILES, []), FILES); +}); + +test('filterByPatterns keeps only what a pattern matches', () => { + assert.deepEqual(filterByPatterns(FILES, ['\\.tsx?$']), ['src/app.ts', 'src/app.tsx']); +}); + +test('filterByPatterns unions its patterns', () => { + assert.deepEqual(filterByPatterns(FILES, ['\\.tsx?$', '\\.jsx?$']), ['src/app.ts', 'src/app.tsx', 'src/app.js']); +}); + +test('filterByPatterns skips an invalid pattern and applies the rest', () => { + assert.deepEqual(filterByPatterns(FILES, ['[unclosed', '\\.md$']), ['docs/readme.md']); +}); + +test('filterByPatterns leaves the list alone when every pattern is unusable', () => { + // Compiling nothing must not mean matching nothing: an all-invalid list that + // filtered everything away would report a clean tree to every caller. + assert.deepEqual(filterByPatterns(FILES, ['[unclosed', '(']), FILES); + assert.deepEqual(filterByPatterns(FILES, ['', null, undefined, 42]), FILES); +}); + +test('filterByPatterns matches anywhere in the path, not just the end', () => { + assert.deepEqual(filterByPatterns(FILES, ['^src/']), ['src/app.ts', 'src/app.tsx', 'src/app.js']); + assert.deepEqual(filterByPatterns(FILES, ['app']), ['src/app.ts', 'src/app.tsx', 'src/app.js']); +}); + +test('countInFile counts every match of a string pattern', () => { + withTempDir(dir => { + const file = write(dir, 'a.js', 'console.log(1)\nconsole.log(2)\nconsole.log(3)\n'); + assert.equal(countInFile(file, 'console\\.log'), 3); + }); +}); + +test('countInFile counts every match of a RegExp that lacks the global flag', () => { + // Without the flag enforced, String.match returns the first match only and the + // count silently reads 1 however many there are. + withTempDir(dir => { + const file = write(dir, 'a.js', 'console.log(1)\nconsole.log(2)\nconsole.log(3)\n'); + assert.equal(countInFile(file, /console\.log/), 3); + }); +}); + +test('countInFile preserves the other flags on the pattern it is handed', () => { + withTempDir(dir => { + const file = write(dir, 'a.js', 'CONSOLE.LOG(1)\nconsole.log(2)\n'); + assert.equal(countInFile(file, /console\.log/i), 2); + assert.equal(countInFile(file, /console\.log/), 1); + }); +}); + +test('countInFile is not disturbed by a global RegExp reused across calls', () => { + withTempDir(dir => { + const file = write(dir, 'a.js', 'console.log(1)\nconsole.log(2)\n'); + const shared = /console\.log/g; + assert.equal(countInFile(file, shared), 2); + assert.equal(countInFile(file, shared), 2); + }); +}); + +test('countInFile returns zero for a missing file, a bad pattern or a bad type', () => { + assert.equal(countInFile(missing(), 'x'), 0); + withTempDir(dir => { + const file = write(dir, 'a.js', 'x\n'); + assert.equal(countInFile(file, '[unclosed'), 0); + assert.equal(countInFile(file, 42), 0); + assert.equal(countInFile(file, null), 0); + }); +}); + +test('grepFile reports matching lines with 1-based numbers', () => { + withTempDir(dir => { + const file = write(dir, 'a.js', 'const x = 1;\nconsole.log(x);\nconst y = 2;\n'); + assert.deepEqual(grepFile(file, /console\.log/), [{ lineNumber: 2, content: 'console.log(x);' }]); + }); +}); + +test('grepFile matches consecutive lines despite a global RegExp', () => { + // The g flag makes .test() stateful: lastIndex carries between calls, so + // consecutive matching lines alternate match/miss and half the findings vanish. + withTempDir(dir => { + const file = write(dir, 'a.js', 'console.log(1)\nconsole.log(2)\nconsole.log(3)\nconsole.log(4)\n'); + assert.deepEqual(grepFile(file, /console\.log/g).map(m => m.lineNumber), [1, 2, 3, 4]); + }); +}); + +test('grepFile gives the same answer when a global RegExp is reused', () => { + withTempDir(dir => { + const file = write(dir, 'a.js', 'console.log(1)\nconsole.log(2)\n'); + const shared = /console\.log/g; + assert.deepEqual(grepFile(file, shared), grepFile(file, shared)); + }); +}); + +test('grepFile keeps the flags that are not g', () => { + withTempDir(dir => { + const file = write(dir, 'a.js', 'CONSOLE.LOG(1)\nconst x = 2;\n'); + assert.deepEqual(grepFile(file, /console\.log/i).map(m => m.lineNumber), [1]); + assert.deepEqual(grepFile(file, /console\.log/), []); + }); +}); + +test('grepFile accepts a string pattern', () => { + withTempDir(dir => { + const file = write(dir, 'a.js', 'const x = 1;\nconsole.log(x);\n'); + assert.deepEqual(grepFile(file, 'console\\.log').map(m => m.lineNumber), [2]); + }); +}); + +test('grepFile returns nothing for a missing file or a bad pattern', () => { + assert.deepEqual(grepFile(missing(), /x/), []); + withTempDir(dir => { + assert.deepEqual(grepFile(write(dir, 'a.js', 'x\n'), '[unclosed'), []); + }); +}); + +test('findFiles converts the glob wildcards', () => { + withTempDir(dir => { + write(dir, 'a.tmp'); + write(dir, 'b.tmp'); + write(dir, 'c.md'); + assert.deepEqual(findFiles(dir, '*.tmp').map(f => path.basename(f.path)).sort(), ['a.tmp', 'b.tmp']); + + write(dir, 'log1.txt'); + write(dir, 'log22.txt'); + assert.deepEqual(findFiles(dir, 'log?.txt').map(f => path.basename(f.path)), ['log1.txt']); + }); +}); + +test('findFiles escapes the regex specials in a pattern', () => { + // Unescaped, the dot in `a.txt` is a wildcard and the pattern also matches + // `axtxt` -- a file the caller never asked for, deleted by a caller that + // sweeps what this returns. + withTempDir(dir => { + write(dir, 'a.txt'); + write(dir, 'axtxt'); + write(dir, 'a+b.txt'); + assert.deepEqual(findFiles(dir, 'a.txt').map(f => path.basename(f.path)), ['a.txt']); + assert.deepEqual(findFiles(dir, 'a+b.txt').map(f => path.basename(f.path)), ['a+b.txt']); + }); +}); + +test('findFiles anchors the pattern to the whole name', () => { + withTempDir(dir => { + write(dir, 'notes.md'); + write(dir, 'notes.md.bak'); + assert.deepEqual(findFiles(dir, '*.md').map(f => path.basename(f.path)), ['notes.md']); + }); +}); + +test('findFiles descends only when asked to', () => { + withTempDir(dir => { + write(dir, 'top.md'); + write(dir, 'nested/deep.md'); + assert.deepEqual(findFiles(dir, '*.md').map(f => path.basename(f.path)), ['top.md']); + assert.deepEqual( + findFiles(dir, '*.md', { recursive: true }).map(f => path.basename(f.path)).sort(), + ['deep.md', 'top.md'] + ); + }); +}); + +test('findFiles drops anything older than maxAge', () => { + withTempDir(dir => { + const day = 24 * 60 * 60 * 1000; + write(dir, 'fresh.md', 'x\n'); + write(dir, 'stale.md', 'x\n', 10 * day); + assert.deepEqual(findFiles(dir, '*.md', { maxAge: 5 }).map(f => path.basename(f.path)), ['fresh.md']); + assert.equal(findFiles(dir, '*.md', { maxAge: 30 }).length, 2); + }); +}); + +test('findFiles sorts newest first', () => { + withTempDir(dir => { + const hour = 60 * 60 * 1000; + write(dir, 'oldest.md', 'x\n', 3 * hour); + write(dir, 'newest.md', 'x\n'); + write(dir, 'middle.md', 'x\n', hour); + assert.deepEqual( + findFiles(dir, '*.md').map(f => path.basename(f.path)), + ['newest.md', 'middle.md', 'oldest.md'] + ); + }); +}); + +test('findFiles returns nothing for a missing directory or a bad argument', () => { + assert.deepEqual(findFiles(path.join(os.tmpdir(), 'grimoire-utils-nodir-8812'), '*.md'), []); + withTempDir(dir => { + assert.deepEqual(findFiles(dir, ''), []); + assert.deepEqual(findFiles(dir, null), []); + assert.deepEqual(findFiles(null, '*.md'), []); + assert.deepEqual(findFiles(42, '*.md'), []); + }); +}); diff --git a/tests/plugins/praxis/hooks/scripts/suggest-compact.test.js b/tests/plugins/praxis/hooks/scripts/suggest-compact.test.js new file mode 100644 index 0000000..803af93 --- /dev/null +++ b/tests/plugins/praxis/hooks/scripts/suggest-compact.test.js @@ -0,0 +1,125 @@ +// Process contract for the strategic-compact hook. +// +// The resolvers it composes are unit-tested in lib/transcript-context.test.js. +// What is left here is the contract: exit 0 and at most one JSON object, whatever +// arrives on stdin, with both signals sharing the single payload a hook is +// allowed. Every spawn gets its own temp directory, since the hook writes +// per-session state there. +// +// The thresholds and the wording are tuning, not facts, so only the disable +// switch and the shape of the payload are asserted. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const SCRIPT = path.resolve(__dirname, '../../../../../plugins/praxis/hooks/scripts/suggest-compact.js'); + +/** Spawn the hook with isolated state, asserting exit 0 and at most one JSON object. */ +function spawnHook({ stdin = '', env = {} } = {}) { + const state = fs.mkdtempSync(path.join(os.tmpdir(), 'grimoire-compact-')); + try { + const r = spawnSync(process.execPath, [SCRIPT], { + input: stdin, + encoding: 'utf8', + env: { ...process.env, TMPDIR: state, TMP: state, TEMP: state, ...env } + }); + + assert.equal(r.status, 0, `exited ${r.status}, signal ${r.signal}; stderr: ${r.stderr}`); + const out = r.stdout.trim(); + if (out === '') return null; + + const payload = JSON.parse(out); + assert.equal(typeof payload, 'object'); + assert.notEqual(payload, null); + return payload; + } finally { + fs.rmSync(state, { recursive: true, force: true }); + } +} + +/** A one-record transcript reporting `tokens` of context. */ +function transcript(tokens) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grimoire-compact-tx-')); + const file = path.join(dir, 'transcript.jsonl'); + fs.writeFileSync(file, JSON.stringify({ message: { usage: { input_tokens: tokens } } }) + '\n'); + return { dir, file }; +} + +function withTranscript(tokens, fn) { + const { dir, file } = transcript(tokens); + try { + return fn(file); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +test('contract: malformed JSON on stdin', () => { + assert.equal(spawnHook({ stdin: '{"session_id": ' }), null); +}); + +test('contract: empty stdin', () => { + assert.equal(spawnHook({ stdin: '' }), null); +}); + +test('contract: a payload with no transcript_path', () => { + assert.equal(spawnHook({ stdin: JSON.stringify({ session_id: 'contract-test' }) }), null); +}); + +test('contract: a transcript_path that is not a string', () => { + assert.equal(spawnHook({ stdin: JSON.stringify({ session_id: 'x', transcript_path: 42 }) }), null); +}); + +test('contract: a transcript_path pointing at nothing', () => { + const absent = path.join(os.tmpdir(), 'grimoire-compact-absent-4471.jsonl'); + assert.equal(spawnHook({ stdin: JSON.stringify({ session_id: 'x', transcript_path: absent }) }), null); +}); + +test('contract: the count signal emits exactly one JSON object', () => { + const payload = spawnHook({ + stdin: JSON.stringify({ session_id: 'contract-test' }), + env: { COMPACT_THRESHOLD: '1' } + }); + assert.equal(payload.hookSpecificOutput.hookEventName, 'PreToolUse'); + assert.match(payload.hookSpecificOutput.additionalContext, /1 tool calls reached/); +}); + +test('contract: both signals share the one payload a hook is allowed', () => { + withTranscript(400000, file => { + const payload = spawnHook({ + stdin: JSON.stringify({ session_id: 'contract-test', transcript_path: file }), + env: { COMPACT_THRESHOLD: '1', COMPACT_CONTEXT_THRESHOLD: '1000' } + }); + const context = payload.hookSpecificOutput.additionalContext; + assert.match(context, /Context ~400k tokens/); + assert.match(context, /1 tool calls reached/); + }); +}); + +test('contract: COMPACT_CONTEXT_THRESHOLD=0 silences the context signal', () => { + withTranscript(400000, file => { + assert.equal( + spawnHook({ + stdin: JSON.stringify({ session_id: 'contract-test', transcript_path: file }), + env: { COMPACT_CONTEXT_THRESHOLD: '0' } + }), + null + ); + }); +}); + +test('contract: a transcript below the threshold says nothing', () => { + withTranscript(1000, file => { + assert.equal( + spawnHook({ + stdin: JSON.stringify({ session_id: 'contract-test', transcript_path: file }), + env: { COMPACT_CONTEXT_THRESHOLD: '160000' } + }), + null + ); + }); +}); diff --git a/tests/plugins/praxis/hooks/scripts/suggest-skills.test.js b/tests/plugins/praxis/hooks/scripts/suggest-skills.test.js new file mode 100644 index 0000000..13dcbc5 --- /dev/null +++ b/tests/plugins/praxis/hooks/scripts/suggest-skills.test.js @@ -0,0 +1,59 @@ +// Process contract for the skill-activation hook. +// +// The whole script is a gate on the incoming event, so there is nothing to +// import: the contract IS the behaviour. A hook that throws breaks the user's +// turn, and no other check in this repository can see that. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const SCRIPT = path.resolve(__dirname, '../../../../../plugins/praxis/hooks/scripts/suggest-skills.js'); + +/** + * Spawn the hook and assert exit 0 plus either no stdout or exactly one + * well-formed JSON object. JSON.parse is the "exactly one" half -- two + * concatenated objects do not parse. + */ +function spawnHook(stdin) { + const r = spawnSync(process.execPath, [SCRIPT], { input: stdin, encoding: 'utf8' }); + + assert.equal(r.status, 0, `exited ${r.status}, signal ${r.signal}; stderr: ${r.stderr}`); + const out = r.stdout.trim(); + if (out === '') return null; + + const payload = JSON.parse(out); + assert.equal(typeof payload, 'object'); + assert.notEqual(payload, null); + return payload; +} + +test('contract: malformed JSON on stdin', () => { + assert.equal(spawnHook('{"hook_event_name": '), null); +}); + +test('contract: empty stdin', () => { + assert.equal(spawnHook(''), null); +}); + +test('contract: a payload with no event name', () => { + // An unread or unparsed payload leaves the event unknown, and guessing would + // label the output with an event that did not happen. + assert.equal(spawnHook(JSON.stringify({ transcript_path: '/nowhere' })), null); + assert.equal(spawnHook(JSON.stringify({ hook_event_name: '' })), null); +}); + +test('contract: a prompt submission emits exactly one JSON object', () => { + const payload = spawnHook(JSON.stringify({ hook_event_name: 'UserPromptSubmit' })); + assert.equal(payload.hookSpecificOutput.hookEventName, 'UserPromptSubmit'); + assert.match(payload.hookSpecificOutput.additionalContext, /load them with Skill/); +}); + +test('contract: a session start says nothing unless a compact caused it', () => { + assert.equal(spawnHook(JSON.stringify({ hook_event_name: 'SessionStart', source: 'startup' })), null); + assert.equal(spawnHook(JSON.stringify({ hook_event_name: 'SessionStart' })), null); + + const payload = spawnHook(JSON.stringify({ hook_event_name: 'SessionStart', source: 'compact' })); + assert.equal(payload.hookSpecificOutput.hookEventName, 'SessionStart'); +}); diff --git a/tests/plugins/praxis/hooks/scripts/sync-codex-agents.test.js b/tests/plugins/praxis/hooks/scripts/sync-codex-agents.test.js new file mode 100644 index 0000000..046e934 --- /dev/null +++ b/tests/plugins/praxis/hooks/scripts/sync-codex-agents.test.js @@ -0,0 +1,168 @@ +// Process contract for the Codex role installer. +// +// Spawned rather than imported, and not because spawning is tidier: hooks.json +// invokes this script as `require(root + '/hooks/scripts/sync-codex-agents.js')` +// and it does its work at require time, so the `require.main === module` guard +// that would make it importable would turn the hook into a no-op. +// +// Every plugin that ships a copy is covered from here, in one file rather than +// one per plugin: the copies must be byte-identical, which `make verify` asserts +// through generate-codex.py's divergence finder, so a second file would only +// duplicate this one. PLUGIN_ROOT and CODEX_HOME point at temporary directories +// throughout -- the script writes into the user's real Codex config otherwise. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const PLUGINS = path.resolve(__dirname, '../../../../../plugins'); +const RELATIVE = path.join('hooks', 'scripts', 'sync-codex-agents.js'); + +/** Every plugin shipping this hook, discovered so a new copy is covered too. */ +const COPIES = fs + .readdirSync(PLUGINS, { withFileTypes: true }) + .filter(entry => entry.isDirectory()) + .map(entry => ({ plugin: entry.name, script: path.join(PLUGINS, entry.name, RELATIVE) })) + .filter(copy => fs.existsSync(copy.script)); + +test('the hook is shipped by at least one plugin', () => { + // Discovery that matched nothing would leave every contract below vacuous. + assert.ok(COPIES.length > 0, `no sync-codex-agents.js found under ${PLUGINS}`); +}); + +/** Spawn one copy, asserting exit 0 and stdout empty or exactly one JSON object. */ +function spawnHook(script, { stdin = '', env = {} } = {}) { + const r = spawnSync(process.execPath, [script], { + input: stdin, + encoding: 'utf8', + env: { ...process.env, ...env } + }); + + assert.equal(r.status, 0, `exited ${r.status}, signal ${r.signal}; stderr: ${r.stderr}`); + const out = r.stdout.trim(); + if (out === '') return null; + + const payload = JSON.parse(out); + assert.equal(typeof payload, 'object'); + assert.notEqual(payload, null); + return payload; +} + +/** A fake plugin root holding `roles`, plus an empty Codex home. */ +function withDirs(roles, fn) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'grimoire-codex-')); + const pluginRoot = path.join(dir, 'someplugin'); + const codexHome = path.join(dir, 'codex-home'); + fs.mkdirSync(codexHome); + + if (roles) { + const agents = path.join(pluginRoot, 'codex', 'agents'); + fs.mkdirSync(agents, { recursive: true }); + for (const [name, contents] of Object.entries(roles)) { + fs.writeFileSync(path.join(agents, name), contents); + } + } else { + fs.mkdirSync(pluginRoot); + } + + try { + return fn({ + env: { PLUGIN_ROOT: pluginRoot, CODEX_HOME: codexHome }, + codexHome, + installed: path.join(codexHome, 'agents', 'grimoire', 'someplugin') + }); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +for (const { plugin, script } of COPIES) { + test(`${plugin}: contract with PLUGIN_ROOT unset -- a clean no-op`, () => { + // Claude Code sets only the prefixed name, so this is every non-Codex session. + const env = { PLUGIN_ROOT: undefined }; + const r = spawnSync(process.execPath, [script], { + input: '', + encoding: 'utf8', + env: Object.fromEntries(Object.entries({ ...process.env, ...env }).filter(([, v]) => v !== undefined)) + }); + assert.equal(r.status, 0, `exited ${r.status}; stderr: ${r.stderr}`); + assert.equal(r.stdout.trim(), ''); + }); + + test(`${plugin}: contract with malformed JSON on stdin`, () => { + withDirs({ 'reviewer.toml': 'name = "reviewer"\n' }, ({ env }) => { + assert.equal(spawnHook(script, { stdin: '{"hook_event_name": ', env }), null); + }); + }); + + test(`${plugin}: contract with empty stdin`, () => { + withDirs({ 'reviewer.toml': 'name = "reviewer"\n' }, ({ env }) => { + assert.equal(spawnHook(script, { stdin: '', env }), null); + }); + }); + + test(`${plugin}: contract with a payload carrying no transcript_path`, () => { + withDirs({ 'reviewer.toml': 'name = "reviewer"\n' }, ({ env }) => { + assert.equal(spawnHook(script, { stdin: JSON.stringify({ hook_event_name: 'SessionStart' }), env }), null); + }); + }); + + test(`${plugin}: contract with no roles to install`, () => { + withDirs(null, ({ env }) => { + assert.equal(spawnHook(script, { env }), null); + }); + }); + + test(`${plugin}: installs the roles it ships under its own namespace`, () => { + withDirs({ 'reviewer.toml': 'name = "reviewer"\n' }, ({ env, installed }) => { + spawnHook(script, { env }); + assert.equal(fs.readFileSync(path.join(installed, 'reviewer.toml'), 'utf8'), 'name = "reviewer"\n'); + }); + }); + + test(`${plugin}: removes a role it no longer ships and leaves other files alone`, () => { + withDirs({ 'reviewer.toml': 'name = "reviewer"\n' }, ({ env, installed }) => { + fs.mkdirSync(installed, { recursive: true }); + fs.writeFileSync(path.join(installed, 'dropped.toml'), 'name = "dropped"\n'); + fs.writeFileSync(path.join(installed, 'notes.md'), 'not a role\n'); + + spawnHook(script, { env }); + + assert.equal(fs.existsSync(path.join(installed, 'dropped.toml')), false); + assert.equal(fs.existsSync(path.join(installed, 'notes.md')), true); + assert.equal(fs.existsSync(path.join(installed, 'reviewer.toml')), true); + }); + }); + + test(`${plugin}: leaves an unchanged role's mtime alone`, () => { + // Writing only on difference keeps mtimes stable across the many sessions + // that change nothing. + withDirs({ 'reviewer.toml': 'name = "reviewer"\n' }, ({ env, installed }) => { + spawnHook(script, { env }); + const target = path.join(installed, 'reviewer.toml'); + const first = fs.statSync(target).mtimeMs; + + spawnHook(script, { env }); + assert.equal(fs.statSync(target).mtimeMs, first); + }); + }); + + test(`${plugin}: installs under the owned namespace even when PLUGIN_ROOT ends in ..`, () => { + // Resolving before taking the basename is what keeps the sweep inside a + // directory this plugin owns. Unresolved, the basename is ".." and the + // target collapses to the shared agents directory, putting roles this + // plugin does not own within reach of the sweep. + // + // Built by concatenation, not path.join: join normalizes the `..` away, + // which would make this test pass against the bug it exists to catch. + withDirs({ 'reviewer.toml': 'name = "reviewer"\n' }, ({ env, installed, codexHome }) => { + const trailing = { ...env, PLUGIN_ROOT: `${env.PLUGIN_ROOT}/codex/..` }; + assert.equal(spawnHook(script, { env: trailing }), null); + assert.equal(fs.existsSync(path.join(installed, 'reviewer.toml')), true); + assert.equal(fs.existsSync(path.join(codexHome, 'agents', 'reviewer.toml')), false); + }); + }); +}