feat(#71): IMP-42 u1~u5 silent fail chain diagnostics (assert + invalid-char detector + DIAG log)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 24s

Stage 4 binding scope — diagnostic-only, fail-loud, sample-agnostic
(RULE 0 / AI-isolation contract). No production behavior change beyond
fail-loud raises on previously-silent failure classes.

u1 src/phase_z2_pipeline.py:2747-2772 — render_slide precondition assert
   (template_id non-empty str + slot_payload dict), placed after the
   `__empty__` short-circuit at 2740 to preserve empty-zone grid behavior.
u2 src/phase_z2_pipeline.py:2681-2710 — _scan_rendered_html_for_invalid_path_chars
   helper covering src / href / url(...) values for backslash, &, '.
   Invoked on partial render (2778) and slide_base assembly (2798).
u3 src/phase_z2_pipeline.py:2638-2676,2733,5509 — _emit_diag_zones_shape
   shape-only [DIAG] JSON at Step 12 slot_payload emit and Step 13
   render_slide entry. No env gate — silence is the bug.
u4 Front/client/src/pages/Home.tsx:388-392 — unconditional [DIAG raw overrides]
   console.log on handleGenerate boundary, after flushUserOverrides() and
   immediately before runPipeline.
u5 tests/phase_z2/test_phase_z2_diag_smoke_general.py — 32-frame general
   smoke driven by load_frame_contracts() registry (not literal MDX 03/04/05),
   parametrizes u1/u2/u3 across the full frame_contracts.yaml top-level.

Tests (Stage 4 verification PASS):
- u1 8 passed, u2 14 passed, u3 12 passed, u4 5 passed, u5 97 passed.
- Backend full regression tests/phase_z2/ 499 passed in 110.84s.
- Frontend full regression 182 passed in 1.10s.

Out of scope (separate axes):
- Path normalization / as_posix migration.
- Autoescape policy change.
- build_layout_css refactor (Stage 1 category-error rejection).
- Recovery / auto-fix on detected invalid path.
- MDX content / frame-selection / zone-composition change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 08:28:54 +09:00
parent c59864eb9a
commit 5deeb97cf6
7 changed files with 818 additions and 4 deletions

View File

@@ -384,6 +384,14 @@ export default function Home() {
// clicks Generate would race the PUT against /api/run; the u2
// fallback could then load a stale persisted document.
await flushUserOverrides();
// IMP-42 u4 — unconditional DIAG console.log on the handleGenerate
// entry-to-backend boundary. Surfaces the override payload + uploaded
// file name so the user can see exactly what crossed the wire when
// the pipeline fails silently. No env gate (silence is the bug).
console.log("[DIAG raw overrides]", {
file: state.uploadedFile.name,
overrides,
});
const result = await runPipeline(state.uploadedFile, overrides);
if (!result.success || !result.final_html_exists) {

View File

@@ -0,0 +1,117 @@
// IMP-42 u4 — Source-slice coverage for the unconditional handleGenerate
// DIAG console.log on the frontend → backend boundary (issue #71).
//
// Scope (Stage 2 unit u4 contract):
// 1) A single `console.log("[DIAG raw overrides]", ...)` call exists
// inside handleGenerate and precedes the runPipeline call site.
// 2) The DIAG call is unconditional — not wrapped in `if (...)` / `?:` /
// env-var gate / `__DEV__`-style guard. "Silence is the bug" per
// Stage 1 scope-lock (Codex #3) and the Step 13 backend mirror
// already landed in u3.
// 3) The DIAG payload carries shape-only metadata — uploaded file name
// and the override payload object — without referencing raw MDX
// content or any other sample-specific identifier (RULE 0).
//
// Why source-slice (per Stage 2 plan): Home.tsx handleGenerate is wired to
// React state, toast, and a 700-line component tree; the cheapest way to
// pin a single-line surface and prove placement relative to runPipeline is
// to read the source and assert ordering. No React rendering, no fetch
// mock, no DOM. Mirrors the existing pure-helper pattern in
// tests/imp41_application_mode.test.ts.
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
const HOME_TSX_PATH = resolve(__dirname, "..", "src", "pages", "Home.tsx");
const HOME_TSX_SOURCE = readFileSync(HOME_TSX_PATH, "utf-8");
// Locate the handleGenerate callback body. The closing brace of
// useCallback's `async () => { ... }` is the next line whose indent matches
// the opening `useCallback(async () => {` exactly — but a simpler proxy is
// "from the handleGenerate keyword to the next useCallback declaration or
// the end-of-file." This is sufficient to scope every assertion below to
// the right function body.
function sliceHandleGenerateBody(source: string): string {
const startMarker = "const handleGenerate = useCallback(async () =>";
const startIdx = source.indexOf(startMarker);
if (startIdx === -1) {
throw new Error("handleGenerate declaration not found in Home.tsx");
}
// End at the next top-level `const ` that begins a new useCallback /
// useMemo / hook binding. handleGenerate is followed by additional
// hooks (handleFileUpload sibling pattern); slicing to the next
// declaration is more than enough to capture the full body.
const afterStart = source.slice(startIdx + startMarker.length);
const nextDeclIdx = afterStart.search(/\n {2}const [A-Za-z]/);
return nextDeclIdx === -1 ? afterStart : afterStart.slice(0, nextDeclIdx);
}
const HANDLE_GENERATE_BODY = sliceHandleGenerateBody(HOME_TSX_SOURCE);
describe("handleGenerate [DIAG raw overrides] (IMP-42 u4)", () => {
it("emits exactly one console.log labelled '[DIAG raw overrides]' inside handleGenerate", () => {
const matches = HANDLE_GENERATE_BODY.match(
/console\.log\(\s*"\[DIAG raw overrides\]"/g,
);
expect(matches).not.toBeNull();
// Exactly one DIAG site per Stage 2 contract — multiple calls would
// either be a copy-paste regression or evidence that the helper
// moved without removing the old site.
expect(matches?.length).toBe(1);
});
it("places the DIAG console.log before the runPipeline call site", () => {
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
"runPipeline(state.uploadedFile, overrides)",
);
expect(diagIdx).toBeGreaterThan(-1);
expect(runPipelineIdx).toBeGreaterThan(-1);
expect(diagIdx).toBeLessThan(runPipelineIdx);
});
it("is unconditional — no env-var gate or if-guard wraps the DIAG call", () => {
// Slice the 80 chars immediately preceding the DIAG console.log and
// confirm none of the common gating patterns appear directly above.
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const preface = HANDLE_GENERATE_BODY.slice(Math.max(0, diagIdx - 200), diagIdx);
// Stage 1 contract: silence is the bug. Any gate here is a regression.
expect(preface).not.toMatch(/if\s*\([^)]*\)\s*$/m);
expect(preface).not.toMatch(/process\.env/);
expect(preface).not.toMatch(/import\.meta\.env/);
expect(preface).not.toMatch(/__DEV__/);
expect(preface).not.toMatch(/DIAG_VERBOSE/i);
expect(preface).not.toMatch(/DEBUG/);
});
it("forwards the file name and overrides object as shape-only payload", () => {
// The DIAG payload must include the uploaded file name (so the user
// can correlate the log line with the MDX they uploaded) and the
// overrides object (so the user can see what crossed the wire).
// It must NOT spread MDX text content or any other large blob —
// sample-agnostic and reviewable in a single log line.
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const window = HANDLE_GENERATE_BODY.slice(diagIdx, diagIdx + 300);
// Both fields appear in the payload object literal.
expect(window).toMatch(/file:\s*state\.uploadedFile\.name/);
expect(window).toMatch(/\boverrides\b/);
// Sanity: the payload does not pass MDX raw content / a File blob.
expect(window).not.toMatch(/mdxContent|rawMdx|normalizedContent/);
});
it("runs after flushUserOverrides() so the persisted PUT is already committed", () => {
// Ordering invariant from IMP-52 u10 (already in place):
// flushUserOverrides() → DIAG → runPipeline
// Asserts the DIAG sits between the flush and the network call so the
// logged overrides match what backend reads from disk.
const flushIdx = HANDLE_GENERATE_BODY.indexOf("await flushUserOverrides()");
const diagIdx = HANDLE_GENERATE_BODY.indexOf('console.log("[DIAG raw overrides]"');
const runPipelineIdx = HANDLE_GENERATE_BODY.indexOf(
"runPipeline(state.uploadedFile, overrides)",
);
expect(flushIdx).toBeGreaterThan(-1);
expect(diagIdx).toBeGreaterThan(flushIdx);
expect(diagIdx).toBeLessThan(runPipelineIdx);
});
});