@dzhechkov/p-replicator 1.12.0 → 1.13.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.dz-manifest.json +281 -65
  2. package/CHANGELOG.md +148 -1
  3. package/LICENSE +21 -0
  4. package/MULTIPLATFORM_ROADMAP.md +1 -1
  5. package/README/eng/01_quickstart.md +2 -2
  6. package/README/eng/02_user_guide.md +1 -1
  7. package/README/eng/03_admin_guide.md +2 -2
  8. package/README/eng/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +2 -2
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/05_architecture.md +1 -1
  14. package/README/ru/README.md +2 -1
  15. package/README/ru/html/index.html +8 -8
  16. package/README.md +150 -9
  17. package/bin/cli.js +0 -0
  18. package/package.json +10 -11
  19. package/sbom.json +604 -64
  20. package/scripts/check-pipeline-gaps.sh +510 -21
  21. package/src/commands/init.js +1 -1
  22. package/src/rule-components.json +5 -1
  23. package/src/utils.js +33 -3
  24. package/templates/.claude/agents/product-discoverer.md +38 -0
  25. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  26. package/templates/.claude/commands/feature.md +72 -6
  27. package/templates/.claude/commands/go.md +6 -8
  28. package/templates/.claude/commands/harvest.md +5 -7
  29. package/templates/.claude/commands/replicate.md +169 -44
  30. package/templates/.claude/commands/start.md +28 -7
  31. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  32. package/templates/.claude/hooks/check-canon.cjs +493 -0
  33. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  34. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  35. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  36. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  37. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  38. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  39. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  40. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  41. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  42. package/templates/.claude/hooks/check-ports.cjs +27 -6
  43. package/templates/.claude/hooks/check-review-contract.cjs +205 -0
  44. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  45. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  46. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  47. package/templates/.claude/hooks/statusline.cjs +2 -2
  48. package/templates/.claude/rules/embeddable-widget.md +73 -0
  49. package/templates/.claude/rules/feature-lifecycle.md +5 -6
  50. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  51. package/templates/.claude/rules/long-running-job.md +73 -0
  52. package/templates/.claude/rules/model-call-cost.md +85 -0
  53. package/templates/.claude/rules/replicate-pipeline.md +122 -52
  54. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  55. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  56. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  57. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  58. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  59. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  60. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  61. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  62. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  63. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  64. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  65. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  66. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  67. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  68. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  69. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  70. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  71. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  72. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  73. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  74. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  75. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  76. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  77. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  78. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  79. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  80. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  81. package/templates/.claude/skills/requirements-validator/SKILL.md +18 -5
  82. package/templates/.claude/skills/requirements-validator/references/feature-report-contracts.md +67 -0
  83. package/templates/.claude/skills/requirements-validator/references/scoring-system.md +15 -6
  84. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  85. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +173 -725
  86. package/tests/e2e/feature-contour.test.js +176 -0
  87. package/tests/fixtures/feature-contour/docs/features/demo-gate/01_specification.md +15 -0
  88. package/tests/fixtures/feature-contour/docs/features/demo-gate/02_pseudocode.md +19 -0
  89. package/tests/fixtures/feature-contour/docs/features/demo-gate/03_architecture.md +3 -0
  90. package/tests/fixtures/feature-contour/docs/features/demo-gate/04_refinement.md +3 -0
  91. package/tests/fixtures/feature-contour/docs/features/demo-gate/05_completion.md +7 -0
  92. package/tests/fixtures/feature-contour/docs/features/demo-gate/review-report.md +11 -0
  93. package/tests/fixtures/feature-contour/docs/features/demo-gate/validation-report.md +10 -0
  94. package/tests/fixtures/feature-contour/tests/demo.test.js +16 -0
  95. package/tests/snapshot/baseline.json +64 -40
  96. package/tests/unit/capture-source-path.test.js +492 -0
  97. package/tests/unit/check-canon.test.js +403 -0
  98. package/tests/unit/check-embed-contract.test.js +422 -0
  99. package/tests/unit/check-external-deps.test.js +363 -0
  100. package/tests/unit/check-file-ownership.test.js +388 -0
  101. package/tests/unit/check-handoff-manifest.test.js +410 -0
  102. package/tests/unit/check-job-contract.test.js +514 -0
  103. package/tests/unit/check-look-origin.test.js +180 -0
  104. package/tests/unit/check-look-trace.test.js +420 -0
  105. package/tests/unit/check-metric-source.test.js +325 -0
  106. package/tests/unit/check-model-cost.test.js +425 -0
  107. package/tests/unit/check-ports.test.js +46 -2
  108. package/tests/unit/check-review-contract.test.js +181 -0
  109. package/tests/unit/check-source-version.test.js +344 -0
  110. package/tests/unit/check-swarm-receipts.test.js +231 -0
  111. package/tests/unit/check-webhook-contract.test.js +536 -0
  112. package/tests/unit/db-port-rule.test.js +8 -2
  113. package/tests/unit/detection-ladder-registry.test.js +2 -2
  114. package/tests/unit/generator-swarm-contract.test.js +287 -0
  115. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  116. package/tests/unit/honest-failure-rules.test.js +106 -9
  117. package/tests/unit/look-phase-contract.test.js +231 -0
  118. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  119. package/tests/unit/quote-provenance.test.js +122 -0
  120. package/tests/unit/sync-templates-guard.test.js +46 -3
  121. package/tests/unit/traceability-completion-gate.test.js +267 -0
  122. package/tests/unit/utils.test.js +40 -3
@@ -42,6 +42,17 @@ record of something recited from memory. That combination is legal, expressible,
42
42
  | `ASSERTED` | Stated from model memory, source never opened | Nothing about the source | `0.0` |
43
43
  | *(absent)* | Fact predates this axis | Evidence is **unknown** — neither asserted nor verified | no ceiling of its own |
44
44
 
45
+ For quoted text, the acquisition method is a separate closed vocabulary. The author-facing names
46
+ map exactly to the stored values; only the verifier can emit the verdict after comparing captured bytes.
47
+
48
+ | Stored method | Author-facing name | Verbatim ceiling |
49
+ |---|---|---|
50
+ | `raw-fetch` | `сырая-загрузка` | eligible only after a matching captured excerpt |
51
+ | `tool-summary` | `пересказ-инструментом` | never verbatim, even when the words happen to match |
52
+ | `search-listing` | `из-поисковой-выдачи` | eligible only after reconciliation against a captured excerpt |
53
+ | `manual` | `вручную` | eligible only after reconciliation against a captured excerpt |
54
+ | *(absent/other)* | `method-unknown` | never verbatim |
55
+
45
56
  **`FETCH_VERIFIED` cannot be self-declared.** It is minted only by `create_fetched_fact()`, which
46
57
  requires a `FetchRecord` — the byte hash, HTTP status and date of a request that actually happened.
47
58
  The manual constructors (`create_listing_fact`, `create_asserted_fact`) have no way to produce it.
@@ -54,7 +65,7 @@ Provenance, not truth.
54
65
  ### The report gate
55
66
 
56
67
  ```bash
57
- python3 scripts/check_report_evidence.py --report report.md --facts facts.json [--profile patient.json]
68
+ python3 scripts/check_report_evidence.py --report report.md --facts facts.json [--profile patient.json] [--excerpts evidence_excerpts]
58
69
  ```
59
70
 
60
71
  `ASSERTED` claims must not appear in a report at all. `LISTING_ONLY` claims may appear only with a
@@ -197,12 +208,10 @@ mkdir ~/health-research && cd ~/health-research # medical work lives here
197
208
  Do not run medical investigations inside a shared code repository. If you already have,
198
209
  the prompt log is `.dz/recall-usage.jsonl` and the lessons are in `.health-brain/`.
199
210
 
200
- Choosing the shared store anyway is not blocked it is ADVISED against. `dz teach
201
- --domain health-research` into a shared store prints what follows from the choice and
202
- the command that does it the other way; `dz recall --all --json --include-domain
203
- health-research` hands the data over and says how much of it is medical. Both end with
204
- "Nothing was blocked — this is your call." The data is yours; our job is that the choice
205
- is knowing rather than accidental.
211
+ Choosing the shared store anyway is ADVISED against, never blocked: both `dz teach --domain
212
+ health-research` and `dz recall --all --json --include-domain health-research` print what follows
213
+ from the choice and end with "Nothing was blocked this is your call" — the choice must be
214
+ knowing, not accidental.
206
215
  Step 1 is what makes a lesson safe to keep: a rule about a method has nowhere to put a
207
216
  person. But note what that is and is not — it is a discipline, performed by you and
208
217
  asserted with `--confirm-method`, which nothing verifies. The property that holds
@@ -501,38 +510,44 @@ Invalid signatures are rejected with confidence `0.0`; they are never a recovera
501
510
  4. Extract claims and source URLs.
502
511
  5. **Fetch each source through the tool, not by hand:**
503
512
  ```python
504
- from evidence_fetch import fetch_source, FetchRecord
505
- record = fetch_source(url) # real HTTP, stdlib only
513
+ from evidence_fetch import fetch_source_returning_body, FetchRecord
514
+ from quote_provenance import QuoteRecord, capture_excerpt, write_excerpt
515
+ record, body = fetch_source_returning_body(url) # real HTTP, captured bytes
506
516
  if isinstance(record, FetchRecord):
507
- fact = verifier.create_fetched_fact(claim, record, issuer, source_date="2024-03-01")
517
+ captured = capture_excerpt(body, quoted_text, record)
518
+ write_excerpt("evidence_excerpts", captured)
519
+ quoted = QuoteRecord(quoted_text, "raw-fetch", record.final_url,
520
+ record.sha256_body, locator, captured["excerpt_id"])
521
+ fact = verifier.create_fetched_fact(claim, record, issuer, quote=quoted)
508
522
  else: # offline, 404, oversize, refused scheme…
509
523
  fact = verifier.create_listing_fact(claim, url, reason=record.reason)
510
524
  ```
511
525
  A claim you never opened a source for is `verifier.create_asserted_fact(claim)` — record it
512
526
  honestly and let the gate refuse it. Never hand-label a class you did not earn.
527
+ 5a. **When quoting verbatim, capture at fetch time as shown above.** No captured source bytes means
528
+ no verbatim verdict; `tool-summary` can never earn one even when its words happen to match.
513
529
  6. Add facts to the research ledger; issuer-signed facts only when a pinned issuer key actually
514
530
  signed the message.
515
531
  7. Verify facts and citation chains.
516
532
  8. Cross-check claims through ordinary source evaluation.
517
533
  9. **Run the report gate before delivering:**
518
534
  `python3 scripts/check_report_evidence.py --report <report.md> --facts <facts.json>` — exit 0 required.
535
+ 9a. **A NEGATIVE conclusion ("no competitor has this") MUST carry its own basis:** КОРПУС · ПОЛНОТА ·
536
+ СПОСОБ ПОИСКА · ГРАНИЦА КОРПУСА · СЛЕДСТВИЕ. "We did not find it" is a property of the SEARCH;
537
+ "it is not there" is a property of the WORLD, and only an exhaustive corpus licenses the second.
538
+ Form, closed values and the measured reason: `references/negative-results.md`.
519
539
  10. Report confidence, evidence-class mix, unsigned claims, rejected signatures, and limitations
520
540
  explicitly.
521
541
 
522
542
  ## Output Expectations
523
543
 
524
- Reports should include:
525
-
526
- - Research objective and GOAP plan executed.
527
- - Findings with source URLs.
528
- - Verification status per claim on BOTH axes: trust (`ISSUER_SIGNED` / `SELF_ATTESTED` /
529
- `UNVERIFIED`) and evidence (`FETCH_VERIFIED` / `LISTING_ONLY` / `ASSERTED` / unknown-legacy).
530
- - The evidence-class mix as a count — the share of `ASSERTED` should be zero.
531
- - A visible marker next to every `LISTING_ONLY` claim ("source not opened directly — verify").
532
- - Chain integrity result when citation chains are used.
533
- - Unsigned and rejected claims.
534
- - Explicit caveat that cryptographic provenance is not truth verification, and that
535
- `FETCH_VERIFIED` means the bytes arrived — not that the source is right.
544
+ Reports include: the objective + executed GOAP plan; findings with source URLs; per-claim status on
545
+ BOTH axes — trust (`ISSUER_SIGNED`/`SELF_ATTESTED`/`UNVERIFIED`) and evidence
546
+ (`FETCH_VERIFIED`/`LISTING_ONLY`/`ASSERTED`/unknown-legacy); the evidence-class mix as a count
547
+ (the `ASSERTED` share should be zero); a visible marker on every `LISTING_ONLY` claim ("source not
548
+ opened directly verify"); chain integrity when chains are used; unsigned and rejected claims; the
549
+ explicit caveat that cryptographic provenance is not truth verification — `FETCH_VERIFIED` means
550
+ the bytes arrived, not that the source is right.
536
551
 
537
552
  ## Implementation
538
553
 
@@ -0,0 +1,94 @@
1
+ # Отрицательный вывод: «не нашли» и «доказали, что нет» — разные утверждения
2
+
3
+ Читается по требованию. В `SKILL.md` из этого файла живёт одна строка-указатель; всё остальное здесь,
4
+ потому что всегда-загружаемый корпус пакета исчерпан, а объём этой темы в него не помещается.
5
+
6
+ ## Отказ, ради которого это написано
7
+
8
+ Отчёт исследования умеет делать очень сильное утверждение: **«такой функции нет ни у одного из
9
+ конкурентов; ниша свободна»**. На нём основывают продукт. И ровно оно было НЕВИДИМО для аппарата
10
+ доверия.
11
+
12
+ **ИЗМЕРЕНО 2026-09-01, ТВИН-ТЕСТОМ, а не грепом** (грепу здесь верить нельзя: он находит словарь, а
13
+ не слепоту). `scripts/check_report_evidence.py` шёл ОТ ЛЕДЖЕРА К ОТЧЁТУ — для каждого записанного
14
+ факта искал его употребление в тексте. Два прогона на ОДНОМ леджере, отчёты различаются РОВНО одной
15
+ строкой отрицательного вывода:
16
+
17
+ ```bash
18
+ python3 check_report_evidence.py --report a.md --facts f.json --json > oa.json
19
+ python3 check_report_evidence.py --report b.md --facts f.json --json > ob.json # b.md = a.md + одна строка
20
+ diff oa.json ob.json # ДО починки: пусто, оба exit 0
21
+ ```
22
+
23
+ Вывод гейта был БАЙТ В БАЙТ одинаков. То есть ничем не подкреплённое отрицание не могло ни
24
+ увеличить, ни уменьшить число находок: оно было вне области зрения ПО ПОСТРОЕНИЮ, а не по недосмотру.
25
+ Причина простая и структурная: аппарат оценивал только утверждения, у которых ЕСТЬ источник, а у
26
+ отрицания источника нет — в этом и состоит отрицание.
27
+
28
+ **МЕХАНИЗМ:** «не встретилось» и «доказано, что нет» приходят в отчёте В ОДИНАКОВОЙ ФОРМЕ, и решения
29
+ по ним принимают одинаково. Первое — свойство ПОИСКА. Второе — свойство МИРА. Только второе
30
+ оправдывает продукт, построенный на пустой нише.
31
+
32
+ ## Форма, которую отрицательный вывод обязан нести
33
+
34
+ Пять полей рядом с утверждением (в пределах 900 знаков — блок, а не приложение):
35
+
36
+ | Поле | Что отвечает | Закрытые значения |
37
+ |---|---|---|
38
+ | `КОРПУС` | что именно прочитано, ПЕРЕЧНЕМ | свободный текст, но перечислимый |
39
+ | `ПОЛНОТА` | чем является этот перечень | `перечислимо и объявлено исчерпывающим` · `выборка` · `неизвестна` |
40
+ | `СПОСОБ ПОИСКА` | как искали и какими запросами | свободный текст |
41
+ | `ГРАНИЦА КОРПУСА` | по состоянию на когда | свободный текст |
42
+ | `СЛЕДСТВИЕ` | что из этого следует | `не встретилось` · `намеренно отсутствует` |
43
+
44
+ **Потолок, и он несущий.** `СЛЕДСТВИЕ: намеренно отсутствует` допустимо ТОЛЬКО при
45
+ `ПОЛНОТА: перечислимо и объявлено исчерпывающим`. Выборка и неизвестная полнота поддерживают
46
+ «не встретилось» и ничего сильнее: отсутствие в ЧАСТИ корпуса — отсутствие в ПОИСКЕ, а не в мире.
47
+
48
+ **Почему пяти полей, а не двух.** Первая редакция требовала только `КОРПУС` и `ПОЛНОТА`. Проверяющая
49
+ модель назвала это самым слабым местом заявки и была ПРАВА: **исчерпывающий ПЕРЕЧЕНЬ не доказывает
50
+ исчерпывающего ПОИСКА**. Двенадцать страниц, прочитанных от корки до корки, всё равно пропустят
51
+ функцию, если это были не те двенадцать — или если в запросе ни разу не прозвучало слово, которым
52
+ её называет сам поставщик. Поэтому в потолок входят также способ поиска, покрытие запросов, отказы
53
+ выборки и временная граница корпуса. Без них сильное «нет» выписывает себе разрешение само.
54
+
55
+ ## Пример
56
+
57
+ ```markdown
58
+ Вывод: массового экспорта нет ни у одного из трёх конкурентов.
59
+
60
+ КОРПУС: справочные центры A (14 стр.), B (9 стр.), C (11 стр.) — перечень в приложении A
61
+ ПОЛНОТА: выборка — 34 из ~120 страниц; отказ выборки: раздел для партнёров под входом
62
+ СПОСОБ ПОИСКА: полнотекстовый поиск по 6 запросам: «bulk export», «экспорт», «CSV», «API export», «выгрузка», «download all»
63
+ ГРАНИЦА КОРПУСА: по состоянию на 2026-08-30
64
+ СЛЕДСТВИЕ: не встретилось
65
+ ```
66
+
67
+ `СЛЕДСТВИЕ: намеренно отсутствует` в этом примере было бы отвергнуто гейтом: полнота — выборка.
68
+
69
+ ## Чего проверка НЕ решает, и это граница, а не недоделка
70
+
71
+ **Подходит ли названный корпус ВОПРОСУ.** Второй случай из полевого каталога ровно об этом: вопрос
72
+ «есть ли у продукта X функция Y», функция описана ТОЛЬКО в справочном центре, а корпусом взята карта
73
+ сайта. Карта сайта — идеально перечислимый, идеально исчерпывающий и НЕ ТОТ корпус. Проверка
74
+ заставляет корпус НАЗВАТЬ, чтобы человек мог судить о его пригодности; судить — не её работа, и
75
+ делать вид, что её, значило бы то самое ложное закрытие класса, которое весь гейт и отвергает.
76
+
77
+ ## Ворота
78
+
79
+ Отдельного вызова не нужно: проверка живёт внутри уже существующего гейта и ходит во ВТОРУЮ сторону —
80
+ от отрицательных выводов отчёта к их обоснованию.
81
+
82
+ ```bash
83
+ python3 .claude/skills/goap-research-ed25519/scripts/check_report_evidence.py \
84
+ --report report.md --facts facts.json --json
85
+ ```
86
+
87
+ Виды находок: `NEGATIVE_CONCLUSION_WITHOUT_BASIS` (нет одного из пяти полей — перечисляются, каких) ·
88
+ `NEGATIVE_COMPLETENESS_UNRECOGNISED` · `NEGATIVE_IMPLICATION_UNRECOGNISED` ·
89
+ `NEGATIVE_CLAIM_EXCEEDS_ITS_CEILING` (сильное следствие на выборке).
90
+
91
+ **Область срабатывания сужена намеренно.** Ловятся УНИВЕРСАЛЬНЫЕ отрицания («ни у одного», «none of
92
+ the», «ниша свободна»), а не любое «нет» в тексте: жадный сторож — не более строгий сторож, а
93
+ сторож, который удалят. Новая формулировка отрицания будет пропущена, и здесь это сказано вслух,
94
+ ровно как соседний пояс по относительному риску говорит о себе то же самое.
@@ -11,7 +11,7 @@ evidence axis is lost if "don't include unread claims" stays a sentence in
11
11
  SKILL.md that a tired model skips at 2am.
12
12
 
13
13
  Usage:
14
- python3 check_report_evidence.py --report report.md --facts facts.json [--json]
14
+ python3 check_report_evidence.py --report report.md --facts facts.json [--excerpts DIR] [--json]
15
15
 
16
16
  Exit codes:
17
17
  0 clean — no ASSERTED used, every used LISTING_ONLY carries a marker
@@ -30,11 +30,14 @@ from __future__ import annotations
30
30
 
31
31
  import argparse
32
32
  import json
33
+ import os
33
34
  import re
34
35
  import sys
35
36
  from dataclasses import dataclass
36
37
  from typing import Any, Dict, List, Mapping, Optional, Sequence, Tuple
37
38
 
39
+ import quote_provenance as quote_provenance
40
+
38
41
  EVIDENCE_FETCH_VERIFIED = "FETCH_VERIFIED"
39
42
  EVIDENCE_LISTING_ONLY = "LISTING_ONLY"
40
43
  EVIDENCE_ASSERTED = "ASSERTED"
@@ -56,6 +59,17 @@ LISTING_MARKERS = (
56
59
  # does not warn the reader of a sentence on page 2.
57
60
  MARKER_WINDOW_CHARS = 400
58
61
 
62
+ # Report quotes are paired to a fact through the existing generous claim scanner,
63
+ # but the verdict itself is an exact normalized comparison in quote_provenance.
64
+ # Keeping those two polarities separate is load-bearing: generosity is safe for
65
+ # finding a claim and unsafe for granting "verbatim".
66
+ QUOTE_MIN_SIGNIFICANT_CHARS = 15
67
+ QUOTE_PATTERNS = (
68
+ re.compile(r'«([^»\n]+)»', re.UNICODE),
69
+ re.compile(r'"([^"\n]+)"', re.UNICODE),
70
+ re.compile(r'„([^“\n]+)“', re.UNICODE),
71
+ )
72
+
59
73
  # ---------------------------------------------------------------------------
60
74
  # SLICE C ADDITIONS (ADR-001 §5, ADR-002 §3) — ADDITIVE ONLY.
61
75
  # No existing constant, rule, threshold or exit path above or below is modified:
@@ -370,6 +384,118 @@ def evaluate(report_text: str, facts: Sequence[Dict[str, Any]]) -> Tuple[List[Fi
370
384
  return findings, counts
371
385
 
372
386
 
387
+ def _quoted_spans(report_text: str) -> List[Tuple[str, int, int]]:
388
+ spans: List[Tuple[str, int, int]] = []
389
+ occupied: List[Tuple[int, int]] = []
390
+ for pattern in QUOTE_PATTERNS:
391
+ for match in pattern.finditer(report_text):
392
+ start, end = match.span(1)
393
+ if any(left <= start < right or left < end <= right for left, right in occupied):
394
+ continue
395
+ span = match.group(1).strip()
396
+ significant = len(re.sub(r"[^\w\d]", "", span, flags=re.UNICODE))
397
+ if significant < QUOTE_MIN_SIGNIFICANT_CHARS:
398
+ continue
399
+ occupied.append(match.span(0))
400
+ spans.append((span, start, end))
401
+ return sorted(spans, key=lambda item: item[1])
402
+
403
+
404
+ def _quote_finding_kind(verdict: str) -> str:
405
+ if verdict == "not-in-excerpt":
406
+ return "QUOTE_NOT_IN_SOURCE"
407
+ if verdict == "method-ineligible":
408
+ return "QUOTE_METHOD_INELIGIBLE"
409
+ return "QUOTE_NO_EXCERPT"
410
+
411
+
412
+ def evaluate_quotes(
413
+ report_text: str,
414
+ facts: Sequence[Dict[str, Any]],
415
+ excerpt_dir: str,
416
+ ) -> Tuple[List[Finding], Dict[str, int]]:
417
+ """Grade report quotations only from captured excerpt bytes, never metadata.
418
+
419
+ ``claim_positions`` is deliberately used only to associate report text with a
420
+ fact. Its word-overlap fallback NEVER reaches ``verify_verbatim``: the latter
421
+ performs the exact normalized substring comparison that alone can confirm.
422
+ """
423
+ findings: List[Finding] = []
424
+ counts = {f"quote-{verdict}": 0 for verdict in quote_provenance.VERBATIM_VERDICTS}
425
+ counts.update({f"quote-acquisition-{method}": 0 for method in quote_provenance.ACQUISITION_METHODS})
426
+ counts["quote-acquisition-unknown"] = 0
427
+ counts["quote-total"] = 0
428
+ counts["quote-unchecked"] = 0
429
+ spans = _quoted_spans(report_text)
430
+ seen_pairs = set()
431
+
432
+ for fact_index, fact in enumerate(facts):
433
+ recorded_quote = fact.get("quote")
434
+ if not isinstance(recorded_quote, str) or not recorded_quote.strip():
435
+ continue
436
+ acquisition = quote_provenance.read_acquisition(fact.get("acquisition"))
437
+ claim = str(fact.get("claim", ""))
438
+ positions = claim_positions(report_text, claim) or claim_positions(report_text, recorded_quote)
439
+ normalized_recorded = quote_provenance.normalize_text(recorded_quote)
440
+ candidates = [
441
+ (span, start, end) for span, start, end in spans
442
+ if quote_provenance.normalize_text(span) == normalized_recorded
443
+ and any(abs(start - position) <= MARKER_WINDOW_CHARS for position in positions)
444
+ ]
445
+ for span, start, _ in candidates:
446
+ pair = (fact_index, start)
447
+ if pair in seen_pairs:
448
+ continue
449
+ seen_pairs.add(pair)
450
+ counts["quote-total"] += 1
451
+ method_key = (
452
+ f"quote-acquisition-{acquisition}"
453
+ if acquisition in quote_provenance.ACQUISITION_METHODS
454
+ else "quote-acquisition-unknown"
455
+ )
456
+ counts[method_key] += 1
457
+ locator = fact.get("locator") or "(no locator)"
458
+ source_url = str(fact.get("source_url", ""))
459
+
460
+ try:
461
+ schema_version = int(fact.get("schema_version"))
462
+ except (TypeError, ValueError):
463
+ schema_version = 0
464
+ if schema_version < 4:
465
+ verdict = quote_provenance.METHOD_UNKNOWN if acquisition == quote_provenance.METHOD_UNKNOWN else "no-excerpt"
466
+ counts[f"quote-{verdict}"] += 1
467
+ findings.append(Finding(
468
+ kind="QUOTE_NO_EXCERPT", claim=span, source_url=source_url,
469
+ detail=(f"quote {span!r} at {locator} is carried by schema v{schema_version or 'unknown'}; "
470
+ "quote provenance is not inside its signed message, so verbatim is unverified")))
471
+ continue
472
+
473
+ try:
474
+ excerpt_rec = quote_provenance.load_excerpt(excerpt_dir, fact.get("excerpt_id"))
475
+ except quote_provenance.ExcerptStoreError as exc:
476
+ counts["quote-unchecked"] += 1
477
+ findings.append(Finding(
478
+ kind="QUOTES_UNCHECKED", claim=span, source_url=source_url,
479
+ detail=f"quote {span!r} at {locator} could not be checked: {exc}"))
480
+ continue
481
+
482
+ verdict = quote_provenance.verify_verbatim(span, excerpt_rec, fact)
483
+ counts[f"quote-{verdict}"] += 1
484
+ if verdict == "verbatim-confirmed":
485
+ continue
486
+ reason = {
487
+ "not-in-excerpt": "is absent from the single stored excerpt leaf; joining excerpts is forbidden",
488
+ "no-excerpt": "has no decodable captured excerpt",
489
+ "method-ineligible": f"uses acquisition method {acquisition!r}, whose ceiling forbids verbatim regardless of content",
490
+ "hash-mismatch": "has an excerpt/body/source hash mismatch; author-constructed material grants nothing",
491
+ quote_provenance.METHOD_UNKNOWN: "has an absent or unknown acquisition method; legacy method-unknown never grants verbatim",
492
+ }[verdict]
493
+ findings.append(Finding(
494
+ kind=_quote_finding_kind(verdict), claim=span, source_url=source_url,
495
+ detail=f"quote {span!r} at {locator} {reason} (verdict: {verdict})"))
496
+ return findings, counts
497
+
498
+
373
499
  # ---------------------------------------------------------------------------
374
500
  # SLICE C — population applicability (ADR-001 §5) and the relative-risk belt
375
501
  # (ADR-002 §3). New functions; nothing above is rewritten.
@@ -561,6 +687,209 @@ def evaluate_population(report_text: str, facts: Sequence[Dict[str, Any]],
561
687
  return findings, counts
562
688
 
563
689
 
690
+ # ---------------------------------------------------------------------------
691
+ # NEGATIVE CONCLUSIONS — the SECOND DIRECTION.
692
+ #
693
+ # Everything above this line walks FROM THE LEDGER TO THE REPORT: for each recorded
694
+ # fact, is it used, and is its use honest? That direction is structurally blind to a
695
+ # claim that HAS NO SOURCE — and the strongest claim a research report can make is
696
+ # exactly that shape: "no competitor has this; the niche is free."
697
+ #
698
+ # MEASURED on this package, 2026-09-01, by TWIN TEST rather than by grep (grep is not
699
+ # trustworthy here — it finds the vocabulary, not the blindness): two runs over ONE
700
+ # ledger, the reports differing by EXACTLY one line, that line being an unsupported
701
+ # negative conclusion. The gate's `--json` output was BYTE-IDENTICAL, both exit 0. So
702
+ # an unbacked negation can neither raise nor lower the finding count: it is outside
703
+ # the field of view by construction, not by oversight.
704
+ #
705
+ # THE MECHANISM: "we did not find it" and "we proved it is not there" arrive in the
706
+ # report in the SAME FORM, and decisions are made on both identically. The first is a
707
+ # property of the SEARCH. The second is a property of the WORLD. Only the second
708
+ # justifies building a product on an empty niche.
709
+ #
710
+ # THE CEILING, and why the obvious version of it is not enough. The first design said
711
+ # a negative conclusion needs an enumerable CORPUS declared exhaustive. A reviewing
712
+ # model called that the weakest part of the proposal and was RIGHT: an exhaustive LIST
713
+ # does not prove an exhaustive SEARCH. Twelve pages read end-to-end still miss the
714
+ # feature if the twelve were the wrong twelve, or if the query never used the vendor's
715
+ # word for it. So the ceiling also requires the SEARCH METHOD, the QUERY COVERAGE, the
716
+ # SAMPLING REFUSALS, and the corpus's TIME BOUNDARY. Without those, a strong "no"
717
+ # writes itself a licence.
718
+ #
719
+ # WHAT THIS SCAN CANNOT DECIDE, stated here and printed in the gate's output: whether
720
+ # the named corpus is the RIGHT corpus for the question. The record's own second case
721
+ # is exactly that — "does product X have feature Y", where the feature is documented
722
+ # only in the help centre and the corpus taken was the sitemap. A sitemap is a
723
+ # perfectly enumerable, perfectly exhaustive corpus, and the wrong one. This scan
724
+ # forces the corpus to be NAMED so a person can judge its fitness; judging it stays
725
+ # layer 3, and pretending otherwise would be the same false closure the whole gate
726
+ # exists to refuse.
727
+ # ---------------------------------------------------------------------------
728
+
729
+ # A NEGATIVE UNIVERSAL claim: absence asserted across a whole population of products,
730
+ # vendors or sources. Deliberately narrower than "any negation" — `there is no` alone
731
+ # fires on ordinary prose, and an eager gate is not a stricter gate, it is a deleted
732
+ # one. Every pattern here requires the UNIVERSAL, which is what makes the claim strong.
733
+ NEGATIVE_CLAIM_PATTERNS = (
734
+ r"ни\s+у\s+одного",
735
+ r"ни\s+один\s+из\s+[^.\n]{0,60}?\s+не\b",
736
+ r"ни\s+одного\s+из",
737
+ r"нет\s+ни\s+у\s+кого",
738
+ r"ниша\s+свободна",
739
+ r"никто\s+(?:из\s+\w+\s+)?не\s+(?:предлагает|поддерживает|делает|умеет)",
740
+ r"отсутствует\s+у\s+всех",
741
+ r"no\s+(?:competitor|vendor|provider|product|tool)s?\s+\w*\s*(?:has|have|offers?|supports?|provides?)",
742
+ r"none\s+of\s+the\s+\w+\s+(?:has|have|offer|offers|support|supports|provide|provides)",
743
+ r"nobody\s+(?:offers|supports|provides)",
744
+ r"the\s+niche\s+is\s+(?:free|open|unoccupied)",
745
+ r"we\s+found\s+no\s+\w+\s+(?:that|which)",
746
+ )
747
+
748
+ # The block that licenses such a claim. Both spellings, because a report may be written
749
+ # in either language; the fields must be VISIBLE to a reader, never a hidden attribute.
750
+ NEGATIVE_FIELDS = (
751
+ ("corpus", ("КОРПУС", "CORPUS")),
752
+ ("completeness", ("ПОЛНОТА", "COMPLETENESS")),
753
+ ("method", ("СПОСОБ ПОИСКА", "SEARCH METHOD")),
754
+ ("boundary", ("ГРАНИЦА КОРПУСА", "CORPUS AS OF")),
755
+ ("implication", ("СЛЕДСТВИЕ", "IMPLICATION")),
756
+ )
757
+
758
+ # How far from the claim the licensing block may sit. Wider than MARKER_WINDOW_CHARS
759
+ # because this is a five-line block rather than a parenthetical, and narrower than the
760
+ # document because a basis in the appendix does not reach a sentence on page 2.
761
+ NEGATIVE_WINDOW_CHARS = 900
762
+
763
+ # CLOSED. Three, because collapsing them would erase the only distinction that matters:
764
+ # an enumerated corpus its own author calls complete is a different epistemic object
765
+ # from a sample, and both differ from not knowing which you have.
766
+ COMPLETENESS_VALUES = {
767
+ "перечислимо и объявлено исчерпывающим": "exhaustive",
768
+ "enumerable and declared exhaustive": "exhaustive",
769
+ "выборка": "sample",
770
+ "sample": "sample",
771
+ "неизвестна": "unknown",
772
+ "unknown": "unknown",
773
+ }
774
+
775
+ # CLOSED, and the whole point of the feature is that these two are not interchangeable.
776
+ IMPLICATION_VALUES = {
777
+ "не встретилось": "not-encountered",
778
+ "not encountered": "not-encountered",
779
+ "намеренно отсутствует": "deliberately-absent",
780
+ "deliberately absent": "deliberately-absent",
781
+ }
782
+
783
+
784
+ def _negative_field(window: str, spellings: Sequence[str]) -> Optional[str]:
785
+ """The value of one licensing field inside the window, or None when it is absent.
786
+
787
+ An EMPTY value returns '' and is never collapsed into absent: "КОРПУС:" with
788
+ nothing after it is a different mistake from no КОРПУС line at all, and the two
789
+ need different repairs.
790
+ """
791
+ for spelling in spellings:
792
+ match = re.search(
793
+ r"^[\s>*\-]*(?:\*\*)?" + re.escape(spelling) + r"(?:\*\*)?\s*[::]\s*(.*)$",
794
+ window, re.IGNORECASE | re.MULTILINE | re.UNICODE)
795
+ if match:
796
+ return match.group(1).strip().strip("*`")
797
+ return None
798
+
799
+
800
+ def _closed_value(raw: Optional[str], table: Mapping[str, str]) -> Optional[str]:
801
+ """Map a field value onto a CLOSED vocabulary, matching on the leading phrase so a
802
+ author may add detail after it — `выборка — 12 из ~40 страниц` is a sample that
803
+ says how big, which is better than a bare word and must not be refused for it."""
804
+ if raw is None:
805
+ return None
806
+ lowered = raw.strip().lower()
807
+ for key, value in table.items():
808
+ if lowered == key or lowered.startswith(key + " ") or lowered.startswith(key + ",") \
809
+ or lowered.startswith(key + " —") or lowered.startswith(key + " -") \
810
+ or lowered.startswith(key + ":"):
811
+ return value
812
+ return "unrecognised"
813
+
814
+
815
+ def scan_negative_conclusions(report_text: str) -> List[Finding]:
816
+ """The second direction: from the report's NEGATIVE conclusions back to their basis.
817
+
818
+ A claim of universal absence must carry, within its window, the five fields that
819
+ distinguish a property of the SEARCH from a property of the WORLD — and the strong
820
+ implication (`намеренно отсутствует`) is admissible only on top of an exhaustive
821
+ corpus, because an exhaustive list is not an exhaustive search.
822
+
823
+ HONEST SCOPE: this matches CLAIM SHAPES and FIELD PRESENCE. It cannot tell whether
824
+ the named corpus fits the question — a sitemap is enumerable, exhaustive and the
825
+ wrong place to look for a feature. Naming the corpus is what makes that arguable;
826
+ judging it is not this gate's to do.
827
+ """
828
+ findings: List[Finding] = []
829
+ seen_spans: List[Tuple[int, int]] = []
830
+ for pattern in NEGATIVE_CLAIM_PATTERNS:
831
+ for hit in re.finditer(pattern, report_text, re.IGNORECASE | re.UNICODE):
832
+ # One sentence, one finding: two patterns matching the same claim describe
833
+ # one defect, and reporting it twice would inflate the count the twin test
834
+ # measures.
835
+ if any(start <= hit.start() < end for start, end in seen_spans):
836
+ continue
837
+ line_start = report_text.rfind("\n", 0, hit.start()) + 1
838
+ line_end = report_text.find("\n", hit.end())
839
+ line_end = len(report_text) if line_end < 0 else line_end
840
+ seen_spans.append((line_start, line_end))
841
+ claim = report_text[line_start:line_end].strip()
842
+
843
+ window = report_text[max(0, hit.start() - NEGATIVE_WINDOW_CHARS):
844
+ min(len(report_text), hit.end() + NEGATIVE_WINDOW_CHARS)]
845
+ values = {name: _negative_field(window, spellings) for name, spellings in NEGATIVE_FIELDS}
846
+ missing = [spellings[0] for name, spellings in NEGATIVE_FIELDS
847
+ if values[name] is None or values[name] == ""]
848
+ if missing:
849
+ findings.append(Finding(
850
+ kind="NEGATIVE_CONCLUSION_WITHOUT_BASIS", claim=claim, source_url="",
851
+ detail=("a claim of universal ABSENCE with no basis beside it — missing: {m}. "
852
+ "'we did not find it' and 'we proved it is not there' are written the "
853
+ "same way and decided on identically; the first is a property of the "
854
+ "SEARCH, the second of the WORLD. Required within {w} chars: {req}"
855
+ .format(m=", ".join(missing), w=NEGATIVE_WINDOW_CHARS,
856
+ req=", ".join(s[0] for _, s in NEGATIVE_FIELDS)))))
857
+ continue
858
+
859
+ completeness = _closed_value(values["completeness"], COMPLETENESS_VALUES)
860
+ implication = _closed_value(values["implication"], IMPLICATION_VALUES)
861
+ if completeness == "unrecognised":
862
+ findings.append(Finding(
863
+ kind="NEGATIVE_COMPLETENESS_UNRECOGNISED", claim=claim, source_url="",
864
+ detail=("ПОЛНОТА is {v!r}, which is outside the closed set {allowed}. An open "
865
+ "field here collects a word that sounds thorough and commits to nothing"
866
+ .format(v=values["completeness"],
867
+ allowed=sorted(set(COMPLETENESS_VALUES.values()))))))
868
+ continue
869
+ if implication == "unrecognised":
870
+ findings.append(Finding(
871
+ kind="NEGATIVE_IMPLICATION_UNRECOGNISED", claim=claim, source_url="",
872
+ detail=("СЛЕДСТВИЕ is {v!r}, which is outside the closed set {allowed}. These "
873
+ "two are exactly what must not be interchangeable"
874
+ .format(v=values["implication"],
875
+ allowed=sorted(set(IMPLICATION_VALUES.values()))))))
876
+ continue
877
+
878
+ # THE CEILING, and the correction that made this record worth shipping: the
879
+ # strong reading needs an exhaustive corpus UNDER it. A sample can support
880
+ # "we did not encounter it" and can never support "it is deliberately absent".
881
+ if implication == "deliberately-absent" and completeness != "exhaustive":
882
+ findings.append(Finding(
883
+ kind="NEGATIVE_CLAIM_EXCEEDS_ITS_CEILING", claim=claim, source_url="",
884
+ detail=("СЛЕДСТВИЕ 'намеренно отсутствует' rests on ПОЛНОТА {c!r}. A sample — or "
885
+ "an unknown completeness — supports 'не встретилось' and nothing "
886
+ "stronger: absence in part of a corpus is absence from the SEARCH, not "
887
+ "from the world. Lower the implication, or make the corpus enumerable "
888
+ "and declare it exhaustive"
889
+ .format(c=values["completeness"]))))
890
+ return findings
891
+
892
+
564
893
  def scan_relative_risk(report_text: str) -> List[Finding]:
565
894
  """BELT (D-19): refuse a relative figure with no absolute companion in the window.
566
895
 
@@ -619,6 +948,30 @@ def render_population_and_risk(counts: Dict[str, int], profile_supplied: bool) -
619
948
  return "\n".join(lines)
620
949
 
621
950
 
951
+ def render_quotes(counts: Dict[str, int]) -> str:
952
+ """One compact, auditable line for the independent quote-verdict axis."""
953
+ return (
954
+ " quotes: {total} checked — confirmed {confirmed} / absent {absent} / no-excerpt {missing} / "
955
+ "method-ineligible {ineligible} / hash-mismatch {mismatch} / method-unknown {unknown} / "
956
+ "unchecked {unchecked}; methods raw-fetch {raw} / tool-summary {tool} / search-listing {search} / "
957
+ "manual {manual} / unknown {method_unknown}"
958
+ ).format(
959
+ total=counts.get("quote-total", 0),
960
+ confirmed=counts.get("quote-verbatim-confirmed", 0),
961
+ absent=counts.get("quote-not-in-excerpt", 0),
962
+ missing=counts.get("quote-no-excerpt", 0),
963
+ ineligible=counts.get("quote-method-ineligible", 0),
964
+ mismatch=counts.get("quote-hash-mismatch", 0),
965
+ unknown=counts.get("quote-method-unknown", 0),
966
+ unchecked=counts.get("quote-unchecked", 0),
967
+ raw=counts.get("quote-acquisition-raw-fetch", 0),
968
+ tool=counts.get("quote-acquisition-tool-summary", 0),
969
+ search=counts.get("quote-acquisition-search-listing", 0),
970
+ manual=counts.get("quote-acquisition-manual", 0),
971
+ method_unknown=counts.get("quote-acquisition-unknown", 0),
972
+ )
973
+
974
+
622
975
  def render(findings: Sequence[Finding], counts: Dict[str, int]) -> str:
623
976
  lines: List[str] = []
624
977
  total = sum(counts.values())
@@ -649,7 +1002,9 @@ def render(findings: Sequence[Finding], counts: Dict[str, int]) -> str:
649
1002
  " scope: proves the report does not lean on unread sources AND that the ledger's evidence "
650
1003
  "classes carry intact signatures. It does NOT prove the cited sources support the claims, "
651
1004
  "and it can only judge claims it recognises as used — paraphrase detection is deliberately "
652
- "over-eager, because a missed unread claim is silent while a false alarm is arguable."
1005
+ "over-eager, because a missed unread claim is silent while a false alarm is arguable. "
1006
+ "Negative conclusions are judged in the OTHER direction, from the claim back to its basis; "
1007
+ "that scan proves the basis is STATED, never that the named corpus fits the question."
653
1008
  )
654
1009
  return "\n".join(lines)
655
1010
 
@@ -660,6 +1015,10 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
660
1015
  parser.add_argument("--facts", required=True, help="path to facts.json")
661
1016
  parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
662
1017
  parser.add_argument("--pins", help="JSON file of {issuer: pubkey_b64} so ISSUER_SIGNED facts can be checked")
1018
+ parser.add_argument(
1019
+ "--excerpts",
1020
+ help="captured excerpt directory (default: evidence_excerpts beside the facts ledger)",
1021
+ )
663
1022
  # SLICE C: OPTIONAL on purpose. Making it mandatory would break every documented
664
1023
  # invocation for a check that cannot always apply; letting its absence pass
665
1024
  # silently would be "inconclusive reads as pass". Its absence is PRINTED instead.
@@ -695,13 +1054,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
695
1054
  # SLICE C: three independent judgements merged into the SAME findings list — the
696
1055
  # merge must not lose one, and the existing exit path below is unchanged.
697
1056
  population_findings, population_counts = evaluate_population(report_text, facts, profile)
698
- findings = findings + population_findings + scan_relative_risk(report_text)
1057
+ findings = (findings + population_findings + scan_relative_risk(report_text)
1058
+ + scan_negative_conclusions(report_text))
1059
+ excerpt_dir = args.excerpts or os.path.join(os.path.dirname(os.path.abspath(args.facts)), "evidence_excerpts")
1060
+ quote_findings, quote_counts = evaluate_quotes(report_text, facts, excerpt_dir)
1061
+ findings += quote_findings
699
1062
  if args.json:
700
1063
  print(
701
1064
  json.dumps(
702
1065
  {
703
1066
  "ok": not findings,
704
- "counts": dict(counts, **population_counts),
1067
+ "counts": dict(counts, **population_counts, **quote_counts),
705
1068
  "populationChecked": profile is not None,
706
1069
  "findings": [f.__dict__ for f in findings],
707
1070
  "exitCode": 1 if findings else 0,
@@ -712,6 +1075,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
712
1075
  else:
713
1076
  print(render(findings, counts))
714
1077
  print(render_population_and_risk(population_counts, profile is not None))
1078
+ print(render_quotes(quote_counts))
715
1079
  return 1 if findings else 0
716
1080
 
717
1081