baodan/.agents/skills/impeccable/scripts/lib/staleness.mjs

458 lines
19 KiB
JavaScript
Raw Normal View History

/**
* 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 || [],
})
: []),
];
}