- 1. What has been bulldozed (so you don’t have to)
- 2. Milestones
- 2.1. M0 — Workspace + substrate smoke test
- 2.2. M1 — Core finding model + engine skeleton + human reporter
- 2.3. M2 — T0 catalogue (rules 1–8) + the falsifier gate
- 2.4. M3 — JSON + SARIF reporters
- 2.5. M4 — Python CFG + dataflow + T1 rules 9–10
- 2.6. M5 — T2 typestate + rule 11 (flagship) + toy protocol
- 2.7. M6 — T3 rules 13–15 + SPECULATIVE plumbing
- 2.8. M7 — Suppression + CLI polish + catalogue generator
- 2.9. M8 — Acceptance sweep + tag v0.1.0
- 3. Cross-cutting rules for every milestone
- 4. Appendix A — Rule → milestone → tier map
- 5. Appendix B — Evidence class ⇄ severity ⇄ default configuration
- 6. Appendix C — The falsifier gate (CI mechanics)
- 7. Appendix D — tree-sitter query authoring guidance (T0)
- 8. Appendix E — SARIF 2.1.0 mapping (the demotion must survive)
- 9. Appendix F — Dependency + grammar pins
- 10. Appendix G — JSON report envelope
Audience: implementation agents (Opus + friends). Every genuine design question this project poses has been resolved in the kickoff + ADRs 0001–0004; what remains below is execution, not invention. If a milestone tempts you to re-open a decided question, stop and read the named ADR instead.
Reading order: kickoff → ADR-0001 (substrate) → ADR-0002 (T1 CFG/dataflow) → ADR-0003 (T2 typestate) → ADR-0004 (positioning) → this plan. The dataflow equations live in ADR-0002; the SARIF/JSON schemas and dependency pins live in this plan’s appendices.
The three real intellectual walls in this project, and where each is resolved:
-
"Engine or ruleset?" (substrate) → ADR-0001: tree-sitter native + Rust. Decided by analysis, not spike. One 30-minute smoke test remains in M0.
-
"How do you do sound dataflow without a build, and on which language?" → ADR-0002: Python; full CFG construction table, both dataflow lattices with transfer functions, the exception model, and the OPAQUE soundness hatch — all written out. Transcribe, don’t derive.
-
"How do you detect a contradiction without guessing the contract?" → ADR-0003: user-supplied TOML protocol + typestate walk; the honesty is structural (no protocol ⇒ no finding).
Everything else (T0 rules, reporters, suppression, CLI) is ordinary engineering.
Each milestone has an exit gate. Do not start N+1 until N’s gate is green. Milestones map to the kickoff’s suggested task order.
-
Cargo workspace with the four crates (ADR-0001 architecture):
pons-core,pons-rules,pons-cli,pons-protocols. Addpons-fixturestest-support as a dev-dependency helper if convenient. -
Pin grammars per Appendix F.
build.rswiring so grammars compile in. -
Smoke test (the residual Task-0 validation): parse one real file per pinned grammar (Python, JavaScript, TypeScript, Rust) and run one trivial query against each; assert non-empty parse + query match.
-
Repo hygiene: SPDX
MPL-2.0header on every source file;justfilewithbuild,test,falsify,catalogue; UK-English.editorconfig; CI skeleton (fmt + clippy + test).
Exit gate: cargo build + the smoke test green on all four grammars. If a
grammar won’t load, fix the pin — do NOT reconsider ADR-0001.
-
finding.rs:Finding,Tier,EvidenceClass,Severity,Location(file + byte span + 1-based line/col span) exactly as the kickoff model. Add#[derive(Serialize)](serde) now — the JSON reporter needs it in M3. -
source.rs: recursive file discovery; language detection by extension (Appendix F table); skip.git,target,node_modules, and honour a.ponsignoreif present. (Mirror panic-attack’s habit of skippingexternal_corpora/,third_party/,corpus/.) -
lang.rs:Langenum + grammar-handle registry; TypeScript and TSX are distinct `Lang`s sharing a rule set (ADR-0001). -
parse.rs: source → tree-sitterTree, with Latin-1 fallback for non-UTF-8 (panic-attack house behaviour). -
engine.rs: orchestration — discover → parse → run applicable rules → collect → sort (file, then byte offset) → report. -
report/human.rs: the default reporter. Must implement the demotion rule:SPECULATIVEfindings are visually set apart and carry the fixed suffix "(heuristic — not a verdict)"; every finding prints itsevidence_note; printcounter_conditionwhen present. -
RuleCtx+Ruletrait (kickoff shape) inpons-core; the inventory of rules lives inpons-rules::registry.
Exit gate: pons scan <dir> parses a mixed tree and prints an (empty)
human report without panicking; a unit test constructs one Finding of each
evidence class and asserts the human formatter demotes SPECULATIVE.
Implement rules 1–8 (kickoff catalogue) as tree-sitter queries + small local
predicates in pons-rules/src/t0/, each across Python, JS/TS, and Rust where it
applies. Query authoring guidance: Appendix D.
Build the falsifier gate FIRST, before rule 3 (Appendix C mechanics), so the
catalogue grows under the gate from the start. Every rule ships
fixtures/<rule-id>/positive/ and fixtures/<rule-id>/negative/ in the
same commit as the rule.
Per-rule counter-condition/severity notes are in the kickoff catalogue; obey
them (e.g. while-true-no-break is WARN, never ERROR; div-by-literal-zero
is HEURISTIC, never ERROR).
Exit gate (this is the kickoff CI gate): for every T0 rule, all positive
fixtures fire and zero negative fixtures fire. just falsify green in CI.
-
report/json.rs: the envelope + finding schema in Appendix G. Stable field order; counts by evidence class. -
report/sarif.rs: SARIF 2.1.0 per Appendix E. The evidence-class demotion must survive into SARIF —SPECULATIVE⇒level:"note"+ lowrank
properties.evidenceClass+ message suffix, so even a dumb SARIF viewer shows the demotion. -
--format human|json|sarifwired in the CLI.
Exit gate: golden-file tests for JSON and SARIF on a fixed small corpus;
a SARIF validator (Appendix E lists one) accepts the output; a SPECULATIVE
finding is demoted in all three formats (asserted).
Implement ADR-0002 verbatim:
-
pons-core/cfg/—Cfg,BasicBlock,trait CfgBuilder, and the Python impl (the CFG-construction table). Include the CFG snapshot tests ADR-0002 mandates (for/else, try/except/finally, comprehension+walrus). -
The OPAQUE hatch — implement before the first T1 fixture.
-
dataflow.rs— the backward live-variables and forward may-be-unbound worklist solvers (ADR-0002 equations). Generic over the lattice so both reuse one fixpoint engine. -
pons-rules/src/t1/— rule 9dead-store, rule 10read-before-init, with the counter-conditions and the specific positive/negative corpora ADR-0002 lists as test obligations.
Exit gate: both T1 rules pass their positive/negative corpora; CFG snapshot
tests green; a function containing exec( produces zero T1 findings (OPAQUE).
Implement ADR-0003:
-
pons-protocols— TOML protocol loader + the shippedquiet-channel.toml. -
pons-rules/src/t2/— the typestate walk over the Python CFG; rule 11suppress-then-emit; the path-witness capture forevidence_note. -
--protocol <file>and the default-toy-protocol stderr note. -
Rule 12
resource-acquired-not-releasedonly if time permits (optional per kickoff); itsmust_exit_insemantics are already specified.
Exit gate: rule 11 fires on the positive corpus (incl. suppress-in-branch and
suppress-in-loop) and stays silent on suppress→unsuppress→emit and on
emit-on-different-key; every T2 finding names two concrete lines in its
evidence_note.
-
pons-rules/src/t3/— rule 13possible-superlinear, rule 14no-interrupt-on-long-op, rule 15general-div-by-zero. All structural/ heuristic; all evidence classSPECULATIVE; all severityINFO. -
--no-speculativeand--min-evidencefilters. -
T3 rules get positive fixtures (must fire) but their "negative" corpora are disclosed-limitation notes, not gated falsifiers where the design admits it cannot decide — follow the ADR-0003 "limitation vs falsifier" distinction.
Exit gate: --no-speculative removes exactly the T3 findings; every T3
finding is demoted in human/JSON/SARIF; --min-evidence dataflow hides
HEURISTIC+SPECULATIVE.
-
suppress.rs— inline# pons:allow <rule-id>(Python) and// pons:allow <rule-id>(JS/TS/Rust) on the offending line, plus repo-levelpons.toml([suppress]by rule-id / path glob). Precedence: inline > config > defaults (kickoff). -
Full CLI surface (kickoff):
--lang,--tier,--min-evidence,--format,--protocol,--no-speculative. -
docs/catalogue.adocgenerated from the rule registry (ajust cataloguetask that walksregistryand emits one entry per rule: id, tier, evidence class, languages, message template, counter-condition).
Exit gate: a suppressed line produces no finding via both inline and config;
docs/catalogue.adoc regenerates and matches the committed copy (drift test).
-
No rule merges without a negative corpus. The negative corpus is the point (kickoff).
-
Zero compiler warnings (release + test), matching panic-attack policy.
-
UK English; AsciiDoc for docs; TOML for config; SPDX
MPL-2.0header on every file;anyhow::Resultfor errors; serde on public types. -
GitHub is the single source of truth; arm every PR for auto-merge on open (estate rule) except the initial owner-decisions issue and any owner-ratification PR.
| # | id | tier | milestone | evidence |
|---|---|---|---|---|
1 |
div-by-literal-zero |
T0 |
M2 |
HEURISTIC |
2 |
self-assignment |
T0 |
M2 |
HEURISTIC |
3 |
constant-condition |
T0 |
M2 |
HEURISTIC |
4 |
while-true-no-break |
T0 |
M2 |
HEURISTIC |
5 |
empty-effect-loop |
T0 |
M2 |
HEURISTIC |
6 |
unreachable-after-jump |
T0 |
M2 |
HEURISTIC |
7 |
swallowed-error |
T0 |
M2 |
HEURISTIC |
8 |
string-concat-in-loop |
T0 |
M2 |
HEURISTIC |
9 |
dead-store |
T1 |
M4 |
DATAFLOW |
10 |
read-before-init |
T1 |
M4 |
DATAFLOW |
11 |
suppress-then-emit |
T2 |
M5 |
PROTOCOL |
12 |
resource-acquired-not-released |
T2 |
M5 (opt) |
PROTOCOL |
13 |
possible-superlinear |
T3 |
M6 |
SPECULATIVE |
14 |
no-interrupt-on-long-op |
T3 |
M6 |
SPECULATIVE |
15 |
general-div-by-zero |
T3 |
M6 |
SPECULATIVE |
-
Evidence class is fixed by tier (T0→HEURISTIC, T1→DATAFLOW, T2→PROTOCOL, T3→SPECULATIVE) and is not overridable per finding — it is the honesty invariant.
-
Severity is advisory and per-rule (kickoff): no T0 rule exceeds
WARN;div-by-literal-zeroandwhile-true-no-breakare capped as the kickoff states; all T3 areINFO. -
A rule that fires on its own negative corpus is demoted (tier down and/or severity down) or removed — never merged as-is (kickoff falsifier discipline).
Implement as a single Rust integration test tests/falsifier.rs (plus a
just falsify alias) that:
-
enumerates every registered rule id;
-
for each, globs
fixtures/<rule-id>/positive/*/and asserts the rule produces ≥1 finding of that rule id in each positive file (an unflagged positive file fails the build, naming the file); -
globs
fixtures/<rule-id>/negative/*/and asserts 0 findings of that rule id across all of them (any hit fails the build, naming file + line); -
a rule with an empty or missing negative corpus fails the gate — the corpus is mandatory.
Wire into CI as a required check. This is the kickoff’s "zero findings on negative corpora is build-breaking" made concrete. Keep it rule-scoped: a finding from a different rule in another rule’s fixture is not a failure (so fixtures can be shared), only same-rule-id hits count.
Fixture file naming: free-form; a leading README.adoc per rule folder
explaining why each negative case is a legitimate non-finding (this doubles as
catalogue source material and as the "expert knows when it’s fine" record).
-
Store queries as
.scmdata files underpons-rules/rules/<rule-id>/<lang>.scmwhere practical, loaded at startup; keep the Rust predicate (the "small local predicate" a pure query can’t express — e.g. identifier equality forself-assignment) beside them. This matches the kickoff’s "rules as data" intent and lets the catalogue generator read metadata without running code. -
Node-type names differ per grammar — e.g. Python
binary_operator, JSbinary_expression, Rustbinary_expression; Pythonexcept_clause, JScatch_clause, Rust has no exceptions (rule 7swallowed-errorskips Rust, but considerlet _ = result;-style ignoredResultas the Rust analogue — out of scope for v0.1.0, note only). Encode the per-language node names in each rule’s.scm; the fixture corpus is your check that you got them right. -
Prefer a query that over-captures + a Rust predicate that narrows, over a clever all-in-one query — the predicate is testable in isolation.
-
string-concat-in-loop(rule 8) andconstant-condition(rule 3): the loop- ancestor / literal-boolean checks are structural; walk parents from the captured node rather than encoding ancestry in the query.
Per finding → one result:
-
ruleId=rule_id;results[].ruleIndexintotool.driver.rules. -
level: T0/T1/T2 mapERROR→"error",WARN→"warning",INFO→"note". T3/SPECULATIVE⇒ always"note"regardless of severity. -
rank: HEURISTIC 50, DATAFLOW 80, PROTOCOL 70, SPECULATIVE 20 (low rank = visually deprioritised in viewers that honour rank). -
message.text=message; forSPECULATIVE, append " (heuristic — not a verdict)". -
locations[].physicalLocation= file URI +region(1-based line/col
byte offsets). -
properties(survives round-trip on every consumer):evidenceClass,tier,evidenceNote, andcounterConditionwhen present. -
tool.driver.rules[](reporting descriptors):id,name,shortDescription,defaultConfiguration.level, andproperties.tier/properties.evidenceClass.
Validate output against the SARIF 2.1.0 JSON schema (bundle the schema in
tests/ and validate with jsonschema), plus a smoke check with the Microsoft
sarif-tools/sarif validate if available in CI. Golden-file the exact output
for a fixed corpus.
Pin exact versions in the workspace Cargo.toml; a grammar bump is a
deliberate, corpus-gated change (ADR-0001 consequence). Use the latest stable
tree-sitter core compatible with all four grammars at implementation time;
record the resolved versions here in M0 and treat this table as the source of
truth thereafter.
| crate | purpose | pin (fill at M0) |
|---|---|---|
|
core parsing/query API |
|
|
Python grammar (T0 + T1 + T2) |
|
|
JS grammar (T0) |
|
|
TS + TSX grammars (T0) |
|
|
Rust grammar (T0) |
|
|
JSON + SARIF serialisation |
|
|
protocol + |
|
|
CLI |
|
|
error handling (house style) |
|
|
file discovery + |
|
Extension → Lang table (source.rs / lang.rs): .py→Python; .js/.mjs/
.cjs→JavaScript; .ts→TypeScript; .tsx→TSX; .jsx→JavaScript (or TSX —
decide at M1, document); .rs→Rust. Unknown extensions are skipped silently
unless --lang forces one.
{
"schema_version": "0.1.0",
"tool": { "name": "pons", "version": "<cargo pkg version>" },
"scanned": { "root": "<abs path>", "files": 123, "languages": ["python","rust"] },
"counts": { "by_evidence": { "HEURISTIC": 4, "DATAFLOW": 2,
"PROTOCOL": 1, "SPECULATIVE": 3 },
"total": 10 },
"findings": [
{
"rule_id": "dead-store",
"tier": "T1",
"evidence": "DATAFLOW",
"severity": "WARN",
"location": { "file": "app/svc.py",
"byte_start": 812, "byte_end": 819,
"line_start": 41, "col_start": 5,
"line_end": 41, "col_end": 12 },
"message": "value assigned to `total` is never read before it is overwritten",
"evidence_note": "live-variable analysis: `total` not in LiveOut after this store",
"counter_condition": null
}
]
}Field order stable (golden-file tested). counter_condition is null when
absent, never omitted. This envelope is compatible in shape with panic-attack’s
JSON habit (schema_version + typed arrays), easing the ADR-0004 records sink.