Audit d'intégrité du pipeline

Audit de l'intégrité de bout en bout du pipeline et des contrats de données inter-étapes.

Spar Skills Guide Bot
DeveloppementAvancé
1007/08/2026
Claude Code
#pipeline#audit#integrity#data-contracts#verification

Recommandé pour


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_pipeline path, 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_parse writes {"events": ..., "metadata": ...} (from tracker/parser_fast.py parse_midi_to_frames). run_map reads it via load_json_stage(args.input, ['events'], 'parse') (main.py:106), which now fails with a clean [ERROR] message and exit 1 — rather than a bare KeyError/FileNotFoundError/JSONDecodeError — on a missing/corrupt/ wrong-stage file (load_json_stage, main.py:64-93; SAFE-01/#120, closed). Verify: every call site's required_keys list actually names what that stage's body indexes next — run_frames/run_export/run_detect_patterns pass [] 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_mapassign_tracks_to_nes_channels(midi_data["events"], dpcm_index_path) (tracker/track_mapper.py). run_frames (main.py:114-121) feeds that JSON straight into NESEmulatorCore.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 omits variations, 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-patterns stub now does carry 'variations': {}, closing the narrower one-path-only KeyError gap #258/PAT-09; the stub lives in detect_patterns_or_direct_export since #406 (extracted out of run_full_pipeline, which calls it for Step 4), not inline in run_full_pipeline itself any more. The detect-patterns subcommand omission above is a separate, still-open question.)
  • #379/PIPE-2026-07-19-3 is CLOSED: export_frames_and_resolve_mapper (the stage helper run_full_pipeline calls for Steps 5-5.5 since #406) used to hardcode a bare empty dict {} for references regardless of what pattern detection produced, while run_export passes the detector's native {'pattern_id': [positions]} shape (pattern_data['references']) straight through unmodified. Both entry points now pass pattern_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 documents references as 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: if references is ever wired up to affect output, confirm both entry points still derive it from the same pattern_result/pattern_data source rather than drifting apart again — that was the exact shape of the original bug.
  • grep each 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_frames import (the older full parser) no longer exists in main.py. Both entry points now import parser_fast locally and identically: run_parse (main.py:97) and run_full_pipeline (main.py:776), both as from 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 use parser_fast too).
  • Pattern-detector parameter divergence (F-08/#19, closed): main.py now defines shared module-level constants PATTERN_MIN_LENGTH = 3 / PATTERN_MAX_LENGTH = 12, and both run_detect_patterns and the parallel/fallback detector construction run_full_pipeline calls into (detect_patterns_or_direct_export, extracted from run_full_pipeline since #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.
  • stats schema divergence (fixed): the --no-patterns stub — now living in detect_patterns_or_direct_export since #406, not inline in run_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 old original_events/patterns_found mismatch. Verify every stats reader (success banner main.py:1046-1049, run_detect_patterns's banner main.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 compile subcommand (main.py:413-462), which runs compile_rom + validate_rom together — giving preparecompile parity with the default path's compile+validate steps. The former residual asymmetry (no backup/restore on run_compile) is now also closed: run_compile calls the shared _backup_existing_rom/_restore_backup helpers (Dimension 6, PL-05/#178, closed), so preparecompile now 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).
  • --version combined with other args (#179/PL-06, closed): the manual loop now matches argparse's action='version' semantics — a --version token prints MIDI2NES <ver> and sys.exit(0)s immediately inside the loop (main.py:1278-1284), before any input file is consumed, so python main.py --version song.mid no longer silently runs the full pipeline. The bare python main.py --version (argv length 2) still short-circuits earlier at main.py:1231-1233. Verify both forms exit 0 and print the version, and that no path files --version into global_args where SimpleArgs would ignore it again.
  • --skip-validation argparse parity (partially fixed): it is now also a first-class argparse argument on the compile subcommand (main.py:1168, part of the #15 fix), not manual-default-path-only anymore. --no-patterns remains manual-default-path-only with no subcommand equivalent — this appears intentional (the per-subcommand analogue is simply omitting --patterns on export); 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.
  • --arranger before a subcommand (#174/PL-01, closed): now rejected with a clear sys.exit(2) error (main.py:1247-1253) instead of being silently discarded. Verify the positive case still works: --arranger on the default path reaches arrange_for_nes (main.py:786-790) and produces a {channel: {frame: {...}}} structure the downstream pattern/export code accepts identically to process_all_tracks's output (no drift observed; worth re-checking after any arranger refactor).
  • --debugrun_prepare parity (#175/PL-02, closed): run_prepare now passes debug_mode=getattr(args, 'debug', False) into NESProjectBuilder (main.py:479), matching the default path's debug_mode derivation (main.py:991). No divergence found.
  • run_map --config/--dpcm-index (F-05/#13, closed): --dpcm-index is honored (main.py:108, getattr(args, 'dpcm_index', None) or 'dpcm_index.json'); --config was removed from the map subcommand entirely (comment at main.py:1100-1101) rather than left declared-but-ignored. detect-patterns's --config was 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) via get_pattern_detection_caps (main.py:39-62), declared at main.py:1135 and read at main.py:614; it does not touch tempo or PATTERN_MIN/MAX_LENGTH. Verify no other subcommand still declares a flag its handler silently ignores (grep every add_argument call against the body of its func=).

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 one try (main.py:773) / except Exception (main.py:1063) / finally (main.py:1071-1076). Verify no inner except still 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.warning and 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 both run_full_pipeline and run_export; it now lives in one shared pack_dpcm_into_asm helper (main.py:126-215, except Exception as e: at :194) called from both (run_export at main.py:709; export_frames_and_resolve_mapper at :1052 — the stage helper run_full_pipeline calls 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): the try/except Exception around ROMDiagnostics(...).diagnose_rom(...) (main.py:380-385) now returns False (validation failed) — not True — on any exception, and prints the warning unconditionally (not only under --verbose). So an infrastructure failure (e.g. a broken import in debug/rom_diagnostics.py) is treated as a failed gate rather than a silently-accepted ROM. Callers only reach validate_rom when the user did NOT pass --skip-validation, so this is the correct fail-closed direction. Verify the return stays False and the message stays unconditional; this dimension no longer has an open "continues past a real failure" case here.
  • CC65 failure surfacing (confirmed correct): compile_rom (compiler/compiler.py:225-260) converts CompilationError/ValidationError/any other exception into a False return with a printed [ERROR]; compiler/cc65_wrapper.py raises ToolchainError/CompilationError on a missing tool or nonzero ca65/ld65 exit code (compiler/cc65_wrapper.py:47-52, 162-167, 225-230; see core/exceptions.py:88 CompilationError, :158 ToolchainError). Both run_full_pipeline (main.py:1024-1026) and run_compile (main.py:449) sys.exit(1) on a False return. No gap found here.
  • run_prepare silent-exit-0 (F-06/#15, closed): prepare_project (nes/project_builder.py:75) is now called inside a try/except Exception that exits 1 on a raised exception, AND separately checks if not prepared: sys.exit(1) for a falsy-but-non-raising return (main.py:483-490). Verify prepare_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 checks reset_vectors_valid and apu_pattern_count == 0 as explicit fatal_defects (main.py:387-396) before consulting overall_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 exposes reset_vectors_valid/apu_pattern_count/assembly_code_score/overall_health — a different boot-fatal condition (e.g. a mapper-number/nes.cfg mismatch, undetected PRG-bank overflow) would have to route through overall_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.asm and nes_project/ (both still assigned directly in run_full_pipeline), and that compile_rom(project_path, output_rom) — called from build_and_validate_rom since #406, the stage helper run_full_pipeline calls for Steps 6-8 — writes the final ROM to output_rom — the user's path, outside the temp dir — so it survives TemporaryDirectory cleanup. Confirmed by reading the call; no late read of anything inside temp_path after the with block observed.
  • DPCM append-mode double-write (F-10/#23, closed): both call sites append via the shared pack_dpcm_into_asm helper's with open(asm_path, 'a') as f (main.py:168, extracted in #380/TD-28 — previously two separate inline open(..., 'a') sites, one per call site). run_full_pipeline passes it the fresh temp music.asm (safe, new file every run); run_export passes args.output after export_tables_with_patterns already 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 duplicate dpcm_* 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_rom helper (main.py:346). Re-verified directly: Path('my.song.nes').with_suffix('.nes.backup')my.song.nes.backupPath.with_suffix only 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 finally block (main.py:1071-1076) calls _restore_backup (main.py:352-364) whenever build_succeeded is still False. Because it's in finally, it covers every sys.exit(1) reached inside the try — compile failure, prepare failure, validation failure — and the top-level except 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 the with block's finally would run (none found).
  • Backup cleanup on success (F-12/#29, closed): main.py:1059-1061 now does backup_path.unlink(missing_ok=True) immediately after build_succeeded = True is set. .nes.backup no 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 a finally, 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, so backup_path is None) is also handled: _restore_backup (main.py:352-364) moves the just-written unbootable ROM aside to <name>.nes.failed rather than leaving a broken .nes at the output path. Both run_full_pipeline and run_compile share these two helpers, so the contract is uniform. Verify the finally restore path still fires on a validation-only failure (compile OK, validate_rom returns False) and that a first-time failed build produces <name>.nes.failed, not a bootable-looking <name>.nes.
  • Step-by-step export/prepare/frames still silently overwrite their output with no backup — unchanged; acceptable for intermediate files, flag only if export clobbers a hand-edited music.asm in 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 calls sample_events_for_detection(events, max_events) (main.py:844; default DETECTOR_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-852 no 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 sampled events list feeds only pattern-detection's compression analysis; every emitted ROM byte still derives from the full frames dict regardless of patterns (per CLAUDE.md's Assembly Export section and exporter/exporter_ca65.py:962-971, where patterns truthiness only selects export_direct_frames vs. 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_patterns asymmetry (F-09/#21, closed): the step-by-step subcommand now also samples via sample_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 at tracker/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) through get_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 what ParallelPatternDetector can 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 fordocs/ROADMAP.md (the "Song banks → ROM" section, lines 57-63) explicitly lists a song build route as an open item, and SongBank (nes/song_bank.py:30) has no build/compile method. Only flag this dimension if docs/ROADMAP.md's stated status drifts from what the code actually supports (doc-rot, LOW/MEDIUM) — not as a functional defect.
  • run_song_add derives metadata from CLI args and defaults the bank to song_bank.json when --bank is omitted (main.py:660-688); run_song_list/run_song_remove (main.py:690-736) require a positional bank. 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 calls parse_midi_to_frames imported from tracker.parser_fast (nes/song_bank.py:11) instead of an independent third parser — fixed by commit d8f6a0e (#33/#34). Verify the segment shape _process_segments (nes/song_bank.py:91) expects from parse_midi_to_frames's output still matches what run_parse/run_map treat 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:

  1. 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?".
  2. Contract Map — a short table of each stage boundary (producer fn → key(s) → consumer fn) with a ✓/✗ for "verified matching".
  3. Findings — base per-finding format from _audit-common.md plus Dimension and Both paths?. Apply the _audit-severity.md floors: contract break = HIGH, silent song change (truncation, ignored flag, wrong refs) = CRITICAL.

Then suggest:

/audit-publish docs/audits/AUDIT_PIPELINE_<TODAY>.md
Skills similaires