Skip to content

Quality & Risk: a cache tier can no longer change the verdict (#527) - #530

Merged
richard-devbot merged 2 commits into
mainfrom
claude/index-risk-parity-527
Aug 1, 2026
Merged

Quality & Risk: a cache tier can no longer change the verdict (#527)#530
richard-devbot merged 2 commits into
mainfrom
claude/index-risk-parity-527

Conversation

@richard-devbot

@richard-devbot richard-devbot commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Closes #527 · found while live-verifying the #525 dials.

The bug

computeQualityRisk reads task.builder (risks, files_modified) — data the rollup index never persisted. So the same run produced two different governance verdicts depending only on which cache tier served it:

Full parse Index-served
Aggregated Risk 50 — band high 15 — band low
severities critical 1, high 1, medium 1, low 1 all 0
Complexity 6 (5 files, 2 tasks) 2 (0 files, 0 tasks)
task.risk_count in the same snapshot [2, 2] [2, 2]

Not an honest unknown — a false score under a reassuring green band, on the tier that serves most runs most of the time. "0 risks flagged" rendered while the same snapshot counted them. The #525 dials made it louder: a confident green arc on a genuinely high-risk run.

The fix

Index entries persist a compact per-task builder_risk summary (severity + mitigation fields, files_modified); computeQualityRisk reads it when the full builder is absent. INDEX_VERSION 9 → 10 forces the rebuild.

Two deliberate calls:

  • A distinct key, not a partial builder. client-state renders builder.summary / memory_summary / tests_run; rehydrating a stub builder would have traded this false score for a fresh set of false empties — the same honesty bug wearing a different hat.
  • Bounded but still exact — corrected after review. My first cut claimed FILES_CAP was 50; it is 200, so those caps really could lower a score (Qodo, findings 1-2). Risks now persist as per-severity tallies (exact at any volume, nothing truncated) and files as a run-level deduped union bounded by the imported FILES_CAP. Pinned by a volume parity case: 150 files + 130 risks, identical verdict on both tiers.

Pinned

New case in the canonical lite↔full parity guard: the entire verdict (score, band, severities, mitigated, complexity, files, builder tasks) must match on both paths — plus a cross-check that the card can never report "no risks" while task.risk_count counts them. Verified failing first; the original probe now returns identical numbers on both tiers.

Rider, and one honest non-fix

That new guard was itself flaky — buildFullState doesn't await the index write, so cycle 2 could read too early and the guard would silently test nothing. Fixed with tests/helpers/index-settle.js (one shared helper, per the #515 lesson).

I also tried the same cure on the pre-existing dashboard-command-pages flake — it did not work, so I reverted it rather than ship a fix that doesn't fix. A standalone probe runs that sequence 20/20 successfully, and the run signature covers mtimes, so the mechanism is signature-recompute under concurrent load, not an unwritten index. Evidence and the narrowed mechanism handed to #517.

Verification

Core 1901/1901 (repeat runs; the only intermittent is the pre-existing #517 flake above, which fails identically on main) · browser 26/26 · lint 0 · typecheck 0 · validate 196 · security green.

Merging after green checks + reviewer bodies read.

🤖 Generated with Claude Code

computeQualityRisk reads task.builder (risks, files_modified) — data the
rollup index never persisted — so the SAME run scored 50/high on a full
parse and 15/low once index-served, reporting "0 risks flagged" while the
very same snapshot carried risk_count: 2 per task. Not an honest unknown:
a false score under a reassuring green band, on the tier that serves most
runs most of the time. The #525 dials made it louder — a confident green
arc on a genuinely high-risk run.

Index entries now persist a compact per-task `builder_risk` summary
(severity + mitigation fields, files_modified) and computeQualityRisk
reads it when the full builder is absent. Deliberately a DISTINCT key,
not a partial `builder`: client-state renders builder.summary /
memory_summary / tests_run, so rehydrating a stub builder would have
traded this false score for a new set of false empties. Bounded and still
EXACT — the complexity file component saturates at FILES_CAP (50) unique
files, so the 50/task cap cannot change a score. INDEX_VERSION 9 → 10
forces the rebuild.

Pinned by a new case in the canonical lite↔full parity guard: the whole
Quality & Risk verdict (score, band, severities, mitigated, complexity,
files, builder tasks) must be identical on both paths, plus a cross-check
that the card can never report "no risks" while task.risk_count counts
them. Verified failing first; the original probe now returns identical
numbers on both tiers.

Rider: that guard was itself flaky — buildFullState does not await the
index write, so cycle 2 could read too early and the guard would silently
test nothing. tests/helpers/index-settle.js waits for the run to really
be in the index (one shared helper, per the #515 lesson). Note it does
NOT cure the separate dashboard-command-pages flake — evidence and a
narrowed mechanism handed to #517 rather than papered over.

Closes #527.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@strix-security

strix-security Bot commented Aug 1, 2026

Copy link
Copy Markdown

Strix is installed on this repository, but we couldn't run this PR security review because this workspace's trial has ended. Add a card to resume code reviews here.

@coderabbitai

coderabbitai Bot commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@richard-devbot, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 2310cba6-057a-4559-9d03-2a6f912a8b96

📥 Commits

Reviewing files that changed from the base of the PR and between 3d9d337 and 2c6f48e.

📒 Files selected for processing (4)
  • src/observability/dashboard/state/quality-risk.js
  • src/observability/dashboard/state/rollup-index.js
  • tests/dashboard-index-parity.test.js
  • tests/helpers/index-settle.js

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Fix Quality & Risk parity by persisting builder_risk in rollup index

🐞 Bug fix 🧪 Tests ⚙️ Configuration changes 🕐 40+ Minutes

Grey Divider

AI Description

• Persist compact per-task risk inputs in the rollup index to keep risk scoring consistent.
• Make computeQualityRisk consume builder_risk when full builder data is absent.
• Add parity + race-proofing tests so cache tier can’t change the governance verdict.
Diagram

graph TD
  A["Run artifacts"] --> B["buildFullState"] --> E["computeQualityRisk"] --> F{{"Dashboard card"}}
  B --> C[("rollup index") ] --> D["liteRunFromEntry"] --> E
  subgraph Legend
    direction LR
    _p["Process"] ~~~ _db[("Cache/index") ] ~~~ _ui{{"UI"}}
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Persist the full task.builder in the index
  • ➕ No need for a special-case risk summary key
  • ➕ Future computations could reuse full builder data
  • ➖ Index size/perf hit (builder contracts can be large)
  • ➖ High risk of consumers misinterpreting a partial/legacy builder shape
2. Treat missing builder as unknown (null) risk/complexity
  • ➕ Avoids any possibility of undercounting or false green scores
  • ➕ No index schema change required
  • ➖ Degrades product value for the most common path (index-served runs)
  • ➖ Still allows cache-tier-dependent UI behavior (known vs unknown)
3. Compute from task.risk_count only
  • ➕ Already persisted; minimal schema change
  • ➕ Avoids relying on builder risk objects
  • ➖ Loses severity, mitigation, and files_modified needed for exact scoring
  • ➖ Would change scoring semantics vs full-parse path

Recommendation: The chosen approach (persisting a bounded, distinct builder_risk summary and teaching computeQualityRisk to use it) is the best tradeoff: it keeps the verdict identical across cache tiers without inflating index size or pretending a stubbed builder satisfies the full builder contract. The explicit INDEX_VERSION bump plus parity tests appropriately pin the invariant going forward.

Files changed (4) +106 / -4

Bug fix (1) +15 / -3
quality-risk.jsMake Quality & Risk scoring use builder_risk on index-served runs +15/-3

Make Quality & Risk scoring use builder_risk on index-served runs

• Introduces a unified riskSource accessor that prefers task.builder but falls back to task.builder_risk. Updates risk collection, task selection, and files_modified complexity inputs so the computed verdict stays consistent when runs are served from the rollup index.

src/observability/dashboard/state/quality-risk.js

Tests (2) +73 / -0
dashboard-index-parity.test.jsPin Quality & Risk verdict parity across full vs index-served paths +48/-0

Pin Quality & Risk verdict parity across full vs index-served paths

• Adds a dedicated parity test asserting that risk score/band/severities/mitigated and complexity metrics match between full-parse and index-served state. Also hardens existing parity flow by waiting for the index write to land before starting the second cycle.

tests/dashboard-index-parity.test.js

index-settle.jsAdd helper to wait for rollup index persistence to avoid race flakes +25/-0

Add helper to wait for rollup index persistence to avoid race flakes

• Adds waitForIndexedRun(), polling .rstack/index.json until a runId appears (tolerating mid-write JSON). This prevents lite-vs-full parity tests from accidentally reading before buildFullState’s async index write completes.

tests/helpers/index-settle.js

Other (1) +18 / -1
rollup-index.jsPersist compact per-task builder_risk summary and bump index version +18/-1

Persist compact per-task builder_risk summary and bump index version

• Bumps INDEX_VERSION to force a rebuild of cached entries. Extends entryFromRun to persist a distinct builder_risk payload (bounded risks + files_modified) required for Quality & Risk scoring without persisting the full builder object.

src/observability/dashboard/state/rollup-index.js

@qodo-code-review

qodo-code-review Bot commented Aug 1, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📜 Skill insights (3)

Context used
✅ Compliance rules (platform): 300 rules
✅ Skills: 17 invoked
  code-review-pr
  claude-api
  documentation-writing
  pptx
  docx
  performance-monitoring
  security-compliance
  cso
  plan-eng-review
  design-review
  prompt-engineering
  mcp-builder
  qa-testing
  code-patterns
  xlsx
  security-owasp
  testing-qa

Grey Divider


Action required

1. Risks cap breaks parity ✓ Resolved 🐞 Bug ≡ Correctness
Description
entryFromRun() truncates builder_risk.risks to 100 items, but computeQualityRisk() sums weights and
counts severities across the full risk list, so index-served runs will undercount
by_severity/mitigated and can compute a lower aggregated risk score/band than a full parse. This can
again make governance outputs cache-tier-dependent on runs with >100 reported risks.
Code

src/observability/dashboard/state/rollup-index.js[R290-295]

+        risks: (task.builder.risks ?? []).slice(0, 100).map((risk) => ({
+          severity: risk?.severity ?? null,
+          mitigation: risk?.mitigation ?? null,
+          mitigated: risk?.mitigated ?? null,
+          status: risk?.status ?? null,
+        })),
Relevance

●● Moderate

Risk-list truncation affecting governance output is important but may be accepted if “bounded exact”
is intended.

PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The index path deliberately truncates the risk list to 100, but the risk computation iterates over
all collected risks to compute severityCounts, mitigatedCount, and rawRisk. Because the builder
contract only requires risks to be an array (no size limit), truncation can produce different counts
and potentially different scores on index-served runs.

src/observability/dashboard/state/rollup-index.js[281-297]
src/observability/dashboard/state/quality-risk.js[126-147]
src/core/harness/contracts.js[47-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`entryFromRun()` persists only the first 100 builder risks into `task.builder_risk.risks`, but `computeQualityRisk()` rolls up the entire `risks` list to compute `risk.score`, `by_severity`, and `mitigated`. This makes index-served verdicts lossy for runs where the builder reports more than 100 risks.

## Issue Context
The scoring code has no cap at 100 and the builder contract does not constrain the size of `risks`. Even if the score sometimes clamps to 100, `by_severity`, `mitigated`, and `total` will be wrong whenever truncation occurs.

## Fix Focus Areas
- src/observability/dashboard/state/rollup-index.js[289-296]
- src/observability/dashboard/state/quality-risk.js[126-147]
- src/core/harness/contracts.js[47-99]

## What to change
Choose a persistence strategy that is **lossless for the scoring inputs**:
- Preferred: Persist an exact compact summary (e.g., per-task `risk_summary: { total, by_severity, mitigated }`) computed at index time, and update `computeQualityRisk()` to consume the summary when `task.builder` is absent.
- Acceptable minimal: remove the `slice(0, 100)` cap and persist all risks but normalize to the minimal scoring fields (severity + the fields needed to decide mitigation) to control index growth.
- If you must cap for size, then `computeQualityRisk()` should return `null/unknown` on index-served runs when it detects truncation, rather than computing a quieter (incorrect) score.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Files cap breaks parity ✓ Resolved 🐞 Bug ≡ Correctness
Description
entryFromRun() truncates builder_risk.files_modified to 50 entries, but computeQualityRisk()’s
complexity score scales unique files up to FILES_CAP=200, so index-served runs can compute a lower
complexity score/band than a full parse. This reintroduces cache-tier-dependent governance output
for runs touching >50 unique files.
Code

src/observability/dashboard/state/rollup-index.js[R289-297]

+      builder_risk: task.builder ? {
+        risks: (task.builder.risks ?? []).slice(0, 100).map((risk) => ({
+          severity: risk?.severity ?? null,
+          mitigation: risk?.mitigation ?? null,
+          mitigated: risk?.mitigated ?? null,
+          status: risk?.status ?? null,
+        })),
+        files_modified: (task.builder.files_modified ?? []).slice(0, 50),
+      } : null,
Relevance

●● Moderate

Score-parity vs index-size tradeoff is semantic; no close precedent on caps matching FILES_CAP.

PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The index persists only 50 file paths per task, but complexity scoring explicitly uses a 200-file
cap and derives the score from the unique-file set size; therefore truncating below 200 can change
the computed complexity score for large runs. The builder contract validation enforces only that
files_modified is an array, so >50 entries is a valid/expected shape.

src/observability/dashboard/state/rollup-index.js[272-297]
src/observability/dashboard/state/quality-risk.js[33-37]
src/observability/dashboard/state/quality-risk.js[148-156]
src/core/harness/contracts.js[47-99]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`entryFromRun()` persists `builder_risk.files_modified` with `slice(0, 50)`, but `computeQualityRisk()` computes the files component using `FILES_CAP = 200`. This means index-served runs can undercount unique files and compute a different complexity score/band than fully-parsed runs.

## Issue Context
The PR’s stated goal is “a cache tier must never change a governance score”. Truncating persisted file paths below the scoring cap violates that for large runs.

## Fix Focus Areas
- src/observability/dashboard/state/rollup-index.js[289-297]
- src/observability/dashboard/state/quality-risk.js[33-37]
- src/observability/dashboard/state/quality-risk.js[148-156]

## What to change
- Persist enough `files_modified` information to reproduce the `FILES_CAP=200` unique-file count exactly.
 - Minimal change: increase the slice limit from 50 to 200 and (ideally) dedupe + filter to strings before slicing so you keep 200 *unique* usable paths.
 - Better for maintainability: export the cap from `quality-risk.js` (or define a shared constant) and use it in `rollup-index.js` so the two can’t drift again.
- Update the comment in `rollup-index.js` claiming FILES_CAP is 50 (it is 200 in `quality-risk.js`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Magic number caps in builder_risk ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The new index persistence code uses literal cap values (100, 50) directly in .slice() calls
instead of named constants. This makes the caps easy to misuse or drift from related limits like
FILES_CAP.
Code

src/observability/dashboard/state/rollup-index.js[R289-297]

+      builder_risk: task.builder ? {
+        risks: (task.builder.risks ?? []).slice(0, 100).map((risk) => ({
+          severity: risk?.severity ?? null,
+          mitigation: risk?.mitigation ?? null,
+          mitigated: risk?.mitigated ?? null,
+          status: risk?.status ?? null,
+        })),
+        files_modified: (task.builder.files_modified ?? []).slice(0, 50),
+      } : null,
Relevance

●●● Strong

Magic-number caps are commonly extracted into named constants for clarity/tuning.

PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 1400588 requires replacing magic numbers with named constants. The added code hardcodes cap
values in .slice(0, 100) and .slice(0, 50) rather than using clearly named constants.

src/observability/dashboard/state/rollup-index.js[289-297]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The PR introduces literal numeric caps in `entryFromRun()` (`slice(0, 100)` and `slice(0, 50)`) rather than named constants.

## Issue Context
Compliance requires magic numbers used for caps/limits/timeouts to be replaced with descriptively named constants to prevent drift and improve readability.

## Fix Focus Areas
- src/observability/dashboard/state/rollup-index.js[289-297]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Malformed builder counts as signal 🐞 Bug ☼ Reliability
Description
scoredTasks() treats any non-null task.builder/task.builder_risk as a valid risk source without
verifying it’s an object carrying risks/files_modified; a malformed truthy value can make
riskSignalPresent true and produce a fabricated 0 score instead of an honest null. This weakens the
“honest nulls” contract in computeQualityRisk() for corrupted/partial task data.
Code

src/observability/dashboard/state/quality-risk.js[R91-97]

+function riskSource(task) {
+  return task?.builder ?? task?.builder_risk ?? null;
+}
+
+function scoredTasks(tasks) {
+  return (tasks ?? []).filter((task) => riskSource(task) !== null);
+}
Relevance

●●● Strong

Team often hardens against malformed truthy values/invalid JSON shapes to avoid false signals.

PR-#503
PR-#152

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new scoredTasks() filter only checks for non-null sources, while the “honest null” gate in
computeQualityRisk() is keyed on builderTasks.length; collectRisks() ignores non-array risks, so
malformed-but-truthy sources can cause riskSignalPresent to be true while contributing zero risks,
resulting in a misleading 0 score.

src/observability/dashboard/state/quality-risk.js[91-97]
src/observability/dashboard/state/quality-risk.js[99-105]
src/observability/dashboard/state/quality-risk.js[142-147]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`scoredTasks()` currently includes tasks as long as `riskSource(task) !== null`, even if the selected source is not an object with the expected arrays. This can incorrectly mark risk/complexity signals as present and yield a 0 score.

## Issue Context
`computeQualityRisk()` uses `builderTasks.length` in `riskSignalPresent` and uses `collectRisks()` which ignores non-array `risks`. A non-object-but-truthy source can therefore force a misleading “0” instead of “unknown”.

## Fix Focus Areas
- src/observability/dashboard/state/quality-risk.js[91-97]
- src/observability/dashboard/state/quality-risk.js[99-106]
- src/observability/dashboard/state/quality-risk.js[142-147]

## What to change
- Make `riskSource()` (or `scoredTasks()`) return/include only sources that are plain objects (and optionally validate that `risks` / `files_modified` are arrays when present).
 - Example: `const src = task?.builder ?? task?.builder_risk; return (src && typeof src === 'object' && !Array.isArray(src)) ? src : null;`
- Update the complexity loop to use safe access (`riskSource(task)?.files_modified`) if you change filtering logic.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Magic retry defaults in waitForIndexedRun ✓ Resolved 📜 Skill insight ⚙ Maintainability
Description
The new helper hardcodes retry/timing defaults (attempts = 50, delayMs = 20) rather than using
named constants, reducing clarity and making future tuning error-prone. These values function as
operational limits/timeouts and should be extracted into descriptive constants.
Code

tests/helpers/index-settle.js[R15-23]

+export async function waitForIndexedRun(projectRoot, runId, { attempts = 50, delayMs = 20 } = {}) {
+  const indexPath = join(projectRoot, '.rstack', 'index.json');
+  for (let attempt = 0; attempt < attempts; attempt += 1) {
+    try {
+      const parsed = JSON.parse(await readFile(indexPath, 'utf8'));
+      if (parsed?.runs && Object.prototype.hasOwnProperty.call(parsed.runs, runId)) return true;
+    } catch { /* not written yet, or mid-write */ }
+    await new Promise((resolve) => { setTimeout(resolve, delayMs); });
+  }
Relevance

●●● Strong

Repo has accepted extracting timeout/retry magic numbers into named constants in tests.

PR-#509

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 1400588 requires magic numbers used for limits/timeouts to be replaced with named constants.
The added helper uses literal numeric defaults and delay timing values directly in function
parameters and polling logic.

tests/helpers/index-settle.js[15-23]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`waitForIndexedRun()` introduces magic numbers for retry behavior (`attempts = 50`, `delayMs = 20`) and uses them to control polling timing.

## Issue Context
Compliance requires numeric timeout/cap values to be expressed as named constants for maintainability and auditability.

## Fix Focus Areas
- tests/helpers/index-settle.js[15-23]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (2)
6. Mitigation payload unbounded ✓ Resolved 🐞 Bug ➹ Performance
Description
builder_risk persists risk.mitigation verbatim into index.json, which can cause unnecessary index
growth and heavier parsing if mitigation is a large string/object. computeQualityRisk() only needs
mitigation as a small string-presence signal (or an equivalent boolean), so persisting arbitrary
mitigation payloads adds avoidable cost.
Code

src/observability/dashboard/state/rollup-index.js[R291-295]

+          severity: risk?.severity ?? null,
+          mitigation: risk?.mitigation ?? null,
+          mitigated: risk?.mitigated ?? null,
+          status: risk?.status ?? null,
+        })),
Relevance

●● Moderate

Index size/perf vs retaining mitigation detail is subjective; no clear repo precedent.

PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The persisted builder_risk includes mitigation as-is, but the mitigation logic in isRiskMitigated()
only treats mitigation as a non-empty string; persisting arbitrary objects/large strings is
therefore unnecessary for scoring and increases index size/parse work.

src/observability/dashboard/state/rollup-index.js[289-296]
src/observability/dashboard/state/quality-risk.js[78-84]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`entryFromRun()` persists `risk.mitigation` directly into the rollup index. If that field is large (long text or object), it can bloat `.rstack/index.json` and increase JSON parse/IO costs.

## Issue Context
`computeQualityRisk()` only needs enough info to evaluate mitigation via `isRiskMitigated()`, which checks boolean `mitigated`, `status`, and whether `mitigation` is a non-empty string.

## Fix Focus Areas
- src/observability/dashboard/state/rollup-index.js[289-295]
- src/observability/dashboard/state/quality-risk.js[78-84]

## What to change
- Persist only the minimal mitigation signal needed for scoring:
 - Either store `mitigation` only when it is a string (and optionally clamp length),
 - Or store a boolean like `has_mitigation` / `is_mitigated` (precomputed) and update `isRiskMitigated()` / the index path to use it.
- Avoid persisting mitigation objects (they don’t affect `isRiskMitigated()` today).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


7. #527 test not AAA-structured 📜 Skill insight ▣ Testability
Description
The new test interleaves setup, execution, and assertions without clear Arrange/Act/Assert
separation, making it harder to scan and maintain. This violates the required AAA test structure
guideline.
Code

tests/dashboard-index-parity.test.js[R115-158]

+test('index parity: the Quality & Risk verdict is identical on both paths (#527)', async () => {
+  // computeQualityRisk reads task.builder (risks, files_modified) — data the
+  // index did not persist, so the SAME run scored 50/high fresh and 15/low
+  // once index-served: a false score under a reassuring green band, on the
+  // tier that serves most runs most of the time.
+  const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-qr-parity-'));
+  try {
+    await fixtureScoredRun(projectRoot);
+
+    const first = await buildFullState(projectRoot, { includeRegistry: false });
+    const full = first.runs.find((run) => run.runId === 'run-fx-scored');
+    assert.ok(full && !full.fromIndex, 'cycle 1 parses the run fully');
+    assert.ok(Number.isFinite(first.qualityRisk?.risk?.score),
+      'the scored fixture yields a real risk score, or this guard proves nothing');
+
+    assert.ok(await waitForIndexedRun(projectRoot, 'run-fx-scored'), 'cycle 1 persisted the run to the index');
+    const second = await buildFullState(projectRoot, { includeRegistry: false });
+    const lite = second.runs.find((run) => run.runId === 'run-fx-scored');
+    assert.ok(lite?.fromIndex, 'cycle 2 serves the run from the index');
+
+    const verdict = (state) => ({
+      risk: state.qualityRisk?.risk?.score ?? null,
+      band: state.qualityRisk?.risk?.band ?? null,
+      severities: state.qualityRisk?.risk?.by_severity ?? null,
+      mitigated: state.qualityRisk?.risk?.mitigated ?? null,
+      complexity: state.qualityRisk?.complexity?.score ?? null,
+      files: state.qualityRisk?.complexity?.files_touched ?? null,
+      builderTasks: state.qualityRisk?.complexity?.builder_tasks ?? null,
+    });
+    assert.deepEqual(verdict(second), verdict(first),
+      'a cache tier must never change the governance verdict — persist the risk/complexity inputs in entryFromRun (with an INDEX_VERSION bump) or report unknown, never a quieter score');
+
+    // And the card can never claim "no risks" while the same snapshot counts them.
+    const countedRisks = (lite.tasks ?? []).reduce((sum, task) => sum + (task.risk_count ?? 0), 0);
+    if (countedRisks > 0) {
+      const severities = second.qualityRisk?.risk?.by_severity ?? {};
+      const reported = Object.values(severities).reduce((sum, n) => sum + n, 0);
+      assert.ok(reported > 0,
+        `snapshot counts ${countedRisks} risk(s) via task.risk_count but the card reports none`);
+    }
+  } finally {
+    rmSync(projectRoot, { recursive: true, force: true });
+  }
+});
Relevance

●● Moderate

AAA structuring is style-level; no strong accepted/rejected precedent found.

PR-#528

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 1400638 requires tests to follow the AAA pattern with distinct phases. The added test performs
setup, then assertions, then more actions and assertions without clearly delineated AAA sections.

tests/dashboard-index-parity.test.js[115-158]
Skill: testing-qa

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `index parity: the Quality & Risk verdict is identical on both paths (#527)` mixes Arrange, Act, and Assert steps throughout the body without clear separation.

## Issue Context
The compliance checklist requires tests to follow the Arrange-Act-Assert (AAA) pattern, ideally separated by comments or blank lines.

## Fix Focus Areas
- tests/dashboard-index-parity.test.js[115-158]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

8. waitForIndexedRun missing JSDoc 📜 Skill insight ⚙ Maintainability
Description
The new exported function waitForIndexedRun has no JSDoc block documenting parameters and return
value. This violates the requirement that exported/public functions include JSDoc with @param and
@returns (and @throws when applicable).
Code

tests/helpers/index-settle.js[R15-25]

+export async function waitForIndexedRun(projectRoot, runId, { attempts = 50, delayMs = 20 } = {}) {
+  const indexPath = join(projectRoot, '.rstack', 'index.json');
+  for (let attempt = 0; attempt < attempts; attempt += 1) {
+    try {
+      const parsed = JSON.parse(await readFile(indexPath, 'utf8'));
+      if (parsed?.runs && Object.prototype.hasOwnProperty.call(parsed.runs, runId)) return true;
+    } catch { /* not written yet, or mid-write */ }
+    await new Promise((resolve) => { setTimeout(resolve, delayMs); });
+  }
+  return false;
+}
Relevance

● Weak

Exported-function JSDoc requests were explicitly rejected in similar dashboard/state changes.

PR-#509
PR-#511

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 1400405 requires exported functions to have JSDoc documentation.
waitForIndexedRun is exported but has no JSDoc comment block above it in the added file.

tests/helpers/index-settle.js[15-25]
Skill: code-patterns

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The exported function `waitForIndexedRun` is missing a JSDoc comment with `@param` and `@returns` tags (and `@throws` if applicable).

## Issue Context
This helper is exported and used by tests; per compliance requirements, exported/public functions must be documented with JSDoc.

## Fix Focus Areas
- tests/helpers/index-settle.js[15-25]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. Regression test added to existing file 📜 Skill insight ⚙ Maintainability
Description
A new regression-style test for bug #527 was added to an existing test file, rather than being
introduced as a new dedicated regression test file. This violates the guideline intended to keep
regression coverage isolated and discoverable via naming conventions.
Code

tests/dashboard-index-parity.test.js[R115-159]

+test('index parity: the Quality & Risk verdict is identical on both paths (#527)', async () => {
+  // computeQualityRisk reads task.builder (risks, files_modified) — data the
+  // index did not persist, so the SAME run scored 50/high fresh and 15/low
+  // once index-served: a false score under a reassuring green band, on the
+  // tier that serves most runs most of the time.
+  const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-qr-parity-'));
+  try {
+    await fixtureScoredRun(projectRoot);
+
+    const first = await buildFullState(projectRoot, { includeRegistry: false });
+    const full = first.runs.find((run) => run.runId === 'run-fx-scored');
+    assert.ok(full && !full.fromIndex, 'cycle 1 parses the run fully');
+    assert.ok(Number.isFinite(first.qualityRisk?.risk?.score),
+      'the scored fixture yields a real risk score, or this guard proves nothing');
+
+    assert.ok(await waitForIndexedRun(projectRoot, 'run-fx-scored'), 'cycle 1 persisted the run to the index');
+    const second = await buildFullState(projectRoot, { includeRegistry: false });
+    const lite = second.runs.find((run) => run.runId === 'run-fx-scored');
+    assert.ok(lite?.fromIndex, 'cycle 2 serves the run from the index');
+
+    const verdict = (state) => ({
+      risk: state.qualityRisk?.risk?.score ?? null,
+      band: state.qualityRisk?.risk?.band ?? null,
+      severities: state.qualityRisk?.risk?.by_severity ?? null,
+      mitigated: state.qualityRisk?.risk?.mitigated ?? null,
+      complexity: state.qualityRisk?.complexity?.score ?? null,
+      files: state.qualityRisk?.complexity?.files_touched ?? null,
+      builderTasks: state.qualityRisk?.complexity?.builder_tasks ?? null,
+    });
+    assert.deepEqual(verdict(second), verdict(first),
+      'a cache tier must never change the governance verdict — persist the risk/complexity inputs in entryFromRun (with an INDEX_VERSION bump) or report unknown, never a quieter score');
+
+    // And the card can never claim "no risks" while the same snapshot counts them.
+    const countedRisks = (lite.tasks ?? []).reduce((sum, task) => sum + (task.risk_count ?? 0), 0);
+    if (countedRisks > 0) {
+      const severities = second.qualityRisk?.risk?.by_severity ?? {};
+      const reported = Object.values(severities).reduce((sum, n) => sum + n, 0);
+      assert.ok(reported > 0,
+        `snapshot counts ${countedRisks} risk(s) via task.risk_count but the card reports none`);
+    }
+  } finally {
+    rmSync(projectRoot, { recursive: true, force: true });
+  }
+});
+
Relevance

● Weak

Prior reviews rejected moving new regression cases into new dedicated files; edits to existing test
files were allowed.

PR-#475
PR-#488
PR-#513

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
Rule 1400748 requires regression tests to be added as new files and prohibits modifying existing
test files for regression coverage. The diff shows a new test(...) block for #527 added directly
into tests/dashboard-index-parity.test.js.

tests/dashboard-index-parity.test.js[115-159]
Skill: qa-testing

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
A regression test for `#527` was added to an existing test file (`tests/dashboard-index-parity.test.js`). The compliance rule requires regression tests to be added as new files (e.g., `*.regression-*.test.js`) rather than modifying existing test files.

## Issue Context
This PR adds a new parity guard test explicitly tied to `#527`, which functions as regression coverage.

## Fix Focus Areas
- tests/dashboard-index-parity.test.js[115-159]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Qodo Logo

Comment on lines +115 to +158
test('index parity: the Quality & Risk verdict is identical on both paths (#527)', async () => {
// computeQualityRisk reads task.builder (risks, files_modified) — data the
// index did not persist, so the SAME run scored 50/high fresh and 15/low
// once index-served: a false score under a reassuring green band, on the
// tier that serves most runs most of the time.
const projectRoot = mkdtempSync(join(tmpdir(), 'rstack-qr-parity-'));
try {
await fixtureScoredRun(projectRoot);

const first = await buildFullState(projectRoot, { includeRegistry: false });
const full = first.runs.find((run) => run.runId === 'run-fx-scored');
assert.ok(full && !full.fromIndex, 'cycle 1 parses the run fully');
assert.ok(Number.isFinite(first.qualityRisk?.risk?.score),
'the scored fixture yields a real risk score, or this guard proves nothing');

assert.ok(await waitForIndexedRun(projectRoot, 'run-fx-scored'), 'cycle 1 persisted the run to the index');
const second = await buildFullState(projectRoot, { includeRegistry: false });
const lite = second.runs.find((run) => run.runId === 'run-fx-scored');
assert.ok(lite?.fromIndex, 'cycle 2 serves the run from the index');

const verdict = (state) => ({
risk: state.qualityRisk?.risk?.score ?? null,
band: state.qualityRisk?.risk?.band ?? null,
severities: state.qualityRisk?.risk?.by_severity ?? null,
mitigated: state.qualityRisk?.risk?.mitigated ?? null,
complexity: state.qualityRisk?.complexity?.score ?? null,
files: state.qualityRisk?.complexity?.files_touched ?? null,
builderTasks: state.qualityRisk?.complexity?.builder_tasks ?? null,
});
assert.deepEqual(verdict(second), verdict(first),
'a cache tier must never change the governance verdict — persist the risk/complexity inputs in entryFromRun (with an INDEX_VERSION bump) or report unknown, never a quieter score');

// And the card can never claim "no risks" while the same snapshot counts them.
const countedRisks = (lite.tasks ?? []).reduce((sum, task) => sum + (task.risk_count ?? 0), 0);
if (countedRisks > 0) {
const severities = second.qualityRisk?.risk?.by_severity ?? {};
const reported = Object.values(severities).reduce((sum, n) => sum + n, 0);
assert.ok(reported > 0,
`snapshot counts ${countedRisks} risk(s) via task.risk_count but the card reports none`);
}
} finally {
rmSync(projectRoot, { recursive: true, force: true });
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. #527 test not aaa-structured 📜 Skill insight ▣ Testability

The new test interleaves setup, execution, and assertions without clear Arrange/Act/Assert
separation, making it harder to scan and maintain. This violates the required AAA test structure
guideline.
Agent Prompt
## Issue description
The test `index parity: the Quality & Risk verdict is identical on both paths (#527)` mixes Arrange, Act, and Assert steps throughout the body without clear separation.

## Issue Context
The compliance checklist requires tests to follow the Arrange-Act-Assert (AAA) pattern, ideally separated by comments or blank lines.

## Fix Focus Areas
- tests/dashboard-index-parity.test.js[115-158]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/observability/dashboard/state/rollup-index.js
Comment thread tests/helpers/index-settle.js Outdated
Comment thread src/observability/dashboard/state/rollup-index.js
Comment thread src/observability/dashboard/state/rollup-index.js Outdated
Comment thread src/observability/dashboard/state/rollup-index.js Outdated
Comment on lines +91 to +97
function riskSource(task) {
return task?.builder ?? task?.builder_risk ?? null;
}

function scoredTasks(tasks) {
return (tasks ?? []).filter((task) => riskSource(task) !== null);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

9. Malformed builder counts as signal 🐞 Bug ☼ Reliability

scoredTasks() treats any non-null task.builder/task.builder_risk as a valid risk source without
verifying it’s an object carrying risks/files_modified; a malformed truthy value can make
riskSignalPresent true and produce a fabricated 0 score instead of an honest null. This weakens the
“honest nulls” contract in computeQualityRisk() for corrupted/partial task data.
Agent Prompt
## Issue description
`scoredTasks()` currently includes tasks as long as `riskSource(task) !== null`, even if the selected source is not an object with the expected arrays. This can incorrectly mark risk/complexity signals as present and yield a 0 score.

## Issue Context
`computeQualityRisk()` uses `builderTasks.length` in `riskSignalPresent` and uses `collectRisks()` which ignores non-array `risks`. A non-object-but-truthy source can therefore force a misleading “0” instead of “unknown”.

## Fix Focus Areas
- src/observability/dashboard/state/quality-risk.js[91-97]
- src/observability/dashboard/state/quality-risk.js[99-106]
- src/observability/dashboard/state/quality-risk.js[142-147]

## What to change
- Make `riskSource()` (or `scoredTasks()`) return/include only sources that are plain objects (and optionally validate that `risks` / `files_modified` are arrays when present).
  - Example: `const src = task?.builder ?? task?.builder_risk; return (src && typeof src === 'object' && !Array.isArray(src)) ? src : null;`
- Update the complexity loop to use safe access (`riskSource(task)?.files_modified`) if you change filtering logic.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Qodo caught a genuine correctness error in the first cut, and my commit
message and PR body both asserted the opposite: I claimed the file
component "saturates at FILES_CAP (50)" — FILES_CAP is 200. So the 50-file
slice DID silently lower complexity for any task touching >50 unique
files, and the 100-risk slice lowered the risk score and severity chips
for busy runs: the exact cache-tier-dependent verdict this fix exists to
remove, merely at a higher threshold.

Redesigned so no cap can move a score:

- Risks persist as per-severity {total, mitigated} TALLIES via a shared
  summarizeRiskSeverities() — exact for any number of risks, and the
  projection sums the identical arithmetic whether the tally came from a
  live builder contract or the index. No list, no truncation.
- Files persist as a run-level deduped UNION bounded by the imported
  FILES_CAP (one definition, no drift) — identical up to the point the
  component provably saturates, and smaller than per-task copies.
- risk.total now derives from the tallies rather than a risk array.

Pinned by a new parity case at volume: 150 unique files (>the old 50 cap)
and 130 risks (>the old 100 cap) must produce an identical verdict on both
tiers — verified failing against the first cut.

Also: named the settle helper's retry constants (#490 convention). The
literal caps Qodo flagged separately are simply gone with the redesign.

Refs #527 (PR #530 review follow-up).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@richard-devbot

Copy link
Copy Markdown
Owner Author

All four findings addressed — and Qodo caught a real correctness error that my own PR body asserted the opposite of. Recording that plainly:

I wrote "the complexity file component saturates at FILES_CAP (50) unique files, so the 50/task cap cannot change a score." FILES_CAP is 200. So the 50-file slice genuinely did lower complexity for any task touching >50 unique files, and the 100-risk slice lowered both the risk score and the severity chips on busy runs — the same cache-tier-dependent verdict this PR exists to eliminate, just at a higher threshold. Findings 1 and 2 were correct; my "provably exact" claim was not.

Redesigned so that no cap can move a score:

  • Risks → per-severity {total, mitigated} tallies via a shared summarizeRiskSeverities(), used by both the index writer and the projection. Exact for any number of risks; nothing is truncated because nothing is copied.
  • Files → a run-level deduped union bounded by the imported FILES_CAP (one definition, no drift), which is identical up to the point the component provably saturates — and smaller than the per-task copies were.
  • risk.total derives from the tallies rather than a risk array.

Pinned by a new parity case at volume: 150 unique files and 130 risks must yield an identical verdict on both tiers — verified failing against the first cut before the fix.

Findings 3/4: the literal caps are gone with the redesign; the settle helper's retry defaults are named constants (#490 convention).

Gates: core 1902/1902, browser 26/26, lint/typecheck/validate/security clean. The PR body's inaccurate "FILES_CAP (50)" sentence is corrected below. — Claude Main

@richard-devbot
richard-devbot merged commit 4f48553 into main Aug 1, 2026
10 checks passed
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.

Quality & Risk silently UNDERSTATES risk for index-served runs — same run scores 50/high fresh vs 15/low from the index

2 participants