From 8d1e0d8b5cce3b860eb5a592f006e204180957cf Mon Sep 17 00:00:00 2001 From: carl Date: Sun, 16 Aug 2026 15:27:32 +0800 Subject: [PATCH 1/2] feat(source-facts): [ markdown ] name the unclosed fence instead of failing silently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 關閉行帶 info string(```json)依 CommonMark 不算關閉,所以掃描器從那一行起把整份 文件當成圍籬內容:掃出零筆、沒有任何錯誤,與「這份來源本來就沒結構」完全無法區分。 判定維持嚴格(ADR 0008)。放寬會在最貴的那個案例上出錯——兩個相鄰的開啟圍籬會被 讀成一開一關,夾在中間的程式碼範例因此變成來源事實,而假事實在 fail-closed 閘門下 會擋掉正確的擷取。改的是可見性:SourceFacts 記下未關閉的圍籬開在第幾行,涵蓋投影 帶著這個純量,SOURCE_FACTS_UNSCANNED 的訊息與 verify-extraction 的預告直接點名行號 ——成因已知時就不該叫 operator 從三種可能裡自己猜。 量測:十三個 benchmark case 掃過 320 個圍籬行,零個 info-string 關閉、零份文件掃到 檔尾仍在圍籬內。這是把便宜的揭露做在前面,不是在滅火。 markdown_drafts 那套掃描器是寬容的(任何以標記開頭的行都算關閉),刻意不動:它的 產物是給人審的非權威草稿,外洩一段範例的代價是審閱者多看一眼;source_facts 餵的是 fail-closed 閘門,同樣的外洩代價是一份正確的擷取。分歧記在 ADR 0008,收斂與否留給 掃描器分歧那張票。 --- AGENTS.md | 2 +- ...osed-fence-is-reported-not-guessed-shut.md | 67 +++++++++++++++++++ docs/operator-manual.en.html | 2 +- docs/operator-manual.html | 2 +- loop_apidoc/agentcli/fact_coverage.py | 7 +- loop_apidoc/agentcli/verify.py | 18 +++-- loop_apidoc/source_facts/markdown.py | 6 ++ loop_apidoc/source_facts/models.py | 4 ++ loop_apidoc/validate/fact_coverage.py | 17 ++++- .../reference/assemble-and-correction.md | 2 +- tests/source_facts/test_markdown.py | 49 ++++++++++++++ ...est_cli_verify_extraction_fact_coverage.py | 12 ++++ tests/validate/test_fact_coverage.py | 20 ++++++ 13 files changed, 197 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md diff --git a/AGENTS.md b/AGENTS.md index dcdcf1f..b81229f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -122,7 +122,7 @@ The CLI commands include source acquisition, quality, assembly, analysis, and Fo | `loop_apidoc/docx_normalization.py` + `docx_{models,validation,render,publish}.py` | stable DOCX facade plus bounded, fail-closed OOXML validation, deterministic rendering, and staged Markdown/`.source.json` publication with rollback on reported write failures; package validation scans every Word XML part for active DDE fields, markup alternatives, merged cells, and external content without executing or resolving relationships | | `loop_apidoc/adapters/fragments.py` | read-side I/O exit that materializes exact page/line/section/table-cell/JSON Pointer fragments from source artifacts; fragment digests use normalized fragment content, not whole-document bytes | | `loop_apidoc/shadow/` | opt-in legacy/Core compatibility sidecar: `models.py` (mode, diagnostics, comparison, summaries), `bridge.py` (pure manifest/plan → evidence/support proposals/metadata; a v1 exact reference owns its declared claim path while filename-only legacy citations degrade to `insufficient`/unverified), `runner.py` (in-memory deterministic verification and evidence-aware projections through validate only), `report.py` (successful `core/*.json` plus `core/projections/`, or safe `core/error.json`; this package's only file-I/O exit) | -| `loop_apidoc/source_facts/` | deterministic source-fact inventory feeding the semantic completeness gate (issue #14): `models.py` (`EndpointFact`/`SourceFacts`/`FactIndex`, `by_identity()` keeping only the **intersection** when several sources document one `(METHOD, path)` — an overview index table or a deprecated v1 section would otherwise widen the requirement past what the extraction was right to ignore; ambiguity fails open), `markdown.py` (`scan_markdown`, pure: endpoint declarations, parameter-table field names — only tables whose first header cell is name-like, with nested-row decoration stripped and group-label rows skipped — and fenced example-block counts, fence-aware so code samples never leak facts. **Scope limit:** only well-structured Markdown yields facts; a flattened HTML-to-text dump yields none and the gate is a no-op on it — an accepted trade-off, since guessing structure would manufacture false facts and a false fact blocks a correct extraction — the cost is disclosed at runtime as `SOURCE_FACTS_UNSCANNED`, and `collect.py` is named in ADR 0007's falsification condition), `collect.py` (`collect_facts`, the package's only read: manifest-named Markdown sources → `FactIndex`; unreadable sources are skipped, since manifest coverage already reports them), `gate.py` (`source_fact_violations`, pure: for every extracted endpoint that matches a fact by `(METHOD, path)`, a documented field absent from every structural position — and not named in `missing[]` — or a documented example with an empty `examples[]` is a violation; no match ⇒ no judgement), `deferral.py` (`deferral_violations`, pure: rejects placeholder answers like "requires further extraction"/「需進一步擷取」 outside `missing[]`). `markdown.py` also recognises **error-code tables** into per-source `ErrorCodeFact`s, and `FactIndex.documented_error_codes()` unions them across sources into the documented error-code floor — union, not the `by_identity()` intersection, because different documents tabulate different code sets rather than competing accounts of one thing (ADR 0005). Recognition is strict: an unambiguous code header, or a generic one (`代碼`/`code`/`status code`) corroborated by an enclosing error section; at least two columns; and one malformed data row discards the whole table rather than lowering the floor silently. **`gate.py` deliberately does not consume the floor** — only focus directives are judged against it. Error codes are a document-level shared catalogue with no endpoint to match against, so an unscoped requirement would hit integrations that correctly implement only part of a provider; requiring exhaustiveness stays something a requester asks for (ADR 0006). That is a decision, not an oversight, and `source_facts/gate.py` is named in ADR 0006's falsification condition | +| `loop_apidoc/source_facts/` | deterministic source-fact inventory feeding the semantic completeness gate (issue #14): `models.py` (`EndpointFact`/`SourceFacts`/`FactIndex`, `by_identity()` keeping only the **intersection** when several sources document one `(METHOD, path)` — an overview index table or a deprecated v1 section would otherwise widen the requirement past what the extraction was right to ignore; ambiguity fails open), `markdown.py` (`scan_markdown`, pure: endpoint declarations, parameter-table field names — only tables whose first header cell is name-like, with nested-row decoration stripped and group-label rows skipped — and fenced example-block counts, fence-aware so code samples never leak facts; a closing fence must carry no info string (CommonMark), and when the scan ends inside a fence `SourceFacts.unclosed_fence_line` records where it opened so the `SOURCE_FACTS_UNSCANNED` warning names the line instead of listing possible causes — the strict rule is never relaxed into guessing a fence shut, ADR 0008. **Scope limit:** only well-structured Markdown yields facts; a flattened HTML-to-text dump yields none and the gate is a no-op on it — an accepted trade-off, since guessing structure would manufacture false facts and a false fact blocks a correct extraction — the cost is disclosed at runtime as `SOURCE_FACTS_UNSCANNED`, and `collect.py` is named in ADR 0007's falsification condition), `collect.py` (`collect_facts`, the package's only read: manifest-named Markdown sources → `FactIndex`; unreadable sources are skipped, since manifest coverage already reports them), `gate.py` (`source_fact_violations`, pure: for every extracted endpoint that matches a fact by `(METHOD, path)`, a documented field absent from every structural position — and not named in `missing[]` — or a documented example with an empty `examples[]` is a violation; no match ⇒ no judgement), `deferral.py` (`deferral_violations`, pure: rejects placeholder answers like "requires further extraction"/「需進一步擷取」 outside `missing[]`). `markdown.py` also recognises **error-code tables** into per-source `ErrorCodeFact`s, and `FactIndex.documented_error_codes()` unions them across sources into the documented error-code floor — union, not the `by_identity()` intersection, because different documents tabulate different code sets rather than competing accounts of one thing (ADR 0005). Recognition is strict: an unambiguous code header, or a generic one (`代碼`/`code`/`status code`) corroborated by an enclosing error section; at least two columns; and one malformed data row discards the whole table rather than lowering the floor silently. **`gate.py` deliberately does not consume the floor** — only focus directives are judged against it. Error codes are a document-level shared catalogue with no endpoint to match against, so an unscoped requirement would hit integrations that correctly implement only part of a provider; requiring exhaustiveness stays something a requester asks for (ADR 0006). That is a decision, not an oversight, and `source_facts/gate.py` is named in ADR 0006's falsification condition | | `loop_apidoc/focus/` | requester-authored extraction focus directives: `models.py` (strict `extra="forbid"` `FocusDirective`/`FocusResponse` contracts — `kind` is the sole determinant of severity, `intent` the sole determinant of anchor type, and the only two outcomes are `satisfied`/`not_found`; there is deliberately no "not applicable", since whether a directive applies is the requester's judgement), `loader.py` (this package's only read exit: parses `focus.json` and `/focus-response.json`, `FocusInputError`), `gate.py` (pure: directive↔response correspondence, intent↔anchor-type agreement, anchor resolution against the extraction, and the requirement that a `not_found` answer account for every readable manifest source; also projects anchor evidence for the shared exact-evidence verifier rather than verifying it separately), `fields.py`/`codes.py` (pure anchor vocabularies built on the shared field-name and typed error-catalogue readers), `report.py` (this package's only write exit: `/focus/focus-report.{json,zh-TW.md}`). Structural violations fold into `agentcli/gate.py`, so they fail before a run directory exists. The documented error-code floor is judged outside this package, in `validate/focus.py` (`omitted_error_codes`, shared by the `assemble` issue and the `verify-extraction` forecast so the two cannot disagree); `codes.py` stays the anchor vocabulary that resolves a reported code against the typed catalogue and is deliberately not the floor's source. Focus material never reaches provenance, the score, or Foundry (ADR 0004). | | `loop_apidoc/agentcli/identity.py` | the one definition of an endpoint's cross-file identity key (`METHOD /path`, or `METHOD (webhook) ` when a webhook's path is null), shared by the cross-file invariants and the focus anchor resolver | | `loop_apidoc/extraction/` | shared models + utilities (models, stages, questions, store, jsonblock) used by the agent extraction | diff --git a/docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md b/docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md new file mode 100644 index 0000000..807f249 --- /dev/null +++ b/docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md @@ -0,0 +1,67 @@ +--- +status: accepted +--- + +# An unclosed fence is reported, not guessed shut + +`source_facts/markdown.py` tracks fenced code blocks so that a JSON sample inside one never +becomes a source fact. Following CommonMark, a closing fence must carry no info string: a line +reading ```` ```json ```` opens a fence, it never closes one. + +Some sources close their fences that way anyway — pairing ```` ```json ```` with ```` ```json ```` +reads naturally to a human, and a renderer that is lenient about it will display the document +correctly. Under the strict rule the scan treats everything after that line as fence content, so +the rest of the document is never read: zero facts, no error, and a result identical to a source +that genuinely has no structure. + +The strict rule stays. The alternative — accepting an info-string line as a close — is a guess +about which of two readings the author meant, and it is wrong in the case that costs the most: two +adjacent opening fences (a JSON request sample followed by a JSON response sample, each opened and +closed in the ordinary way, with a stray info string on one close) would be read as one open and +one close, putting the sample *between* them outside any fence. Its contents then become source +facts. A fabricated fact blocks a correct extraction under the fail-closed completeness gate, +which is the harm this project consistently refuses to risk (ADR 0007). + +What changes is that the failure is no longer silent. `SourceFacts` records the line where a fence +opened and was never closed, the coverage projection carries that line, and the +`SOURCE_FACTS_UNSCANNED` warning (ADR 0007) names it: the operator is told which line to open +instead of being handed three possible causes to choose between. `verify-extraction` forecasts the +same line before a run directory exists. + +## Considered options + +- Accepting an info-string line as a closing fence fixes the documents that pair their fences that + way, but it is a guess, and the case it gets wrong leaks a code sample into the fact inventory. + A missed fact costs a check that did not run; a fabricated one costs an operator who cannot ship + correct work. +- Re-scanning leniently *only when* the strict scan ends inside a fence would bound the guess to + documents the strict scan definitely failed on. It is tempting and still rejected: a source that + is genuinely truncated mid-sample ends inside a fence too, and that is precisely when a lenient + re-scan reads the truncated sample as prose. +- Failing the run on an unclosed fence would guarantee nobody ships an unread source, but a fence + that never closes is a defect in the *source*, and the pipeline's answer to a defective source is + to report it, not to refuse to produce the artifacts an operator needs in order to judge it. +- Leaving the limit undisclosed — the state before this decision — makes an unread document + indistinguishable from an unstructured one, which is the exact confusion ADR 0007 exists to + remove. + +## Consequences + +A source whose fences are mismatched still yields nothing after that line, and the operator has to +fix the source (or its acquisition path) before the gate can judge it. That is the accepted cost. + +The two Markdown scanners now differ on this point deliberately: `markdown_drafts/markdown.py` +closes a fence on any line starting with the marker, info string or not, because its output is +non-authoritative draft material that a human reviews, and a leaked sample there costs a reviewer +one glance. `source_facts/markdown.py` feeds a fail-closed gate, where the same leak costs a +correct extraction. The divergence is catalogued in the scanner-divergence follow-up rather than +resolved by making one match the other. + +No benchmark source currently trips this: a scan across all thirteen cases found zero +info-string closes and zero documents ending inside a fence. This decision is therefore about a +failure mode that is cheap to disclose and expensive to misread, not about a fire being put out. + +**Falsified if:** an unclosed fence stops being reported, or the scan starts guessing fences shut. +Concretely, this decision no longer holds when `loop_apidoc/source_facts/markdown.py` treats a line +carrying an info string as a closing fence, or when `loop_apidoc/validate/fact_coverage.py` stops +naming the unclosed fence's line. diff --git a/docs/operator-manual.en.html b/docs/operator-manual.en.html index 9dc1bb6..797c4b9 100644 --- a/docs/operator-manual.en.html +++ b/docs/operator-manual.en.html @@ -260,7 +260,7 @@

verify-extraction — check that the ext

The source-fact gate mechanically scans the manifest's Markdown sources for endpoint declarations, parameter tables and fenced example blocks, then matches them to the extraction by (METHOD, path). When a matched source section documents fields or examples that the extraction dropped, the run fails closed — a silent omission is not the same as "the source does not say so." Naming the field in missing satisfies the gate, so it only ever forces a source-grounded gap, never an invention. Field names are resolved through schema_ref into inventory.schemas transitively, so factoring a shared request body out into a common type counts as deduplication, not an omission.

A companion check rejects placeholder answers that defer the work, and it does so in two layers to avoid false positives. Phrases that explicitly name the extraction itself ("further extraction", "not yet extracted", 「需進一步擷取」) count anywhere in a value, since a real API description never discusses its own extraction. Generic placeholders ("TBD", "to be determined", 「待補」) count only when they are the entire field, because "amount to be determined at capture" is legitimate API prose. ASCII phrases match on word boundaries — CJK has none, so those stay substring matches. Without this check a run could finish as passed with empty artifacts.

Know the scope limit. The scan only recognises well-structured Markdown: headings, GFM tables with a separator row, and fenced code blocks. A source flattened into long single lines — an HTML-to-text dump, for instance — yields zero facts, and the gate is a no-op on it. So a clean gate exit is not by itself evidence of a complete extraction; it only proves nothing contradicted the facts that could be mechanically read. On unstructured sources, keep relying on review.html and human review.

-

That limit is no longer silent. Every assemble records, per manifest source, how many facts were scanned and how many of them matched an extracted endpoint identity, and reports both failure shapes as warning-severity SOURCE_FACTS_UNSCANNED validation issues: zero facts (no endpoint facts were scanned from that source — open it first: content flattened into single lines, or an unconverted PDF/Word file, calls for re-running preprocessing along a table-preserving path such as normalize-html-snapshot or preprocess, and re-reading it achieves nothing; a structurally sound source whose endpoints are not written as METHOD /path — a bare URL with the method stated in prose, or a null-path webhook — will keep the warning permanently, because the scanner does not infer a missing method (ADR 0007), and the extraction may be entirely correct; a prose-only source legitimately lands here) and zero matches (facts were scanned but none matched the extraction by METHOD /path — check whether the extraction missed the endpoints that source documents). The severity is always warning and never blocks a run: a legitimate prose-only source with no parameter tables lands in the zero-fact class, and failing it would read "could not be measured" as "is wrong". It does count against the documentation-quality score under source grounding, so two runs differ in score when one had more sources the gate never judged. verify-extraction forecasts the same thing on stderr, before you pay for plan→generate; the forecast stays out of --json and never changes the exit code. The reasoning is recorded in docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md.

+

That limit is no longer silent. Every assemble records, per manifest source, how many facts were scanned and how many of them matched an extracted endpoint identity, and reports both failure shapes as warning-severity SOURCE_FACTS_UNSCANNED validation issues: zero facts (no endpoint facts were scanned from that source — open it first: content flattened into single lines, or an unconverted PDF/Word file, calls for re-running preprocessing along a table-preserving path such as normalize-html-snapshot or preprocess, and re-reading it achieves nothing; a structurally sound source whose endpoints are not written as METHOD /path — a bare URL with the method stated in prose, or a null-path webhook — will keep the warning permanently, because the scanner does not infer a missing method (ADR 0007), and the extraction may be entirely correct; a prose-only source legitimately lands here). When the message names a line number the cause is already settled: a fence opened there and never closed — usually a closing fence carrying an info string, such as ending with ```json — so everything after it went unread. Fix the source and re-extract; the reasoning is recorded in docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md and zero matches (facts were scanned but none matched the extraction by METHOD /path — check whether the extraction missed the endpoints that source documents). The severity is always warning and never blocks a run: a legitimate prose-only source with no parameter tables lands in the zero-fact class, and failing it would read "could not be measured" as "is wrong". It does count against the documentation-quality score under source grounding, so two runs differ in score when one had more sources the gate never judged. verify-extraction forecasts the same thing on stderr, before you pay for plan→generate; the forecast stays out of --json and never changes the exit code. The reasoning is recorded in docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md.

--focus — task-specific extraction focus directives

uv run loop-apidoc verify-extraction --sources ./sources --extraction ./work --focus ./focus.json
 uv run loop-apidoc assemble --sources ./sources --extraction ./work --output ./output \
diff --git a/docs/operator-manual.html b/docs/operator-manual.html
index a01415a..47420b8 100644
--- a/docs/operator-manual.html
+++ b/docs/operator-manual.html
@@ -259,7 +259,7 @@ 

verify-extraction — 檢查擷取 JSON

來源事實閘會機械掃描 manifest 中的 Markdown 來源,取出端點宣告、參數表與圍籬範例區塊,再以 (METHOD, path) 與擷取結果對照。一旦對上的來源小節寫了欄位或範例、擷取卻交回空的,就 fail closed——靜默遺漏不等於「來源沒寫」。要主張來源沒寫,在 missing 裡具名該欄位即可通過,所以這道閘只會逼出有據可查的缺口,不會逼出捏造。欄位名會沿 schema_ref 遞迴解析進 inventory.schemas,因此把共用 request body 抽成共用型別算去重複,不算遺漏。

另一道檢查攔下佔位式延後答案,並分兩層以避免誤判。明確指涉「擷取這件事」的說法(further extractionnot yet extracted、「需進一步擷取」)出現在值的任何位置都算,因為真實 API 描述不會提到自己的擷取流程;泛用佔位字(TBDto be determined、「待補」)則只有在整個欄位就只有這句時才算,因為「amount to be determined at capture」是合法的 API 描述。英文片語以詞界比對,CJK 沒有詞界可言,維持子字串比對。少了這道檢查,run 會以 passed 收場而產物是空的。

請注意適用範圍。這道掃描只認得結構良好的 Markdown:標題、含分隔列的 GFM 表格、圍籬程式碼區塊。若來源被壓成一行行超長文字(例如 HTML 轉純文字的傾印檔),掃描結果為零筆事實,這道閘對它就完全沒有作用。因此閘門乾淨通過本身並不等於擷取完整,它只證明「機械讀得到的事實」沒有被違反。面對非結構化來源,仍要靠 review.html 與人工核對。

-

這個限制不再是靜默的。每次 assemble 都會逐份 manifest 來源記下「掃出幾筆事實、其中幾筆對得上擷取的端點識別」,並把兩種失能寫成 warning 級的 SOURCE_FACTS_UNSCANNED 驗證問題:零事實(這份來源掃不出任何端點事實。先看它屬於哪一種:內容被壓平成單行、或未轉換的 PDF/Word,補救方向是改走保留表格結構的前處理路徑,例如 normalize-html-snapshotpreprocess,重讀來源沒有用;結構完好但端點沒寫成 METHOD /path(只給完整 URL、method 寫在散文裡,或本來就是 path 為 null 的 webhook),掃描器不會去推測缺少的 method——那是 ADR 0007 拒絕的推論,因此這筆警告會長期存在,擷取本身可能完全正確;純散文來源則本來就會落在這裡)與零匹配(掃出了事實,但沒有一筆能以 METHOD /path 對上擷取,補救方向是檢查擷取是否漏了這份來源記載的端點)。severity 恆為 warning、不阻擋 run——純散文、本來就沒有參數表的合法來源會落在零事實這一類,擋下它等於把「量不到」誤判成「錯」——但會計入文件品質分數的 source grounding 類別,讓兩次 run 的分差能表達「這次有更多來源沒被檢查」。verify-extraction 會在 stderr 預告同一件事,讓你在付出 plan→generate 成本之前就能改用別的前處理指令;預告不進 --json、不改退出碼。理由記在 docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md

+

這個限制不再是靜默的。每次 assemble 都會逐份 manifest 來源記下「掃出幾筆事實、其中幾筆對得上擷取的端點識別」,並把兩種失能寫成 warning 級的 SOURCE_FACTS_UNSCANNED 驗證問題:零事實(這份來源掃不出任何端點事實。先看它屬於哪一種:內容被壓平成單行、或未轉換的 PDF/Word,補救方向是改走保留表格結構的前處理路徑,例如 normalize-html-snapshotpreprocess,重讀來源沒有用;結構完好但端點沒寫成 METHOD /path(只給完整 URL、method 寫在散文裡,或本來就是 path 為 null 的 webhook),掃描器不會去推測缺少的 method——那是 ADR 0007 拒絕的推論,因此這筆警告會長期存在,擷取本身可能完全正確;純散文來源則本來就會落在這裡)。訊息若點名了行號,成因就已經確定:那一行開啟的圍籬直到檔尾都沒關閉(常見於關閉行帶了 info string,例如以 ```json 結尾),其後的內容全部沒被讀到——修好來源再重新擷取即可,理由記在 docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md零匹配(掃出了事實,但沒有一筆能以 METHOD /path 對上擷取,補救方向是檢查擷取是否漏了這份來源記載的端點)。severity 恆為 warning、不阻擋 run——純散文、本來就沒有參數表的合法來源會落在零事實這一類,擋下它等於把「量不到」誤判成「錯」——但會計入文件品質分數的 source grounding 類別,讓兩次 run 的分差能表達「這次有更多來源沒被檢查」。verify-extraction 會在 stderr 預告同一件事,讓你在付出 plan→generate 成本之前就能改用別的前處理指令;預告不進 --json、不改退出碼。理由記在 docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md

--focus — 依任務對擷取下重點指令

uv run loop-apidoc verify-extraction --sources ./sources --extraction ./work --focus ./focus.json
 uv run loop-apidoc assemble --sources ./sources --extraction ./work --output ./output \
diff --git a/loop_apidoc/agentcli/fact_coverage.py b/loop_apidoc/agentcli/fact_coverage.py
index 7213343..e072718 100644
--- a/loop_apidoc/agentcli/fact_coverage.py
+++ b/loop_apidoc/agentcli/fact_coverage.py
@@ -40,5 +40,10 @@ def build_fact_coverage(
             in identities
         )
         coverage[source.relative_path] = FactCoverage(
-            facts=len(endpoints), matched=matched)
+            facts=len(endpoints),
+            matched=matched,
+            unclosed_fence_line=(
+                entry.unclosed_fence_line if entry is not None else None
+            ),
+        )
     return coverage
diff --git a/loop_apidoc/agentcli/verify.py b/loop_apidoc/agentcli/verify.py
index 36f85bd..db462d2 100644
--- a/loop_apidoc/agentcli/verify.py
+++ b/loop_apidoc/agentcli/verify.py
@@ -108,11 +108,19 @@ def verify_extraction(
 
 def _forecast(coverage) -> list[str]:
     """把投影寫成人可讀的一行一份來源。"""
-    return [
-        f"{source}:掃出 0 筆端點事實" if entry.facts == 0
-        else f"{source}:{entry.facts} 筆事實無一對上 extraction 的端點"
-        for source, entry in unscanned_sources(coverage)
-    ]
+    lines: list[str] = []
+    for source, entry in unscanned_sources(coverage):
+        if entry.facts == 0 and entry.unclosed_fence_line is not None:
+            lines.append(
+                f"{source}:第 {entry.unclosed_fence_line} 行的圍籬未關閉,"
+                "其後的內容全部沒被讀到"
+            )
+        elif entry.facts == 0:
+            lines.append(f"{source}:掃出 0 筆端點事實")
+        else:
+            lines.append(
+                f"{source}:{entry.facts} 筆事實無一對上 extraction 的端點")
+    return lines
 
 
 def preview_falsified_expectations(
diff --git a/loop_apidoc/source_facts/markdown.py b/loop_apidoc/source_facts/markdown.py
index 1446631..228c76c 100644
--- a/loop_apidoc/source_facts/markdown.py
+++ b/loop_apidoc/source_facts/markdown.py
@@ -135,6 +135,9 @@ def scan_markdown(relative_path: str, text: str) -> SourceFacts:
         relative_path=relative_path,
         endpoints=state.endpoints,
         error_codes=state.error_codes,
+        # 掃完仍在圍籬內 ⇒ 這份文件從那一行起沒有被讀過。判定維持嚴格(見 ADR 0008),
+        # 但失效不再是靜默的。
+        unclosed_fence_line=state.fence_line or None,
     )
 
 
@@ -152,6 +155,7 @@ def __init__(self, relative_path: str) -> None:
         self.declaring_level = 0
         self.fence_marker: str | None = None
         self.fence_length = 0
+        self.fence_line = 0
         self.table: list[tuple[int, str]] = []
         self.previous = ""
         # 錯誤碼表不依附端點,所以索引與累積都在來源層級。
@@ -176,6 +180,7 @@ def open_fence(
                 )
         self.fence_marker = marker[0]
         self.fence_length = len(marker)
+        self.fence_line = index
         self.previous = ""
 
     def closes_fence(self, marker: str, info: str) -> bool:
@@ -188,6 +193,7 @@ def closes_fence(self, marker: str, info: str) -> bool:
     def close_fence(self) -> None:
         self.fence_marker = None
         self.fence_length = 0
+        self.fence_line = 0
         self.previous = ""
 
     def flush_table(self) -> None:
diff --git a/loop_apidoc/source_facts/models.py b/loop_apidoc/source_facts/models.py
index 05df6c1..d0952c1 100644
--- a/loop_apidoc/source_facts/models.py
+++ b/loop_apidoc/source_facts/models.py
@@ -80,6 +80,10 @@ class SourceFacts(BaseModel):
     endpoints: list[EndpointFact] = Field(default_factory=list)
     #: 這份來源以表格結構明確記載的錯誤碼,依出現順序。
     error_codes: list[ErrorCodeFact] = Field(default_factory=list)
+    #: 掃到檔尾仍未關閉的那個圍籬開啟在第幾行(沒有就是 None)。
+    #: 從該行起整份文件都被當成在圍籬內,掃出零筆且毫無錯誤——這個欄位存在的
+    #: 唯一目的,是讓那次靜默失效在報告裡有具名的成因。
+    unclosed_fence_line: int | None = None
 
 
 class FactIndex(BaseModel):
diff --git a/loop_apidoc/validate/fact_coverage.py b/loop_apidoc/validate/fact_coverage.py
index 97323e0..d16d768 100644
--- a/loop_apidoc/validate/fact_coverage.py
+++ b/loop_apidoc/validate/fact_coverage.py
@@ -32,6 +32,9 @@ class FactCoverage(BaseModel):
 
     facts: int
     matched: int
+    #: 掃到檔尾仍未關閉的圍籬開在第幾行(沒有就是 None)。零事實唯一一種掃描器
+    #: 自己知道確切成因的情形,知道就別叫 operator 從三種可能裡猜。
+    unclosed_fence_line: int | None = None
 
 
 def unscanned_sources(
@@ -62,7 +65,19 @@ def check_fact_coverage(
 
 
 def _issue(source: str, entry: FactCoverage) -> Issue:
-    if entry.facts == 0:
+    if entry.facts == 0 and entry.unclosed_fence_line is not None:
+        evidence = (
+            f"這份來源掃出 0 筆端點事實:第 {entry.unclosed_fence_line} 行開啟的圍籬"
+            "直到檔尾都沒有被關閉,從該行起整份文件都被當成程式碼區塊,"
+            "語意完整性閘門對它完全沒有作用"
+        )
+        fix = (
+            f"打開來源第 {entry.unclosed_fence_line} 行,補上關閉圍籬。"
+            "常見成因是關閉行帶了 info string(例如以 ```json 結尾)——依 CommonMark "
+            "那不算關閉,而是又開了一個新的圍籬。修好之後重新擷取,閘門才會讀到"
+            "該行以後的內容。"
+        )
+    elif entry.facts == 0:
         evidence = (
             "這份來源掃出 0 筆端點事實,語意完整性閘門對它完全沒有作用;"
             "報告乾淨不代表它被逐條比對過"
diff --git a/skills/loop-apidoc/reference/assemble-and-correction.md b/skills/loop-apidoc/reference/assemble-and-correction.md
index 6d963d7..c3e87c8 100644
--- a/skills/loop-apidoc/reference/assemble-and-correction.md
+++ b/skills/loop-apidoc/reference/assemble-and-correction.md
@@ -153,7 +153,7 @@ your extraction JSON and re-running.
 | `UNSUPPORTED_ASSERTION` | error | the output asserts something no source states (speculation leaked in) | remove the unsupported content; fail-closed |
 | `FOCUS_UNMET` | error **or** warning | a focus directive was answered `not_found`. ERROR for an Expectation Directive (the requester asserted a source documents this and it was not found), WARNING for a Coverage Directive (finding nothing is a complete answer there). Severity comes from the directive's `kind` alone — there is no per-directive override | re-read the sources named in `requery_scope` and confirm nothing was missed. If the provider genuinely does not document it, **do not invent it**: report the gap to the requester, who either supplies better sources or rewrites the directive as `coverage`. `target_file` is `focus-response.json` and `field_path` is `/responses/` |
 | `FOCUS_INCOMPLETE` | error **or** warning | a `collect_error_codes` directive was answered with fewer codes than the sources tabulate. The **documented error-code floor** is the set of codes a Markdown source presents in an error-code table; reporting fewer names the ones left out, reporting more still passes (it is a floor, not an equality), and sources with no recognisable table produce no floor and no judgement. Severity comes from the directive's `kind` alone, as with `FOCUS_UNMET` | `evidence` names each omitted code with the source path and line where it is documented — open those lines, read the codes, and add them to `inventory.errors[]` with an `error_code` anchor each carrying its own evidence. Padding with invented codes fails earlier, at the gate. `target_file` is `focus-response.json`, `field_path` is `/responses/`, and `requery_scope` lists the documenting sources |
-| `SOURCE_FACTS_UNSCANNED` | warning (always) | the semantic completeness gate never judged that source. Either it scanned **zero endpoint facts**, or it scanned facts but **none matched** an extracted endpoint identity (`METHOD /path`). The report being clean says nothing about that source | **Do not re-read the source** — that is what `SOURCE_UNVERIFIED` asks for and it does not apply here. Open the source and decide which of three shapes it is: (a) flattened into single lines, or an unconverted PDF/Word file — re-run acquisition/preprocessing along a table-preserving path (`normalize-html-snapshot`, `preprocess`), point the manifest at the result, and re-extract; (b) structurally fine (headings, GFM tables) but its endpoints are not written as `METHOD /path` — a bare URL with the method stated elsewhere in prose, or a webhook whose path is null. The scanner will not infer the missing method (ADR 0007), so this warning is permanent for that source; say so to the user and move on, your extraction may be entirely correct; (c) prose-only with no parameter tables — legitimate, report as a known gap, never as a failure. For the zero-match shape, check whether the extraction missed the endpoints that source documents, or wrote a method/path the source does not use. `location` is the source relative path; the routing fields stay `null` on purpose, because there is no JSON field to re-fill. Never blocking (ADR 0007) |
+| `SOURCE_FACTS_UNSCANNED` | warning (always) | the semantic completeness gate never judged that source. Either it scanned **zero endpoint facts**, or it scanned facts but **none matched** an extracted endpoint identity (`METHOD /path`). The report being clean says nothing about that source | **Do not re-read the source** — that is what `SOURCE_UNVERIFIED` asks for and it does not apply here. Open the source and decide which of three shapes it is: (a) flattened into single lines, or an unconverted PDF/Word file — re-run acquisition/preprocessing along a table-preserving path (`normalize-html-snapshot`, `preprocess`), point the manifest at the result, and re-extract; (b) structurally fine (headings, GFM tables) but its endpoints are not written as `METHOD /path` — a bare URL with the method stated elsewhere in prose, or a webhook whose path is null. The scanner will not infer the missing method (ADR 0007), so this warning is permanent for that source; say so to the user and move on, your extraction may be entirely correct; (c) prose-only with no parameter tables — legitimate, report as a known gap, never as a failure. When the issue names a line number, the cause is already known: a fence opened there and never closed (usually a close carrying an info string, e.g. ending with ```json), so everything after it went unread — fix the source and re-extract. For the zero-match shape, check whether the extraction missed the endpoints that source documents, or wrote a method/path the source does not use. `location` is the source relative path; the routing fields stay `null` on purpose, because there is no JSON field to re-fill. Never blocking (ADR 0007) |
 
 ## Driving a correction round (default max 3 rounds; `--score` uses `--max-rounds`, default 6)
 
diff --git a/tests/source_facts/test_markdown.py b/tests/source_facts/test_markdown.py
index 4106604..ed39637 100644
--- a/tests/source_facts/test_markdown.py
+++ b/tests/source_facts/test_markdown.py
@@ -596,3 +596,52 @@ def test_json_and_xml_fences_still_count_as_examples() -> None:
 ```
 """
     assert scan_markdown("doc.md", text).endpoints[0].example_blocks == 2
+
+
+def test_a_fence_that_is_never_closed_is_recorded_with_its_line() -> None:
+    """從該行起整份文件被當成在圍籬內,而那是靜默的全域失效。
+
+    關閉行帶 info string(```` ```json ````)依 CommonMark 不算關閉,所以掃描器維持
+    嚴格判定——放寬會讓兩個相鄰的開啟圍籬被誤判成一開一關,把程式碼範例外洩成事實。
+    代價是掃到檔尾仍在圍籬內時掃出零筆且毫無錯誤,與「這份來源本來就沒結構」無法
+    區分。記下開啟的行號,成因就從三選一縮到一。
+    """
+    text = """
+## GET /a
+
+```json
+{"a": 1}
+```json
+
+## GET /b
+
+`POST /b`
+
+| Name | Type |
+| --- | --- |
+| id | string |
+"""
+    facts = scan_markdown("doc.md", text)
+
+    # 圍籬之後的一切都不見了:`GET /b` 與它的參數表從未被讀到。
+    assert [fact.path for fact in facts.endpoints] == ["/a"]
+    assert facts.endpoints[0].parameter_names == []
+    assert facts.unclosed_fence_line == 4
+
+
+def test_a_document_whose_fences_all_close_records_nothing() -> None:
+    text = """
+## GET /a
+
+```json
+{"a": 1}
+```
+
+| Name | Type |
+| --- | --- |
+| id | string |
+"""
+    facts = scan_markdown("doc.md", text)
+
+    assert facts.unclosed_fence_line is None
+    assert facts.endpoints[0].parameter_names == ["id"]
diff --git a/tests/test_cli_verify_extraction_fact_coverage.py b/tests/test_cli_verify_extraction_fact_coverage.py
index 6e1fda0..6337581 100644
--- a/tests/test_cli_verify_extraction_fact_coverage.py
+++ b/tests/test_cli_verify_extraction_fact_coverage.py
@@ -102,3 +102,15 @@ def test_the_forecast_stays_out_of_the_json_payload(tmp_path):
     res = _verify(sources, extraction, "--json")
 
     assert json.loads(res.stdout) == []
+
+
+def test_the_forecast_names_an_unclosed_fence(tmp_path):
+    """成因已知時,預告就該直接說是哪一行,而不是叫人自己猜。"""
+    # 圍籬開在第 3 行且從未關閉,所以其後的 `GET /ping` 宣告根本沒被讀到。
+    source = "# Demo API\n\n```json\n{}\n```json\n\n`GET /ping`\n"
+    sources, extraction = _setup(tmp_path, source_text=source)
+
+    res = _verify(sources, extraction)
+
+    assert res.exit_code == 0, res.output
+    assert "第 3 行" in res.output
diff --git a/tests/validate/test_fact_coverage.py b/tests/validate/test_fact_coverage.py
index 0a75b94..d0c1327 100644
--- a/tests/validate/test_fact_coverage.py
+++ b/tests/validate/test_fact_coverage.py
@@ -56,3 +56,23 @@ def test_the_warning_never_fails_the_report():
 
     assert len(_unscanned(report)) == 2
     assert report.ok is True
+
+
+def test_an_unclosed_fence_is_named_as_the_cause():
+    """成因已知時就不要叫 operator 從三種可能裡自己猜。
+
+    圍籬未閉合是唯一一種掃描器自己知道確切成因的零事實:從那一行起整份文件都被
+    當成在圍籬內。訊息點名行號,operator 打開來源就看得到問題。
+    """
+    report = _report({"api.md": FactCoverage(facts=0, matched=0, unclosed_fence_line=42)})
+    issue = _unscanned(report)[0]
+
+    assert "42" in issue.evidence
+    assert "42" in issue.suggested_fix or "圍籬" in issue.suggested_fix
+
+
+def test_a_zero_fact_source_without_a_known_cause_still_enumerates_them():
+    issue = _unscanned(_report({"dump.md": FactCoverage(facts=0, matched=0)}))[0]
+
+    assert "42" not in issue.evidence
+    assert "normalize-html-snapshot" in issue.suggested_fix

From 6533edeff4f31362156cef73f5460fd4f0613826 Mon Sep 17 00:00:00 2001
From: carl 
Date: Sun, 16 Aug 2026 15:39:43 +0800
Subject: [PATCH 2/2] fix(source-facts): [ fence ] report a half-read source,
 and only claim real loss
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

code review 的 HIGH 成立:揭露只在事實數為零時才發,所以「前半讀得好好的、圍籬之後
全部沒讀到」這種來源完全不會浮現——比全篇未讀更危險,因為閘門判了前半、報告是乾淨的。
而它一旦開口(零匹配那條分支)還會叫 operator 去查一個並不存在的 extraction 缺陷。
改法:成因已知時壓過其他措辭,判準也從「零匹配」放寬成「零匹配或有未讀的尾巴」。

回報條件同時收緊成更誠實的一條:只有在掃描器看見「長得像關閉、卻不算關閉」的行時
才主張有內容沒被讀到。圍籬單純沒有收尾不算——CommonMark 在檔尾關閉未終止的圍籬,
那份文件在每個讀者眼中都一樣,沒有任何內容因為我們的判定而遺失,回報只會叫人去修
一個不存在的缺陷,而「修好」之後事實數仍然是零。

其餘 review 項目:ADR 0008 改寫成程式碼實際實作的規則(含 EOF 圍籬與半讀來源兩段);
兩份 operator manual 的新段落原本插進了兩項並列的中間、把句子切斷,改成自己的段落;
AGENTS.md 補上 validate 那列與 correction 分類那列(ADR 0008 的 falsification 點名
fact_coverage.py,那列正是它的邊界文件);fence_line 的 0 哨兵改成 None;
測試補上 tilde 圍籬、先關後開、EOF 圍籬、半讀來源與非 Markdown 來源的投影分支,
並拿掉一個用 or 串起來、少了行號也會通過的斷言。
---
 AGENTS.md                                     |  4 +--
 ...osed-fence-is-reported-not-guessed-shut.md | 29 +++++++++++++------
 docs/operator-manual.en.html                  |  3 +-
 docs/operator-manual.html                     |  3 +-
 loop_apidoc/agentcli/verify.py                |  2 +-
 loop_apidoc/source_facts/markdown.py          | 15 ++++++++--
 loop_apidoc/source_facts/models.py            |  8 +++--
 loop_apidoc/validate/fact_coverage.py         | 22 ++++++++++----
 tests/agentcli/test_assemble_fact_coverage.py | 12 ++++++++
 tests/source_facts/test_markdown.py           | 24 +++++++++++++++
 tests/validate/test_fact_coverage.py          | 24 ++++++++++++++-
 11 files changed, 119 insertions(+), 27 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index b81229f..5c7970d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -128,7 +128,7 @@ The CLI commands include source acquisition, quality, assembly, analysis, and Fo
 | `loop_apidoc/extraction/` | shared models + utilities (models, stages, questions, store, jsonblock) used by the agent extraction |
 | `loop_apidoc/plan/` | normalization plan + source-match classification, including typed `transport[]` / `amount_direction[]` / `idempotency[]` / `line_currency_policy[]`; `claim_projection.py` is the pure, shared legacy-plan → material-claim projection used by the v1 gate and shadow bridge |
 | `loop_apidoc/generate/` | OpenAPI / Markdown / `review.html` / provenance / always-written `integration-contract.json` generation (`integration.py` carries source-backed typed domain semantics, operational rules, and integration mechanics; `review.py` builds the offline manual-review page; `handoff.py`'s `build_handoff` emits the derived `handoff/` pack — `integration-tasks.md` / `postman_collection.json` / `sdk-hints.json` — from OpenAPI + plan + integration, duplicating no schema) |
-| `loop_apidoc/validate/` | structure / completeness / consistency / no-speculation checks + report; `coverage.py` also flags supported/readable sources with zero material citations, while `response_contract.py` reports successful path responses with no usable schema fields and computes response-contract metrics. `fact_coverage.py` (pure) makes the semantic completeness gate's no-op visible: `agentcli/fact_coverage.py` computes one per-source projection (endpoint facts scanned, facts matching an extracted endpoint identity) from the `FactIndex` and extraction `assemble`/`verify-extraction` already hold — it lives in `agentcli/` so `validate/` never depends on the extraction gate's vocabulary, and a source with zero facts or zero matches becomes a warning-severity `SOURCE_FACTS_UNSCANNED` issue scored under source grounding. `validate_outputs` takes the projection, never the `FactIndex`; no projection means the check was not evaluated, so `validate_run_dir` never emits it. `verify-extraction` forecasts the same projection without entering `--json` or changing its exit code (ADR 0007) |
+| `loop_apidoc/validate/` | structure / completeness / consistency / no-speculation checks + report; `coverage.py` also flags supported/readable sources with zero material citations, while `response_contract.py` reports successful path responses with no usable schema fields and computes response-contract metrics. `fact_coverage.py` (pure) makes the semantic completeness gate's no-op visible: `agentcli/fact_coverage.py` computes one per-source projection (endpoint facts scanned, facts matching an extracted endpoint identity) from the `FactIndex` and extraction `assemble`/`verify-extraction` already hold — it lives in `agentcli/` so `validate/` never depends on the extraction gate's vocabulary, and a source with zero facts, zero matches, or an unread tail behind a refused closing fence (`unclosed_fence_line`, ADR 0008 — reported even when earlier facts matched, since a half-read source is the more dangerous shape) becomes a warning-severity `SOURCE_FACTS_UNSCANNED` issue scored under source grounding. `validate_outputs` takes the projection, never the `FactIndex`; no projection means the check was not evaluated, so `validate_run_dir` never emits it. `verify-extraction` forecasts the same projection without entering `--json` or changing its exit code (ADR 0007) |
 | `loop_apidoc/run/` | run-id generation, result/status models, and persisting the plan into the run dir |
 | `loop_apidoc/diff/` | run-to-run version diff: `loader.py` (load a completed run-dir's artifacts, `DiffInputError`), `compare.py` (classify changes across `openapi.yaml` / `integration-contract.json` / `provenance.json` / `validation/report.json` / `manifest.json` into `breaking` / `additive` / `changed` / `source_only`), `models.py` (`DiffFinding` / `DiffImpact` / `DiffReport`), `report.py` (render + write `diff/report.{json,md}`) |
 | `loop_apidoc/review/` | local single-user Foundry review workbench behind `review`: `workflow.py` opens/imports a candidate, compares it with the current asset (or a baseline), persists only structured `review/decision.json` handoff, and approves on an explicit human action; `binding.py` fingerprints the reviewed artifacts and rejects stale decisions; `web.py` is a loopback-only, token-protected standard-library UI adapter. It never calls a model or replaces deterministic validation. |
@@ -154,7 +154,7 @@ How the agent responds, by intent:
 - **Regenerate after fix** (`OPENAPI_INVALID`, `OUTPUT_MISMATCH`): invalid OpenAPI/Markdown or an unresolved integration `payload_ref`/`operation_ref` → correct the upstream JSON/reference, re-assemble.
 - **Re-read & fill** (`REQUIRED_INFO_MISSING`, or `SOURCE_UNVERIFIED` from a missing citation): re-read the affected source scope and fill the JSON.
 - **Fail-closed** (`SOURCE_CONFLICT`, `UNSUPPORTED_ASSERTION`, or `SOURCE_UNVERIFIED` surviving re-verification): present the remaining gaps/conflicts — **never fabricate**.
-- **Change the preprocessing path** (`SOURCE_FACTS_UNSCANNED`): the semantic completeness gate never judged that source — it scanned zero endpoint facts, or its facts matched no extracted endpoint. Read the source before acting: a flattened dump or an unconverted PDF/Word file needs acquisition/preprocessing re-run along a table-preserving path (`normalize-html-snapshot`, `preprocess`) — re-reading it fixes nothing; a source whose structure is intact but whose endpoints are not written as `METHOD /path` (a bare URL with the method in prose, or a null-path webhook) keeps the warning permanently — the scan never infers a missing method (ADR 0007) — and is not an extraction defect; and for the zero-match shape, check whether the extraction missed the endpoints that source documents. Warning severity, never blocking; a prose-only source with no parameter tables legitimately lands here (ADR 0007).
+- **Change the preprocessing path** (`SOURCE_FACTS_UNSCANNED`): the semantic completeness gate never judged that source — it scanned zero endpoint facts, its facts matched no extracted endpoint, or its tail went unread behind a fence whose closing line was refused. Read the source before acting: a flattened dump or an unconverted PDF/Word file needs acquisition/preprocessing re-run along a table-preserving path (`normalize-html-snapshot`, `preprocess`) — re-reading it fixes nothing; a source whose structure is intact but whose endpoints are not written as `METHOD /path` (a bare URL with the method in prose, or a null-path webhook) keeps the warning permanently — the scan never infers a missing method (ADR 0007) — and is not an extraction defect; for the zero-match shape, check whether the extraction missed the endpoints that source documents; and when the issue names a line number the cause is already known — fix the fence in the source, never the extraction. Warning severity, never blocking; a prose-only source with no parameter tables legitimately lands here (ADR 0007).
 
 Per-code severity and the structured-routing fields (`target_file`/`field_path`/`requery_scope`) are documented in `skills/loop-apidoc/reference/assemble-and-correction.md`. The skill's other reference docs live alongside it in `skills/loop-apidoc/reference/`: `extraction-schemas.md`, `focus-directives.md`, `model-orchestration.md`, `source-quality.md`, and `url-fetching.md`.
 
diff --git a/docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md b/docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md
index 807f249..de98c7b 100644
--- a/docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md
+++ b/docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md
@@ -22,11 +22,22 @@ one close, putting the sample *between* them outside any fence. Its contents the
 facts. A fabricated fact blocks a correct extraction under the fail-closed completeness gate,
 which is the harm this project consistently refuses to risk (ADR 0007).
 
-What changes is that the failure is no longer silent. `SourceFacts` records the line where a fence
-opened and was never closed, the coverage projection carries that line, and the
-`SOURCE_FACTS_UNSCANNED` warning (ADR 0007) names it: the operator is told which line to open
-instead of being handed three possible causes to choose between. `verify-extraction` forecasts the
-same line before a run directory exists.
+What changes is that the failure is no longer silent. `SourceFacts` records the line where such a
+fence opened, the coverage projection carries that line, and the `SOURCE_FACTS_UNSCANNED` warning
+(ADR 0007) names it: the operator is told which line to open instead of being handed three possible
+causes to choose between. `verify-extraction` forecasts the same line before a run directory exists.
+
+The record is made only when the scan saw a line that *looks* like a close and was refused — an
+info string on the closing fence, or a marker that does not match the opening one. A fence that
+simply runs to the end of the document without any such line is not reported: CommonMark closes an
+unterminated fence at end of input, so every reader agrees with the scanner and nothing was lost by
+the strict rule. Reporting it anyway would send an operator to fix a source that is not broken, and
+the fact count after the "fix" would be unchanged.
+
+The warning also fires when the source is only *partly* unread — facts before the fence matched the
+extraction while everything after it went unread. That case is more dangerous than a wholly
+unscanned source, not less: the gate ran, found nothing wrong with the part it could see, and the
+report looks clean.
 
 ## Considered options
 
@@ -61,7 +72,7 @@ No benchmark source currently trips this: a scan across all thirteen cases found
 info-string closes and zero documents ending inside a fence. This decision is therefore about a
 failure mode that is cheap to disclose and expensive to misread, not about a fire being put out.
 
-**Falsified if:** an unclosed fence stops being reported, or the scan starts guessing fences shut.
-Concretely, this decision no longer holds when `loop_apidoc/source_facts/markdown.py` treats a line
-carrying an info string as a closing fence, or when `loop_apidoc/validate/fact_coverage.py` stops
-naming the unclosed fence's line.
+**Falsified if:** a refused closing fence stops being reported, or the scan starts guessing fences
+shut. Concretely, this decision no longer holds when `loop_apidoc/source_facts/markdown.py` treats a
+line carrying an info string as a closing fence, or when `loop_apidoc/validate/fact_coverage.py`
+stops naming the line where the unclosed fence opened.
diff --git a/docs/operator-manual.en.html b/docs/operator-manual.en.html
index 797c4b9..0843f02 100644
--- a/docs/operator-manual.en.html
+++ b/docs/operator-manual.en.html
@@ -260,7 +260,8 @@ 

verify-extraction — check that the ext

The source-fact gate mechanically scans the manifest's Markdown sources for endpoint declarations, parameter tables and fenced example blocks, then matches them to the extraction by (METHOD, path). When a matched source section documents fields or examples that the extraction dropped, the run fails closed — a silent omission is not the same as "the source does not say so." Naming the field in missing satisfies the gate, so it only ever forces a source-grounded gap, never an invention. Field names are resolved through schema_ref into inventory.schemas transitively, so factoring a shared request body out into a common type counts as deduplication, not an omission.

A companion check rejects placeholder answers that defer the work, and it does so in two layers to avoid false positives. Phrases that explicitly name the extraction itself ("further extraction", "not yet extracted", 「需進一步擷取」) count anywhere in a value, since a real API description never discusses its own extraction. Generic placeholders ("TBD", "to be determined", 「待補」) count only when they are the entire field, because "amount to be determined at capture" is legitimate API prose. ASCII phrases match on word boundaries — CJK has none, so those stay substring matches. Without this check a run could finish as passed with empty artifacts.

Know the scope limit. The scan only recognises well-structured Markdown: headings, GFM tables with a separator row, and fenced code blocks. A source flattened into long single lines — an HTML-to-text dump, for instance — yields zero facts, and the gate is a no-op on it. So a clean gate exit is not by itself evidence of a complete extraction; it only proves nothing contradicted the facts that could be mechanically read. On unstructured sources, keep relying on review.html and human review.

-

That limit is no longer silent. Every assemble records, per manifest source, how many facts were scanned and how many of them matched an extracted endpoint identity, and reports both failure shapes as warning-severity SOURCE_FACTS_UNSCANNED validation issues: zero facts (no endpoint facts were scanned from that source — open it first: content flattened into single lines, or an unconverted PDF/Word file, calls for re-running preprocessing along a table-preserving path such as normalize-html-snapshot or preprocess, and re-reading it achieves nothing; a structurally sound source whose endpoints are not written as METHOD /path — a bare URL with the method stated in prose, or a null-path webhook — will keep the warning permanently, because the scanner does not infer a missing method (ADR 0007), and the extraction may be entirely correct; a prose-only source legitimately lands here). When the message names a line number the cause is already settled: a fence opened there and never closed — usually a closing fence carrying an info string, such as ending with ```json — so everything after it went unread. Fix the source and re-extract; the reasoning is recorded in docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md and zero matches (facts were scanned but none matched the extraction by METHOD /path — check whether the extraction missed the endpoints that source documents). The severity is always warning and never blocks a run: a legitimate prose-only source with no parameter tables lands in the zero-fact class, and failing it would read "could not be measured" as "is wrong". It does count against the documentation-quality score under source grounding, so two runs differ in score when one had more sources the gate never judged. verify-extraction forecasts the same thing on stderr, before you pay for plan→generate; the forecast stays out of --json and never changes the exit code. The reasoning is recorded in docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md.

+

When the message names a line, the cause is already settled. A fence opened there and a later line that looks like its close was refused — most often a closing fence carrying an info string, such as one ending with ```json, which CommonMark reads as opening a new fence rather than closing the old one — so everything after that line went unread. It is reported even when facts before the fence matched the extraction: a source that goes unread halfway through is more dangerous than one never read at all, because the gate appears to have worked. Fix the source and re-extract; the extraction itself needs no change. A fence that simply runs to the end of the document with no refused close is not reported — CommonMark closes an unterminated fence at end of input, so nothing was lost. The reasoning is recorded in docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md.

+

That limit is no longer silent. Every assemble records, per manifest source, how many facts were scanned and how many of them matched an extracted endpoint identity, and reports both failure shapes as warning-severity SOURCE_FACTS_UNSCANNED validation issues: zero facts (no endpoint facts were scanned from that source — open it first: content flattened into single lines, or an unconverted PDF/Word file, calls for re-running preprocessing along a table-preserving path such as normalize-html-snapshot or preprocess, and re-reading it achieves nothing; a structurally sound source whose endpoints are not written as METHOD /path — a bare URL with the method stated in prose, or a null-path webhook — will keep the warning permanently, because the scanner does not infer a missing method (ADR 0007), and the extraction may be entirely correct; a prose-only source legitimately lands here) and zero matches (facts were scanned but none matched the extraction by METHOD /path — check whether the extraction missed the endpoints that source documents). The severity is always warning and never blocks a run: a legitimate prose-only source with no parameter tables lands in the zero-fact class, and failing it would read "could not be measured" as "is wrong". It does count against the documentation-quality score under source grounding, so two runs differ in score when one had more sources the gate never judged. verify-extraction forecasts the same thing on stderr, before you pay for plan→generate; the forecast stays out of --json and never changes the exit code. The reasoning is recorded in docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md.

--focus — task-specific extraction focus directives

uv run loop-apidoc verify-extraction --sources ./sources --extraction ./work --focus ./focus.json
 uv run loop-apidoc assemble --sources ./sources --extraction ./work --output ./output \
diff --git a/docs/operator-manual.html b/docs/operator-manual.html
index 47420b8..e3220a6 100644
--- a/docs/operator-manual.html
+++ b/docs/operator-manual.html
@@ -259,7 +259,8 @@ 

verify-extraction — 檢查擷取 JSON

來源事實閘會機械掃描 manifest 中的 Markdown 來源,取出端點宣告、參數表與圍籬範例區塊,再以 (METHOD, path) 與擷取結果對照。一旦對上的來源小節寫了欄位或範例、擷取卻交回空的,就 fail closed——靜默遺漏不等於「來源沒寫」。要主張來源沒寫,在 missing 裡具名該欄位即可通過,所以這道閘只會逼出有據可查的缺口,不會逼出捏造。欄位名會沿 schema_ref 遞迴解析進 inventory.schemas,因此把共用 request body 抽成共用型別算去重複,不算遺漏。

另一道檢查攔下佔位式延後答案,並分兩層以避免誤判。明確指涉「擷取這件事」的說法(further extractionnot yet extracted、「需進一步擷取」)出現在值的任何位置都算,因為真實 API 描述不會提到自己的擷取流程;泛用佔位字(TBDto be determined、「待補」)則只有在整個欄位就只有這句時才算,因為「amount to be determined at capture」是合法的 API 描述。英文片語以詞界比對,CJK 沒有詞界可言,維持子字串比對。少了這道檢查,run 會以 passed 收場而產物是空的。

請注意適用範圍。這道掃描只認得結構良好的 Markdown:標題、含分隔列的 GFM 表格、圍籬程式碼區塊。若來源被壓成一行行超長文字(例如 HTML 轉純文字的傾印檔),掃描結果為零筆事實,這道閘對它就完全沒有作用。因此閘門乾淨通過本身並不等於擷取完整,它只證明「機械讀得到的事實」沒有被違反。面對非結構化來源,仍要靠 review.html 與人工核對。

-

這個限制不再是靜默的。每次 assemble 都會逐份 manifest 來源記下「掃出幾筆事實、其中幾筆對得上擷取的端點識別」,並把兩種失能寫成 warning 級的 SOURCE_FACTS_UNSCANNED 驗證問題:零事實(這份來源掃不出任何端點事實。先看它屬於哪一種:內容被壓平成單行、或未轉換的 PDF/Word,補救方向是改走保留表格結構的前處理路徑,例如 normalize-html-snapshotpreprocess,重讀來源沒有用;結構完好但端點沒寫成 METHOD /path(只給完整 URL、method 寫在散文裡,或本來就是 path 為 null 的 webhook),掃描器不會去推測缺少的 method——那是 ADR 0007 拒絕的推論,因此這筆警告會長期存在,擷取本身可能完全正確;純散文來源則本來就會落在這裡)。訊息若點名了行號,成因就已經確定:那一行開啟的圍籬直到檔尾都沒關閉(常見於關閉行帶了 info string,例如以 ```json 結尾),其後的內容全部沒被讀到——修好來源再重新擷取即可,理由記在 docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md零匹配(掃出了事實,但沒有一筆能以 METHOD /path 對上擷取,補救方向是檢查擷取是否漏了這份來源記載的端點)。severity 恆為 warning、不阻擋 run——純散文、本來就沒有參數表的合法來源會落在零事實這一類,擋下它等於把「量不到」誤判成「錯」——但會計入文件品質分數的 source grounding 類別,讓兩次 run 的分差能表達「這次有更多來源沒被檢查」。verify-extraction 會在 stderr 預告同一件事,讓你在付出 plan→generate 成本之前就能改用別的前處理指令;預告不進 --json、不改退出碼。理由記在 docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md

+

訊息點名行號時,成因已經確定。那一行開啟的圍籬其後出現過一個「長得像關閉、卻不算關閉」的行(最常見的是關閉行帶了 info string,例如以 ```json 結尾——依 CommonMark 那不算關閉,而是又開了一個新圍籬),因此該行之後的內容完全沒有被讀到。即使前半有事實對上 extraction 也照樣回報:讀到一半才失效比全篇未讀更危險,因為閘門看起來運作正常。修好來源再重新擷取即可,不必動 extraction。圍籬單純沒有收尾、其後沒有疑似關閉行的文件不在此列——CommonMark 在檔尾關閉未終止的圍籬,那種文件沒有任何內容因此遺失。理由記在 docs/adr/0008-an-unclosed-fence-is-reported-not-guessed-shut.md

+

這個限制不再是靜默的。每次 assemble 都會逐份 manifest 來源記下「掃出幾筆事實、其中幾筆對得上擷取的端點識別」,並把兩種失能寫成 warning 級的 SOURCE_FACTS_UNSCANNED 驗證問題:零事實(這份來源掃不出任何端點事實。先看它屬於哪一種:內容被壓平成單行、或未轉換的 PDF/Word,補救方向是改走保留表格結構的前處理路徑,例如 normalize-html-snapshotpreprocess,重讀來源沒有用;結構完好但端點沒寫成 METHOD /path(只給完整 URL、method 寫在散文裡,或本來就是 path 為 null 的 webhook),掃描器不會去推測缺少的 method——那是 ADR 0007 拒絕的推論,因此這筆警告會長期存在,擷取本身可能完全正確;純散文來源則本來就會落在這裡)與零匹配(掃出了事實,但沒有一筆能以 METHOD /path 對上擷取,補救方向是檢查擷取是否漏了這份來源記載的端點)。severity 恆為 warning、不阻擋 run——純散文、本來就沒有參數表的合法來源會落在零事實這一類,擋下它等於把「量不到」誤判成「錯」——但會計入文件品質分數的 source grounding 類別,讓兩次 run 的分差能表達「這次有更多來源沒被檢查」。verify-extraction 會在 stderr 預告同一件事,讓你在付出 plan→generate 成本之前就能改用別的前處理指令;預告不進 --json、不改退出碼。理由記在 docs/adr/0007-source-fact-scanning-stays-limited-to-well-structured-markdown.md

--focus — 依任務對擷取下重點指令

uv run loop-apidoc verify-extraction --sources ./sources --extraction ./work --focus ./focus.json
 uv run loop-apidoc assemble --sources ./sources --extraction ./work --output ./output \
diff --git a/loop_apidoc/agentcli/verify.py b/loop_apidoc/agentcli/verify.py
index db462d2..65e5220 100644
--- a/loop_apidoc/agentcli/verify.py
+++ b/loop_apidoc/agentcli/verify.py
@@ -110,7 +110,7 @@ def _forecast(coverage) -> list[str]:
     """把投影寫成人可讀的一行一份來源。"""
     lines: list[str] = []
     for source, entry in unscanned_sources(coverage):
-        if entry.facts == 0 and entry.unclosed_fence_line is not None:
+        if entry.unclosed_fence_line is not None:
             lines.append(
                 f"{source}:第 {entry.unclosed_fence_line} 行的圍籬未關閉,"
                 "其後的內容全部沒被讀到"
diff --git a/loop_apidoc/source_facts/markdown.py b/loop_apidoc/source_facts/markdown.py
index 228c76c..5fefe19 100644
--- a/loop_apidoc/source_facts/markdown.py
+++ b/loop_apidoc/source_facts/markdown.py
@@ -90,6 +90,8 @@ def scan_markdown(relative_path: str, text: str) -> SourceFacts:
                 fence.group("marker"), fence.group("info")
             ):
                 state.close_fence()
+            elif fence:
+                state.reject_close()
             continue
         if fence:
             state.flush_table()
@@ -137,7 +139,7 @@ def scan_markdown(relative_path: str, text: str) -> SourceFacts:
         error_codes=state.error_codes,
         # 掃完仍在圍籬內 ⇒ 這份文件從那一行起沒有被讀過。判定維持嚴格(見 ADR 0008),
         # 但失效不再是靜默的。
-        unclosed_fence_line=state.fence_line or None,
+        unclosed_fence_line=state.fence_line if state.rejected_close else None,
     )
 
 
@@ -155,7 +157,9 @@ def __init__(self, relative_path: str) -> None:
         self.declaring_level = 0
         self.fence_marker: str | None = None
         self.fence_length = 0
-        self.fence_line = 0
+        self.fence_line: int | None = None
+        # 目前這道圍籬內,是否出現過「長得像關閉、卻不算關閉」的行。
+        self.rejected_close = False
         self.table: list[tuple[int, str]] = []
         self.previous = ""
         # 錯誤碼表不依附端點,所以索引與累積都在來源層級。
@@ -181,6 +185,7 @@ def open_fence(
         self.fence_marker = marker[0]
         self.fence_length = len(marker)
         self.fence_line = index
+        self.rejected_close = False
         self.previous = ""
 
     def closes_fence(self, marker: str, info: str) -> bool:
@@ -190,10 +195,14 @@ def closes_fence(self, marker: str, info: str) -> bool:
             and len(marker) >= self.fence_length
         )
 
+    def reject_close(self) -> None:
+        self.rejected_close = True
+
     def close_fence(self) -> None:
         self.fence_marker = None
         self.fence_length = 0
-        self.fence_line = 0
+        self.fence_line = None
+        self.rejected_close = False
         self.previous = ""
 
     def flush_table(self) -> None:
diff --git a/loop_apidoc/source_facts/models.py b/loop_apidoc/source_facts/models.py
index d0952c1..32c4a56 100644
--- a/loop_apidoc/source_facts/models.py
+++ b/loop_apidoc/source_facts/models.py
@@ -80,9 +80,11 @@ class SourceFacts(BaseModel):
     endpoints: list[EndpointFact] = Field(default_factory=list)
     #: 這份來源以表格結構明確記載的錯誤碼,依出現順序。
     error_codes: list[ErrorCodeFact] = Field(default_factory=list)
-    #: 掃到檔尾仍未關閉的那個圍籬開啟在第幾行(沒有就是 None)。
-    #: 從該行起整份文件都被當成在圍籬內,掃出零筆且毫無錯誤——這個欄位存在的
-    #: 唯一目的,是讓那次靜默失效在報告裡有具名的成因。
+    #: 開在第幾行、其後出現過「長得像關閉、卻不算關閉」的行、且掃到檔尾仍未關閉
+    #: 的那道圍籬(沒有就是 None)。從該行起整份文件都被當成在圍籬內,掃出零筆且
+    #: 毫無錯誤——這個欄位存在的唯一目的,是讓那次靜默失效在報告裡有具名的成因。
+    #: 圍籬單純沒有收尾(其後沒有任何疑似關閉行)不算:CommonMark 在檔尾關閉未
+    #: 終止的圍籬,那份文件在每個讀者眼中都一樣,沒有任何內容因為我們的判定而遺失。
     unclosed_fence_line: int | None = None
 
 
diff --git a/loop_apidoc/validate/fact_coverage.py b/loop_apidoc/validate/fact_coverage.py
index d16d768..8850846 100644
--- a/loop_apidoc/validate/fact_coverage.py
+++ b/loop_apidoc/validate/fact_coverage.py
@@ -40,7 +40,11 @@ class FactCoverage(BaseModel):
 def unscanned_sources(
     coverage: dict[str, FactCoverage] | None,
 ) -> list[tuple[str, FactCoverage]]:
-    """閘門對之無作用的來源(依識別碼排序);有匹配的來源不列入。
+    """閘門沒有完整判過的來源(依識別碼排序)。
+
+    兩種:一筆事實都對不上(閘門對它完全無作用),或掃描在某一行之後就停了
+    (未關閉的圍籬)。後者即使前半有事實對上也必須列入——讀到一半才失效比全篇
+    未讀更危險,因為閘門看起來運作正常,而報告是乾淨的。
 
     公開的原因與 `omitted_error_codes` 相同:`verify-extraction` 的預告與
     `assemble` 的驗證警告必須算出同一件事,兩邊各寫一份就會漂移。
@@ -53,7 +57,7 @@ def unscanned_sources(
         return []
     return [
         (source, entry) for source, entry in sorted(coverage.items())
-        if entry.matched == 0
+        if entry.matched == 0 or entry.unclosed_fence_line is not None
     ]
 
 
@@ -65,11 +69,17 @@ def check_fact_coverage(
 
 
 def _issue(source: str, entry: FactCoverage) -> Issue:
-    if entry.facts == 0 and entry.unclosed_fence_line is not None:
+    # 成因已知時它壓過其他措辭:掃描器知道是圍籬,就不該叫 operator 去查一個
+    # 並不存在的 extraction 缺陷。
+    if entry.unclosed_fence_line is not None:
+        scanned = (
+            "掃出 0 筆端點事實" if entry.facts == 0
+            else f"圍籬之前掃出 {entry.facts} 筆端點事實"
+        )
         evidence = (
-            f"這份來源掃出 0 筆端點事實:第 {entry.unclosed_fence_line} 行開啟的圍籬"
-            "直到檔尾都沒有被關閉,從該行起整份文件都被當成程式碼區塊,"
-            "語意完整性閘門對它完全沒有作用"
+            f"第 {entry.unclosed_fence_line} 行開啟的圍籬直到檔尾都沒有被關閉,"
+            f"該行之後的內容全部沒有被讀到({scanned});"
+            "語意完整性閘門沒有完整判過這份來源"
         )
         fix = (
             f"打開來源第 {entry.unclosed_fence_line} 行,補上關閉圍籬。"
diff --git a/tests/agentcli/test_assemble_fact_coverage.py b/tests/agentcli/test_assemble_fact_coverage.py
index 83d93c4..93874e5 100644
--- a/tests/agentcli/test_assemble_fact_coverage.py
+++ b/tests/agentcli/test_assemble_fact_coverage.py
@@ -53,3 +53,15 @@ def test_a_source_whose_facts_match_the_extraction_is_not_reported(tmp_path):
     res = assemble(tmp_path, sources, extraction, None, "--json")
 
     assert _issues(res, "SOURCE_FACTS_UNSCANNED") == []
+
+
+def test_a_source_with_no_scanned_facts_at_all_still_gets_a_projection_entry(tmp_path):
+    """manifest 有、掃描結果沒有的來源(非 Markdown)不得從投影裡消失。
+
+    投影少一筆,報告就少一份警告,而那份來源正是最可能沒被判過的那種。
+    """
+    sources, extraction, _ = setup(tmp_path, extra_sources={"page.html": ""})
+
+    res = assemble(tmp_path, sources, extraction, None, "--json")
+
+    assert [i["location"] for i in _issues(res, "SOURCE_FACTS_UNSCANNED")] == ["page.html"]
diff --git a/tests/source_facts/test_markdown.py b/tests/source_facts/test_markdown.py
index ed39637..cf78a96 100644
--- a/tests/source_facts/test_markdown.py
+++ b/tests/source_facts/test_markdown.py
@@ -645,3 +645,27 @@ def test_a_document_whose_fences_all_close_records_nothing() -> None:
 
     assert facts.unclosed_fence_line is None
     assert facts.endpoints[0].parameter_names == ["id"]
+
+
+def test_a_fence_left_open_on_the_last_structural_line_loses_nothing() -> None:
+    """CommonMark 在檔尾關閉未終止的圍籬,所以這種文件渲染正常、也沒有內容被吃掉。
+
+    照樣回報會叫 operator 去修一個不存在的缺陷,而「修好」之後事實數仍然是零。
+    只在圍籬之後真的還有內容時才主張有東西沒被讀到。
+    """
+    text = "`GET /a`\n\n```json\n{\"a\": 1}\n"
+
+    assert scan_markdown("doc.md", text).unclosed_fence_line is None
+
+
+def test_a_tilde_fence_left_open_is_reported_like_a_backtick_one() -> None:
+    text = "~~~json\n{}\n~~~json\n\n## GET /b\n\n`GET /b`\n"
+
+    assert scan_markdown("doc.md", text).unclosed_fence_line == 1
+
+
+def test_only_the_fence_that_stayed_open_is_reported() -> None:
+    """前面關好的圍籬不得留下殘影。"""
+    text = "```json\n{}\n```\n\n```json\n{}\n```json\n\n`GET /b`\n"
+
+    assert scan_markdown("doc.md", text).unclosed_fence_line == 5
diff --git a/tests/validate/test_fact_coverage.py b/tests/validate/test_fact_coverage.py
index d0c1327..b5f2b8b 100644
--- a/tests/validate/test_fact_coverage.py
+++ b/tests/validate/test_fact_coverage.py
@@ -68,7 +68,7 @@ def test_an_unclosed_fence_is_named_as_the_cause():
     issue = _unscanned(report)[0]
 
     assert "42" in issue.evidence
-    assert "42" in issue.suggested_fix or "圍籬" in issue.suggested_fix
+    assert "42" in issue.suggested_fix
 
 
 def test_a_zero_fact_source_without_a_known_cause_still_enumerates_them():
@@ -76,3 +76,25 @@ def test_a_zero_fact_source_without_a_known_cause_still_enumerates_them():
 
     assert "42" not in issue.evidence
     assert "normalize-html-snapshot" in issue.suggested_fix
+
+
+def test_a_partially_unread_source_is_reported_even_though_facts_matched():
+    """讀到一半才失效才是最危險的:閘門判了前半,報告乾淨,後半根本沒被讀過。
+
+    以事實數/匹配數為唯一判準時這種來源完全不會浮現——比全篇未讀更危險,
+    因為閘門看起來運作正常。
+    """
+    coverage = {"api.md": FactCoverage(facts=3, matched=2, unclosed_fence_line=7)}
+    issue = _unscanned(_report(coverage))[0]
+
+    assert "7" in issue.evidence
+    assert "extraction" not in issue.suggested_fix
+
+
+def test_the_known_cause_wins_over_the_zero_match_wording():
+    """成因已知時不得再叫人去查 extraction 漏了什麼——那個缺陷並不存在。"""
+    coverage = {"api.md": FactCoverage(facts=4, matched=0, unclosed_fence_line=9)}
+    issue = _unscanned(_report(coverage))[0]
+
+    assert "9" in issue.evidence
+    assert "檢查 extraction" not in issue.suggested_fix