Skip to content

refactor(rules): give the rule layer one entry point (#84) - #194

Merged
Gallevy merged 2 commits into
mainfrom
advisor/039-one-rule-layer
Sep 13, 2026
Merged

Gallevy merged 2 commits into
mainfrom
advisor/039-one-rule-layer

Conversation

@Gallevy

@Gallevy Gallevy commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Closes #84.

What

Rule evaluation happened in four places, and the results were stitched onto an already-constructed AggregatedReport by reassigning a field three times:

# Where Rules
1 aggregateReports no-packages, require-packages
2 evaluateRules file, max-file-size, script, package-field, engine-version, codeowners
3 evaluateRegistryRules no-outdated-packages, no-deprecated-packages
4 runPlugins third-party findings

This gives the rule layer one entry point and builds the report once.

  • AnalysisFacts split from AggregatedReport. aggregateReports returns what the run observed; AggregatedReport extends AnalysisFacts adds the verdicts. Package rules move out of aggregation.
  • New runRules() (src/rules/run.ts) — the only place that knows the rule families exist. Package → evaluator → registry, in one explicitly commented array. Registry progress leaves through callbacks, so the rule layer doesn't import ora.
  • runPlugins takes facts and violations as separate arguments. buildInventoryView no longer reaches into a report that is still being assembled.
  • runPipeline constructs AggregatedReport exactly once, at the end. Nothing reassigns a field on it.

Why now

#84 predicted this would bite "before another rule type gets added and has to guess which side of the seam it belongs on." Three have been added since it was filed (#168, #183, #187), each making that call unaided — the seam widened from two sites to four rather than closing.

The issue's original "nobody reads the half-built report yet" caveat had also expired. src/plugins/runner.ts did:

violations: [...aggregated.ruleViolations, ...violations],

That was correct only because the plugin phase happens to run last — and PluginInventoryView.violations is public plugin API, so the completeness promise was made to third-party authors and kept by one unenforced line. It's now kept by the signature.

Verification

  • pnpm run test:output: all 30 cases match main — byte-identical CLI output. This is the acceptance test; emission order is preserved exactly, now chosen in one array rather than inherited from call-site sequencing.
  • 1204 tests pass (59 files). Lint, format, typecheck, build all exit 0.
  • No published API change: aggregateReports and AggregatedReport are not exported from src/index.ts, HermexScanResult is untouched, and PluginInventoryView's shape is unchanged — only how it's fed.
  • No changeset: internal only.

One thing worth a reviewer's eye

printScanResults (src/commands/scan.ts) typed its parameter as ReturnType<typeof aggregateReports>, so narrowing that return type silently retargeted a renderer at the facts-only type. It now names AggregatedReport directly, which is what the annotation always meant. There may be other ReturnType<typeof …> annotations standing in for a named type — they defeat exactly the kind of compiler check this refactor leaned on. Not swept here; noted in plans/README.md.

Contents

  • 4a35226 — plan 039 + README entry (the repo's plans/ handoff format)
  • 0352d86 — the refactor

🤖 Generated with Claude Code

Gallevy and others added 2 commits September 13, 2026 23:42
Re-checked #84 against main at 044719c. Still reproducing, and the seam it
reports widened from two rule-evaluation call sites to four since it was
filed (#168, #183, #187, #191). Its own prediction — that another rule type
would have to guess which side of the seam it belonged on — has played out
three times.

Rewrote the issue body: the fix it originally proposed (a synchronous
evaluateRules taking the inventory) no longer fits the async registry and
plugin phases, and its "nobody reads the half-built report yet" caveat has
expired — src/plugins/runner.ts:59 does, through public plugin API.

Plan 039 splits AnalysisFacts from AggregatedReport, gives the rule layer a
single runRules() entry point, feeds plugins their inputs instead of a
partially-assembled report, and constructs AggregatedReport exactly once.
Internal only; acceptance test is a zero-diff `pnpm run test:output`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rule evaluation happened in four places — package rules inside
aggregateReports, six families in evaluateRules, registry rules and plugins in
runPipeline — and the results were stitched onto an already-constructed
AggregatedReport by reassigning a field three times.

- Split AnalysisFacts (what the run observed) from AggregatedReport (facts plus
  verdicts). aggregateReports returns facts only and no longer evaluates rules.
- Add runRules() in src/rules/run.ts: the one place that knows the rule
  families exist, running package -> evaluator -> registry in one explicitly
  ordered list. Registry progress goes out through callbacks so the rule layer
  does not import ora.
- runPlugins takes facts and violations as separate arguments.
  buildInventoryView no longer reaches into a report that is still being
  assembled — the promise that PluginInventoryView.violations is complete is
  now kept by the signature rather than by call ordering.
- runPipeline constructs AggregatedReport exactly once, at the end. No field is
  reassigned anywhere.

printScanResults typed its parameter as ReturnType<typeof aggregateReports>,
which silently retargeted a renderer at the narrowed type; it now names
AggregatedReport directly, which is what it always meant.

No behaviour change: all 30 output-review cases match main byte for byte.
Emission order is preserved exactly — it is now chosen in one commented array
rather than inherited from whichever call site ran first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ No Changeset found

Latest commit: 0352d86

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions

Copy link
Copy Markdown
Contributor

Output Review

0 of 30 case(s) changed · 0 invariant breach(es) · all 30 cases

Reference: 044719c — reused from cache.

Output is unchanged. Nothing to review.

@github-actions

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🟢 Lines 98.87%
⬇️ -0.3%
1754 / 1774
🟢 Statements 98.39%
⬇️ -0.3%
1960 / 1992
🟢 Functions 98.81%
⬇️ -0.2%
417 / 422
🟢 Branches 97.17%
⬇️ -0.1%
1341 / 1380
File Coverage
File Stmts Branches Functions Lines
Changed Files
src/rules/run.ts 62.5% 50% 100% 62.5%
src/plugins/runner.ts 92.85%
🟰 ±0%
76.92%
🟰 ±0%
88.88%
🟰 ±0%
95.83%
🟰 ±0%
src/utils/aggregator-core.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #248 for commit 0352d86 by the Vitest Coverage Report Action

@Gallevy
Gallevy merged commit ef8369c into main Sep 13, 2026
6 checks passed
@Gallevy
Gallevy deleted the advisor/039-one-rule-layer branch September 13, 2026 21:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Rule evaluation is split across four call sites and the report is mutated after construction

1 participant