Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 26 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,18 +64,42 @@ jobs:
- name: Lint
run: make lint

test:
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

# Node 20 rather than the runner's default: the hook scripts run under
# whatever Node the user's harness carries, so the floor is the version
# worth testing on. No matrix -- CONTRIBUTING.md has why.
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: "20"

# No pip cache, for the same reason the Verify job gives: the test suite is
# stdlib-only, so there is nothing to install and nothing to key a cache on.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"

- name: Test
run: make test

# The required status check. Do NOT add a `name:` -- the ruleset matches the
# context by job id, so naming it silently stops the check from matching.
check:
if: always()
# When you add jobs above, add them to BOTH the needs list and the results
# array below.
needs: [verify, lint]
needs: [verify, lint, test]
runs-on: ubuntu-24.04
timeout-minutes: 2
steps:
- run: |
results=("${{ needs.verify.result }}" "${{ needs.lint.result }}")
results=("${{ needs.verify.result }}" "${{ needs.lint.result }}" "${{ needs.test.result }}")
for r in "${results[@]}"; do
if [[ "$r" != "success" && "$r" != "skipped" ]]; then
echo "Check failed: $r"
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ done, confirm:
```bash
make verify
make lint
make test
```

`make test` joins these two once the suite lands -- it is specified in
`CONTRIBUTING.md` and arrives with its first tests, not ahead of them.
`CONTRIBUTING.md` has what `make test` covers, what it deliberately does not,
and where a new test goes.

CI runs the same checks. `AGENTS.md` is a symlink to this file, not a second
source of truth -- edit `CLAUDE.md`.
Expand Down
6 changes: 4 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ error.
```bash
make lint
make verify
make test
```

Both must pass -- CI runs the same checks. `make help` lists what each target
covers. `make test` joins them once the suite lands; see [Tests](#tests).
All three must pass -- CI runs the same checks. `make help` lists what each
target covers, and [Tests](#tests) has what the suite covers, what it
deliberately does not, and where a new test goes.

## Tests

Expand Down
20 changes: 19 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# CI calls these targets directly, so this file is the single definition of the
# checks. Recipes run under dash both here and on the runners -- no bashisms.

.PHONY: help install lint verify
.PHONY: help install lint verify test

help: ## Show available targets
@grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | awk 'BEGIN{FS=":.*?## "}{printf " %-12s %s\n", $$1, $$2}'
Expand Down Expand Up @@ -29,3 +29,21 @@ verify: ## Run the correctness gate (ASCII, JSON parses, Codex drift)
@python3 -c "import json,subprocess; files=subprocess.run(['git','ls-files','*.json'],capture_output=True,text=True,check=True).stdout.split(); assert files, 'git ls-files matched no JSON -- gate would pass having checked nothing'; [json.load(open(f)) for f in files]"
@echo "Checking generated Codex files against their sources..."
@python3 scripts/generate-codex.py --check

# Each half hands its runner an explicit file list from `git ls-files`, and
# asserts the list is non-empty for the same reason the JSON check above does:
# `git ls-files` exits 0 on no match and `node --test` with no arguments walks
# the whole tree instead of failing, so an unguarded list is a silent pass.
# Why a list rather than a directory or a glob: see CONTRIBUTING.md.
#
# Node runs first, and Make stops at the first failing line, so a Node failure
# hides the Python result -- the same trade `verify` makes above.
test: ## Run the unit tests and hook process contracts (Node, then Python)
@echo "Running the Node tests..."
@files=$$(git ls-files 'tests/*.test.js'); \
if [ -z "$$files" ]; then echo "ERROR: git ls-files matched no Node tests -- gate would pass having checked nothing"; exit 1; fi; \
node --test $$files
@echo "Running the Python tests..."
@files=$$(git ls-files 'tests/test_*.py' 'tests/*/test_*.py'); \
if [ -z "$$files" ]; then echo "ERROR: git ls-files matched no Python tests -- gate would pass having checked nothing"; exit 1; fi; \
python3 -m unittest $$files
2 changes: 1 addition & 1 deletion plugins/praxis/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "praxis",
"version": "1.4.0",
"version": "1.4.2",
"description": "Development workflow -- issue planning, implementation, PR creation, code review with specialized reviewers, and project conventions",
"author": {
"name": "Jartan LLC",
Expand Down
2 changes: 1 addition & 1 deletion plugins/praxis/.codex-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "praxis",
"version": "1.4.0",
"version": "1.4.2",
"description": "Development workflow -- issue planning, implementation, PR creation, code review with specialized reviewers, and project conventions",
"interface": {
"displayName": "Praxis",
Expand Down
2 changes: 2 additions & 0 deletions plugins/praxis/hooks/scripts/lib/transcript-context.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,8 @@ function resolveContextInterval(env) {

module.exports = {
MAX_TOKEN_SETTING,
readFileTail,
extractUsageTokens,
readLatestContextTokens,
resolveContextThreshold,
resolveContextInterval
Expand Down
1 change: 1 addition & 0 deletions plugins/praxis/hooks/scripts/lib/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ module.exports = {
getProjectName,

// File operations
filterByPatterns,
findFiles,
readFile,
writeFile,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,6 @@ function main() {
report(dedupePairs(findings));
}

module.exports = { buildSkipMatcher, prose, buildCommentIndex, findRetoldInDiff, dedupePairs };
module.exports = { parseArgs, buildSkipMatcher, prose, trackedPaths, buildCommentIndex, findRetoldInDiff, dedupePairs };

if (require.main === module) main();
192 changes: 192 additions & 0 deletions tests/plugins/praxis/hooks/scripts/check-console-log.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
Binary file not shown.
Loading