@devmedic/report-engine
v0.1.0
Published
Normalizes and aggregates findings, computes HealthScore, dispatches to reporter adapters.
Readme
@devmedic/report-engine
Turns Normalized Issues (from @devmedic/issue-engine) into
scores, summaries, and six output formats. No auto-fix, no AI, no
UI — only reporting.
import { generateReport, renderReport } from '@devmedic/report-engine';
const report = generateReport({
issues, // NormalizedIssue[] — the only required input
rules, // optional: include zero-issue rules in the rule summary
plugins, // optional: enables the plugin summary
execution, // optional: enables execution summary + performance metrics, and a more accurate health score
});
renderReport(report, 'cli'); // colored terminal output
renderReport(report, 'json'); // for a future dashboard API
renderReport(report, 'markdown'); // PR comment / docs
renderReport(report, 'html'); // self-contained static page
renderReport(report, 'sarif'); // GitHub code scanning et al.
renderReport(report, 'github-actions-summary'); // $GITHUB_STEP_SUMMARYissues is the only required field — a report can always be generated from
Normalized Issues alone. Everything else is optional enrichment: without
rules, a rule that found nothing won't appear in the rule summary; without
execution, there's no execution/performance section and the health score
falls back to a less accurate file-count estimate (see below).
Scores
Health Score and the four Category Scores (feeding the named
architectureScore / securityScore / performanceScore convenience
fields) all use the same formula: severity-weighted issue density, decayed
exponentially into 0–100.
penaltyPerFile = Σ(severityPenalty) / fileCount
score = round(100 × e^(-penaltyPerFile / decayConstant))Bounded and monotonic — more or worse issues never raise the score, and it
can never go negative the way a linear subtraction could. fileCount prefers
an explicit override, then execution.filesAnalyzed (the real number the
Rule Engine looked at), and only falls back to counting distinct files with
an issue — which is always an undercount (clean files are invisible to it)
and therefore understates the score. Pass execution for an accurate one.
The two constants, and why they're not magic numbers
severityPenalty is @devmedic/severity-engine's SEVERITY_PENALTY,
re-exported here rather than duplicated: penalty(rank) = 2^rank − 1, where
rank is the severity's 0-indexed position among hint < info < warning <
error < critical. That gives { hint: 0, info: 1, warning: 3, error: 7,
critical: 15 } — each severity step is exactly twice the previous penalty
plus one, so a critical issue outweighs 15 hint-level ones categorically,
not just linearly. hint is 0 by construction: a pure suggestion never
moves the score. See @devmedic/severity-engine's README for the full
derivation.
decayConstant (SCORE_DECAY_CONSTANT = 10) controls how sharply the
score falls off as penaltyPerFile rises — it is not derived from the
penalty table, it is the exponential's own rate parameter. Its precise
meaning: the score crosses 100/e ≈ 36.8 exactly when penaltyPerFile
equals decayConstant — e.g. at the current value of 10, a codebase
averaging one error-severity issue (penalty 7) plus a bit more per file
has already crossed that point. Raising it makes the score more lenient
(the same issue density produces a higher score); lowering it makes the
score fall off faster for the same issues.
What's computed
| Responsibility | Where |
| --------------------------------------- | --------------------------------------------------------------------------------------------------- |
| Health Score / Category Scores | scoring.ts |
| Issue Statistics | statistics.ts — totals, by severity, by category, fixable count, file count |
| Rule Summary | rule-summary.ts — per-rule issue/fixable counts, sorted most-impactful-first |
| Plugin Summary | plugin-summary.ts — per-plugin rule/issue counts, from a caller-supplied rule-id → plugin mapping |
| Execution Summary / Performance Metrics | execution-summary.ts — duration, files analyzed, rule errors, slowest rule invocations |
Rule Metadata
RuleMetadataInput (the rules field above) carries the full
@devmedic/rule-engine Rule Metadata contract, not just
id/title/category/severity/documentationUrl: fixable,
sinceVersion, minimumRNVersion, estimatedFixTime, tags, and
references all flow through to the matching RuleSummaryEntry when a
rule is seeded from rules — they're absent for a rule seeded only from
issues, since those fields don't exist on Issue itself.
apps/cli maps @devmedic/rule-engine's Rules (via extractRuleMetadata)
onto this shape before calling generateReport — previously, rules was
never populated by any CLI command at all, so a rule that ran and found
nothing never appeared in the rule summary, and none of this metadata
reached a report regardless of what a rule declared. formats/markdown.ts
adds a "Tags" column to the Rule Summary table when at least one rule in
the report declares tags.
Rule Failures
execution.ruleFailures (from @devmedic/rule-engine's AnalyzeResult.errors
— pass it straight through) and execution.ruleExecutionSummary (from
AnalyzeResult.ruleExecutionSummary) drive a dedicated Rule Failures
section — every format that has an execution summary at all renders the
full detail (rule, file, phase, reason, and stack when available), never
just a bare "N rule error(s)" count with nothing behind it:
const report = generateReport({
issues,
execution: {
durationMs,
filesAnalyzed,
ruleFailures: analyzeResult.errors, // full RuleExecutionFailure[] detail
ruleExecutionSummary: analyzeResult.ruleExecutionSummary, // successful/failed/skipped rule ids
},
});
report.execution?.ruleFailures; // readonly RuleFailureInput[]
report.execution?.ruleExecutionCounts; // { successful, failed, skipped, total }| Format | Rule Failures rendering |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| cli | A Rule Failures (N) section per failure — rule, file, phase, reason, and the full stack, dimmed. |
| markdown | A ## Rule Failures table (Rule / File / Phase / Reason), with each stack trace in its own <details>. |
| html | A table matching the per-file issue tables, with the stack in a scrollable <pre>. |
| sarif | runs[].invocations[0] — executionSuccessful and one toolExecutionNotification per failure, the SARIF-native way to represent a tool's own execution errors (distinct from results, which are findings). |
| github-actions-summary | The rule error count (previously silently dropped from this format entirely) plus a collapsible table. |
| json | The full RuleExecutionFailure shape, serialized — error is already { name, message, stack }, not a raw Error (which loses those properties to JSON.stringify). |
Output formats
| Format | Notes |
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| cli | Chalk-colored, degrades automatically in non-TTY environments. Score bars, severity-colored issue lines, grouped by file. |
| json | The Report as-is, pretty-printed — the shape a future dashboard API would ingest directly. |
| markdown | Full report: scores, statistics, rule/plugin summaries, issues grouped by file under headings. |
| html | Self-contained (no external stylesheets/scripts), light/dark via prefers-color-scheme. |
| sarif | SARIF 2.1.0. Severity maps to SARIF's 3 levels: info→note, warning→warning, error/critical→error (SARIF has no "critical"). |
| github-actions-summary | Markdown for $GITHUB_STEP_SUMMARY; per-file issues in a collapsible <details> block, since a job summary is scanned, not read top to bottom. |
Future dashboard APIs
generateReport(input, { dashboard }) accepts a dashboard?: { projectId?, ingestUrl? }
that's carried onto the report but otherwise inert — reserved so a report
generated today won't need to reshape once a dashboard API exists. Until
then, the json format itself is the dashboard-consumable shape.
Depends on
@devmedic/core@devmedic/telemetry@devmedic/issue-engine— the source ofNormalizedIssue, and reused directly for grouping issues by file in the Markdown/GitHub Actions formatschalk
