All notable changes to this project are documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
-
#109— every accepted record was validated twice.repair_recordcalledtle.validate_record(line1, line2)after both lines had already passedrepair_line's fullvalidate_line. The only new information for two individually-valid lines is the catalog-number cross-check. A newtle.validate_record_catalog(l1, l2)helper performs only that check, returning the byte-identical error string;repair_recordnow calls it instead.validate_recordis unchanged. Property tests confirm equivalence for all matching and mismatched valid pairs. -
#110A—compute_checksumhot-path: per-char membership tests replaced with a precomputed lookup table. The original loop calledch in _DIGITthenint(ch)for every character. A module-level_CHECKSUM_CONTRIBdict (ASCII digits'0'–'9'→ their integer value,'-'→ 1, absent = 0) reduces the loop body tosum(_CHECKSUM_CONTRIB.get(c, 0) for c in line[:68]) % 10— one dict lookup per character. Byte-equivalent by construction; the existing checksum property tests confirm invariance. -
#123— pipeline allocation micro-optimisations. (a)slots=Trueadded toRecordCandidate,Orphan,_ProgressBatcher(pipeline.py) andAccepted,Quarantined(repair.py) — eliminates per-instance__dict__allocation on every record; slotted dataclasses pickle correctly across the worker pool. (b)_record_acceptancenow writes both cleaned lines in a singlehandle.write(line1 + "\n" + line2 + "\n")call — byte-identical output, half the Python-level write calls on the accepted-record hot path. (c)_ProgressBatcher.enabledwas a@propertyre-evaluated every call; replaced with a_enabled: boolfield computed once in__post_init__.
#120/#106— the validator now returns a typedtle.FieldErrorinstead of a bare error string.FieldErrorsubclassesstr(so every consumer that treats an error as text — substring tests,"; ".join(...), f-string interpolation — keeps working byte-for-byte) while carrying structured fields:kind("length"/"column"/"semantic"/"checksum"/"catalog"), a 1-indexed inclusivecolumn_range, andobserved/expected.repairnow routes onFieldError.kindrather than grepping the prose for"checksum"(the brittle contract #106 pinned as a tripwire), and populatesreport.jsonl'scolumn_range/observed/expectedfor column, semantic, and catalog findings — previously they were filled only for checksum mismatches. Thereport.jsonlline schema stays"1": the field set and types are unchanged; only previously-nulloptional values are now filled in. Human-facing output (report.md, the.broken.txtsidecar, thenotefield) is byte-identical — pinned by the sgp4 oracle and the full existing suite.
-
#87/#99— the out-dir lock had a TOCTOU reclaim race, a blind release, a post-reboot wedge, and a PID-reuse hostage. The hand-rolled pidfile read the holder's PID, checked liveness withos.kill(pid, 0), andunlink+retried to reclaim a dead lock — none of which re-verified the file it was deleting, so two runs could both reclaim and proceed (#87, P0, reproduced), and a run whose lock was raced away blind-unlinked the current holder's lock on exit. Identity also embedded Linuxboot_id, so a crash-then-reboot left an unreclaimable "different host" lock (#99), and a recycled PID kept a dead lock alive. Replaced the whole scheme with an advisoryfcntl.flockheld for the run: the kernel releases it the instant the holder closes its fd, exits, is killed, or the host reboots, so liveness needs no PID check or boot-id and there is no reclaim step to race. Release is the bareos.closeof our own fd — a run can only ever drop its own lock, never a successor's. The.clean.lockfile is deliberately never unlinked (flockbinds to the inode; unlinking would let a racing opener lock an orphaned inode), and now records{host, pid, started}only as informational text for theLockHeldErrormessage, which names the file and the manual-removal escape hatch. POSIX-only; Windows is out of scope (use WSL). A shared out-dir across hosts over a network FS is documented as untested (relies on server-sideflockpropagation). -
#95— a newline-free or CR-only multi-GB file was materialised as one giantbytesobject, violating constant-memory (Critical Rule #3).iter_recordspreviously iterated over the binary handle withfor raw in handle, which splits only on\n; a file with no\n(or only\rterminators) buffered the entire file as onerawchunk — a 3.2 GB file would load whole, OOM the worker, and then be pickled across the pool boundary. Fixed by replacing the iterator withhandle.readline(_MAX_LINE_BYTES)(C-level, throughput unchanged for normal lines). A chunk of exactly_MAX_LINE_BYTES = 4096with no trailing\nis the start of an oversized line: the excerpt is kept as a bounded quarantine payload, the remainder is drained in fixed-size chunks (bytes still counted intobytes_consumed), and oneOrphanwithRuleID.LINE_LENGTHis emitted for the logical line. The raw bytes in the quarantine entry are noted as truncated — the one place byte-faithfulness yields to constant-memory, and only for a pathological input. Normal lines (the entire real corpus) are processed byte-identically.stats.bytes_consumedstill reachesst_sizeat EOF, andinput_lines_seencounts each logical line exactly once. -
#104—QuarantineSink.__enter__was not exception-safe;cleaned_handlewas opened outside the sink'swithblock.QuarantineSink.__enter__entered itsBrokenFileWriterand then itsJsonlFindingsWritersequentially: if the jsonl writer'sopenfailed (disk full, unwritable.shards), the already-enteredBrokenFileWriter.__exit__never ran, leaving a leaked body handle and.broken.txt.body.partialdebris. Inpipeline._run,cleaned_handle = open(...)happened beforewith sink:so asink.__enter__failure leaked the cleaned.partial. Fixed with two changes: (1)QuarantineSink.__enter__now uses acontextlib.ExitStack— each sub-writer is entered onto the stack; on successstack.pop_all()transfers ownership toself._stack(closed by__exit__); a mid-enter failure unwinds already-entered writers via the stack's own cleanup. (2) Inpipeline._run,cleaned_handle = open(...)is now opened inside thewith sink:block (before the inner try/finally) so asink.__enter__failure cannot leak it — the handle simply doesn't exist at that point. -
#101a— broken sidecar excluded from resume integrity check when no records quarantined.resume.output_sizespreviously recorded the.broken.txtsidecar only whenstats.quarantined_count > 0, butpipelinealways writes a header-only sidecar even for a clean file. A file whose sidecar was deleted or truncated would not be detected on resume. The sidecar is now recorded unconditionally. -
#101b— output naming convention duplicated across modules. Suffix and dirname strings (.cleaned.txt,.broken.txt,.findings.jsonl,cleaned,broken,.shards) were re-encoded independently inpipeline._clean_output_paths,resume.output_sizes,cli.discover_paths, andreport_writers.concat_findings_shards. They now live as module-level constants (CLEANED_SUFFIX,BROKEN_SUFFIX,FINDINGS_SUFFIX,CLEANED_DIRNAME,BROKEN_DIRNAME,SHARDS_DIRNAME) inlintle/__init__.py— the single source of truth — and all consumers import from there. -
#117—concat_findings_shardssilently skipped a missing shard, causingreport.jsonlto underreport vsreport.jsonon resume. On a resumed run, completed files' stats come from the checkpoint (not reprocessed), so their findings shards are not regenerated. If a shard was deleted out-of-band,report.jsonlwould omit those findings whilereport.jsoncounted them — a silent disagreement. Fixed with two defenses: (1) the findings shard is now recorded inresume.output_sizes, so a missing or truncated shard on resume triggers reprocessing — regenerating the shard and keepingreport.jsonlcomplete; (2)concat_findings_shardsnow returns the list of source filenames whose shard was missing but had quarantined records so the caller (output_artifacts) can surface awarning:on stderr. -
#105— stale-checkpoint archives accumulated unboundedly.archive_checkpointnow prunes older archives after creating a new one, keeping only the newest 3 (_STALE_ARCHIVE_KEEP). The ISO-8601 timestamp suffix is lexicographically sortable so the oldest entries are reliably identified and removed.
- Failed input files are now recorded in the run envelope (issue #83). When a worker
raises,
run.failed_filescarries a[{"file": basename, "error": str}]list (sorted, always present —[]on a clean run) andsummary.failed_countmirrors its length.report.mdgains a## Failurestable when any file failed (omitted on a clean run). Exit code 2 is unchanged for this case. Schema version bumped"2"→"3"because both new fields are required (not additive-optional). clean --reconstruct-checksumopts in to tier-2 missing-checksum reconstruction.
#94— disk-space guard charged the wrong amount. The 2× guard now runs at the right moment in each branch. For a--resumerun it charges 2× the remaining (unprocessed) input bytes — so a nearly-complete resume on a tight disk is no longer wrongly refused. For a fresh run it runs afterscrub_outputsso the freed prior outputs are already reflected in the available space before the guard fires.#93—scrub_outputshad no ownership check. A fresh run on a mistyped--out-dirpointing at a directory with user content (e.g. acleaned/subdirectory) could silently destroy it. The preflight now refuses (exit 2, no data destroyed) when the out-dir is non-empty and carries no lintle-ownership signal (.lintle-outputmarker, checkpoint, or stale-checkpoint archive). A.lintle-outputmarker is written on every first fresh run so subsequent runs and scrubs recognise the directory.#102—scrub_outputsleft prior-run report artifacts. An interrupted fresh run could leave a stalereport.json(from the prior run) thatlintle reportwould then render as current.scrub_outputsnow also removesreport.md,report.json,report.jsonl, andbroken-noradids.ndjsonso the out-dir is truly clean before a new run's workers write fresh outputs.
- Records whose lines carry leading whitespace now pair and repair via the
leading-trimfix class instead of being quarantined asBAD_PREFIX.iter_recordsmatches the1/2prefix on a whitespace-trimmed view while carrying the raw bytes forward to the repairer (issue #88).
- Missing-checksum reconstruction is now opt-in (default off). A checksumless 68-char
line is quarantined by default rather than having a recomputed checksum appended: a dropped
trailing data character is indistinguishable from a dropped checksum, so reconstructing it
by default could silently emit wrong-but-valid data (Critical Rule #2, issue #82). Pass
--reconstruct-checksumto restore the recompute. The flag is part of the resume run-identity, so changing it forces a re-run rather than folding mismatched outputs.
- A clean run now persists its run envelope as
report.jsonin the output directory — byte-identical to the--report jsonstdout output — alongsidereport.md,report.jsonl, andbroken-noradids.ndjson. - A new read-only
lintle report [out-dir]subcommand re-renders a prior clean run's aggregate summary from itsreport.json(text → panel on stdout;--report json→ the file's bytes verbatim). A missing or unreadablereport.jsonexits2. - New runtime dependency:
humanize>=4,<5— human-readable durations and sizes in the human display (panel duration viaprecisedelta, roster sizes vianaturalsize(gnu=True)). Pure-Python, zero transitive deps; confined tosummary.pyandcli_progress.py(stderr/stdout panel only — structured output is unaffected). - New dev dependencies:
hypothesis>=6,<7(property-based tests for the validator and repair logic) andpytest-xdist>=3,<4(parallel test execution — the default suite now runs with-n auto).
cleannow renders a terminal-width-responsive aggregate summary panel to stderr at the end of every run (replacing the per-file stdout summary dump); text-mode stdout is now empty, and the per-file detail lives inreport.md/report.json.- The
cleansummary panel now shows elapsed time in human-readable form (e.g. "2 minutes and 4 seconds" instead of raw seconds) and the pre-run roster shows file sizes ingnu-unit notation (e.g. "3.0G") viahumanize. Fixes a roster unit bug where the old hand-rolled_format_sizeused binary (1024-based) division but decimal labels — so 3 GiB rendered as "3.0 GB" (binary value, wrong "GB" label) rather than the correct "3.0G";naturalsize(gnu=True)is now used consistently. Display-format change only — structured outputs carry raw numbers as before.
- The
lintle validatesubcommand (read-only audit mode) has been removed from the CLI. Uselintle clean; itsreport.md,report.jsonl, and--report jsonenvelope cover all audit needs thatvalidatepreviously addressed. The validator definition (tle.py) and the streaming pipeline are unchanged — this was a CLI-surface removal only.
- The
cleanlive progress block now shows, per in-flight file, a byte throughput (rich.progress.TransferSpeedColumn) and a time-remaining ETA (TimeRemainingColumn) — derived from the per-file byte total already supplied, so a multi-hour 30 GB run shows real per-file speed and ETA. The overall row gains a files-done/total counter (MofNCompleteColumn). These columns are gated by task kind (a small_ForKindwrapper) so the byte columns never render on the file-count overall row and the counter never renders raw bytes on a per-file row. TTY-only, additive UX — off a TTY the plain per-file summary lines are unchanged, and stdout / structured output are untouched. - A spinner (
richstatus) now covers the otherwise-silent report finalization after the progress block exits — writingreport.md,broken-noradids.ndjson, and concatenating the per-worker shards intoreport.jsonl(the slow part on a large corpus). TTY-only; a no-op context off a TTY, so piped/structured output is unaffected.
- Upgraded the
richruntime dependency from the 13.x series to 15.x (rich>=15,<16). No behavioural change — the stderr-only progress UI, roster, anderror:/warning:rendering are unchanged (verified by the byte-exacttermtests and the progress/roster suite); stdout and structured outputs never touchedrich. - Dependency pinning policy: every dependency (runtime and dev) is now pinned
>=current_major,<next_major— minor/patch releases resolve automatically, but major upgrades are deliberate and manual, one at a time. Caps added to the dev group (pytest<10,pytest-cov<8,ruff<0.16,sgp4<3). SeeARCHITECTURE.md§7.
- The
cleancancel message no longer claims it will "continue where it stopped". Resume granularity is a whole file: re-running skips fully-completed files and restarts the file interrupted mid-stream, so a single-file run that is cancelled starts over from the beginning. The message now says so, and drops the dangling--no-resumehint when nothing had completed (there is no checkpoint to ignore).
- README restructured for newcomers/evaluators — it now leads with the pitch
and the common commands, with the deeper design rationale moved to
ARCHITECTURE.md. Reorganised for faster onboarding; no content lost. - README "Cancelling and resuming" and ARCHITECTURE §5 now state the per-file resume granularity (completed files skipped, in-progress file restarted) upfront, rather than leaving it to be inferred.
-
cleangains a redesigned live progress UI (issue #53): a one-shot size-only roster of the files to be processed (printed instantly fromos.stat— no pre-read of the corpus), a multi-file per-worker progress block showing each active file's byte progress and running record count, and exact per-file counts at completion. The--jobsdefault is now CPU count − 1, capped at the file count (reserving a core during the long run; an explicit--jobsis still honoured as-is). This adoptsrich(>=13,<14) as the first runtime dependency, clearing the four-bar policy (authoritative spec §3.1): it replaces ~150 lines of hand-rolled ANSI incli.py, is the de-facto standard live-display library (pip,uv,pdm), is pure-Python with a small transitive surface (markdown-it-py,pygments), and is confined to terminal rendering incli.py. -
cleannow prints a borderline disk-space warning when free space on the--out-dirvolume sits between the 2× input-size abort floor and a 2.5× ceiling. The abort path is unchanged — exit2below 2×, message unchanged — but a run that previously fell silent above the floor now surfaces awarning:line on stderr (free space in <out-dir> is close to the 2× safety guard: N bytes free of ~M recommended; the run will proceed but may exhaust the disk) when free is in the 2×-to-2.5× band, so users know they are cutting it close before commits start exhausting the disk. Internal:cli._check_disk_spacenow returns a(severity, message)tuple —"error"(caller aborts) or"warn"(caller prints and proceeds) — orNonewhen free is comfortably above the warn ceiling. -
--max-quarantined(on bothvalidateandclean) now accepts a trailing%to express the exit-code threshold as a rate rather than an absolute count.--max-quarantined 1%exits non-zero if more than 1% of routed records (clean_count + quarantined_count) were quarantined; the integer form (--max-quarantined 100) is unchanged and the default0still means "any quarantine fails". The two modes are mutually exclusive by construction — a single value is either a count or a rate, never both — which sidesteps the combination semantics that a separate--max-quarantined-pctflag would have forced. Comparison is strictly greater (100*q > p*r, cross-multiplied to avoid divide-by-zero on an empty corpus and float drift at the boundary);0%≡0and100%effectively never trips. Design atdocs/superpowers/archive/specs/2026-05-27-max-quarantined-percentage-design.md. -
Host-aware out-dir lock: refuses to start a second concurrent
cleanagainst the same--out-dir.
-
Terminology unified on "quarantine". The codebase and outputs used "reject" and "quarantine" interchangeably; everything now says quarantine (the act of setting a bad record aside). The stdout summary label
rejects:is nowquarantined:, andlintle explaincalls a rule a "quarantine rule". Internals renamed to match (QuarantineSink,QuarantineEntry,Quarantined, etc.). Breaking change to two machine-readable surfaces:--report json: the per-rule mapreject_countsis renamedquarantine_counts(in bothsummaryandfiles[]), andschema_versionbumps"1"→"2". Consumers keying onschema_version == "1"orreject_countsmust update.- The
clean --resumecheckpointSCHEMA_VERSIONbumps2→3; a checkpoint written by an olderlintleis refused and the run restarts fresh (the existing refuse-on-change behaviour — no data loss, the prior outputs are archived).
The
report.jsonlfindings stream andlintle diffare unaffected (they never carriedreject_counts; theirschema_versionstays"1"). StableRuleIDwire tokens (TLE-CHK-001, …) are unchanged. -
All CLI stderr messages now route through
rich:error:lines render bold-red andwarning:lines yellow on a terminal, while status, prompt, and cancel notices share the one stderrConsole. Output is unchanged off a TTY (pipes, CI, redirects) — no ANSI, no wrapping — so machine-readable stderr and stdout/result data stay plain. Internally a newterm.pyleaf owns the shared Console and theerror/warning/note/promptemitters, so the styled prefix lives in one place (used by bothcli.pyanddiff.py). -
cleannow resumes by default after an interruption: re-run the same command (same--out-dir, unchanged inputs) to continue where it stopped. Interactive terminals prompt; CI/non-TTY auto-resumes with a notice.--no-resumestarts fresh (clearing prior outputs);--resumeresumes without prompting. -
Cancelling (
Ctrl-C, orSIGTERM/SIGHUPfrom a scheduler) prints how to continue or start over. -
Breaking change. Minimum Python is now 3.14 (was 3.11).
requires-python,tool.ruff.target-version,.python-version, and the trove classifiers all bumped together; drops 3.11 / 3.12 / 3.13 support. Aligns lintle with the drunik-org Python stack standard (drunik / lintle / descent-engine all on Python 3.14,line-length = 88,target-version = "py314", ruff rule set["E","F","I","UP","B","SIM"],pytest-covin the dev group). -
Every output file
cleancommits — thecleaned/files,.broken.txtsidecars, findings shards,report.jsonl/report.md/broken-noradids.ndjson, and the--resumecheckpoint — is now committed durably, not just atomically: a newlintle.fsutil.durable_replacehelperfsyncs the file's data,os.replaces it into place, thenfsyncs the containing directory, so a committed file survives a hard power loss or kernel panic rather than only a clean Ctrl-C / sleep / crash. On macOS the true power-loss barrier isF_FULLFSYNC(plainfsyncdoes not flush the drive's write cache);fsutiluses it there and plainos.fsyncon Linux/other platforms. This closes the gap that mattered most forclean --resume(#56), which trusts a previously-committed output without reprocessing it: the worker now makes its outputs durable before the parent records the filecompleted, so the checkpoint can never name a file whose bytes are not yet on disk. Durability is always-on (no flag) — measured at roughly 1 second of overhead across a full ~120-commit run on the 30 GB corpus. Closes #58. -
Breaking change.
lintle validateandlintle cleannow accept exactly one positional input — a single file or a single directory — instead of zero-or-more. The default remainsdata/source. Scripts invokinglintle clean dirA dirB(or multiple explicit files) will now fail at argparse with a usage error. Run the tool once per input directory (for d in dirA dirB; do lintle clean "$d"; done), or stage the inputs into a single directory first (e.g.mkdir merged && cp dirA/* dirB/* merged/ && lintle clean merged). This trims speculative flexibility the documented workflow never exercised: the per-file output names are derived from each input's basename alone, so multi-input runs needed a defensive collision check whose existence was the only reason multi-input was risky in the first place. With single-input, basenames within one directory are unique by filesystem guarantee, so the failure mode and its guard disappear together.
cli._detect_basename_collisionsand itsTestDetectBasenameCollisionstests — no callers after the single-inputvalidate/cleanchange above.- The realpath dedup loop inside
cli.discover_paths(a single input has nothing to dedup against).discover_pathsandcheck_pathsnow take a single path string rather than a list.
- New
clean --resumeflag for single-run resume: continue an interruptedclean(Ctrl-C, a closed laptop, a crash) so it processes only the files not yet completed, rather than restarting the whole corpus. Checkpointing is always-on — the parent fingerprints every input up front and atomically rewrites a.clean-state.jsonin--out-dirafter each file commits, deleting it on full success, so the checkpoint's presence marks an interrupted run and a finished run leaves none behind.--resumevalidates refuse-on- change: any drift in thelintleversion or an input's identity (size,mtime_ns, head/tail 64 KB hash) aborts with a specific message (exit2) rather than mixing outputs from two states. Completed files' findings shards survive the interruption, so a resumed run'sreport.jsonl,report.md, andbroken-noradids.ndjsonmatch a non-interrupted full run. This is not a cross-run cache (the rejected design §13, #12) — it is scoped to finishing one run and never skips re-validation of records it emits. Newlintle.resumemodule;report.stats_from_summaryreconstructs aFileStatsfrom its JSON summary so reused files appear in the final report. Closes #56. - New
lintle explain <TAG>subcommand turns the validator into its own reference: it documents both public vocabularies lintle stamps on a report — the rejection rules (RuleID, e.g.TLE-CHK-001) and the repair tags (FixClass, e.g.reconstructed-checksum). For any tag it prints a plain-English definition (single-sourced fromRuleSpec/FixSpec, never re-described), a good/bad or before/after example with the failing column marked, the repair-tier linkage, and a source-of-truth citation into the code. Read-only; an unknown tag exits2listing every valid tag. Every example is the same object the test suite validates against the livetle.py/repair.pyacross all classification layers (line, pairing, record), so the docs cannot silently drift from validator behaviour; import-time guards make explain-coverage and tag-namespace disjointness structural. A newFixSpec/FIXESregistry gives each repair tag a canonical one-line definition, mirroringRuleSpec/RULES. Thereconstructed-checksumentry carries an explicit safety note (the only sanctioned reconstruction: a deterministic recompute, re-validated in full before commit — never a guessed data character). Closes #11. - New
lintle diff RUN-A RUN-Bsubcommand compares two clean-run output directories by streaming each one'sreport.jsonland printing the defect classes new in B, the classes fixed (present in A, absent in B), the per-rule count deltas, and a per-file (per-basename) breakdown — turning "eyeball tworeport.mdfiles" into a focused delta of what the upstream export pipeline broke, fixed, or shifted between runs. Read-only; writes nothing. Counts the primaryrule_idof each finding only — never therelated[]array — mirroringpipeline._record_rejectso the diff's per-rule totals agree with each run's ownreport.md. The corpus-level totals are derived by summing the per-file counts, so the two sections can never disagree. A mismatched (or missing)schema_version, a malformed line, non-UTF-8 bytes, or a missingreport.jsonlis a hard error (exit2); a clean comparison exits0. The per-file breakdown is keyed by thereport.jsonlfilebasename: becausecleanrefuses inputs with colliding basenames (_detect_basename_collisions), each basename names exactly one file within a run, so the key is unambiguous. A basename present in only one run is flagged ("only in run A/B — fixed, removed, or renamed") rather than attributed, and never rendered as a misleadingN -> 0, sincereport.jsonllists only files that had findings. Memory is bounded by (distinct files × distinct rule IDs), not the number of findings. Decision recorded indebates/010-lintle-diff-implementation/. Closes #10. - New
--max-quarantined Nflag on bothvalidateandclean(issue #13). Exit code stays0when the total quarantined record count is at or belowN; flips to1only when more thanNrecords were quarantined. The defaultN=0preserves the historical "any quarantine fails" contract, so the flag is purely opt-in for CI/DataOps callers that need a tolerance budget. Unlikelintle ... || true; jq -e '.summary.quarantined_count <= N', the flag preserves the meaningful2(operational error) and130(Ctrl-C) exit codes that a swallow-and-parse wrapper would mask. The two other thresholds floated in the original issue (--threshold RATIOand--fail-on RULE-ID=N) were intentionally NOT shipped:--thresholdis redundant with--max-quarantinedand adds denominator ambiguity, and--fail-onwould promoteRuleIDstrings from "report artifact" to "CI YAML public-forever contract" — a meaningfully bigger compatibility promise that should wait on real user demand. Decision recorded indebates/013-fail-on-threshold-flags/. lintle validate --report json(andlintle clean --report json) now emits a top-level versioned envelope object instead of the prior flat array of per-file summaries. The shape is{schema_version, run, environment, summary, files}—runcarries the subcommand name, the ISO 8601 UTC start timestamp, and the parent-process wall-clockelapsed_seconds;environmentcarriestool_versionandpython_version(no env vars, paths, or hostnames);summarycarries corpus-wide aggregates (files_processed,paired_records,clean_count,quarantined_count,fix_counts,reject_counts);filesis the per-file array, where each entry is the existingsummary_dict()shape extended withelapsed_seconds,bytes, andrecords_per_sec. The throughput field is always a stable float (denominator clamped to 1 ms) — nevernull— so statically-typed consumers can declare a single type without sentinel handling. Per- file timing is captured by each worker viatime.monotonic();summaryaggregates are NOT summed worker durations (--jobs Nparallelism would inflate that), sorun.elapsed_secondsis the authoritative end-to-end duration. The contract is locked bydocs/superpowers/archive/specs/2026-05-25-report-json-envelope.mdand the golden fixture attests/fixtures/report-envelope-v1.golden.json(the envelope was later bumped to schema"2"and the fixture renamed-v2; see the Unreleased section). Closes #20.- New
lintle.diagnosticsmodule defines a stable, citable rule-ID registry (TLE-COL-001,TLE-CHK-001,TLE-PAIR-001, …) and a structuredDiagnosticdataclass withrule_id,source_line_nos,tier_attempted,column_range,observed,expected, andnotefields. Reject reasons are no longer free-form prose — they are now structured records keyed by a stable identifier that downstream consumers can pin inreport.md, the.broken.txtsidecar, JSON output, and future tooling. Rule IDs follow theTLE-<FAMILY>-<NNN>shape (families: COL, CHK, PAIR, SEM, INT) and are never recycled — retired IDs stay readable forever. Includes aRuleSpecregistry (RULES) with metadata about every rule, queryable for futurelintle explain TLE-XXX-NNNtooling. Closes #8. - The
report.mdrun report now includes a "Rule reference" section, auto-generated from thediagnostics.RULESregistry, listing every rule that fired in the run with its short title so the report is self-explanatory. report.mdnow ends with a## Per-NORAD breakdowntable: one row per satellite catalog number whose records were quarantined, with the corpus-wide quarantine count, the per-rule defect breakdown, and the source filenames the satellite appeared in. Rows are sorted by quarantined-record count descending (NORAD ID ascending on ties); the Files column shows the first five filenames alphabetically followed by a+N moresuffix when the satellite spans more files than that, keeping the cell bounded for persistent NORADs. The table caps atformat_run_report(all_stats, top_n=100)rows by default with an italicised "...and N more — see broken-noradids.ndjson for the full list." footer when truncation activates; passtop_n=Noneto render every row. The richer per-NORAD data is the human-facing counterpart tobroken-noradids.ndjson, whose{"noradId":N}contract stays minimal. Closes #40.- Per-rule drop visibility everywhere
lintlesurfaces reject totals.FileSamplegains adropped_count: dict[RuleID, int]field, populated byRejectSink.addwhen the per-rule bucket is at cap (the bound that in-memory exemplars are capped at — full byte-faithful catalog reaches.broken.txtregardless). The new data threads through three surfaces: thelintle validatesummary's per-rule heading switches from(M):to(N of M hits, K dropped):whenK > 0; the JSON output (lintle validate --report json) gains adropped_countsfield parallel toreject_counts, keyed by stable rule IDs; andreport.md's "Records quarantined (by rule)" table gains aDroppedcolumn summed across files. The trailing...and X moreunder each rule block in the validate summary stays, so the existing truncation-indicator stays visually anchored to the exemplars it applies to. Closes #46. lintle cleannow emits a corpus-widereport.jsonlalongsidereport.mdandbroken-noradids.ndjson: one JSON object per quarantined record, citing the stableRuleID(TLE-CHK-001,TLE-COL-003, …) and carrying the structured fields downstream automation needs —file,source_lines,tier_attempted,norad_id,column_range,observed,expected,note, andrelated(secondary diagnostics). Every line carriesschema_version: "1"andoutcome: "quarantined"(the latter reserves space for future"fixed"emission without breaking consumers). The format is compact (json.dumps(..., separators=(",", ":"))), key-sorted (sort_keys=True), UTF-8, LF-terminated — byte-deterministic across runs on identical input, enabling content-hash caching and thelintle diffconsumer (issue #10). Streaming is per-worker: each worker writes<out_dir>/.shards/<stem>.findings.jsonl; the main process concatenates shards in alphabeticalsrc_nameorder at end of run and removes the shard directory. A pre-run shard-dir scrub prevents contamination from prior aborted runs. The byte-faithful catalog stays inbroken/<stem>.broken.txt;report.jsonlis the structured-findings stream consumers canjqagainst. TheRejectEntrydataclass gains a trailing optionalnorad_idfield decoded once at quarantine time. Closes #9.
- Breaking —
--report jsonoutput shape. The flat array of per-filesummary_dict()entries previously emitted bylintle ... --report jsonis replaced by the top-level envelope described under Added above. Consumers that didpayload[0]to read the first file's stats now dopayload["files"][0]; the per-file keys (src_name,paired_records, …) are unchanged but join three new ones (elapsed_seconds,bytes,records_per_sec). No legacy flag is provided; the schema is pinned byschema_version: "1"so future minor revisions stay additive within"1"and any breaking rename bumps to"2". - Internal: extracted
RejectSinkandFileSamplefromFileStatsso the 5-per-rule exemplar cap is enforced by construction rather than by convention in a single caller.pipeline.process_fileno longer juggles a separatebroken_writerand exemplar dict —RejectSinkowns both responsibilities and the cap is now a structural property of the sink type.FileStats.reject_exemplarsis replaced byFileStats.reject_sample: FileSample(a frozen, per-rule bounded sample).FileSample.from_bounded(cap=N, entries_by_rule={...})is the test-fixture entry point; production code writes throughsink.add(entry). Renderers (format_reject_lines,write_broken_file) read fromstats.reject_sample.buckets. No user-visible byte format changes (.broken.txt, JSON output, andreport.mdare byte-identical to the pre-refactor baseline). Closes #19. - Internal: encapsulated
FileStats.quarantined_norad_idsbehind aNoradTrackertype with a singlerecord(norad_id, rule_id)mutation entry point.pipeline._record_rejectno longer hand-rolls thesetdefault/get/+1dance — future writers will find.record(...)by name instead of reinventing the pattern. Field name unchanged (quarantined_norad_idspreserved so thesummary_dictJSON-key contract andgit log -Shistory stay intact); only the type changed fromdicttoNoradTracker. Renderers (summary_dict,_aggregate_per_norad,aggregate_broken_norad_ids) read viatracker.counts. Sibling refactor to issue #19'sRejectSinkextraction, deliberately simpler — no cap, no file resource, no context-manager, nomerge, no freeze boundary (half-encapsulation by deliberate choice so the per-NORAD data shape stays free to evolve toward per-satellite timestamps or provenance without breaking a monoid contract). No user-visible byte format changes (broken-noradids.ndjson, JSON output, andreport.mdare byte-identical to the pre-refactor baseline). Closes #47. - Free-form short tags used across
repair.py,pipeline.py, and tests are now defined inlintle.categories(forFixClass, the successful-repair taxonomy) andlintle.diagnostics(forRuleID, the rejection taxonomy) asenum.StrEnumclasses, so typos and renames are caught rather than silently drifting across call sites. Closes #18. - Breaking —
.broken.txtsidecar line format. The per-entry headline now cites the rule ID and structured fields instead of a free-form sentence:[N] source lines X-Y - rule: TLE-CHK-001 (tier-1) - col 69 observed='7' expected='3'. Related diagnostics on the same record (when both lines of a record fail) render on indentedand: ...continuation lines. The sidecar header (# source: ... | generated: ... | lintle <version>) is unchanged and already pins the format to a release, so downstream parsers can dispatch on version. - Breaking — JSON output via
lintle validate --report json. The per-file"reject_categories"field is renamed"reject_counts"and its inner keys change from free-form tags ("checksum-mismatch") to stable rule IDs ("TLE-CHK-001").fix_countsand its inner keys are unchanged. The per-file payload also gains"quarantined_norad_ids"carrying the per-satellite per-rule breakdown that backs the new Markdown per-NORAD section (see Added above), shaped as{"<noradId>": {"TLE-CHK-001": count, ...}, ...}— integer NORAD keys auto-stringify,RuleIDkeys serialise as their stable wire token. FileStats.reject_categoriesis renamedFileStats.reject_countsto match the new vocabulary; values are keyed bydiagnostics.RuleID(which compares and hashes as its stable string value).FileStats.quarantined_norad_idsis now adict[int, dict[RuleID, int]]instead of aset[int]: outer keys are still the satellite catalog numbers, but each value is a per-rule count dict tallying which diagnostics that satellite hit in this file.pipeline._record_rejectrecords the rule ID alongside the satellite at quarantine time, feeding the new## Per-NORAD breakdownsection. Thebroken-noradids.ndjsonsidecar still emits one{"noradId":N}line per ID —aggregate_broken_norad_idsnow iterates the dict's keys — so that downstream contract is byte-identical. The per-file map is O(IDs × 9), and the corpus-wide rollup adds an O(IDs × source files) term for the Files column; both are bounded by the satellite catalog and the small fixed number of source files, preserving the constant-memory invariant. Closes #40.validatemode now groups reject exemplars by rule ID (up to 5 per rule, sorted by descending occurrence count with alphabetic tiebreak), so a single noisy defect class can no longer hide rarer rules in the operator summary.FileStats.reject_exemplarsis nowdict[RuleID, list[RejectEntry]]capped at_PER_RULE_EXEMPLAR_BOUND = 5per rule; the per-file memory ceiling drops from 1000 entries to|RuleID| × 5 = 45. Each exemplar line reuses_format_diagnosticso column ranges, observed/expected, repair tier, and related-diagnostic continuations carry over. The on-disk.broken.txtstreaming path is untouched — every reject still reaches the byte-faithful catalog. Closes #21.
lintle.categories.RejectCategory(replaced bylintle.diagnostics.RuleID). Call sites updated;RejectCategorywas internal — no external API breakage beyond the JSON /.broken.txtchanges noted above.
pipeline.process_fileno longer conflates unpaired orphan lines with paired 2-line records in its counter.FileStats.total_recordsis replaced by three independent counters:paired_records(true 2-line entries),orphan_entries(unpaired single lines surfaced as findings), andinput_lines_seen(every physical line read from the file). Per-file summary, JSON output (--report json),.broken.txtsidecar header, andreport.mdrun report all surface the three counters in their own columns so percentages and breakdowns are unambiguous.clean_count/quarantined_countsemantics are unchanged: orphans still go to.broken.txtand remain tallied underreject_categories['orphan-line']. Closes #5.cli.mainnow refuses to run when two distinct inputs share a basename, because theircleaned/andbroken/sidecars would otherwise silently overwrite each other underdata/output/— exactly the kind of wrong-but-valid-looking outcome the spec forbids.discover_pathsalso dedupes inputs byos.path.realpath, so the same canonical file listed twice (literally, via a parent directory, or through a symlink) is processed once. Closes #4.cli.check_pathsno longer pre-checks readability viaos.access. That call consults POSIX mode bits only and false-negatives on filesystems that grant read via ACLs (NFSv4, SMB, FUSE), producing a misleading "unreadable" verdict on inputs the worker can in fact open. The authoritative readability test is the worker'sopen(); a real permission failure surfaces through the per-file processing path with the same exit code 2. Landed alongside the basename-collision fix in commita898fb9. Closes #7.
-
lintle cleannow emits a corpus-widebroken-noradids.ndjsonat the--out-dirroot, alongsidecleaned/,broken/, andreport.md. One{"noradId":N}object per line, deduplicated and sorted ascending, listing every NORAD catalog number whose records were quarantined anywhere in the run. Records whose line 1 is itself unreadable are omitted (no catalog number to recover). Intended for downstream consumers (e.g. descent-app) that need to flag affected satellites without parsing the human-readablebroken/*.txtdefect reports. The file is always written incleanmode — empty when nothing was quarantined — so the artifact is always present. Schema is deliberately minimal (one field); future releases can extend each record additively without breaking compat. Closes #2. -
tle.extract_norad_id()— recovers the 5-digit catalog number from a TLE line 1, used by the new NDJSON emitter. -
The live progress line on a TTY now reports throughput (
records/sec) and the longest-running file currently in flight (with+N morewhen other files are also being processed). With--jobs Nthe oldest active file surfaces alone once peers finish — making a single slow file visible at a glance during long runs of the 29-file corpus. The progress queue now carries("start", name)/("end", name)lifecycle events alongside the existing integer record-count deltas;process_filealways emits("end", name)from afinally, so a failed file is correctly cleared from the display's active set. Closes #24. -
tests/test_pipeline_throughput.py— an opt-in end-to-end throughput regression test forpipeline.process_file()that streams synthetic TLE records and fails on a severe slowdown. Gated by the newslowpytest marker (registered inpyproject.toml, excluded from the default suite viaaddopts), so the existing CI matrix is unaffected. Combines a within-run stability check (no timed run more than 30% slower than the median) with an opt-in per-machine stored baseline attests/.throughput_baseline.json(git-ignored). Run withuv run pytest -m slow -s; refresh the baseline after intentional perf changes withLINTLE_UPDATE_BASELINE=1 uv run pytest -m slow. Closes #23.
report.pynow streams the.broken.txtreject sidecar line-by-line instead of holding the full reject set in memory, so the constant-memory invariant survives files with a high reject ratio.
CLAUDE.md§ Worktree Workflow andCONTRIBUTING.md§ Parallel development with git worktrees — how to iterate on several branches at once while sharing the ~30 GB corpus across worktrees via a symlink..gitignoreexcludes/.worktrees/.
lintle clean(andvalidate) no longer crash with aFileNotFoundErrortraceback when the default input directorydata/sourcedoes not exist on the host. A new input-validation step incli.main()catches the situation upfront and prints a friendly hint that points the user at--helpand explains how to pass paths or create the directory.discover_pathsno longer silently treats a nonexistent path as a file; missing entries are dropped (and the newcheck_pathshelper rejects them at the boundary with a clearno such file or directorymessage instead of a crash deeper in the pipeline).--jobs 0is rejected upfront instead of silently spinning up a zero-worker pool that hangs.
--version/-Von the top-levellintlecommand.- Top-level and per-subcommand help now include an
Examples:block and anExit codes:reference. Subcommands carry richer descriptions and metavars (PATH,DIR,N) solintle --helpandlintle clean --helpare self-explanatory. check_paths(paths, using_default)— a small public helper incli.pythat returns a user-facing error string for missing or unreadable inputs, orNoneif everything is fine.
- The
pathspositional argument's argparse default is nowNone(resolved todata/sourceinsidemain()) so the CLI can tell "user passed nothing" apart from "user explicitly passeddata/source" and tailor the error wording. - The version string is now tracked in one place:
pyproject.toml. The__version__attribute insrc/lintle/__init__.pyis resolved at runtime viaimportlib.metadata.version("lintle")(falling back to0.0.0+localfor uninstalled source checkouts). Future releases need only a single bump inpyproject.toml— seeCONTRIBUTING.mdfor the release flow.
lintleconsole script with two modes:validate(read-only audit) andclean(writes corrected files plus quarantine sidecars).tle.py— the single TLE validator: column layout, mod-10 checksum, semantic range checks, and paired-record validation.repair.py— speculative, validated repairs: trailing-\stripping, CRLF normalisation, whitespace trimming, and deterministic checksum reconstruction.pipeline.py— constant-memory streaming with prefix-driven1/2line pairing.report.py— per-file statistics, the byte-faithful.broken.txtquarantine sidecar, and the Markdown run report.cli.py— argument parsing, path globbing, per-fileProcessPoolExecutorparallelism, a live single-line progress display, and graceful Ctrl-C shutdown (exit code130).- Test suite: 92 tests across 7 files, including an
sgp4oracle cross-check and golden-output / idempotence integration tests;cli.pyis fully covered. - Project tooling:
rufffor linting and formatting,pytest-covfor coverage. - Documentation:
README.md,CONTRIBUTING.md, and this changelog.