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.
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')(main.py:106), 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:64-93; SAFE-01/#120, closed). Verify: every call site'srequired_keyslist actually names what that stage's body indexes next —run_frames/run_export/run_detect_patternspass[]since they only iterate the (all-optional) channel dict rather than a fixed key; confirm that's genuinely safe rather than a gap in the guard.run_map→assign_tracks_to_nes_channels(midi_data["events"], dpcm_index_path)(tracker/track_mapper.py).run_frames(main.py:114-121) 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.run_detect_patterns(main.py:645-649) still saves only{'patterns','references','stats'}and omitsvariations, which both detectors (tracker/pattern_detector.py,tracker/pattern_detector_parallel.py) return. Confirm no consumer (run_export,main.py:519-561;CA65Exporter.export_tables_with_patterns) ever needs it — unresolved question, not a filed bug; keep checking on each audit pass. (Note the--no-patternsstub now does carry'variations': {}, closing the narrower one-path-only KeyError gap #258/PAT-09; the stub lives indetect_patterns_or_direct_exportsince #406 (extracted out ofrun_full_pipeline, which calls it for Step 4), not inline inrun_full_pipelineitself any more. Thedetect-patternssubcommand omission above is a separate, still-open question.)- #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) 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:962-971, 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) 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:97) andrun_full_pipeline(main.py:776), 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):
main.pynow defines shared module-level constantsPATTERN_MIN_LENGTH = 3/PATTERN_MAX_LENGTH = 12, and bothrun_detect_patternsand the parallel/fallback detector constructionrun_full_pipelinecalls into (detect_patterns_or_direct_export, extracted fromrun_full_pipelinesince #406) 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— uses exactly the key set both detectors emit —original_size/compressed_size/compression_ratio/unique_patterns(tracker/pattern_detector.py:884-889) — not the oldoriginal_events/patterns_foundmismatch. Verify everystatsreader (success bannermain.py:1046-1049,run_detect_patterns's bannermain.py:655-658) 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:413-462), 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.
Dimension 3: Flag Routing (--arranger / --no-patterns / --debug / --skip-validation / --version)
Flags are parsed twice: argparse declares --verbose/--debug/--arranger/--version as
global options (main.py:1083-1086), but the hand-rolled dispatch in main() (the
SimpleArgs builder, main.py:1328-1341) re-derives them from a manually whitelisted
global_args list (main.py:1263-1311). Audit both:
- Unknown/typo flags (F-03/#8, closed): the manual loop now
sys.exit(2)s with"Error: Unknown option: <arg>"(main.py:1303-1308) for anything starting with-that isn't in the whitelist (--verbose/-v,--debug/-d,--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). --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:1278-1284), 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:1231-1233. 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:1168, part of the #15 fix), 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:1247-1253) instead of being silently discarded. Verify the positive case still works:--arrangeron the default path reachesarrange_for_nes(main.py:786-790) 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→run_prepareparity (#175/PL-02, closed):run_preparenow passesdebug_mode=getattr(args, 'debug', False)intoNESProjectBuilder(main.py:479), matching the default path'sdebug_modederivation (main.py:991). No divergence found.run_map --config/--dpcm-index(F-05/#13, closed):--dpcm-indexis honored (main.py:108,getattr(args, 'dpcm_index', None) or 'dpcm_index.json');--configwas removed from themapsubcommand entirely (comment atmain.py:1100-1101) 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:39-62), declared atmain.py:1135and read atmain.py:614; 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:773) /except Exception(main.py:1063) /finally(main.py:1071-1076). 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:126-215,except Exception as e:at:194) called from both (run_exportatmain.py:709;export_frames_and_resolve_mapperat:1052— 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:719/:1183) 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:380-385) 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:225-260) convertsCompilationError/ValidationError/any other exception into aFalsereturn with a printed[ERROR];compiler/cc65_wrapper.pyraisesToolchainError/CompilationErroron a missing tool or nonzeroca65/ld65exit code (compiler/cc65_wrapper.py:47-52, 162-167, 225-230; seecore/exceptions.py:88CompilationError,:158ToolchainError). Bothrun_full_pipeline(main.py:1024-1026) andrun_compile(main.py:449)sys.exit(1)on aFalsereturn. No gap found here. run_preparesilent-exit-0 (F-06/#15, closed):prepare_project(nes/project_builder.py:75) 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:483-490). 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:367-410) now checksreset_vectors_validandapu_pattern_count == 0as explicitfatal_defects(main.py:387-396) 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:398-409), which remains correct (non-boot-fatal). Verify completeness:ROMDiagnosticResult(debug/rom_diagnostics.py:40-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:770); 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, the stage helperrun_full_pipelinecalls for Steps 6-8 — 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:168, 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 opened the same path in'w'(truncate) mode first (exporter/exporter_ca65.py:894,:1283), 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-truncated file and cannot accumulate duplicatedpcm_*symbols. Verify the exporter still truncates ('w') rather than appends; if it ever switches to append mode, the original double-write hazard returns — and now only needs fixing once at the shared helper's call 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:346). 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:1071-1076) calls_restore_backup(main.py:352-364) 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:1063-1069), 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). - Backup cleanup on success (F-12/#29, closed):
main.py:1059-1061now 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:413-462) now backs up a pre-existing ROM via the shared_backup_existing_rom(main.py:443) and, in afinally, restores it on any compile/validation failure (main.py:458-462) — matching the default path's contract. The first-time-build case (no pre-existing ROM, sobackup_pathisNone) is also handled:_restore_backup(main.py:352-364) moves the just-written unbootable ROM aside to<name>.nes.failedrather than leaving a broken.nesat the output path. Bothrun_full_pipelineandrun_compileshare these two helpers, so the contract is uniform. 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
run_full_pipeline has a LARGE_FILE_THRESHOLD = 10000 (main.py:818) and a
parallel→sequential fallback (main.py:827-853).
- The threshold only prints a suggestion to use
--no-patterns(main.py:820-821); it does not change behavior. Still true, still intentional — confirm the message stays accurate as sampling behavior changes elsewhere. - 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:844; defaultDETECTOR_MAX_EVENTS = 1000,tracker/pattern_detector.py:23), which samples uniformly across the whole song (np.linspace,tracker/pattern_detector.py:26-38) 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:846-852no 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:962-971, 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:60-63,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 now also samples viasample_events_for_detection(events, max_events)(main.py:636) with an equivalent warning (main.py:637-639), 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:16) than the sequential detector/subcommand (DETECTOR_MAX_EVENTS = 1000) — this remains an intentional, documented complexity-driven difference (comments attracker/pattern_detector.py:14-23: 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:39-62); verify the override keeps the two paths in sync.- Confirm the fallback's
except Exception(main.py:832) 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/run_song_list/run_song_remove) operate on a JSON
bank via nes/song_bank.py (SongBank.add_song_from_midi, export_bank, import_bank).
- Disjoint from the main pipeline (F-13/#30): this remains true, and per project status
is a known roadmap gap, not a bug to hunt for —
docs/ROADMAP.md(the "Song banks → ROM" section, lines 57-63) explicitly lists asong buildroute as an open item, andSongBank(nes/song_bank.py:30) has no build/compile method. Only flag this dimension ifdocs/ROADMAP.md's stated status drifts from what the code actually supports (doc-rot, LOW/MEDIUM) — not as a functional defect. run_song_addderivesmetadatafrom CLI args and defaults the bank tosong_bank.jsonwhen--bankis omitted (main.py:660-688);run_song_list/run_song_remove(main.py:690-736) require a positionalbank. Unchanged; 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:72-89) now callsparse_midi_to_framesimported fromtracker.parser_fast(nes/song_bank.py:11) instead of an independent third parser — fixed by commitd8f6a0e(#33/#34). Verify the segment shape_process_segments(nes/song_bank.py:91) 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.
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
Expert Next.js App Router
Developpement
Un skill qui transforme Claude en expert Next.js App Router.
Générateur de README
Developpement
Crée des README.md professionnels et complets pour vos projets.
Rédacteur de Documentation API
Developpement
Génère de la documentation API complète au format OpenAPI/Swagger.