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
+48 -37
View File
@@ -5,36 +5,41 @@
There is **one** user-invocable skill, `impeccable`, with **23 commands** underneath it. Users type `/impeccable polish`, `/impeccable audit`, etc. The skill is defined in `skill/`:
- `SKILL.src.md` — frontmatter (with the auto-trigger-optimized description and the `allowed-tools` list), shared design laws, and the **Commands** router table. Provider `SKILL.md` files are generated from this source.
- `reference/` — one `<command>.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.) plus the domain reference files (`typography.md`, `color-and-contrast.md`, etc.). When a sub-command is matched, the router loads its reference file.
- `reference/brand.md` and `reference/product.md` — the two register references. SKILL.md's Setup section selects one based on the task cue, the surface in focus, or the `register` field in PRODUCT.md (first match wins).
- `reference/` — one `<command>.md` per command (`audit.md`, `polish.md`, `critique.md`, etc.), the shared playbooks the router loads outside the command table (`new-work.md`, `craft-floor.md`, `operate.md`, `routing.md`), and the native platform references (`ios.md`, `android.md`). When a sub-command is matched, the router loads its reference file.
- `scripts/command-metadata.json` — single source of truth for each command's description, argument hint, and (eventually) category. Both the build and `pin.mjs` read from this.
- `scripts/pin.mjs` — creates/removes lightweight redirect shims so users can have `/audit` as a standalone shortcut that delegates to `/impeccable audit`.
**Do not add standalone skills** unless there's a strong reason. The consolidation was deliberate: the `/` menu pollution problem is real and gets worse as users install more plugins.
### Register (brand vs product)
**Do not reintroduce per-domain reference files.** v4 removed `typography.md`, `color-and-contrast.md`, `spatial-design.md`, `motion-design.md`, `interaction-design.md`, `responsive-design.md`, `ux-writing.md`, `cognitive-load.md`, `personas.md`, `heuristics-scoring.md`, `build-floor.md`, and `live-generation.md`. Their content lives in the command references and `craft-floor.md`, where it is loaded only when it applies.
Every design task belongs to one of two registers:
### Modes (Persuade / Operate / Read / Experience)
- **Brand** — design IS the product: marketing, landing pages, brand sites, campaign surfaces, portfolios, long-form content. Distinctiveness is the bar. Spans every visual lane (tech-minimal, luxury, editorial-magazine, consumer-warm, brutalist, etc.) — do not default to only one.
- **Product** — design SERVES the product: app UI, admin, dashboards, tools. Earned familiarity is the bar — fluent users of Linear / Figma / Notion / Raycast / Stripe should trust it.
v4 replaced the old brand/product **register** axis with four modes, named in SKILL.src.md's `## Modes` section. A mode names what the visitor's success looks like on the surface in hand:
PRODUCT.md at the project root carries a `## Register` section with a bare value (`brand` or `product`). `/impeccable init` asks about register first because it shapes every downstream answer.
- **Persuade** — the visitor decides and acts; design is the product. Landing pages, marketing, campaigns, pricing.
- **Operate** — the visitor completes a task. App UI, dashboards, editors, admin, settings, tools.
- **Read** — the visitor understands something. Docs, articles, guides, help, changelogs.
- **Experience** — the visitor is inside the work itself. Portfolios, galleries, showcases.
Sub-command reference files add a short `## Register` section near the top *only where the answer diverges between the two*. Don't restate the register files' content in sub-commands — link instead. Sub-commands where register meaningfully diverges today: `typeset`, `animate`, `bolder`, `delight`, `colorize`, `layout`, `quieter`.
Three differences from register that matter when editing skill text:
**a11y lives in `audit.md`**, not in SKILL.md, `brand.md`, or `product.md`. Models over-cautious themselves into safe, underdesigned output when reminded about accessibility at design time. The audit command is the dedicated place for that check.
1. **Mode is per surface, not per project.** A tool's landing page is Persuade even though the product is Operate; a fashion house's documentation is Read. Choose from the requested surface.
2. **Mode is not stored in PRODUCT.md.** It persists only in that surface's brief under `.impeccable/surfaces/`. There is no `## Register` field and no `extractRegister()`; PRODUCT.md's only bare-value field is `## Platform`. A `## Register` section left over from v3 is reported at boot as deprecated (see `lib/staleness.mjs`) and read by nothing.
3. **There are no register reference files.** `reference/brand.md` and `reference/product.md` are gone. `reference/operate.md` carries the deeper Operate and Read guidance; `reference/new-work.md` owns new surfaces.
**a11y lives in `audit.md`**, not in SKILL.md or the mode guidance. Models over-cautious themselves into safe, underdesigned output when reminded about accessibility at design time. The audit command is the dedicated place for that check.
### Platform (web / ios / android / adaptive)
A second axis, **orthogonal to register**. Register answers "does design IS or SERVES the product"; platform answers "what's the delivery target and which native conventions apply":
A second axis, **orthogonal to mode**. Mode answers "what does the visitor come here to do"; platform answers "what's the delivery target and which native conventions apply":
- **web** — a website or web app (including responsive mobile web). The default. No extra rulebook and no reference file: the General rules in SKILL.md and the register reference cover it.
- **ios** — a native iOS / iPadOS app. Loads `reference/ios.md` (Apple HIG distilled) on top of the register reference.
- **android** — a native Android app. Loads `reference/android.md` (Material Design 3 distilled) on top of the register reference.
- **web** — a website or web app (including responsive mobile web). The default. No extra rulebook and no reference file: the General rules in SKILL.md cover it.
- **ios** — a native iOS / iPadOS app. Loads `reference/ios.md` (Apple HIG distilled).
- **android** — a native Android app. Loads `reference/android.md` (Material Design 3 distilled).
- **adaptive** — a cross-platform app shipping both iOS and Android from one codebase (Flutter, React Native, KMP) that adapts per OS. Loads **both** `reference/ios.md` and `reference/android.md`. A Flutter/RN app that uses one look on both platforms (Material-everywhere is the Flutter default) is not adaptive; it takes that single platform's value.
PRODUCT.md carries a `## Platform` section with a bare value (`web` / `ios` / `android` / `adaptive`). It's parsed by `extractPlatform()` in `skill/scripts/context.mjs` (mirroring `extractRegister()`); a **missing field defaults to `web`** so legacy projects are unaffected. A line that names both native targets (e.g. `ios, android`) is also read as `adaptive`; any other unrecognized value falls back to web **and** the `context.mjs` CLI prints a WARNING directive naming the bad value, so a toolchain name or typo never silently gets web guidance. `context.mjs` appends a NEXT STEP directive to read the native reference(s) when the value is `ios`, `android`, or `adaptive` (both). `init` (Step 3) asks platform right after register.
PRODUCT.md carries a `## Platform` section with a bare value (`web` / `ios` / `android` / `adaptive`). It's parsed by `extractPlatform()` in `skill/scripts/context.mjs`, built on the generic `extractSectionValue()` helper; a **missing field defaults to `web`** so legacy projects are unaffected. A line that names both native targets (e.g. `ios, android`) is also read as `adaptive`; any other unrecognized value falls back to web **and** the `context.mjs` CLI prints a WARNING directive naming the bad value, so a toolchain name or typo never silently gets web guidance. `context.mjs` inlines the native reference(s) directly into its output when the value is `ios`, `android`, or `adaptive` (both), so native conventions land in context without a second model-directed read. `init` (Step 3) confirms an ambiguous platform as part of the product-truth interview, and Step 4 records it as the bare value.
`ios.md` and `android.md` are distilled from the MIT-licensed [ehmo/platform-design-skills](https://github.com/ehmo/platform-design-skills); attribution is in `NOTICE.md`.
@@ -42,6 +47,29 @@ Where a command's native guidance diverges too much to share a file, it gets a *
**Live mode, the `detect` CLI, and the design hook are web-only.** They operate on a browser / HTML rules, so SKILL.md's routing skips live and `detect.mjs` for any native (`ios` / `android` / `adaptive`) project, and the hook (`hook-lib.mjs` `resolveProjectPlatform` / `isNativePlatform`, also used by `hook-before-edit.mjs`) skips its scan when PRODUCT.md declares a native platform — a React Native project is made of exactly the `.tsx` / `.ts` / `.js` files the hook watches.
### Artifact staleness and the doctor pass
Impeccable writes files into user projects, so a released version has to cope with artifacts an older one wrote. Three kinds of drift travel under "out of date" and they are handled separately:
1. **Tool version drift** (installed skill older than published). `computeUpdateDirective()` in `context.mjs`, emitted as `UPDATE_AVAILABLE`. Predates this system, unchanged.
2. **Schema drift** (an artifact carries fields nothing reads, is missing fields now expected, or sits in a retired location). Deterministic. `skill/scripts/lib/staleness.mjs`.
3. **Truth drift** (the code moved on and the document no longer describes it). Not mechanical. `document` and `init` own the rewrite; the deep pass measures a proxy and is required to say it is a proxy.
**Two tiers, and the split is a performance contract, not a preference.**
- **Tier 1** is `collectBootFindings()` in `lib/staleness.mjs`, called from `appendStalenessDirective()` in `context.mjs`. It may only spend what a boot already spends: markdown already in memory, a bounded set of stats, and the small JSON files the boot reads regardless. **No directory walks, no git, no cross-workspace sweep.** The one walk it uses (`discoverTargetCandidates`) is one `resolveTargetSelection` has already paid for. Adding an expensive check here taxes every session in every project.
- **Tier 2** is `lib/staleness-deep.mjs`, run on demand by `skill/scripts/doctor.mjs`. Git log, per-workspace sweep, ignore-list validation against the live `ANTIPATTERNS` registry, hook script resolution.
**Findings are data.** `{ id, artifact, path, severity, summary, fix }`, so the boot directive, the text report, and `--json` all render one set. Severity says what should happen, not how bad it is: `auto` (fix silently on the next write to that file), `mention` (state once, carry on), `route` (name the command that owns the repair). `doctor --fix` applies only `auto`, and only where no judgment is involved.
**Emission discipline.** Boot output is already heavy, so Tier 1 emits **one** `CONTEXT_STALE` directive for the whole set, and `lib/staleness-notice.mjs` throttles `mention` and `route` findings to once a week per project (cached in `~/.impeccable/staleness-check.json`, alongside the update cache, so no gitignore entry is owed). `auto` findings are never throttled and never shown to the user. Opt out with `"stalenessCheck": false` or `IMPECCABLE_NO_STALENESS_CHECK=1`. **A test that asserts on other boot directives should set that env var**, which is why the update-check suite in `tests/context.test.mjs` does.
**Provenance stamps.** PRODUCT.md carries `<!-- impeccable:product-schema N -->` (constants in `lib/artifact-schema.mjs`, template in `init.md`). Without it, every check is a heuristic reconstruction of what era a file came from. **Stamps are schema versions, not release versions**: a PRODUCT.md written by v4.0.0 is not stale under v4.0.1, and a schema version changes only when the shape does. **DESIGN.md deliberately carries no stamp** because it follows the external design.md spec that Stitch's linter validates, and every DESIGN.md signal (sidecar `schemaVersion`, sidecar mtime, section coverage, git drift) is measurable without one.
**When you retire a PRODUCT.md field, add it to `PRODUCT_DEPRECATED_SECTIONS`** in `lib/artifact-schema.mjs` with the reason. The reason is not decoration: told only that a field is deprecated, models preserve it "just in case", which is how a retired axis keeps steering current output.
**`doctor` is a utility command, not a design command.** It follows the `hooks` and `pin` pattern (a line in SKILL.src.md plus `reference/doctor.md`), not the Commands-table pattern. It is deliberately **not** in `IMPECCABLE_SUB_COMMANDS`, `command-metadata.json`, `SKILL_CATEGORIES`, or `pin.mjs`'s `VALID_COMMANDS`, and it does not count toward the 23. Keep maintenance tooling out of the design menu.
## Repo split: public product vs private service (impeccable-site)
As of v4 the repo holds only the open-source product layer: the skill, CLI, extension, their tests, and the build that generates provider outputs. Everything service-side lives in the private repo `pbakaus/impeccable-site` (checked out at `~/code/impeccable-site`): the impeccable.style site, the review labs, the concept/composition catalogs and reviews, the world-card image pipeline and R2 publish, the Cloudflare Pages Functions (including `/api/roll` and `/api/chosen`), and `docs/WORLD-CATALOG-AUTHORING.md`.
@@ -139,36 +167,19 @@ Adding a new fixture is a matter of cloning a directory under `tests/framework-f
### Skill-behavior tests
`tests/skill-behavior/scenarios.test.mjs` is the LLM-backed safety net for edits to `skill/SKILL.src.md` and the Setup-adjacent reference files (`init.md`, `document.md`, `brand.md`, `product.md`, sub-command refs). It inlines the source `skill/SKILL.src.md` into the system prompt of a real LLM, gives the agent `bash` / `read` / `write` / `list` tools scoped to a temp workspace, and asserts on the tool-call trace — not on the model's free-form output. The trace is the source of truth.
`tests/skill-behavior/scenarios.test.mjs` is the LLM-backed safety net for edits to `skill/SKILL.src.md` and the Setup-adjacent reference files (`init.md`, `document.md`, `new-work.md`, sub-command refs). It inlines the source `skill/SKILL.src.md` into the system prompt of a real LLM, gives the agent `bash` / `read` / `write` / `list` tools scoped to a temp workspace, and asserts on the tool-call trace — not on the model's free-form output. The trace is the source of truth. `tests/skill-behavior/workflow-contract.test.mjs` adds the end-to-end flows (attended fresh init, initialized natural build request, replacement-world redesign, scope-preserving refinement), asserting on question order and artifact writes.
```bash
bun run test:skill-behavior # full suite (27 tests, ~5 min, ~$0.50-1.50 across providers)
IMPECCABLE_SKILL_BEHAVIOR_MODELS=gemini-3.1-flash-lite bun run test:skill-behavior # scope to one provider
IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1 bun run test:skill-behavior # dump per-scenario trace JSON to stderr (use when iterating)
bun run test:skill-behavior # full suite, ~5 min, ~$0.50-1.50 across providers
IMPECCABLE_SKILL_BEHAVIOR_MODELS=gemini-3.5-flash bun run test:skill-behavior # scope to one provider
IMPECCABLE_SKILL_BEHAVIOR_VERBOSE=1 bun run test:skill-behavior # dump per-scenario trace JSON to stderr (use when iterating)
```
**Three providers per run, every run.** The suite always exercises `claude-sonnet-4-6`, `gpt-5.5`, and `gemini-3.1-flash-lite`. Sonnet and GPT-5.5 are production-tier, matching what users actually run, so the pass/fail signal reflects real agent behavior rather than a cheap proxy; gemini stays on the flash-lite tier. **Don't substitute Claude alone**: many of the most useful findings come from divergence between providers.
**Every provider, every run.** The lineup is `DEFAULT_MODELS` in `tests/skill-behavior/providers.mjs`, currently `claude-sonnet-5`, `gpt-5.6-luna`, `gemini-3.5-flash`, and `deepseek-v4-flash`. **Don't substitute Claude alone**: many of the most useful findings come from divergence between providers.
**Auth** lives in repo-root `.env` (copied from `~/code/impeccable-evals/.env`, gitignored). Providers skip cleanly when their key is unset; they don't fail.
**Fifteen scenarios:**
1. empty workspace → agent loads `reference/init.md`
2. PRODUCT.md only → loads `brand.md`
3. PRODUCT.md + DESIGN.md → loads `brand.md` + consults the design system
4. context already loaded in turn 1 → turn 2 does **not** re-run `context.mjs`
5. PRODUCT.md without `## Register` field → agent infers `brand` from task cue
6. `/impeccable polish` → loads `reference/polish.md`
7. `/impeccable audit` → loads `reference/audit.md`
8. existing SvelteKit project → agent reads at least one project code file
9. `context.mjs` emits `UPDATE_AVAILABLE` (seeded newer version) → agent surfaces it but does **not** auto-run `npx impeccable skills update`
10. scoped command with no PRODUCT.md → proceeds without forcing init
11. `/impeccable shape` with no PRODUCT.md → diverts into `reference/init.md`
12. natural-language build intent with no PRODUCT.md → diverts into `reference/init.md`
13. `/impeccable teach` → diverts into `reference/init.md` (alias)
14. PRODUCT.md with `## Platform: ios``context.mjs` emits the native NEXT STEP and the agent loads `reference/ios.md`
15. same iOS fixture, `/impeccable audit` → agent loads `reference/audit.native.md` (route-instead variant)
**Baseline.** The 21-22 / 24 baseline (with stable gpt scenario 6/7 failures) was measured on the old cheap tier (`claude-haiku-4-5` / `gpt-5.4-mini`). It needs re-measuring on the current `claude-sonnet-4-6` / `gpt-5.5` lineup; the production-tier models are expected to do better on the sub-command routing scenarios the old gpt tier failed. See `tests/skill-behavior/README.md`.
**The scenario list and the baseline live in `tests/skill-behavior/README.md`**, not here. Read that table before changing Setup or routing text, and update it in the same change. Duplicating it in this file is how it went stale before.
**Cost.** Each run is real LLM calls, billed to the keys in `.env`. Production-tier models put a full sweep around $0.50-1.50. Keep it out of CI unless you really want it there.
+4 -2
View File
@@ -25,10 +25,10 @@ export const SUITES = {
triggers: [
...COMMON_INFRA_PATTERNS,
/^scripts\/(?!benchmark-detector|build-browser-detector|build-extension)/,
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|concept-seed|context|context-signals|critique-storage|design-parser|hook|impeccable-paths|is-generated|lib\/(composition-catalog|concept-catalog|provider|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
/^skill\/(SKILL\.src\.md|agents\/|reference\/|scripts\/(cleanup-deprecated|concept-seed|context|context-signals|critique-storage|design-parser|doctor|hook|impeccable-paths|is-generated|lib\/(artifact-schema|composition-catalog|concept-catalog|provider|staleness|staleness-deep|staleness-notice|surface-briefs|target-slug|template-extensions)|pin|surface-brief))/,
/^README(\.npm)?\.md$/,
/^cli\/bin\//,
/^tests\/(build|cleanup-deprecated|cli-args|cli-ignores|concept-seed|context|context-signals|critique-storage|design-parser|github-sheriff|hook|hook-build|impeccable-paths|openai-plugin|pin|skills-cli|surface-brief|target-args|template-extensions|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
/^tests\/(build|cleanup-deprecated|cli-args|cli-ignores|concept-seed|context|context-signals|critique-storage|design-parser|doctor|github-sheriff|hook|hook-build|impeccable-paths|openai-plugin|pin|skills-cli|staleness|surface-brief|target-args|template-extensions|test-suites|windows-path-fix|zip)\.test\.(js|mjs)$/,
/^tests\/lib\//,
],
commands: [
@@ -65,6 +65,8 @@ export const SUITES = {
'tests/impeccable-paths.test.mjs',
'tests/openai-plugin.test.mjs',
'tests/pin.test.mjs',
'tests/doctor.test.mjs',
'tests/staleness.test.mjs',
'tests/target-args.test.mjs',
'tests/surface-brief.test.mjs',
'tests/template-extensions.test.mjs',
+4
View File
@@ -79,3 +79,7 @@ After init writes PRODUCT.md, resume without rerunning `context.mjs`; init loads
**Pin / Unpin:** `node {{scripts_path}}/pin.mjs <pin|unpin> <command>` creates or removes a standalone `{{command_prefix}}<command>` shortcut. Report the script's result concisely; relay stderr verbatim on error.
**Hooks:** `{{command_prefix}}impeccable hooks <on|off|status|ignore-rule|ignore-file|ignore-value|reset>` manages the design detector hook for this project (auto-runs the detector after UI file edits and surfaces findings). Load [reference/hooks.md](reference/hooks.md) when the user invokes it with any argument.
**Doctor:** `{{command_prefix}}impeccable doctor` reports and repairs drift between this project's Impeccable artifacts (PRODUCT.md, DESIGN.md and its sidecar, config, surface briefs, the hook) and what this version reads. Load [reference/doctor.md](reference/doctor.md) when the user invokes it, or when they ask what is out of date, stale, or needs refreshing. A `CONTEXT_STALE` directive in Setup's output is the cheap subset of the same report; act on it there per its own instructions rather than running doctor unasked. <!-- rule:skill-doctor-route -->
**Never repair drift as a side effect of a design task.** A `CONTEXT_STALE` finding is reported, not acted on, unless the user asks. The one exception is a finding marked `auto`, which the next write to that file performs anyway. <!-- rule:skill-drift-not-a-side-quest -->
+53
View File
@@ -0,0 +1,53 @@
Report and repair drift between this project's Impeccable artifacts and what the installed version reads: PRODUCT.md, DESIGN.md and its `.impeccable/design.json` sidecar, `.impeccable/config.json`, persisted surface briefs, and the design hook.
This is maintenance, not design. Do not redesign anything, do not open files outside the ones the report names, and do not run any other command as a side effect.
## What this owns, and what it does not
Three kinds of drift travel under "out of date". Keep them apart:
- **Tool version.** The installed skill is older than the published one. `context.mjs` reports that at boot as `UPDATE_AVAILABLE` and `npx impeccable update` fixes it. Not this command's job.
- **Schema drift.** An artifact was written by an older Impeccable: fields nothing reads, fields now expected, files in retired locations. Mechanical, and this command repairs most of it.
- **Truth drift.** The code moved on and the document no longer describes it. No file comparison settles this. `document` owns DESIGN.md, `init` owns PRODUCT.md, and this command's job is to hand them a specific gap rather than a vague suspicion.
## Step 1: Run the pass
```
node {{scripts_path}}/doctor.mjs --json
```
Add `--target <path>` when the user named a workspace, file, or route in a monorepo. Without it the report describes the repo root, and in a monorepo that is often the wrong project.
The output carries `findings` (each with `id`, `artifact`, `path`, `severity`, `summary`, `fix`) and, in a monorepo, `workspaces` with each app's product and design resolution. `ruleRegistryAvailable: false` means ignored rule ids could not be validated; say so rather than implying that list is clean.
An empty `findings` array is the good outcome. Say so in one line and stop.
## Step 2: Act by severity
The severity says what should happen, not how bad it is.
- **`auto`** carries no decision. Run `node {{scripts_path}}/doctor.mjs --fix` once to apply these, then report what it moved in one line. Do not ask permission first, and do not ask about them afterward.
- **`mention`** needs the user to know but not to decide anything now. State each one in a sentence with its offered fix.
- **`route`** needs a specific command. Name the command and the gap it would close. Run it only if the user asks in this turn; `init` and `document` are conversations, not repairs you perform unattended.
Report all three groups in one pass. Findings are not errors and the command does not fail on them.
## Step 3: Deprecated fields are binding
A finding that reports a deprecated field (`## Register` is the current one) is not a style note. Treat that field as absent for every decision from here on, whatever value it holds, and offer to delete the section. Preserving it "just in case" is how a retired axis keeps steering current output.
## Step 4: Do not overclaim on truth drift
`design-md-drift` counts commits to the visual source directories since DESIGN.md was last edited. A commit count is not a contradiction. Report the number, say what it measures, and if the user wants to know whether the document is actually wrong, read DESIGN.md against the current tokens and components and answer from that. Never assert that DESIGN.md is stale because the number is large.
The same restraint applies to `workspace-context-inherited`. Inheritance is a designed behavior. Whether one product record truthfully describes several apps is a question for the user, not a defect to fix.
## Monorepo notes
- `workspace-platform-native-evidence` is the finding that matters most here: a workspace carrying native build files while inheriting a root record that resolves to web gets web guidance for its whole life and never loads [ios.md](ios.md) or [android.md](android.md). The repair is a child PRODUCT.md in that workspace, because one inherited record cannot hold two platforms.
- `config-project-roots-match-nothing` means every `projectRoots` glob missed, so the repo root is silently standing in as the active project. A renamed workspace directory is the usual cause. Report the patterns and ask which directories they should name.
- Use the `workspaces` table to show the user which apps carry their own context, which inherit, and which have none, before proposing any change.
## Opting out of the boot check
`context.mjs` reports the cheap subset of these findings at session start, throttled to once a week per project. Set `"stalenessCheck": false` in `.impeccable/config.json` to silence that, or `IMPECCABLE_NO_STALENESS_CHECK=1` for one session. This command still works with the check disabled, and that is the combination to suggest for a user who wants the report only when they ask for it.
+4
View File
@@ -58,6 +58,8 @@ Write only confirmed facts and explicitly marked open decisions. Omit irrelevant
```markdown
# Product
<!-- impeccable:product-schema 1 -->
## Platform
web
@@ -92,6 +94,8 @@ web
Platform is the bare value `web`, `ios`, `android`, or `adaptive`. Preserve useful legacy headings. New files go at `PROJECT_ROOT/PRODUCT.md`; otherwise update the resolved file. Write it before any visual-world or surface-concept work.
Copy the `impeccable:product-schema` comment verbatim, including when you update an older file. It records which version of the product record this file follows, so later versions can tell a deliberately short record from one written before a section existed, and never propose an interview the user has already sat through. Update the number only when this reference's template changes it. Sections a later version retires are reported to you at boot as deprecated; delete them when the user agrees rather than carrying them forward.
When the platform you just recorded is `ios`, `android`, or `adaptive`, load [ios.md](ios.md), [android.md](android.md), or both before any design work. On a project that had no PRODUCT.md, context.mjs could not know the platform and so never loaded them; init is the only place that learns the answer.
### Completion gate
+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({
+336
View File
@@ -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 };
+93
View File
@@ -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;
}
+2 -8
View File
@@ -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 = {}) {
+410
View File
@@ -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;
}
}
+169
View File
@@ -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(' ');
}
+457
View File
@@ -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 || [],
})
: []),
];
}
+50 -56
View File
@@ -48,6 +48,27 @@ function write(rel, body = '# placeholder\n') {
return abs;
}
// Stage a runnable copy of context.mjs plus its whole lib/ directory.
// The bundle tests used to enumerate the helpers they needed, which turned
// every new import in context.mjs into a mysterious non-zero exit here.
// Copying the directory keeps them honest about what the real script loads.
function stageContextBundle(scriptsDir, { providerId } = {}) {
const libSrc = path.join(path.dirname(SCRIPT_PATH), 'lib');
const libDest = path.join(scriptsDir, 'lib');
fs.mkdirSync(scriptsDir, { recursive: true });
fs.copyFileSync(SCRIPT_PATH, path.join(scriptsDir, 'context.mjs'));
fs.cpSync(libSrc, libDest, { recursive: true });
if (providerId) {
const providerPath = path.join(libDest, 'provider.mjs');
fs.writeFileSync(
providerPath,
fs.readFileSync(providerPath, 'utf8')
.replace("IMPECCABLE_PROVIDER_ID = 'source'", `IMPECCABLE_PROVIDER_ID = '${providerId}'`),
);
}
return path.join(scriptsDir, 'context.mjs');
}
function parseTargetSelection(stdout) {
const tail = stdout.split('TARGET_SELECTION_REQUIRED:\n')[1];
assert.ok(tail, `missing TARGET_SELECTION_REQUIRED block in:\n${stdout}`);
@@ -479,7 +500,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
const selection = parseTargetSelection(res.stdout);
@@ -510,7 +531,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.doesNotMatch(res.stdout, /MONOREPO_TARGET_REQUIRED/);
@@ -523,7 +544,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'apps/dashboard/src/App.jsx'], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /# Dashboard product/);
@@ -540,7 +561,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/);
@@ -565,7 +586,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
const selection = parseTargetSelection(res.stdout);
@@ -626,7 +647,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
const selection = parseTargetSelection(res.stdout);
@@ -648,7 +669,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/);
@@ -665,7 +686,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
const selection = parseTargetSelection(res.stdout);
@@ -682,7 +703,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.doesNotMatch(res.stdout, /TARGET_SELECTION_REQUIRED/);
@@ -694,7 +715,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', '.'], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /# PRODUCT\.md\n\n# Root product/);
@@ -708,7 +729,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', '--help'], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 1);
assert.match(res.stderr, /--target requires a path value/);
@@ -727,7 +748,7 @@ describe('loadContext (monorepo project context)', () => {
], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /# Dashboard product/);
@@ -740,7 +761,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'apps/dashboard/routes/pricing'], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
@@ -760,7 +781,7 @@ describe('loadContext (monorepo project context)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /TARGET_SELECTION_REQUIRED:/);
@@ -835,7 +856,7 @@ describe('loadContext (impeccable projectRoots config)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
const selection = parseTargetSelection(res.stdout);
@@ -851,7 +872,7 @@ describe('loadContext (impeccable projectRoots config)', () => {
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
const selection = parseTargetSelection(res.stdout);
@@ -1041,15 +1062,7 @@ describe('context.mjs CLI', () => {
it('keeps the manual-detector directive out of early context when the current provider hook is active', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
const lib = path.join(scripts, 'lib');
fs.mkdirSync(lib, { recursive: true });
fs.copyFileSync(SCRIPT_PATH, path.join(scripts, 'context.mjs'));
for (const helper of ['target-args.mjs', 'surface-briefs.mjs', 'target-slug.mjs']) {
fs.copyFileSync(path.join(path.dirname(SCRIPT_PATH), 'lib', helper), path.join(lib, helper));
}
const provider = fs.readFileSync(path.join(path.dirname(SCRIPT_PATH), 'lib', 'provider.mjs'), 'utf8')
.replace("IMPECCABLE_PROVIDER_ID = 'source'", "IMPECCABLE_PROVIDER_ID = 'codex'");
fs.writeFileSync(path.join(lib, 'provider.mjs'), provider);
stageContextBundle(scripts, { providerId: 'codex' });
const project = path.join(scratch, 'project');
fs.mkdirSync(path.join(project, '.codex'), { recursive: true });
@@ -1061,7 +1074,7 @@ describe('context.mjs CLI', () => {
const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
cwd: project,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
@@ -1071,7 +1084,7 @@ describe('context.mjs CLI', () => {
const disabled = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
cwd: project,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(disabled.status, 0, disabled.stderr);
assert.match(disabled.stdout, /MANUAL_DETECTOR_REQUIRED:/);
@@ -1080,15 +1093,7 @@ describe('context.mjs CLI', () => {
it('adds no detector directive when a per-edit-only hook is active', () => {
const scripts = path.join(scratch, 'bundle', 'skills', 'impeccable', 'scripts');
const lib = path.join(scripts, 'lib');
fs.mkdirSync(lib, { recursive: true });
fs.copyFileSync(SCRIPT_PATH, path.join(scripts, 'context.mjs'));
for (const helper of ['target-args.mjs', 'surface-briefs.mjs', 'target-slug.mjs']) {
fs.copyFileSync(path.join(path.dirname(SCRIPT_PATH), 'lib', helper), path.join(lib, helper));
}
const provider = fs.readFileSync(path.join(path.dirname(SCRIPT_PATH), 'lib', 'provider.mjs'), 'utf8')
.replace("IMPECCABLE_PROVIDER_ID = 'source'", "IMPECCABLE_PROVIDER_ID = 'cursor'");
fs.writeFileSync(path.join(lib, 'provider.mjs'), provider);
stageContextBundle(scripts, { providerId: 'cursor' });
const project = path.join(scratch, 'project');
fs.mkdirSync(path.join(project, '.cursor'), { recursive: true });
@@ -1100,7 +1105,7 @@ describe('context.mjs CLI', () => {
const res = spawnSync(process.execPath, [path.join(scripts, 'context.mjs')], {
cwd: project,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /MANUAL_DETECTOR_REQUIRED:/);
@@ -1193,7 +1198,7 @@ Make plan tradeoffs legible before asking for a trial.
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /# SURFACE BRIEF \(\.impeccable\/surfaces\/src-pages-pricing-astro\.md\)/);
@@ -1230,7 +1235,7 @@ HOME_STRATEGY_SENTINEL
const res = spawnSync(process.execPath, [SCRIPT_PATH, '--target', 'src/pages/pricing.astro'], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /PRICING_STRATEGY_SENTINEL/);
@@ -1257,7 +1262,7 @@ related_targets: []
const res = spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1', IMPECCABLE_NO_STALENESS_CHECK: '1' },
});
assert.equal(res.status, 0);
assert.match(res.stdout, /SURFACE_CONTEXT_AVAILABLE:/);
@@ -1354,22 +1359,7 @@ describe('context.mjs update check', () => {
const cachePath = () => path.join(scratch, 'update-check.json');
function setup(cacheObj, { disable = false, host } = {}) {
const skillScript = path.join(scratch, 'skill', 'scripts', 'context.mjs');
fs.mkdirSync(path.dirname(skillScript), { recursive: true });
fs.copyFileSync(SCRIPT_PATH, skillScript);
const targetArgsSrc = path.join(path.dirname(SCRIPT_PATH), 'lib', 'target-args.mjs');
const targetArgsDest = path.join(path.dirname(skillScript), 'lib', 'target-args.mjs');
fs.mkdirSync(path.dirname(targetArgsDest), { recursive: true });
fs.copyFileSync(targetArgsSrc, targetArgsDest);
const providerSrc = path.join(path.dirname(SCRIPT_PATH), 'lib', 'provider.mjs');
const providerDest = path.join(path.dirname(skillScript), 'lib', 'provider.mjs');
fs.copyFileSync(providerSrc, providerDest);
for (const helper of ['surface-briefs.mjs', 'target-slug.mjs']) {
fs.copyFileSync(
path.join(path.dirname(SCRIPT_PATH), 'lib', helper),
path.join(path.dirname(skillScript), 'lib', helper),
);
}
const skillScript = stageContextBundle(path.join(scratch, 'skill', 'scripts'));
fs.writeFileSync(
path.join(scratch, 'skill', 'SKILL.md'),
`---\nname: impeccable\nversion: ${LOCAL_VERSION}\n---\n\nbody\n`,
@@ -1382,6 +1372,10 @@ describe('context.mjs update check', () => {
...process.env,
IMPECCABLE_UPDATE_CACHE: cachePath(),
IMPECCABLE_NO_UPDATE_CHECK: disable ? '1' : '',
// This suite asserts on the update directive alone. Staleness findings
// are a separate directive with their own tests, and leaving the check on
// would also write notice state into the developer's home dir.
IMPECCABLE_NO_STALENESS_CHECK: '1',
...(host ? { IMPECCABLE_UPDATE_HOST: host } : {}),
};
return { skillScript, project, env };
+435
View File
@@ -0,0 +1,435 @@
/**
* Tests for the deep staleness pass and the doctor CLI.
* Run with: node --test tests/doctor.test.mjs
*
* The Tier 1 checks are covered in tests/staleness.test.mjs. This file covers
* what only the deep pass does: git drift, ignore-list validation against the
* rule registry, hook script resolution, the monorepo sweep, and the narrow set
* of migrations `--fix` is allowed to perform.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import { spawnSync, execFileSync } from 'node:child_process';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import { extractPlatform } from '../skill/scripts/context.mjs';
import { parseDesignMd } from '../skill/scripts/lib/design-parser.mjs';
import { checkNativePlatformEvidence } from '../skill/scripts/lib/staleness.mjs';
import {
checkDesignCoverage,
checkDesignDrift,
checkDetectorIgnores,
checkHookInstallation,
checkLegacyLiveState,
checkWorkspaces,
loadKnownRuleIds,
} from '../skill/scripts/lib/staleness-deep.mjs';
const SCRIPTS_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'scripts');
const DOCTOR_PATH = path.join(SCRIPTS_DIR, 'doctor.mjs');
let scratch;
beforeEach(() => {
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-doctor-'));
});
afterEach(() => {
fs.rmSync(scratch, { recursive: true, force: true });
});
function write(rel, body) {
const abs = path.join(scratch, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
return abs;
}
function ids(findings) {
return findings.map((entry) => entry.id);
}
function git(args, cwd = scratch) {
return execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] });
}
function initRepo() {
git(['init', '-q']);
git(['config', 'user.email', 'test@example.com']);
git(['config', 'user.name', 'Test']);
}
function commit(message) {
git(['add', '-A']);
git(['commit', '-q', '--no-gpg-sign', '-m', message]);
}
const CURRENT_PRODUCT = [
'# Product',
'',
'<!-- impeccable:product-schema 1 -->',
'',
'## Platform',
'',
'web',
'',
'## Positioning',
'The only one that does the thing.',
'',
].join('\n');
// ─── git drift ─────────────────────────────────────────────────────────────
describe('checkDesignDrift', () => {
it('reports nothing outside a git repository', () => {
const design = write('DESIGN.md', '---\nname: X\n---\n');
write('src/a.css', 'a{}');
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch }), []);
});
it('reports nothing when UI commits stay under the threshold', () => {
initRepo();
const design = write('DESIGN.md', '---\nname: X\n---\n');
write('src/a.css', 'a{color:red}');
commit('initial');
for (let i = 0; i < 3; i++) {
write(`src/f${i}.css`, `.f${i}{}`);
commit(`ui ${i}`);
}
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch }), []);
});
it('reports the commit count once the threshold is crossed, framed as a proxy', () => {
initRepo();
const design = write('DESIGN.md', '---\nname: X\n---\n');
write('src/a.css', 'a{color:red}');
commit('initial');
for (let i = 0; i < 4; i++) {
write(`src/f${i}.css`, `.f${i}{}`);
commit(`ui ${i}`);
}
const findings = checkDesignDrift({ designPath: design, projectRoot: scratch, threshold: 3 });
assert.deepEqual(ids(findings), ['design-md-drift']);
assert.match(findings[0].summary, /4 commits have touched/);
assert.match(findings[0].summary, /not that it is wrong/);
assert.equal(findings[0].severity, 'route');
});
it('counts only commits after the last DESIGN.md edit', () => {
initRepo();
const design = write('DESIGN.md', '---\nname: X\n---\n');
write('src/a.css', 'a{}');
commit('initial');
for (let i = 0; i < 4; i++) {
write(`src/old${i}.css`, `.o${i}{}`);
commit(`old ui ${i}`);
}
fs.writeFileSync(design, '---\nname: X2\n---\n');
commit('refresh design');
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch, threshold: 3 }), []);
});
it('reports nothing for an untracked DESIGN.md', () => {
initRepo();
write('src/a.css', 'a{}');
commit('initial');
const design = write('DESIGN.md', '---\nname: X\n---\n');
assert.deepEqual(checkDesignDrift({ designPath: design, projectRoot: scratch, threshold: 1 }), []);
});
});
// ─── DESIGN.md coverage ────────────────────────────────────────────────────
describe('checkDesignCoverage', () => {
it('names the canonical sections that carry nothing', () => {
const findings = checkDesignCoverage({
design: '---\nname: X\n---\n\n# Design System: X\n\n## Overview\n\nSomething.\n',
designPath: 'DESIGN.md',
parseDesignMd,
});
assert.deepEqual(ids(findings), ['design-md-coverage']);
assert.match(findings[0].summary, /colors, typography, components/);
assert.equal(findings[0].severity, 'mention');
});
it('stays quiet when the sections are present', () => {
const design = [
'---', 'name: X', '---', '',
'# Design System: X', '',
'## Colors', '', '### Primary', '- **Ink** (#111): Text.', '',
'## Typography', '', '**Body Font:** Inter', '',
'### Hierarchy', '- **Body** (400, 16px, 1.5): Paragraphs.', '',
'## Components', '', '### Button', '- Primary action.', '',
].join('\n');
assert.deepEqual(checkDesignCoverage({ design, designPath: 'DESIGN.md', parseDesignMd }), []);
});
it('reports nothing without a DESIGN.md', () => {
assert.deepEqual(checkDesignCoverage({ design: null, parseDesignMd }), []);
});
});
// ─── detector ignore lists ─────────────────────────────────────────────────
describe('checkDetectorIgnores', () => {
it('flags rule ids the engine does not have', () => {
write('.impeccable/config.json', JSON.stringify({
detector: { ignoreRules: ['side-tab', 'no-such-rule'] },
}));
const findings = checkDetectorIgnores({
projectRoot: scratch,
knownRuleIds: new Set(['side-tab']),
});
assert.deepEqual(ids(findings), ['detector-ignore-rules-unknown']);
assert.match(findings[0].summary, /`no-such-rule`/);
assert.doesNotMatch(findings[0].summary, /`side-tab`/);
});
it('skips validation entirely when the registry is unavailable', () => {
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRules: ['whatever'] } }));
assert.deepEqual(checkDetectorIgnores({ projectRoot: scratch, knownRuleIds: null }), []);
});
it('accepts the wildcard rule', () => {
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRules: ['*'] } }));
assert.deepEqual(
checkDetectorIgnores({ projectRoot: scratch, knownRuleIds: new Set(['side-tab']) }),
[],
);
});
it('flags ignored files that no longer exist but leaves globs alone', () => {
write('src/here.tsx', 'x');
write('.impeccable/config.json', JSON.stringify({
detector: { ignoreFiles: ['src/here.tsx', 'src/gone.tsx', 'src/**/*.stories.tsx'] },
}));
const findings = checkDetectorIgnores({ projectRoot: scratch });
assert.deepEqual(ids(findings), ['detector-ignore-files-missing']);
assert.match(findings[0].summary, /`src\/gone\.tsx`/);
assert.doesNotMatch(findings[0].summary, /stories/);
});
it('resolves the real rule registry from the source checkout', async () => {
const knownRuleIds = await loadKnownRuleIds(SCRIPTS_DIR);
assert.ok(knownRuleIds instanceof Set, 'expected the bundled detector to resolve');
assert.ok(knownRuleIds.size > 20, `expected many rule ids, got ${knownRuleIds.size}`);
assert.ok(knownRuleIds.has('side-tab'));
});
});
// ─── hook installation ─────────────────────────────────────────────────────
describe('checkHookInstallation', () => {
it('flags an installed hook whose script path does not resolve', () => {
write('.claude/settings.json', JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
const findings = checkHookInstallation({
projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code',
});
assert.deepEqual(ids(findings), ['hook-script-missing']);
assert.match(findings[0].summary, /no-op/);
});
it('stays quiet when the hook script is present', () => {
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
write('.claude/settings.json', JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
[],
);
});
it('flags an installed manifest contradicted by hook.enabled false', () => {
write('.claude/skills/impeccable/scripts/hook.mjs', '// hook\n');
write('.claude/settings.json', JSON.stringify({
hooks: { Stop: [{ hooks: [{ command: 'node .claude/skills/impeccable/scripts/hook.mjs' }] }] },
}));
write('.impeccable/config.json', JSON.stringify({ hook: { enabled: false } }));
const findings = checkHookInstallation({
projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code',
});
assert.deepEqual(ids(findings), ['hook-enabled-conflict']);
});
it('reports nothing when no manifest installs the hook', () => {
write('.claude/settings.json', JSON.stringify({ hooks: { Stop: [{ hooks: [{ command: 'node other.mjs' }] }] } }));
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'claude-code' }),
[],
);
});
it('reports nothing for a provider with no known manifest', () => {
assert.deepEqual(
checkHookInstallation({ projectRoot: scratch, repoRoot: scratch, providerId: 'source' }),
[],
);
});
});
// ─── retired live-mode state ───────────────────────────────────────────────
describe('checkLegacyLiveState', () => {
it('flags retired locations as automatic', () => {
write('.impeccable-live.json', '{}');
const findings = checkLegacyLiveState({ projectRoot: scratch });
assert.deepEqual(ids(findings), ['legacy-live-state']);
assert.equal(findings[0].severity, 'auto');
});
it('reports nothing on a current project', () => {
assert.deepEqual(checkLegacyLiveState({ projectRoot: scratch }), []);
});
});
// ─── monorepo sweep ────────────────────────────────────────────────────────
describe('checkWorkspaces', () => {
function sweep(candidates) {
return checkWorkspaces({
repoRoot: scratch,
candidates,
checkNativePlatformEvidence,
extractPlatform,
readFile: (filePath) => {
try { return fs.readFileSync(filePath, 'utf-8'); } catch { return null; }
},
});
}
it('flags a native workspace inheriting a web product record', () => {
write('PRODUCT.md', CURRENT_PRODUCT);
write('apps/mobile/ios/Podfile', '');
const { findings } = sweep([
{ name: 'mobile', path: 'apps/mobile', productStatus: 'inherited', productPath: 'PRODUCT.md', designStatus: 'missing' },
]);
assert.ok(ids(findings).includes('workspace-platform-native-evidence'));
const entry = findings.find((f) => f.id === 'workspace-platform-native-evidence');
assert.match(entry.summary, /inherits the repo-root PRODUCT\.md/);
assert.match(entry.fix, /its own PRODUCT\.md/);
});
it('does not flag a workspace whose own record declares the native platform', () => {
write('apps/mobile/PRODUCT.md', CURRENT_PRODUCT.replace('web', 'ios'));
write('apps/mobile/ios/Podfile', '');
const { findings } = sweep([
{ name: 'mobile', path: 'apps/mobile', productStatus: 'child', productPath: 'apps/mobile/PRODUCT.md', designStatus: 'missing' },
]);
assert.deepEqual(ids(findings), []);
});
it('reports inheritance as information, not as a defect to fix', () => {
write('PRODUCT.md', CURRENT_PRODUCT);
const { findings, workspaces } = sweep([
{ name: 'web', path: 'apps/web', productStatus: 'inherited', productPath: 'PRODUCT.md', designStatus: 'missing' },
]);
assert.deepEqual(ids(findings), ['workspace-context-inherited']);
assert.match(findings[0].summary, /is not something this check can tell/);
assert.equal(workspaces[0].platform, 'web');
});
it('returns an empty sweep with no candidates', () => {
assert.deepEqual(checkWorkspaces({ repoRoot: scratch, candidates: [] }), { findings: [], workspaces: [] });
});
});
// ─── the CLI ───────────────────────────────────────────────────────────────
describe('doctor CLI', () => {
function run(args = [], cwd = scratch) {
return spawnSync(process.execPath, [DOCTOR_PATH, ...args], {
cwd,
encoding: 'utf8',
env: { ...process.env, IMPECCABLE_NO_UPDATE_CHECK: '1' },
});
}
it('reports a clean project and exits zero', () => {
write('PRODUCT.md', CURRENT_PRODUCT);
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /No drift found/);
});
it('groups findings by severity in JSON mode', () => {
write('PRODUCT.md', '# Product\n\n## Register\n\nbrand\n\n## Users\nDesigners.\n');
const res = run(['--json']);
assert.equal(res.status, 0, res.stderr);
const report = JSON.parse(res.stdout);
assert.ok(report.findings.some((entry) => entry.id === 'product-deprecated-register'));
assert.ok(report.findings.some((entry) => entry.id === 'product-schema-legacy'));
for (const entry of report.findings) {
assert.ok(['auto', 'mention', 'route'].includes(entry.severity), entry.severity);
assert.ok(entry.summary && entry.fix, entry.id);
}
assert.equal(report.ruleRegistryAvailable, true);
});
it('applies only the automatic migrations under --fix', () => {
write('PRODUCT.md', CURRENT_PRODUCT.replace('<!-- impeccable:product-schema 1 -->\n\n', ''));
write('DESIGN.json', JSON.stringify({ schemaVersion: 2 }));
const res = run(['--fix']);
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /Moved DESIGN\.json to \.impeccable\/design\.json/);
assert.match(res.stdout, /Stamped PRODUCT\.md/);
assert.ok(fs.existsSync(path.join(scratch, '.impeccable', 'design.json')));
assert.ok(!fs.existsSync(path.join(scratch, 'DESIGN.json')));
assert.match(fs.readFileSync(path.join(scratch, 'PRODUCT.md'), 'utf8'), /impeccable:product-schema 1/);
});
it('leaves a legacy sidecar alone when the canonical one already exists', () => {
write('PRODUCT.md', CURRENT_PRODUCT);
write('DESIGN.json', JSON.stringify({ schemaVersion: 2, title: 'legacy' }));
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2, title: 'current' }));
const res = run(['--fix', '--json']);
assert.equal(res.status, 0, res.stderr);
const report = JSON.parse(res.stdout);
assert.equal(report.fixes.applied.length, 0);
assert.equal(
JSON.parse(fs.readFileSync(path.join(scratch, '.impeccable', 'design.json'), 'utf8')).title,
'current',
);
assert.ok(fs.existsSync(path.join(scratch, 'DESIGN.json')));
});
it('does not stamp a PRODUCT.md that init should rewrite instead', () => {
write('PRODUCT.md', '# Product\n\n## Users\nDesigners.\n');
const res = run(['--fix']);
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(fs.readFileSync(path.join(scratch, 'PRODUCT.md'), 'utf8'), /product-schema/);
assert.match(res.stdout, /product-schema-legacy/);
});
it('lists workspaces and their context resolution in a monorepo', () => {
write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }));
write('PRODUCT.md', CURRENT_PRODUCT);
write('apps/web/package.json', JSON.stringify({ name: 'web' }));
write('apps/mobile/package.json', JSON.stringify({ name: 'mobile', dependencies: { 'react-native': '0.74' } }));
write('apps/mobile/ios/Podfile', '');
const res = run(['--json']);
assert.equal(res.status, 0, res.stderr);
const report = JSON.parse(res.stdout);
assert.equal(report.isMonorepo, true);
assert.deepEqual(report.workspaces.map((entry) => entry.path).sort(), ['apps/mobile', 'apps/web']);
assert.ok(report.findings.some((entry) => entry.id === 'workspace-platform-native-evidence'));
});
it('prints usage for --help without running any check', () => {
const res = run(['--help']);
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /Usage: node doctor\.mjs/);
assert.doesNotMatch(res.stdout, /No drift found/);
});
it('rejects a malformed target argument', () => {
const res = run(['--target']);
assert.notEqual(res.status, 0);
});
});
+511
View File
@@ -0,0 +1,511 @@
/**
* Tests for staleness detection of Impeccable's own project artifacts.
* Run with: node --test tests/staleness.test.mjs
*
* The checks are pure or near-pure, so most cases assert on the returned
* finding set. The CLI cases at the bottom cover the one thing the units
* cannot: that context.mjs emits a single CONTEXT_STALE directive at boot and
* respects its throttle and its opt-outs.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import { spawnSync } from 'node:child_process';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import {
PRODUCT_SCHEMA_VERSION,
productStampLine,
readProductSchemaVersion,
stampProductSchema,
} from '../skill/scripts/lib/artifact-schema.mjs';
import {
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from '../skill/scripts/lib/staleness.mjs';
import {
buildStalenessDirective,
filterFreshFindings,
stalenessCheckDisabled,
} from '../skill/scripts/lib/staleness-notice.mjs';
const SCRIPT_PATH = path.join(
path.dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'scripts', 'context.mjs',
);
let scratch;
let savedCacheEnv;
beforeEach(() => {
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stale-'));
savedCacheEnv = process.env.IMPECCABLE_STALENESS_CACHE;
process.env.IMPECCABLE_STALENESS_CACHE = path.join(scratch, 'notice.json');
});
afterEach(() => {
if (savedCacheEnv === undefined) delete process.env.IMPECCABLE_STALENESS_CACHE;
else process.env.IMPECCABLE_STALENESS_CACHE = savedCacheEnv;
fs.rmSync(scratch, { recursive: true, force: true });
});
function write(rel, body) {
const abs = path.join(scratch, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
return abs;
}
function ids(findings) {
return findings.map((entry) => entry.id);
}
const CURRENT_PRODUCT = [
'# Product',
'',
productStampLine(),
'',
'## Platform',
'',
'web',
'',
'## Positioning',
'The only one that does the thing.',
'',
].join('\n');
// ─── the PRODUCT.md stamp ──────────────────────────────────────────────────
describe('product schema stamp', () => {
it('reads a stamped version and returns null when unstamped', () => {
assert.equal(readProductSchemaVersion(CURRENT_PRODUCT), PRODUCT_SCHEMA_VERSION);
assert.equal(readProductSchemaVersion('# Product\n\n## Users\nx\n'), null);
assert.equal(readProductSchemaVersion(''), null);
assert.equal(readProductSchemaVersion(null), null);
});
it('places a new stamp under the leading heading', () => {
const stamped = stampProductSchema('# Product\n\n## Users\nDesigners.\n');
assert.equal(stamped.split('\n')[0], '# Product');
assert.equal(readProductSchemaVersion(stamped), PRODUCT_SCHEMA_VERSION);
assert.match(stamped, /## Users\nDesigners\./);
});
it('stamps a body with no heading at the top', () => {
const stamped = stampProductSchema('## Users\nDesigners.\n');
assert.equal(stamped.split('\n')[0], productStampLine());
assert.equal(readProductSchemaVersion(stamped), PRODUCT_SCHEMA_VERSION);
});
it('is idempotent and updates an older stamp in place', () => {
const once = stampProductSchema('# Product\n\n## Users\nx\n');
assert.equal(stampProductSchema(once), once);
const upgraded = stampProductSchema('# Product\n\n<!-- impeccable:product-schema 0 -->\n\n## Users\nx\n');
assert.equal(readProductSchemaVersion(upgraded), PRODUCT_SCHEMA_VERSION);
assert.equal((upgraded.match(/impeccable:product-schema/g) || []).length, 1);
});
});
// ─── PRODUCT.md ────────────────────────────────────────────────────────────
describe('checkProduct', () => {
it('flags a deprecated Register section and binds the agent to ignore it', () => {
const findings = checkProduct('# Product\n\n## Register\n\nbrand\n\n## Positioning\nx\n');
assert.deepEqual(ids(findings), ['product-deprecated-register']);
assert.match(findings[0].summary, /visitor modes/);
assert.match(findings[0].fix, /Treat `## Register` as absent/);
assert.equal(findings[0].severity, 'mention');
});
it('flags an unstamped file carrying none of the current sections', () => {
const findings = checkProduct('# Product\n\n## Users\nDesigners.\n');
assert.deepEqual(ids(findings), ['product-schema-legacy']);
assert.equal(findings[0].severity, 'route');
assert.match(findings[0].fix, /Offer `init`/);
});
it('trusts a current stamp over the section heuristic', () => {
const stamped = stampProductSchema('# Product\n\n## Users\nDesigners.\n');
assert.deepEqual(checkProduct(stamped), []);
});
it('accepts an unstamped file that has the current sections', () => {
assert.deepEqual(checkProduct('# Product\n\n## Product Principles\n- One\n'), []);
});
it('flags a stamp older than the current schema', () => {
const findings = checkProduct('# Product\n\n<!-- impeccable:product-schema 0 -->\n\n## Users\nx\n');
assert.deepEqual(ids(findings), ['product-schema-outdated']);
});
it('reports nothing when there is no PRODUCT.md', () => {
assert.deepEqual(checkProduct(null), []);
});
});
// ─── native platform evidence ──────────────────────────────────────────────
describe('checkNativePlatformEvidence', () => {
it('flags a web platform on a project carrying native build files', () => {
write('ios/Podfile', "platform :ios, '15.0'\n");
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT, productPath: 'PRODUCT.md',
});
assert.deepEqual(ids(findings), ['platform-native-evidence']);
assert.match(findings[0].summary, /ios\/Podfile/);
assert.match(findings[0].fix, /`ios`/);
});
it('reads react-native and expo out of package.json', () => {
write('package.json', JSON.stringify({ devDependencies: { expo: '51' } }));
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT,
});
assert.deepEqual(ids(findings), ['platform-native-evidence']);
assert.match(findings[0].fix, /`adaptive`/);
});
it('suggests adaptive when both native targets are present', () => {
write('ios/Podfile', '');
write('android/build.gradle', '');
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT,
});
assert.match(findings[0].fix, /`adaptive`/);
});
it('says so when the platform field is missing rather than web', () => {
write('pubspec.yaml', 'name: app\n');
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: null, product: '# Product\n\n## Users\nx\n',
});
assert.match(findings[0].summary, /no `## Platform` section/);
});
it('stays silent when the platform is already native', () => {
write('ios/Podfile', '');
for (const platform of ['ios', 'android', 'adaptive']) {
assert.deepEqual(
checkNativePlatformEvidence({ projectRoot: scratch, platform, product: CURRENT_PRODUCT }),
[],
platform,
);
}
});
it('stays silent on an ordinary web project', () => {
write('package.json', JSON.stringify({ dependencies: { react: '19' } }));
assert.deepEqual(
checkNativePlatformEvidence({ projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT }),
[],
);
});
});
// ─── design.json sidecar ───────────────────────────────────────────────────
describe('checkDesignSidecar', () => {
function candidates() {
return designSidecarCandidatesFor(scratch, scratch);
}
it('reports nothing when no sidecar exists anywhere', () => {
assert.deepEqual(checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch }), []);
});
it('accepts a current sidecar in the canonical location', () => {
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2 }));
assert.deepEqual(checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch }), []);
});
it('flags an outdated schema version as a routable repair', () => {
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 1 }));
const findings = checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch });
assert.deepEqual(ids(findings), ['design-sidecar-schema-outdated']);
assert.equal(findings[0].severity, 'route');
assert.match(findings[0].fix, /Offer `document`/);
});
it('treats a missing schemaVersion as an outdated sidecar', () => {
write('.impeccable/design.json', JSON.stringify({ title: 'x' }));
assert.deepEqual(
ids(checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch })),
['design-sidecar-schema-outdated'],
);
});
it('flags a retired location as an automatic migration', () => {
write('DESIGN.json', JSON.stringify({ schemaVersion: 2 }));
const findings = checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch });
assert.deepEqual(ids(findings), ['design-sidecar-legacy-path']);
assert.equal(findings[0].severity, 'auto');
assert.match(findings[0].fix, /\.impeccable\/design\.json/);
});
it('flags a sidecar older than the DESIGN.md it extends', () => {
const sidecar = write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2 }));
const design = write('DESIGN.md', '---\nname: X\n---\n');
const old = new Date(Date.now() - 60_000);
fs.utimesSync(sidecar, old, old);
const findings = checkDesignSidecar({
designPath: design, sidecarCandidates: candidates(), projectRoot: scratch,
});
assert.deepEqual(ids(findings), ['design-sidecar-stale']);
assert.equal(findings[0].severity, 'mention');
});
it('does not flag a sidecar newer than DESIGN.md', () => {
const design = write('DESIGN.md', '---\nname: X\n---\n');
const sidecar = write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2 }));
const old = new Date(Date.now() - 60_000);
fs.utimesSync(design, old, old);
fs.utimesSync(sidecar, new Date(), new Date());
assert.deepEqual(
checkDesignSidecar({ designPath: design, sidecarCandidates: candidates(), projectRoot: scratch }),
[],
);
});
});
// ─── config.json ───────────────────────────────────────────────────────────
describe('checkConfig', () => {
it('accepts a config using only recognized keys', () => {
write('.impeccable/config.json', JSON.stringify({
updateCheck: true,
projectRoots: ['apps/*'],
hook: { enabled: true, consent: 'accepted' },
detector: { ignoreRules: ['side-tab'], designSystem: { enabled: false } },
}));
assert.deepEqual(checkConfig({ projectRoot: scratch, repoRoot: scratch }), []);
});
it('flags unrecognized top-level keys', () => {
write('.impeccable/config.json', JSON.stringify({ updateChek: true }));
const findings = checkConfig({ projectRoot: scratch, repoRoot: scratch });
assert.deepEqual(ids(findings), ['config-unknown-keys']);
assert.match(findings[0].summary, /`updateChek`/);
});
it('flags unrecognized detector keys, the singular-typo case', () => {
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRule: ['side-tab'] } }));
const findings = checkConfig({ projectRoot: scratch, repoRoot: scratch });
assert.deepEqual(ids(findings), ['config-unknown-detector-keys']);
assert.match(findings[0].summary, /`ignoreRule`/);
});
it('checks the local config too', () => {
write('.impeccable/config.local.json', JSON.stringify({ bogus: 1 }));
const findings = checkConfig({ projectRoot: scratch, repoRoot: scratch });
assert.deepEqual(ids(findings), ['config-unknown-keys']);
assert.match(findings[0].path, /config\.local\.json$/);
});
it('does not check the hook subtree, which has many writers', () => {
write('.impeccable/config.json', JSON.stringify({ hook: { somethingRuntime: true } }));
assert.deepEqual(checkConfig({ projectRoot: scratch, repoRoot: scratch }), []);
});
it('ignores a malformed config rather than reporting it as drift', () => {
write('.impeccable/config.json', '{ not json');
assert.deepEqual(checkConfig({ projectRoot: scratch, repoRoot: scratch }), []);
});
});
// ─── surface briefs ────────────────────────────────────────────────────────
describe('checkSurfaceBriefs', () => {
it('flags a brief whose primary target no longer exists', () => {
const findings = checkSurfaceBriefs({
projectRoot: scratch,
candidates: [{ path: '.impeccable/surfaces/pricing.md', primaryTarget: 'src/Pricing.tsx' }],
});
assert.deepEqual(ids(findings), ['surface-brief-orphaned']);
assert.match(findings[0].summary, /src\/Pricing\.tsx/);
});
it('accepts a brief whose target is still on disk', () => {
write('src/Pricing.tsx', 'export default null;\n');
assert.deepEqual(checkSurfaceBriefs({
projectRoot: scratch,
candidates: [{ path: '.impeccable/surfaces/pricing.md', primaryTarget: 'src/Pricing.tsx' }],
}), []);
});
it('skips route and URL targets, which have no file to check', () => {
assert.deepEqual(checkSurfaceBriefs({
projectRoot: scratch,
candidates: [
{ path: 'a.md', primaryTarget: 'route:/pricing' },
{ path: 'b.md', primaryTarget: 'https://example.com/pricing' },
],
}), []);
});
});
// ─── projectRoots ──────────────────────────────────────────────────────────
describe('checkProjectRoots', () => {
it('flags patterns that match no directory', () => {
const findings = checkProjectRoots({ patterns: ['apps/*'], candidates: [] });
assert.deepEqual(ids(findings), ['config-project-roots-match-nothing']);
assert.match(findings[0].summary, /`apps\/\*`/);
});
it('stays quiet when candidates were discovered', () => {
assert.deepEqual(checkProjectRoots({ patterns: ['apps/*'], candidates: [{ path: 'apps/web' }] }), []);
});
it('stays quiet when only negations are declared', () => {
assert.deepEqual(checkProjectRoots({ patterns: ['!apps/legacy'], candidates: [] }), []);
});
});
// ─── notice throttling ─────────────────────────────────────────────────────
describe('staleness notices', () => {
const mention = { id: 'a', severity: 'mention', summary: 's', fix: 'f', artifact: 'x', path: null };
const auto = { id: 'b', severity: 'auto', summary: 's', fix: 'f', artifact: 'x', path: null };
it('passes a finding through once, then suppresses it', () => {
assert.deepEqual(ids(filterFreshFindings([mention], { projectRoot: scratch })), ['a']);
assert.deepEqual(filterFreshFindings([mention], { projectRoot: scratch }), []);
});
it('re-surfaces a finding once the renotify window has passed', () => {
const now = Date.now();
filterFreshFindings([mention], { projectRoot: scratch, now });
const later = now + 8 * 24 * 60 * 60 * 1000;
assert.deepEqual(ids(filterFreshFindings([mention], { projectRoot: scratch, now: later })), ['a']);
});
it('never throttles auto findings, which the agent needs every session', () => {
assert.deepEqual(ids(filterFreshFindings([auto], { projectRoot: scratch })), ['b']);
assert.deepEqual(ids(filterFreshFindings([auto], { projectRoot: scratch })), ['b']);
});
it('keys throttling per project', () => {
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stale-other-'));
try {
filterFreshFindings([mention], { projectRoot: scratch });
assert.deepEqual(ids(filterFreshFindings([mention], { projectRoot: other })), ['a']);
} finally {
fs.rmSync(other, { recursive: true, force: true });
}
});
it('forgets a finding that stopped firing so a recurrence is reported again', () => {
filterFreshFindings([mention, { ...mention, id: 'c' }], { projectRoot: scratch });
filterFreshFindings([mention], { projectRoot: scratch });
assert.deepEqual(ids(filterFreshFindings([{ ...mention, id: 'c' }], { projectRoot: scratch })), ['c']);
});
it('renders one directive for the whole set, or nothing', () => {
assert.equal(buildStalenessDirective([]), null);
const directive = buildStalenessDirective([mention, auto]);
assert.equal((directive.match(/CONTEXT_STALE:/g) || []).length, 1);
assert.match(directive, /Do not stop, reorder, or expand the requested task/);
assert.match(directive, /Surface the reportable findings once/);
});
it('omits the user-facing instruction when every finding is automatic', () => {
assert.doesNotMatch(buildStalenessDirective([auto]), /Surface the reportable findings/);
});
it('honors stalenessCheck false in config, local overriding shared', () => {
assert.equal(stalenessCheckDisabled([scratch]), false);
write('.impeccable/config.json', JSON.stringify({ stalenessCheck: false }));
assert.equal(stalenessCheckDisabled([scratch]), true);
write('.impeccable/config.local.json', JSON.stringify({ stalenessCheck: true }));
assert.equal(stalenessCheckDisabled([scratch]), false);
});
it('honors the environment opt-out', () => {
process.env.IMPECCABLE_NO_STALENESS_CHECK = '1';
try {
assert.equal(stalenessCheckDisabled([scratch]), true);
} finally {
delete process.env.IMPECCABLE_NO_STALENESS_CHECK;
}
});
});
// ─── boot integration ──────────────────────────────────────────────────────
describe('context.mjs CONTEXT_STALE directive', () => {
function run(env = {}) {
return spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: {
...process.env,
IMPECCABLE_NO_UPDATE_CHECK: '1',
IMPECCABLE_STALENESS_CACHE: path.join(scratch, 'notice.json'),
...env,
},
});
}
it('emits one directive at boot and throttles the next run', () => {
write('PRODUCT.md', '# Product\n\n## Register\n\nbrand\n\n## Users\nDesigners.\n');
const first = run();
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /CONTEXT_STALE:/);
assert.match(first.stdout, /product-deprecated-register/);
assert.equal((first.stdout.match(/CONTEXT_STALE:/g) || []).length, 1);
// Context itself still leads; staleness never replaces it.
assert.match(first.stdout, /^# PRODUCT\.md/);
const second = run();
assert.equal(second.status, 0, second.stderr);
assert.doesNotMatch(second.stdout, /CONTEXT_STALE:/);
});
it('says nothing on a current project', () => {
write('PRODUCT.md', CURRENT_PRODUCT);
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /CONTEXT_STALE:/);
});
it('respects IMPECCABLE_NO_STALENESS_CHECK', () => {
write('PRODUCT.md', '# Product\n\n## Register\n\nbrand\n');
const res = run({ IMPECCABLE_NO_STALENESS_CHECK: '1' });
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /CONTEXT_STALE:/);
});
it('checks sidecar and config drift with no PRODUCT.md present', () => {
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 1 }));
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRule: [] } }));
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /NO_PRODUCT_MD:/);
assert.match(res.stdout, /design-sidecar-schema-outdated/);
assert.match(res.stdout, /config-unknown-detector-keys/);
});
it('leaves the native-platform question to init when no PRODUCT.md exists', () => {
write('ios/Podfile', '');
write('src/app.css', ':root { --a: 1; --b: 2; --c: 3; }\n');
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /platform-native-evidence/);
});
it('flags projectRoots globs that match nothing in a monorepo', () => {
write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }));
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['services/*'] }));
write('PRODUCT.md', CURRENT_PRODUCT);
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /config-project-roots-match-nothing/);
});
});