bmad-module-skill-forge 5.0.0 → 7.0.0

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 (104) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/agents.md +1 -1
  4. package/docs/architecture.md +3 -3
  5. package/docs/campaign.md +1 -1
  6. package/docs/troubleshooting.md +9 -3
  7. package/docs/verifying-a-skill.md +9 -9
  8. package/docs/workflows.md +7 -7
  9. package/package.json +1 -1
  10. package/src/knowledge/tool-resolution.md +6 -6
  11. package/src/shared/references/headless-gate-convention.md +4 -1
  12. package/src/shared/references/output-contract-schema.md +3 -3
  13. package/src/shared/references/pipeline-contracts.md +2 -1
  14. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/skf-build-change-manifest.py +332 -54
  16. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  17. package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
  18. package/src/shared/scripts/skf-extract-public-api.py +33 -13
  19. package/src/shared/scripts/skf-parse-gaps.py +3 -2
  20. package/src/shared/scripts/skf-quick-batch.py +16 -3
  21. package/src/shared/scripts/skf-verify-provenance-completeness.py +317 -44
  22. package/src/skf-analyze-source/SKILL.md +1 -0
  23. package/src/skf-analyze-source/customize.toml +3 -2
  24. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  25. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  26. package/src/skf-analyze-source/references/headless-contract.md +3 -3
  27. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  28. package/src/skf-audit-skill/SKILL.md +2 -1
  29. package/src/skf-audit-skill/references/report.md +1 -1
  30. package/src/skf-brief-skill/SKILL.md +1 -0
  31. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  32. package/src/skf-campaign/SKILL.md +1 -0
  33. package/src/skf-campaign/references/campaign-contracts.md +3 -1
  34. package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
  35. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  36. package/src/skf-campaign/references/step-08-verify.md +3 -3
  37. package/src/skf-campaign/references/step-09-refine.md +3 -3
  38. package/src/skf-campaign/references/step-10-export.md +3 -2
  39. package/src/skf-campaign/scripts/campaign-state.py +22 -5
  40. package/src/skf-create-skill/SKILL.md +3 -2
  41. package/src/skf-create-skill/customize.toml +4 -4
  42. package/src/skf-create-skill/references/batch-mode.md +3 -1
  43. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  44. package/src/skf-create-skill/references/component-extraction.md +1 -1
  45. package/src/skf-create-skill/references/extract.md +1 -1
  46. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  47. package/src/skf-create-skill/references/report.md +9 -4
  48. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  49. package/src/skf-create-skill/references/validate.md +2 -2
  50. package/src/skf-create-stack-skill/SKILL.md +1 -0
  51. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  52. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  53. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  54. package/src/skf-create-stack-skill/references/report.md +1 -1
  55. package/src/skf-create-stack-skill/references/validate.md +12 -4
  56. package/src/skf-drop-skill/SKILL.md +1 -0
  57. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  58. package/src/skf-drop-skill/references/report.md +1 -1
  59. package/src/skf-export-skill/SKILL.md +1 -0
  60. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  61. package/src/skf-export-skill/references/summary.md +1 -1
  62. package/src/skf-forger/SKILL.md +1 -0
  63. package/src/skf-forger/references/pipeline-mode.md +2 -2
  64. package/src/skf-quick-skill/SKILL.md +2 -1
  65. package/src/skf-quick-skill/references/batch-mode.md +2 -0
  66. package/src/skf-quick-skill/references/finalize.md +1 -1
  67. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  68. package/src/skf-quick-skill/references/write-and-validate.md +20 -10
  69. package/src/skf-refine-architecture/SKILL.md +2 -1
  70. package/src/skf-refine-architecture/customize.toml +1 -1
  71. package/src/skf-refine-architecture/references/report.md +1 -1
  72. package/src/skf-rename-skill/SKILL.md +1 -0
  73. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  74. package/src/skf-setup/SKILL.md +1 -0
  75. package/src/skf-setup/references/ccc-index.md +1 -1
  76. package/src/skf-setup/references/invocation-contract.md +1 -1
  77. package/src/skf-test-skill/SKILL.md +1 -0
  78. package/src/skf-test-skill/assets/output-section-formats.md +4 -4
  79. package/src/skf-test-skill/references/coherence-check.md +8 -6
  80. package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
  81. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  82. package/src/skf-test-skill/references/coverage-check.md +30 -43
  83. package/src/skf-test-skill/references/init.md +1 -1
  84. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  85. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  86. package/src/skf-test-skill/references/report.md +3 -3
  87. package/src/skf-test-skill/references/scoring-rules.md +7 -7
  88. package/src/skf-test-skill/references/signature-comparison-rules.md +74 -0
  89. package/src/skf-test-skill/references/source-access-protocol.md +12 -8
  90. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  91. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  92. package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
  93. package/src/skf-test-skill/scripts/load-coverage-inputs.py +575 -9
  94. package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
  95. package/src/skf-test-skill/scripts/score-signatures.py +937 -76
  96. package/src/skf-test-skill/scripts/validate-inventory.py +325 -30
  97. package/src/skf-update-skill/SKILL.md +1 -0
  98. package/src/skf-update-skill/customize.toml +1 -1
  99. package/src/skf-update-skill/references/gap-driven.md +28 -22
  100. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  101. package/src/skf-update-skill/references/merge.md +4 -4
  102. package/src/skf-update-skill/references/report.md +11 -4
  103. package/src/skf-update-skill/references/write.md +6 -5
  104. package/src/skf-verify-stack/SKILL.md +2 -1
@@ -1,6 +1,6 @@
1
1
  # /// script
2
2
  # requires-python = ">=3.11"
3
- # dependencies = []
3
+ # dependencies = ["pyyaml"]
4
4
  # ///
5
5
  """SKF Build Change Manifest — aggregate category A/B/C/D detection results.
6
6
 
@@ -81,14 +81,18 @@ Subcommands:
81
81
  (the exports marked public: false), "warnings"}.
82
82
 
83
83
  gap-records --manifest <file> --provenance-map <file> [--source-root <dir>]
84
- [--drift-status ok|skipped|overridden] [--judgments <file>]
85
- [--remediation-records <file>] [--evidence <file> --tier <tier>]
84
+ [--brief <file>] [--drift-status ok|skipped|overridden]
85
+ [--judgments <file>] [--remediation-records <file>]
86
+ [--evidence <file> --tier <tier>]
86
87
  (--plan [--files-out <file>] | -o <file>)
87
88
  A gap-driven repair's spot-checks, routing and verification records
88
89
  (see "Gap records" below): --plan prints what to answer and which
89
90
  files §4a's extraction scans (targeted re-extraction, and the
90
91
  signatures of the exports a spot-check located); without it, the
91
92
  records are written to -o through a temporary file and a rename.
93
+ With --brief (the skill brief), no file a gap cites or its
94
+ Remediation names that the brief's scope leaves out is checked,
95
+ scanned or repaired from.
92
96
 
93
97
  baseline-gaps --provenance-map <file> --extraction <file> --files <file>
94
98
  --source-root <dir> -o <file>
@@ -325,8 +329,8 @@ Apply (update-skill write.md §3):
325
329
  export_name, gap_category, severity, verification, in_map (the map holds
326
330
  an entry of that name), map_entry ({source_file, source_line} of the one
327
331
  entry the spot-check took, else null), source_citation, new_location,
328
- unknown_reason, citation_check, map_locations (see "Gap records"),
329
- pinned_definition_lines, reachability, and the located
332
+ unknown_reason, citation_check, map_locations, outside_scope (see "Gap
333
+ records"), pinned_definition_lines, reachability, and the located
330
334
  export's export_type, params, return_type, extraction_method and
331
335
  ast_node_type (from §4a's record at its line, see "Gap records"; null
332
336
  when none holds it). It finds an export's entry by its name, else by the
@@ -351,6 +355,20 @@ Apply (update-skill write.md §3):
351
355
  per name and file, with the signature (below)
352
356
  and the source-read labels whatever its record's
353
357
  tool
358
+ unknown, outside-scope unchanged and no entry added, whatever its
359
+ severity (never refused), with a warning that
360
+ names its gap_id, its unknown_reason and its
361
+ words, its outside_scope (the files the brief's
362
+ scope leaves out), the cause its citation_check
363
+ keeps for a citation the scope takes (with its
364
+ words) and the fix: when the skill covers that
365
+ file, change the brief's scope so it takes it
366
+ (its scope.include or scope.exclude), then
367
+ re-run update-skill; otherwise re-run Test
368
+ Skill (the test compared a declaration the
369
+ skill does not cover). A citation_check that
370
+ read nothing (UNREAD) takes its own fix in
371
+ their place, as below
354
372
  unknown, not in_map, a NEW_EXPORT or MODIFIED_EXPORT, a Medium, Low or
355
373
  Info severity an entry from --merge-records with no
356
374
  source_file or source_line
@@ -371,6 +389,12 @@ Apply (update-skill write.md §3):
371
389
  any other, fix the entry by hand (its
372
390
  source_file and source_line), since the
373
391
  re-run checks the same entry again. A
392
+ missing MOVED_EXPORT, whatever its severity,
393
+ takes that warning too (the gone file and
394
+ the hand fix): rule R5 moves a line, never
395
+ a file. A MOVED_EXPORT's warning names the
396
+ pinned_definition_lines only when the test
397
+ report listed any. A
374
398
  cited-elsewhere one, whatever its severity,
375
399
  takes in their place a warning that names
376
400
  its gap_id, its source_citation, its
@@ -385,6 +409,12 @@ Apply (update-skill write.md §3):
385
409
  moved, fix the entry by hand (with several
386
410
  map_locations, whichever of those entries
387
411
  moved), then re-run update-skill. A
412
+ citation_check outside-scope (no check ran:
413
+ the brief's scope leaves the cited file out)
414
+ takes no advice to exclude it, and its fix
415
+ for a move is to change the brief's scope so
416
+ it takes that file (its scope.include or
417
+ scope.exclude) and fix the entry by hand. A
388
418
  citation_check that read nothing (UNREAD:
389
419
  no-source-root, verifier-missing,
390
420
  spot-check-failed) takes its own fix in
@@ -570,7 +600,25 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
570
600
  STRUCTURAL_FIX and metadata update entries are forwarded to merge as
571
601
  they are (`forwarded`); every other entry is export-bearing. A severity
572
602
  is blocking unless it is Medium, Low or Info (NON_BLOCKING), compared
573
- case-insensitively, so a missing one is blocking. In order:
603
+ case-insensitively, so a missing one is blocking.
604
+
605
+ The brief's scope (--brief, the skill brief) bounds the repair (#824),
606
+ by skf-classify-changed-files.py's load_scope and Scope.takes, loaded
607
+ from this folder (Category A's in-scope test: an exclude wins over an
608
+ include, and an empty include takes only a path with the suffix of a
609
+ path the gaps or the map's entries name or of the brief's language,
610
+ so a file with no suffix is not taken), with Category A's
611
+ rule that a file the map's entries[] cite is always taken. Each path is
612
+ tested relative to --source-root, POSIX, its `.` and `..` parts
613
+ resolved (an absolute path under the root loses the root's prefix).
614
+ An entry's outside_scope is its cited file and each of its
615
+ resolved_paths the scope leaves out: none of them is checked, scanned
616
+ or repaired from, so a declaration of another package the skill does
617
+ not cover is never read for it. A map entry's own file is checked
618
+ wherever it sits (found and MOVED_EXPORT routing does not test it).
619
+ Without --brief (a skill with no brief) there is no scope test and
620
+ nothing is left out. A --brief that names no file or cannot be read
621
+ is an input error (exit 1). In order:
574
622
 
575
623
  1. Drift gate (--drift-status overridden): each export-bearing entry
576
624
  but a MOVED_EXPORT needs the tree, with the first reason that fits:
@@ -579,8 +627,11 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
579
627
  whose map_match is not-found with no candidate that has a
580
628
  source_file "a line and a signature from the tree", any other (a
581
629
  not-found with such a candidate, a citation of another file than
582
- the map's entries, among them) "a signature from the tree". Any
583
- such entry: {"status":
630
+ the map's entries, among them) "a signature from the tree". An
631
+ entry the brief's scope keeps from a repair before anything is read
632
+ for it (step 3 records it outside-scope, or cited-elsewhere with
633
+ citation_check outside-scope, with no check run and nothing to
634
+ scan) passes. Any other such entry: {"status":
584
635
  "drift-blocked", "drift_blocked": [{gap_id, name, change_category,
585
636
  severity, reason}]}, and no spot-check runs.
586
637
  2. A DELETED_EXPORT without a `rescope` that holds its `amendment` and
@@ -589,7 +640,10 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
589
640
  lines rules, loaded from this folder, under --source-root:
590
641
  DELETED_EXPORT rescoped, no check
591
642
  MOVED_EXPORT checked at its map entry when map_match is
592
- found (unknown otherwise); under the
643
+ found (unknown otherwise), a bookkeeping
644
+ variant its file never names against its
645
+ base (the verifier's fold; no other
646
+ category folds); under the
593
647
  override a `moved` becomes unknown with
594
648
  unknown_reason drift-override; its
595
649
  pinned_definition_lines are the numbers of
@@ -599,7 +653,15 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
599
653
  ambiguous unknown, unknown_reason ambiguous (several
600
654
  map entries of its name: no check runs)
601
655
  not-found checked at its source_citation (no export
602
- type) when it has one. When the map holds
656
+ type) when it has one, but never at a
657
+ cited file its outside_scope holds: no
658
+ check runs there, and it is unknown,
659
+ unknown_reason cited-elsewhere with
660
+ citation_check outside-scope when the map
661
+ holds entries of its name with a
662
+ source_file (never re-extracted), else
663
+ unknown_reason outside-scope, routed on
664
+ as below. When the map holds
603
665
  entries of its name (in_map, which a
604
666
  not-found has only for a citation of
605
667
  another file than theirs), one of them
@@ -613,17 +675,29 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
613
675
  check's cause (missing, or its
614
676
  unknown_reason) and its map_locations the
615
677
  `file:line` (or file) of each candidate
616
- with a source_file, both null on any other
617
- record. Candidates with no source_file
618
- (placeholders) do not count. Otherwise,
619
- when that pins no line
678
+ with a source_file, the map_locations null
679
+ on any other record. Candidates with no
680
+ source_file (placeholders) do not count.
681
+ Otherwise,
682
+ when its outside_scope is not empty and
683
+ the scope takes none of its
684
+ resolved_paths: unknown, unknown_reason
685
+ outside-scope, whatever its severity, with
686
+ nothing to scan (never in the plan); its
687
+ citation_check keeps the cause of a check
688
+ at a citation the scope takes (missing, or
689
+ its unknown_reason), null with none. The
690
+ citation_check is null on every record but
691
+ a cited-elsewhere or outside-scope one.
692
+ Otherwise, when that pins no line
620
693
  (unknown or missing), or with no
621
694
  citation: targeted re-extraction (§4a) for a
622
695
  provenance_completeness entry with a source
623
696
  root, for a blocking one whatever its
624
697
  resolved_paths, and for a missing-export or
625
698
  missing-type one with resolved_paths; else
626
- unknown
699
+ unknown. Targeted re-extraction scans only
700
+ the resolved_paths the scope takes
627
701
  A check gives `verified` (the line is a definition line, or a module
628
702
  or package entry whose file exists), `moved` (one definition line,
629
703
  not the recorded one: new_location), `missing` (no such file) or
@@ -646,19 +720,24 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
646
720
  candidate that has a source_file) never goes to targeted
647
721
  re-extraction, whatever its severity: one that ends missing or
648
722
  unknown keeps its map entries unchanged and adds none, and apply
649
- warns (see "Apply").
723
+ warns (see "Apply"). One recorded unknown, outside-scope (its record's
724
+ outside_scope lists the files the scope leaves out) adds no entry
725
+ either, a placeholder included, whatever its severity, and apply
726
+ warns.
650
727
  A NEW_EXPORT or MODIFIED_EXPORT a check gives verified or moved, at
651
728
  its map entry or at a citation the map holds no entry of its name
652
729
  for, is located: its signature_at is the checked file and the
653
730
  recorded line (verified) or the new one (moved). One whose citation
654
731
  names another file than the map's entries of its name is not (apply
655
732
  warns, as before), nor one whose file is absolute or outside
656
- --source-root, links resolved, nor a NEW_EXPORT --judgments answers
657
- internal-unreachable (one not yet answered is located, so its scan
658
- may go unused).
733
+ --source-root, links resolved (a cited file the brief's scope leaves
734
+ out is never checked, so never located), nor a NEW_EXPORT
735
+ --judgments answers internal-unreachable (one not yet answered is
736
+ located, so its scan may go unused).
659
737
  --plan stops here: {"status": "planned", "reextract": {"entries":
660
- [{gap_id, name, severity, resolved_paths}], "files": [the union of
661
- their resolved_paths, then of the located exports' files],
738
+ [{gap_id, name, severity, resolved_paths (those the scope takes)}],
739
+ "files": [the union of their resolved_paths, then of the located
740
+ exports' files],
662
741
  "signatures": [{gap_id, name, file, line} of each located export]},
663
742
  "warnings"}, the files also written to --files-out as a JSON array.
664
743
  4. Targeted re-extraction (--remediation-records, `records`' output
@@ -677,7 +756,10 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
677
756
  a renamed re-export by its public name matches no record.
678
757
  An entry with no match, or no path to scan, is unresolved, except a
679
758
  missing-export or missing-type one that is not blocking, which is
680
- unknown. Any unresolved entry: {"status": "unresolved",
759
+ unknown. An entry with no match whose outside_scope is not empty is
760
+ unknown, unknown_reason outside-scope (a file the scope leaves out
761
+ may define it), whatever its severity and category, never
762
+ unresolved. Any unresolved entry: {"status": "unresolved",
681
763
  "unresolved": [{gap_id, name, severity, remediation_paths,
682
764
  rejected_paths, files_scanned, exports_found_in_scan}]}.
683
765
  Each located export takes the one record of its name (or of its map
@@ -713,8 +795,10 @@ Gap records (update-skill gap-driven.md §3, §4 and §4a):
713
795
  [one record per export-bearing entry: gap_id, export_name,
714
796
  gap_category, severity, verification, in_map, map_entry,
715
797
  provenance_citation, source_citation, new_location, unknown_reason,
716
- citation_check, map_locations, pinned_definition_lines,
717
- resolution_source, reachability, and
798
+ citation_check, map_locations, outside_scope (the files a gap cites or
799
+ its Remediation names that the brief's scope leaves out, whatever the
800
+ record's outcome; null when it leaves out none, or with no --brief),
801
+ pinned_definition_lines, resolution_source, reachability, and
718
802
  export_type, params, return_type, extraction_method and ast_node_type
719
803
  (a located export's record's, step 4; else null, and apply takes the
720
804
  signature of an entry it adds from merge.md's merge-records.json)],
@@ -773,6 +857,7 @@ import hashlib
773
857
  import importlib.util
774
858
  import json
775
859
  import os
860
+ import posixpath
776
861
  import re
777
862
  import subprocess
778
863
  import sys
@@ -1999,17 +2084,22 @@ def _apply_gap_driven(pmap: _Map, records: dict, merge_records: dict | None, dri
1999
2084
  and reason in UNKNOWN_REASONS else ""
2000
2085
 
2001
2086
  def unchecked(r: dict) -> str | None:
2002
- """The warning for a blocking NEW_EXPORT or MODIFIED_EXPORT the map holds that its spot-check could not
2003
- locate (missing, or unknown for a cause its unknown_reason names), which stays unchanged: the gap, the
2087
+ """The warning for an export the map holds that its spot-check could not locate, which stays unchanged: a
2088
+ blocking NEW_EXPORT or MODIFIED_EXPORT (missing, or unknown for a cause its unknown_reason names), or a
2089
+ MOVED_EXPORT whose map entry's file is gone (missing, rule R5), whatever its severity. It gives the gap, the
2004
2090
  export, the outcome, its cause and the one fix a re-run acts on; None for any other record."""
2005
- outcome = r.get("verification")
2006
- if outcome not in ("missing", "unknown") or not in_map(r) or not _blocking(r.get("severity")) \
2007
- or r.get("gap_category") not in ("NEW_EXPORT", "MODIFIED_EXPORT"):
2091
+ outcome, category = r.get("verification"), r.get("gap_category")
2092
+ gone = outcome == "missing" and category == "MOVED_EXPORT" # R5 cannot point an entry at another file
2093
+ if not in_map(r) or not gone and (
2094
+ outcome not in ("missing", "unknown") or not _blocking(r.get("severity"))
2095
+ or category not in ("NEW_EXPORT", "MODIFIED_EXPORT")):
2008
2096
  return None
2009
2097
  name, gap = _name_of(r), r.get("gap_id")
2010
2098
  path = _location(r.get("map_entry"))[0]
2011
2099
  cause = f"its map entry's file{f' {path}' if path else ''} is gone" if outcome == "missing" else why(r)
2012
- detail = "; ".join(part for part in (cause, note) if part)
2100
+ lines = r.get("pinned_definition_lines") if gone else None
2101
+ detail = "; ".join(part for part in (
2102
+ cause, note, f"definition lines per the test report: {lines}" if lines else "") if part)
2013
2103
  if r.get("unknown_reason") == "ambiguous": # a Source at the definition lets the re-run's lookup pick one
2014
2104
  fix = ("its entries are left unchanged. Fix its entries by hand, or give the gap a `file:line` Source at "
2015
2105
  "its definition when that file holds only one of them, then re-run update-skill")
@@ -2028,7 +2118,8 @@ def _apply_gap_driven(pmap: _Map, records: dict, merge_records: dict | None, dri
2028
2118
  cited_file, cited_line = _location(r.get("source_citation"))
2029
2119
  cited = _where(cited_file, cited_line) or cited_file
2030
2120
  check = r.get("citation_check") if isinstance(r.get("citation_check"), str) else None
2031
- words = "no such file" if check == "missing" else UNKNOWN_REASONS.get(check or "")
2121
+ words = "no such file" if check == "missing" else OUTSIDE_SCOPE_CHECK if check == "outside-scope" \
2122
+ else UNKNOWN_REASONS.get(check or "")
2032
2123
  where = [w for w in r.get("map_locations") or [] if isinstance(w, str) and w]
2033
2124
  detail = "; ".join(part for part in (
2034
2125
  why(r), f"it cites {cited}" if cited else "",
@@ -2040,16 +2131,49 @@ def _apply_gap_driven(pmap: _Map, records: dict, merge_records: dict | None, dri
2040
2131
  "re-extracted for it."
2041
2132
  if check in UNREAD: # nothing was read at the cited file: make the check read it first
2042
2133
  return f"{head} Nothing was read at the cited file: {UNREAD[check]}, then re-run update-skill"
2043
- moved = "when an export moved, fix by hand whichever of those entries moved" if several \
2044
- else "when the export moved, fix the map entry by hand"
2045
- scope = "" if check == "missing" else (", and if the gap comes back, exclude the cited file in the brief's "
2046
- "scope when the skill does not cover it")
2134
+ if check == "outside-scope": # the scope already leaves the cited file out: only a move there brings it in
2135
+ moved = f"when an export moved there, {TAKE_FILE} and fix by hand whichever of those entries moved" \
2136
+ if several else f"when the export moved there, {TAKE_FILE} and fix the map entry by hand"
2137
+ else:
2138
+ moved = "when an export moved, fix by hand whichever of those entries moved" if several \
2139
+ else "when the export moved, fix the map entry by hand"
2140
+ scope = "" if check in ("missing", "outside-scope") else (
2141
+ ", and if the gap comes back, exclude the cited file in the brief's scope when the skill does not cover it")
2047
2142
  return (f"{head} When the map is right, re-run Test Skill, since the test compared another file's "
2048
2143
  f"declaration{scope}; {moved} (set its source_file and source_line to the line that defines it), "
2049
2144
  f"then re-run update-skill")
2050
2145
 
2146
+ def scoped_out(r: dict) -> bool:
2147
+ """True for an `unknown` record whose gap the brief's scope keeps from a repair (outside-scope)."""
2148
+ return r.get("verification") == "unknown" and r.get("unknown_reason") == "outside-scope"
2149
+
2150
+ def outside(r: dict) -> str | None:
2151
+ """The warning for a NEW_EXPORT or MODIFIED_EXPORT recorded unknown, outside-scope, whatever its severity: the
2152
+ gap, the export, the files the brief's scope leaves out and the fix for each case; None for any other
2153
+ record."""
2154
+ if not scoped_out(r):
2155
+ return None
2156
+ name, gap = _name_of(r), r.get("gap_id")
2157
+ left = [p for p in r.get("outside_scope") or [] if isinstance(p, str) and p]
2158
+ # the cause of a check at an in-scope citation that pinned no line (its file the scope takes)
2159
+ check = r.get("citation_check") if isinstance(r.get("citation_check"), str) else None
2160
+ words = "no such file" if check == "missing" else UNKNOWN_REASONS.get(check or "")
2161
+ cited = _where(*_location(r.get("source_citation"))) or _location(r.get("source_citation"))[0]
2162
+ checked = f"its check there: {check}{f' ({words})' if words else ''}" if check else ""
2163
+ detail = "; ".join(part for part in (
2164
+ why(r), f"left out: {', '.join(left)}" if left else "", f"it cites {cited}" if check and cited else "",
2165
+ checked, note) if part)
2166
+ head = f"provenance: {name}{f' ({gap})' if gap else ''}: unknown ({detail}); nothing is added or re-extracted " \
2167
+ "for it."
2168
+ if check in UNREAD: # nothing was read at the cited file the scope takes: make the check read it first
2169
+ return f"{head} Nothing was read at the cited file: {UNREAD[check]}, then re-run update-skill"
2170
+ covers = "When the skill covers one of those files, change the brief's scope so it takes it" if len(left) > 1 \
2171
+ else "When the skill covers that file, change the brief's scope so it takes it"
2172
+ return (f"{head} {covers} (its scope.include or scope.exclude), then re-run update-skill; otherwise re-run "
2173
+ "Test Skill, since the test compared a declaration the skill does not cover")
2174
+
2051
2175
  blocking = [{"export_name": _name_of(r), "severity": r.get("severity")} for r in verification
2052
- if r.get("verification") == "unknown" and not in_map(r)
2176
+ if r.get("verification") == "unknown" and not in_map(r) and not scoped_out(r)
2053
2177
  and r.get("gap_category") in ("NEW_EXPORT", "MODIFIED_EXPORT") and _blocking(r.get("severity"))]
2054
2178
  if blocking:
2055
2179
  return blocking
@@ -2059,7 +2183,8 @@ def _apply_gap_driven(pmap: _Map, records: dict, merge_records: dict | None, dri
2059
2183
  entry_file, entry_line = _location(r.get("map_entry"))
2060
2184
  index = pmap.find(name, entry_file, entry_line, reexport=True) if r.get("map_entry") else None
2061
2185
  lines = r.get("pinned_definition_lines")
2062
- r5 = f"; definition lines per the test report: {lines}" if category == "MOVED_EXPORT" else ""
2186
+ # rule R5's lines, only when the test report listed any (a citation that did not resolve lists none)
2187
+ r5 = f"; definition lines per the test report: {lines}" if category == "MOVED_EXPORT" and lines else ""
2063
2188
  if outcome == "rescoped":
2064
2189
  if index is not None:
2065
2190
  pmap.remove([index])
@@ -2101,14 +2226,15 @@ def _apply_gap_driven(pmap: _Map, records: dict, merge_records: dict | None, dri
2101
2226
  "export_name": name, **signature(r)[0],
2102
2227
  "source_file": path, "source_line": line, **_labels("source-read", None)},
2103
2228
  keep=("ast_node_type",)), pmap.library_for(path, name)))
2104
- elif outcome == "unknown" and not in_map(r) and category in ("NEW_EXPORT", "MODIFIED_EXPORT"):
2229
+ elif outcome == "unknown" and not in_map(r) and category in ("NEW_EXPORT", "MODIFIED_EXPORT") \
2230
+ and not scoped_out(r): # an outside-scope one takes no placeholder: it may be another package's
2105
2231
  pmap.add(_new_entry(_without_none({
2106
2232
  "export_name": name, **signature(r)[0],
2107
2233
  **_labels("source-read", None)}, keep=("ast_node_type",)), pmap.library_for(None, name)))
2108
2234
  else:
2109
2235
  if r.get("unknown_reason") == "drift-override":
2110
2236
  warnings.append(f"provenance: {name}: unknown ({note}; no line taken from HEAD{r5})")
2111
- elif (warning := elsewhere(r) or unchecked(r)) is not None:
2237
+ elif (warning := elsewhere(r) or outside(r) or unchecked(r)) is not None:
2112
2238
  warnings.append(warning)
2113
2239
  elif drift:
2114
2240
  warnings.append(f"provenance: {name}: {outcome} ({why(r) + '; ' if why(r) else ''}{note}{r5})")
@@ -2765,7 +2891,13 @@ UNKNOWN_REASONS = {
2765
2891
  "no-source-root": "no source tree to check",
2766
2892
  "drift-override": "the drift override kept its line from being moved",
2767
2893
  "cited-elsewhere": "the gap cites another file than the provenance map's entries of its name",
2894
+ "outside-scope": "the brief's scope leaves out a file the gap cites or its Remediation names, so the export is "
2895
+ "not read there",
2768
2896
  }
2897
+ # A cited-elsewhere entry's citation_check when the brief's scope leaves the cited file out, so no check ran there.
2898
+ OUTSIDE_SCOPE_CHECK = "not run: the brief's scope leaves it out"
2899
+ # The fix for a file the brief's scope leaves out that the skill covers: an include may miss it, or an exclude take it.
2900
+ TAKE_FILE = "change the brief's scope so it takes that file (its scope.include or scope.exclude)"
2769
2901
  # A cited-elsewhere entry's citation_check that read nothing at the cited file, with the fix that lets a re-run read it.
2770
2902
  UNREAD = {
2771
2903
  "no-source-root": "give update-skill the source tree",
@@ -2795,6 +2927,95 @@ def _verifier():
2795
2927
  return _VERIFIER or None
2796
2928
 
2797
2929
 
2930
+ CLASSIFIER_FILE = "skf-classify-changed-files.py" # load_scope: the brief's scope (--brief)
2931
+
2932
+
2933
+ def _brief_scope(path: Path, cited: set[str] | frozenset = frozenset()):
2934
+ """The skill brief's scope by skf-classify-changed-files.py's load_scope, loaded from this folder: its
2935
+ takes(path) is Category A's in-scope test (an exclude wins over an include; an empty include takes a path only
2936
+ when its suffix is the suffix of a `cited` path, the files the gaps and the map's entries name, or of the brief's
2937
+ language, so a file with no suffix is never taken, and an exclude still leaves a path out).
2938
+ Raises ValueError, on one line, for a brief that names no file or cannot be read, and for a scope test that
2939
+ cannot be loaded."""
2940
+ try:
2941
+ spec = importlib.util.spec_from_file_location(
2942
+ "skf_classify_changed_files_for_gap_records", Path(__file__).resolve().parent / CLASSIFIER_FILE)
2943
+ if spec is None or spec.loader is None:
2944
+ raise ImportError(f"{CLASSIFIER_FILE} not found")
2945
+ classifier = importlib.util.module_from_spec(spec)
2946
+ spec.loader.exec_module(classifier)
2947
+ except Exception as exc: # noqa: BLE001 - a classifier (or the resolver it loads) missing or failing
2948
+ raise ValueError(f"cannot load the brief's scope test, {CLASSIFIER_FILE} beside "
2949
+ f"{Path(__file__).name}: {exc}; re-install SKF") from exc
2950
+ try:
2951
+ return classifier.load_scope(path, cited)
2952
+ except ValueError as exc: # a brief it cannot find or read (a YAML error spans several lines)
2953
+ raise ValueError(re.sub(r"\s*[\r\n]\s*", " ", str(exc).strip())) from exc
2954
+ except Exception as exc: # noqa: BLE001 - an out-of-date classifier, or a resolver beside it that fails
2955
+ raise ValueError(f"cannot load the brief's scope test, {CLASSIFIER_FILE} beside "
2956
+ f"{Path(__file__).name}: {exc}; re-install SKF") from exc
2957
+
2958
+
2959
+ def _takes(scope, path: str) -> bool:
2960
+ """True when the brief's scope takes `path`. Raises ValueError when its test fails (the resolver it calls cannot
2961
+ be loaded or is out of date)."""
2962
+ try:
2963
+ return bool(scope.takes(path))
2964
+ except Exception as exc: # noqa: BLE001 - whatever the scope test raises, the run reports it on one line
2965
+ raise ValueError(f"the brief's scope test failed on {path}: {exc}; re-install SKF") from exc
2966
+
2967
+
2968
+ def _scope_rel(path: object, root: Path | None) -> str | None:
2969
+ """`path` as the scope test reads it: POSIX, relative to the source root (an absolute path under it loses the
2970
+ root's prefix), its `.` and `..` parts resolved lexically (posixpath.normpath); None for no path."""
2971
+ text = _norm_path(path)
2972
+ if text is None:
2973
+ return None
2974
+ if root is not None:
2975
+ bases = [root.as_posix()]
2976
+ try:
2977
+ bases.append(root.resolve().as_posix())
2978
+ except OSError:
2979
+ pass
2980
+ for base in dict.fromkeys(b.rstrip("/") for b in bases if b.rstrip("/")):
2981
+ if text.startswith(base + "/"):
2982
+ text = text[len(base) + 1:]
2983
+ break
2984
+ return posixpath.normpath(text)
2985
+
2986
+
2987
+ def _scope_test(scope, provenance: dict | None, root: Path | None):
2988
+ """The test gap-records applies to each file a gap cites or its Remediation names: True for a file the
2989
+ provenance map's entries[] cite (Category A takes each file the map tracks whatever the scope says) and for one
2990
+ the brief's scope takes, each path read by _scope_rel."""
2991
+ tracked = {_scope_rel(_file_of(e), root) for e in _entries_of(provenance)} - {None}
2992
+
2993
+ def takes(path: str) -> bool:
2994
+ rel = _scope_rel(path, root)
2995
+ return rel is not None and (rel in tracked or _takes(scope, rel))
2996
+
2997
+ return takes
2998
+
2999
+
3000
+ def _map_paths(provenance: object) -> set[str]:
3001
+ """The files the provenance map's entries[] cite (load_scope's `cited`, with the gaps' paths)."""
3002
+ return {p for p in (_file_of(e) for e in _entries_of(provenance)) if p}
3003
+
3004
+
3005
+ def _gap_paths(manifest: object) -> set[str]:
3006
+ """Every file a manifest's entries name: each citation's file and resolved path (load_scope's `cited`)."""
3007
+ entries = manifest.get("entries") if isinstance(manifest, dict) else None
3008
+ paths = set()
3009
+ for entry in entries if isinstance(entries, list) else []:
3010
+ if not isinstance(entry, dict):
3011
+ continue
3012
+ citation = entry.get("source_citation") if isinstance(entry.get("source_citation"), dict) else {}
3013
+ for path in [citation.get("file"), *(entry.get("resolved_paths") or [])]:
3014
+ if _norm_path(path):
3015
+ paths.add(_norm_path(path))
3016
+ return paths
3017
+
3018
+
2798
3019
  def _blocking(severity: object) -> bool:
2799
3020
  return str(severity or "").strip().lower() not in NON_BLOCKING
2800
3021
 
@@ -2832,9 +3053,11 @@ def _drift_reason(entry: dict) -> str | None:
2832
3053
 
2833
3054
 
2834
3055
  def _spot_check(root: Path | None, path: str | None, name: str, line: int | None, export_type: object,
2835
- answers: dict) -> tuple[str, int | None, bool, str | None, str | None]:
3056
+ answers: dict, fold: bool = False) -> tuple[str, int | None, bool, str | None, str | None]:
2836
3057
  """(outcome, the line a `moved` export moves to, whether it waits on a `declaring_line` answer, why the
2837
- check failed, why it is `unknown`: an UNKNOWN_REASONS key)."""
3058
+ check failed, why it is `unknown`: an UNKNOWN_REASONS key). `fold` (rule R5's MOVED_EXPORT only) checks a
3059
+ bookkeeping variant its file never names against its base; any other gap judges the name as written, so a
3060
+ fabricated variant is never located at its base's line."""
2838
3061
  if root is None:
2839
3062
  return "unknown", None, False, None, "no-source-root"
2840
3063
  verifier = _verifier()
@@ -2844,7 +3067,7 @@ def _spot_check(root: Path | None, path: str | None, name: str, line: int | None
2844
3067
  return "unknown", None, False, None, "no-line"
2845
3068
  try:
2846
3069
  report = verifier.definition_lines_report(path, name, root, line,
2847
- export_type if isinstance(export_type, str) else None)
3070
+ export_type if isinstance(export_type, str) else None, fold)
2848
3071
  except Exception as exc: # noqa: BLE001 - a file it cannot read, or any verifier failure: this entry only
2849
3072
  return "unknown", None, False, f"{type(exc).__name__}: {exc}", "spot-check-failed"
2850
3073
  check = report.get("line_check")
@@ -2894,7 +3117,7 @@ def _where(path: object, line: object) -> str | None:
2894
3117
  class _GapEntry:
2895
3118
  """One export-bearing manifest entry as gap-records routes it."""
2896
3119
 
2897
- def __init__(self, entry: dict, answers: dict):
3120
+ def __init__(self, entry: dict, answers: dict, takes=None):
2898
3121
  self.entry = entry
2899
3122
  self.answers = answers
2900
3123
  self.name = _name_of(entry) or ""
@@ -2912,9 +3135,17 @@ class _GapEntry:
2912
3135
  self.map_locations = list(dict.fromkeys(
2913
3136
  _where(c.get("source_file"), c.get("source_line")) or _norm_path(c.get("source_file"))
2914
3137
  for c in match.get("candidates") or [] if isinstance(c, dict) and _norm_path(c.get("source_file"))))
2915
- self.citation_check: str | None = None # a cited-elsewhere entry's citation check: missing, or its reason
3138
+ # a cited-elsewhere or outside-scope entry's citation check: missing, its reason, or outside-scope
3139
+ self.citation_check: str | None = None
3140
+ self.cited_cause: str | None = None # why a check at an in-scope citation pinned no line: missing, its reason
2916
3141
  citation = entry.get("source_citation")
2917
3142
  self.citation = citation if isinstance(citation, dict) else None
3143
+ self.cited_file = _norm_path(self.citation.get("file")) if self.citation else None
3144
+ # the cited file and each resolved path the brief's scope leaves out (#824): none is checked, scanned or
3145
+ # repaired from; with no brief there is no scope test (`takes` None), and nothing is left out
3146
+ self.outside_scope: list[str] = [] if takes is None else list(dict.fromkeys(
3147
+ p for p in (self.cited_file, *(_norm_path(p) for p in entry.get("resolved_paths") or []))
3148
+ if p and not takes(p)))
2918
3149
  self.recorded: tuple[str | None, int | None] = (None, None) # the file and line a check read
2919
3150
  self.outcome: str | None = None
2920
3151
  self.new_line: int | None = None
@@ -2930,14 +3161,31 @@ class _GapEntry:
2930
3161
  def blocking(self) -> bool:
2931
3162
  return _blocking(self.entry.get("severity"))
2932
3163
 
3164
+ @property
3165
+ def scoped_out(self) -> bool:
3166
+ """True for an entry route() records outside-scope (or cited-elsewhere with citation_check outside-scope)
3167
+ before anything is read for it: no in-scope citation to check and no in-scope file to scan. The drift gate
3168
+ passes it, since nothing it writes comes from the tree."""
3169
+ if self.category not in ("NEW_EXPORT", "MODIFIED_EXPORT") or self.match_status != "not-found":
3170
+ return False
3171
+ cited_out = self.cited_file is not None and self.cited_file in self.outside_scope
3172
+ if self.citation and not cited_out: # an in-scope citation is checked
3173
+ return False
3174
+ if self.map_locations: # with the cited file left out: cited-elsewhere, never checked; with none, as before
3175
+ return cited_out
3176
+ return bool(self.outside_scope) and not self.paths
3177
+
2933
3178
  @property
2934
3179
  def paths(self) -> list[str]:
2935
- return [p for p in (_norm_path(p) for p in self.entry.get("resolved_paths") or []) if p]
3180
+ """The resolved paths targeted re-extraction scans: each one the brief's scope takes."""
3181
+ return [p for p in (_norm_path(p) for p in self.entry.get("resolved_paths") or [])
3182
+ if p and p not in self.outside_scope]
2936
3183
 
2937
3184
  def check(self, root: Path | None, path: object, line: object, export_type: object) -> None:
2938
3185
  self.recorded = (_norm_path(path), _line_of(line))
2939
3186
  self.outcome, self.new_line, self.waits, self.failure, self.unknown_reason = _spot_check(
2940
- root, self.recorded[0], self.declared, self.recorded[1], export_type, self.answers)
3187
+ root, self.recorded[0], self.declared, self.recorded[1], export_type, self.answers,
3188
+ self.category == "MOVED_EXPORT")
2941
3189
 
2942
3190
  def locate(self, root: Path | None) -> None:
2943
3191
  """Record where a check located the export (the checked file, at the recorded line or the one it moved
@@ -2978,18 +3226,31 @@ class _GapEntry:
2978
3226
  if self.match_status == "ambiguous": # several map entries of its name: no check, apply warns
2979
3227
  self.outcome, self.unknown_reason = "unknown", "ambiguous"
2980
3228
  return
2981
- if self.citation:
3229
+ if self.citation and self.cited_file is not None and self.cited_file in self.outside_scope:
3230
+ # the brief's scope leaves the cited file out: the skill does not cover its declaration, so no check runs
3231
+ # there (#824); the fallbacks below scan only the files the scope takes
3232
+ if self.map_locations: # the map holds its name at other files: cited elsewhere, and never checked
3233
+ self.outcome, self.unknown_reason, self.citation_check = "unknown", "cited-elsewhere", "outside-scope"
3234
+ return
3235
+ self.outcome, self.unknown_reason = "unknown", "outside-scope"
3236
+ elif self.citation:
2982
3237
  self.check(root, self.citation.get("file"), self.citation.get("line"), None)
2983
3238
  if self.waits or self.outcome in ("verified", "moved"):
2984
3239
  if not self.in_map: # a citation of another file than the map's entries of its name takes none
2985
3240
  self.locate(root)
2986
3241
  return
3242
+ self.cited_cause = "missing" if self.outcome == "missing" else self.unknown_reason
2987
3243
  if self.match_status == "not-found" and self.map_locations:
2988
3244
  # the map holds its name at other files: the cited file's declaration may be another package's, so it is
2989
3245
  # never re-extracted there (#824); the record keeps the check's cause, and apply warns with what to fix
2990
3246
  self.citation_check = "missing" if self.outcome == "missing" else self.unknown_reason
2991
3247
  self.outcome, self.unknown_reason = "unknown", "cited-elsewhere"
2992
3248
  return
3249
+ if self.outside_scope and not self.paths:
3250
+ # no file the brief's scope takes is left to scan: never in the plan (#824); the record keeps the cause of
3251
+ # a check at an in-scope citation, which apply's warning names
3252
+ self.outcome, self.unknown_reason, self.citation_check = "unknown", "outside-scope", self.cited_cause
3253
+ return
2993
3254
  if self.entry.get("provenance_completeness") and root is not None:
2994
3255
  self.reextract = True
2995
3256
  elif self.blocking:
@@ -3050,6 +3311,7 @@ class _GapEntry:
3050
3311
  signed = self.signature() or {}
3051
3312
  recorded = _where(*self.recorded)
3052
3313
  elsewhere = self.outcome == "unknown" and self.unknown_reason == "cited-elsewhere"
3314
+ outside = self.outcome == "unknown" and self.unknown_reason == "outside-scope"
3053
3315
  if self.outcome == "re-extracted":
3054
3316
  citation = self.new_location()
3055
3317
  elif self.outcome in ("verified", "moved", "missing", "rescoped") and recorded:
@@ -3067,8 +3329,9 @@ class _GapEntry:
3067
3329
  "source_citation": self.citation,
3068
3330
  "new_location": self.new_location(),
3069
3331
  "unknown_reason": self.unknown_reason if self.outcome == "unknown" else None,
3070
- "citation_check": self.citation_check if elsewhere else None,
3332
+ "citation_check": self.citation_check if elsewhere or outside else None,
3071
3333
  "map_locations": self.map_locations if elsewhere else None,
3334
+ "outside_scope": self.outside_scope or None, # whatever the outcome: each file the scope left out
3072
3335
  "pinned_definition_lines": _pinned_lines(self.entry.get("remediation"))
3073
3336
  if self.category == "MOVED_EXPORT" else None,
3074
3337
  "resolution_source": "remediation-paths" if self.outcome == "re-extracted" else None,
@@ -3106,8 +3369,9 @@ class _GapEntry:
3106
3369
 
3107
3370
  def gap_records(manifest: dict, provenance: dict | None, *, source_root: Path | None, drift_status: str = "ok",
3108
3371
  judgments: dict | None = None, remediation: dict | None = None, plan: bool = False,
3109
- tier: str | None = None) -> tuple[dict | None, dict]:
3110
- """(reextract-records.json or None, the summary): see "Gap records" in the module docstring."""
3372
+ tier: str | None = None, scope=None) -> tuple[dict | None, dict]:
3373
+ """(reextract-records.json or None, the summary): see "Gap records" in the module docstring. `scope` is the
3374
+ brief's (_brief_scope), or None for a skill with no brief."""
3111
3375
  if not isinstance(manifest, dict) or not isinstance(manifest.get("entries"), list):
3112
3376
  raise ValueError("the change manifest has no `entries` array: is it translate's output?")
3113
3377
  entries = [e for e in manifest["entries"] if isinstance(e, dict)]
@@ -3115,13 +3379,14 @@ def gap_records(manifest: dict, provenance: dict | None, *, source_root: Path |
3115
3379
  root = source_root if source_root is not None and source_root.is_dir() else None
3116
3380
  forwarded = [{"gap_id": e.get("gap_id"), "change_category": e.get("change_category")}
3117
3381
  for e in entries if e.get("change_category") not in EXPORT_CATEGORIES]
3118
- routed = [_GapEntry(e, judgments.get(e.get("gap_id")) or {}) for e in entries
3382
+ takes = None if scope is None else _scope_test(scope, provenance, source_root)
3383
+ routed = [_GapEntry(e, judgments.get(e.get("gap_id")) or {}, takes) for e in entries
3119
3384
  if e.get("change_category") in EXPORT_CATEGORIES]
3120
3385
  drift = drift_status == "overridden"
3121
3386
  if drift:
3122
3387
  blocked = [{"gap_id": g.entry.get("gap_id"), "name": g.name, "change_category": g.category,
3123
3388
  "severity": g.entry.get("severity"), "reason": _drift_reason(g.entry)}
3124
- for g in routed if _drift_reason(g.entry)]
3389
+ for g in routed if _drift_reason(g.entry) and not g.scoped_out] # nothing is read for it
3125
3390
  if blocked:
3126
3391
  return None, {"status": "drift-blocked", "drift_blocked": blocked}
3127
3392
  bare = [g.name for g in routed if g.category == "DELETED_EXPORT" and not (
@@ -3168,6 +3433,9 @@ def gap_records(manifest: dict, provenance: dict | None, *, source_root: Path |
3168
3433
  files_scanned, found = g.match(blocks)
3169
3434
  if g.record is not None:
3170
3435
  continue
3436
+ if g.outside_scope: # none of the files the scope takes holds it, and a file it leaves out may (#824)
3437
+ g.outcome, g.unknown_reason, g.citation_check = "unknown", "outside-scope", g.cited_cause
3438
+ continue
3171
3439
  if not g.blocking and g.entry.get("category") in DOCUMENT_CATEGORIES:
3172
3440
  g.outcome = "unknown"
3173
3441
  continue
@@ -3572,15 +3840,22 @@ def _cmd_gap_records(args: argparse.Namespace) -> int:
3572
3840
  raise ValueError("--evidence and --tier go together")
3573
3841
  judgments = _gap_judgments(_load_json_file(Path(args.judgments), "--judgments file")) \
3574
3842
  if args.judgments else {}
3843
+ manifest = _load_json_file(Path(args.manifest), "--manifest file")
3844
+ provenance = _load_json_file(Path(args.provenance_map), "--provenance-map file")
3845
+ if args.brief is not None and not args.brief.strip():
3846
+ raise ValueError("--brief is empty (an unbound brief path?): pass the skill brief, or leave --brief out")
3847
+ scope = _brief_scope(Path(args.brief), _gap_paths(manifest) | _map_paths(provenance)) \
3848
+ if args.brief is not None else None
3575
3849
  records, summary = gap_records(
3576
- _load_json_file(Path(args.manifest), "--manifest file"),
3577
- _load_json_file(Path(args.provenance_map), "--provenance-map file"),
3850
+ manifest,
3851
+ provenance,
3578
3852
  source_root=Path(args.source_root) if args.source_root else None,
3579
3853
  drift_status=args.drift_status,
3580
3854
  judgments=judgments,
3581
3855
  remediation=_optional_json(args.remediation_records, "--remediation-records file"),
3582
3856
  plan=args.plan,
3583
3857
  tier=args.tier,
3858
+ scope=scope,
3584
3859
  )
3585
3860
  if summary["status"] == "planned" and args.files_out:
3586
3861
  _write_json_atomic(Path(args.files_out), summary["reextract"]["files"])
@@ -3803,6 +4078,9 @@ def _build_parser() -> argparse.ArgumentParser:
3803
4078
  p_gap.add_argument("--manifest", required=True, metavar="FILE", help="skf-parse-gaps.py translate's manifest")
3804
4079
  p_gap.add_argument("--provenance-map", required=True, metavar="FILE", help="the map the repair starts from")
3805
4080
  p_gap.add_argument("--source-root", metavar="DIR", help="the source tree the spot-checks read")
4081
+ p_gap.add_argument("--brief", metavar="FILE",
4082
+ help="the skill brief (skill-brief.yaml): no file a gap cites or its Remediation names that its "
4083
+ "scope leaves out is checked, scanned or repaired from; without it there is no scope test")
3806
4084
  p_gap.add_argument("--drift-status", choices=DRIFT_STATUSES, default="ok",
3807
4085
  help="the workspace drift check's status; overridden runs the drift gate")
3808
4086
  p_gap.add_argument("--judgments", metavar="FILE", help="the answers to its needs_judgment[], keyed by gap id")