mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-17 00:26:41 +03:00
Flag and repair drift in Impeccable's own project artifacts
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>
This commit is contained in:
@@ -33,6 +33,12 @@ import { fileURLToPath } from 'node:url';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
import { IMPECCABLE_COMMAND, IMPECCABLE_PROVIDER_ID } from './lib/provider.mjs';
|
||||
import { resolveSurfaceBrief } from './lib/surface-briefs.mjs';
|
||||
import { collectBootFindings, designSidecarCandidatesFor } from './lib/staleness.mjs';
|
||||
import {
|
||||
buildStalenessDirective,
|
||||
filterFreshFindings,
|
||||
stalenessCheckDisabled,
|
||||
} from './lib/staleness-notice.mjs';
|
||||
|
||||
const PRODUCT_NAMES = ['PRODUCT.md', 'Product.md', 'product.md'];
|
||||
const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md'];
|
||||
@@ -1138,6 +1144,7 @@ async function cli() {
|
||||
if (shouldWarnMissingTarget(ctx, targetProvided, targetExists)) {
|
||||
parts.push(buildMissingTargetDirective());
|
||||
}
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (updateDirective) parts.push(updateDirective);
|
||||
process.stdout.write(parts.join('\n\n---\n\n') + '\n');
|
||||
process.exit(0);
|
||||
@@ -1169,6 +1176,7 @@ async function cli() {
|
||||
`# NATIVE PLATFORM REFERENCE: ${reference.name.toUpperCase()} (reference/${reference.name}.md)\n\n${reference.content.trim()}`,
|
||||
);
|
||||
}
|
||||
appendStalenessDirective(parts, ctx, cliOptions);
|
||||
if (!ctx.platform) {
|
||||
// A `## Platform` section that names something we don't recognize (a
|
||||
// toolchain like `flutter`, a typo) would otherwise silently fall back to
|
||||
@@ -1282,6 +1290,49 @@ function appendDetectorFallback(parts, ctx) {
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
// Tier 1 staleness: schema drift in Impeccable's own project files, measured
|
||||
// with what the boot already spends. Everything here is either a parse of
|
||||
// markdown already in memory, a bounded set of stats, or one of the small JSON
|
||||
// files the boot reads regardless. The deep pass (git drift, token divergence,
|
||||
// cross-workspace sweep) belongs to the doctor command, not to every session.
|
||||
function appendStalenessDirective(parts, ctx, options) {
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
if (stalenessCheckDisabled([projectRoot, ctx.repoRoot])) return;
|
||||
const absCwd = path.resolve(process.cwd());
|
||||
|
||||
let findings;
|
||||
try {
|
||||
findings = collectBootFindings(ctx, {
|
||||
absProductPath: ctx.productPath ? path.resolve(absCwd, ctx.productPath) : null,
|
||||
absDesignPath: ctx.designPath ? path.resolve(absCwd, ctx.designPath) : null,
|
||||
sidecarCandidates: designSidecarCandidatesFor(projectRoot, ctx.contextDir),
|
||||
...projectRootsDiagnostic(ctx, options),
|
||||
});
|
||||
} catch {
|
||||
// A staleness check must never be the reason a boot fails to print context.
|
||||
return;
|
||||
}
|
||||
|
||||
const fresh = filterFreshFindings(findings, { projectRoot });
|
||||
const directive = buildStalenessDirective(fresh);
|
||||
if (directive) parts.push(directive);
|
||||
}
|
||||
|
||||
// `projectRoots` globs that match nothing leave the repo root standing in as
|
||||
// the active project with no other signal. Only computed in the one situation
|
||||
// where that happens and cli() has not already exited on a target selection:
|
||||
// a monorepo, at its root, with no --target. In that case discovery has just
|
||||
// returned an empty candidate list, so the walk repeated here is the cheap
|
||||
// path (a pattern that matches nothing exits before reading any directory).
|
||||
function projectRootsDiagnostic(ctx, options) {
|
||||
if (hasTargetOption(options)) return {};
|
||||
if (!ctx.isMonorepo || !ctx.repoRoot) return {};
|
||||
if (path.resolve(ctx.projectRoot || '') !== path.resolve(ctx.repoRoot)) return {};
|
||||
const patterns = readImpeccableProjectRoots(ctx.repoRoot);
|
||||
if (!patterns.length) return {};
|
||||
return { projectRootPatterns: patterns, targetCandidates: discoverTargetCandidates(ctx.repoRoot) };
|
||||
}
|
||||
|
||||
function buildResolvedContextDirective(ctx, options, { targetExists = null } = {}) {
|
||||
const targetPath = hasTargetOption(options) ? options.targetPath : null;
|
||||
return `RESOLVED_CONTEXT:\n${JSON.stringify({
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Deep staleness pass over Impeccable's own project artifacts.
|
||||
*
|
||||
* node doctor.mjs # human-readable report
|
||||
* node doctor.mjs --json # machine-readable, for the skill command
|
||||
* node doctor.mjs --fix # apply the mechanical migrations only
|
||||
* node doctor.mjs --target <path> # pick a monorepo workspace
|
||||
*
|
||||
* The boot check in context.mjs reports what a session can afford to measure.
|
||||
* This runs everything: git drift, per-workspace sweep, ignore-list validation
|
||||
* against the live rule registry, hook script resolution.
|
||||
*
|
||||
* `--fix` is deliberately narrow. It performs only the migrations marked
|
||||
* severity 'auto', the ones with no judgment in them: stamp the product record,
|
||||
* move a sidecar out of a retired location. Anything that needs an answer from
|
||||
* the user (a platform value, whether an inherited record still describes an
|
||||
* app, whether a document has drifted from the code) is reported and left
|
||||
* alone. Exit code is 0 unless the run itself failed; findings are not errors.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { loadContext, extractPlatform, resolveTargetSelection } from './context.mjs';
|
||||
import { parseTargetOptions } from './lib/target-args.mjs';
|
||||
import { IMPECCABLE_COMMAND, IMPECCABLE_PROVIDER_ID } from './lib/provider.mjs';
|
||||
import { parseDesignMd } from './lib/design-parser.mjs';
|
||||
import {
|
||||
PRODUCT_SCHEMA_VERSION,
|
||||
readProductSchemaVersion,
|
||||
stampProductSchema,
|
||||
} from './lib/artifact-schema.mjs';
|
||||
import {
|
||||
checkConfig,
|
||||
checkDesignSidecar,
|
||||
checkNativePlatformEvidence,
|
||||
checkProduct,
|
||||
checkProjectRoots,
|
||||
checkSurfaceBriefs,
|
||||
designSidecarCandidatesFor,
|
||||
} from './lib/staleness.mjs';
|
||||
import {
|
||||
checkDesignCoverage,
|
||||
checkDesignDrift,
|
||||
checkDetectorIgnores,
|
||||
checkHookInstallation,
|
||||
checkLegacyLiveState,
|
||||
checkWorkspaces,
|
||||
loadKnownRuleIds,
|
||||
} from './lib/staleness-deep.mjs';
|
||||
|
||||
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function safeRead(filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const passthrough = [];
|
||||
const flags = { json: false, fix: false, help: false };
|
||||
for (const arg of argv) {
|
||||
if (arg === '--json') flags.json = true;
|
||||
else if (arg === '--fix') flags.fix = true;
|
||||
else if (arg === '--help' || arg === '-h') flags.help = true;
|
||||
else passthrough.push(arg);
|
||||
}
|
||||
return { flags, targetOptions: parseTargetOptions(passthrough, { strict: true }) };
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
`Usage: node doctor.mjs [--json] [--fix] [--target <path>]`,
|
||||
'',
|
||||
"Report drift between this project's Impeccable artifacts and what the",
|
||||
'installed version reads: PRODUCT.md, DESIGN.md and its sidecar,',
|
||||
'.impeccable/config.json, surface briefs, and the design hook.',
|
||||
'',
|
||||
' --json Emit findings as JSON.',
|
||||
' --fix Apply the mechanical migrations (severity "auto") only.',
|
||||
' --target <path> Select a workspace in a monorepo.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function collect(cwd, targetOptions) {
|
||||
const ctx = loadContext(cwd, targetOptions);
|
||||
const projectRoot = ctx.projectRoot || cwd;
|
||||
const absProductPath = ctx.productPath ? path.resolve(cwd, ctx.productPath) : null;
|
||||
const absDesignPath = ctx.designPath ? path.resolve(cwd, ctx.designPath) : null;
|
||||
const sidecarCandidates = designSidecarCandidatesFor(projectRoot, ctx.contextDir);
|
||||
const knownRuleIds = await loadKnownRuleIds(SCRIPTS_DIR);
|
||||
|
||||
const selection = resolveTargetSelection(cwd, targetOptions);
|
||||
const workspaceCandidates = selection?.targetCandidates || [];
|
||||
|
||||
const workspaceResult = checkWorkspaces({
|
||||
repoRoot: ctx.repoRoot,
|
||||
candidates: workspaceCandidates,
|
||||
checkNativePlatformEvidence,
|
||||
extractPlatform,
|
||||
readFile: safeRead,
|
||||
});
|
||||
|
||||
const findings = [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
...(ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({ designPath: absDesignPath, sidecarCandidates, projectRoot }),
|
||||
...checkDesignDrift({ designPath: absDesignPath, projectRoot }),
|
||||
...checkDesignCoverage({ design: ctx.design, designPath: ctx.designPath, parseDesignMd }),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkDetectorIgnores({ projectRoot, knownRuleIds }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...checkHookInstallation({
|
||||
projectRoot,
|
||||
repoRoot: ctx.repoRoot,
|
||||
providerId: IMPECCABLE_PROVIDER_ID,
|
||||
}),
|
||||
...checkLegacyLiveState({ projectRoot }),
|
||||
...checkProjectRoots({
|
||||
patterns: readProjectRootPatterns(ctx.repoRoot),
|
||||
candidates: workspaceCandidates,
|
||||
}),
|
||||
...workspaceResult.findings,
|
||||
];
|
||||
|
||||
return {
|
||||
ctx,
|
||||
projectRoot,
|
||||
absProductPath,
|
||||
sidecarCandidates,
|
||||
findings,
|
||||
workspaces: workspaceResult.workspaces,
|
||||
ruleRegistryAvailable: knownRuleIds !== null,
|
||||
};
|
||||
}
|
||||
|
||||
// Read straight from disk rather than importing context.mjs's private reader.
|
||||
// Only the positive/negative pattern strings matter here.
|
||||
function readProjectRootPatterns(repoRoot) {
|
||||
if (!repoRoot) return [];
|
||||
const patterns = [];
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(repoRoot, '.impeccable', name), 'utf-8'));
|
||||
if (Array.isArray(raw?.projectRoots)) {
|
||||
for (const entry of raw.projectRoots) {
|
||||
if (typeof entry === 'string' && entry.trim()) patterns.push(entry.trim());
|
||||
}
|
||||
}
|
||||
} catch { /* missing or malformed: nothing to check */ }
|
||||
}
|
||||
return patterns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the migrations that carry no decision. Returns what was done and what
|
||||
* was deliberately left for the user.
|
||||
*/
|
||||
function applyFixes(report) {
|
||||
const applied = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const entry of report.findings) {
|
||||
if (entry.severity !== 'auto') {
|
||||
skipped.push({ id: entry.id, reason: 'needs a decision from the user' });
|
||||
continue;
|
||||
}
|
||||
if (entry.id === 'design-sidecar-legacy-path') {
|
||||
const canonical = report.sidecarCandidates[0];
|
||||
const present = report.sidecarCandidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!canonical || !present || path.resolve(canonical) === path.resolve(present)) continue;
|
||||
if (fs.existsSync(canonical)) {
|
||||
skipped.push({ id: entry.id, reason: `${rel(canonical, report.projectRoot)} already exists; not overwriting` });
|
||||
continue;
|
||||
}
|
||||
fs.mkdirSync(path.dirname(canonical), { recursive: true });
|
||||
fs.renameSync(present, canonical);
|
||||
applied.push(`Moved ${rel(present, report.projectRoot)} to ${rel(canonical, report.projectRoot)}.`);
|
||||
continue;
|
||||
}
|
||||
if (entry.id === 'legacy-live-state') {
|
||||
// Reported, never deleted here: a running live session still reads these,
|
||||
// and losing session state to a doctor run is a worse outcome than a
|
||||
// stale file. The report says what to remove and when.
|
||||
skipped.push({ id: entry.id, reason: 'delete by hand once no live session is running' });
|
||||
continue;
|
||||
}
|
||||
skipped.push({ id: entry.id, reason: 'no automatic migration implemented' });
|
||||
}
|
||||
|
||||
// Stamping the product record is additive and safe, and it is what stops a
|
||||
// later version proposing an interview the user has already sat through.
|
||||
const productPath = report.absProductPath;
|
||||
if (productPath && report.ctx.product && readProductSchemaVersion(report.ctx.product) === null
|
||||
&& !report.findings.some((entry) => entry.id === 'product-schema-legacy')) {
|
||||
fs.writeFileSync(productPath, stampProductSchema(report.ctx.product), 'utf-8');
|
||||
applied.push(`Stamped ${rel(productPath, report.projectRoot)} as product-schema ${PRODUCT_SCHEMA_VERSION}.`);
|
||||
}
|
||||
|
||||
return { applied, skipped };
|
||||
}
|
||||
|
||||
function rel(filePath, root) {
|
||||
const value = path.relative(root, filePath);
|
||||
return value && !value.startsWith('..') ? value.split(path.sep).join('/') : filePath;
|
||||
}
|
||||
|
||||
const SEVERITY_LABEL = {
|
||||
auto: 'automatic',
|
||||
mention: 'worth saying',
|
||||
route: 'needs a command',
|
||||
};
|
||||
|
||||
function renderText(report, fixes) {
|
||||
const lines = [];
|
||||
const { findings } = report;
|
||||
|
||||
lines.push(`Impeccable doctor: ${rel(report.projectRoot, process.cwd()) || '.'}`);
|
||||
if (report.ctx.isMonorepo) {
|
||||
lines.push(`Monorepo, repo root ${rel(report.ctx.repoRoot, process.cwd()) || '.'}.`);
|
||||
}
|
||||
lines.push('');
|
||||
|
||||
if (!findings.length) {
|
||||
lines.push('No drift found. Every artifact matches what this version reads.');
|
||||
} else {
|
||||
const order = ['route', 'mention', 'auto'];
|
||||
for (const severity of order) {
|
||||
const group = findings.filter((entry) => entry.severity === severity);
|
||||
if (!group.length) continue;
|
||||
lines.push(`${SEVERITY_LABEL[severity]} (${group.length}):`);
|
||||
for (const entry of group) {
|
||||
lines.push(` ${entry.id}${entry.path ? ` [${entry.path}]` : ''}`);
|
||||
lines.push(` ${entry.summary}`);
|
||||
lines.push(` → ${entry.fix}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
if (report.workspaces.length) {
|
||||
lines.push('Workspaces:');
|
||||
for (const workspace of report.workspaces) {
|
||||
lines.push(` ${workspace.path} product: ${workspace.productStatus}`
|
||||
+ ` design: ${workspace.designStatus}`
|
||||
+ `${workspace.platform ? ` platform: ${workspace.platform}` : ''}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (!report.ruleRegistryAvailable) {
|
||||
lines.push('Note: the bundled detector could not be resolved, so ignored rule ids were not validated.');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (fixes) {
|
||||
lines.push(fixes.applied.length ? 'Applied:' : 'Applied nothing.');
|
||||
for (const entry of fixes.applied) lines.push(` ${entry}`);
|
||||
const held = fixes.skipped.filter((entry) => entry.reason !== 'needs a decision from the user');
|
||||
if (held.length) {
|
||||
lines.push('Left alone:');
|
||||
for (const entry of held) lines.push(` ${entry.id}: ${entry.reason}`);
|
||||
}
|
||||
} else if (findings.some((entry) => entry.severity === 'auto')) {
|
||||
lines.push(`Run \`node doctor.mjs --fix\` to apply the automatic migrations, `
|
||||
+ `or \`${IMPECCABLE_COMMAND} doctor\` to work through all of them.`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function cli() {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = parseArgs(process.argv.slice(2));
|
||||
} catch (err) {
|
||||
process.stderr.write(`${err.message}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (parsed.flags.help) {
|
||||
process.stdout.write(`${usage()}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const report = await collect(process.cwd(), parsed.targetOptions);
|
||||
const fixes = parsed.flags.fix ? applyFixes(report) : null;
|
||||
|
||||
if (parsed.flags.json) {
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
projectRoot: report.projectRoot,
|
||||
repoRoot: report.ctx.repoRoot,
|
||||
isMonorepo: report.ctx.isMonorepo,
|
||||
productPath: report.ctx.productPath,
|
||||
designPath: report.ctx.designPath,
|
||||
platform: report.ctx.platform,
|
||||
ruleRegistryAvailable: report.ruleRegistryAvailable,
|
||||
findings: report.findings,
|
||||
workspaces: report.workspaces,
|
||||
...(fixes ? { fixes } : {}),
|
||||
}, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(`${renderText(report, fixes)}\n`);
|
||||
}
|
||||
|
||||
function invokedAsScript() {
|
||||
const arg = process.argv[1];
|
||||
if (!arg) return false;
|
||||
try {
|
||||
return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (invokedAsScript()) {
|
||||
cli().catch((err) => {
|
||||
process.stderr.write(`impeccable doctor failed: ${err?.message || err}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
export { collect, applyFixes, renderText };
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Schema versions for the artifacts Impeccable writes, plus the readers and
|
||||
* writers for the PRODUCT.md provenance stamp.
|
||||
*
|
||||
* Why schema versions rather than the skill version: a PRODUCT.md written by
|
||||
* v4.0.0 is not stale under v4.0.1, so stamping the release version would make
|
||||
* every artifact "old" on every patch. A schema version changes only when the
|
||||
* shape changes, which is exactly when a migration is owed. It also gives the
|
||||
* writing flows a literal constant to copy instead of a value they would have
|
||||
* to look up.
|
||||
*
|
||||
* DESIGN.md deliberately carries no stamp. It follows the external
|
||||
* design.md spec that Stitch's linter validates, and an extra frontmatter key
|
||||
* risks failing that lint for no gain: every DESIGN.md staleness signal
|
||||
* (sidecar schema version, sidecar mtime, section coverage, git drift) is
|
||||
* measurable without one.
|
||||
*/
|
||||
|
||||
/** PRODUCT.md as init.md writes it today: the ten-section v4 record. */
|
||||
export const PRODUCT_SCHEMA_VERSION = 1;
|
||||
|
||||
/** `.impeccable/design.json`, as documented in reference/document.md Step 4b. */
|
||||
export const DESIGN_SIDECAR_SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* Sections init.md added in v4. A PRODUCT.md carrying none of them, and no
|
||||
* stamp, predates the current record. Used only as a fallback: an explicit
|
||||
* stamp always wins.
|
||||
*/
|
||||
export const PRODUCT_V4_SECTIONS = Object.freeze([
|
||||
'Positioning',
|
||||
'Operating Context',
|
||||
'Evidence on Hand',
|
||||
'Product Principles',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Headings Impeccable used to read and no longer does, with the reason. The
|
||||
* agent needs the reason: told only that a field is deprecated it tends to
|
||||
* preserve it "just in case", which is how a v3 register value keeps steering
|
||||
* v4 output.
|
||||
*/
|
||||
export const PRODUCT_DEPRECATED_SECTIONS = Object.freeze({
|
||||
Register: 'v4 replaced the brand/product register axis with the four visitor modes '
|
||||
+ '(Persuade, Operate, Read, Experience), which are chosen per surface and persisted in that '
|
||||
+ "surface's brief. Nothing reads `## Register` any more.",
|
||||
});
|
||||
|
||||
const PRODUCT_STAMP_RE = /^[ \t]*<!--[ \t]*impeccable:product-schema[ \t]+(\d+)[ \t]*-->[ \t]*$/im;
|
||||
|
||||
/** The literal stamp line, for the init template and for migrations. */
|
||||
export function productStampLine(version = PRODUCT_SCHEMA_VERSION) {
|
||||
return `<!-- impeccable:product-schema ${version} -->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema version stamped in a PRODUCT.md body, or null when unstamped. Null
|
||||
* means "written before stamping existed", not "invalid".
|
||||
*/
|
||||
export function readProductSchemaVersion(markdown) {
|
||||
const match = String(markdown || '').match(PRODUCT_STAMP_RE);
|
||||
if (!match) return null;
|
||||
const version = Number.parseInt(match[1], 10);
|
||||
return Number.isInteger(version) ? version : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update the stamp, returning the new body. Idempotent. A stamped file
|
||||
* keeps the stamp where it already sits so a migration never reorders the
|
||||
* user's prose; an unstamped file gets it directly under the leading `#`
|
||||
* heading, or at the top when there is none.
|
||||
*/
|
||||
export function stampProductSchema(markdown, version = PRODUCT_SCHEMA_VERSION) {
|
||||
const body = String(markdown || '');
|
||||
const line = productStampLine(version);
|
||||
if (PRODUCT_STAMP_RE.test(body)) return body.replace(PRODUCT_STAMP_RE, line);
|
||||
|
||||
const lines = body.split('\n');
|
||||
const headingIndex = lines.findIndex((entry) => /^#\s+\S/.test(entry));
|
||||
if (headingIndex === -1) return `${line}\n\n${body.replace(/^\n+/, '')}`;
|
||||
lines.splice(headingIndex + 1, 0, '', line);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema version of a parsed design.json. Returns null for a missing or
|
||||
* non-numeric field, which is how schemaVersion-1-era sidecars present
|
||||
* (the field predates the v2 rewrite in some files).
|
||||
*/
|
||||
export function readSidecarSchemaVersion(sidecar) {
|
||||
const version = sidecar && typeof sidecar === 'object' ? sidecar.schemaVersion : null;
|
||||
return Number.isInteger(version) ? version : null;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { resolveProjectRoot } from '../context.mjs';
|
||||
import { designSidecarCandidatesFor } from './staleness.mjs';
|
||||
export { IMPECCABLE_COMMAND_PREFIX } from './provider.mjs';
|
||||
|
||||
export const IMPECCABLE_DIR = '.impeccable';
|
||||
@@ -16,14 +17,7 @@ export function getDesignSidecarPath(cwd = process.cwd(), options = {}) {
|
||||
}
|
||||
|
||||
export function getDesignSidecarCandidates(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
const projectRoot = resolveProjectRoot(cwd, options);
|
||||
const candidates = [
|
||||
getDesignSidecarPath(cwd, options),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
return designSidecarCandidatesFor(resolveProjectRoot(cwd, options), contextDir);
|
||||
}
|
||||
|
||||
export function resolveDesignSidecarPath(cwd = process.cwd(), contextDir = cwd, options = {}) {
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* Tier 2 staleness checks: the ones that cost too much to run on every session
|
||||
* boot. Shelling out to git, walking workspaces, resolving hook script paths,
|
||||
* and validating ignore lists against the live rule registry all belong here.
|
||||
*
|
||||
* The boot tier answers "did an older Impeccable write this". This tier also
|
||||
* asks "does it still describe the code", which no file comparison can settle
|
||||
* on its own. Where the answer needs judgment, the finding reports a measured
|
||||
* proxy and says it is a proxy. It never claims a document is wrong because a
|
||||
* number is large.
|
||||
*
|
||||
* Same finding shape and severities as lib/staleness.mjs.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const VISUAL_SOURCE_DIRS = ['src', 'app', 'pages', 'components', 'site', 'styles', 'public'];
|
||||
|
||||
const HOOK_MANIFESTS_BY_PROVIDER = Object.freeze({
|
||||
'claude-code': ['.claude/settings.local.json', '.claude/settings.json'],
|
||||
codex: ['.codex/hooks.json'],
|
||||
agents: ['.codex/hooks.json'],
|
||||
cursor: ['.cursor/hooks.json'],
|
||||
github: ['.github/hooks/impeccable.json'],
|
||||
grok: ['.grok/hooks/impeccable.json'],
|
||||
});
|
||||
|
||||
const HOOK_SCRIPT_MARKERS = [
|
||||
'skills/impeccable/scripts/hook.mjs',
|
||||
'skills/impeccable/scripts/hook-before-edit.mjs',
|
||||
];
|
||||
|
||||
// Retired live-mode state locations. impeccable-paths still reads these as
|
||||
// fallbacks; reporting them is what eventually lets the fallbacks go.
|
||||
const LEGACY_LIVE_PATHS = ['.impeccable-live.json', '.impeccable-live'];
|
||||
|
||||
function finding({ id, artifact, filePath = null, severity, summary, fix }) {
|
||||
return { id, artifact, path: filePath, severity, summary, fix };
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toRelative(filePath, root) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(root, filePath);
|
||||
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join('/')
|
||||
: filePath;
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
try {
|
||||
return execFileSync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── DESIGN.md truth drift ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How much UI work has landed since DESIGN.md was last touched, measured in
|
||||
* commits to the visual source directories. A proxy, and reported as one: a
|
||||
* large number means the document is worth re-reading, not that it is wrong.
|
||||
* Silent outside a git repo, on an untracked DESIGN.md, and when the count is
|
||||
* small enough to be ordinary maintenance.
|
||||
*/
|
||||
export function checkDesignDrift({ designPath, projectRoot, threshold = 25 }) {
|
||||
if (!designPath || !projectRoot) return [];
|
||||
if (!git(['rev-parse', '--is-inside-work-tree'], projectRoot)) return [];
|
||||
|
||||
const relDesign = toRelative(designPath, projectRoot);
|
||||
const lastDesignCommit = git(['log', '-1', '--format=%H', '--', relDesign], projectRoot);
|
||||
if (!lastDesignCommit) return [];
|
||||
|
||||
const dirs = VISUAL_SOURCE_DIRS.filter((dir) => fs.existsSync(path.join(projectRoot, dir)));
|
||||
if (!dirs.length) return [];
|
||||
|
||||
const log = git(
|
||||
['log', '--oneline', `${lastDesignCommit}..HEAD`, '--', ...dirs],
|
||||
projectRoot,
|
||||
);
|
||||
if (log === null) return [];
|
||||
const commits = log ? log.split('\n').filter(Boolean).length : 0;
|
||||
if (commits < threshold) return [];
|
||||
|
||||
const when = git(['log', '-1', '--format=%ad', '--date=short', '--', relDesign], projectRoot);
|
||||
return [finding({
|
||||
id: 'design-md-drift',
|
||||
artifact: 'DESIGN.md',
|
||||
filePath: relDesign,
|
||||
severity: 'route',
|
||||
summary: `${commits} commits have touched ${dirs.join(', ')} since ${relDesign} was last edited`
|
||||
+ `${when ? ` (${when})` : ''}. This counts commits, not contradictions: it says the document is worth `
|
||||
+ 're-reading, not that it is wrong.',
|
||||
fix: 'Read DESIGN.md against the current tokens and components before trusting it as authority. '
|
||||
+ 'If it has genuinely drifted, `document` regenerates it from the code.',
|
||||
})];
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical DESIGN.md sections that carry nothing. Distinct from truth drift:
|
||||
* a section can be absent because it never applied, so this is reported as a
|
||||
* documentation gap for a human to judge, never as an error.
|
||||
*/
|
||||
export function checkDesignCoverage({ design, designPath, parseDesignMd }) {
|
||||
if (!design || typeof parseDesignMd !== 'function') return [];
|
||||
let model;
|
||||
try {
|
||||
model = parseDesignMd(design);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const missing = ['colors', 'typography', 'components']
|
||||
.filter((section) => !model[section]);
|
||||
if (!missing.length) return [];
|
||||
return [finding({
|
||||
id: 'design-md-coverage',
|
||||
artifact: 'DESIGN.md',
|
||||
filePath: designPath,
|
||||
severity: 'mention',
|
||||
summary: `${designPath || 'DESIGN.md'} has no ${missing.join(', ')} section. `
|
||||
+ 'Agents generating new screens get no normative guidance for those, and the live design panel renders '
|
||||
+ 'generic approximations in their place.',
|
||||
fix: 'Ask whether the section never applied or was never written. `document` fills it from the code if the '
|
||||
+ 'project has the answer in its CSS.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── detector ignore lists ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ignore entries that no longer match anything: rule ids the engine dropped or
|
||||
* renamed, and file paths that are gone. Both read as working suppressions
|
||||
* until someone checks, and a dead rule ignore also hides that the rule left.
|
||||
*/
|
||||
export function checkDetectorIgnores({ projectRoot, knownRuleIds = null }) {
|
||||
const findings = [];
|
||||
if (!projectRoot) return findings;
|
||||
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const filePath = path.join(projectRoot, '.impeccable', name);
|
||||
const raw = readJson(filePath);
|
||||
const detector = raw?.detector;
|
||||
if (!detector || typeof detector !== 'object') continue;
|
||||
const rel = toRelative(filePath, projectRoot);
|
||||
|
||||
if (knownRuleIds && Array.isArray(detector.ignoreRules)) {
|
||||
const unknown = detector.ignoreRules
|
||||
.map((rule) => String(rule || '').trim().toLowerCase())
|
||||
.filter((rule) => rule && rule !== '*' && !knownRuleIds.has(rule));
|
||||
if (unknown.length) {
|
||||
findings.push(finding({
|
||||
id: 'detector-ignore-rules-unknown',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} ignores rule id(s) the detector does not have: `
|
||||
+ `${unknown.map((rule) => `\`${rule}\``).join(', ')}. Either the rule was renamed or removed, or the `
|
||||
+ 'id was mistyped and has never suppressed anything.',
|
||||
fix: 'Report the exact ids. Removing them is safe; keeping a dead ignore hides that the rule is gone.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(detector.ignoreFiles)) {
|
||||
const missing = detector.ignoreFiles
|
||||
.map((entry) => String(entry || '').trim())
|
||||
.filter((entry) => entry && !entry.includes('*') && !fs.existsSync(path.join(projectRoot, entry)));
|
||||
if (missing.length) {
|
||||
findings.push(finding({
|
||||
id: 'detector-ignore-files-missing',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} ignores file path(s) that no longer exist: `
|
||||
+ `${missing.map((entry) => `\`${entry}\``).join(', ')}.`,
|
||||
fix: 'Ask whether the file moved (repoint the entry) or was deleted (drop it). '
|
||||
+ 'A stale entry silently stops covering the file that replaced it.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── hook installation ─────────────────────────────────────────────────────
|
||||
|
||||
function collectHookCommands(value, out = []) {
|
||||
if (typeof value === 'string') {
|
||||
if (HOOK_SCRIPT_MARKERS.some((marker) => value.includes(marker))) out.push(value);
|
||||
return out;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) collectHookCommands(entry, out);
|
||||
return out;
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
for (const entry of Object.values(value)) collectHookCommands(entry, out);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pull the script path out of a hook command line. Commands look like
|
||||
// `node .claude/skills/impeccable/scripts/hook.mjs` and may be quoted or carry
|
||||
// trailing arguments.
|
||||
function hookScriptPathFrom(command) {
|
||||
const match = String(command).match(/(\S*skills\/impeccable\/scripts\/hook(?:-before-edit)?\.mjs)/);
|
||||
return match ? match[1].replace(/^['"]|['"]$/g, '') : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A hook whose script path does not resolve is a silent no-op, and the user
|
||||
* believes the project is covered. Also catches the contradiction of an
|
||||
* installed manifest against `hook.enabled: false`.
|
||||
*/
|
||||
export function checkHookInstallation({ projectRoot, repoRoot, providerId }) {
|
||||
const findings = [];
|
||||
const manifests = HOOK_MANIFESTS_BY_PROVIDER[providerId] || [];
|
||||
if (!manifests.length) return findings;
|
||||
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
let installedAt = null;
|
||||
|
||||
for (const root of roots) {
|
||||
for (const rel of manifests) {
|
||||
const manifestPath = path.join(root, rel);
|
||||
const raw = readJson(manifestPath);
|
||||
if (!raw?.hooks) continue;
|
||||
const commands = collectHookCommands(raw.hooks);
|
||||
if (!commands.length) continue;
|
||||
installedAt = toRelative(manifestPath, projectRoot || root);
|
||||
|
||||
const broken = commands.filter((command) => {
|
||||
const scriptPath = hookScriptPathFrom(command);
|
||||
if (!scriptPath) return false;
|
||||
const abs = path.isAbsolute(scriptPath) ? scriptPath : path.join(root, scriptPath);
|
||||
return !fs.existsSync(abs);
|
||||
});
|
||||
if (broken.length) {
|
||||
findings.push(finding({
|
||||
id: 'hook-script-missing',
|
||||
artifact: 'hook manifest',
|
||||
filePath: installedAt,
|
||||
severity: 'mention',
|
||||
summary: `${installedAt} installs the design hook, but its script path does not exist: `
|
||||
+ `${broken.map((command) => `\`${command}\``).join(', ')}. The hook runs as a no-op, so UI edits `
|
||||
+ 'have been going unscanned while the project looks covered.',
|
||||
fix: `Reinstall with \`impeccable hooks on\`, which rewrites the manifest against the skill's current location.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (installedAt) {
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw?.hook && raw.hook.enabled === false) {
|
||||
findings.push(finding({
|
||||
id: 'hook-enabled-conflict',
|
||||
artifact: 'config.json',
|
||||
filePath: toRelative(path.join(root, '.impeccable', name), projectRoot || root),
|
||||
severity: 'mention',
|
||||
summary: `${installedAt} installs the design hook while this config sets \`hook.enabled: false\`, `
|
||||
+ 'so the hook fires and then declines to scan.',
|
||||
fix: 'Ask which was intended: `impeccable hooks on` to enable, or `impeccable hooks off` to uninstall '
|
||||
+ 'the manifest entry as well.',
|
||||
}));
|
||||
return findings;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── retired locations ─────────────────────────────────────────────────────
|
||||
|
||||
export function checkLegacyLiveState({ projectRoot }) {
|
||||
if (!projectRoot) return [];
|
||||
const present = LEGACY_LIVE_PATHS.filter((rel) => fs.existsSync(path.join(projectRoot, rel)));
|
||||
if (!present.length) return [];
|
||||
return [finding({
|
||||
id: 'legacy-live-state',
|
||||
artifact: 'live state',
|
||||
filePath: present.join(', '),
|
||||
severity: 'auto',
|
||||
summary: `Live-mode state sits in retired location(s): ${present.map((rel) => `\`${rel}\``).join(', ')}. `
|
||||
+ 'Current live mode writes under `.impeccable/live/`.',
|
||||
fix: 'These are read only through backward-compatible fallbacks and are safe to delete once no live session '
|
||||
+ 'is running. No user decision is needed.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── monorepo sweep ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Per-workspace context, plus the case worth acting on: a workspace with
|
||||
* native build files inheriting a repo-root PRODUCT.md that says web. Each
|
||||
* such app gets web guidance and never loads the native references, and
|
||||
* nothing at boot reports it because the root record parses cleanly.
|
||||
*
|
||||
* `candidates` comes from context.mjs's discovery so the walk is not repeated.
|
||||
*/
|
||||
export function checkWorkspaces({ repoRoot, candidates = [], checkNativePlatformEvidence, extractPlatform, readFile }) {
|
||||
if (!repoRoot || !candidates.length) return { findings: [], workspaces: [] };
|
||||
const findings = [];
|
||||
const workspaces = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const workspaceRoot = path.join(repoRoot, candidate.path);
|
||||
const productPath = candidate.productPath ? path.join(repoRoot, candidate.productPath) : null;
|
||||
const product = productPath && readFile ? readFile(productPath) : null;
|
||||
const platform = extractPlatform ? extractPlatform(product) : null;
|
||||
|
||||
workspaces.push({
|
||||
name: candidate.name,
|
||||
path: candidate.path,
|
||||
productStatus: candidate.productStatus,
|
||||
productPath: candidate.productPath,
|
||||
designStatus: candidate.designStatus,
|
||||
designPath: candidate.designPath,
|
||||
platform: platform || (product ? 'web (default)' : null),
|
||||
});
|
||||
|
||||
if (!checkNativePlatformEvidence) continue;
|
||||
const native = checkNativePlatformEvidence({
|
||||
projectRoot: workspaceRoot,
|
||||
platform,
|
||||
product,
|
||||
productPath: candidate.productPath,
|
||||
});
|
||||
for (const entry of native) {
|
||||
findings.push(finding({
|
||||
id: 'workspace-platform-native-evidence',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: candidate.productPath || `${candidate.path}/PRODUCT.md`,
|
||||
severity: 'mention',
|
||||
summary: `Workspace \`${candidate.path}\` ${
|
||||
candidate.productStatus === 'inherited'
|
||||
? 'inherits the repo-root PRODUCT.md'
|
||||
: 'has a PRODUCT.md'
|
||||
} that resolves to web, but the workspace itself carries native build files. ${entry.summary}`,
|
||||
fix: candidate.productStatus === 'inherited'
|
||||
? `Give \`${candidate.path}\` its own PRODUCT.md with the right \`## Platform\`. `
|
||||
+ 'An inherited record cannot describe two platforms at once.'
|
||||
: entry.fix,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
const inherited = workspaces.filter((entry) => entry.productStatus === 'inherited');
|
||||
if (inherited.length) {
|
||||
findings.push(finding({
|
||||
id: 'workspace-context-inherited',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: null,
|
||||
severity: 'mention',
|
||||
summary: `${inherited.length} of ${workspaces.length} workspace(s) inherit the repo-root PRODUCT.md: `
|
||||
+ `${inherited.map((entry) => `\`${entry.path}\``).join(', ')}. Inheritance is intended; whether one `
|
||||
+ 'record truthfully describes these apps is not something this check can tell.',
|
||||
fix: 'Ask the user whether the inherited record describes each app. Where it does not, `init` in that '
|
||||
+ 'workspace writes a child PRODUCT.md that overrides it.',
|
||||
}));
|
||||
}
|
||||
|
||||
return { findings, workspaces };
|
||||
}
|
||||
|
||||
// ─── rule registry ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Rule ids from the bundled detector, or null when it cannot be resolved (a
|
||||
* partial install, or a harness that ships the skill without the engine).
|
||||
* Null means "cannot check", which the ignore-rule check treats as skip rather
|
||||
* than as every id being unknown.
|
||||
*/
|
||||
export async function loadKnownRuleIds(scriptsDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')) {
|
||||
// Same two locations detect.mjs resolves: the bundled copy in an installed
|
||||
// skill, then the source-repo engine when running from a checkout.
|
||||
const candidates = [
|
||||
path.join(scriptsDir, 'detector', 'detect-antipatterns.mjs'),
|
||||
path.join(scriptsDir, '..', '..', 'cli', 'engine', 'detect-antipatterns.mjs'),
|
||||
];
|
||||
const detectorPath = candidates.find((candidate) => fs.existsSync(candidate));
|
||||
if (!detectorPath) return null;
|
||||
try {
|
||||
const { ANTIPATTERNS } = await import(pathToFileURL(detectorPath).href);
|
||||
if (!Array.isArray(ANTIPATTERNS)) return null;
|
||||
return new Set(ANTIPATTERNS.map((rule) => String(rule.id).toLowerCase()));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Notice throttling and directive rendering for staleness findings.
|
||||
*
|
||||
* The boot path already carries PRODUCT.md, DESIGN.md, a surface brief,
|
||||
* RESOLVED_CONTEXT, the detector fallback, native platform references, and the
|
||||
* update directive. An unthrottled staleness block would push real context out
|
||||
* of attention and train the agent to open every session with housekeeping, so
|
||||
* the rules here are deliberately strict:
|
||||
*
|
||||
* - One directive for the whole set, never one per finding.
|
||||
* - A 'mention' or 'route' finding surfaces at most once a week per project,
|
||||
* mirroring the update check's anti-nag window. A finding the user has
|
||||
* already declined to act on must not reappear tomorrow.
|
||||
* - 'auto' findings are not throttled and are not shown to the user. They are
|
||||
* migrations the next write performs anyway, so the agent needs the note
|
||||
* every session until the write happens, and the user needs it never.
|
||||
*
|
||||
* State lives in the user's home dir alongside the update cache rather than in
|
||||
* the project, so no gitignore entry is owed and a clone does not inherit
|
||||
* someone else's dismissals.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const RENOTIFY_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
// Resolved per call rather than at import so a test (or a sandboxed run) can
|
||||
// redirect the cache without reloading the module.
|
||||
function cachePath() {
|
||||
return process.env.IMPECCABLE_STALENESS_CACHE
|
||||
|| path.join(os.homedir(), '.impeccable', 'staleness-check.json');
|
||||
}
|
||||
|
||||
function readCache() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(cachePath(), 'utf-8'));
|
||||
return raw && typeof raw === 'object' && raw.projects ? raw : { projects: {} };
|
||||
} catch {
|
||||
return { projects: {} };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop project entries whose newest stamp has aged past the renotify window.
|
||||
* They would be re-notified on the next boot anyway, so keeping them only lets
|
||||
* the file accumulate one entry per directory Impeccable has ever booted in
|
||||
* (scratch dirs and test fixtures included).
|
||||
*/
|
||||
function pruneCache(cache, now) {
|
||||
const projects = {};
|
||||
for (const [key, entries] of Object.entries(cache.projects || {})) {
|
||||
if (!entries || typeof entries !== 'object') continue;
|
||||
const stamps = Object.values(entries).filter((value) => typeof value === 'number');
|
||||
if (stamps.length && now - Math.max(...stamps) < RENOTIFY_INTERVAL_MS) projects[key] = entries;
|
||||
}
|
||||
return { projects };
|
||||
}
|
||||
|
||||
function writeCache(cache) {
|
||||
try {
|
||||
const filePath = cachePath();
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, JSON.stringify(cache));
|
||||
} catch {
|
||||
// Best-effort. A read-only home dir means the notice repeats next session,
|
||||
// which is strictly better than failing the boot.
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Opt out with IMPECCABLE_NO_STALENESS_CHECK=1 or `"stalenessCheck": false` in
|
||||
* .impeccable/config.json. Local config overrides shared, matching how
|
||||
* updateCheck resolves.
|
||||
*/
|
||||
export function stalenessCheckDisabled(roots = [process.cwd()]) {
|
||||
if (process.env.IMPECCABLE_NO_STALENESS_CHECK) return true;
|
||||
let value;
|
||||
for (const root of roots) {
|
||||
if (!root) continue;
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const raw = readJson(path.join(root, '.impeccable', name));
|
||||
if (raw && typeof raw === 'object' && typeof raw.stalenessCheck === 'boolean') {
|
||||
value = raw.stalenessCheck;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop findings already surfaced for this project inside the renotify window,
|
||||
* and stamp the ones that survive. 'auto' findings pass through untouched and
|
||||
* unstamped: they are for the agent, not the user, and repeat until fixed.
|
||||
*/
|
||||
export function filterFreshFindings(findings, { projectRoot, now = Date.now() } = {}) {
|
||||
if (!findings.length) return [];
|
||||
const auto = findings.filter((entry) => entry.severity === 'auto');
|
||||
const notifiable = findings.filter((entry) => entry.severity !== 'auto');
|
||||
if (!notifiable.length) return auto;
|
||||
|
||||
const key = path.resolve(projectRoot || process.cwd());
|
||||
const cache = readCache();
|
||||
const seen = cache.projects[key] && typeof cache.projects[key] === 'object' ? cache.projects[key] : {};
|
||||
|
||||
const fresh = notifiable.filter((entry) => {
|
||||
const last = seen[entry.id];
|
||||
return !(typeof last === 'number' && now - last < RENOTIFY_INTERVAL_MS);
|
||||
});
|
||||
|
||||
// Forget stamps for findings that no longer fire, so a recurrence after a
|
||||
// real fix is reported again instead of being suppressed by an old stamp.
|
||||
// This has to run even when nothing is fresh: the common shape is one
|
||||
// finding fixed while another is still inside its window.
|
||||
const live = new Set(notifiable.map((entry) => entry.id));
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(seen).filter(([id]) => live.has(id)),
|
||||
);
|
||||
for (const entry of fresh) next[entry.id] = now;
|
||||
|
||||
const changed = JSON.stringify(next) !== JSON.stringify(seen);
|
||||
if (changed) {
|
||||
const pruned = pruneCache(cache, now);
|
||||
pruned.projects[key] = next;
|
||||
writeCache(pruned);
|
||||
}
|
||||
return [...auto, ...fresh];
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the single boot directive, or null when nothing survived throttling.
|
||||
*/
|
||||
export function buildStalenessDirective(findings) {
|
||||
if (!findings.length) return null;
|
||||
const payload = findings.map((entry) => ({
|
||||
id: entry.id,
|
||||
artifact: entry.artifact,
|
||||
path: entry.path,
|
||||
severity: entry.severity,
|
||||
summary: entry.summary,
|
||||
fix: entry.fix,
|
||||
}));
|
||||
|
||||
const hasReportable = findings.some((entry) => entry.severity !== 'auto');
|
||||
const lines = [
|
||||
`CONTEXT_STALE:\n${JSON.stringify(payload, null, 2)}`,
|
||||
"Impeccable's own project files have drifted from what this version reads. "
|
||||
+ 'Do not stop, reorder, or expand the requested task for any of this.',
|
||||
'By severity: `auto` is a migration the next write to that file performs anyway, so apply it then and do not '
|
||||
+ 'raise it with the user. `mention` gets one short line in your reply with the offered fix. `route` names the '
|
||||
+ 'command that owns the repair; offer it, and run it only if the user asks.',
|
||||
'A finding that reports a deprecated field is binding: treat that field as absent for every decision in this '
|
||||
+ 'session, whatever value it holds.',
|
||||
];
|
||||
if (hasReportable) {
|
||||
lines.push('Surface the reportable findings once, after the task response, in at most two sentences. '
|
||||
+ 'They are already throttled, so say them plainly rather than hedging about whether they matter.');
|
||||
}
|
||||
return lines.join(' ');
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
/**
|
||||
* Staleness detection for Impeccable's own project artifacts: PRODUCT.md,
|
||||
* DESIGN.md and its `.impeccable/design.json` sidecar, `.impeccable/config.json`,
|
||||
* and persisted surface briefs.
|
||||
*
|
||||
* Three kinds of drift live under "out of date", and they want different
|
||||
* handling:
|
||||
*
|
||||
* 1. Tool version drift. The installed skill is older than the published one.
|
||||
* Owned by computeUpdateDirective in context.mjs, not by this module.
|
||||
* 2. Schema drift. An artifact was written by an older Impeccable: fields it
|
||||
* no longer reads, fields it now expects, files in retired locations.
|
||||
* Deterministic, and mostly fixable without asking anyone.
|
||||
* 3. Truth drift. The code moved on and the document no longer describes it.
|
||||
* Not mechanical. `document` and `init` own the rewrite; the most this
|
||||
* module does is measure a proxy and name it as a proxy.
|
||||
*
|
||||
* Two tiers, because the boot path runs on every session:
|
||||
*
|
||||
* Tier 1 (collectBootFindings) spends only what a boot already spends. It
|
||||
* parses markdown context.mjs has in memory, stats a bounded set of paths,
|
||||
* and reads the two small JSON files the boot reads anyway. No directory
|
||||
* walks, no git, no cross-workspace sweep.
|
||||
*
|
||||
* Tier 2 (the doctor pass) is on demand and may walk, shell out to git, and
|
||||
* compare declared tokens against real CSS.
|
||||
*
|
||||
* Findings are data, not prose, so both tiers and the JSON output render the
|
||||
* same set. Severity says what should happen, not how bad it is:
|
||||
*
|
||||
* 'auto' fix it silently the next time that file is written anyway
|
||||
* 'mention' state it once, offer the fix, carry on with the user's task
|
||||
* 'route' needs a specific command, so name the command and the gap
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
PRODUCT_SCHEMA_VERSION,
|
||||
PRODUCT_DEPRECATED_SECTIONS,
|
||||
PRODUCT_V4_SECTIONS,
|
||||
DESIGN_SIDECAR_SCHEMA_VERSION,
|
||||
readProductSchemaVersion,
|
||||
readSidecarSchemaVersion,
|
||||
} from './artifact-schema.mjs';
|
||||
|
||||
// Top-level keys any reader honors: `hook` and `detector` subtrees (hook-lib's
|
||||
// readConfig), `updateCheck` (context.mjs), `projectRoots` (context.mjs's
|
||||
// monorepo resolution), plus `stalenessCheck` below. `$schema` and `version`
|
||||
// are allowed as conventional metadata nobody reads.
|
||||
const KNOWN_CONFIG_KEYS = new Set([
|
||||
'hook',
|
||||
'detector',
|
||||
'updateCheck',
|
||||
'stalenessCheck',
|
||||
'projectRoots',
|
||||
'$schema',
|
||||
'version',
|
||||
]);
|
||||
|
||||
// `detector` is a closed set, so a typo here is worth reporting. `hook` is not
|
||||
// checked: it carries runtime settings from several writers and the false
|
||||
// positive rate would outweigh the catch.
|
||||
const KNOWN_DETECTOR_KEYS = new Set([
|
||||
'ignoreRules',
|
||||
'ignoreFiles',
|
||||
'ignoreValues',
|
||||
'designSystem',
|
||||
'extensions',
|
||||
]);
|
||||
|
||||
// Evidence that a project ships a native app. Checked only to catch a
|
||||
// PRODUCT.md that says web (or says nothing, which resolves to web) on a
|
||||
// project that is plainly not: that combination silently skips the iOS and
|
||||
// Android references for the whole session.
|
||||
const NATIVE_EVIDENCE_PATHS = Object.freeze([
|
||||
{ rel: 'pubspec.yaml', platform: 'adaptive', reason: 'a Flutter pubspec.yaml' },
|
||||
{ rel: 'ios/Podfile', platform: 'ios', reason: 'an ios/Podfile' },
|
||||
{ rel: 'android/build.gradle', platform: 'android', reason: 'an android/build.gradle' },
|
||||
{ rel: 'android/build.gradle.kts', platform: 'android', reason: 'an android/build.gradle.kts' },
|
||||
{ rel: 'ios/Runner.xcodeproj', platform: 'ios', reason: 'an ios/Runner.xcodeproj' },
|
||||
]);
|
||||
|
||||
const NATIVE_EVIDENCE_DEPENDENCIES = Object.freeze([
|
||||
{ name: 'react-native', platform: 'adaptive', reason: 'a react-native dependency' },
|
||||
{ name: 'expo', platform: 'adaptive', reason: 'an expo dependency' },
|
||||
{ name: '@react-native/metro-config', platform: 'adaptive', reason: 'a React Native metro config dependency' },
|
||||
]);
|
||||
|
||||
function finding({ id, artifact, filePath = null, severity, summary, fix }) {
|
||||
return { id, artifact, path: filePath, severity, summary, fix };
|
||||
}
|
||||
|
||||
/**
|
||||
* Every location a design sidecar may live, canonical first. Pure so that both
|
||||
* impeccable-paths (which resolves the project root) and context.mjs (which
|
||||
* cannot import impeccable-paths without a cycle) share one definition of
|
||||
* where the retired locations are.
|
||||
*/
|
||||
export function designSidecarCandidatesFor(projectRoot, contextDir = projectRoot) {
|
||||
const candidates = [
|
||||
path.join(projectRoot, '.impeccable', 'design.json'),
|
||||
path.join(projectRoot, 'DESIGN.json'),
|
||||
];
|
||||
const contextLegacy = path.join(contextDir || projectRoot, 'DESIGN.json');
|
||||
if (!candidates.includes(contextLegacy)) candidates.push(contextLegacy);
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function mtimeMs(filePath) {
|
||||
try {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function hasSection(markdown, heading) {
|
||||
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^##\\s+${escaped}\\s*$`, 'im').test(String(markdown || ''));
|
||||
}
|
||||
|
||||
function toRelative(filePath, root) {
|
||||
if (!filePath) return null;
|
||||
const rel = path.relative(root, filePath);
|
||||
return rel && !rel.startsWith('..') && !path.isAbsolute(rel)
|
||||
? rel.split(path.sep).join('/')
|
||||
: filePath;
|
||||
}
|
||||
|
||||
// ─── PRODUCT.md ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Pure: schema drift visible in a PRODUCT.md body. `productPath` is used for
|
||||
* reporting only.
|
||||
*/
|
||||
export function checkProduct(product, productPath = 'PRODUCT.md') {
|
||||
if (!product) return [];
|
||||
const findings = [];
|
||||
|
||||
for (const [heading, reason] of Object.entries(PRODUCT_DEPRECATED_SECTIONS)) {
|
||||
if (!hasSection(product, heading)) continue;
|
||||
findings.push(finding({
|
||||
id: `product-deprecated-${heading.toLowerCase()}`,
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath,
|
||||
severity: 'mention',
|
||||
summary: `PRODUCT.md still carries a \`## ${heading}\` section. ${reason}`,
|
||||
fix: `Treat \`## ${heading}\` as absent for every decision this session. `
|
||||
+ 'Offer to delete the section; do not let its value influence the work either way.',
|
||||
}));
|
||||
}
|
||||
|
||||
const stamped = readProductSchemaVersion(product);
|
||||
if (stamped === null && !PRODUCT_V4_SECTIONS.some((section) => hasSection(product, section))) {
|
||||
findings.push(finding({
|
||||
id: 'product-schema-legacy',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath,
|
||||
severity: 'route',
|
||||
summary: 'PRODUCT.md has no schema stamp and none of the sections the current record adds '
|
||||
+ `(${PRODUCT_V4_SECTIONS.join(', ')}), so it predates this version of the product record.`,
|
||||
fix: 'Offer `init`, which preserves confirmed answers and fills the gaps by interview. '
|
||||
+ 'Do not rewrite the file from inference.',
|
||||
}));
|
||||
} else if (stamped !== null && stamped < PRODUCT_SCHEMA_VERSION) {
|
||||
findings.push(finding({
|
||||
id: 'product-schema-outdated',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath,
|
||||
severity: 'route',
|
||||
summary: `PRODUCT.md is stamped product-schema ${stamped}; the current record is ${PRODUCT_SCHEMA_VERSION}.`,
|
||||
fix: 'Offer `init` to bring the record current, preserving confirmed answers.',
|
||||
}));
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
/**
|
||||
* A project that resolves to web while carrying native build files. Bounded:
|
||||
* a handful of stats plus one package.json read at the project root.
|
||||
*/
|
||||
export function checkNativePlatformEvidence({ projectRoot, platform, product, productPath }) {
|
||||
if (!projectRoot) return [];
|
||||
// Only the web resolution is worth checking. An explicit native value is
|
||||
// already honored, and an unrecognized value already gets its own warning.
|
||||
if (platform && platform !== 'web') return [];
|
||||
|
||||
const evidence = [];
|
||||
for (const entry of NATIVE_EVIDENCE_PATHS) {
|
||||
if (fs.existsSync(path.join(projectRoot, entry.rel))) evidence.push(entry);
|
||||
}
|
||||
const pkg = readJson(path.join(projectRoot, 'package.json'));
|
||||
if (pkg) {
|
||||
const deps = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) };
|
||||
for (const entry of NATIVE_EVIDENCE_DEPENDENCIES) {
|
||||
if (deps[entry.name]) evidence.push(entry);
|
||||
}
|
||||
}
|
||||
if (!evidence.length) return [];
|
||||
|
||||
const platforms = new Set(evidence.map((entry) => entry.platform));
|
||||
const suggested = platforms.size > 1 || platforms.has('adaptive')
|
||||
? 'adaptive'
|
||||
: [...platforms][0];
|
||||
const declared = platform === 'web'
|
||||
? 'PRODUCT.md declares `## Platform: web`'
|
||||
: product
|
||||
? 'PRODUCT.md has no `## Platform` section, so the project resolves to web'
|
||||
: 'no PRODUCT.md declares a platform, so the project resolves to web';
|
||||
|
||||
return [finding({
|
||||
id: 'platform-native-evidence',
|
||||
artifact: 'PRODUCT.md',
|
||||
filePath: productPath || null,
|
||||
severity: 'mention',
|
||||
summary: `${declared}, but the project carries ${evidence.map((entry) => entry.reason).join(' and ')}. `
|
||||
+ 'Web guidance is being applied to a native codebase, and the iOS and Android references never load.',
|
||||
fix: `Ask the user whether \`## Platform\` should be \`${suggested}\`. `
|
||||
+ 'If it should, write the value and load the matching native reference before designing.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── DESIGN.md and the design.json sidecar ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* Sidecar drift: retired location, schema version behind, or older than the
|
||||
* DESIGN.md it extends. Costs three stats and one small JSON read.
|
||||
*
|
||||
* `sidecarCandidates` comes from impeccable-paths' resolver so this module
|
||||
* stays out of the business of knowing where sidecars may live; the first
|
||||
* entry is the canonical location.
|
||||
*/
|
||||
export function checkDesignSidecar({ designPath, sidecarCandidates = [], projectRoot }) {
|
||||
const findings = [];
|
||||
const canonical = sidecarCandidates[0] || null;
|
||||
const present = sidecarCandidates.find((candidate) => fs.existsSync(candidate)) || null;
|
||||
if (!present) return findings;
|
||||
|
||||
const relPresent = toRelative(present, projectRoot);
|
||||
|
||||
if (canonical && path.resolve(present) !== path.resolve(canonical)) {
|
||||
findings.push(finding({
|
||||
id: 'design-sidecar-legacy-path',
|
||||
artifact: 'design.json',
|
||||
filePath: relPresent,
|
||||
severity: 'auto',
|
||||
summary: `The design sidecar sits at ${relPresent}, a location kept only for backward compatibility.`,
|
||||
fix: `Move it to ${toRelative(canonical, projectRoot)} the next time the sidecar is written. `
|
||||
+ 'No user decision is needed.',
|
||||
}));
|
||||
}
|
||||
|
||||
const sidecar = readJson(present);
|
||||
const schemaVersion = readSidecarSchemaVersion(sidecar);
|
||||
if (sidecar && (schemaVersion === null || schemaVersion < DESIGN_SIDECAR_SCHEMA_VERSION)) {
|
||||
findings.push(finding({
|
||||
id: 'design-sidecar-schema-outdated',
|
||||
artifact: 'design.json',
|
||||
filePath: relPresent,
|
||||
severity: 'route',
|
||||
summary: `${relPresent} is schemaVersion ${schemaVersion === null ? 'unset' : schemaVersion}; `
|
||||
+ `the current sidecar is ${DESIGN_SIDECAR_SCHEMA_VERSION}. Token primitives moved to the DESIGN.md `
|
||||
+ 'frontmatter, so the old shape carries values that are now read from two places.',
|
||||
fix: 'Offer `document` to regenerate the sidecar. It reads the existing DESIGN.md, so no interview is needed.',
|
||||
}));
|
||||
}
|
||||
|
||||
if (designPath) {
|
||||
const designMtime = mtimeMs(designPath);
|
||||
const sidecarMtime = mtimeMs(present);
|
||||
if (designMtime !== null && sidecarMtime !== null && designMtime > sidecarMtime) {
|
||||
findings.push(finding({
|
||||
id: 'design-sidecar-stale',
|
||||
artifact: 'design.json',
|
||||
filePath: relPresent,
|
||||
severity: 'mention',
|
||||
summary: `DESIGN.md was edited after ${relPresent} was generated, so the sidecar's ramps, `
|
||||
+ 'shadows, motion tokens, and component snippets may contradict it.',
|
||||
fix: 'Offer `document` to refresh the sidecar, preserving DESIGN.md.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── .impeccable/config.json ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Unrecognized keys in the shared and local configs. A key nothing reads is
|
||||
* indistinguishable from a working setting until someone checks, which is how
|
||||
* a singular `ignoreRule` silences nothing for months.
|
||||
*/
|
||||
export function checkConfig({ projectRoot, repoRoot }) {
|
||||
const findings = [];
|
||||
const roots = [...new Set([projectRoot, repoRoot].filter(Boolean).map((root) => path.resolve(root)))];
|
||||
for (const root of roots) {
|
||||
for (const name of ['config.json', 'config.local.json']) {
|
||||
const filePath = path.join(root, '.impeccable', name);
|
||||
const raw = readJson(filePath);
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) continue;
|
||||
const rel = toRelative(filePath, projectRoot || root);
|
||||
|
||||
const unknownTop = Object.keys(raw).filter((key) => !KNOWN_CONFIG_KEYS.has(key));
|
||||
if (unknownTop.length) {
|
||||
findings.push(finding({
|
||||
id: 'config-unknown-keys',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} has top-level key(s) nothing reads: ${unknownTop.map((key) => `\`${key}\``).join(', ')}. `
|
||||
+ `Recognized keys are ${[...KNOWN_CONFIG_KEYS].map((key) => `\`${key}\``).join(', ')}.`,
|
||||
fix: 'Report the exact keys to the user. A near-miss of a real key is a setting that has never applied.',
|
||||
}));
|
||||
}
|
||||
|
||||
const detector = raw.detector;
|
||||
if (detector && typeof detector === 'object' && !Array.isArray(detector)) {
|
||||
const unknownDetector = Object.keys(detector).filter((key) => !KNOWN_DETECTOR_KEYS.has(key));
|
||||
if (unknownDetector.length) {
|
||||
findings.push(finding({
|
||||
id: 'config-unknown-detector-keys',
|
||||
artifact: 'config.json',
|
||||
filePath: rel,
|
||||
severity: 'mention',
|
||||
summary: `${rel} has \`detector\` key(s) nothing reads: ${unknownDetector.map((key) => `\`${key}\``).join(', ')}. `
|
||||
+ `Recognized keys are ${[...KNOWN_DETECTOR_KEYS].map((key) => `\`${key}\``).join(', ')}.`,
|
||||
fix: 'Report the exact keys. `ignoreRule` for `ignoreRules` is the common one, and it silences nothing.',
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return findings;
|
||||
}
|
||||
|
||||
// ─── Surface briefs ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* A brief whose primary target no longer exists still resolves and still gets
|
||||
* injected as authority for a surface that is gone. Route and URL targets have
|
||||
* no file to check and are skipped.
|
||||
*/
|
||||
export function checkSurfaceBriefs({ candidates = [], projectRoot }) {
|
||||
if (!projectRoot) return [];
|
||||
const orphaned = [];
|
||||
for (const brief of candidates) {
|
||||
const target = brief?.primaryTarget;
|
||||
if (!target || typeof target !== 'string') continue;
|
||||
if (/^https?:\/\//i.test(target) || target.startsWith('route:')) continue;
|
||||
if (!fs.existsSync(path.join(projectRoot, target))) orphaned.push(brief);
|
||||
}
|
||||
if (!orphaned.length) return [];
|
||||
return [finding({
|
||||
id: 'surface-brief-orphaned',
|
||||
artifact: 'surface brief',
|
||||
filePath: orphaned.map((brief) => brief.path).filter(Boolean).join(', ') || null,
|
||||
severity: 'mention',
|
||||
summary: `${orphaned.length} persisted surface brief(s) name a primary target that no longer exists: `
|
||||
+ `${orphaned.map((brief) => `${brief.path} → ${brief.primaryTarget}`).join('; ')}.`,
|
||||
fix: 'Ask whether the surface moved (repoint the brief) or was removed (delete the brief). '
|
||||
+ 'Until then the brief is authority for a file that is gone.',
|
||||
})];
|
||||
}
|
||||
|
||||
// ─── Monorepo structure ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* `projectRoots` globs that match no directory. When every pattern misses,
|
||||
* candidate discovery returns nothing, the repo root silently becomes the
|
||||
* active project, and no other signal fires.
|
||||
*
|
||||
* Takes the candidate list rather than computing it: the boot path has already
|
||||
* paid for that walk, and this module must not pay for it twice.
|
||||
*/
|
||||
export function checkProjectRoots({ patterns = [], candidates = [], configuredIn = '.impeccable/config.json' }) {
|
||||
const positive = patterns.filter((pattern) => pattern && !String(pattern).trim().startsWith('!'));
|
||||
if (!positive.length || candidates.length) return [];
|
||||
return [finding({
|
||||
id: 'config-project-roots-match-nothing',
|
||||
artifact: 'config.json',
|
||||
filePath: configuredIn,
|
||||
severity: 'mention',
|
||||
summary: `\`projectRoots\` declares ${positive.map((pattern) => `\`${pattern}\``).join(', ')}, `
|
||||
+ 'but no directory matches any of them, so the repo root is being treated as the active project.',
|
||||
fix: 'Report the patterns and ask which directories they should name. A renamed workspace folder is the usual cause.',
|
||||
})];
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspaces that inherit the repo-root PRODUCT.md. Inheritance is a feature,
|
||||
* not a defect, so this is reported as information for the doctor pass rather
|
||||
* than emitted at boot: the judgment call is whether the inherited record
|
||||
* actually describes that app.
|
||||
*/
|
||||
export function describeWorkspaceContext(candidates = []) {
|
||||
return candidates.map((candidate) => ({
|
||||
name: candidate.name,
|
||||
path: candidate.path,
|
||||
productStatus: candidate.productStatus,
|
||||
productPath: candidate.productPath,
|
||||
designStatus: candidate.designStatus,
|
||||
designPath: candidate.designPath,
|
||||
}));
|
||||
}
|
||||
|
||||
// ─── Tier 1 orchestration ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Everything a boot can afford. `ctx` is the loadContext result; `extras`
|
||||
* carries values the caller already computed so nothing is recomputed here.
|
||||
*/
|
||||
export function collectBootFindings(ctx, extras = {}) {
|
||||
if (!ctx) return [];
|
||||
const projectRoot = ctx.projectRoot || process.cwd();
|
||||
const absProductPath = extras.absProductPath || null;
|
||||
const absDesignPath = extras.absDesignPath || null;
|
||||
|
||||
return [
|
||||
...checkProduct(ctx.product, ctx.productPath || 'PRODUCT.md'),
|
||||
// Only checked once a PRODUCT.md exists. Without one the boot already
|
||||
// emits NO_PRODUCT_MD and routes into init, which asks for the platform
|
||||
// directly; a second signal saying the same thing is noise.
|
||||
...(ctx.product
|
||||
? checkNativePlatformEvidence({
|
||||
projectRoot,
|
||||
platform: ctx.platform,
|
||||
product: ctx.product,
|
||||
productPath: ctx.productPath,
|
||||
})
|
||||
: []),
|
||||
...checkDesignSidecar({
|
||||
designPath: absDesignPath,
|
||||
sidecarCandidates: extras.sidecarCandidates || [],
|
||||
projectRoot,
|
||||
}),
|
||||
...checkConfig({ projectRoot, repoRoot: ctx.repoRoot }),
|
||||
...checkSurfaceBriefs({ candidates: ctx.surfaceBriefCandidates, projectRoot }),
|
||||
...(extras.projectRootPatterns
|
||||
? checkProjectRoots({
|
||||
patterns: extras.projectRootPatterns,
|
||||
candidates: extras.targetCandidates || [],
|
||||
})
|
||||
: []),
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user