Skip to content

policy(validation): fail hard on TS import cycles; split dead-code into proven/introduced/evidence tiers #288

Description

@EivMeyer

Revised after verifying against main (v0.2.1) — the original body cited a stale checkout. The core asymmetry stands; the "config deferred" section was wrong and is corrected below.

Problem

In an agent gate, warnings are decoration: the agent branches on the exit code, so a non-blocking finding is a finding the loop is licensed to ignore forever. Two of the strongest structural signals are non-blocking for TS on main:

  • TS_IMPORT_GRAPH_CYCLE is severity: "warning" (packages/validation-typescript/src/import-graph-check.ts:48,72 on main). Rust blocks cycles: rust.import-graph has defaultSeverity: "error" (packages/validation-rust/src/import-graph-check.ts:113). The TS/Rust asymmetry is not a recorded decision.
  • typescript.dead-code is defaultSeverity: "warning" (dead-code-check.ts:13 on main), so unused code lands freely.

Why dead-code cannot simply flip to error

The check measures absence of evidence ("no incoming CALLS graph evidence"), not proven deadness. Rust dead-code is safe as error because rustc only flags provably-dead private items and never flags pub. The TS equivalent needs entrypoint knowledge to make the same guarantee; entrypoint modeling already ships on main: automatic package.json discovery (main/exports/bin + tsconfig outDir mapping + test roots, dead-code-roots.ts) with manual deadCode.entrypoints override. The proof machinery for the "provably dead" tier exists; only the severity policy is missing.

Proposed policy

Error must mean proven out of tolerance, not suspicious. Applied:

  1. Cycles -> error for TS import cycles (matches Rust). Cycle fingerprints are already canonical on main (canonicalCycle()), so introduced-mode correctly limits blast radius to cycles the change created. Promotion gated on chore(policy): shadow-run strict severity policy (cycles/dead-code as error) and publish the counts #285 (shadow-run data).
  2. Dead-code split into tiers with distinct codes:
    • provably dead (unexported symbol with no callers; file unreachable from configured/automatic entrypoints) -> error
    • introduced-mode new dead export (added in this changeset, nothing in the changeset calls it) -> error
    • evidence-absence on pre-existing public exports -> stays reported, labeled as evidence-absence

Example (the introduced-mode error)

Agent adds a helper and forgets to wire it in:

// utils/retry.ts  (new file in this changeset)
export function retryWithBackoff(fn, attempts = 3) { ... }

Today: warning, exit 0, the agent ships it, unused code accumulates. After: opcore check --changed exits 1 with "you added retryWithBackoff and nothing calls it" — wire it in or delete it. This is the 20k-line-file failure mode stopped at line one.

Config note (corrected)

Main already has a policy config surface (packages/validation-policy): thresholds, lint plugins, layer rules, docs policy, Rust command gates — all via .opcore/config. What does NOT exist, deliberately or not, is per-check severity override. This issue proposes keeping it that way: severity stays vendor-owned so a green gate always means the same thing. If severity config is ever added, loosening must be surfaced in the check result and receipts, not silent.

Depends on

Acceptance

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions