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 qui guide Claude a travers le cycle TDD complet.
Audit d'Accessibilité Web
Testing
Réalise un audit d'accessibilité web complet selon les normes WCAG.
Générateur de Tests UAT
Testing
Génère des cas de test d'acceptation utilisateur structurés et complets.