2026-08-06 09f01778d5a034e2af50ae05da8908f7b6a871c8
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
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 || [],
        })
      : []),
  ];
}