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