mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
v4 changed PRODUCT.md's shape and retired the register axis, so an upgraded project can carry answers nothing reads. Nothing measured that. Two tiers, and the split is a performance contract: - Boot (context.mjs, emitting CONTEXT_STALE) spends only what a boot already spends: markdown already in memory, a bounded set of stats, the small JSON files the boot reads anyway. No new directory walks. One directive for the whole set, throttled to once a week per project so a finding the user declined does not reappear tomorrow. - doctor.mjs runs the deep pass on demand: git drift, ignore lists validated against the live rule registry, hook script paths that stop resolving, and the monorepo workspace sweep. --fix applies only the migrations that carry no decision. Findings are data, not prose, so the boot directive, the text report and --json all render one set. Severity says what should happen: auto (fix on the next write anyway), mention (state once), route (name the command that owns the repair). PRODUCT.md now carries a schema stamp so the checks stop reconstructing a file's vintage from which sections it happens to have. Schema version, not release version: a record written by 4.0.0 is not stale under 4.0.1. DESIGN.md gets no stamp, because it follows the external design.md spec that Stitch lints and every DESIGN.md signal is measurable without one. The highest-value catch is a project that resolves to web while carrying native build files, including a monorepo app inheriting a root record that says web. That one costs output quality silently; nothing failed before. doctor follows the hooks/pin pattern rather than the Commands table, so it stays out of the design menu and the count stays at 23. Also corrects CLAUDE.md, which still documented the register axis, reference/brand.md, reference/product.md, eleven deleted domain reference files, and an extractRegister() whose only occurrence in the repo was that sentence. Prepared with AI assistance (Claude Code). Co-Authored-By: Claude <noreply@anthropic.com>
436 lines
17 KiB
JavaScript
436 lines
17 KiB
JavaScript
/**
|
|
* Tests for the deep staleness pass and the doctor CLI.
|
|
* Run with: node --test tests/doctor.test.mjs
|
|
*
|
|
* The Tier 1 checks are covered in tests/staleness.test.mjs. This file covers
|
|
* what only the deep pass does: git drift, ignore-list validation against the
|
|
* rule registry, hook script resolution, the monorepo sweep, and the narrow set
|
|
* of migrations `--fix` is allowed to perform.
|
|
*/
|
|
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import { spawnSync, execFileSync } from 'node:child_process';
|
|
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 { extractPlatform } from '../skill/scripts/context.mjs';
|
|
import { parseDesignMd } from '../skill/scripts/lib/design-parser.mjs';
|
|
import { checkNativePlatformEvidence } from '../skill/scripts/lib/staleness.mjs';
|
|
import {
|
|
checkDesignCoverage,
|
|
checkDesignDrift,
|
|
checkDetectorIgnores,
|
|
checkHookInstallation,
|
|
checkLegacyLiveState,
|
|
checkWorkspaces,
|
|
loadKnownRuleIds,
|
|
} from '../skill/scripts/lib/staleness-deep.mjs';
|
|
|
|
const SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'scripts');
|
|
const DOCTOR_PATH = path.join(SCRIPTS_DIR, 'doctor.mjs');
|
|
|
|
let scratch;
|
|
|
|
beforeEach(() => {
|
|
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-doctor-'));
|
|
});
|
|
|
|
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);
|
|
return abs;
|
|
}
|
|
|
|
function ids(findings) {
|
|
return findings.map((entry) => entry.id);
|
|
}
|
|
|
|
function git(args, cwd = scratch) {
|
|
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
}
|
|
|
|
function initRepo() {
|
|
git(['init', '-q']);
|
|
git(['config', 'user.email', 'test@example.com']);
|
|
git(['config', 'user.name', 'Test']);
|
|
}
|
|
|
|
function commit(message) {
|
|
git(['add', '-A']);
|
|
git(['commit', '-q', '--no-gpg-sign', '-m', message]);
|
|
}
|
|
|
|
const CURRENT_PRODUCT = [
|
|
'# Product',
|
|
'',
|
|
'<!-- impeccable:product-schema 1 -->',
|
|
'',
|
|
'## Platform',
|
|
'',
|
|
'web',
|
|
'',
|
|
'## Positioning',
|
|
'The only one that does the thing.',
|
|
'',
|
|
].join('\n');
|
|
|
|
// ─── git drift ─────────────────────────────────────────────────────────────
|
|
|
|
describe('checkDesignDrift', () => {
|
|
it('reports nothing outside a git repository', () => {
|
|
const design = write('DESIGN.md', '---\nname: X\n---\n');
|
|
write('src/a.css', 'a{}');
|
|
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch }), []);
|
|
});
|
|
|
|
it('reports nothing when UI commits stay under the threshold', () => {
|
|
initRepo();
|
|
const design = write('DESIGN.md', '---\nname: X\n---\n');
|
|
write('src/a.css', 'a{color:red}');
|
|
commit('initial');
|
|
for (let i = 0; i < 3; i++) {
|
|
write(`src/f${i}.css`, `.f${i}{}`);
|
|
commit(`ui ${i}`);
|
|
}
|
|
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch }), []);
|
|
});
|
|
|
|
it('reports the commit count once the threshold is crossed, framed as a proxy', () => {
|
|
initRepo();
|
|
const design = write('DESIGN.md', '---\nname: X\n---\n');
|
|
write('src/a.css', 'a{color:red}');
|
|
commit('initial');
|
|
for (let i = 0; i < 4; i++) {
|
|
write(`src/f${i}.css`, `.f${i}{}`);
|
|
commit(`ui ${i}`);
|
|
}
|
|
const findings = checkDesignDrift({ designPath: design, projectRoot: scratch, threshold: 3 });
|
|
assert.deepEqual(ids(findings), ['design-md-drift']);
|
|
assert.match(findings[0].summary, /4 commits have touched/);
|
|
assert.match(findings[0].summary, /not that it is wrong/);
|
|
assert.equal(findings[0].severity, 'route');
|
|
});
|
|
|
|
it('counts only commits after the last DESIGN.md edit', () => {
|
|
initRepo();
|
|
const design = write('DESIGN.md', '---\nname: X\n---\n');
|
|
write('src/a.css', 'a{}');
|
|
commit('initial');
|
|
for (let i = 0; i < 4; i++) {
|
|
write(`src/old${i}.css`, `.o${i}{}`);
|
|
commit(`old ui ${i}`);
|
|
}
|
|
fs.writeFileSync(design, '---\nname: X2\n---\n');
|
|
commit('refresh design');
|
|
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch, threshold: 3 }), []);
|
|
});
|
|
|
|
it('reports nothing for an untracked DESIGN.md', () => {
|
|
initRepo();
|
|
write('src/a.css', 'a{}');
|
|
commit('initial');
|
|
const design = write('DESIGN.md', '---\nname: X\n---\n');
|
|
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch, threshold: 1 }), []);
|
|
});
|
|
});
|
|
|
|
// ─── DESIGN.md coverage ────────────────────────────────────────────────────
|
|
|
|
describe('checkDesignCoverage', () => {
|
|
it('names the canonical sections that carry nothing', () => {
|
|
const findings = checkDesignCoverage({
|
|
design: '---\nname: X\n---\n\n# Design System: X\n\n## Overview\n\nSomething.\n',
|
|
designPath: 'DESIGN.md',
|
|
parseDesignMd,
|
|
});
|
|
assert.deepEqual(ids(findings), ['design-md-coverage']);
|
|
assert.match(findings[0].summary, /colors, typography, components/);
|
|
assert.equal(findings[0].severity, 'mention');
|
|
});
|
|
|
|
it('stays quiet when the sections are present', () => {
|
|
const design = [
|
|
'---', 'name: X', '---', '',
|
|
'# Design System: X', '',
|
|
'## Colors', '', '### Primary', '- **Ink** (#111): Text.', '',
|
|
'## Typography', '', '**Body Font:** Inter', '',
|
|
'### Hierarchy', '- **Body** (400, 16px, 1.5): Paragraphs.', '',
|
|
'## Components', '', '### Button', '- Primary action.', '',
|
|
].join('\n');
|
|
assert.deepEqual(checkDesignCoverage({ design, designPath: 'DESIGN.md', parseDesignMd }), []);
|
|
});
|
|
|
|
it('reports nothing without a DESIGN.md', () => {
|
|
assert.deepEqual(checkDesignCoverage({ design: null, parseDesignMd }), []);
|
|
});
|
|
});
|
|
|
|
// ─── detector ignore lists ─────────────────────────────────────────────────
|
|
|
|
describe('checkDetectorIgnores', () => {
|
|
it('flags rule ids the engine does not have', () => {
|
|
write('.impeccable/config.json', JSON.stringify({
|
|
detector: { ignoreRules: ['side-tab', 'no-such-rule'] },
|
|
}));
|
|
const findings = checkDetectorIgnores({
|
|
projectRoot: scratch,
|
|
knownRuleIds: new Set(['side-tab']),
|
|
});
|
|
assert.deepEqual(ids(findings), ['detector-ignore-rules-unknown']);
|
|
assert.match(findings[0].summary, /`no-such-rule`/);
|
|
assert.doesNotMatch(findings[0].summary, /`side-tab`/);
|
|
});
|
|
|
|
it('skips validation entirely when the registry is unavailable', () => {
|
|
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRules: ['whatever'] } }));
|
|
assert.deepEqual(checkDetectorIgnores({ projectRoot: scratch, knownRuleIds: null }), []);
|
|
});
|
|
|
|
it('accepts the wildcard rule', () => {
|
|
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRules: ['*'] } }));
|
|
assert.deepEqual(
|
|
checkDetectorIgnores({ projectRoot: scratch, knownRuleIds: new Set(['side-tab']) }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('flags ignored files that no longer exist but leaves globs alone', () => {
|
|
write('src/here.tsx', 'x');
|
|
write('.impeccable/config.json', JSON.stringify({
|
|
detector: { ignoreFiles: ['src/here.tsx', 'src/gone.tsx', 'src/**/*.stories.tsx'] },
|
|
}));
|
|
const findings = checkDetectorIgnores({ projectRoot: scratch });
|
|
assert.deepEqual(ids(findings), ['detector-ignore-files-missing']);
|
|
assert.match(findings[0].summary, /`src\/gone\.tsx`/);
|
|
assert.doesNotMatch(findings[0].summary, /stories/);
|
|
});
|
|
|
|
it('resolves the real rule registry from the source checkout', async () => {
|
|
const knownRuleIds = await loadKnownRuleIds(SCRIPTS_DIR);
|
|
assert.ok(knownRuleIds instanceof Set, 'expected the bundled detector to resolve');
|
|
assert.ok(knownRuleIds.size > 20, `expected many rule ids, got ${knownRuleIds.size}`);
|
|
assert.ok(knownRuleIds.has('side-tab'));
|
|
});
|
|
});
|
|
|
|
// ─── hook installation ─────────────────────────────────────────────────────
|
|
|
|
describe('checkHookInstallation', () => {
|
|
it('flags an installed hook whose script path does not resolve', () => {
|
|
write('.claude/settings.json', JSON.stringify({
|
|
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
|
|
}));
|
|
const findings = checkHookInstallation({
|
|
projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code',
|
|
});
|
|
assert.deepEqual(ids(findings), ['hook-script-missing']);
|
|
assert.match(findings[0].summary, /no-op/);
|
|
});
|
|
|
|
it('stays quiet when the hook script is present', () => {
|
|
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
|
write('.claude/settings.json', JSON.stringify({
|
|
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
|
|
}));
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('flags an installed manifest contradicted by hook.enabled false', () => {
|
|
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
|
write('.claude/settings.json', JSON.stringify({
|
|
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
|
|
}));
|
|
write('.impeccable/config.json', JSON.stringify({ hook: { enabled: false } }));
|
|
const findings = checkHookInstallation({
|
|
projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code',
|
|
});
|
|
assert.deepEqual(ids(findings), ['hook-enabled-conflict']);
|
|
});
|
|
|
|
it('reports nothing when no manifest installs the hook', () => {
|
|
write('.claude/settings.json', JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'node other.mjs' }] }] } }));
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('reports nothing for a provider with no known manifest', () => {
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'source' }),
|
|
[],
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── retired live-mode state ───────────────────────────────────────────────
|
|
|
|
describe('checkLegacyLiveState', () => {
|
|
it('flags retired locations as automatic', () => {
|
|
write('.impeccable-live.json', '{}');
|
|
const findings = checkLegacyLiveState({ projectRoot: scratch });
|
|
assert.deepEqual(ids(findings), ['legacy-live-state']);
|
|
assert.equal(findings[0].severity, 'auto');
|
|
});
|
|
|
|
it('reports nothing on a current project', () => {
|
|
assert.deepEqual(checkLegacyLiveState({ projectRoot: scratch }), []);
|
|
});
|
|
});
|
|
|
|
// ─── monorepo sweep ────────────────────────────────────────────────────────
|
|
|
|
describe('checkWorkspaces', () => {
|
|
function sweep(candidates) {
|
|
return checkWorkspaces({
|
|
repoRoot: scratch,
|
|
candidates,
|
|
checkNativePlatformEvidence,
|
|
extractPlatform,
|
|
readFile: (filePath) => {
|
|
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
|
|
},
|
|
});
|
|
}
|
|
|
|
it('flags a native workspace inheriting a web product record', () => {
|
|
write('PRODUCT.md', CURRENT_PRODUCT);
|
|
write('apps/mobile/ios/Podfile', '');
|
|
const { findings } = sweep([
|
|
{ name: 'mobile', path: 'apps/mobile', productStatus: 'inherited', productPath: 'PRODUCT.md', designStatus: 'missing' },
|
|
]);
|
|
assert.ok(ids(findings).includes('workspace-platform-native-evidence'));
|
|
const entry = findings.find((f) => f.id === 'workspace-platform-native-evidence');
|
|
assert.match(entry.summary, /inherits the repo-root PRODUCT\.md/);
|
|
assert.match(entry.fix, /its own PRODUCT\.md/);
|
|
});
|
|
|
|
it('does not flag a workspace whose own record declares the native platform', () => {
|
|
write('apps/mobile/PRODUCT.md', CURRENT_PRODUCT.replace('web', 'ios'));
|
|
write('apps/mobile/ios/Podfile', '');
|
|
const { findings } = sweep([
|
|
{ name: 'mobile', path: 'apps/mobile', productStatus: 'child', productPath: 'apps/mobile/PRODUCT.md', designStatus: 'missing' },
|
|
]);
|
|
assert.deepEqual(ids(findings), []);
|
|
});
|
|
|
|
it('reports inheritance as information, not as a defect to fix', () => {
|
|
write('PRODUCT.md', CURRENT_PRODUCT);
|
|
const { findings, workspaces } = sweep([
|
|
{ name: 'web', path: 'apps/web', productStatus: 'inherited', productPath: 'PRODUCT.md', designStatus: 'missing' },
|
|
]);
|
|
assert.deepEqual(ids(findings), ['workspace-context-inherited']);
|
|
assert.match(findings[0].summary, /is not something this check can tell/);
|
|
assert.equal(workspaces[0].platform, 'web');
|
|
});
|
|
|
|
it('returns an empty sweep with no candidates', () => {
|
|
assert.deepEqual(checkWorkspaces({ repoRoot: scratch, candidates: [] }), { findings: [], workspaces: [] });
|
|
});
|
|
});
|
|
|
|
// ─── the CLI ───────────────────────────────────────────────────────────────
|
|
|
|
describe('doctor CLI', () => {
|
|
function run(args = [], cwd = scratch) {
|
|
return spawnSync(process.execPath, [DOCTOR_PATH, ...args], {
|
|
cwd,
|
|
encoding: 'utf8',
|
|
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
|
|
});
|
|
}
|
|
|
|
it('reports a clean project and exits zero', () => {
|
|
write('PRODUCT.md', CURRENT_PRODUCT);
|
|
const res = run();
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.match(res.stdout, /No drift found/);
|
|
});
|
|
|
|
it('groups findings by severity in JSON mode', () => {
|
|
write('PRODUCT.md', '# Product\n\n## Register\n\nbrand\n\n## Users\nDesigners.\n');
|
|
const res = run(['--json']);
|
|
assert.equal(res.status, 0, res.stderr);
|
|
const report = JSON.parse(res.stdout);
|
|
assert.ok(report.findings.some((entry) => entry.id === 'product-deprecated-register'));
|
|
assert.ok(report.findings.some((entry) => entry.id === 'product-schema-legacy'));
|
|
for (const entry of report.findings) {
|
|
assert.ok(['auto', 'mention', 'route'].includes(entry.severity), entry.severity);
|
|
assert.ok(entry.summary && entry.fix, entry.id);
|
|
}
|
|
assert.equal(report.ruleRegistryAvailable, true);
|
|
});
|
|
|
|
it('applies only the automatic migrations under --fix', () => {
|
|
write('PRODUCT.md', CURRENT_PRODUCT.replace('<!-- impeccable:product-schema 1 -->\n\n', ''));
|
|
write('DESIGN.json', JSON.stringify({ schemaVersion: 2 }));
|
|
const res = run(['--fix']);
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.match(res.stdout, /Moved DESIGN\.json to \.impeccable\/design\.json/);
|
|
assert.match(res.stdout, /Stamped PRODUCT\.md/);
|
|
assert.ok(fs.existsSync(path.join(scratch, '.impeccable', 'design.json')));
|
|
assert.ok(!fs.existsSync(path.join(scratch, 'DESIGN.json')));
|
|
assert.match(fs.readFileSync(path.join(scratch, 'PRODUCT.md'), 'utf8'), /impeccable:product-schema 1/);
|
|
});
|
|
|
|
it('leaves a legacy sidecar alone when the canonical one already exists', () => {
|
|
write('PRODUCT.md', CURRENT_PRODUCT);
|
|
write('DESIGN.json', JSON.stringify({ schemaVersion: 2, title: 'legacy' }));
|
|
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2, title: 'current' }));
|
|
const res = run(['--fix', '--json']);
|
|
assert.equal(res.status, 0, res.stderr);
|
|
const report = JSON.parse(res.stdout);
|
|
assert.equal(report.fixes.applied.length, 0);
|
|
assert.equal(
|
|
JSON.parse(fs.readFileSync(path.join(scratch, '.impeccable', 'design.json'), 'utf8')).title,
|
|
'current',
|
|
);
|
|
assert.ok(fs.existsSync(path.join(scratch, 'DESIGN.json')));
|
|
});
|
|
|
|
it('does not stamp a PRODUCT.md that init should rewrite instead', () => {
|
|
write('PRODUCT.md', '# Product\n\n## Users\nDesigners.\n');
|
|
const res = run(['--fix']);
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.doesNotMatch(fs.readFileSync(path.join(scratch, 'PRODUCT.md'), 'utf8'), /product-schema/);
|
|
assert.match(res.stdout, /product-schema-legacy/);
|
|
});
|
|
|
|
it('lists workspaces and their context resolution in a monorepo', () => {
|
|
write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }));
|
|
write('PRODUCT.md', CURRENT_PRODUCT);
|
|
write('apps/web/package.json', JSON.stringify({ name: 'web' }));
|
|
write('apps/mobile/package.json', JSON.stringify({ name: 'mobile', dependencies: { 'react-native': '0.74' } }));
|
|
write('apps/mobile/ios/Podfile', '');
|
|
const res = run(['--json']);
|
|
assert.equal(res.status, 0, res.stderr);
|
|
const report = JSON.parse(res.stdout);
|
|
assert.equal(report.isMonorepo, true);
|
|
assert.deepEqual(report.workspaces.map((entry) => entry.path).sort(), ['apps/mobile', 'apps/web']);
|
|
assert.ok(report.findings.some((entry) => entry.id === 'workspace-platform-native-evidence'));
|
|
});
|
|
|
|
it('prints usage for --help without running any check', () => {
|
|
const res = run(['--help']);
|
|
assert.equal(res.status, 0, res.stderr);
|
|
assert.match(res.stdout, /Usage: node doctor\.mjs/);
|
|
assert.doesNotMatch(res.stdout, /No drift found/);
|
|
});
|
|
|
|
it('rejects a malformed target argument', () => {
|
|
const res = run(['--target']);
|
|
assert.notEqual(res.status, 0);
|
|
});
|
|
});
|