@dzhechkov/p-replicator 1.10.4 → 1.13.1

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 (136) hide show
  1. package/.dz-manifest.json +312 -76
  2. package/CHANGELOG.md +232 -0
  3. package/MULTIPLATFORM_ROADMAP.md +1 -1
  4. package/README/eng/01_quickstart.md +3 -3
  5. package/README/eng/02_user_guide.md +1 -1
  6. package/README/eng/03_admin_guide.md +2 -2
  7. package/README/eng/04_api_reference.md +11 -5
  8. package/README/eng/05_architecture.md +1 -1
  9. package/README/eng/README.md +2 -1
  10. package/README/ru/01_quickstart.md +3 -3
  11. package/README/ru/02_user_guide.md +1 -1
  12. package/README/ru/03_admin_guide.md +2 -2
  13. package/README/ru/04_api_reference.md +11 -5
  14. package/README/ru/05_architecture.md +1 -1
  15. package/README/ru/README.md +2 -1
  16. package/README/ru/html/index.html +9 -9
  17. package/README.md +278 -39
  18. package/package.json +5 -4
  19. package/sbom.json +665 -75
  20. package/scripts/check-pipeline-gaps.sh +413 -0
  21. package/src/commands/doctor.js +94 -4
  22. package/src/commands/init.js +1 -1
  23. package/src/rule-components.json +15 -0
  24. package/src/utils.js +35 -11
  25. package/templates/.claude/agents/harvest-coordinator.md +10 -1
  26. package/templates/.claude/agents/product-discoverer.md +38 -0
  27. package/templates/.claude/agents/replicate-coordinator.md +11 -1
  28. package/templates/.claude/commands/feature.md +81 -9
  29. package/templates/.claude/commands/go.md +9 -0
  30. package/templates/.claude/commands/harvest.md +39 -3
  31. package/templates/.claude/commands/myinsights.md +21 -26
  32. package/templates/.claude/commands/replicate.md +171 -37
  33. package/templates/.claude/commands/start.md +29 -0
  34. package/templates/.claude/hooks/capture-source-path.cjs +795 -0
  35. package/templates/.claude/hooks/check-canon.cjs +493 -0
  36. package/templates/.claude/hooks/check-embed-contract.cjs +374 -0
  37. package/templates/.claude/hooks/check-external-deps.cjs +288 -0
  38. package/templates/.claude/hooks/check-file-ownership.cjs +424 -0
  39. package/templates/.claude/hooks/check-handoff-manifest.cjs +367 -0
  40. package/templates/.claude/hooks/check-job-contract.cjs +501 -0
  41. package/templates/.claude/hooks/check-look-origin.cjs +240 -0
  42. package/templates/.claude/hooks/check-look-trace.cjs +385 -0
  43. package/templates/.claude/hooks/check-metric-source.cjs +296 -0
  44. package/templates/.claude/hooks/check-model-cost.cjs +470 -0
  45. package/templates/.claude/hooks/check-ports.cjs +434 -24
  46. package/templates/.claude/hooks/check-source-version.cjs +312 -0
  47. package/templates/.claude/hooks/check-swarm-receipts.cjs +197 -0
  48. package/templates/.claude/hooks/check-webhook-contract.cjs +535 -0
  49. package/templates/.claude/hooks/session-insights.cjs +158 -25
  50. package/templates/.claude/hooks/statusline.cjs +2 -2
  51. package/templates/.claude/hooks/write-insight.cjs +253 -0
  52. package/templates/.claude/rules/cost-of-detection-ladder.md +96 -0
  53. package/templates/.claude/rules/docker-ports.md +41 -19
  54. package/templates/.claude/rules/embeddable-widget.md +73 -0
  55. package/templates/.claude/rules/feature-lifecycle.md +13 -3
  56. package/templates/.claude/rules/honest-configuration.md +54 -0
  57. package/templates/.claude/rules/incoming-webhooks.md +99 -0
  58. package/templates/.claude/rules/insights-capture.md +10 -5
  59. package/templates/.claude/rules/long-running-job.md +73 -0
  60. package/templates/.claude/rules/model-call-cost.md +85 -0
  61. package/templates/.claude/rules/replicate-pipeline.md +123 -52
  62. package/templates/.claude/rules/skill-interface-protocol.md +1 -0
  63. package/templates/.claude/rules/swarm-file-evidence.md +46 -0
  64. package/templates/.claude/settings.json +13 -1
  65. package/templates/.claude/skills/brutal-honesty-review/SKILL.md +9 -0
  66. package/templates/.claude/skills/cc-toolkit-generator-enhanced/SKILL.md +4 -0
  67. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/03-generate-p0.md +46 -1
  68. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/04-generate-p1.md +7 -1
  69. package/templates/.claude/skills/cc-toolkit-generator-enhanced/modules/06-package-deliver.md +20 -2
  70. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/claude-md-strategy.md +7 -0
  71. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/automation-commands.md +17 -0
  72. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle-ent.md +43 -5
  73. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/feature-lifecycle.md +43 -7
  74. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/start-command.md +19 -1
  75. package/templates/.claude/skills/cc-toolkit-generator-enhanced/references/templates/swarm-file-evidence.md +151 -0
  76. package/templates/.claude/skills/goap-research-ed25519/SKILL.md +37 -22
  77. package/templates/.claude/skills/goap-research-ed25519/references/negative-results.md +94 -0
  78. package/templates/.claude/skills/goap-research-ed25519/scripts/check_report_evidence.py +368 -4
  79. package/templates/.claude/skills/goap-research-ed25519/scripts/ed25519_verifier.py +122 -5
  80. package/templates/.claude/skills/goap-research-ed25519/scripts/evidence_fetch.py +33 -16
  81. package/templates/.claude/skills/goap-research-ed25519/scripts/quote_provenance.py +342 -0
  82. package/templates/.claude/skills/goap-research-ed25519/scripts/test_ed25519_verifier.py +60 -0
  83. package/templates/.claude/skills/goap-research-ed25519/scripts/test_evidence_provenance.py +139 -6
  84. package/templates/.claude/skills/goap-research-ed25519/scripts/test_quote_provenance.py +274 -0
  85. package/templates/.claude/skills/goap-research-ed25519/scripts/test_suite_completeness.py +2 -1
  86. package/templates/.claude/skills/knowledge-extractor/SKILL.md +4 -0
  87. package/templates/.claude/skills/knowledge-extractor/modules/01-agent-review.md +16 -5
  88. package/templates/.claude/skills/pipeline-forge/SKILL.md +18 -23
  89. package/templates/.claude/skills/pipeline-forge/examples/replicate-analysis.md +7 -2
  90. package/templates/.claude/skills/pipeline-forge/references/patterns-catalog.md +19 -1
  91. package/templates/.claude/skills/pipeline-forge/references/self-extracted-patterns.md +17 -6
  92. package/templates/.claude/skills/pipeline-forge/references/skill-anatomy.md +0 -1
  93. package/templates/.claude/skills/reverse-engineering-unicorn/modules/025-cjm-prototype.md +21 -1
  94. package/templates/.claude/skills/sparc-prd-mini/SKILL.md +234 -716
  95. package/tests/e2e/lifecycle.test.js +55 -9
  96. package/tests/e2e/packed-insights-writer.test.js +308 -0
  97. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/01_specification.md +29 -0
  98. package/tests/fixtures/prep-traceability-fixture/docs/features/order-refund/02_pseudocode.md +57 -0
  99. package/tests/snapshot/baseline.json +72 -46
  100. package/tests/snapshot/templates.test.js +47 -0
  101. package/tests/unit/absence-is-not-emptiness.test.js +15 -1
  102. package/tests/unit/capture-source-path.test.js +492 -0
  103. package/tests/unit/check-canon.test.js +403 -0
  104. package/tests/unit/check-embed-contract.test.js +422 -0
  105. package/tests/unit/check-external-deps.test.js +363 -0
  106. package/tests/unit/check-file-ownership.test.js +388 -0
  107. package/tests/unit/check-handoff-manifest.test.js +410 -0
  108. package/tests/unit/check-job-contract.test.js +514 -0
  109. package/tests/unit/check-look-origin.test.js +180 -0
  110. package/tests/unit/check-look-trace.test.js +420 -0
  111. package/tests/unit/check-metric-source.test.js +325 -0
  112. package/tests/unit/check-model-cost.test.js +425 -0
  113. package/tests/unit/check-pipeline-gaps.test.js +94 -0
  114. package/tests/unit/check-ports.test.js +773 -2
  115. package/tests/unit/check-source-version.test.js +344 -0
  116. package/tests/unit/check-swarm-receipts.test.js +231 -0
  117. package/tests/unit/check-webhook-contract.test.js +536 -0
  118. package/tests/unit/db-port-rule.test.js +43 -6
  119. package/tests/unit/detection-ladder-contract.test.js +302 -0
  120. package/tests/unit/detection-ladder-registry.test.js +52 -0
  121. package/tests/unit/doctor-insight-flow.test.js +315 -0
  122. package/tests/unit/external-dependency-check.test.js +19 -19
  123. package/tests/unit/generator-swarm-contract.test.js +287 -0
  124. package/tests/unit/guard-honest-input-meta.test.js +64 -0
  125. package/tests/unit/honest-failure-rules.test.js +574 -0
  126. package/tests/unit/hooks-project-anchored.test.js +67 -3
  127. package/tests/unit/insights-docs-tell-the-truth.test.js +52 -31
  128. package/tests/unit/insights-dz-delegation.test.js +197 -0
  129. package/tests/unit/insights-writer.test.js +285 -0
  130. package/tests/unit/look-phase-contract.test.js +231 -0
  131. package/tests/unit/negative-conclusion-gate.test.js +300 -0
  132. package/tests/unit/quote-provenance.test.js +122 -0
  133. package/tests/unit/shipped-suite-context.test.js +3 -1
  134. package/tests/unit/traceability-machine-ids.test.js +413 -0
  135. package/tests/unit/traceability-negative-fixture.test.js +322 -0
  136. package/tests/unit/utils.test.js +40 -2
@@ -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