feat(#69): IMP-40 u1~u6 frame contract label_default placeholder/fallback role discriminator (BIM/DX leak fix)
Some checks failed
Multi-MDX Regression (IMP-91) / multi-mdx-regression (push) Failing after 26s

- catalog (frame_contracts.yaml): F18 bim_dx_comparison_table col_a/col_b
  label_default_role=placeholder; F30 industry_current_status_three_col +
  F31 industry_characteristics_three_col col_a/col_b/col_c forward-compat
  placeholder; F33 engn_sw_three_types untouched (no label_default).
- mapper (_build_compare_table_2col): generic _resolve_label_default(col_key)
  branches on <col>_label_default_role — placeholder -> '' (Figma placeholder
  suppressed at runtime), fallback -> catalog literal (legacy default), unknown
  -> ValueError with template_id + role_key + value. Absent role defaults to
  fallback (backward compat for contracts without discriminator).
- tests (tests/phase_z2/test_imp40_label_default_role.py): u4 generic matrix
  (placeholder / fallback / absent / unknown / 3-col axis) + u5 F18-reuse
  non-BIM/DX synthetic rows asserting placeholder labels emit '' and BIM/DX
  literal tokens do not leak.
- snapshot (tests/integration/__snapshots__/slot_payload.json): mdx 01 F18
  string_slot_nonempty.col_a_label/col_b_label True -> False (u6 expected
  drift from u3 placeholder -> empty string flip). slot_names + rows + title
  preserved.

Verification:
- imp40_label_default_role: 6/6 PASSED
- phase_z2 sweep: 608/608 PASSED
- multi_mdx_regression: 50/50 PASSED
- cross-suite sweep: 662/662 PASSED
- BIM/DX literal grep on mapper + new test: 0 hits
- No mdx-specific branches (mdx 03/04/05 grep on mapper: 0 hits)

Guardrails: no MDX 03/04/05 hardcoding (catalog policy only); no spacing
shrink; no auto frame swap on reject; no AI call at Step 12; F33 untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-24 18:53:20 +09:00
parent 028042aaa9
commit 8648a468d9
4 changed files with 360 additions and 5 deletions

View File

@@ -579,12 +579,23 @@ def _build_compare_table_2col(section, units, contract) -> dict:
builder_options : builder_options :
item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`) item_parser : ITEM_PARSERS key (예: `compare_row_2col_item`)
col_a_label_default : col_a header (MDX 미명시 시 fallback. F1-a fix) col_a_label_default : col_a header literal in catalog.
col_b_label_default : col_b header (MDX 미명시 시 fallback) Semantics depend on col_a_label_default_role.
col_a_label_default_role : "placeholder" | "fallback" (IMP-40 #69).
placeholder = Figma visual placeholder; suppressed
at runtime → col_a_label emitted as "".
fallback = MDX 미명시 시 catalog literal 사용.
absent = legacy contracts default to fallback.
col_b_label_default : col_b header literal (same policy as col_a).
col_b_label_default_role : same role discriminator for col_b (IMP-40 #69).
strip_col_prefix_aliases : list[str] — col_a/col_b 값의 prefix `<alias>:` strip_col_prefix_aliases : list[str] — col_a/col_b 값의 prefix `<alias>:`
를 strip (Codex round 43 §F1-b — narrow alias). 를 strip (Codex round 43 §F1-b — narrow alias).
예 : ["BIM", "DX"]. default [] (no stripping). 예 : ["BIM", "DX"]. default [] (no stripping).
max_rows : N (default 999 — practical 한계). max_rows : N (default 999 — practical 한계).
NOTE: MDX 측 col_a_label / col_b_label inflow 경로 없음
(compare_row_2col_item parser → {label,col_a,col_b}, _resolve_title → title only).
placeholder role 은 col_*_label 을 빈 문자열로 확정 — 정책 결정점은 catalog 한 곳뿐.
""" """
options = contract["payload"]["builder_options"] options = contract["payload"]["builder_options"]
parser_name = options["item_parser"] parser_name = options["item_parser"]
@@ -595,8 +606,21 @@ def _build_compare_table_2col(section, units, contract) -> dict:
f"but ITEM_PARSERS has no such entry." f"but ITEM_PARSERS has no such entry."
) )
col_a_label = options.get("col_a_label_default", "") def _resolve_label_default(col_key: str) -> str:
col_b_label = options.get("col_b_label_default", "") default_key = f"{col_key}_label_default"
role_key = f"{col_key}_label_default_role"
role = options.get(role_key, "fallback")
if role == "placeholder":
return ""
if role == "fallback":
return options.get(default_key, "")
raise ValueError(
f"Contract '{contract['template_id']}' builder_options.{role_key}='{role}' "
f"is invalid; expected 'placeholder' or 'fallback' (IMP-40 #69)."
)
col_a_label = _resolve_label_default("col_a")
col_b_label = _resolve_label_default("col_b")
strip_aliases = options.get("strip_col_prefix_aliases", []) or [] strip_aliases = options.get("strip_col_prefix_aliases", []) or []
max_rows = options.get("max_rows", 999) max_rows = options.get("max_rows", 999)

View File

@@ -474,7 +474,9 @@ bim_dx_comparison_table:
builder_options: builder_options:
item_parser: compare_row_2col_item # NEW parser — top_bullet → {label, col_a, col_b} item_parser: compare_row_2col_item # NEW parser — top_bullet → {label, col_a, col_b}
col_a_label_default: "BIM" # F1-a (Codex round 43) — explicit default col_a_label_default: "BIM" # F1-a (Codex round 43) — explicit default
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_b_label_default: "DX" # F1-a — explicit default col_b_label_default: "DX" # F1-a — explicit default
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
strip_col_prefix_aliases: # F1-b (Codex round 43) — narrow alias 만 strip strip_col_prefix_aliases: # F1-b (Codex round 43) — narrow alias 만 strip
- "BIM" - "BIM"
- "DX" - "DX"
@@ -1785,8 +1787,11 @@ industry_current_status_three_col:
builder_options: builder_options:
item_parser: compare_row_3col_item # NEW parser placeholder — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item. item_parser: compare_row_3col_item # NEW parser placeholder — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item.
col_a_label_default: "제조업" # F30 source column 1 label (analysis.md three_industries anchor set). col_a_label_default: "제조업" # F30 source column 1 label (analysis.md three_industries anchor set).
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_b_label_default: "건축" # F30 source column 2 label. col_b_label_default: "건축" # F30 source column 2 label.
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_c_label_default: "토목" # F30 source column 3 label (강조 테두리 빨간색 cue — visual styling). col_c_label_default: "토목" # F30 source column 3 label (강조 테두리 빨간색 cue — visual styling).
col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
max_rows: 12 # typical 4-6, overflow 보호 (peer parity with compare_table_2col max_rows 12). max_rows: 12 # typical 4-6, overflow 보호 (peer parity with compare_table_2col max_rows 12).
@@ -1847,6 +1852,9 @@ industry_characteristics_three_col:
builder_options: builder_options:
item_parser: compare_row_3col_item # Shared parser with industry_current_status_three_col (F30) — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item. item_parser: compare_row_3col_item # Shared parser with industry_current_status_three_col (F30) — top_bullet → {label, col_a, col_b, col_c}. Peer parity with compare_row_2col_item.
col_a_label_default: "제조업" # F31 source column 1 label (analysis.md three_industries anchor set; shared with F30). col_a_label_default: "제조업" # F31 source column 1 label (analysis.md three_industries anchor set; shared with F30).
col_a_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_b_label_default: "건축" # F31 source column 2 label (shared with F30). col_b_label_default: "건축" # F31 source column 2 label (shared with F30).
col_b_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
col_c_label_default: "토목" # F31 source column 3 label (강조 테두리 빨간색 cue — visual styling; shared with F30). col_c_label_default: "토목" # F31 source column 3 label (강조 테두리 빨간색 cue — visual styling; shared with F30).
col_c_label_default_role: placeholder # IMP-40 (#69) — Figma visual placeholder; suppressed at runtime, NOT a fallback
max_rows: 12 # typical 3-4 (F31 compressed view), overflow 보호 (peer parity with compare_table_2col + F30 compare_table_3col max_rows 12). max_rows: 12 # typical 3-4 (F31 compressed view), overflow 보호 (peer parity with compare_table_2col + F30 compare_table_3col max_rows 12).

View File

@@ -8,7 +8,7 @@
"slot_names": ["col_a_label", "col_b_label", "rows", "title"], "slot_names": ["col_a_label", "col_b_label", "rows", "title"],
"list_slot_counts": {"rows": 2}, "list_slot_counts": {"rows": 2},
"dict_slot_sub_counts": {}, "dict_slot_sub_counts": {},
"string_slot_nonempty": {"col_a_label": true, "col_b_label": true, "title": true} "string_slot_nonempty": {"col_a_label": false, "col_b_label": false, "title": true}
}, },
{ {
"position": "bottom", "position": "bottom",

View File

@@ -0,0 +1,323 @@
"""IMP-40 u4 (issue #69) — synthetic role-policy matrix for
``_build_compare_table_2col`` label-default discriminator.
Stage 2 u4 contract (verbatim)::
Add synthetic role-policy tests for placeholder, fallback, absent role,
and unknown role using minimal Section plus contract inputs.
Why this is load-bearing
========================
u1 (frame_contracts.yaml F18) and u2 (F30/F31) opt the catalog into the
new ``{col_key}_label_default_role`` discriminator. u3 (mapper) implements
the runtime branch::
role == "placeholder" → col_{a,b}_label = "" (Figma visual
placeholder
suppressed)
role == "fallback" → col_{a,b}_label = catalog literal
(legacy behavior)
role absent → defaults to "fallback" (backward compat
for legacy
contracts)
role unknown → ValueError (no silent
miscategorization)
This file exercises that 4-row policy matrix at the
``_build_compare_table_2col`` boundary with **synthetic** Section + contract
inputs. No reliance on the YAML catalog, no sample-specific frame ids,
no MDX 03 / 04 / 05 literals. Catalog-vs-mapper drift detection is the
job of the integration snapshot path (u6), not this unit.
Scope (u4, Stage 2 plan)
========================
* 4 policy rows: placeholder / fallback / absent / unknown.
* ``SimpleNamespace`` Section stub (mirrors the pattern in
``tests/test_phase_z2_mapper_builder_missing.py``).
* Inline contract dicts — no catalog import.
* ``title`` slot omitted (``_resolve_title`` returns ``{}`` when
``payload.title.source`` is absent — verified at
``src/phase_z2_mapper.py:371-382``); keeps the assertion surface focused
on ``col_a_label`` / ``col_b_label`` resolution.
* Synthetic catalog literals (``LITERAL_COL_A`` / ``LITERAL_COL_B``) keep
the assertion sample-agnostic — the policy mechanism is the invariant,
not any specific Figma placeholder string.
Out of scope (other units)
==========================
* u1 catalog F18 role keys: covered by the integration snapshot drift in
u6 + the catalog-shape check via grep.
* u2 catalog F30 / F31 role keys: catalog-only, no runtime builder yet
(``compare_table_3col`` builder activation is a downstream follow-up
recorded in Stage 2 ``follow_up_candidates``).
* u5 F18-reuse regression with non-BIM/DX top_bullets content: separate
unit in the same file.
* u6 mdx 01 F18 ``slot_payload`` snapshot refresh.
"""
from __future__ import annotations
from types import SimpleNamespace
import pytest
from src.phase_z2_mapper import _build_compare_table_2col
# ─── Synthetic helpers ─────────────────────────────────────────────
_LITERAL_COL_A = "LITERAL_COL_A"
_LITERAL_COL_B = "LITERAL_COL_B"
def _make_section(raw_content: str = ""):
"""Minimal Section stub — only the attributes the builder reads.
``_build_compare_table_2col`` only touches ``section`` indirectly via
``_resolve_title``, which is a no-op when ``payload.title.source`` is
absent. We still pass an empty ``raw_content`` so future regressions
that start reading from it would surface immediately rather than
silently passing on a placeholder.
"""
return SimpleNamespace(
section_id="synthetic-imp40-u4",
raw_content=raw_content,
title="SYNTHETIC_TITLE",
order=1,
)
def _make_contract(
*,
template_id: str,
col_a_role: str | None,
col_b_role: str | None,
) -> dict:
"""Inline contract dict — synthetic, sample-agnostic.
role=None → key omitted entirely (legacy / absent-role axis).
"""
builder_options: dict = {
"item_parser": "compare_row_2col_item",
"col_a_label_default": _LITERAL_COL_A,
"col_b_label_default": _LITERAL_COL_B,
}
if col_a_role is not None:
builder_options["col_a_label_default_role"] = col_a_role
if col_b_role is not None:
builder_options["col_b_label_default_role"] = col_b_role
return {
"template_id": template_id,
"source_shape": "top_bullets",
"cardinality": {},
"payload": {
"builder": "compare_table_2col",
"builder_options": builder_options,
},
}
# ─── Policy row 1: placeholder → "" (Figma placeholder suppressed) ─
def test_placeholder_role_emits_empty_label_for_both_columns():
"""role=placeholder MUST suppress the catalog literal at runtime.
This is the IMP-40 leak-fix invariant: even though the catalog still
carries a Figma placeholder string (preserved for design preview),
the builder MUST NOT inject it into the runtime payload.
"""
contract = _make_contract(
template_id="synthetic_placeholder_both",
col_a_role="placeholder",
col_b_role="placeholder",
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == ""
assert payload["col_b_label"] == ""
assert _LITERAL_COL_A not in payload["col_a_label"]
assert _LITERAL_COL_B not in payload["col_b_label"]
# ─── Policy row 2: fallback → catalog literal (legacy behavior) ────
def test_fallback_role_emits_catalog_literal_for_both_columns():
"""role=fallback MUST preserve the pre-IMP-40 behavior byte-for-byte.
Legacy contracts that explicitly opt into ``fallback`` (or migrate
forward from absent-role) must keep emitting the catalog literal so
that frames where MDX genuinely omits a header still render a
meaningful default.
"""
contract = _make_contract(
template_id="synthetic_fallback_both",
col_a_role="fallback",
col_b_role="fallback",
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == _LITERAL_COL_A
assert payload["col_b_label"] == _LITERAL_COL_B
# ─── Policy row 3: absent role → fallback (backward compatibility) ─
def test_absent_role_defaults_to_fallback_for_both_columns():
"""Contracts without the new ``_role`` discriminator MUST be inert.
Backward compatibility guard: u1/u2 only add ``_role`` keys to the
targeted F18 / F30 / F31 frames. Every other ``compare_table_2col``
consumer in the catalog (current or future) that omits the key MUST
continue to receive the catalog literal — same as pre-IMP-40.
"""
contract = _make_contract(
template_id="synthetic_absent_role",
col_a_role=None,
col_b_role=None,
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == _LITERAL_COL_A
assert payload["col_b_label"] == _LITERAL_COL_B
def test_partial_role_mix_is_resolved_per_column():
"""Role discriminator MUST be resolved independently per column.
Synthetic edge case: col_a=placeholder, col_b absent. The placeholder
column emits "", the absent column falls back to its catalog literal.
Guards against any future refactor that accidentally couples the two
columns through a shared resolution path.
"""
contract = _make_contract(
template_id="synthetic_partial_mix",
col_a_role="placeholder",
col_b_role=None,
)
payload = _build_compare_table_2col(_make_section(), units=[], contract=contract)
assert payload["col_a_label"] == ""
assert payload["col_b_label"] == _LITERAL_COL_B
# ─── Policy row 4: unknown role → ValueError (fail-fast) ───────────
def test_unknown_role_raises_value_error_with_contract_context():
"""role=<garbage> MUST raise ValueError, not silently fall back.
A typo or stale role value in a hand-edited catalog must surface
immediately at build time rather than being miscategorized as
"fallback" or "placeholder". Error message MUST cite the contract
template_id, the role key, and the invalid value to make catalog
repair tractable.
"""
contract = _make_contract(
template_id="synthetic_unknown_role",
col_a_role="not_a_real_role",
col_b_role="placeholder",
)
with pytest.raises(ValueError) as exc:
_build_compare_table_2col(_make_section(), units=[], contract=contract)
msg = str(exc.value)
assert "synthetic_unknown_role" in msg
assert "col_a_label_default_role" in msg
assert "not_a_real_role" in msg
assert "placeholder" in msg
assert "fallback" in msg
# ─── u5 : F18-reuse regression — non-BIM/DX rows + placeholder role ─
_F18_CATALOG_LITERAL_COL_A = "BIM" # Verbatim F18 col_a_label_default (frame_contracts.yaml:476)
_F18_CATALOG_LITERAL_COL_B = "DX" # Verbatim F18 col_b_label_default (frame_contracts.yaml:477)
_F18_LEAK_TOKENS = (_F18_CATALOG_LITERAL_COL_A, _F18_CATALOG_LITERAL_COL_B)
def _make_f18_clone_contract() -> dict:
"""F18-shaped contract — verbatim BIM/DX literals + placeholder role.
Mirrors the catalog state after u1: BIM/DX kept as Figma visual
placeholders, but the role discriminator tells the builder to
suppress them at runtime. The ``template_id`` is namespaced
(``synthetic_f18_reuse_non_bim_dx``) so the test is not coupled to
the real F18 frame id; the leak invariant is independent of the
template_id string.
"""
return {
"template_id": "synthetic_f18_reuse_non_bim_dx",
"source_shape": "top_bullets",
"cardinality": {},
"payload": {
"builder": "compare_table_2col",
"builder_options": {
"item_parser": "compare_row_2col_item",
"col_a_label_default": _F18_CATALOG_LITERAL_COL_A,
"col_a_label_default_role": "placeholder",
"col_b_label_default": _F18_CATALOG_LITERAL_COL_B,
"col_b_label_default_role": "placeholder",
},
},
}
def test_f18_reuse_with_non_bim_dx_rows_suppresses_catalog_placeholder():
"""F18-reuse axis (mdx 04-2 scenario, synthetic): BIM/DX MUST NOT leak.
Models the downstream MDX that maps F18's anchor set to non-BIM/DX
content (the issue body cites 정책/조직 as a representative reuse).
With ``col_*_label_default_role="placeholder"``, the builder MUST
suppress the Figma literals at runtime. Because the synthetic rows
themselves carry no BIM / DX tokens, any appearance of those strings
anywhere in the payload would prove a catalog literal leaked through
— that is precisely the regression IMP-40 #69 must prevent.
"""
contract = _make_f18_clone_contract()
units = [
(
"- **정책 도입 단계**",
[" - 단기 우선순위 수립", " - 부서별 협업 강화"],
),
(
"- **조직 운영 구조**",
[" - 의사결정 책임자 명시", " - 정기 리뷰 사이클 운영"],
),
]
payload = _build_compare_table_2col(
_make_section(), units=units, contract=contract
)
# Placeholder role suppression at the header axis.
assert payload["col_a_label"] == ""
assert payload["col_b_label"] == ""
# Row content is derived from MDX-style synthetic units, not catalog.
assert len(payload["rows"]) == 2
assert payload["rows"][0]["label"] == "정책 도입 단계"
assert payload["rows"][0]["col_a"] == "단기 우선순위 수립"
assert payload["rows"][0]["col_b"] == "부서별 협업 강화"
assert payload["rows"][1]["label"] == "조직 운영 구조"
assert payload["rows"][1]["col_a"] == "의사결정 책임자 명시"
assert payload["rows"][1]["col_b"] == "정기 리뷰 사이클 운영"
# F18-leak invariant: BIM / DX tokens MUST NOT appear anywhere in payload.
for leak in _F18_LEAK_TOKENS:
assert leak not in payload["col_a_label"], (
f"placeholder role failed to suppress catalog literal '{leak}' in col_a_label"
)
assert leak not in payload["col_b_label"], (
f"placeholder role failed to suppress catalog literal '{leak}' in col_b_label"
)
for row in payload["rows"]:
assert leak not in row["label"]
assert leak not in row["col_a"]
assert leak not in row["col_b"]