bmad-module-skill-forge 3.0.0 → 4.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 (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/examples.md +1 -1
  4. package/docs/troubleshooting.md +8 -0
  5. package/docs/verifying-a-skill.md +7 -3
  6. package/docs/workflows.md +3 -3
  7. package/package.json +1 -1
  8. package/src/knowledge/qmd-registry.md +1 -1
  9. package/src/knowledge/version-paths.md +1 -1
  10. package/src/shared/health-check.md +2 -50
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  12. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
  13. package/src/shared/scripts/skf-atomic-write.py +212 -59
  14. package/src/shared/scripts/skf-build-change-manifest.py +593 -47
  15. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  16. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  17. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  18. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  19. package/src/shared/scripts/skf-extract-public-api.py +1309 -157
  20. package/src/shared/scripts/skf-extraction-inventory.py +580 -46
  21. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  22. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  23. package/src/shared/scripts/skf-github-probe.py +47 -8
  24. package/src/shared/scripts/skf-hash-content.py +14 -8
  25. package/src/shared/scripts/skf-load-provenance.py +3 -1
  26. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  27. package/src/shared/scripts/skf-preflight.py +15 -1
  28. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  29. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  30. package/src/shared/scripts/skf-structural-diff.py +133 -18
  31. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  32. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  33. package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
  34. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  35. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  36. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  37. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  38. package/src/skf-audit-skill/references/health-check.md +2 -0
  39. package/src/skf-audit-skill/references/init.md +2 -1
  40. package/src/skf-audit-skill/references/re-index.md +14 -9
  41. package/src/skf-audit-skill/references/report.md +2 -2
  42. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  43. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  44. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  45. package/src/skf-brief-skill/SKILL.md +1 -1
  46. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  47. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
  48. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  49. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  52. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  53. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  54. package/src/skf-create-skill/assets/skill-sections.md +6 -6
  55. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  56. package/src/skf-create-skill/references/compile.md +31 -60
  57. package/src/skf-create-skill/references/component-extraction.md +3 -3
  58. package/src/skf-create-skill/references/enrich.md +2 -2
  59. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  60. package/src/skf-create-skill/references/extract.md +22 -19
  61. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  62. package/src/skf-create-skill/references/extraction-patterns.md +8 -9
  63. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  64. package/src/skf-create-skill/references/report.md +16 -5
  65. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  66. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  67. package/src/skf-drop-skill/references/execute.md +5 -1
  68. package/src/skf-drop-skill/references/health-check.md +2 -0
  69. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  70. package/src/skf-drop-skill/references/report.md +2 -2
  71. package/src/skf-drop-skill/references/select.md +7 -3
  72. package/src/skf-test-skill/references/coherence-check.md +1 -1
  73. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  74. package/src/skf-test-skill/references/coverage-check.md +39 -57
  75. package/src/skf-test-skill/references/init.md +3 -5
  76. package/src/skf-test-skill/references/report.md +10 -8
  77. package/src/skf-test-skill/references/scoring-rules.md +6 -5
  78. package/src/skf-test-skill/references/source-access-protocol.md +14 -17
  79. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  80. package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
  82. package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
  83. package/src/skf-test-skill/scripts/score-signatures.py +36 -16
  84. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  85. package/src/skf-update-skill/references/detect-changes.md +32 -13
  86. package/src/skf-update-skill/references/gap-driven.md +3 -3
  87. package/src/skf-update-skill/references/init.md +6 -2
  88. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  89. package/src/skf-update-skill/references/merge.md +2 -2
  90. package/src/skf-update-skill/references/re-extract.md +2 -2
  91. package/src/skf-update-skill/references/report.md +3 -1
  92. package/src/skf-update-skill/references/write.md +43 -23
  93. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
@@ -85,11 +85,33 @@ cites (never read; default `metadata.json`):
85
85
  name the --stale result (classify-stale) marks `fabricated` is a
86
86
  Critical `fabricated-signature` gap `Fabricated signature:
87
87
  {name}` at its `source` (one with no `source` is INVALID_INPUT,
88
- never a lesser gap); any other is a Medium
89
- `stale-documentation` gap `Stale documentation: {name}` at the
90
- first line of SKILL.md, then of references/*.md in path order,
91
- that writes the name (validate-inventory.py's match, with
92
- --skill-dir), else `SKILL.md`. Scalar and stack branches: a
88
+ never a lesser gap). One whose `defined_at` (where the source
89
+ declares it) is a Python or TS/JS `file:line` is a documented
90
+ extra when its name has no dot and the --surface result was
91
+ built from an extraction that read the whole scope (an
92
+ `extraction` object, not `no-ast-grep`, `truncated` or
93
+ `fallback.needed`: only such an extraction fills
94
+ `excluded.outsideScope`, the names a brief scoped out) and its
95
+ `excluded.outsideScope` list does not list
96
+ it: an Info `observation` gap `Documented extra: {name}` at
97
+ `defined_at`. With no `defined_at`, a homonym (its `declared_in`
98
+ lists several declaring files) is one too, with --skill-dir,
99
+ when the skill's `[AST:path:L<n>]` and `[SRC:path:L<n>]`
100
+ citations on the lines of SKILL.md and references/**/*.md that
101
+ name it (outside the citations), or on the next non-blank line
102
+ after one, cite exactly one of those files: the gap
103
+ is at that file's `declared_in` item, and two cited or none
104
+ leave it Medium. The documented extra's issue names the line
105
+ that documents it (with --skill-dir) and its remediation asks no
106
+ change (update-skill never routes it) and says whether signature
107
+ scoring compared its documented signature (a name the
108
+ --signatures result lists in `comparedNames`). `excluded` is
109
+ read once a stale name that is not fabricated needs it, and a
110
+ malformed one is then INVALID_INPUT. Any other is a Medium `stale-documentation` gap
111
+ `Stale documentation: {name}` at the first line of SKILL.md,
112
+ then of references/*.md in path order, that writes the name
113
+ (validate-inventory.py's match, with --skill-dir), else
114
+ `SKILL.md`. Scalar and stack branches: a
93
115
  `missingCount` above 0 is one Medium `missing-export` gap
94
116
  `{missingCount} of {denominator} exports not documented` at
95
117
  `{meta}`, except that a scalar count taken from the verified
@@ -104,6 +126,18 @@ cites (never read; default `metadata.json`):
104
126
  the result's `inputs.brief` (else `skill-brief.yaml`), whose
105
127
  remediation recommends `stats.effective_denominator` when
106
128
  `guards.umbrella.umbrella` is true, else `scope.tier_a_include`.
129
+ `guards.staleScope.fires` is one Medium `brief-scope-stale` gap
130
+ at that brief, whose issue names each `unmatchedInclude` glob,
131
+ each `restored` name with its file and each
132
+ `unmatchedTierAInclude` glob. The deflation and inflation gaps
133
+ say the brief has no `scope.tier_a_include` when
134
+ `guards.tierAInclude` lists no glob, that no glob matches a file
135
+ when each is an `unmatchedTierAInclude` glob, and else that no
136
+ glob counts a public name (a surface without
137
+ `guards.tierAInclude` reads the `unmatchedTierAInclude` globs
138
+ alone). A stored `stats.effective_denominator` of 0 (`pct`
139
+ null) is written as a count against 0, not a percentage, and
140
+ its fix re-runs Create Skill to count it again.
107
141
  numerator --input verify-declared-numerator.py's result; --metadata.
108
142
  `inflated` true is a High `numerator-inflation` gap at `{meta}`
109
143
  whose issue lists the `absent` names.
@@ -186,6 +220,7 @@ import errno
186
220
  import importlib.util
187
221
  import json
188
222
  import os
223
+ import posixpath
189
224
  import re
190
225
  import secrets
191
226
  import sys
@@ -224,13 +259,15 @@ CATEGORIES: dict[str, tuple[str, str]] = {
224
259
  "missing-export": ("Coverage", "an exported function or class the skill does not document"),
225
260
  "signature-mismatch": ("Coverage", "a documented signature that differs from the source"),
226
261
  "fabricated-signature": ("Coverage", "a documented export that is absent at the cited source line"),
227
- "stale-documentation": ("Coverage", "documentation for an export the source no longer has"),
262
+ "stale-documentation": ("Coverage", "documentation for an export the enumerated source surface lacks"),
228
263
  "missing-type": ("Coverage", "a type or interface the skill does not document"),
229
264
  "provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
230
265
  "provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
231
266
  "provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
232
267
  "metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
233
268
  "denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
269
+ "brief-scope-stale": (
270
+ "Coverage", "a scope.include or scope.tier_a_include glob in the brief that matches no source file"),
234
271
  "numerator-inflation": (
235
272
  "Coverage",
236
273
  "a documented count equal to the denominator while declared exports are absent from the skill",
@@ -252,7 +289,7 @@ CATEGORIES: dict[str, tuple[str, str]] = {
252
289
  "a section, code fence, table or usage example in SKILL.md that is missing or wrong",
253
290
  ),
254
291
  "metadata": ("Structural", "missing optional metadata or examples"),
255
- "observation": ("Structural", "a style suggestion or another non-blocking observation"),
292
+ "observation": ("Structural", "a style suggestion, a documented extra or another non-blocking observation"),
256
293
  "discovery": ("Discovery", "a discovery test that misrouted prompts or did not run"),
257
294
  "description": ("Discovery", "a description whose triggers need optimizing"),
258
295
  "external-validator": ("External", "a skill-check diagnostic or a Tessl Review suggestion"),
@@ -527,18 +564,45 @@ def ledger_lock(path: Path, timeout: float | None = None):
527
564
  that the file it locked is still the one at the path: otherwise a waiter
528
565
  on the removed file and a newcomer on a new one would both get a lock.
529
566
  Windows removes no file that is open, so there the file goes after the
530
- release, unless a waiter has it open.
567
+ release, unless a waiter has it open. While the removal is under way (the
568
+ remover's own delete handle is open, or a virus scanner's or the search
569
+ indexer's), Windows refuses a new open of the file with PermissionError:
570
+ an append takes that for a held lock and tries again until the same
571
+ deadline, then raises LEDGER_LOCKED naming the last refusal. A refusal
572
+ with a folder at the path, or with nothing there on an immediate second
573
+ try, is a lasting denial and is raised as is.
531
574
  """
532
575
  timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
533
576
  lock_path = path.with_name(path.name + ".skf-lock")
534
577
  path.parent.mkdir(parents=True, exist_ok=True)
535
578
  deadline = time.monotonic() + timeout
536
579
 
537
- def refuse() -> LedgerError:
538
- return LedgerError("LEDGER_LOCKED", f"another append held {lock_path} for more than {timeout:g} s")
580
+ def refuse(cause: OSError | None = None) -> LedgerError:
581
+ message = f"another append held {lock_path} for more than {timeout:g} s"
582
+ if cause is not None:
583
+ message += f" (last open refused: {cause})"
584
+ return LedgerError("LEDGER_LOCKED", message)
539
585
 
586
+ vanished = False
540
587
  while True:
541
- fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
588
+ try:
589
+ fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
590
+ except PermissionError as exc:
591
+ # Windows refuses to open a file whose removal is pending. A folder
592
+ # at the path, or nothing there twice running (once can be the
593
+ # removal ending just now), is a lasting denial: raise it.
594
+ if not _IS_WINDOWS or os.path.isdir(lock_path):
595
+ raise
596
+ if not os.path.lexists(lock_path):
597
+ if vanished:
598
+ raise
599
+ vanished = True
600
+ continue
601
+ vanished = False
602
+ if time.monotonic() >= deadline:
603
+ raise refuse(exc) from exc
604
+ time.sleep(_LOCK_POLL_SECONDS)
605
+ continue
542
606
  try:
543
607
  while not _try_lock(fd):
544
608
  if time.monotonic() >= deadline:
@@ -761,6 +825,125 @@ def _documenting_line(skill_dir: Path, name: str) -> str | None:
761
825
  return None
762
826
 
763
827
 
828
+ def _outside_scope(surface: dict | None) -> set[str] | None:
829
+ """The names a surface result's `excluded.outsideScope` lists (defined in
830
+ a file the brief scopes out). None with no surface, no such list, or a
831
+ surface built without an extraction (no `extraction` object) or from
832
+ one that did not read the whole scope (a `no-ast-grep` status, a
833
+ `truncated` run, or `fallback.needed`): only an extraction fills that
834
+ list, so the names a brief scoped out are then unknown."""
835
+ excluded = (surface or {}).get("excluded")
836
+ if excluded is None:
837
+ return None
838
+ if not isinstance(excluded, dict):
839
+ raise AdapterError("--surface: 'excluded' must be an object")
840
+ rows = excluded.get("outsideScope")
841
+ if rows is None:
842
+ return None
843
+ if not isinstance(rows, list):
844
+ raise AdapterError("--surface: 'excluded.outsideScope' must be a list")
845
+ extraction = (surface or {}).get("extraction")
846
+ if not isinstance(extraction, dict):
847
+ return None
848
+ fallback = extraction.get("fallback")
849
+ if (extraction.get("status") == "no-ast-grep" or extraction.get("truncated")
850
+ or (isinstance(fallback, dict) and fallback.get("needed"))):
851
+ return None
852
+ return {row["name"] for row in rows if isinstance(row, dict) and isinstance(row.get("name"), str)}
853
+
854
+
855
+ # The extensions classify-stale reads a `defined_at` in.
856
+ # Keep identical to PYTHON_EXTENSIONS | TSJS_EXTENSIONS in skf-verify-provenance-completeness.py.
857
+ _DEFINED_AT_EXTENSIONS = frozenset({".py", ".pyi", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"})
858
+ # classify-stale's `defined_at`: a Python or TS/JS file and a line.
859
+ _DEFINED_AT_RE = re.compile(
860
+ r"^.+(?:" + "|".join(re.escape(e) for e in sorted(_DEFINED_AT_EXTENSIONS)) + r"):[1-9]\d*$", re.IGNORECASE)
861
+
862
+
863
+ # A source citation the skill writes, `[AST:path:L12]`, `[SRC:path:L12-34]` or `[SRC:path:L12-L34]` (one with no
864
+ # `:L<n>` part is ignored). Groups: prefix, path, first line, the `L` of a range's end, the range's end.
865
+ # Keep identical to _SKILL_CITATION_RE in skf-verify-provenance-completeness.py.
866
+ _SKILL_CITATION_RE = re.compile(
867
+ r"\[(AST|SRC):([^\[\]\n]+?):L(\d+)(?:-(L?)(\d+))?\]"
868
+ )
869
+ # Any bracketed citation (`[AST:...]`, `[SRC:...]`, `[EXT:...]`): a line names a documented name outside them.
870
+ _ANY_CITATION_RE = re.compile(r"\[[A-Z]+:[^\[\]\n]*\]")
871
+
872
+
873
+ def _cited_path(path: str) -> str:
874
+ """A cited or `declared_in` path as both compare: forward slashes, no `./`."""
875
+ path = path.strip().replace("\\", "/")
876
+ while path.startswith("./"):
877
+ path = path[2:]
878
+ return posixpath.normpath(path) if path else path
879
+
880
+
881
+ def skill_markdown_files(skill_dir: Path) -> list[Path]:
882
+ """`SKILL.md` then every `references/**/*.md`, in a stable order.
883
+ Keep identical to skill_markdown_files in skf-verify-provenance-completeness.py."""
884
+ files: list[Path] = []
885
+ skill_md = skill_dir / "SKILL.md"
886
+ if skill_md.is_file():
887
+ files.append(skill_md)
888
+ refs = skill_dir / "references"
889
+ if refs.is_dir():
890
+ files.extend(sorted(p for p in refs.rglob("*.md") if p.is_file()))
891
+ return files
892
+
893
+
894
+ def _skill_cited_files(skill_dir: Path, name: str) -> set[str]:
895
+ """The files the skill's `[AST:]` and `[SRC:]` citations name on a line of
896
+ SKILL.md or references/**/*.md that writes `name` outside its citations
897
+ (validate-inventory.py's match), or on the next non-blank line (a
898
+ heading, then its citation)."""
899
+ pattern = _sibling("validate-inventory.py").name_pattern(name)
900
+ cited: set[str] = set()
901
+ for path in skill_markdown_files(skill_dir):
902
+ try:
903
+ text = path.read_bytes().decode("utf-8-sig", errors="replace")
904
+ except OSError:
905
+ continue
906
+ lines = text.split("\n")
907
+ for number, line in enumerate(lines):
908
+ if not pattern.search(_ANY_CITATION_RE.sub(" ", line)):
909
+ continue
910
+ following = next((ln for ln in lines[number + 1:] if ln.strip()), "")
911
+ for cited_line in (line, following):
912
+ cited.update(_cited_path(m.group(2)) for m in _SKILL_CITATION_RE.finditer(cited_line))
913
+ return cited
914
+
915
+
916
+ def _documented_extra_at(name: str, item: dict, outside: set[str] | None,
917
+ skill_dir: str | None = None) -> tuple[str, int] | None:
918
+ """Where the source declares a stale name that is a documented extra, given
919
+ a surface built from an extraction whose `excluded.outsideScope` does not
920
+ list the name: (the item's `defined_at`, 0), or, with no `defined_at`,
921
+ (the one `declared_in` item whose file the skill cites on a line that
922
+ names it, the number of files `declared_in` lists) when it lists several.
923
+ None for anything else, which stays a Medium gap: no `defined_at` and no
924
+ single cited declaring file (two or more cited, none, or no --skill-dir),
925
+ a `defined_at` or `declared_in` item not a Python or TS/JS `file:line`, a
926
+ dotted name, no surface or one without an extraction, or a name a brief
927
+ scoped out."""
928
+ if outside is None or name in outside or "." in name:
929
+ return None
930
+ defined_at = item.get("defined_at")
931
+ if defined_at is not None:
932
+ if not isinstance(defined_at, str) or not _DEFINED_AT_RE.match(defined_at):
933
+ return None
934
+ return defined_at, 0
935
+ declared_in = item.get("declared_in")
936
+ if not skill_dir or not isinstance(declared_in, list) or not all(
937
+ isinstance(d, str) and _DEFINED_AT_RE.match(d) for d in declared_in):
938
+ return None
939
+ by_file = {_cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
940
+ if len(by_file) < 2:
941
+ return None
942
+ cited = _skill_cited_files(Path(skill_dir), name)
943
+ chosen = [d for f, d in sorted(by_file.items()) if f in cited]
944
+ return (chosen[0], len(by_file)) if len(chosen) == 1 else None
945
+
946
+
764
947
  def _missing_export(name: str, where: str | None, meta: str, kind: str = "export") -> dict:
765
948
  if where:
766
949
  read = f"read its definition at `{where}`"
@@ -787,12 +970,19 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
787
970
  branch = cov["branch"]
788
971
  records: list[dict] = []
789
972
  if branch == "enumerated":
790
- types = set()
973
+ types: set[str] = set()
974
+ # the names whose documented signature signature scoring compared
975
+ scored: set[str] = set()
791
976
  if signatures is not None:
792
- types = set(_names(_object(signatures, "--signatures (score-signatures.py)", "missingTypes"),
793
- "missingTypes", "--signatures"))
794
- where = _surface_sources(_object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
795
- if surface is not None else None)
977
+ signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
978
+ types = set(_names(signatures, "missingTypes", "--signatures"))
979
+ scored = set(_names(signatures, "comparedNames", "--signatures"))
980
+ if surface is not None:
981
+ surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
982
+ where = _surface_sources(surface)
983
+ # `excluded` is read once a stale name may be a documented extra
984
+ outside: set[str] | None = None
985
+ outside_read = False
796
986
  for name in _names(cov, "missing", "--input"):
797
987
  records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
798
988
  classified: dict[str, dict] = {}
@@ -815,7 +1005,28 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
815
1005
  "file does not define",
816
1006
  export=name))
817
1007
  continue
1008
+ if not outside_read:
1009
+ outside, outside_read = _outside_scope(surface), True
1010
+ extra = _documented_extra_at(name, item, outside, skill_dir)
818
1011
  doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1012
+ if extra:
1013
+ declared, among = extra
1014
+ # a homonym: the one declaring file the skill's own citations name
1015
+ picked = (f"; of the {among} files that declare it, the skill cites only "
1016
+ f"`{declared.rsplit(':', 1)[0]}`") if among else ""
1017
+ # never routed: update-skill leaves an `observation` alone. Signature scoring compares a
1018
+ # documented signature of a name on the surface's records (a nested sub-package's) alone.
1019
+ checked = ("Signature scoring compares its documented signature with the surface's record of it."
1020
+ if name in scored else
1021
+ "Its documented signature was not checked against that declaration.")
1022
+ records.append(_gap(
1023
+ "Info", "observation", f"Documented extra: {name}", declared,
1024
+ f"No change: `{name}` is a documented extra, a name the source still declares at "
1025
+ f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1026
+ issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1027
+ f"surface lacks and the source still declares{picked}",
1028
+ export=name))
1029
+ continue
819
1030
  records.append(_gap(
820
1031
  "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
821
1032
  f"Check `{name}` against the source and update or remove its documentation at "
@@ -847,7 +1058,7 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
847
1058
 
848
1059
 
849
1060
  def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
850
- """load-coverage-inputs.py surface's deflation and inflation guards."""
1061
+ """load-coverage-inputs.py surface's deflation, inflation and stale-scope guards."""
851
1062
  surface = _object(surface, "--input (load-coverage-inputs.py surface)", "guards", "inputs")
852
1063
  guards = surface["guards"]
853
1064
  if guards is None:
@@ -858,22 +1069,56 @@ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
858
1069
  meta = metadata or inputs.get("metadata") or _DEFAULT_METADATA
859
1070
  brief = inputs.get("brief") or _DEFAULT_BRIEF
860
1071
  records: list[dict] = []
1072
+ stale = guards.get("staleScope") or {}
1073
+ label = "--input (load-coverage-inputs.py surface): guards.staleScope"
1074
+ # A tier A glob that matches no file counts no name, and so does one that
1075
+ # matches only excluded files or files that define no name: when the
1076
+ # guards fire, the brief has no tier A glob, or none of its globs counts a
1077
+ # name. `tierAInclude` (the brief's tier A globs) is absent from a surface
1078
+ # older than #712, where only the stale globs say the brief has some.
1079
+ tier_a_globs = _names(stale, "unmatchedTierAInclude", label)
1080
+ brief_tier_a = list(dict.fromkeys(
1081
+ _names(guards, "tierAInclude", "--input (load-coverage-inputs.py surface): guards") + tier_a_globs))
1082
+ if not brief_tier_a:
1083
+ no_tier_a = "the brief has no `scope.tier_a_include`"
1084
+ elif set(brief_tier_a) <= set(tier_a_globs):
1085
+ no_tier_a = "no `scope.tier_a_include` glob in the brief matches a file"
1086
+ else:
1087
+ no_tier_a = "no `scope.tier_a_include` glob in the brief counts a public name"
861
1088
  deflation = guards.get("deflation") or {}
862
1089
  if deflation.get("fires") is True:
1090
+ if deflation.get("pct") is None: # a stored 0: no percentage above it
1091
+ above = (f" against a `stats.effective_denominator` of "
1092
+ f"{_count(deflation.get('effectiveDenominator')) or 0}")
1093
+ else:
1094
+ above = (f", {deflation.get('pct')}% above `stats.effective_denominator` "
1095
+ f"({deflation.get('effectiveDenominator')})")
1096
+ if deflation.get("pct") is None: # a 0 measures no tier: count it again
1097
+ first = ("Edit `scope.tier_a_include` in the brief so its globs match the files that define the "
1098
+ "authored tier" if brief_tier_a else "Check `scope.include` in the brief")
1099
+ fix = (f"{first}, then re-run Create Skill so `stats.effective_denominator` in `{meta}` is counted "
1100
+ "again.")
1101
+ elif brief_tier_a:
1102
+ fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
1103
+ "or edit `scope.tier_a_include` in the brief so its globs match the files of the authored tier "
1104
+ "the smaller count measures.")
1105
+ else:
1106
+ fix = (f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, "
1107
+ "or add `scope.tier_a_include` to the brief to name the authored tier the smaller count "
1108
+ "measures.")
863
1109
  records.append(_gap(
864
1110
  "Medium", "metadata-drift",
865
1111
  "denominator deflation: effective_denominator below source public surface without tier_a_include",
866
- meta,
867
- f"Set `stats.effective_denominator` in `{meta}` to the public surface the source re-derives, or "
868
- "add `scope.tier_a_include` to the brief to name the authored tier the smaller count measures.",
869
- issue=f"the re-derived surface counts {deflation.get('rederived')} exports, "
870
- f"{deflation.get('pct')}% above `stats.effective_denominator` "
871
- f"({deflation.get('effectiveDenominator')}), and the brief has no `scope.tier_a_include`"))
1112
+ meta, fix,
1113
+ issue=f"the re-derived surface counts {deflation.get('rederived')} exports{above}, and {no_tier_a}"))
872
1114
  inflation = guards.get("inflation") or {}
873
1115
  if inflation.get("fires") is True:
874
1116
  if (guards.get("umbrella") or {}).get("umbrella") is True:
875
1117
  fix = (f"Set `stats.effective_denominator` in `{meta}` to the authored surface: the root barrel "
876
1118
  "is an umbrella of re-exports, so a `scope.tier_a_include` glob would still count them.")
1119
+ elif brief_tier_a:
1120
+ fix = (f"Edit `scope.tier_a_include` in `{brief}` so its globs match the files of the authored "
1121
+ "surface: the denominator then counts it instead of the coarse `scope.include` union.")
877
1122
  else:
878
1123
  fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface, so "
879
1124
  "the denominator counts it instead of the coarse `scope.include` union.")
@@ -882,7 +1127,45 @@ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
882
1127
  "denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
883
1128
  issue=f"the `scope.include` union counts {inflation.get('scopeIncludeUnion')} exports, "
884
1129
  f"{inflation.get('pct')}% above the {inflation.get('provenanceEntries')} provenance entries, "
885
- "and the brief has no `scope.tier_a_include`"))
1130
+ f"and {no_tier_a}"))
1131
+ if stale.get("fires") is True:
1132
+ globs = _names(stale, "unmatchedInclude", label)
1133
+ restored = sorted({(r["name"], r["file"] if isinstance(r.get("file"), str) else "")
1134
+ for r in _list(stale, "restored", label)
1135
+ if isinstance(r, dict) and isinstance(r.get("name"), str) and r["name"]})
1136
+ names = ", ".join(f"`{name}` in `{file}`" if file else f"`{name}`" for name, file in restored)
1137
+ if len(globs) == 1:
1138
+ unmatched = f"the `scope.include` glob {_code(globs)} matches no file in the source tested"
1139
+ else:
1140
+ unmatched = f"the `scope.include` globs {_code(globs)} match no file in the source tested"
1141
+ if not restored:
1142
+ back = "no root export was restored"
1143
+ elif len(restored) == 1:
1144
+ back = f"the `all` set restores 1 root export, defined in a file no include glob covers: {names}"
1145
+ else:
1146
+ back = (f"the `all` set restores {len(restored)} root exports, defined in files no include glob "
1147
+ f"covers: {names}")
1148
+ if len(tier_a_globs) == 1:
1149
+ tier_a = (f"the `scope.tier_a_include` glob {_code(tier_a_globs)} matches no file in the source "
1150
+ "tested, so it counts no name")
1151
+ else:
1152
+ tier_a = (f"the `scope.tier_a_include` globs {_code(tier_a_globs)} match no file in the source "
1153
+ "tested, so they count no name")
1154
+ exclude = ", and add to `scope.exclude` any restored file the brief meant to leave out"
1155
+ if not tier_a_globs: # the include globs alone, as #677 wrote the gap
1156
+ lists, rewrite, clauses = ["scope.include"], "an include glob", [unmatched, back]
1157
+ elif not globs: # no include glob is stale, so nothing was restored
1158
+ lists, rewrite, clauses, exclude = ["scope.tier_a_include"], "a tier A glob", [tier_a], ""
1159
+ else:
1160
+ lists = ["scope.include", "scope.tier_a_include"]
1161
+ rewrite, clauses = "an include or tier A glob", [unmatched, back, tier_a]
1162
+ edit = " and ".join(f"`{key}`" for key in lists)
1163
+ records.append(_gap(
1164
+ "Medium", "brief-scope-stale", f"stale brief scope: {' and '.join(lists)} globs match no source file",
1165
+ brief,
1166
+ f"Edit {edit} in `{brief}` by hand so each glob matches the files it meant in this version of the "
1167
+ f"source (update-skill does not rewrite {rewrite}){exclude}.",
1168
+ issue="; ".join(clauses)))
886
1169
  return records
887
1170
 
888
1171
 
@@ -899,10 +1182,11 @@ def from_numerator(numerator: object, metadata: str | None = None) -> list[dict]
899
1182
  f"numerator inflation: {declared - verified} of {declared} declared exports absent from "
900
1183
  "SKILL.md/references",
901
1184
  meta,
902
- f"Document the absent exports in SKILL.md, or set `stats.exports_documented` in `{meta}` to the "
903
- f"{verified} exports the skill documents.",
904
- issue=f"`stats.exports_documented` equals the denominator, and these declared exports are absent "
905
- f"from SKILL.md and references/: {_code(absent)}")]
1185
+ f"Document the absent exports in SKILL.md or references/: the skill documents {verified} of its "
1186
+ f"{declared} declared exports.",
1187
+ issue=f"a declared count (`stats.exports_documented`, or the provenance map's distinct entry names) "
1188
+ f"equals `stats.effective_denominator`, and these declared exports are absent from SKILL.md and "
1189
+ f"references/: {_code(absent)}")]
906
1190
 
907
1191
 
908
1192
  def from_metadata_coherence(result: object, metadata: str | None = None) -> list[dict]:
@@ -1329,10 +1613,11 @@ def _build_parser() -> argparse.ArgumentParser:
1329
1613
  "(structure takes it more than once)")
1330
1614
  p.add_argument("--from", dest="adapter", choices=tuple(ADAPTER_OPTIONS),
1331
1615
  help="build the records from the --input result of a script (see the docstring's Adapters)")
1332
- p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds)")
1616
+ p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds, "
1617
+ "the names outside scope)")
1333
1618
  p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
1334
1619
  p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
1335
- p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures)")
1620
+ p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras)")
1336
1621
  p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
1337
1622
  p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
1338
1623
  p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")