Files
pbakaus_impeccable/tests/context-signals.test.mjs
T
Paul BakausandClaude Opus 4.8 772aa73aa3 feat(skill): make bare /impeccable context-aware (re: #159)
Reshape of the "/impeccable suggest" proposal in #159. Instead of adding a
24th command (menu pollution + the command-add tax + its own discoverability
problem), upgrade the path users already hit: bare `/impeccable` with no
argument.

- New skill/scripts/context-signals.mjs gathers cheap, deterministic signals
  (setup gaps, register, latest cached critique score, git change scope, a
  dev-server port probe, and a `scan.detectTarget` for the detector) and emits
  JSON. It does NOT score or rank, and it does NOT run the detector itself
  (the engine isn't importable in an installed skill, and shelling npx+jsdom
  would risk a hang) — the agent reasons over the raw signals.
- SKILL.md routing rule 1 now leads with the 2-3 highest-value next commands,
  each with a reason from the signals, then the full menu. Never auto-runs;
  always confirms. Reuses init's "Recommend starting points" vocabulary. When
  a project has never been critiqued it offers critique; when scan.detectTarget
  is set it runs `npx impeccable detect --fast --json` and folds the hits in.
- Export extractRegister from context.mjs for reuse.

Stays 23 commands; no metadata/pin/site-data changes. Unit-tested, including a
regression guard for porcelain leading-space path parsing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 15:50:53 -07:00

145 lines
5.4 KiB
JavaScript

/**
* Tests for context-signals.mjs — the signal gatherer behind the
* context-aware bare `/impeccable` (no-argument) path.
*
* The script collects deterministic project signals and emits JSON; it does
* not score or rank (the agent reasons over the raw signals). These tests
* cover signal collection and the never-throw / always-valid-JSON contract.
*
* Each test runs in its own scratch dir under os.tmpdir().
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { gatherSignals } from '../skill/scripts/context-signals.mjs';
const SCRIPT_PATH = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..', 'skill', 'scripts', 'context-signals.mjs',
);
let scratch;
beforeEach(() => {
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-signals-'));
});
afterEach(() => {
fs.rmSync(scratch, { recursive: true, force: true });
});
function write(rel, body) {
const abs = path.join(scratch, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
}
describe('gatherSignals', () => {
it('reports no setup context in an empty dir', async () => {
const s = await gatherSignals(scratch);
assert.equal(s.setup.hasProduct, false);
assert.equal(s.setup.hasDesign, false);
assert.equal(s.setup.register, null);
assert.equal(s.setup.hasCode, false);
assert.equal(s.critique.latest, null);
});
it('detects PRODUCT.md, register, and code presence', async () => {
write('PRODUCT.md', '# Product\n\n## Register\n\nbrand\n');
write('package.json', '{"name":"x"}');
const s = await gatherSignals(scratch);
assert.equal(s.setup.hasProduct, true);
assert.equal(s.setup.register, 'brand');
assert.equal(s.setup.hasCode, true);
});
it('flags missing DESIGN.md when code exists', async () => {
write('PRODUCT.md', '# Product\n\n## Register\n\nproduct\n');
write('src/App.tsx', 'export default 1;');
const s = await gatherSignals(scratch);
assert.equal(s.setup.hasProduct, true);
assert.equal(s.setup.hasDesign, false);
assert.equal(s.setup.hasCode, true);
assert.equal(s.setup.register, 'product');
});
it('reads the newest critique snapshot score', async () => {
write('.impeccable/critique/2026-05-01T10-00-00Z__home.md',
'---\nslug: home\nscore: 6\np0: 1\np1: 3\ntimestamp: 2026-05-01T10-00-00Z\n---\nbody\n');
write('.impeccable/critique/2026-05-02T10-00-00Z__home.md',
'---\nslug: home\nscore: 8\np0: 0\np1: 1\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n');
const s = await gatherSignals(scratch);
assert.equal(s.critique.latest.score, 8); // newest by timestamp prefix
assert.equal(s.critique.latest.p0, 0);
assert.equal(s.critique.latest.slug, 'home');
});
it('handles a non-git dir without throwing', async () => {
const s = await gatherSignals(scratch);
assert.equal(s.git.isRepo, false);
assert.deepEqual(s.git.changedFiles, []);
assert.equal(s.git.changedCount, 0);
});
it('reports working-tree changes with full, untruncated paths', async () => {
const { execFileSync } = await import('node:child_process');
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
git('init', '-q');
git('config', 'user.email', 't@example.com');
git('config', 'user.name', 'Test');
write('site/styles/home.css', 'a{}\n');
git('add', '.');
git('commit', '-qm', 'init');
// Modify it so it shows as ` M ...` (leading-space porcelain line) — the
// exact shape that a naive global trim would truncate to "ite/...".
write('site/styles/home.css', 'a{color:red}\n');
const s = await gatherSignals(scratch);
assert.equal(s.git.isRepo, true);
assert.ok(
s.git.changedFiles.includes('site/styles/home.css'),
`expected full path, got: ${JSON.stringify(s.git.changedFiles)}`,
);
});
it('always includes a well-formed devServer probe', async () => {
const s = await gatherSignals(scratch);
assert.equal(typeof s.devServer.running, 'boolean');
assert.ok(Array.isArray(s.devServer.ports));
});
it('points scan.detectTarget at an HTML entry when one exists', async () => {
write('public/index.html', '<!doctype html><title>x</title>');
const s = await gatherSignals(scratch);
// A live dev server (if one happens to run on the host) wins; otherwise
// the static HTML entry is the target.
if (!s.devServer.running) {
assert.equal(s.scan.detectTarget, 'public/index.html');
assert.equal(s.scan.via, 'html');
} else {
assert.equal(s.scan.via, 'dev-server');
}
});
it('has a null scan.detectTarget when there is nothing scannable', async () => {
const s = await gatherSignals(scratch);
if (!s.devServer.running) {
assert.equal(s.scan.detectTarget, null);
assert.equal(s.scan.via, null);
}
});
});
describe('context-signals CLI', () => {
it('emits valid JSON with all top-level signal groups', async () => {
const { spawnSync } = await import('node:child_process');
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8' });
assert.equal(res.status, 0);
const parsed = JSON.parse(res.stdout);
for (const k of ['setup', 'critique', 'git', 'devServer']) {
assert.ok(k in parsed, `expected "${k}" in signals output`);
}
});
});