description: "Audit end-to-end pipeline integrity and inter-stage data contracts" argument-hint: "[--focus <dims>]"
Pipeline Integrity Audit
Audit the end-to-end conversion chain — parse → map/arrange → frames → detect-patterns →
export → prepare → compile → validate — as a single contract-bound system. The job is not to
re-audit each stage's internal correctness (the subsystem skills own that); it is to verify
that each stage emits exactly what the next stage consumes, that the subcommand-less
run_full_pipeline path stays in lockstep with the step-by-step subcommands, that global
flags route into both paths, and that a failure at any stage stops the run instead of leaving
a stale or broken .nes on disk.
Read .claude/commands/_audit-common.md first — it defines the project layout, the
Inter-Stage Data Contracts table (the authority for what each stage hands off), the
Python-specific drift rules, the dedup protocol, and the per-finding format. Read
.claude/commands/_audit-severity.md for the severity scale and the Special-Rules floors.
Do not restate either file here; this skill only adds the pipeline-specific dimensions.
A large batch of pipeline bugs (F-01..F-13, SAFE-01, SAFE-04, PL-01..PL-06) has since been fixed — every dimension below now describes verify-the-fix checks rather than live bugs. The narrower issues (PL-03..PL-06) found while verifying the first batch are now closed too; confirm each fix still holds and treat any regression as a fresh finding.
Exception — Dimension 8 is not a verify-the-fix dimension. The song build jukebox path
(#30/F-13) shipped recently and is the youngest code in the pipeline; its first audit pass
already found two defects that made it produce zero working ROMs at any bank size
(PL-2026-08-07-1 and friends, fixed in 8ea7ac3). Audit it as new code, not as a
regression check.
Parameters (from $ARGUMENTS)
--focus <dims>— comma-separated dimension numbers (e.g.--focus 1,4). Default: all.
Extra Per-Finding Field
- Dimension: one of the dimensions below.
- Both paths?: does the finding affect the default
run_full_pipelinepath, the step-by-step subcommands, or both? (A divergence between the two is itself the finding.)
Dimensions
Dimension 1: Stage JSON Contract Integrity
Every step-by-step subcommand reads a JSON file written by the previous one. Confirm each
producer key matches each consumer's read. Concrete checks in main.py:
run_parsewrites{"events": ..., "metadata": ...}(fromtracker/parser_fast.pyparse_midi_to_frames).run_mapreads it viaload_json_stage(args.input, ['events'], 'parse'), which now fails with a clean[ERROR]message and exit 1 — rather than a bareKeyError/FileNotFoundError/JSONDecodeError— on a missing/corrupt/ wrong-stage file (load_json_stage,main.py:88-139; SAFE-01/#120, closed). Fixed (#485/PIPE-2026-08-22-1, closed; regression of #377/PIPE-2026-07-19-1):run_frames/run_export/run_detect_patternsstill passrequired_keys=[](their input's channel keys are individually optional, so no single key can be required), but now also passchannel_shape=True—load_json_stagerejects a non-empty JSON object that has none of the five NES channel keys (_PIPELINE_CHANNEL_KEYS,main.py:31, a frozen copy ofCA65Exporter.SEQUENCE_CHANNELScaptured at import time so a test's@patch('main.CA65Exporter')can't silently empty it out), while still accepting a genuinely empty{}(an all-rest song). This was previously a real gap — a parse-stage or detect-patterns-stage file fed to the wrong subcommand silently produced an empty-but-exit-0 result at every stage downstream — verify the guard still fires on a wrong-stage file and still passes a legitimate empty frames dict.run_map→assign_tracks_to_nes_channels(midi_data["events"], dpcm_index_path)(tracker/track_mapper.py).run_frames(main.py:278-313) feeds that JSON straight intoNESEmulatorCore.process_all_tracks. No change observed here; verify the mapped shape the emulator expects still equals what the mapper emits.- Fixed (#498/PAT-2026-08-23-1, closed):
run_detect_patterns(main.py:784-861) used to save only{'patterns','references','stats'}and omitvariations, which both detectors (tracker/pattern_detector.py,tracker/pattern_detector_parallel.py) return. This is now fixed — the persistedoutputdict (main.py:839-844) includes'variations': pattern_result['variations'], matching the in-memory 4-key envelope. The in-memory--no-patternsstub already carried'variations': {}since #258/PAT-09 (the stub lives indetect_patterns_or_direct_export,main.py:1149-1273, extracted out ofrun_full_pipelinesince #406, which calls it for Step 4). Verify-the-fix: confirm the on-disk key stays present and that no consumer regresses to requiring only 3 keys. - #379/PIPE-2026-07-19-3 is CLOSED:
export_frames_and_resolve_mapper(the stage helperrun_full_pipelinecalls for Steps 5-5.5 since #406,main.py:1274-1354) used to hardcode a bare empty dict{}forreferencesregardless of what pattern detection produced, whilerun_exportpasses the detector's native{'pattern_id': [positions]}shape (pattern_data['references']) straight through unmodified. Both entry points now passpattern_result['references']— the real dict, not a hardcoded stand-in. This was already inert either way (CA65Exporter.export_tables_with_patterns,exporter/exporter_ca65.py:1628-1648, still documentsreferencesas not consumed — "retained for call-site compatibility", F-01/#4, confirmed intentional per CLAUDE.md's Assembly Export section), so the fix is forward-compatibility only: it does not change any emitted byte today. Verify-the-fix: ifreferencesis ever wired up to affect output, confirm both entry points still derive it from the samepattern_result/pattern_datasource rather than drifting apart again — that was the exact shape of the original bug. grepeach contract key (events,patterns,references,stats,compression_ratio,variations) across producer and consumer; a key renamed on one side only is the finding.
Dimension 2: run_full_pipeline vs Step-by-Step Parity
- Parser consistency (fixed): the old top-level
from tracker.parser import parse_midi_to_framesimport (the older full parser) no longer exists inmain.py. Both entry points now importparser_fastlocally and identically:run_parse(main.py:250) andrun_full_pipeline(main.py:1434), both asfrom tracker.parser_fast import parse_midi_to_frames as parse_fast. The wrong-parser divergence this bullet used to flag is gone. Verify no other stage reintroduces a third parser (see Dimension 8 — song-bank ingestion was independently fixed to useparser_fasttoo). - Pattern-detector parameter divergence (F-08/#19, closed):
constants.py(imported atmain.py:51) now defines shared module-level constantsPATTERN_MIN_LENGTH = 3/PATTERN_MAX_LENGTH = 12, and bothrun_detect_patterns(main.py:807-809) and the parallel/fallback detector constructionrun_full_pipelinecalls into (detect_patterns_or_direct_export, extracted fromrun_full_pipelinesince #406,main.py:1229/:1237) use these same constants. Verify no other call site (arranger path, any test-only helper) still hardcodes different bounds that could reintroduce the drift. statsschema divergence (fixed): the--no-patternsstub — now living indetect_patterns_or_direct_exportsince #406, not inline inrun_full_pipeline(main.py:1163-1193) — uses exactly the key set both detectors emit —original_size/compressed_size/compression_ratio/unique_patterns(tracker/pattern_detector.py:922-929) — not the oldoriginal_events/patterns_foundmismatch. Verify everystatsreader (success bannermain.py:1517-1521,run_detect_patterns's bannermain.py:850-860) only relies on keys present in both schemas.- Default-vs-step-by-step stage coverage (F-06/#15, closed): this gap is now closed by
the
compilesubcommand (main.py:602-656), which runscompile_rom+validate_romtogether — givingprepare→compileparity with the default path's compile+validate steps. The former residual asymmetry (no backup/restore onrun_compile) is now also closed:run_compilecalls the shared_backup_existing_rom/_restore_backuphelpers (Dimension 6, PL-05/#178, closed), soprepare→compilenow matches the default path's backup contract too. Note a further divergence since #457/SAFE-2026-08-21-3 (see Dimension 4):run_compilestill converts a boolFalsefromcompile_rom/validate_rominto a directsys.exit(1)(main.py:635-641), whilerun_full_pipeline/run_song_buildnow go through the sharedbuild_and_validate_rom(main.py:1355-1400), which raises typedMIDI2NESErrorsubclasses instead, caught by oneexcept MIDI2NESErrorclause in each caller. Both reach the same outcome (clean[ERROR]+ exit 1 + backup restore), just via different mechanisms — not a functional gap, but worth knowing before assuming the two paths share one code path here.
Dimension 3: Flag Routing (--arranger / --no-patterns / --debug / --visualizer / --skip-validation / --version)
Flags are parsed twice: argparse declares --verbose/--debug/--visualizer/--arranger/
--version as global options (main.py:1576-1580), but the hand-rolled dispatch in main()
(the SimpleArgs builder, main.py:1852-1866) re-derives them from a manually whitelisted
global_args list (main.py:1783-1831). Audit both:
- Unknown/typo flags (F-03/#8, closed): the manual loop now
sys.exit(2)s with"Error: Unknown option: <arg>"(main.py:1829) for anything starting with-that isn't in the whitelist (--verbose/-v,--debug/-d,--visualizer,--arranger/-a,--version,--no-patterns,--skip-validation), instead of silently dropping it. Verify the whitelist stays in sync with the argparse-declared globals — a legitimate new global flag not yet added here would now hard-error rather than silently no-op (a usability regression risk, much lower severity than the original silent-song-change bug it replaced). Verified live: the--visualizerflag added alongside this report's cycle is correctly present in both the argparse declaration (main.py:1579) and this whitelist loop (main.py:1795-1797) — the class of gap this bullet exists to catch did not recur. --versioncombined with other args (#179/PL-06, closed): the manual loop now matches argparse'saction='version'semantics — a--versiontoken printsMIDI2NES <ver>andsys.exit(0)s immediately inside the loop (main.py:1801-1807), before any input file is consumed, sopython main.py --version song.midno longer silently runs the full pipeline. The barepython main.py --version(argv length 2) still short-circuits earlier atmain.py:1738. Verify both forms exit 0 and print the version, and that no path files--versionintoglobal_argswhereSimpleArgswould ignore it again.--skip-validationargparse parity (partially fixed): it is now also a first-class argparse argument on thecompilesubcommand (main.py:1662, part of the #15 fix) and onsong build(main.py:1704), not manual-default-path-only anymore.--no-patternsremains manual-default-path-only with no subcommand equivalent — this appears intentional (the per-subcommand analogue is simply omitting--patternsonexport); flag only if you find an input where the default path's pattern-compression decision can't be reproduced via the step-by-step subcommands.--arrangerbefore a subcommand (#174/PL-01, closed): now rejected with a clearsys.exit(2)error (main.py:1755-1770) instead of being silently discarded — and, since #487/PIPE-2026-08-22-3 (closed), the message correctly special-casessong build --arranger(which now has its own--arranger,main.py:1701) instead of claiming no step-by-step equivalent exists anywhere. Verify the positive case still works:--arrangeron the default path reachesarrange_for_nes(main.py:1444-1448) and produces a{channel: {frame: {...}}}structure the downstream pattern/export code accepts identically toprocess_all_tracks's output (no drift observed; worth re-checking after any arranger refactor).--debug/--visualizer→run_prepareparity (#175/PL-02, closed;--visualizernew):run_preparenow passesdebug_mode=getattr(args, 'debug', False)andvisualizer_mode=getattr(args, 'visualizer', False)intoNESProjectBuilder(main.py:672-673), matching the default path's derivation (main.py:1500-1501). Both flags are declared only on the top-level parser (no subcommand-local re-declaration for either), so both rely on argparse's flag-before-subcommand form (python main.py --debug prepare ..., confirmed working directly againstargparse) —prepare --debug ...(flag after the subcommand) is NOT accepted by that subparser and errors with "unrecognized arguments", which is pre-existing behavior for--debug, not a new gap introduced by--visualizer._reject_debug_visualizer_combo(main.py:587-600) rejects the combination with a clear message on both the subcommand-dispatch path (main.py:1775) and the default pipeline path (main.py:1869), in both cases after the flag is populated ontoargs.run_map --config/--dpcm-index(F-05/#13, closed):--dpcm-indexis honored (main.py:259run_map, readinggetattr(args, 'dpcm_index', None) or 'dpcm_index.json');--configwas removed from themapsubcommand entirely rather than left declared-but-ignored.detect-patterns's--configwas subsequently re-added for a narrow, genuinely-consumed purpose — it overrides only the pattern-detection sampling caps (processing.pattern_detection.max_events/max_pattern_events, #219) viaget_pattern_detection_caps(main.py:58-86), declared atmain.py:1629and read atmain.py:792; it does not touch tempo orPATTERN_MIN/MAX_LENGTH. Verify no other subcommand still declares a flag its handler silently ignores (grep everyadd_argumentcall against the body of itsfunc=).
Dimension 4: Error Propagation & Fail-Fast (no broken ROM on stage failure)
The cardinal rule: a stage failure must abort before a stale/garbage .nes is left where the
user expects a good one.
run_full_pipeline's body is onetry(main.py:1431) /except Exception(main.py:1554) /finally(main.py:1564-1567). Verify no innerexceptstill swallows a fatal error and lets the run reach ROM emission:- The DPCM-pack step catches broadly but is genuinely non-fatal by design — it records
a
DpcmPackResult.warningand surfaces it prominently in the success banner rather than burying it (SAFE-04/#123, closed); the ROM still builds without drums. #380/TD-28 closed: the pack logic used to be duplicated inline in bothrun_full_pipelineandrun_export; it now lives in one sharedpack_dpcm_into_asmhelper (main.py:159-249,except Exception as e:at:233) called from both (run_exportatmain.py:764;export_frames_and_resolve_mapperat:1328— the stage helperrun_full_pipelinecalls for this since #406), so this check only needs verifying once instead of per call site. #367/DP-DPCM-05 closed: the warning used to fire only on an all-samples-missing pack; a partial miss (some but not all referenced samples resolve) now also warns, labeled "PARTIAL DPCM MISS" vs "NO DRUMS" (main.py:781/:1528) so a silently-dropped single drum isn't mistaken for "no warning printed, so it worked." validate_rom's own diagnostics-import guard (#177/PL-04, closed): thetry/except ExceptionaroundROMDiagnostics(...).diagnose_rom(...)(main.py:554-558) now returnsFalse(validation failed) — notTrue— on any exception, and prints the warning unconditionally (not only under--verbose). So an infrastructure failure (e.g. a broken import indebug/rom_diagnostics.py) is treated as a failed gate rather than a silently-accepted ROM. Callers only reachvalidate_romwhen the user did NOT pass--skip-validation, so this is the correct fail-closed direction. Verify the return staysFalseand the message stays unconditional; this dimension no longer has an open "continues past a real failure" case here.
- The DPCM-pack step catches broadly but is genuinely non-fatal by design — it records
a
- CC65 failure surfacing (confirmed correct):
compile_rom(compiler/compiler.py:268-312) convertsCompilationError/ValidationError/any other exception into aFalsereturn with a printed[ERROR];compiler/cc65_wrapper.pyraisesToolchainError/CompilationErroron a missing tool or nonzeroca65/ld65exit code throughout (e.g.compiler/cc65_wrapper.py:47,:160,:231; seecore/exceptions.py:88CompilationError,:169ToolchainError). Two different mechanisms reach the same outcome now (see Dimension 2's note):run_compile(main.py:635-641) still does a directsys.exit(1)on a boolFalsereturn fromcompile_rom/validate_rom, whilerun_full_pipeline/run_song_buildgo through the sharedbuild_and_validate_rom(main.py:1355-1400), which raisesExportError/CompilationError/ValidationError(allMIDI2NESErrorsubclasses, #457/SAFE-2026-08-21-3) instead of returning bool, caught by oneexcept MIDI2NESErrorclause in each caller. No gap found — both directions are fail-closed — but a future edit to either path should preserve the other's contract rather than assuming they share one code path. run_preparesilent-exit-0 (F-06/#15, closed):prepare_project(nes/project_builder.py:89) is now called inside atry/except Exceptionthat exits 1 on a raised exception, AND separately checksif not prepared: sys.exit(1)for a falsy-but-non-raising return (main.py:677-685). Verifyprepare_project's real failure modes (bad path, permissions) are covered by one of these two branches, not a third one that falls through silently.- ROM-validation gate only blocking on
ERROR(F-02/#6, closed):validate_rom(main.py:541-587) now checksreset_vectors_validandapu_pattern_count == 0as explicitfatal_defects(main.py:561-567) before consultingoverall_health— a bad-vector or no-APU-init ROM is rejected regardless of what health score the diagnostics engine assigns it, closing the original gap. POOR/FAIR health with no fatal defect still only warns (main.py:569-582), which remains correct (non-boot-fatal). Verify completeness:ROMDiagnosticResult(debug/rom_diagnostics.py:28-44) only exposesreset_vectors_valid/apu_pattern_count/assembly_code_score/overall_health— a different boot-fatal condition (e.g. a mapper-number/nes.cfgmismatch, undetected PRG-bank overflow) would have to route throughoverall_health/issues, which is only ever a warning path here. Worth probing whether such a condition can occur and slip through.
Dimension 5: Temp-File / Intermediate Handling
The default path writes intermediates into a tempfile.TemporaryDirectory(prefix="midi2nes_")
(main.py:1428; run_song_build has its own at main.py:1077); the step-by-step path writes
user-named JSON/asm files.
- Confirm the temp dir is the parent of
music.asmandnes_project/(both still assigned directly inrun_full_pipeline), and thatcompile_rom(project_path, output_rom)— called frombuild_and_validate_romsince #406 (main.py:1355-1400, the stage helper bothrun_full_pipelineandrun_song_buildcall for the capacity/prepare/compile/validate sequence since #486/#467) — writes the final ROM tooutput_rom— the user's path, outside the temp dir — so it survivesTemporaryDirectorycleanup. Confirmed by reading the call; no late read of anything insidetemp_pathafter thewithblock observed. - DPCM append-mode double-write (F-10/#23, closed): both call sites append via the shared
pack_dpcm_into_asmhelper'swith open(asm_path, 'a') as f(main.py:159-249, extracted in #380/TD-28 — previously two separate inlineopen(..., 'a')sites, one per call site).run_full_pipelinepasses it the fresh tempmusic.asm(safe, new file every run);run_exportpassesargs.outputafterexport_tables_with_patternsalready wrote the same path via the sharedatomic_write_texthelper first (core/io_utils.py:13-38, #385/SAFE-2026-07-19-3 — writes to a sibling temp file andos.replace()s it into place, so the target is fully replaced, not appended to, and never left partially written even on a mid-write crash; call sitesexporter/exporter_ca65.py:1129,:1703,:1850), so it wipes the entire prior file, including any DPCM block appended on an earlier run, before the fresh append. A re-run therefore lands the append into a freshly-replaced file and cannot accumulate duplicatedpcm_*symbols. Verify the exporter still fully replaces the file (whether viaatomic_write_textor a future equivalent) rather than appending; if it ever switches to append mode, the original double-write hazard returns — and now only needs fixing once at the sharedpack_dpcm_into_asmcall sites, not twice. - Step-by-step intermediates (
parsed.json,mapped.json, etc.) remain user-managed and uncleaned — confirm no stage overwrites an input it still needs.
Dimension 6: Backup & Overwrite Safety
- Backup path:
output_rom.with_suffix('.nes.backup'), now created by the shared_backup_existing_romhelper (main.py:510-524). Re-verified directly:Path('my.song.nes').with_suffix('.nes.backup')→my.song.nes.backup—Path.with_suffixonly replaces the text after the last dot, so a dotted stem does not cause an unexpected clobber as previously suspected. No finding here; this bullet can be dropped from future passes unless the naming scheme changes. - Restore-on-failure (F-11/#26, closed): now a single
finallyblock (main.py:1564-1567) calls_restore_backup(main.py:526-540) wheneverbuild_succeededis stillFalse. Because it's infinally, it covers everysys.exit(1)reached inside thetry— compile failure, prepare failure, validation failure — and the top-levelexcept Exception(main.py:1554-1561), unlike before where several exit points bypassed restore. Confirmed fixed; verify no code path returns out of the function before thewithblock'sfinallywould run (none found). #486/PIPE-2026-08-22-2 (closed) extended this same contract torun_song_build(main.py:999-1118), which used to have no backup/ restore at all — it now calls_backup_existing_romup front (main.py:1074) and sharesbuild_and_validate_rom(below) plus an equivalenttry/except MIDI2NESError/except Exception/finallystructure (main.py:1099-1115), giving all three ROM-build entry points (run_full_pipeline,run_compile,run_song_build) the same contract. - Backup cleanup on success (F-12/#29, closed):
main.py:1536(inrun_full_pipeline) now doesbackup_path.unlink(missing_ok=True)immediately afterbuild_succeeded = Trueis set..nes.backupno longer lingers after a successful run; on a failed run it correctly stays in place (only the success branch deletes it). Confirmed fixed. - Validation-failed ROM left at the output path (#178/PL-05, closed):
run_compile(main.py:602-656) now backs up a pre-existing ROM via the shared_backup_existing_rom(main.py:632) and, in afinally, restores it on any compile/validation failure (main.py:651-656) — matching the default path's contract. The first-time-build case (no pre-existing ROM, sobackup_pathisNone) is also handled:_restore_backup(main.py:526-540) moves the just-written unbootable ROM aside to<name>.nes.failedrather than leaving a broken.nesat the output path.run_full_pipeline,run_compile, and (since #486)run_song_buildall share these two helpers, so the contract is uniform across all three. Verify thefinallyrestore path still fires on a validation-only failure (compile OK,validate_romreturns False) and that a first-time failed build produces<name>.nes.failed, not a bootable-looking<name>.nes. - Step-by-step
export/prepare/framesstill silently overwrite theiroutputwith no backup — unchanged; acceptable for intermediate files, flag only ifexportclobbers a hand-editedmusic.asmin a way that's surprising.
Dimension 7: Large-File Threshold & Pattern-Detector Fallback Hand-off
detect_patterns_or_direct_export (main.py:1149-1273, Step 4, extracted out of
run_full_pipeline by #406) has an advisory large_file_threshold check (main.py:1221) and
a parallel→sequential fallback (main.py:1225-1252).
- The threshold only prints a suggestion (
main.py:1222-1223); it does not change behavior. Still true, still intentional — confirm the message stays accurate as sampling behavior changes elsewhere. The threshold's default (LARGE_FILE_THRESHOLD_DEFAULT = MAX_PATTERN_EVENTS,main.py:56) and its--configoverride live inget_pattern_detection_caps(main.py:58-86), not a bare module constant any more. - Truncation-to-2000 (F-04/#10, closed): the fallback no longer
does
events = events[:2000]. It now callssample_events_for_detection(events, max_events)(main.py:1244; defaultDETECTOR_MAX_EVENTS = 300,tracker/pattern_detector.py:36, lowered from 1000 by #459/TD-39), which samples uniformly across the whole song (np.linspace,tracker/pattern_detector.py:39-51) rather than head-cutting it, so musical structure is preserved in what pattern detection sees. This closes the literal silent-truncation/song-shortening bug F-04 described. - The fallback's warning message (#176/PL-03, closed): when sampling triggers,
main.py:1245-1251no longer claims "the ROM is INCOMPLETE / re-run with --no-patterns for full fidelity." It now states the sampling feeds "compression analysis only — compression stats are approximate; ROM content is unaffected (#176/PL-03)", which is the accurate framing: the sampledeventslist feeds only pattern-detection's compression analysis; every emitted ROM byte still derives from the fullframesdict regardless ofpatterns(per CLAUDE.md's Assembly Export section andexporter/exporter_ca65.py:1628-1648, wherepatternstruthiness only selectsexport_direct_framesvs. the macro-bytecode serializer — both iterate the complete frame range). Verify the message still describes analysis-only loss (not ROM incompleteness), and that it stays consistent with the parallel detector's own internal-sampling note (tracker/pattern_detector_parallel.py:73,MAX_PATTERN_EVENTS = 15000), which prints an inline "lossy" percentage — both now describe the same class of event as analysis-only, no longer contradicting each other. run_detect_patternsasymmetry (F-09/#21, closed): the step-by-step subcommand (main.py:784-861) now also samples viasample_events_for_detection(events, max_events)(main.py:825) with an equivalent warning (main.py:826-828), matching the default path's fallback behavior — the old "no fallback, no threshold, processes the full set unbounded" asymmetry is gone. Verify: the parallel detector (used by default when it succeeds) still has a different, higher cap (MAX_PATTERN_EVENTS = 15000,tracker/pattern_detector.py:17) than the sequential detector/subcommand (DETECTOR_MAX_EVENTS = 300) — this remains an intentional, documented complexity-driven difference (comments attracker/pattern_detector.py:9-36: parallel is O(n) hash-grouping, sequential is O(n^2)-ish), not a bug. Both caps are now overridable via--config(#219) throughget_pattern_detection_caps(main.py:58-86); verify the override keeps the two paths in sync.- Confirm the fallback's
except Exception(main.py:1232) still genuinely catches whatParallelPatternDetectorcan realistically raise (pickling/worker errors) rather than only trivial exceptions — no change observed here; still worth a real multiprocessing-failure spot-check. A parallel crash with no fallback firing is a HIGH floor.
Dimension 8: Song-Bank Path
The song subcommands (run_song_add main.py:862-900, run_song_list main.py:901-927,
run_song_remove main.py:928-949, run_song_build main.py:999-1118) operate on a JSON
bank via nes/song_bank.py (SongBank.add_song_from_midi, export_bank, import_bank).
- #30/F-13 is CLOSED — the bank is no longer disjoint from the pipeline.
song build <bank.json> <out.nes>now compiles a bank into a real multi-song "jukebox" ROM, anddocs/ROADMAP.md§ "Song banks → ROM" is marked "v1 shipped, follow-ups remain". The old prose here told auditors to treat this dimension as a roadmap gap and not look for functional defects — that instruction is retired: this is now live code carrying a full second contract chain (see_audit-common.md§ Inter-Stage Data Contracts, thesong buildsub-list) and is audited like any other path. Verify-the-fix: the v1 scope cuts (MMC3-only, DPCM rejected per-song, no--debug, no visual menu) are documented follow-ups indocs/ROADMAP.md:69-76— flag those only as doc-rot if the code and the roadmap disagree, never as functional defects. Anything not on that list is a real finding. - Bank ordering and re-parse contract:
run_song_buildsorts bybank.songs[name]['metadata'].get('order', 0)(main.py:1052-1053) — this is the first and only consumer oforder, whichrun_song_addhas always written;ordercollisions after a remove+add cycle were fixed by #488/PIPE-2026-08-22-4 (SongBank._next_order,nes/song_bank.py:57-70, derives the next value frommax(existing) + 1instead oflen(self.songs), which used to reuse a removed song's freed slot — verify this still holds).run_song_buildthen rebuilds frames from each song's recordedmidi_path(viamidi_to_frames_for_song,main.py:1087), not from the storedsegments. Verify:segmentsare raw parsed events with no channel mapping, so any future change that makessong buildread them instead is a silent corruption, not a shortcut; and a bank whosemidi_pathis missing or has moved must still exit non-zero with a clear message (main.py:1078-1083) rather than building a partial ROM. #504/PERF-B-01 and #505/PERF-B-02 are CLOSED: this per-song parse used to happen inside an eager loop that built a fullsongslist (all N songs' frames dicts resident at once, ~12-13 MB/song) before a single batchedexport_song_bank_bytecodecall, andbank.import_bankalways retained every song's rawsegmentspayload even though this path never reads it. The parse loop is now a generator,_songs_for_build(main.py:1060-1099), consumed one song at a time byexport_song_bank_bytecode(which now accepts any iterable plus an explicitsong_count,exporter/exporter_ca65.py:1755) so only one song's frames dict is ever resident, andimport_bankis called withkeep_segments=False(main.py:1042) since this path never readssegments. Verify-the-fix: confirm_songs_for_buildis still a generator (not accidentally listified before being passed in) and thatkeep_segments=Falsestays paired withrun_song_build/run_song_listspecifically —run_song_add/run_song_removere-export the full bank afterward and still needsegmentsretained. - Per-song DPCM rejection:
song_has_dpcm_events(frames)(main.py:1093) hard-fails the whole build with an explanatory error before any export. Verify fix (#509/EXP-2026-08-23-2, closed): this used to be amain.py-private_song_has_dpcm_eventsduplicate of the exporter's own guard; the private copy was deleted (not kept alongside) andmain.pynow imports the sharedsong_has_dpcm_eventsfromexporter.exporter_ca65(main.py:22) — the same functionexport_song_bank_bytecodeitself now also calls per-song as a self-contained guard (see/audit-exportersDimension 9). Verify it inspects the frames actually being exported (not the bank metadata) so a song that gains DPCM via--arrangercan't slip past, and that a rejection leaves no partial.neson disk (the whole build runs inside atempfile.TemporaryDirectory,main.py:1119). - Capacity pre-flight: since #486/PIPE-2026-08-22-2 and #467/TD-32 (both closed),
run_song_buildno longer runs its own inlinecheck_mapper_capacitycall — it now shares the samebuild_and_validate_romhelper (main.py:1404-1448, capacity check at:1428) thatrun_full_pipelineuses, called atmain.py:1136. Verify N songs sharing one 60-bank MMC3 pool are sized against the same limit the single-song path uses, now structurally guaranteed by sharing the one call site — an N-song overrun that only surfaces as a CC65 link error is a HIGH contract break. run_song_addderivesmetadatafrom CLI args and defaults the bank tosong_bank.jsonwhen--bankis omitted (main.py:1675,p_song_add.add_argument('--bank', ...));run_song_list/run_song_removerequire a positionalbank(main.py:1687/:1691).run_song_buildtakes the bank as a positional too (main.py:1699), soaddis the only one with a default. Verify the add-default and the list/remove-required asymmetry can't silently write to a different file than the user reads.- Parser drift (fixed):
add_song_from_midi(nes/song_bank.py:81-103) now callsparse_midi_to_framesimported fromtracker.parser_fast(nes/song_bank.py:11) instead of an independent third parser — fixed by commitd8f6a0e(#33/#34).midi_to_frames_for_song(main.py:950-981, used byrun_song_build's re-parse) independently does the same (from tracker.parser_fast import parse_midi_to_frames as parse_fast,main.py:966) — a third confirmed-consistent call site. Verify the segment shape_process_segments(nes/song_bank.py:105) expects fromparse_midi_to_frames's output still matches whatrun_parse/run_maptreat as canonical, since this is now a second, independent consumer of that output shape. - Verify fix (#427/PIPE-2026-08-21-5, closed):
import_bank's guard (#220/SAFE-09) used to validate only the bank-level shape (bank_info,songspresence) and storedata['songs']as-is, with no per-entry validation. A song entry missing'metadata'(or non-dict, or missing'bank'/'size') reached the sort key above (bank.songs[name]['metadata'].get('order', 0)) orrun_song_list's print loop as an unguardedKeyError/AttributeError, escaping as a raw traceback -- both call sites'try/exceptwraps only theimport_bank()call itself, not their own subsequent indexing.import_banknow validates every song entry is a dict with a dict'metadata'and both'bank'/'size'keys present (the shapeadd_songalways writes) before storingself.songs, raising the sameValueErrorstyle as the bank-level checks. Verify-the-fix: a bank with one malformed song entry among otherwise valid ones still fails the whole import (not a silent partial load) with a clean[ERROR]and nonzero exit from all three CLI entry points that callimport_bank(song list/song remove/song build). - Verify fix (#487/PIPE-2026-08-22-3, closed): the pre-subcommand
--arrangerrejection message (Dimension 3) used to claim no step-by-step equivalent to--arrangerexisted for any subcommand, which became false oncesong buildgained its own--arranger(main.py:1701, read atrun_song_buildviagetattr(args, 'arranger', False),main.py:1028). The message now special-casesfirst_arg == 'song'(main.py:1762-1765) with the correct fix ("place--arrangerafterbuild"). Verify a bare--arranger song ...still gets the song-specific message, not the generic one.
Output
Write the report to docs/audits/AUDIT_PIPELINE_<TODAY>.md (YYYY-MM-DD). Structure:
- Summary — finding counts per dimension; the single most dangerous contract break; an explicit yes/no on "does the step-by-step path produce the same ROM as the default path?".
- Contract Map — a short table of each stage boundary (producer fn → key(s) → consumer fn) with a ✓/✗ for "verified matching".
- Findings — base per-finding format from
_audit-common.mdplusDimensionandBoth paths?. Apply the_audit-severity.mdfloors: contract break = HIGH, silent song change (truncation, ignored flag, wrong refs) = CRITICAL.
Then suggest:
/audit-publish docs/audits/AUDIT_PIPELINE_<TODAY>.md
TDD Red-Green-Refactor
Testing
Skill that guides Claude through the complete TDD cycle.
Web Accessibility Audit
Testing
Performs a comprehensive web accessibility audit following WCAG standards.
UAT Test Case Generator
Testing
Generates structured and comprehensive user acceptance test cases.