From 1ec9c665b47dd3fe7320e1db7db569dd06ea6e7c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 6 Oct 2026 22:25:43 +0900 Subject: [PATCH 01/16] feat(governance): add Gap register traceability matrix Add an offline stdlib CLI that links open PRs and issues to the Gap register in docs/product-technical-gap-baseline.md. It reports linked work, register IDs without work, work without a known ID, dangling references and duplicate register rows. --require-link and --require-link-event exist for a later, separately approved PR gate. No workflow or required check is added: 14 cited IDs are undefined on main and must be registered or corrected first. A mention is a textual reference only; it is not evidence that the work implements or closes a gap. --- CHANGELOG.md | 12 + docs/doctoring/gap-traceability-matrix.md | 131 ++++++ scripts/ci/gap_traceability_matrix.py | 491 ++++++++++++++++++++++ tests/test_gap_traceability_matrix.py | 310 ++++++++++++++ 4 files changed, 944 insertions(+) create mode 100644 docs/doctoring/gap-traceability-matrix.md create mode 100644 scripts/ci/gap_traceability_matrix.py create mode 100644 tests/test_gap_traceability_matrix.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d90fa0c899..212f329ccd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### Gap register traceability matrix + +- Added `scripts/ci/gap_traceability_matrix.py`, an offline standard-library + CLI that links open PRs and issues to the Gap register in + `docs/product-technical-gap-baseline.md`. It reports linked work, register + IDs without work, work without a known ID, dangling references and duplicate + register rows, and offers `--require-link-event` for a later, separately + approved PR gate. On the 2026-10-06 inventory, 13 of 623 open items cited a + known register ID, 19 of 27 register IDs had no open work and 14 cited IDs + were undefined on `main`. No workflow or required check is added. See + `docs/doctoring/gap-traceability-matrix.md`. + ### Intel macOS native archives are bound to x86_64 bytes - The release prescreener now requires every native member in an Intel macOS diff --git a/docs/doctoring/gap-traceability-matrix.md b/docs/doctoring/gap-traceability-matrix.md new file mode 100644 index 0000000000..5b8b05ea3b --- /dev/null +++ b/docs/doctoring/gap-traceability-matrix.md @@ -0,0 +1,131 @@ +# Gap traceability matrix + +Status: proposed. Scope: ContextualWisdomLab/.github control plane. +Register: [`docs/product-technical-gap-baseline.md`](../product-technical-gap-baseline.md). +Tool: [`scripts/ci/gap_traceability_matrix.py`](../../scripts/ci/gap_traceability_matrix.py). + +## Problem + +The Gap baseline requires new work to link its Gap ID in the PR description +and test evidence. Nothing measured whether that happens. On 2026-10-06 +12:30 KST, the authenticated `gh pr list` and `gh issue list` exports held 402 +open PRs and 221 open issues. The tool found these results: + +| Measure | Result | +| --- | --- | +| Register IDs (`G-`, `PRD-`, `CONTROL-` rows under a `Gap ID` or `ID` header) | 27, no duplicates | +| Open work items that mention a known register ID | 13 of 623 | +| Register IDs that no open work item mentions | 19 of 27 | +| IDs that open work cites but that the register does not define | 14 | + +The 14 undefined IDs include `G-18` to `G-22`, `PRD-08`, +`CONTROL-PERSONAL-LITELLM-REVIEW-01` and seven other `CONTROL-*` incident IDs. +Some of them are defined only in an unmerged PR branch, and some appear in the +baseline only as prose instead of as a register row. For example, +`CONTROL-PERSONAL-LITELLM-REVIEW-01` is a row on the `align-pr-issue-runner` +branch (#2577), not on protected `main`. In both cases a reader of `main` +cannot resolve the reference. + +These counts describe one snapshot. They do not authorize a merge and they +do not rank work. + +## Decision + +Add an offline, standard-library-only CLI that writes a JSON and Markdown +matrix from the register and the inventory exports: + +```sh +gh pr list --state open --limit 1000 \ + --json number,title,body,isDraft,url > prs.json +gh issue list --state open --limit 1000 \ + --json number,title,body,url > issues.json +python3 scripts/ci/gap_traceability_matrix.py \ + --prs prs.json --issues issues.json \ + --output-json matrix.json --output-md matrix.md \ + --generated-at "$(date '+%Y-%m-%d %H:%M %Z')" +``` + +The report shows, for each register ID, the PRs and issues that mention it. +It also lists IDs without work, work without a known ID, dangling references +and duplicate register rows. The JSON records the SHA-256 digest of each input +so a reader can bind the report to its exact inputs. + +`--require-link-event "$GITHUB_EVENT_PATH"` checks the PR in a live event +payload. `--require-link N` checks PR `N` in an exported inventory. Both exit 1 +when the PR names no known register ID. This change does not add a workflow +or a required check. The current backlog would fail such a gate, so turning it +on needs a separate owner decision. That decision must say which PRs it +applies to, for example new non-bot PRs only. + +## Matching rules + +- A register row is a body row of a Markdown table whose header's first cell + is `Gap ID` or `ID`, and whose first cell is exactly one ID. IDs in prose, + in other cells and in other tables (such as the PR inventory) are mentions. +- Mentions are taken from the title and body. Fenced code, HTML comments, + URLs and Markdown link targets are removed first. Inline code is removed + unless its whole content is one ID, because PR bodies here usually quote IDs + as code. +- An ID must not be attached to a letter, digit, `_`, `-`, `/` or a dotted + name on its left, or to a letter, digit, `_` or `-` on its right. Korean + particles such as `G-15를` still match. +- A range such as `G-17..G-22` yields only its two endpoints. Bodies use + ranges both for "these gaps" and for "outside these gaps", so expanding + them would create links that the author did not claim. + +## Limits + +- A mention is not evidence that the work implements, verifies or closes a + gap. A body can list an ID without doing the work. Reviewers still judge + substance. +- The tool reads one repository's inventory. Cross-repository work such as + `naruon#974` is not linked. +- `gh` returns 30 items unless `--limit` is set. Check that the summary + counts match the expected inventory size. +- Renamed or reused IDs, such as the earlier `G-15` collision, are reported + only when two register rows exist at the same time. + +## Method + +The design used a mixture of agents from different model families. Codex +and OpenCode each proposed parsing rules, an exit-code contract and edge cases +independently. A Claude proposer ran twice and timed out both times without +an answer, so it contributed nothing. The implementation combines the points +both answers shared: the header-anchored register, removal of code and URLs, +exit codes 0/1/2/3 and checking the live event payload. + +A separate read-only Codex pass then reviewed the implementation +adversarially. It timed out before writing a final answer, so its partial +reasoning trace was used. Each candidate finding was reproduced before it was +accepted: + +- `G-04/G-09` lost the second ID. A slash now blocks a match only after a + path segment, not after another ID. +- Example tables in fenced code were parsed as register rows. Fenced code, + including indented, tilde and longer fences, is now skipped. +- An unclosed fence or HTML comment exposed the rest of the body. Both now + run to the end of the text. +- A URL swallowed an ID attached to it after punctuation such as `)`. +- `--fail-on-duplicates` was ignored by the link-check modes, and an output + write failure exited 1, the "unlinked" code. Both are fixed. +- `"isDraft": "false"` was read as a draft. Only JSON `true` counts now. + +A timing probe found two quadratic regular expressions that hostile PR bodies +could trigger: an unclosed `|\Z)", re.S) +INLINE_CODE_PATTERN = re.compile(r"`([^`\n]*)`") +LINK_TARGET_PATTERN = re.compile(r"\]\([^()\s]*(?:\s[^()]*)?\)") +URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.\-]{0,31}://[^\s<>()\[\]]+", re.I) + +REGISTER_HEADERS = frozenset({"gap id", "id"}) + + +class InputError(ValueError): + """Raised when an input file is unreadable or has the wrong shape.""" + + +@dataclass(frozen=True) +class GapEntry: + """One register row: an ID, its short description and its source line.""" + + gap_id: str + description: str + line: int + + +@dataclass(frozen=True) +class WorkItem: + """A PR or issue and the Gap IDs its title and body mention.""" + + kind: str + number: int + title: str + url: str + is_draft: bool + mentions: frozenset[str] + + +def split_cells(row: str) -> list[str]: + """Split a Markdown table row on unescaped pipes and strip each cell.""" + body = row.strip() + if body.startswith("|"): + body = body[1:] + if body.endswith("|") and not body.endswith("\\|"): + body = body[:-1] + return [cell.strip() for cell in re.split(r"(? str: + """Remove emphasis, code and link markup from a table cell.""" + text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", cell) + text = text.replace("**", "").replace("`", "").replace("\\|", "|") + return re.sub(r"\s+", " ", text).strip() + + +def shorten(text: str, limit: int = DESCRIPTION_LIMIT) -> str: + """Cut ``text`` to ``limit`` characters, marking a cut with an ellipsis.""" + if len(text) <= limit: + return text + return text[: limit - 1].rstrip() + "…" + + +def parse_register(markdown: str) -> list[GapEntry]: + """Return every register row in document order, duplicates included. + + A register row is a body row of a Markdown table whose header's first cell + is ``Gap ID`` or ``ID`` and whose first cell is exactly one Gap ID. IDs in + other cells, other tables, fenced code or prose are not definitions. + """ + entries: list[GapEntry] = [] + in_register = False + previous_was_table = False + fence = "" + for index, raw in enumerate(markdown.splitlines(), start=1): + opener = FENCE_LINE_PATTERN.match(raw) + if fence: + if opener and opener.group(1).startswith(fence): + fence = "" + continue + if opener: + fence = opener.group(1) + in_register = False + previous_was_table = False + continue + line = raw.strip() + if not line.startswith("|"): + in_register = False + previous_was_table = False + continue + cells = split_cells(line) + if not previous_was_table: + in_register = clean_cell(cells[0]).lower() in REGISTER_HEADERS + previous_was_table = True + continue + previous_was_table = True + if not in_register or re.fullmatch(r":?-{3,}:?", cells[0]): + continue + candidate = clean_cell(cells[0]) + if FULL_ID_PATTERN.match(candidate): + description = clean_cell(cells[1]) if len(cells) > 1 else "" + entries.append(GapEntry(candidate, shorten(description), index)) + return entries + + +def _keep_exact_id_code(match: re.Match[str]) -> str: + """Keep an inline code span only when its whole content is one Gap ID.""" + content = match.group(1).strip() + return f" {content} " if FULL_ID_PATTERN.match(content) else " " + + +def mention_text(text: str) -> str: + """Remove text regions that must not produce mentions. + + Fenced blocks, HTML comments, URLs and link targets are removed. Inline + code is removed unless it contains exactly one Gap ID, because PR bodies + in this organization routinely quote IDs as code. Link text is kept. + """ + text = text.replace("\r\n", "\n") + text = FENCE_PATTERN.sub(" ", text) + text = HTML_COMMENT_PATTERN.sub(" ", text) + text = INLINE_CODE_PATTERN.sub(_keep_exact_id_code, text) + text = LINK_TARGET_PATTERN.sub("]", text) + return URL_PATTERN.sub(" ", text) + + +def extract_mentions(text: str) -> frozenset[str]: + """Return the Gap IDs that ``text`` mentions. + + Range notation such as ``G-01..G-16`` yields only its two endpoints. PR + bodies use ranges both for "these gaps" and for "outside these gaps", so + expanding them would create links that the author did not claim. + """ + return frozenset(match.group(1) for match in ID_PATTERN.finditer(mention_text(text))) + + +def load_json_list(path: Path, label: str) -> list[dict[str, Any]]: + """Load a JSON array of objects from ``path`` or raise ``InputError``.""" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise InputError(f"{label}: cannot read JSON from {path}: {exc}") from exc + if not isinstance(data, list) or not all(isinstance(row, dict) for row in data): + raise InputError(f"{label}: expected a JSON array of objects in {path}") + return data + + +def parse_work_items(rows: Iterable[dict[str, Any]], kind: str) -> list[WorkItem]: + """Convert ``gh --json`` rows into work items with their Gap mentions.""" + items: list[WorkItem] = [] + for row in rows: + number = row.get("number") + if isinstance(number, bool) or not isinstance(number, int) or number <= 0: + raise InputError(f"{kind}: every row needs a positive integer 'number'") + title = row.get("title") or "" + body = row.get("body") or "" + if not isinstance(title, str) or not isinstance(body, str): + raise InputError(f"{kind} #{number}: 'title' and 'body' must be strings") + items.append( + WorkItem( + kind=kind, + number=number, + title=title, + url=str(row.get("url") or ""), + is_draft=row.get("isDraft") is True, + mentions=extract_mentions(title + "\n" + body), + ) + ) + return items + + +def _ref(item: WorkItem) -> dict[str, Any]: + """Return the compact JSON reference for a work item.""" + return { + "number": item.number, + "title": item.title, + "url": item.url, + "is_draft": item.is_draft, + } + + +def build_matrix( + entries: Sequence[GapEntry], + work: Sequence[WorkItem], + inputs: dict[str, str] | None = None, +) -> dict[str, Any]: + """Build the deterministic traceability matrix as a JSON-ready dict.""" + first: dict[str, GapEntry] = {} + duplicate_lines: dict[str, list[int]] = {} + for entry in entries: + if entry.gap_id in first: + duplicate_lines.setdefault(entry.gap_id, [first[entry.gap_id].line]) + duplicate_lines[entry.gap_id].append(entry.line) + else: + first[entry.gap_id] = entry + known = set(first) + ordered = sorted(work, key=lambda item: (item.kind, item.number)) + gaps: dict[str, Any] = {} + for gap_id, entry in first.items(): + linked = [item for item in ordered if gap_id in item.mentions] + gaps[gap_id] = { + "description": entry.description, + "line": entry.line, + "prs": [_ref(item) for item in linked if item.kind == "pr"], + "issues": [_ref(item) for item in linked if item.kind == "issue"], + } + dangling: dict[str, list[dict[str, Any]]] = {} + for item in ordered: + for gap_id in sorted(item.mentions - known): + dangling.setdefault(gap_id, []).append({"kind": item.kind, **_ref(item)}) + unlinked_work = [ + {"kind": item.kind, **_ref(item)} + for item in ordered + if not item.mentions & known + ] + unlinked_gaps = [ + gap_id for gap_id, row in gaps.items() if not row["prs"] and not row["issues"] + ] + linked_count = len(ordered) - len(unlinked_work) + return { + "schema_version": SCHEMA_VERSION, + "inputs": dict(sorted((inputs or {}).items())), + "summary": { + "register_ids": len(first), + "register_rows": len(entries), + "work_items": len(ordered), + "prs": sum(1 for item in ordered if item.kind == "pr"), + "issues": sum(1 for item in ordered if item.kind == "issue"), + "linked_work_items": linked_count, + "unlinked_work_items": len(unlinked_work), + "unlinked_gaps": len(unlinked_gaps), + "dangling_ids": len(dangling), + "duplicate_register_ids": len(duplicate_lines), + }, + "gaps": gaps, + "unlinked_gaps": unlinked_gaps, + "unlinked_work": unlinked_work, + "dangling_references": dict(sorted(dangling.items())), + "duplicate_register_ids": dict(sorted(duplicate_lines.items())), + } + + +def _md(text: str) -> str: + """Escape a value for one Markdown table cell.""" + return text.replace("|", "\\|").replace("\n", " ") + + +def _numbers(refs: Sequence[dict[str, Any]]) -> str: + """Render work references as ``#N`` numbers, marking drafts.""" + if not refs: + return "—" + return ", ".join( + f"#{ref['number']}" + (" (draft)" if ref.get("is_draft") else "") for ref in refs + ) + + +def render_markdown(matrix: dict[str, Any], generated_at: str) -> str: + """Render the matrix as a human-readable Markdown report.""" + summary = matrix["summary"] + lines = [ + "# Gap traceability matrix", + "", + f"Generated: {generated_at}. Schema: `{matrix['schema_version']}`.", + "", + "A mention is a textual reference in a PR or issue title or body. It is", + "not evidence that the work implements, verifies or closes the gap.", + "", + "## Summary", + "", + "| Measure | Count |", + "| --- | --- |", + ] + lines += [f"| {key.replace('_', ' ')} | {value} |" for key, value in summary.items()] + lines += ["", "## Register coverage", "", "| Gap ID | Description | PRs | Issues |"] + lines.append("| --- | --- | --- | --- |") + for gap_id, row in matrix["gaps"].items(): + lines.append( + f"| {gap_id} | {_md(row['description'])} | {_numbers(row['prs'])} | " + f"{_numbers(row['issues'])} |" + ) + lines += ["", "## Dangling references", ""] + if matrix["dangling_references"]: + lines += ["| Referenced ID | Work items |", "| --- | --- |"] + for gap_id, refs in matrix["dangling_references"].items(): + lines.append(f"| {gap_id} | {_numbers(refs)} |") + else: + lines.append("None.") + lines += ["", "## Duplicate register IDs", ""] + if matrix["duplicate_register_ids"]: + for gap_id, line_numbers in matrix["duplicate_register_ids"].items(): + lines.append(f"- {gap_id}: lines {', '.join(map(str, line_numbers))}") + else: + lines.append("None.") + lines += ["", "## Work without a known Gap ID", ""] + lines.append(f"{summary['unlinked_work_items']} items. See the JSON report for the list.") + return "\n".join(lines) + "\n" + + +def require_link( + known_ids: set[str], items: Sequence[WorkItem], number: int +) -> tuple[int, str]: + """Check that PR ``number`` mentions a known register ID.""" + for item in items: + if item.kind == "pr" and item.number == number: + known = sorted(item.mentions & known_ids) + if known: + return EXIT_OK, f"PR #{number} links {', '.join(known)}" + unknown = sorted(item.mentions - known_ids) + detail = f" (unknown: {', '.join(unknown)})" if unknown else "" + return EXIT_UNLINKED, f"PR #{number} links no known Gap ID{detail}" + return EXIT_INPUT, f"PR #{number} is not in the PR inventory" + + +def pr_from_event(path: Path) -> WorkItem: + """Read the PR from a ``pull_request`` or ``pull_request_target`` payload.""" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc: + raise InputError(f"event: cannot read JSON from {path}: {exc}") from exc + pull = payload.get("pull_request") if isinstance(payload, dict) else None + if not isinstance(pull, dict): + raise InputError("event: payload has no 'pull_request' object") + row = { + "number": pull.get("number"), + "title": pull.get("title"), + "body": pull.get("body"), + "isDraft": pull.get("draft", False), + "url": pull.get("html_url"), + } + return parse_work_items([row], "pr")[0] + + +def sha256_file(path: Path) -> str: + """Return the SHA-256 hex digest of a file's bytes.""" + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def write_text(path: Path, text: str) -> None: + """Write a report file, converting OS errors into ``InputError``.""" + try: + path.write_text(text, encoding="utf-8") + except OSError as exc: + raise InputError(f"output: cannot write {path}: {exc}") from exc + + +def build_arg_parser() -> argparse.ArgumentParser: + """Build the command-line parser.""" + parser = argparse.ArgumentParser( + description="Build a Gap-to-work traceability matrix." + ) + parser.add_argument( + "--register", + type=Path, + default=Path("docs/product-technical-gap-baseline.md"), + help="Gap register Markdown file", + ) + parser.add_argument("--prs", type=Path, help="gh pr list --json export") + parser.add_argument("--issues", type=Path, help="gh issue list --json export") + parser.add_argument("--output-json", type=Path, help="write the JSON matrix here") + parser.add_argument("--output-md", type=Path, help="write the Markdown report here") + parser.add_argument("--generated-at", default="unspecified", help="report timestamp label") + group = parser.add_mutually_exclusive_group() + group.add_argument( + "--require-link", type=int, metavar="PR", help="check one PR from --prs" + ) + group.add_argument( + "--require-link-event", + type=Path, + metavar="EVENT_JSON", + help="check the PR in a GitHub event payload (for example $GITHUB_EVENT_PATH)", + ) + parser.add_argument( + "--fail-on-duplicates", + action="store_true", + help="exit 3 when the register defines an ID more than once", + ) + return parser + + +def run(args: argparse.Namespace) -> int: + """Execute the parsed command and return its exit code.""" + try: + markdown = args.register.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as exc: + raise InputError(f"register: cannot read {args.register}: {exc}") from exc + entries = parse_register(markdown) + if not entries: + raise InputError(f"register: no Gap ID rows found in {args.register}") + known_ids = {entry.gap_id for entry in entries} + duplicates = len(entries) != len(known_ids) + if duplicates: + print("warning: duplicate register IDs found", file=sys.stderr) + if args.fail_on_duplicates: + return EXIT_DUPLICATE + if args.require_link_event is not None: + item = pr_from_event(args.require_link_event) + code, message = require_link(known_ids, [item], item.number) + print(message) + return code + work: list[WorkItem] = [] + inputs = {"register_sha256": sha256_file(args.register)} + if args.prs is not None: + work += parse_work_items(load_json_list(args.prs, "prs"), "pr") + inputs["prs_sha256"] = sha256_file(args.prs) + if args.issues is not None: + work += parse_work_items(load_json_list(args.issues, "issues"), "issue") + inputs["issues_sha256"] = sha256_file(args.issues) + if args.require_link is not None: + if args.prs is None: + raise InputError("--require-link needs --prs") + code, message = require_link(known_ids, work, args.require_link) + print(message) + return code + matrix = build_matrix(entries, work, inputs) + if args.output_json is not None: + write_text(args.output_json, json.dumps(matrix, ensure_ascii=False, indent=2) + "\n") + if args.output_md is not None: + write_text(args.output_md, render_markdown(matrix, args.generated_at)) + print(json.dumps(matrix["summary"], ensure_ascii=False, sort_keys=True)) + return EXIT_OK + + +def main(argv: Sequence[str] | None = None) -> int: + """Command-line entry point. Input errors exit 2 with a message on stderr.""" + args = build_arg_parser().parse_args(argv) + try: + return run(args) + except InputError as exc: + print(f"error: {exc}", file=sys.stderr) + return EXIT_INPUT + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/tests/test_gap_traceability_matrix.py b/tests/test_gap_traceability_matrix.py new file mode 100644 index 0000000000..40a816a481 --- /dev/null +++ b/tests/test_gap_traceability_matrix.py @@ -0,0 +1,310 @@ +"""Tests for the Gap-to-work traceability matrix.""" + +import json + +import pytest + +from scripts.ci import gap_traceability_matrix as gtm + +REGISTER = """# Baseline + +Prose mentions G-99 and G-01 but defines nothing. + +| Gap ID | 상태 | evidence | +|---|---|---| +| CONTROL-SELF-HOSTED-EXECUTION-01 | **Draft** `x` | a \\| b | +| G-01 | [linked](https://example.com/G-02) text | e | + +| ID | 결과 | 증거 | +|---|---|---| +| PRD-01 | find sender | retrieval | +| not an id | ignored | x | + +| PR | title | +|---|---| +| G-03 | wrong table header, not a register row | + +| Gap ID | Status | +| --- | --- | +| G-01 | duplicate definition | +| G-04 | +""" + + +def _write(tmp_path, name, data): + """Write JSON or text fixture data and return the path.""" + path = tmp_path / name + path.write_text(data if isinstance(data, str) else json.dumps(data), encoding="utf-8") + return path + + +def test_parse_register_reads_only_register_tables(): + """Rows count only under a Gap ID/ID header; prose and other tables do not.""" + entries = gtm.parse_register(REGISTER) + assert [e.gap_id for e in entries] == [ + "CONTROL-SELF-HOSTED-EXECUTION-01", + "G-01", + "PRD-01", + "G-01", + "G-04", + ] + assert entries[0].description == "Draft x" + assert entries[1].description == "linked text" + assert entries[-1].description == "" + assert entries[1].line == 8 + + +def test_parse_register_ignores_fenced_examples(): + """Example tables inside fenced code are not register rows.""" + text = ( + "```md\n| Gap ID | x |\n|---|---|\n| G-90 | example |\n```\n" + "| Gap ID | x |\n|---|---|\n| G-01 | real |\n" + "````\n```\n| ID | x |\n|---|---|\n| G-91 | nested |\n````\n" + ) + assert [e.gap_id for e in gtm.parse_register(text)] == ["G-01"] + + +def test_split_cells_respects_escaped_pipes(): + """An escaped pipe stays inside its cell.""" + assert gtm.split_cells("| a \\| b | c |") == ["a \\| b", "c"] + assert gtm.split_cells("a | b \\|") == ["a", "b \\|"] + + +def test_shorten_marks_cut_text(): + """Long descriptions are cut with an ellipsis; short ones are unchanged.""" + assert gtm.shorten("abc", 5) == "abc" + assert gtm.shorten("abcdefgh", 5) == "abcd…" + + +@pytest.mark.parametrize( + "text, expected", + [ + ("Gap G-04 queue hygiene, G-09 green CI", {"G-04", "G-09"}), + ("G-15를 보강한다", {"G-15"}), + ("`CONTROL-OPENCODE-LATEST-REVIEW-01` evidence", {"CONTROL-OPENCODE-LATEST-REVIEW-01"}), + ("`see G-01 here` only", set()), + ("```\nG-01\n```\nG-02", {"G-02"}), + ("", set()), + ("https://x.test/G-01 and [G-02](https://x.test/G-03)", {"G-02"}), + ("/G-01 foo.G-02 file-G-03 x_G-04", set()), + ("G-150 G-1 G-01A G-01-extra g-01", {"G-150"}), + ("deadbeefG-01 cafe0G-02", set()), + ("G-17..G-22 and G-17–G-19", {"G-17", "G-22", "G-19"}), + ("end of sentence. G-05.", {"G-05"}), + ("PRD-08 / TRD-02", {"PRD-08", "TRD-02"}), + ("line\r\nG-06\r\n", {"G-06"}), + ("see https://x.test/a,G-07 and (https://x.test/b)G-08", {"G-08"}), + ("G-02 ", set()), ("https://x.test/G-01 and [G-02](https://x.test/G-03)", {"G-02"}), ("/G-01 foo.G-02 file-G-03 x_G-04", set()), From da846ec62d14a4d653d3dbaf8b2bcb5be95b9272 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:04:30 +0900 Subject: [PATCH 07/16] fix(governance): enforce Markdown parser boundaries --- scripts/ci/gap_traceability_matrix.py | 135 +++++++++++++++++++++----- 1 file changed, 111 insertions(+), 24 deletions(-) diff --git a/scripts/ci/gap_traceability_matrix.py b/scripts/ci/gap_traceability_matrix.py index 73ec6b1c57..029f799f66 100644 --- a/scripts/ci/gap_traceability_matrix.py +++ b/scripts/ci/gap_traceability_matrix.py @@ -53,17 +53,13 @@ ID_PATTERN = re.compile(_LEFT + "(" + _ID_CORE + ")" + _RIGHT) FULL_ID_PATTERN = re.compile(r"\A" + _ID_CORE + r"\Z") -# An unclosed fence runs to the end of the text, as in CommonMark. -FENCE_PATTERN = re.compile( - r"^[ \t]{0,3}(`{3,}|~{3,}).*?(?:^[ \t]{0,3}\1[^\n]*$|\Z)", re.M | re.S -) -FENCE_LINE_PATTERN = re.compile(r"[ \t]{0,3}(`{3,}|~{3,})") +FENCE_LINE_PATTERN = re.compile(r"^[ \\t]{0,3}((?:`{3,})|(?:~{3,}))(.*)$") +INLINE_CODE_RUN_PATTERN = re.compile(r"`+") # Every pattern below must stay linear on hostile PR bodies: unclosed # comments run to the end, link targets cannot restart inside themselves and # URL schemes are bounded so a long hyphenated token cannot backtrack. -HTML_COMMENT_PATTERN = re.compile(r"|\Z)", re.S) -INLINE_CODE_PATTERN = re.compile(r"`([^`\n]*)`") -LINK_TARGET_PATTERN = re.compile(r"\]\([^()\s]*(?:\s[^()]*)?\)") +HTML_COMMENT_PATTERN = re.compile(r"|\\Z)", re.S) +LINK_TARGET_PATTERN = re.compile(r"\\]\\([^()\\s]*(?:\\s[^()]*)?\\)") URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.\-]{0,31}://[^\s<>()\[\]]+", re.I) REGISTER_HEADERS = frozenset({"gap id", "id"}) @@ -118,6 +114,95 @@ def shorten(text: str, limit: int = DESCRIPTION_LIMIT) -> str: return text[: limit - 1].rstrip() + "…" +def _fence_parts(line: str) -> tuple[str, int, str] | None: + """Return a fence character, run length and trailing text for a fence line.""" + match = FENCE_LINE_PATTERN.match(line) + if not match: + return None + marker, trailing = match.groups() + return marker[0], len(marker), trailing + + +def _fence_opener(line: str) -> tuple[str, int] | None: + """Return a valid CommonMark-style opener, rejecting backticks in its info.""" + parts = _fence_parts(line) + if not parts: + return None + character, length, trailing = parts + if character == "`" and "`" in trailing: + return None + return character, length + + +def _closes_fence(line: str, fence: tuple[str, int]) -> bool: + """Return whether ``line`` is a same-character, long-enough clean closer.""" + parts = _fence_parts(line) + return bool( + parts + and parts[0] == fence[0] + and parts[1] >= fence[1] + and not parts[2].strip() + ) + + +def _strip_fenced_blocks(text: str) -> str: + """Remove fenced blocks while preserving line boundaries for later scans.""" + output: list[str] = [] + fence: tuple[str, int] | None = None + for line in text.splitlines(keepends=True): + raw = line.rstrip("\\r\\n") + ending = line[len(raw) :] + if fence: + if _closes_fence(raw, fence): + fence = None + output.append(ending or " ") + continue + opener = _fence_opener(raw) + if opener: + fence = opener + output.append(ending or " ") + else: + output.append(line) + return "".join(output) + + +def _strip_inline_code(text: str) -> str: + """Remove code spans of any backtick-run length, keeping exact Gap IDs.""" + output: list[str] = [] + for line in text.splitlines(keepends=True): + runs = list(INLINE_CODE_RUN_PATTERN.finditer(line)) + cursor = 0 + run_index = 0 + while run_index < len(runs): + opener = runs[run_index] + closer_index = run_index + 1 + while ( + closer_index < len(runs) + and len(runs[closer_index].group()) != len(opener.group()) + ): + closer_index += 1 + if closer_index == len(runs): + run_index += 1 + continue + closer = runs[closer_index] + output.append(line[cursor : opener.start()]) + code_content = line[opener.end() : closer.start()].strip() + output.append( + f" {code_content} " if FULL_ID_PATTERN.match(code_content) else " " + ) + cursor = closer.end() + run_index = closer_index + 1 + output.append(line[cursor:]) + return "".join(output) + + +def _is_table_separator(cells: list[str]) -> bool: + """Return whether every cell is a Markdown table delimiter cell.""" + return bool(cells) and all( + re.fullmatch(r":?-{3,}:?", clean_cell(cell)) for cell in cells + ) + + def parse_register(markdown: str) -> list[GapEntry]: """Return every register row in document order, duplicates included. @@ -127,31 +212,39 @@ def parse_register(markdown: str) -> list[GapEntry]: """ entries: list[GapEntry] = [] in_register = False + awaiting_separator = False previous_was_table = False - fence = "" + fence: tuple[str, int] | None = None for index, raw in enumerate(markdown.splitlines(), start=1): - opener = FENCE_LINE_PATTERN.match(raw) if fence: - if opener and opener.group(1).startswith(fence): - fence = "" + if _closes_fence(raw, fence): + fence = None continue + opener = _fence_opener(raw) if opener: - fence = opener.group(1) + fence = opener in_register = False + awaiting_separator = False previous_was_table = False continue line = raw.strip() if not line.startswith("|"): in_register = False + awaiting_separator = False previous_was_table = False continue cells = split_cells(line) if not previous_was_table: - in_register = clean_cell(cells[0]).lower() in REGISTER_HEADERS + awaiting_separator = clean_cell(cells[0]).lower() in REGISTER_HEADERS + in_register = False previous_was_table = True continue previous_was_table = True - if not in_register or re.fullmatch(r":?-{3,}:?", cells[0]): + if awaiting_separator: + in_register = _is_table_separator(cells) + awaiting_separator = False + continue + if not in_register: continue candidate = clean_cell(cells[0]) if FULL_ID_PATTERN.match(candidate): @@ -160,12 +253,6 @@ def parse_register(markdown: str) -> list[GapEntry]: return entries -def _keep_exact_id_code(match: re.Match[str]) -> str: - """Keep an inline code span only when its whole content is one Gap ID.""" - content = match.group(1).strip() - return f" {content} " if FULL_ID_PATTERN.match(content) else " " - - def mention_text(text: str) -> str: """Remove text regions that must not produce mentions. @@ -173,10 +260,10 @@ def mention_text(text: str) -> str: code is removed unless it contains exactly one Gap ID, because PR bodies in this organization routinely quote IDs as code. Link text is kept. """ - text = text.replace("\r\n", "\n") - text = FENCE_PATTERN.sub(" ", text) + text = text.replace("\\r\\n", "\\n") + text = _strip_fenced_blocks(text) text = HTML_COMMENT_PATTERN.sub(" ", text) - text = INLINE_CODE_PATTERN.sub(_keep_exact_id_code, text) + text = _strip_inline_code(text) text = LINK_TARGET_PATTERN.sub("]", text) return URL_PATTERN.sub(" ", text) From 7153a8b67040bfc29d634b9ce8f092a50a624c15 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:06:47 +0900 Subject: [PATCH 08/16] docs(governance): record Markdown boundary repair --- docs/doctoring/gap-traceability-matrix.md | 35 ++++++++++++++--------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/docs/doctoring/gap-traceability-matrix.md b/docs/doctoring/gap-traceability-matrix.md index 9bd99f7bee..279cd508cf 100644 --- a/docs/doctoring/gap-traceability-matrix.md +++ b/docs/doctoring/gap-traceability-matrix.md @@ -60,12 +60,14 @@ applies to, for example new non-bot PRs only. ## Matching rules - A register row is a body row of a Markdown table whose header's first cell - is `Gap ID` or `ID`, and whose first cell is exactly one ID. IDs in prose, - in other cells and in other tables (such as the PR inventory) are mentions. + is `Gap ID` or `ID`, whose next row is a valid Markdown delimiter row, + and whose first cell is exactly one ID. IDs in prose, in other cells and in + other tables (such as the PR inventory) are mentions. - Mentions are taken from the title and body. Fenced code, HTML comments, - URLs and Markdown link targets are removed first. Inline code is removed - unless its whole content is one ID, because PR bodies here usually quote IDs - as code. + URLs and Markdown link targets are removed first. A fence closes only with + the same marker, at least the opening length and no trailing non-whitespace. + Inline code of any backtick-run length is removed unless its whole content + is one ID, because PR bodies here usually quote IDs as code. - An ID must not be attached to a letter, digit, `_`, `-`, `/` or a dotted name on its left, or to a letter, digit, `_` or `-` on its right. Korean particles such as `G-15를` still match. @@ -94,14 +96,17 @@ an answer, so it contributed nothing. The implementation combines the points both answers shared: the header-anchored register, removal of code and URLs, exit codes 0/1/2/3 and checking the live event payload. -The external sources support the goals, not these repository-specific parser +The external sources support the goals, not repository-specific parser mechanics. ISO/IEC/IEEE 29148 defines requirements-engineering processes and their information items, which supports maintaining explicit links from work to registered requirements -(International Organization for Standardization, 2018). PROV-O defines interoperable provenance descriptions across systems, -which supports recording input digests that bind a report to the artifacts -from which it was derived (World Wide Web Consortium, 2013). Header anchoring, -exit codes and live-event parsing remain local design choices validated by the +(International Organization for Standardization, 2018). PROV-O supports +describing provenance and derivation across systems; the SHA-256 digest is this +repository's local mechanism for binding a report to the exact input artifacts +(World Wide Web Consortium, 2013). GitHub defines `GITHUB_EVENT_PATH` as the +runner file containing the complete webhook payload, so the live-event mode +reads that authoritative input (GitHub, n.d.). Header anchoring, the meanings +of exit codes 1/2/3 and Markdown parsing remain local choices validated by the tests below. A separate read-only Codex pass then reviewed the implementation @@ -138,15 +143,19 @@ https://www.iso.org/standard/72089.html World Wide Web Consortium. (2013, April 30). *PROV-O: The PROV ontology* (W3C Recommendation). https://www.w3.org/TR/prov-o/ +GitHub. (n.d.). *Variables reference*. GitHub Docs. +https://docs.github.com/en/actions/reference/workflows-and-actions/variables + ## Verification - On predecessor head `1ec9c665b47dd3fe7320e1db7db569dd06ea6e7c`, the focused suite passed 55 tests in `tests/test_gap_traceability_matrix.py`, under both normal and `GITHUB_ACTIONS=true` runs. -- Exact head adds one documentation contract. Its four assertions directly - passed against the exact-head doctoring file; the complete 56-test pytest - rerun remains required before merge. +- Exact head adds one documentation contract plus four Markdown-boundary + cases. Direct execution passes all three defect reproductions, five adjacent + regression cases, Python compilation and the four citation assertions. The + complete 60-test pytest rerun remains required before merge. - The new module has 100% statement and branch coverage, and `interrogate` reports 100% docstring coverage for it. - One test parses the repository's own baseline and requires its register From 4b72e5984b89b0fedde28943c7f95707a14b7021 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:07:01 +0900 Subject: [PATCH 09/16] test(governance): bind live event documentation --- tests/test_gap_traceability_matrix.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_gap_traceability_matrix.py b/tests/test_gap_traceability_matrix.py index 607841bb25..e8b95c1c8c 100644 --- a/tests/test_gap_traceability_matrix.py +++ b/tests/test_gap_traceability_matrix.py @@ -312,6 +312,8 @@ def test_doctoring_cites_authoritative_traceability_sources(): assert "(World Wide Web Consortium, 2013)" in doctoring assert "https://www.iso.org/standard/72089.html" in doctoring assert "https://www.w3.org/TR/prov-o/" in doctoring + assert "(GitHub, n.d.)" in doctoring + assert "https://docs.github.com/en/actions/reference/workflows-and-actions/variables" in doctoring def test_live_register_parses_without_duplicates(): From ccfa9b781aaad05d1e855a94b048105b42d9b356 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:07:09 +0900 Subject: [PATCH 10/16] docs(governance): align citation evidence count --- docs/doctoring/gap-traceability-matrix.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/doctoring/gap-traceability-matrix.md b/docs/doctoring/gap-traceability-matrix.md index 279cd508cf..41d1423f86 100644 --- a/docs/doctoring/gap-traceability-matrix.md +++ b/docs/doctoring/gap-traceability-matrix.md @@ -154,7 +154,7 @@ https://docs.github.com/en/actions/reference/workflows-and-actions/variables `GITHUB_ACTIONS=true` runs. - Exact head adds one documentation contract plus four Markdown-boundary cases. Direct execution passes all three defect reproductions, five adjacent - regression cases, Python compilation and the four citation assertions. The + regression cases, Python compilation and the six citation assertions. The complete 60-test pytest rerun remains required before merge. - The new module has 100% statement and branch coverage, and `interrogate` reports 100% docstring coverage for it. From 20c08173643b0b0fcdcc5be4085967850c46fd8b Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:09:13 +0900 Subject: [PATCH 11/16] fix(governance): scan unclosed comments linearly --- scripts/ci/gap_traceability_matrix.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/scripts/ci/gap_traceability_matrix.py b/scripts/ci/gap_traceability_matrix.py index 029f799f66..5b7279ef4e 100644 --- a/scripts/ci/gap_traceability_matrix.py +++ b/scripts/ci/gap_traceability_matrix.py @@ -58,7 +58,6 @@ # Every pattern below must stay linear on hostile PR bodies: unclosed # comments run to the end, link targets cannot restart inside themselves and # URL schemes are bounded so a long hyphenated token cannot backtrack. -HTML_COMMENT_PATTERN = re.compile(r"|\\Z)", re.S) LINK_TARGET_PATTERN = re.compile(r"\\]\\([^()\\s]*(?:\\s[^()]*)?\\)") URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.\-]{0,31}://[^\s<>()\[\]]+", re.I) @@ -166,6 +165,24 @@ def _strip_fenced_blocks(text: str) -> str: return "".join(output) +def _strip_html_comments(text: str) -> str: + """Remove HTML comments in one forward scan; an unclosed comment runs to EOF.""" + output: list[str] = [] + cursor = 0 + while True: + start = text.find("", start + 4) + if end < 0: + break + cursor = end + 3 + return "".join(output) + + def _strip_inline_code(text: str) -> str: """Remove code spans of any backtick-run length, keeping exact Gap IDs.""" output: list[str] = [] @@ -262,7 +279,7 @@ def mention_text(text: str) -> str: """ text = text.replace("\\r\\n", "\\n") text = _strip_fenced_blocks(text) - text = HTML_COMMENT_PATTERN.sub(" ", text) + text = _strip_html_comments(text) text = _strip_inline_code(text) text = LINK_TARGET_PATTERN.sub("]", text) return URL_PATTERN.sub(" ", text) From bdad3010fe0482b46e38b7a0daf89d976893c399 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:09:49 +0900 Subject: [PATCH 12/16] docs(governance): record linear parser evidence --- docs/doctoring/gap-traceability-matrix.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/doctoring/gap-traceability-matrix.md b/docs/doctoring/gap-traceability-matrix.md index 41d1423f86..f0367f9e24 100644 --- a/docs/doctoring/gap-traceability-matrix.md +++ b/docs/doctoring/gap-traceability-matrix.md @@ -125,12 +125,14 @@ accepted: write failure exited 1, the "unlinked" code. Both are fixed. - `"isDraft": "false"` was read as a draft. Only JSON `true` counts now. -A timing probe found two quadratic regular expressions that hostile PR bodies -could trigger: an unclosed `", set()), ("https://x.test/G-01 and [G-02](https://x.test/G-03)", {"G-02"}), + ("[link](relative target G-11)", set()), ("/G-01 foo.G-02 file-G-03 x_G-04", set()), ("G-150 G-1 G-01A G-01-extra g-01", {"G-150"}), ("deadbeefG-01 cafe0G-02", set()), From c7efe939dea2b4529cf83acdbce0d9b635c96aad Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:15:54 +0900 Subject: [PATCH 14/16] fix(governance): strip Markdown link targets --- scripts/ci/gap_traceability_matrix.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/ci/gap_traceability_matrix.py b/scripts/ci/gap_traceability_matrix.py index 5b7279ef4e..22db1724bd 100644 --- a/scripts/ci/gap_traceability_matrix.py +++ b/scripts/ci/gap_traceability_matrix.py @@ -58,7 +58,7 @@ # Every pattern below must stay linear on hostile PR bodies: unclosed # comments run to the end, link targets cannot restart inside themselves and # URL schemes are bounded so a long hyphenated token cannot backtrack. -LINK_TARGET_PATTERN = re.compile(r"\\]\\([^()\\s]*(?:\\s[^()]*)?\\)") +LINK_TARGET_PATTERN = re.compile(r"\]\([^()\s]*(?:\s[^()]*)?\)") URL_PATTERN = re.compile(r"\b[a-z][a-z0-9+.\-]{0,31}://[^\s<>()\[\]]+", re.I) REGISTER_HEADERS = frozenset({"gap id", "id"}) From 951534fa0825786550b2f002ca88f86028d1363c Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 15:22:38 +0900 Subject: [PATCH 15/16] test(governance): cover unmatched Markdown delimiters --- tests/test_gap_traceability_matrix.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_gap_traceability_matrix.py b/tests/test_gap_traceability_matrix.py index ac3311f45a..12955ffa37 100644 --- a/tests/test_gap_traceability_matrix.py +++ b/tests/test_gap_traceability_matrix.py @@ -118,6 +118,8 @@ def test_shorten_marks_cut_text(): ("docs/G-01 and /G-02", set()), ("G-03\n```\nG-01 never closed", {"G-03"}), (" ~~~~\nG-01\n ~~~~\nG-02", {"G-02"}), + ("```bad`info\nG-01", {"G-01"}), + ("`unmatched `` G-01", {"G-01"}), ], ) def test_extract_mentions(text, expected): From 752f383717ff3f3b109c67e625da58a7958b437d Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 7 Oct 2026 16:12:46 +0900 Subject: [PATCH 16/16] docs(governance): align exact-head verification evidence --- docs/doctoring/gap-traceability-matrix.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/doctoring/gap-traceability-matrix.md b/docs/doctoring/gap-traceability-matrix.md index f0367f9e24..42acd841b5 100644 --- a/docs/doctoring/gap-traceability-matrix.md +++ b/docs/doctoring/gap-traceability-matrix.md @@ -154,11 +154,14 @@ https://docs.github.com/en/actions/reference/workflows-and-actions/variables the focused suite passed 55 tests in `tests/test_gap_traceability_matrix.py`, under both normal and `GITHUB_ACTIONS=true` runs. -- Exact head adds one documentation contract plus four Markdown-boundary - cases. Direct execution passes all three defect reproductions, five adjacent - regression cases, all six hostile-input performance fixtures (worst: - 0.029 seconds), Python compilation and the six citation assertions. The - complete 60-test pytest rerun remains required before merge. +- Implementation head `951534fa0825786550b2f002ca88f86028d1363c` + adds the repaired Markdown-link target case plus unmatched-fence and + unmatched-inline-run boundaries. The focused suite passes 63 tests under + both normal and `GITHUB_ACTIONS=true` runs. Direct probes also pass all + defect reproductions, adjacent regressions and six hostile-input performance + fixtures (worst: 0.029 seconds). +- On the same implementation tree, the warning-fatal full suite reports 5,221 + passed and 11 skipped, with 40 subtests passed; `git diff --check` passes. - The new module has 100% statement and branch coverage, and `interrogate` reports 100% docstring coverage for it. - One test parses the repository's own baseline and requires its register