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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/examples.md +1 -1
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +7 -3
- package/docs/workflows.md +3 -3
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +593 -47
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- package/src/shared/scripts/skf-extract-public-api.py +1309 -157
- package/src/shared/scripts/skf-extraction-inventory.py +580 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +6 -6
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +22 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +8 -9
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +16 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
- package/src/skf-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- package/src/skf-test-skill/references/coherence-check.md +1 -1
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +39 -57
- package/src/skf-test-skill/references/init.md +3 -5
- package/src/skf-test-skill/references/report.md +10 -8
- package/src/skf-test-skill/references/scoring-rules.md +6 -5
- package/src/skf-test-skill/references/source-access-protocol.md +14 -17
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
- package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
- package/src/skf-test-skill/scripts/score-signatures.py +36 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +3 -3
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +2 -2
- package/src/skf-update-skill/references/re-extract.md +2 -2
- package/src/skf-update-skill/references/report.md +3 -1
- package/src/skf-update-skill/references/write.md +43 -23
- 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)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
that
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
903
|
-
f"{
|
|
904
|
-
issue=f"`stats.exports_documented
|
|
905
|
-
f"from SKILL.md and
|
|
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)")
|