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:
Paul Bakaus
2026-07-21 21:50:40 -07:00
co-authored by Claude
parent b0a7deb688
commit 5575a027dc
15 changed files with 2627 additions and 103 deletions
+51
View File
@@ -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({