From 32ec505fb2994ea20998415e44fbae47587b9f8b Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 10:23:50 +0900 Subject: [PATCH 01/10] docs: design per-rule evidence in the JSON report --- .../2026-08-03-json-rule-evidence-design.md | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md diff --git a/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md b/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md new file mode 100644 index 000000000..4a8782c00 --- /dev/null +++ b/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md @@ -0,0 +1,107 @@ +# JSON rule evidence: telling "found nothing" from "never ran" — design + +**Date:** 2026-08-03 +**Status:** approved +**Origin:** the first of four follow-ups recorded in `2026-07-31-score-honesty-design.md`, from a field +measurement on a real SvelteKit app. + +## The problem + +`--reporter json` cannot answer "did this rule run?". + +`issues` is filtered to penalized results, so a rule that found nothing contributes no entry. `summary` is +a project-wide count of severities, so `passed` cannot be attributed to a rule. A rule reporting zero +therefore has two indistinguishable meanings — every declaration matched and passed, or nothing matched at +all — and zero is the output nobody thinks to question. + +This is not hypothetical. A field test configured `architecture/unit-entry-file`, saw nothing, and could +not tell whether the tree conformed or the globs were dead. Proving the rule had executed required +planting a deliberately non-conforming unit. The same probe was needed for two sibling rules. + +The console reporter does not have this gap — it lists every passing result under `Passed (N)`. **The gap +is specific to the JSON channel**, which is what the field test used and what CI uses. + +## Design + +### The key insight: counting results is not enough + +Counting only the rules that produced results leaves the two cases identical, because both produce none. +What separates them is **enumerating the rules that were selected**, so a rule that ran and stayed silent +still gets an entry: + +| Output | Meaning | +| -------------------------- | ---------------------------------------------------------------------- | +| key present, `findings: 0` | selected, ran, reported nothing | +| key absent | not selected — `--ignore`, `--rules`, `--category`, or `off` in config | + +Presence is the answer; the counts are the detail. + +### Shape + +`JsonReport` gains a top-level `rules` field: + +```jsonc +"rules": { + "architecture/unit-entry-file": { "findings": 0, "passed": 12 }, + "seo/single-h1": { "findings": 9, "passed": 342 } +} +``` + +**Not inside `summary`.** `Summary` is shared with the console reporter, the markdown reporter, the CLI +and the Vite plugin; a per-rule map would grow a type four consumers read and none of them want. The +follow-up that recorded this item said "in the `summary`" before that coupling was checked. + +### Where the selected list comes from + +`buildJsonReport` and `formatJsonReport` take an optional fourth parameter: the selected rule ids. Both +callers already compute them — the CLI holds `selectRules(allRules, config)` in a variable +(`packages/cli/src/index.ts`), and the Vite plugin calls it inline as an argument to `runRules` +(`packages/vite/src/analyze.ts`), so that call needs hoisting to a local. + +Deriving the list inside the reporter instead was rejected: `selectRules` needs the full rule registry, so +the reporter would import `allRules` and stop being a function of `Result[]`. + +Omitting the parameter keeps today's behaviour — entries only for rules that produced results — so the +change is additive for any external caller. + +### What each entry counts + +- **`passed`** — results this rule contributed that were not penalized. Available nowhere else: `issues` + omits them and `summary.passed` is project-wide. This is the field that closes the gap. +- **`findings`** — penalized results. **Derivable** by scanning `routes[].issues[]` and `siteIssues[]` for + the id, and included anyway: the point of this change is to make "did it run and find nothing" a local + question, and forcing a full scan to answer the second half would defeat it. Recorded as deliberate + redundancy so it is not read as an oversight. + +No severity breakdown. Every issue already carries `id` and `severity`, so that grouping is both derivable +and derivable _locally_ — unlike `passed`. + +### Counts describe the report, not the run + +The CLI applies baseline, suppression and `--diff` filtering before the reporter sees the results, so the +counts describe what the report contains. A rule whose findings were all suppressed shows `findings: 0` +while remaining present. + +That is the consistent choice — `rules` and `issues` then count the same things — but it is surprising +enough to state in the guide: presence proves selection, not that the rule found nothing in the tree. + +## Not in scope + +- **Other reporters.** The console already lists passes; this closes the JSON gap only. +- **`routes[].categories[].score`**, the second recorded follow-up. Same diagnosability theme, independent + decision. + +## Testing + +1. **A selected rule with no results appears**, as `{ findings: 0, passed: 0 }`. This is the whole point: + without it the change is a convenience, not a fix. +2. **An unselected rule does not appear** — one disabled through config and one excluded by `--category`, + since those are different code paths into `selectRules`. +3. **Omitting the parameter reproduces today's behaviour**, so an external caller sees no change. +4. **`passed` counts what `issues` cannot** — a rule contributing only passing results has a `rules` entry + with `passed > 0` and no trace anywhere in `issues`. A test that only checks a rule with findings would + pass even if `passed` were computed from the wrong set. +5. **Suppressed findings are not counted**, and the rule still appears — the surprising half of the + filtering decision above. +6. **Both channels pass the list.** The Vite plugin's `selectRules` call is currently inline; a test that + only covers the CLI would not notice it being left that way. From af1ac57f2ceb28ac84621895b729d5fedbe14283 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 10:44:25 +0900 Subject: [PATCH 02/10] docs: plan the JSON rule-evidence implementation --- .../plans/2026-08-03-json-rule-evidence.md | 470 ++++++++++++++++++ 1 file changed, 470 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-03-json-rule-evidence.md diff --git a/docs/superpowers/plans/2026-08-03-json-rule-evidence.md b/docs/superpowers/plans/2026-08-03-json-rule-evidence.md new file mode 100644 index 000000000..a35ca507e --- /dev/null +++ b/docs/superpowers/plans/2026-08-03-json-rule-evidence.md @@ -0,0 +1,470 @@ +# JSON rule evidence Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `--reporter json` able to answer "did this rule run?", which today it cannot — a rule that +found nothing and a rule that was never selected both produce no trace. + +**Architecture:** `JsonReport` gains a top-level `rules` map of rule id → `{ findings, passed }`. +`buildJsonReport`/`formatJsonReport` take an optional fourth parameter carrying the ids of the rules that **ran**, +so a rule that ran and stayed silent still gets an entry — presence is the answer, the counts are detail. +Both channels already compute that list, but neither hands it to the reporter: the CLI computes it in a +different function from the one that formats, and the Vite plugin computes it inside an argument. + +**Tech Stack:** TypeScript (ESM, `.js` import specifiers), vitest, oxlint + oxfmt, Astro Starlight docs. + +**Spec:** `docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md` — read it before Task 1. + +## Global Constraints + +- **Presence is the answer.** A selected rule that produced no results must still appear, as + `{ findings: 0, passed: 0 }`. Counting only rules that produced results leaves the two cases this change + exists to separate looking identical. +- **The fourth parameter is optional**, and omitting it must reproduce today's behaviour exactly — entries + only for rules that produced results. `buildJsonReport` and `formatJsonReport` are public exports of + `@svelte-vitals/core`. +- **Not inside `summary`.** `Summary` is shared with the console reporter, the markdown reporter, the CLI + and the Vite plugin; the new map goes on `JsonReport` instead. +- **`findings` is deliberately redundant** with `routes[].issues[]` + `siteIssues[]`. It is included so + "did it run and find nothing" is a local question. Do not "simplify" it away. +- **No severity breakdown** — every issue already carries `id` and `severity`, so that grouping is + derivable locally, unlike `passed`. +- `packages/core/src/` must contain no `node:` imports, no I/O, and no runtime-specific globals. +- **Comments earn their place only when they say something the code cannot** (`AGENTS.md`): a constraint, a + rejected alternative, a non-local dependency. Prefer one line over three. Why a change was made belongs + in the commit message, not in a file read every time. +- **en/ja docs ship together.** Write real, idiomatic Japanese. +- **Never name other tools** (linters, plugins, competing products) in code, docs, or commits. +- A changeset is required: this adds a field to a public report shape. +- **Verify commands:** per-package `node_modules/.bin/{vitest,tsc,oxlint,oxfmt}`. A full-workspace `pnpm` + command fails in this sandbox for a pre-existing reason unrelated to this work (the `docs` package has no + installed dependencies — CI's `docs` job is that gate). `packages/core` may need `tsup` first so the + other packages resolve its `dist`. +- **Conventional commits, scoped by package:** `feat(core):`, `feat(cli,vite):`, `docs:`. + +## File Structure + +| File | Responsibility | Task | +| --------------------------------------------------------------- | -------------------------------------------------------------------- | ---- | +| `packages/core/src/reporter/json.ts` | `RuleEvidence` type, the `rules` field, the counting | 1 | +| `packages/core/test/json-report.test.ts` | presence, absence, back-compat, `passed`, suppression | 1 | +| `packages/cli/src/index.ts` | carry the ran-rule ids on `AnalyzeResult`, pass them to the reporter | 2 | +| `packages/vite/src/analyze.ts` | hoist `selectRules`, pass the ids | 2 | +| `docs/src/content/docs/guides/(reporting)/reporters.md` + `ja/` | document `rules` | 3 | +| `.changeset/json-rule-evidence.md` | release note | 3 | + +--- + +### Task 1: The `rules` field + +**Files:** + +- Modify: `packages/core/src/reporter/json.ts` — `JsonReport` (around line 22), `buildJsonReport` + (around line 33) and `formatJsonReport` (around line 66) +- Test: `packages/core/test/json-report.test.ts` + +**Interfaces:** + +- Consumes: nothing from earlier tasks. +- Produces: + - `export interface RuleEvidence { findings: number; passed: number }` + - `JsonReport.rules: Record` + - `buildJsonReport(results, config, meta, ruleIds?: readonly string[]): JsonReport` + - `formatJsonReport(results, config, meta, ruleIds?: readonly string[]): string` + + `ruleIds` is **the rules that ran**, not merely those `selectRules` returned — see Task 2, where the two + turn out to differ. Task 2 calls both four-argument forms. + +- [ ] **Step 1: Write the failing tests** + +Append to `packages/core/test/json-report.test.ts`. That file already declares `config` and a `results` +fixture at the top — reuse them where they fit rather than redeclaring. + +```ts +describe('buildJsonReport — per-rule evidence', () => { + const passOnly: Result[] = [ + { + id: 'architecture/unit-entry-file', + category: 'architecture', + severity: 'info', + detection: { presence: 'own', value: 'static' }, + location: 'src/lib/Card/Card.svelte', + message: 'Unit entry file', + recommendation: 'r' + } + ]; + + it('lists a selected rule that produced nothing, which is the whole point', () => { + // Without this entry, "ran and found nothing" and "was never selected" look identical. + const report = buildJsonReport([], config, { version: 'x' }, ['architecture/directory-naming']); + expect(report.rules['architecture/directory-naming']).toEqual({ findings: 0, passed: 0 }); + }); + + it('omits a rule that was not selected', () => { + const report = buildJsonReport(passOnly, config, { version: 'x' }, ['architecture/unit-entry-file']); + expect(Object.hasOwn(report.rules, 'architecture/directory-naming')).toBe(false); + }); + + it('counts a passing result that appears nowhere in issues', () => { + // `passed` is the field that cannot be derived: `issues` is filtered to penalized results. + const report = buildJsonReport(passOnly, config, { version: 'x' }, ['architecture/unit-entry-file']); + expect(report.rules['architecture/unit-entry-file']).toEqual({ findings: 0, passed: 1 }); + expect(report.routes.flatMap((r) => r.issues)).toHaveLength(0); + expect(report.siteIssues).toHaveLength(0); + }); + + it('counts findings and passes separately for one rule', () => { + const mixed: Result[] = [ + ...passOnly, + { + id: 'architecture/unit-entry-file', + category: 'architecture', + severity: 'info', + detection: { presence: 'none', value: 'absent' }, + route: 'src/lib/Box', + location: 'src/lib/Box/index.ts', + message: 'missing entry file', + recommendation: 'r' + } + ]; + const report = buildJsonReport(mixed, config, { version: 'x' }, ['architecture/unit-entry-file']); + expect(report.rules['architecture/unit-entry-file']).toEqual({ findings: 1, passed: 1 }); + }); + + it('falls back to the rules that produced results when no list is given', () => { + // Back-compat: an external caller on the three-argument form sees today's information. + const report = buildJsonReport(passOnly, config, { version: 'x' }); + expect(report.rules).toEqual({ 'architecture/unit-entry-file': { findings: 0, passed: 1 } }); + }); + + it('reaches the same shape through formatJsonReport', () => { + const parsed = JSON.parse(formatJsonReport([], config, { version: 'x' }, ['seo/single-h1'])); + expect(parsed.rules).toEqual({ 'seo/single-h1': { findings: 0, passed: 0 } }); + }); +}); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: from `packages/core`, `../../node_modules/.bin/vitest run json-report` +Expected: FAIL — `rules` does not exist on the report, and the fourth argument is a TypeScript error. + +- [ ] **Step 3: Implement** + +In `packages/core/src/reporter/json.ts`, add the type beside `JsonIssue`: + +```ts +/** Per-rule counts. A rule present with `findings: 0` ran and reported nothing; an absent rule was not selected. */ +export interface RuleEvidence { + findings: number; + passed: number; +} +``` + +Add the field to `JsonReport`, after `summary`: + +```ts +rules: Record; +``` + +Add this helper above `buildJsonReport`: + +```ts +function ruleEvidence( + results: Result[], + config: Config, + ruleIds: readonly string[] | undefined +): Record { + const out: Record = {}; + // Seeding from the ran-rule list is what separates "ran and found nothing" from "never selected"; + // seeding from results alone would leave both empty. + for (const id of ruleIds ?? []) out[id] = { findings: 0, passed: 0 }; + for (const r of results) { + const entry = (out[r.id] ??= { findings: 0, passed: 0 }); + if (isPenalized(r.detection, config.treatDynamicAs)) entry.findings += 1; + else entry.passed += 1; + } + return out; +} +``` + +Widen both public signatures and thread the parameter: + +```ts +export function buildJsonReport( + results: Result[], + config: Config, + meta: { version: string }, + ruleIds?: readonly string[] +): JsonReport { +``` + +```ts +export function formatJsonReport( + results: Result[], + config: Config, + meta: { version: string }, + ruleIds?: readonly string[] +): string { + return JSON.stringify(buildJsonReport(results, config, meta, ruleIds), null, 2); +} +``` + +and add `rules` to the returned object: + +```ts +return { version: meta.version, score: health, weights, categories, summary, rules, routes, siteIssues }; +``` + +with `const rules = ruleEvidence(results, config, ruleIds);` alongside the existing `summary` line. + +Export the type from `packages/core/src/index.ts` beside the other reporter types — grep for `JsonReport` +to find that export list. + +- [ ] **Step 4: Run the tests** + +Run: from `packages/core`, `../../node_modules/.bin/vitest run json-report` +Expected: PASS, with the file's pre-existing cases unaffected — `rules` is additive. + +- [ ] **Step 5: Prove the seeding is load-bearing** + +Delete the `for (const id of ruleIds ?? [])` line and re-run. +Expected: the "lists a selected rule that produced nothing" and "reaches the same shape through +formatJsonReport" tests FAIL. Restore, and confirm `git diff` on `json.ts` is empty. + +- [ ] **Step 6: Typecheck, lint, commit** + +```bash +cd packages/core && ../../node_modules/.bin/tsc --noEmit -p tsconfig.json +cd ../.. && node_modules/.bin/oxlint . && node_modules/.bin/oxfmt --check . +git add packages/core +git commit -m "feat(core): report per-rule evidence in the JSON report" +``` + +--- + +### Task 2: Both channels pass the selected ids + +**Files:** + +- Modify: `packages/cli/src/index.ts` — `AnalyzeResult` (around line 151), `analyzeProject`'s return + (around line 195) and the `formatJsonReport` call in `run` (around line 449) +- Modify: `packages/vite/src/analyze.ts` — hoist `selectRules(allRules, config)` out of the `runRules` + argument (around line 75) and pass the ids at the `formatJsonReport` call (around line 100) +- Test: `packages/cli/test/` and `packages/vite/test/` — the existing analyze/report tests + +**Interfaces:** + +- Consumes: the four-argument `formatJsonReport` from Task 1. +- Produces: `AnalyzeResult.ruleIds: string[]` — the ids of the rules that ran, after `--category`. + +- [ ] **Step 1: Write the failing tests** + +The point of this task is that **both** channels wire it, so each needs its own assertion. Add to whichever +existing test file already drives the CLI's json output end to end, and to the Vite plugin's analyze test. +Each must assert that a rule which produced **no results** still appears in `rules` — that is the only +assertion that fails when the list is not passed. + +For the Vite side, `analyze` returns an object carrying `jsonReport` as a string, so parse it: + +```ts +const parsed = JSON.parse(result.jsonReport); +// A rule with nothing to say still appears, which only holds if analyze passed the selected ids. +expect(Object.hasOwn(parsed.rules, 'seo/single-h1')).toBe(true); +``` + +For the CLI side, run the json reporter path and assert the same shape. Pick a rule that is on by default +and produces nothing for the fixture, so the assertion is about presence rather than about findings. + +- [ ] **Step 2: Run them to verify they fail** + +Run the two suites. Expected: FAIL — without the fourth argument, a rule that produced nothing has no entry. + +- [ ] **Step 3: Carry the ran-rule ids out of `analyzeProject`** + +The CLI computes its rule set in `analyzeProject` but formats the report in `run` — two separate exported +functions, so the list is not in scope where it is needed. It also is not what `selectRules` returns: + +```ts +const selected = selectRules(allRules, config); +const rules = opts.categories ? selected.filter((r) => opts.categories!.includes(r.category)) : selected; +``` + +`--category` narrows the set **after** selection, so `rules` is what ran and `selected` is not. Passing +`selected` would list rules excluded by `--category` as though they had run — the exact confusion this +change exists to remove. + +Recomputing in `run` is not an option either: `opts.categories` belongs to `analyzeProject`'s options and +is not carried on its result. So `AnalyzeResult` gains the ids: + +```ts +export interface AnalyzeResult { + results: Result[]; + config: Config; + version: string; + /** Ids of the rules that ran, after `--category` narrowing. The JSON report lists these so a rule that found nothing stays distinguishable from one that was never selected. */ + ruleIds: string[]; + warnings: string[]; +} +``` + +and `analyzeProject` returns `ruleIds: rules.map((r) => r.id)` — `rules`, not `selected`. + +`AnalyzeResult` is exported, so adding a required field breaks any external caller constructing one as a +literal. Grep the repo for object literals assigned to that type before committing; if the only producers +are `analyzeProject` itself and test fixtures, a required field is correct and stronger than an optional +one. + +- [ ] **Step 4: Wire both reporters** + +In `run`, pass the carried ids at the `formatJsonReport` call: + +```ts +log(formatJsonReport(results, config, { version }, analysis.ruleIds)); +``` + +Use whatever name the local `AnalyzeResult` binding already has rather than introducing a second one. + +In `packages/vite/src/analyze.ts`, `selectRules(allRules, config)` is an inline argument to `runRules`. +Hoist it and pass the ids: + +```ts +const selected = selectRules(allRules, config); +``` + +```ts +const jsonReport = formatJsonReport( + results, + config, + { version: readPackageVersion() }, + selected.map((r) => r.id) +); +``` + +The Vite plugin has **no** `--category` equivalent, so `selected` is what ran there. That asymmetry with +the CLI is why the two channels wire this differently, and why testing only one of them would miss it. + +- [ ] **Step 5: Run both suites plus core** + +```bash +cd packages/core && ../../node_modules/.bin/tsup && ../../node_modules/.bin/vitest run +cd ../cli && ../../node_modules/.bin/vitest run +cd ../vite && ../../node_modules/.bin/vitest run +``` + +- [ ] **Step 6: Typecheck, lint, commit** + +```bash +for p in core cli vite; do (cd packages/$p && ../../node_modules/.bin/tsc --noEmit -p tsconfig.json) || echo "FAIL $p"; done +node_modules/.bin/oxlint . && node_modules/.bin/oxfmt --check . +git add packages/cli packages/vite +git commit -m "feat(cli,vite): pass the selected rule ids to the JSON reporter" +``` + +--- + +### Task 3: Documentation and changeset + +**Files:** + +- Modify: `docs/src/content/docs/guides/(reporting)/reporters.md` +- Modify: `docs/src/content/docs/ja/guides/(reporting)/reporters.md` +- Create: `.changeset/json-rule-evidence.md` + +**Interfaces:** + +- Consumes: the behaviour from Tasks 1-2. +- Produces: nothing. + +- [ ] **Step 1: Add `rules` to the documented shape** + +The guide's `#### Shape` section carries an annotated `jsonc` example. Add the field after `summary`: + +```jsonc + "rules": { + // Every rule that ran. An entry with `findings: 0` ran and reported nothing; + // a rule missing from this map was not selected (`--ignore`, `--rules`, `--category`, or `off`). + "architecture/unit-entry-file": { "findings": 0, "passed": 12 } + }, +``` + +Then add a paragraph below the two field-name warnings already there: + +```md +`rules` answers a question the rest of the report cannot: **whether a rule ran at all.** `issues` lists +only failing findings, so a rule that found nothing leaves no trace there — and a rule you disabled leaves +the same absence. Look it up in `rules` instead: present means it ran, missing means it was not selected. + +The counts describe the report, not the tree. Baseline, suppression and `--diff` filtering are applied +before the report is built, so a rule whose findings were all suppressed shows `findings: 0` while +remaining present. +``` + +- [ ] **Step 2: Mirror both edits in Japanese** + +Apply the equivalent changes at the matching positions in the `ja/` file. Idiomatic Japanese, not a literal +rendering; keep code spans and field names in their original form. The two files must make the same claims. + +- [ ] **Step 3: Add the changeset** + +Create `.changeset/json-rule-evidence.md`, naming every package that ships the shape: + +```md +--- +'@svelte-vitals/core': patch +'svelte-vitals': patch +'@svelte-vitals/vite': patch +--- + +`--reporter json` gains a top-level `rules` map of rule id to `{ findings, passed }`, listing every rule +that ran. + +It answers a question the report could not: `issues` lists only failing findings, so a rule that found +nothing left no trace — indistinguishable from a rule that was never selected. A rule present in `rules` +ran; a rule missing from it was not selected. `passed` is also unavailable elsewhere, since `summary` is +project-wide. + +The counts describe the report rather than the tree: baseline, suppression and `--diff` filtering are +applied first, so a rule whose findings were all suppressed shows `findings: 0` and stays present. +``` + +- [ ] **Step 4: Verify** + +```bash +node_modules/.bin/oxfmt --write docs .changeset +node_modules/.bin/oxfmt --check . +(cd packages/cli && ../../node_modules/.bin/vitest run docs-links) +``` + +The docs site build cannot run in this sandbox; check the two `.md` files by eye for valid frontmatter and +balanced fences, and say in your report that the build was not run — CI's `docs` job is the gate. + +- [ ] **Step 5: Commit** + +```bash +git add docs .changeset +git commit -m "docs: document the JSON report's rules map" +``` + +--- + +## Self-Review + +**Spec coverage.** The shape and its placement outside `summary` → Task 1 Step 3. The selected-list +parameter and its optionality → Task 1 (signature) and Task 2 (both callers). `findings`/`passed` and the +rejected severity breakdown → Task 1's helper. The filtering semantics → documented in Task 3, tested in +Task 1's mixed-results case. The spec's six test items map as: 1 → "lists a selected rule that produced +nothing"; 2 → "omits a rule that was not selected"; 3 → the back-compat case; 4 → "counts a passing result +that appears nowhere in issues"; 6 → Task 2's two assertions. + +**One spec test item is not covered by a task, deliberately.** Spec item 5 asks that suppressed findings go +uncounted while the rule stays present. Suppression is applied in `packages/cli` before the reporter is +called, so at the reporter's own level there is nothing to test — a suppressed result simply is not in +`results`. Testing it means a CLI-level fixture with a suppression directive, which belongs with Task 2's +CLI assertion. **Fold it there**: assert that a rule whose only finding is suppressed appears with +`findings: 0`. Without that, the claim in the docs is unverified. + +**Type consistency.** `RuleEvidence` is the name in the type, the helper's return, and the tests. +`ruleIds?: readonly string[]` is the parameter in both public signatures. The CLI passes +`analysis.ruleIds` (carried on `AnalyzeResult`); the Vite plugin passes `selected.map((r) => r.id)` from +its hoisted local. From b5fcee781ffdbf500f63dd1273afe415ff4d6e4a Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 10:54:01 +0900 Subject: [PATCH 03/10] feat(core): report per-rule evidence in the JSON report --- packages/core/src/index.ts | 2 +- packages/core/src/reporter/json.ts | 43 ++++++++++++++++-- packages/core/test/html-report.test.ts | 1 + packages/core/test/json-report.test.ts | 62 ++++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a6da1b529..3ee44fd3e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -152,7 +152,7 @@ export { formatConsoleReport } from './reporter/console.js'; export { noColorPalette, scoreColor } from './reporter/palette.js'; export type { Palette } from './reporter/palette.js'; export { buildJsonReport, formatJsonReport } from './reporter/json.js'; -export type { JsonReport } from './reporter/json.js'; +export type { JsonReport, RuleEvidence } from './reporter/json.js'; export { formatAgentReport } from './reporter/agent.js'; export { formatSarifReport } from './reporter/sarif.js'; export { formatGithubReport } from './reporter/github.js'; diff --git a/packages/core/src/reporter/json.ts b/packages/core/src/reporter/json.ts index 9c0db8c17..26becfd24 100644 --- a/packages/core/src/reporter/json.ts +++ b/packages/core/src/reporter/json.ts @@ -19,20 +19,50 @@ function issueOf(result: Result) { type JsonIssue = ReturnType & { severity: ReturnType }; +/** Per-rule counts. A rule present with `findings: 0` ran and reported nothing; an absent rule was not selected. */ +export interface RuleEvidence { + findings: number; + passed: number; +} + export interface JsonReport { version: string; score: number; // combined Health score weights: Partial>; categories: Record; summary: Summary; + rules: Record; routes: Array<{ route: string; score: number; issues: JsonIssue[] }>; siteIssues: JsonIssue[]; } +function ruleEvidence( + results: Result[], + config: Config, + ruleIds: readonly string[] | undefined +): Record { + const out: Record = {}; + // Seeding from the ran-rule list is what separates "ran and found nothing" from "never selected"; + // seeding from results alone would leave both empty. + for (const id of ruleIds ?? []) out[id] = { findings: 0, passed: 0 }; + for (const r of results) { + const entry = (out[r.id] ??= { findings: 0, passed: 0 }); + if (isPenalized(r.detection, config.treatDynamicAs)) entry.findings += 1; + else entry.passed += 1; + } + return out; +} + /** Build the structured JSON report object (design §7). The shape the `json` reporter emits (issue #24). */ -export function buildJsonReport(results: Result[], config: Config, meta: { version: string }): JsonReport { +export function buildJsonReport( + results: Result[], + config: Config, + meta: { version: string }, + ruleIds?: readonly string[] +): JsonReport { const { health, categories: byCat, weights } = computeHealth(results, config); const summary = summarize(results, config); + const rules = ruleEvidence(results, config, ruleIds); const categories = Object.fromEntries( Object.entries(byCat).map(([cat, sr]) => [cat, { score: sr.score, scoreModel: sr.scoreModel }]) @@ -59,10 +89,15 @@ export function buildJsonReport(results: Result[], config: Config, meta: { versi .filter((r) => r.route === undefined && isPenalized(r.detection, config.treatDynamicAs)) .map((r) => ({ ...issueOf(r), severity: effectiveSeverity(r, config) })); - return { version: meta.version, score: health, weights, categories, summary, routes, siteIssues }; + return { version: meta.version, score: health, weights, categories, summary, rules, routes, siteIssues }; } /** Render results as the documented JSON report string (design §7). */ -export function formatJsonReport(results: Result[], config: Config, meta: { version: string }): string { - return JSON.stringify(buildJsonReport(results, config, meta), null, 2); +export function formatJsonReport( + results: Result[], + config: Config, + meta: { version: string }, + ruleIds?: readonly string[] +): string { + return JSON.stringify(buildJsonReport(results, config, meta, ruleIds), null, 2); } diff --git a/packages/core/test/html-report.test.ts b/packages/core/test/html-report.test.ts index 5bc456fe9..0e039d127 100644 --- a/packages/core/test/html-report.test.ts +++ b/packages/core/test/html-report.test.ts @@ -15,6 +15,7 @@ const report: JsonReport = { performance: { score: 68, scoreModel: model() } }, summary: { critical: 1, warning: 2, info: 1, passed: 37, dynamic: 3 }, + rules: {}, routes: [ { route: '/', score: 100, issues: [] }, { diff --git a/packages/core/test/json-report.test.ts b/packages/core/test/json-report.test.ts index 64c668e35..049d66ed5 100644 --- a/packages/core/test/json-report.test.ts +++ b/packages/core/test/json-report.test.ts @@ -83,3 +83,65 @@ describe('formatJsonReport', () => { expect(formatJsonReport(results, config, { version: '9.9.9' })).toBe(JSON.stringify(report, null, 2)); }); }); + +describe('buildJsonReport — per-rule evidence', () => { + const passOnly: Result[] = [ + { + id: 'architecture/unit-entry-file', + category: 'architecture', + severity: 'info', + detection: { presence: 'own', value: 'static' }, + location: 'src/lib/Card/Card.svelte', + message: 'Unit entry file', + recommendation: 'r' + } + ]; + + it('lists a selected rule that produced nothing, which is the whole point', () => { + // Without this entry, "ran and found nothing" and "was never selected" look identical. + const report = buildJsonReport([], config, { version: 'x' }, ['architecture/directory-naming']); + expect(report.rules['architecture/directory-naming']).toEqual({ findings: 0, passed: 0 }); + }); + + it('omits a rule that was not selected', () => { + const report = buildJsonReport(passOnly, config, { version: 'x' }, ['architecture/unit-entry-file']); + expect(Object.hasOwn(report.rules, 'architecture/directory-naming')).toBe(false); + }); + + it('counts a passing result that appears nowhere in issues', () => { + // `passed` is the field that cannot be derived: `issues` is filtered to penalized results. + const report = buildJsonReport(passOnly, config, { version: 'x' }, ['architecture/unit-entry-file']); + expect(report.rules['architecture/unit-entry-file']).toEqual({ findings: 0, passed: 1 }); + expect(report.routes.flatMap((r) => r.issues)).toHaveLength(0); + expect(report.siteIssues).toHaveLength(0); + }); + + it('counts findings and passes separately for one rule', () => { + const mixed: Result[] = [ + ...passOnly, + { + id: 'architecture/unit-entry-file', + category: 'architecture', + severity: 'info', + detection: { presence: 'none', value: 'absent' }, + route: 'src/lib/Box', + location: 'src/lib/Box/index.ts', + message: 'missing entry file', + recommendation: 'r' + } + ]; + const report = buildJsonReport(mixed, config, { version: 'x' }, ['architecture/unit-entry-file']); + expect(report.rules['architecture/unit-entry-file']).toEqual({ findings: 1, passed: 1 }); + }); + + it('falls back to the rules that produced results when no list is given', () => { + // Back-compat: an external caller on the three-argument form sees today's information. + const report = buildJsonReport(passOnly, config, { version: 'x' }); + expect(report.rules).toEqual({ 'architecture/unit-entry-file': { findings: 0, passed: 1 } }); + }); + + it('reaches the same shape through formatJsonReport', () => { + const parsed = JSON.parse(formatJsonReport([], config, { version: 'x' }, ['seo/single-h1'])); + expect(parsed.rules).toEqual({ 'seo/single-h1': { findings: 0, passed: 0 } }); + }); +}); From e8ca01b50aad244e37d7ce6669354256b09c0d1c Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 11:41:03 +0900 Subject: [PATCH 04/10] test(vite): add required rules field to JsonReport test fixtures --- packages/vite/test/app-shell-static.test.ts | 1 + packages/vite/test/ui-dashboard.test.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/vite/test/app-shell-static.test.ts b/packages/vite/test/app-shell-static.test.ts index 334cbff8e..7d0049378 100644 --- a/packages/vite/test/app-shell-static.test.ts +++ b/packages/vite/test/app-shell-static.test.ts @@ -16,6 +16,7 @@ const report: JsonReport = { weights: { seo: 1 }, categories: { seo: { score: 50, scoreModel: { routeAverage: 50, sitePenalty: 0, criticalCap: null } } }, summary: { critical: 1, warning: 0, info: 0, passed: 0, dynamic: 0 } as never, + rules: {}, routes: [ { route: '/blog/hello', diff --git a/packages/vite/test/ui-dashboard.test.ts b/packages/vite/test/ui-dashboard.test.ts index 4413f38bf..53aa1d5ea 100644 --- a/packages/vite/test/ui-dashboard.test.ts +++ b/packages/vite/test/ui-dashboard.test.ts @@ -9,6 +9,7 @@ const baseSnapshot: DashboardSnapshot = { weights: { seo: 1 }, categories: { seo: { score: 80, scoreModel: 'weighted' as never } }, summary: { critical: 0, warning: 0, info: 0, passed: 0, dynamic: 0 } as never, + rules: {}, routes: [ { route: '/a', From 95420b7a6e665bd493b22183b0bc25c1ca39d9b0 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 11:51:09 +0900 Subject: [PATCH 05/10] feat(cli,vite): pass the selected rule ids to the JSON reporter --- packages/cli/src/index.ts | 6 ++++-- packages/cli/test/run-suppressions.test.ts | 18 ++++++++++++++++++ packages/cli/test/run.test.ts | 10 ++++++++++ packages/vite/src/analyze.ts | 10 ++++++++-- packages/vite/test/analyze.test.ts | 9 +++++++++ 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5579a8db7..09876df0d 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -152,6 +152,8 @@ export interface AnalyzeResult { results: Result[]; config: Config; version: string; + /** Ids of the rules that ran, after `--category` narrowing. The JSON report lists these so a rule that found nothing stays distinguishable from one that was never selected. */ + ruleIds: string[]; /** Non-fatal config-file issues (unknown top-level keys, invalid enum values). Empty when no config file or none found. */ warnings: string[]; } @@ -200,7 +202,7 @@ export async function analyzeProject(opts: AnalyzeOptions = {}): Promise r.id), warnings }; } export interface ApplyScopeOptions { @@ -446,7 +448,7 @@ export async function run(opts: RunOptions = {}): Promise { ); } if (reporter === 'json') { - log(formatJsonReport(results, config, { version })); + log(formatJsonReport(results, config, { version }, analysis.ruleIds)); } else if (reporter === 'agent') { log(formatAgentReport(results, config)); } else if (reporter === 'sarif') { diff --git a/packages/cli/test/run-suppressions.test.ts b/packages/cli/test/run-suppressions.test.ts index 318c5a753..c373405ba 100644 --- a/packages/cli/test/run-suppressions.test.ts +++ b/packages/cli/test/run-suppressions.test.ts @@ -174,6 +174,24 @@ describe('run() svelte-vitals-suppressions.json', () => { expect(cap.err.join('\n')).toContain(`svelte-vitals: invalid ${SUPPRESSIONS_FILE}`); }); + it('a fully-suppressed rule still appears in the json report, with findings: 0', async () => { + const dir = makeProjectCopy(); + // /img's only is missing width/height -> the fixture's single performance/image-dimensions + // finding. Suppressing it should zero the count without dropping the rule from `rules` — + // that only holds if the ran-rule ids reach formatJsonReport ahead of suppression removing the result. + writeFileSync( + join(dir, SUPPRESSIONS_FILE), + JSON.stringify({ + version: 1, + suppressions: [{ id: 'performance/image-dimensions', route: '/img', location: 'src/routes/img/+page.svelte' }] + }) + ); + const cap = capture(); + await run({ cwd: dir, log: cap.log, errorLog: cap.errorLog, reporter: 'json', env: CLEAN_ENV }); + const json = JSON.parse(cap.out.join('\n')); + expect(json.rules['performance/image-dimensions']).toEqual({ findings: 0, passed: 0 }); + }); + it('applies after --diff: diff narrows first, suppressions removes what remains', async () => { const dir = makeProjectCopy(); // The "none" route's file has several penalized findings (seo/title-presence, seo/canonical-url, ...); diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts index ac59be480..4c1dd3f42 100644 --- a/packages/cli/test/run.test.ts +++ b/packages/cli/test/run.test.ts @@ -118,6 +118,16 @@ describe('run() reporters and gating', () => { expect(code).toBe(1); // fixture has warnings (og:image, og:title, canonical missing) }); + it('lists a default-on rule that found nothing for this fixture, distinguishing it from one never selected', async () => { + const cap = capture(); + await run({ cwd: fixtureDir, log: cap.log, errorLog: cap.errorLog, reporter: 'json', env: CLEAN_ENV }); + const json = JSON.parse(cap.out.join('\n')); + // security/raw-html has nothing to flag anywhere in the fixture, so this only holds + // if analyzeProject's ran-rule ids reached formatJsonReport. + expect(Object.hasOwn(json.rules, 'security/raw-html')).toBe(true); + expect(json.rules['security/raw-html']).toEqual({ findings: 0, passed: 0 }); + }); + it('disabling a rule via rules:{id:off} removes its findings', async () => { const cap = capture(); await run({ diff --git a/packages/vite/src/analyze.ts b/packages/vite/src/analyze.ts index d24156bcc..e16daf82a 100644 --- a/packages/vite/src/analyze.ts +++ b/packages/vite/src/analyze.ts @@ -70,9 +70,10 @@ export async function analyze( const components = await collectComponentFacts(cwd); const kitModules = await collectKitModuleFacts(cwd, project.kitAliases); const sourceFiles = await collectSourceFiles(cwd); + const selected = selectRules(allRules, config); const results = applyOverrides( applyRuleSeverities( - await runRules(selectRules(allRules, config), { + await runRules(selected, { heads, headings, images, @@ -97,7 +98,12 @@ export async function analyze( `Scanned ${components.length} component(s) under src/ for Correctness/Security/Architecture/Bundle findings.`; const consoleReport = formatConsoleReport(results, config, { mode: 'rendered / plugin' }) + '\n' + coverageNote + '\n'; - const jsonReport = formatJsonReport(results, config, { version: readPackageVersion() }); + const jsonReport = formatJsonReport( + results, + config, + { version: readPackageVersion() }, + selected.map((r) => r.id) + ); return { score, diff --git a/packages/vite/test/analyze.test.ts b/packages/vite/test/analyze.test.ts index 72266e510..1cfa95249 100644 --- a/packages/vite/test/analyze.test.ts +++ b/packages/vite/test/analyze.test.ts @@ -42,6 +42,15 @@ describe('analyze', () => { expect(r.consoleReport).not.toContain('static mode'); }); + it('lists a selected rule in the json report even when it produced no results for this fixture', async () => { + const r = await analyze(pages, cwd, { report: false }); + const parsed = JSON.parse(r.jsonReport); + // Nothing in this fixture triggers security/raw-html, so it has no entries in `results` — + // it appears in `rules` only because analyze passed the selected ids as the seed list. + expect(Object.hasOwn(parsed.rules, 'security/raw-html')).toBe(true); + expect(parsed.rules['security/raw-html']).toEqual({ findings: 0, passed: 0 }); + }); + it('fails when findings meet failOn', async () => { const r = await analyze(pages, cwd, { report: false, failOn: 'critical' }); expect(r.failed).toBe(true); From 44282ff9c9aa61a6cbfa831c5c7ed8a019255f63 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 13:06:14 +0900 Subject: [PATCH 06/10] test(cli): pin --category narrowing before json.rules is seeded --- packages/cli/test/run.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts index 4c1dd3f42..5ed330945 100644 --- a/packages/cli/test/run.test.ts +++ b/packages/cli/test/run.test.ts @@ -313,6 +313,24 @@ describe('run() --category', () => { // 'run() performance rules' above); it must not survive a seo-only filter. expect(allIds).not.toContain('performance/image-dimensions'); }); + + it('omits a rule from an excluded category from `rules`, even though selectRules would include it', async () => { + const cap = capture(); + await run({ + cwd: fixtureDir, + reporter: 'json', + log: cap.log, + errorLog: cap.errorLog, + categories: ['seo'], + env: CLEAN_ENV + }); + const json = JSON.parse(cap.out.join('\n')); + // architecture/component-size is on by default and would be in selectRules's output, but + // --category narrows *after* selection (packages/cli/src/index.ts) -- this only holds if + // ruleIds is the post-narrowing `rules`, not the pre-narrowing `selected`. + expect(Object.hasOwn(json.rules, 'architecture/component-size')).toBe(false); + expect(Object.hasOwn(json.rules, 'seo/title-presence')).toBe(true); + }); }); describe('run() --score', () => { From 45f2c5cfb177d480e495a815669a7d25cabe7160 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 13:13:54 +0900 Subject: [PATCH 07/10] docs: document the JSON report's rules map --- .changeset/json-rule-evidence.md | 16 ++++++++++++++++ .../content/docs/guides/(reporting)/reporters.md | 13 +++++++++++++ .../docs/ja/guides/(reporting)/reporters.md | 9 +++++++++ 3 files changed, 38 insertions(+) create mode 100644 .changeset/json-rule-evidence.md diff --git a/.changeset/json-rule-evidence.md b/.changeset/json-rule-evidence.md new file mode 100644 index 000000000..a0189f0c4 --- /dev/null +++ b/.changeset/json-rule-evidence.md @@ -0,0 +1,16 @@ +--- +'@svelte-vitals/core': patch +'svelte-vitals': patch +'@svelte-vitals/vite': patch +--- + +`--reporter json` gains a top-level `rules` map of rule id to `{ findings, passed }`, listing every rule +that ran. + +It answers a question the report could not: `issues` lists only failing findings, so a rule that found +nothing left no trace — indistinguishable from a rule that was never selected. A rule present in `rules` +ran; a rule missing from it was not selected. `passed` is also unavailable elsewhere, since `summary` is +project-wide. + +The counts describe the report rather than the tree: baseline, suppression and `--diff` filtering are +applied first, so a rule whose findings were all suppressed shows `findings: 0` and stays present. diff --git a/docs/src/content/docs/guides/(reporting)/reporters.md b/docs/src/content/docs/guides/(reporting)/reporters.md index d006d047d..d7ea45d92 100644 --- a/docs/src/content/docs/guides/(reporting)/reporters.md +++ b/docs/src/content/docs/guides/(reporting)/reporters.md @@ -43,6 +43,11 @@ svelte-vitals --reporter json } }, "summary": { "critical": 0, "warning": 33, "info": 44, "passed": 610, "dynamic": 2 }, + "rules": { + // Every rule that ran. An entry with `findings: 0` ran and reported nothing; + // a rule missing from this map was not selected (`--ignore`, `--rules`, `--category`, or `off`). + "architecture/unit-entry-file": { "findings": 0, "passed": 12 } + }, "routes": [ { "route": "/about", // a route id, or a source file path for file-scoped rules @@ -74,6 +79,14 @@ Two field names are worth pointing out, because guessing them wrongly fails sile `line`, `docsUrl` and `fix` are present only when the rule supplies them, and `location` only for a finding tied to a file. `issues` lists **failing** findings only — passing checks are counted in `summary.passed` but are not listed. A route with no failures still appears in `routes`, with an empty `issues` array and its own score. +`rules` answers a question the rest of the report cannot: **whether a rule ran at all.** `issues` lists +only failing findings, so a rule that found nothing leaves no trace there — and a rule you disabled leaves +the same absence. Look it up in `rules` instead: present means it ran, missing means it was not selected. + +The counts describe the report, not the tree. Baseline, suppression and `--diff` filtering are applied +before the report is built, so a rule whose findings were all suppressed shows `findings: 0` while +remaining present. + ### `agent` A Markdown remediation document designed for AI coding agents. Each failing finding includes: diff --git a/docs/src/content/docs/ja/guides/(reporting)/reporters.md b/docs/src/content/docs/ja/guides/(reporting)/reporters.md index 3c7713a33..94f75d434 100644 --- a/docs/src/content/docs/ja/guides/(reporting)/reporters.md +++ b/docs/src/content/docs/ja/guides/(reporting)/reporters.md @@ -43,6 +43,11 @@ svelte-vitals --reporter json } }, "summary": { "critical": 0, "warning": 33, "info": 44, "passed": 610, "dynamic": 2 }, + "rules": { + // 実行されたすべてのルール。`findings: 0` のエントリは実行された上で何も検出しなかったことを示し、 + // このマップに現れないルールは選択されていない(`--ignore`、`--rules`、`--category`、または `off`)ことを示す。 + "architecture/unit-entry-file": { "findings": 0, "passed": 12 } + }, "routes": [ { "route": "/about", // ルート ID。ファイル単位のルールではソースファイルのパス @@ -74,6 +79,10 @@ svelte-vitals --reporter json `line`・`docsUrl`・`fix` はルールが提供した場合のみ、`location` はファイルに紐づく検出の場合のみ現れます。`issues` に並ぶのは**失敗した検出のみ**です。合格したチェックは `summary.passed` に数として計上されますが、一覧には出ません。失敗が1件も無いルートも `routes` には現れ、`issues` が空配列のまま自分のスコアを持ちます。 +`rules` は、レポートの他の部分では答えられない問い、**そのルールがそもそも実行されたかどうか**に答えます。`issues` に載るのは失敗した検出結果だけなので、何も検出しなかったルールはそこに痕跡を残しません。無効化したルールも同じく現れません。判定には `rules` を見ます。存在すれば実行された、存在しなければ選択されていない、という意味です。 + +件数が表すのはツリーではなくレポートそのものです。baseline、抑制、`--diff` によるフィルタリングはレポートを組み立てる前に適用されるため、検出結果がすべて抑制されたルールも `findings: 0` のまま `rules` に残ります。 + ### `agent` AI コーディングエージェント向けに設計された Markdown 修正ドキュメントです。失敗した各検出結果には以下が含まれます: From 99c07c97e836fabfcb2a7ee9f562a62a168b8562 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 13:19:53 +0900 Subject: [PATCH 08/10] docs: bump json-rule-evidence changeset from patch to minor --- .changeset/json-rule-evidence.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/json-rule-evidence.md b/.changeset/json-rule-evidence.md index a0189f0c4..7008032ba 100644 --- a/.changeset/json-rule-evidence.md +++ b/.changeset/json-rule-evidence.md @@ -1,7 +1,7 @@ --- -'@svelte-vitals/core': patch -'svelte-vitals': patch -'@svelte-vitals/vite': patch +'@svelte-vitals/core': minor +'svelte-vitals': minor +'@svelte-vitals/vite': minor --- `--reporter json` gains a top-level `rules` map of rule id to `{ findings, passed }`, listing every rule From d1e0a585aa9d1d0c72501b67f6833ac4844bac9e Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 13:43:43 +0900 Subject: [PATCH 09/10] fix(core): document overrides-off exclusion gap and pin config-off in json.rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Minor findings from the feat/json-rule-evidence whole-branch review: - reporters.md (en/ja): the exclusion list and "counts describe the report" note now distinguish top-level `rules: { id: 'off' }` (removes the rule from the map) from an `overrides`-disabled rule (still ran, still appears as { findings: 0, passed: 0 }) — confirmed end to end against the CLI's json reporter before writing the wording. - app-shell.ts's formatHtmlReport and vite/ui/snapshot.ts's buildSnapshot both build a JsonReport on buildJsonReport's 3-arg form, so their `rules` map is results-only, unlike the json reporter's selection-based map. Threading only the HTML report (a one-step change) would still leave the field meaning two things across payloads, and the dashboard side would cascade through plugin.ts/installUiMiddleware/buildSnapshot into a live layer (hooks/handle.ts) that selects rules independently in a separate process. Left both as-is; recorded a design-doc bullet and a one-line comment at each call site. - Added a CLI-level test pinning that a rule disabled via config `rules` (not just --category) is absent from json.rules, alongside a rule that stays on staying present — the design's config-off half of the selectRules discrimination was previously only exercised by json-report.test.ts, which never calls selectRules itself. --- .../docs/guides/(reporting)/reporters.md | 18 +++++++++++++----- .../docs/ja/guides/(reporting)/reporters.md | 10 ++++++---- .../2026-08-03-json-rule-evidence-design.md | 11 +++++++++++ packages/cli/test/run.test.ts | 18 ++++++++++++++++++ packages/core/src/reporter/app-shell.ts | 3 +++ packages/vite/src/ui/snapshot.ts | 3 +++ 6 files changed, 54 insertions(+), 9 deletions(-) diff --git a/docs/src/content/docs/guides/(reporting)/reporters.md b/docs/src/content/docs/guides/(reporting)/reporters.md index d7ea45d92..b7e2501c4 100644 --- a/docs/src/content/docs/guides/(reporting)/reporters.md +++ b/docs/src/content/docs/guides/(reporting)/reporters.md @@ -45,7 +45,9 @@ svelte-vitals --reporter json "summary": { "critical": 0, "warning": 33, "info": 44, "passed": 610, "dynamic": 2 }, "rules": { // Every rule that ran. An entry with `findings: 0` ran and reported nothing; - // a rule missing from this map was not selected (`--ignore`, `--rules`, `--category`, or `off`). + // a rule missing from this map was disabled at the top level — `--ignore`, `--rules`, + // `--category`, or `rules: { id: 'off' }` in config. A rule disabled through an + // `overrides` entry instead still ran and still appears here (see below). "architecture/unit-entry-file": { "findings": 0, "passed": 12 } }, "routes": [ @@ -80,12 +82,18 @@ Two field names are worth pointing out, because guessing them wrongly fails sile `line`, `docsUrl` and `fix` are present only when the rule supplies them, and `location` only for a finding tied to a file. `issues` lists **failing** findings only — passing checks are counted in `summary.passed` but are not listed. A route with no failures still appears in `routes`, with an empty `issues` array and its own score. `rules` answers a question the rest of the report cannot: **whether a rule ran at all.** `issues` lists -only failing findings, so a rule that found nothing leaves no trace there — and a rule you disabled leaves -the same absence. Look it up in `rules` instead: present means it ran, missing means it was not selected. +only failing findings, so a rule that found nothing leaves no trace there — and a rule disabled at the top +level (`--ignore`, `--rules`, `--category`, or `rules: { id: 'off' }` in config) leaves the same absence. +Look it up in `rules` instead: present means it ran, missing means it was excluded at the top level — with +one exception, below. The counts describe the report, not the tree. Baseline, suppression and `--diff` filtering are applied -before the report is built, so a rule whose findings were all suppressed shows `findings: 0` while -remaining present. +before the report is built, so a rule whose findings were all suppressed shows `findings: 0` while remaining +present. The same is true of a rule disabled through an `overrides` entry rather than at the top level: +`overrides` drops its results (passing ones included) after the rule has already run, so it shows +`{ "findings": 0, "passed": 0 }` — indistinguishable from a selected rule that simply found nothing. +Presence in `rules` proves a rule wasn't excluded by `--ignore`, `--rules`, `--category`, or config's +top-level `rules`; it does not prove `overrides` left anything for it to find. ### `agent` diff --git a/docs/src/content/docs/ja/guides/(reporting)/reporters.md b/docs/src/content/docs/ja/guides/(reporting)/reporters.md index 94f75d434..429af03e1 100644 --- a/docs/src/content/docs/ja/guides/(reporting)/reporters.md +++ b/docs/src/content/docs/ja/guides/(reporting)/reporters.md @@ -44,8 +44,10 @@ svelte-vitals --reporter json }, "summary": { "critical": 0, "warning": 33, "info": 44, "passed": 610, "dynamic": 2 }, "rules": { - // 実行されたすべてのルール。`findings: 0` のエントリは実行された上で何も検出しなかったことを示し、 - // このマップに現れないルールは選択されていない(`--ignore`、`--rules`、`--category`、または `off`)ことを示す。 + // 実行されたすべてのルール。`findings: 0` のエントリは実行された上で何も検出しなかったことを示す。 + // このマップに現れないルールはトップレベルで無効化されている(`--ignore`、`--rules`、`--category`、 + // または設定ファイルの `rules: { id: 'off' }`)。`overrides` で無効化したルールは実行はされるため、 + // 引き続きここに現れる(詳しくは後述)。 "architecture/unit-entry-file": { "findings": 0, "passed": 12 } }, "routes": [ @@ -79,9 +81,9 @@ svelte-vitals --reporter json `line`・`docsUrl`・`fix` はルールが提供した場合のみ、`location` はファイルに紐づく検出の場合のみ現れます。`issues` に並ぶのは**失敗した検出のみ**です。合格したチェックは `summary.passed` に数として計上されますが、一覧には出ません。失敗が1件も無いルートも `routes` には現れ、`issues` が空配列のまま自分のスコアを持ちます。 -`rules` は、レポートの他の部分では答えられない問い、**そのルールがそもそも実行されたかどうか**に答えます。`issues` に載るのは失敗した検出結果だけなので、何も検出しなかったルールはそこに痕跡を残しません。無効化したルールも同じく現れません。判定には `rules` を見ます。存在すれば実行された、存在しなければ選択されていない、という意味です。 +`rules` は、レポートの他の部分では答えられない問い、**そのルールがそもそも実行されたかどうか**に答えます。`issues` に載るのは失敗した検出結果だけなので、何も検出しなかったルールはそこに痕跡を残しません。トップレベルで無効化した(`--ignore`、`--rules`、`--category`、または設定ファイルの `rules: { id: 'off' }`)ルールも同じく現れません。判定には `rules` を見ます。存在すれば実行された、存在しなければトップレベルで除外された、という意味です。ただし一つだけ例外があり、それは次に述べます。 -件数が表すのはツリーではなくレポートそのものです。baseline、抑制、`--diff` によるフィルタリングはレポートを組み立てる前に適用されるため、検出結果がすべて抑制されたルールも `findings: 0` のまま `rules` に残ります。 +件数が表すのはツリーではなくレポートそのものです。baseline、抑制、`--diff` によるフィルタリングはレポートを組み立てる前に適用されるため、検出結果がすべて抑制されたルールも `findings: 0` のまま `rules` に残ります。`overrides` で無効化したルールも同様です。トップレベルとは異なり、`overrides` はルールが実行された後にその結果(合格分も含む)を取り除くため、`{ "findings": 0, "passed": 0 }` として現れ、選択されて何も検出しなかったルールと見分けがつきません。`rules` に存在することが保証するのは、`--ignore`・`--rules`・`--category`・設定ファイルのトップレベルの `rules` で除外されなかったことだけで、`overrides` が検出結果を何も残さなかったことまでは保証しません。 ### `agent` diff --git a/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md b/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md index 4a8782c00..69efa5549 100644 --- a/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md +++ b/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md @@ -90,6 +90,17 @@ enough to state in the guide: presence proves selection, not that the rule found - **Other reporters.** The console already lists passes; this closes the JSON gap only. - **`routes[].categories[].score`**, the second recorded follow-up. Same diagnosability theme, independent decision. +- **The HTML report and the dev dashboard.** `formatHtmlReport` (`packages/core/src/reporter/app-shell.ts`) + and the Vite plugin's `buildSnapshot` (`packages/vite/src/ui/snapshot.ts`) both embed a `JsonReport` built + on `buildJsonReport`'s three-argument form, so their `rules` map is seeded from results only — presence + there means "produced a result," not "was selected," the opposite of what this design documents for the + field of the same name. Left as-is rather than threaded: the dashboard's ran-rule list would have to come + from a fresh `selectRules` call in `plugin.ts`, cascading through `installUiMiddleware` and `buildSnapshot` + and into a dev-dashboard config whose live layer (`packages/vite/src/hooks/handle.ts`) already computes its + own `selectRules` independently, in a separate process — reconciling that is a bigger question than this + fix. Threading only the HTML report's easy case would leave the field still meaning two things across the + three payloads, just in a different proportion, so neither was done. Nothing renders `rules` in either + payload today; each call site carries a comment recording the gap. ## Testing diff --git a/packages/cli/test/run.test.ts b/packages/cli/test/run.test.ts index 5ed330945..e5fcd7a00 100644 --- a/packages/cli/test/run.test.ts +++ b/packages/cli/test/run.test.ts @@ -128,6 +128,24 @@ describe('run() reporters and gating', () => { expect(json.rules['security/raw-html']).toEqual({ findings: 0, passed: 0 }); }); + it('omits a rule disabled via config `rules` from `json.rules`, unlike an `overrides`-disabled one', async () => { + const cap = capture(); + await run({ + cwd: fixtureDir, + log: cap.log, + errorLog: cap.errorLog, + reporter: 'json', + env: CLEAN_ENV, + rules: { 'seo/description-presence': 'off' } + }); + const json = JSON.parse(cap.out.join('\n')); + // Config-level 'off' goes through selectRules (packages/core/src/config-apply.ts), which + // drops the rule before it ever reaches runRules — this only holds if that filtering + // reaches ruleIds, not just `results`. + expect(Object.hasOwn(json.rules, 'seo/description-presence')).toBe(false); + expect(Object.hasOwn(json.rules, 'security/raw-html')).toBe(true); + }); + it('disabling a rule via rules:{id:off} removes its findings', async () => { const cap = capture(); await run({ diff --git a/packages/core/src/reporter/app-shell.ts b/packages/core/src/reporter/app-shell.ts index 008866b7c..670de50ea 100644 --- a/packages/core/src/reporter/app-shell.ts +++ b/packages/core/src/reporter/app-shell.ts @@ -754,5 +754,8 @@ export function formatHtmlReport( config: Config, meta: { version: string; coreVersion?: string } ): string { + // No rule-id list threaded through: unlike the `json` reporter, `report.rules` here is + // seeded from `results` alone, so presence means "produced a result", not "was selected" + // (design doc 2026-08-03-json-rule-evidence-design.md, Not in scope). return buildHtmlDocument(buildJsonReport(results, config, meta), meta); } diff --git a/packages/vite/src/ui/snapshot.ts b/packages/vite/src/ui/snapshot.ts index 5b40ac0ac..65d11bc75 100644 --- a/packages/vite/src/ui/snapshot.ts +++ b/packages/vite/src/ui/snapshot.ts @@ -33,6 +33,9 @@ export function buildSnapshot( meta: { version: string; coreVersion?: string } ): DashboardSnapshot { return { + // No rule-id list threaded through: `report.rules` is seeded from `store.snapshot()` + // alone here, so presence means "produced a result", not "was selected" — unlike the + // `json` reporter (design doc 2026-08-03-json-rule-evidence-design.md, Not in scope). report: sanitizeReport(buildJsonReport(store.snapshot(), config, meta)), badges: store.badges(), analyzing: store.isAnalyzing(), From 293ad5d1b97549a237cdacf3e79f07a698c2438b Mon Sep 17 00:00:00 2001 From: oekazuma Date: Mon, 3 Aug 2026 15:14:04 +0900 Subject: [PATCH 10/10] docs: qualify the rules-presence contract for the fallback path The type comment and the design table both stated presence proves selection unconditionally. That holds only when the caller supplies the rule ids; the compatibility fallback seeds from results, where absence means 'produced nothing'. A rule disabled through an overrides entry also stays present, since selectRules reads only top-level config.rules. Also drops an impossible verification step from the plan: it asked for an empty git diff after restoring a mutated line, while the task's own changes are still uncommitted at that point. --- .../superpowers/plans/2026-08-03-json-rule-evidence.md | 4 +++- .../specs/2026-08-03-json-rule-evidence-design.md | 10 ++++++++++ packages/core/src/reporter/json.ts | 6 +++++- 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-08-03-json-rule-evidence.md b/docs/superpowers/plans/2026-08-03-json-rule-evidence.md index a35ca507e..d2dc31ac1 100644 --- a/docs/superpowers/plans/2026-08-03-json-rule-evidence.md +++ b/docs/superpowers/plans/2026-08-03-json-rule-evidence.md @@ -230,7 +230,9 @@ Expected: PASS, with the file's pre-existing cases unaffected — `rules` is add Delete the `for (const id of ruleIds ?? [])` line and re-run. Expected: the "lists a selected rule that produced nothing" and "reaches the same shape through -formatJsonReport" tests FAIL. Restore, and confirm `git diff` on `json.ts` is empty. +formatJsonReport" tests FAIL. Then restore the line and re-run to confirm they pass again — do **not** +check for an empty `git diff`, since Task 1's own changes are still uncommitted at this point and will +always show. - [ ] **Step 6: Typecheck, lint, commit** diff --git a/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md b/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md index 69efa5549..876173226 100644 --- a/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md +++ b/docs/superpowers/specs/2026-08-03-json-rule-evidence-design.md @@ -34,6 +34,16 @@ still gets an entry: | key present, `findings: 0` | selected, ran, reported nothing | | key absent | not selected — `--ignore`, `--rules`, `--category`, or `off` in config | +**The table holds only for a caller that supplies the list.** Omitted, the map is seeded from results +alone, so an absent key means "produced nothing" and proves nothing about selection. That is the +compatibility fallback, and it is why `rules` means something narrower in the two payloads listed under +"Not in scope" below. + +One path also escapes the second row: a rule turned off through an **`overrides` entry** stays present with +`{ findings: 0, passed: 0 }`, because `selectRules` reads only the top-level `config.rules`. The same word +at top level does remove it. Documented in the guide rather than changed — `overrides` narrows per path, so +a rule off for one file and on for another did run. + Presence is the answer; the counts are the detail. ### Shape diff --git a/packages/core/src/reporter/json.ts b/packages/core/src/reporter/json.ts index 26becfd24..fc489f854 100644 --- a/packages/core/src/reporter/json.ts +++ b/packages/core/src/reporter/json.ts @@ -19,7 +19,11 @@ function issueOf(result: Result) { type JsonIssue = ReturnType & { severity: ReturnType }; -/** Per-rule counts. A rule present with `findings: 0` ran and reported nothing; an absent rule was not selected. */ +/** + * Per-rule counts. A rule present with `findings: 0` ran and reported nothing, and an absent rule was not + * selected — but only when the caller supplied `ruleIds`. Without it the map is seeded from results alone, + * so absence means "produced nothing" rather than "not selected". + */ export interface RuleEvidence { findings: number; passed: number;