mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 06:06:37 +03:00
The deep staleness pass extracted a hook-script path with a greedy `\S*`
prefix that swallowed the `${CLAUDE_PROJECT_DIR}/` placeholder, then
existsSync'd the literal string. That string never exists, so every project
installed by `impeccable hooks on` got a `hook-script-missing` finding with
text claiming UI edits were going unscanned — the opposite of the truth.
Split extraction from resolution. hookScriptTokenFrom now pulls the path
token (quoted-first, so it handles the #399 guarded `[ ! -f "PATH" ] || node
"PATH"` form and absolute user-level installs) without absorbing shell
syntax. resolveHookScriptPath then applies a per-placeholder policy:
- ${CLAUDE_PROJECT_DIR} expands to the scanned root (the runtime mapping).
- ${CLAUDE_PLUGIN_ROOT} / ${PLUGIN_ROOT} / ${GROK_PLUGIN_ROOT}, $(...) command
substitution (GitHub's $(git rev-parse)), and any other $VAR are SKIPPED:
the doctor cannot know those locations and must never assert a negative it
cannot verify.
The check stays real: a placeholder that expands to a genuinely absent path
still flags. Adds TDD coverage for every command form.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
534 lines
22 KiB
JavaScript
534 lines
22 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' }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
// The manifest `impeccable hooks on` actually writes, verbatim: a
|
|
// `${CLAUDE_PROJECT_DIR}`-relative command. Claude Code expands the variable
|
|
// to the project dir at hook time; the doctor must expand it the same way
|
|
// (issue #402) instead of existsSync-ing the literal `${CLAUDE_PROJECT_DIR}/...`.
|
|
const claudeManifest = () => ({
|
|
hooks: {
|
|
PostToolUse: [{
|
|
matcher: 'Edit|Write|MultiEdit',
|
|
hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' }],
|
|
}],
|
|
Stop: [{ hooks: [{ type: 'command', command: 'node "${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"' }] }],
|
|
},
|
|
});
|
|
|
|
it('stays quiet for a ${CLAUDE_PROJECT_DIR} manifest when the script exists at root', () => {
|
|
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
|
write('.claude/settings.json', JSON.stringify(claudeManifest()));
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('still flags a genuinely missing script behind ${CLAUDE_PROJECT_DIR}', () => {
|
|
// Placeholder expands to a real path that does not exist: the check must
|
|
// stay real, not neutered into always-quiet.
|
|
write('.claude/settings.json', JSON.stringify(claudeManifest()));
|
|
const findings = checkHookInstallation({
|
|
projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code',
|
|
});
|
|
assert.deepEqual(ids(findings), ['hook-script-missing']);
|
|
});
|
|
|
|
it('handles the #399 guarded project-relative form', () => {
|
|
const p = '"${CLAUDE_PROJECT_DIR}/.claude/skills/impeccable/scripts/hook.mjs"';
|
|
const guarded = `[ ! -f ${p} ] || node ${p}`;
|
|
write('.claude/settings.json', JSON.stringify({
|
|
hooks: { Stop: [{ hooks: [{ command: guarded }] }] },
|
|
}));
|
|
// missing → flagged
|
|
assert.deepEqual(
|
|
ids(checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' })),
|
|
['hook-script-missing'],
|
|
);
|
|
// present → quiet
|
|
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('handles the #399 guarded absolute form (user-level installs)', () => {
|
|
const abs = path.join(scratch, '.claude', 'skills', 'impeccable', 'scripts', 'hook.mjs');
|
|
const p = JSON.stringify(abs);
|
|
const guarded = `[ ! -f ${p} ] || node ${p}`;
|
|
write('.claude/settings.json', JSON.stringify({
|
|
hooks: { Stop: [{ hooks: [{ command: guarded }] }] },
|
|
}));
|
|
// absolute path missing → flagged
|
|
assert.deepEqual(
|
|
ids(checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' })),
|
|
['hook-script-missing'],
|
|
);
|
|
// present → quiet
|
|
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('never reports missing for the GitHub $(git rev-parse) form', () => {
|
|
// Command substitution is not statically resolvable; a doctor must not
|
|
// assert a negative it cannot verify.
|
|
write('.github/hooks/impeccable.json', JSON.stringify({
|
|
hooks: { postToolUse: [{ bash: 'node "$(git rev-parse --show-toplevel)/.github/skills/impeccable/scripts/hook.mjs"' }] },
|
|
}));
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'github' }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
it('never reports missing for plugin-root placeholders the doctor cannot map', () => {
|
|
for (const token of ['${CLAUDE_PLUGIN_ROOT}', '${PLUGIN_ROOT}', '${GROK_PLUGIN_ROOT}']) {
|
|
fs.rmSync(path.join(scratch, '.claude'), { recursive: true, force: true });
|
|
write('.claude/settings.json', JSON.stringify({
|
|
hooks: { Stop: [{ hooks: [{ command: `node "${token}/skills/impeccable/scripts/hook.mjs"` }] }] },
|
|
}));
|
|
assert.deepEqual(
|
|
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
|
|
[],
|
|
`expected no finding for ${token}`,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── 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);
|
|
});
|
|
});
|