bmad-module-skill-forge 3.0.0 → 5.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/README.md +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/architecture.md +2 -2
- package/docs/concepts.md +4 -4
- package/docs/examples.md +33 -26
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +46 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +5 -5
- 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 +12 -4
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
- 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-count-tokens.py +54 -10
- 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-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
- package/src/shared/scripts/skf-extract-public-api.py +1673 -189
- package/src/shared/scripts/skf-extraction-inventory.py +584 -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-parse-gaps.py +135 -26
- 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-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- 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-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +2 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -2
- 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 +3 -3
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
- 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-campaign/SKILL.md +2 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +7 -7
- 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 +23 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +10 -11
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +21 -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-create-stack-skill/SKILL.md +2 -2
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +2 -2
- 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-export-skill/SKILL.md +2 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +6 -8
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-setup/SKILL.md +2 -2
- package/src/skf-test-skill/SKILL.md +2 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +5 -4
- package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +69 -111
- package/src/skf-test-skill/references/init.md +5 -6
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +11 -9
- package/src/skf-test-skill/references/scoring-rules.md +8 -6
- package/src/skf-test-skill/references/source-access-protocol.md +15 -18
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
- package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
- package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
- package/src/skf-test-skill/scripts/score-signatures.py +356 -61
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +2 -2
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +37 -22
- 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 +17 -12
- package/src/skf-update-skill/references/re-extract.md +4 -2
- package/src/skf-update-skill/references/report.md +7 -4
- package/src/skf-update-skill/references/write.md +77 -36
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
- package/src/skf-verify-stack/SKILL.md +2 -2
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -81,29 +81,107 @@ cites (never read; default `metadata.json`):
|
|
|
81
81
|
gap `Missing export: {name}`, or a Medium `missing-type` gap
|
|
82
82
|
`Missing type: {name}` when the --signatures result lists it in
|
|
83
83
|
`missingTypes`; its Source is the file and line the --surface
|
|
84
|
-
result's `exports[]` records for it, else `{meta}`.
|
|
84
|
+
result's `exports[]` records for it, else `{meta}`. When the
|
|
85
|
+
--surface result's `guards.inflation.fires`, its
|
|
86
|
+
`denominator.surfaceSet` is `scope.include` and its
|
|
87
|
+
`denominator.clause` is one whose ladder has a tier A rung
|
|
88
|
+
(stratified-scope, multi-entry), the `missing` names are
|
|
89
|
+
instead one Medium `denominator-inflation` gap `undocumented
|
|
90
|
+
names in the inflated scope.include union` at its
|
|
91
|
+
`inputs.brief` (else `skill-brief.yaml`), beside the guards
|
|
92
|
+
kind's gap at that brief: its issue gives the union's size
|
|
93
|
+
(`guards.inflation.scopeIncludeUnion`) and lists the first 50
|
|
94
|
+
names, each marked a type when the --signatures result lists
|
|
95
|
+
it in `missingTypes` and with the file and line `exports[]`
|
|
96
|
+
records for it, then how many more, and its remediation
|
|
97
|
+
corrects the denominator as the guard's gap says, then re-runs
|
|
98
|
+
Test Skill. Each `stale`
|
|
85
99
|
name the --stale result (classify-stale) marks `fabricated` is a
|
|
86
100
|
Critical `fabricated-signature` gap `Fabricated signature:
|
|
87
101
|
{name}` at its `source` (one with no `source` is INVALID_INPUT,
|
|
88
|
-
never a lesser gap)
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
that
|
|
92
|
-
|
|
102
|
+
never a lesser gap). One whose `defined_at` (where the source
|
|
103
|
+
declares it) is a Python or TS/JS `file:line` is a documented
|
|
104
|
+
extra when its name has no dot and the --surface result was
|
|
105
|
+
built from an extraction that read the whole scope (an
|
|
106
|
+
`extraction` object, not `no-ast-grep`, `truncated` or
|
|
107
|
+
`fallback.needed`: only such an extraction fills
|
|
108
|
+
`excluded.outsideScope`, the names a brief scoped out) and its
|
|
109
|
+
`excluded.outsideScope` list does not list
|
|
110
|
+
it: an Info `observation` gap `Documented extra: {name}` at
|
|
111
|
+
`defined_at`. With no `defined_at`, a homonym (its `declared_in`
|
|
112
|
+
lists several declaring files) is one too, with --skill-dir,
|
|
113
|
+
when the skill's `[AST:path:L<n>]` and `[SRC:path:L<n>]`
|
|
114
|
+
citations on the lines of SKILL.md and references/**/*.md that
|
|
115
|
+
name it (outside the citations), or on the next non-blank line
|
|
116
|
+
after one, cite exactly one of those files: the gap
|
|
117
|
+
is at that file's `declared_in` item, and two cited or none
|
|
118
|
+
leave it Medium. The documented extra's issue names the line
|
|
119
|
+
that documents it (with --skill-dir) and its remediation asks no
|
|
120
|
+
change (update-skill never routes it) and says whether signature
|
|
121
|
+
scoring compared its documented signature (a name the
|
|
122
|
+
--signatures result lists in `comparedNames`). `excluded` is
|
|
123
|
+
read once a stale name that is not fabricated needs it, and a
|
|
124
|
+
malformed one is then INVALID_INPUT. Any other is a Medium `stale-documentation` gap
|
|
125
|
+
`Stale documentation: {name}` at the first line of SKILL.md,
|
|
126
|
+
then of references/*.md in path order, that writes the name
|
|
127
|
+
(validate-inventory.py's match, with --skill-dir), else
|
|
128
|
+
`SKILL.md`. One whose `excluded.outsideScope` row says the brief
|
|
129
|
+
scopes its file out (`excludeGlob`, the `scope.exclude` glob
|
|
130
|
+
that matches it, or `outsideInclude`, no `scope.include` glob
|
|
131
|
+
covering it) has its own issue, naming the entry point and the
|
|
132
|
+
file and line, and remediation: to keep the documentation,
|
|
133
|
+
narrow that exclude glob (a removal needs a `demoted-exclude`
|
|
134
|
+
amendment) or widen `scope.include`, else remove it. A row the
|
|
135
|
+
brief does not explain keeps the wording above. Scalar and
|
|
136
|
+
stack branches: a
|
|
93
137
|
`missingCount` above 0 is one Medium `missing-export` gap
|
|
94
138
|
`{missingCount} of {denominator} exports not documented` at
|
|
95
139
|
`{meta}`, except that a scalar count taken from the verified
|
|
96
140
|
numerator (`numeratorSource` `verified`) is replaced by one
|
|
97
141
|
`Missing export: {name}` gap per --numerator `absent` name, at
|
|
98
|
-
the first --provenance entry of that name, else `{meta}`.
|
|
99
|
-
|
|
142
|
+
the first --provenance entry of that name, else `{meta}`. A
|
|
143
|
+
scalar result that counts its set (`numeratorSource` `surface`)
|
|
144
|
+
records the `missing` names it lists in place of the count gap,
|
|
145
|
+
as the barrel branch does (the inflated-union summary gap
|
|
146
|
+
under the same gate included), and, when `missingCount` exceeds
|
|
147
|
+
them, one Medium `missing-export` gap `{residual} of
|
|
148
|
+
{denominator} exports not documented beyond the {surfaceSet}
|
|
149
|
+
set` at `{meta}`. A scalar result with `staleApplicable` true
|
|
150
|
+
records its `stale` names as the barrel branch does. Every
|
|
151
|
+
per-name gap carries `export`.
|
|
100
152
|
guards --input load-coverage-inputs.py's surface result; --metadata.
|
|
101
153
|
`guards.deflation.fires` is a Medium `metadata-drift` gap at
|
|
102
|
-
`{meta}` (else the result's `inputs.metadata`)
|
|
103
|
-
`
|
|
104
|
-
the
|
|
105
|
-
|
|
106
|
-
|
|
154
|
+
`{meta}` (else the result's `inputs.metadata`): on the
|
|
155
|
+
`tier_a_include` basis (`guards.deflation.basis`) titled
|
|
156
|
+
`denominator drift: ...`, giving the tier A count, the pct and
|
|
157
|
+
the stored count, asking to recount by re-running Create Skill
|
|
158
|
+
(which stores the runner's count only when
|
|
159
|
+
`effective_denominator_decision.store` holds and `scope.notes`
|
|
160
|
+
documents the stratification, and otherwise drops the stored
|
|
161
|
+
count) and naming what `denominator` counts until then (its
|
|
162
|
+
`surfaceSet`, or the stored count on the scalar branch); else
|
|
163
|
+
`denominator deflation: ...`. `guards.inflation.fires` is a
|
|
164
|
+
Medium `denominator-inflation` gap at the result's
|
|
165
|
+
`inputs.brief` (else `skill-brief.yaml`), whose remediation adds
|
|
166
|
+
or edits `scope.tier_a_include`, listing the files rather than
|
|
167
|
+
the root barrel when `guards.umbrella.umbrella` is true. When
|
|
168
|
+
both fire on the `scope.include` basis, both remediations name
|
|
169
|
+
that one fix and say the `scope.include` union is itself
|
|
170
|
+
inflated. No remediation sets `stats.effective_denominator` by
|
|
171
|
+
hand, which Update Skill's recount drops.
|
|
172
|
+
`guards.staleScope.fires` is one Medium `brief-scope-stale` gap
|
|
173
|
+
at that brief, whose issue names each `unmatchedInclude` glob,
|
|
174
|
+
each `restored` name with its file and each
|
|
175
|
+
`unmatchedTierAInclude` glob. The `scope.include`-basis
|
|
176
|
+
deflation gap and the inflation gap say the brief has no
|
|
177
|
+
`scope.tier_a_include` when `guards.tierAInclude` lists no glob,
|
|
178
|
+
that no glob matches a file when each is an
|
|
179
|
+
`unmatchedTierAInclude` glob, and else that no glob counts a
|
|
180
|
+
public name (a surface without
|
|
181
|
+
`guards.tierAInclude` reads the `unmatchedTierAInclude` globs
|
|
182
|
+
alone). A stored `stats.effective_denominator` of 0 (`pct`
|
|
183
|
+
null) is written as a count against 0, not a percentage, and
|
|
184
|
+
its fix re-runs Create Skill to count it again.
|
|
107
185
|
numerator --input verify-declared-numerator.py's result; --metadata.
|
|
108
186
|
`inflated` true is a High `numerator-inflation` gap at `{meta}`
|
|
109
187
|
whose issue lists the `absent` names.
|
|
@@ -111,7 +189,10 @@ cites (never read; default `metadata.json`):
|
|
|
111
189
|
--input check-metadata-coherence.py's result; --metadata. Each
|
|
112
190
|
`findings[]` entry, titled as it is and with its `detail` as the
|
|
113
191
|
issue, at `{meta}`: Medium is a Medium `metadata-drift` gap, Info
|
|
114
|
-
an Info `multi-denominator` gap.
|
|
192
|
+
an Info `multi-denominator` gap. A `metadata-drift` gap whose
|
|
193
|
+
title names the documented surface explains the `Type::method`
|
|
194
|
+
entries its counts differ by; a barrel one sends the fix to
|
|
195
|
+
Create Skill, which rebuilds `exports[]` from the source.
|
|
115
196
|
provenance-line
|
|
116
197
|
--input skf-verify-provenance-completeness.py verify's result.
|
|
117
198
|
Each `stale[]` item whose reason is `line-not-definition`, at
|
|
@@ -224,13 +305,15 @@ CATEGORIES: dict[str, tuple[str, str]] = {
|
|
|
224
305
|
"missing-export": ("Coverage", "an exported function or class the skill does not document"),
|
|
225
306
|
"signature-mismatch": ("Coverage", "a documented signature that differs from the source"),
|
|
226
307
|
"fabricated-signature": ("Coverage", "a documented export that is absent at the cited source line"),
|
|
227
|
-
"stale-documentation": ("Coverage", "documentation for an export the source
|
|
308
|
+
"stale-documentation": ("Coverage", "documentation for an export the enumerated source surface lacks"),
|
|
228
309
|
"missing-type": ("Coverage", "a type or interface the skill does not document"),
|
|
229
310
|
"provenance-completeness": ("Coverage", "an export the skill documents that the provenance map lacks"),
|
|
230
311
|
"provenance-line": ("Coverage", "a provenance line that is not the definition of its export"),
|
|
231
312
|
"provenance-unverified": ("Coverage", "a provenance line the line-check rules could not verify"),
|
|
232
313
|
"metadata-drift": ("Coverage", "export counts in the metadata that diverge from each other"),
|
|
233
314
|
"denominator-inflation": ("Coverage", "a scope include union larger than the provenance map"),
|
|
315
|
+
"brief-scope-stale": (
|
|
316
|
+
"Coverage", "a scope.include or scope.tier_a_include glob in the brief that matches no source file"),
|
|
234
317
|
"numerator-inflation": (
|
|
235
318
|
"Coverage",
|
|
236
319
|
"a documented count equal to the denominator while declared exports are absent from the skill",
|
|
@@ -252,7 +335,7 @@ CATEGORIES: dict[str, tuple[str, str]] = {
|
|
|
252
335
|
"a section, code fence, table or usage example in SKILL.md that is missing or wrong",
|
|
253
336
|
),
|
|
254
337
|
"metadata": ("Structural", "missing optional metadata or examples"),
|
|
255
|
-
"observation": ("Structural", "a style suggestion or another non-blocking observation"),
|
|
338
|
+
"observation": ("Structural", "a style suggestion, a documented extra or another non-blocking observation"),
|
|
256
339
|
"discovery": ("Discovery", "a discovery test that misrouted prompts or did not run"),
|
|
257
340
|
"description": ("Discovery", "a description whose triggers need optimizing"),
|
|
258
341
|
"external-validator": ("External", "a skill-check diagnostic or a Tessl Review suggestion"),
|
|
@@ -527,18 +610,45 @@ def ledger_lock(path: Path, timeout: float | None = None):
|
|
|
527
610
|
that the file it locked is still the one at the path: otherwise a waiter
|
|
528
611
|
on the removed file and a newcomer on a new one would both get a lock.
|
|
529
612
|
Windows removes no file that is open, so there the file goes after the
|
|
530
|
-
release, unless a waiter has it open.
|
|
613
|
+
release, unless a waiter has it open. While the removal is under way (the
|
|
614
|
+
remover's own delete handle is open, or a virus scanner's or the search
|
|
615
|
+
indexer's), Windows refuses a new open of the file with PermissionError:
|
|
616
|
+
an append takes that for a held lock and tries again until the same
|
|
617
|
+
deadline, then raises LEDGER_LOCKED naming the last refusal. A refusal
|
|
618
|
+
with a folder at the path, or with nothing there on an immediate second
|
|
619
|
+
try, is a lasting denial and is raised as is.
|
|
531
620
|
"""
|
|
532
621
|
timeout = LOCK_TIMEOUT_SECONDS if timeout is None else timeout
|
|
533
622
|
lock_path = path.with_name(path.name + ".skf-lock")
|
|
534
623
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
535
624
|
deadline = time.monotonic() + timeout
|
|
536
625
|
|
|
537
|
-
def refuse() -> LedgerError:
|
|
538
|
-
|
|
626
|
+
def refuse(cause: OSError | None = None) -> LedgerError:
|
|
627
|
+
message = f"another append held {lock_path} for more than {timeout:g} s"
|
|
628
|
+
if cause is not None:
|
|
629
|
+
message += f" (last open refused: {cause})"
|
|
630
|
+
return LedgerError("LEDGER_LOCKED", message)
|
|
539
631
|
|
|
632
|
+
vanished = False
|
|
540
633
|
while True:
|
|
541
|
-
|
|
634
|
+
try:
|
|
635
|
+
fd = os.open(lock_path, os.O_WRONLY | os.O_CREAT, 0o644)
|
|
636
|
+
except PermissionError as exc:
|
|
637
|
+
# Windows refuses to open a file whose removal is pending. A folder
|
|
638
|
+
# at the path, or nothing there twice running (once can be the
|
|
639
|
+
# removal ending just now), is a lasting denial: raise it.
|
|
640
|
+
if not _IS_WINDOWS or os.path.isdir(lock_path):
|
|
641
|
+
raise
|
|
642
|
+
if not os.path.lexists(lock_path):
|
|
643
|
+
if vanished:
|
|
644
|
+
raise
|
|
645
|
+
vanished = True
|
|
646
|
+
continue
|
|
647
|
+
vanished = False
|
|
648
|
+
if time.monotonic() >= deadline:
|
|
649
|
+
raise refuse(exc) from exc
|
|
650
|
+
time.sleep(_LOCK_POLL_SECONDS)
|
|
651
|
+
continue
|
|
542
652
|
try:
|
|
543
653
|
while not _try_lock(fd):
|
|
544
654
|
if time.monotonic() >= deadline:
|
|
@@ -649,7 +759,8 @@ _SIBLINGS: dict[str, object] = {}
|
|
|
649
759
|
|
|
650
760
|
def _sibling(filename: str):
|
|
651
761
|
"""A script beside this one, loaded once: validate-inventory.py (the
|
|
652
|
-
documented-name match the stale-documentation Source uses
|
|
762
|
+
documented-name match the stale-documentation Source uses, and the
|
|
763
|
+
skill-citation scan the homonym pick reads)."""
|
|
653
764
|
module = _SIBLINGS.get(filename)
|
|
654
765
|
if module is None:
|
|
655
766
|
path = Path(__file__).resolve().parent / filename
|
|
@@ -761,6 +872,95 @@ def _documenting_line(skill_dir: Path, name: str) -> str | None:
|
|
|
761
872
|
return None
|
|
762
873
|
|
|
763
874
|
|
|
875
|
+
def _scoped_out_by(row: dict) -> bool:
|
|
876
|
+
"""Whether an `excluded.outsideScope` row says the brief scopes its file
|
|
877
|
+
out: a file, with the `scope.exclude` glob that matches it
|
|
878
|
+
(`excludeGlob`) or no `scope.include` glob covering it
|
|
879
|
+
(`outsideInclude`)."""
|
|
880
|
+
return (isinstance(row.get("file"), str) and bool(row["file"])
|
|
881
|
+
and ((isinstance(row.get("excludeGlob"), str) and bool(row["excludeGlob"]))
|
|
882
|
+
or row.get("outsideInclude") is True))
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _outside_scope(surface: dict | None) -> dict[str, dict] | None:
|
|
886
|
+
"""The names a surface result's `excluded.outsideScope` lists (defined in
|
|
887
|
+
a file out of scope), each with its row: the first the brief explains
|
|
888
|
+
(`_scoped_out_by`), else the first with a file, else its first. None
|
|
889
|
+
with no surface, no such list, or a surface built without an extraction
|
|
890
|
+
(no `extraction` object) or from one that did not read the whole scope
|
|
891
|
+
(a `no-ast-grep` status, a `truncated` run, or `fallback.needed`): only
|
|
892
|
+
an extraction fills that list, so the names a brief scoped out are then
|
|
893
|
+
unknown."""
|
|
894
|
+
excluded = (surface or {}).get("excluded")
|
|
895
|
+
if excluded is None:
|
|
896
|
+
return None
|
|
897
|
+
if not isinstance(excluded, dict):
|
|
898
|
+
raise AdapterError("--surface: 'excluded' must be an object")
|
|
899
|
+
rows = excluded.get("outsideScope")
|
|
900
|
+
if rows is None:
|
|
901
|
+
return None
|
|
902
|
+
if not isinstance(rows, list):
|
|
903
|
+
raise AdapterError("--surface: 'excluded.outsideScope' must be a list")
|
|
904
|
+
extraction = (surface or {}).get("extraction")
|
|
905
|
+
if not isinstance(extraction, dict):
|
|
906
|
+
return None
|
|
907
|
+
fallback = extraction.get("fallback")
|
|
908
|
+
if (extraction.get("status") == "no-ast-grep" or extraction.get("truncated")
|
|
909
|
+
or (isinstance(fallback, dict) and fallback.get("needed"))):
|
|
910
|
+
return None
|
|
911
|
+
def rank(row: dict) -> tuple[bool, bool]:
|
|
912
|
+
return _scoped_out_by(row), isinstance(row.get("file"), str) and bool(row["file"])
|
|
913
|
+
|
|
914
|
+
outside: dict[str, dict] = {}
|
|
915
|
+
for row in rows:
|
|
916
|
+
if isinstance(row, dict) and isinstance(row.get("name"), str):
|
|
917
|
+
known = outside.get(row["name"])
|
|
918
|
+
if known is None or rank(row) > rank(known):
|
|
919
|
+
outside[row["name"]] = row
|
|
920
|
+
return outside
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
# The extensions classify-stale reads a `defined_at` in.
|
|
924
|
+
# Keep identical to PYTHON_EXTENSIONS | TSJS_EXTENSIONS in skf-verify-provenance-completeness.py.
|
|
925
|
+
_DEFINED_AT_EXTENSIONS = frozenset({".py", ".pyi", ".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"})
|
|
926
|
+
# classify-stale's `defined_at`: a Python or TS/JS file and a line.
|
|
927
|
+
_DEFINED_AT_RE = re.compile(
|
|
928
|
+
r"^.+(?:" + "|".join(re.escape(e) for e in sorted(_DEFINED_AT_EXTENSIONS)) + r"):[1-9]\d*$", re.IGNORECASE)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _documented_extra_at(name: str, item: dict, outside: dict[str, dict] | None,
|
|
932
|
+
skill_dir: str | None = None) -> tuple[str, int] | None:
|
|
933
|
+
"""Where the source declares a stale name that is a documented extra, given
|
|
934
|
+
a surface built from an extraction whose `excluded.outsideScope` does not
|
|
935
|
+
list the name: (the item's `defined_at`, 0), or, with no `defined_at`,
|
|
936
|
+
(the one `declared_in` item whose file the skill cites on a line that
|
|
937
|
+
names it, the number of files `declared_in` lists) when it lists several.
|
|
938
|
+
None for anything else, which stays a Medium gap: no `defined_at` and no
|
|
939
|
+
single cited declaring file (two or more cited, none, or no --skill-dir),
|
|
940
|
+
a `defined_at` or `declared_in` item not a Python or TS/JS `file:line`, a
|
|
941
|
+
dotted name, no surface or one without an extraction, or a name a brief
|
|
942
|
+
scoped out."""
|
|
943
|
+
if outside is None or name in outside or "." in name:
|
|
944
|
+
return None
|
|
945
|
+
defined_at = item.get("defined_at")
|
|
946
|
+
if defined_at is not None:
|
|
947
|
+
if not isinstance(defined_at, str) or not _DEFINED_AT_RE.match(defined_at):
|
|
948
|
+
return None
|
|
949
|
+
return defined_at, 0
|
|
950
|
+
declared_in = item.get("declared_in")
|
|
951
|
+
if not skill_dir or not isinstance(declared_in, list) or not all(
|
|
952
|
+
isinstance(d, str) and _DEFINED_AT_RE.match(d) for d in declared_in):
|
|
953
|
+
return None
|
|
954
|
+
# validate-inventory.py's skill-citation scan, which score-signatures.py's plan reads too.
|
|
955
|
+
citations = _sibling("validate-inventory.py")
|
|
956
|
+
by_file = {citations._cited_path(d.rsplit(":", 1)[0]): d for d in declared_in}
|
|
957
|
+
if len(by_file) < 2:
|
|
958
|
+
return None
|
|
959
|
+
cited = citations._skill_cited_files(Path(skill_dir), name)
|
|
960
|
+
chosen = [d for f, d in sorted(by_file.items()) if f in cited]
|
|
961
|
+
return (chosen[0], len(by_file)) if len(chosen) == 1 else None
|
|
962
|
+
|
|
963
|
+
|
|
764
964
|
def _missing_export(name: str, where: str | None, meta: str, kind: str = "export") -> dict:
|
|
765
965
|
if where:
|
|
766
966
|
read = f"read its definition at `{where}`"
|
|
@@ -778,6 +978,196 @@ def _missing_export(name: str, where: str | None, meta: str, kind: str = "export
|
|
|
778
978
|
export=name)
|
|
779
979
|
|
|
780
980
|
|
|
981
|
+
def _brief_of(surface: dict | None) -> str:
|
|
982
|
+
"""The brief a surface result read: its `inputs.brief`, else skill-brief.yaml."""
|
|
983
|
+
inputs = (surface or {}).get("inputs")
|
|
984
|
+
brief = inputs.get("brief") if isinstance(inputs, dict) else None
|
|
985
|
+
return brief if isinstance(brief, str) and brief else _DEFAULT_BRIEF
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
# The most names the issue of the inflated-union summary gap lists (#795).
|
|
989
|
+
_INFLATED_NAMES_LISTED = 50
|
|
990
|
+
# The clauses whose ladder has a tier A rung, so that a brief's `scope.tier_a_include` corrects the
|
|
991
|
+
# denominator an inflated `scope.include` union gave them (#795).
|
|
992
|
+
# Keep identical to the CLAUSE_LADDERS clauses with a `tier_a_include` rung in load-coverage-inputs.py.
|
|
993
|
+
_TIER_A_CLAUSES = frozenset({"stratified-scope", "multi-entry"})
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def _inflated_union(surface: dict | None) -> bool:
|
|
997
|
+
"""Whether the barrel branch counts an inflated `scope.include` union a
|
|
998
|
+
tier A list corrects: the surface's inflation guard fires, its
|
|
999
|
+
`denominator.surfaceSet` is `scope.include` and its `denominator.clause`
|
|
1000
|
+
has a tier A rung. A set the surface lacked (`absentSet`) counted `all`
|
|
1001
|
+
in its place, and the specific-modules and empty-barrel clauses count
|
|
1002
|
+
`scope.include` whatever tier A list the brief has: each name there
|
|
1003
|
+
stays its own gap."""
|
|
1004
|
+
guards = (surface or {}).get("guards")
|
|
1005
|
+
inflation = guards.get("inflation") if isinstance(guards, dict) else None
|
|
1006
|
+
denominator = (surface or {}).get("denominator")
|
|
1007
|
+
return (isinstance(inflation, dict) and inflation.get("fires") is True and isinstance(denominator, dict)
|
|
1008
|
+
and denominator.get("surfaceSet") == "scope.include" and denominator.get("clause") in _TIER_A_CLAUSES)
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def _inflated_union_gap(names: list[str], where: dict[str, str], types: set[str], brief: str,
|
|
1012
|
+
union: object) -> dict:
|
|
1013
|
+
"""One gap at the brief for the names an inflated `scope.include` union
|
|
1014
|
+
counts and the skill does not document, in place of a gap per name: the
|
|
1015
|
+
union sweeps in infrastructure, so most of them belong to no authored
|
|
1016
|
+
surface (#795). It sits beside the guard's own `denominator-inflation`
|
|
1017
|
+
gap at that brief, whose fix it defers to."""
|
|
1018
|
+
def label(name: str) -> str:
|
|
1019
|
+
notes = (["a type"] if name in types else []) + ([f"`{where[name]}`"] if where.get(name) else [])
|
|
1020
|
+
return f"`{name}` ({', '.join(notes)})" if notes else f"`{name}`"
|
|
1021
|
+
|
|
1022
|
+
listed = [label(n) for n in names[:_INFLATED_NAMES_LISTED]]
|
|
1023
|
+
more = len(names) - len(listed)
|
|
1024
|
+
named = ", ".join(listed) + (f", and {more} more" if more else "")
|
|
1025
|
+
size = f" ({union} names)" if _count(union) is not None else ""
|
|
1026
|
+
return _gap(
|
|
1027
|
+
"Medium", "denominator-inflation", "undocumented names in the inflated scope.include union", brief,
|
|
1028
|
+
f"Correct the denominator first, as the denominator-inflation gap for `{brief}` says; then re-run Test "
|
|
1029
|
+
"Skill, and the names the corrected denominator counts that the skill does not document come back as gaps.",
|
|
1030
|
+
issue=f"the inflation guard fired: the `scope.include` union the denominator counts{size} sweeps in names "
|
|
1031
|
+
f"outside the authored surface, and SKILL.md and references/ do not document {len(names)} of them: "
|
|
1032
|
+
f"{named}")
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def _scoped_out_gap(name: str, row: dict, doc: str | None, brief: str) -> dict:
|
|
1036
|
+
"""The Medium `stale-documentation` gap of a documented name an entry
|
|
1037
|
+
point exports from a file the brief scopes out (#796): its row names
|
|
1038
|
+
the `scope.exclude` glob that matches the file, or says no
|
|
1039
|
+
`scope.include` glob covers it, or both."""
|
|
1040
|
+
file, line, entry = row["file"], _count(row.get("line")), row.get("entry")
|
|
1041
|
+
at = f"{file}:{line}" if line else file
|
|
1042
|
+
glob = row.get("excludeGlob") if isinstance(row.get("excludeGlob"), str) and row.get("excludeGlob") else None
|
|
1043
|
+
uncovered = row.get("outsideInclude") is True
|
|
1044
|
+
causes = (["no `scope.include` glob covers that file"] if uncovered else []) + (
|
|
1045
|
+
[f"its `scope.exclude` glob `{glob}` matches {'it' if uncovered else 'that file'}"] if glob else [])
|
|
1046
|
+
fixes = (["widen `scope.include` to cover that file"] if uncovered else []) + (
|
|
1047
|
+
[f"narrow `{glob}` so it no longer matches that file (to remove the glob instead, record a `demoted-exclude` "
|
|
1048
|
+
"scope-expansion amendment in the brief)"] if glob else [])
|
|
1049
|
+
who = f"the entry point `{entry}`" if isinstance(entry, str) and entry else "an entry point"
|
|
1050
|
+
return _gap(
|
|
1051
|
+
"Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
|
|
1052
|
+
f"Decide whether `{name}` belongs on the surface: `{brief}` scopes out `{at}`, where it is defined, since "
|
|
1053
|
+
f"{' and '.join(causes)}. To keep its documentation, {' and '.join(fixes)}; else remove the documentation "
|
|
1054
|
+
f"of `{name}` at {doc or 'SKILL.md'}.",
|
|
1055
|
+
issue=f"{f'`{doc}`' if doc else 'SKILL.md'} documents `{name}`, which {who} exports and `{at}` defines, a "
|
|
1056
|
+
f"file `{brief}` scopes out (`excluded.outsideScope`)",
|
|
1057
|
+
export=name)
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def _named_inputs(surface: object, signatures: object) -> tuple[dict | None, set[str], set[str]]:
|
|
1061
|
+
"""The --surface result, the names --signatures lists in `missingTypes` and the names whose documented
|
|
1062
|
+
signature it compared (`comparedNames`), for a result that names its missing and stale exports."""
|
|
1063
|
+
types: set[str] = set()
|
|
1064
|
+
scored: set[str] = set()
|
|
1065
|
+
if signatures is not None:
|
|
1066
|
+
signatures = _object(signatures, "--signatures (score-signatures.py)", "missingTypes")
|
|
1067
|
+
types = set(_names(signatures, "missingTypes", "--signatures"))
|
|
1068
|
+
scored = set(_names(signatures, "comparedNames", "--signatures"))
|
|
1069
|
+
if surface is not None:
|
|
1070
|
+
surface = _object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
|
|
1071
|
+
return surface, types, scored
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _missing_gaps(cov: dict, surface: dict | None, types: set[str], meta: str) -> list[dict]:
|
|
1075
|
+
"""One gap per `missing` name, at the file and line the surface records for it, or, when the surface counts
|
|
1076
|
+
an inflated `scope.include` union a tier A list corrects (`_inflated_union`), one summary gap at the brief in
|
|
1077
|
+
their place (#795)."""
|
|
1078
|
+
where = _surface_sources(surface)
|
|
1079
|
+
missing = _names(cov, "missing", "--input")
|
|
1080
|
+
if missing and _inflated_union(surface):
|
|
1081
|
+
union = ((surface.get("guards") or {}).get("inflation") or {}).get("scopeIncludeUnion")
|
|
1082
|
+
return [_inflated_union_gap(missing, where, types, _brief_of(surface), union)]
|
|
1083
|
+
return [_missing_export(name, where.get(name), meta, "type" if name in types else "export") for name in missing]
|
|
1084
|
+
|
|
1085
|
+
|
|
1086
|
+
def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str],
|
|
1087
|
+
skill_dir: str | None) -> list[dict]:
|
|
1088
|
+
"""One gap per `stale` name: fabricated, a documented extra, or stale documentation (with its own wording
|
|
1089
|
+
for a name defined in a file the brief scopes out, #796)."""
|
|
1090
|
+
records: list[dict] = []
|
|
1091
|
+
# `excluded` is read once a stale name may be a documented extra
|
|
1092
|
+
outside: dict[str, dict] | None = None
|
|
1093
|
+
outside_read = False
|
|
1094
|
+
classified: dict[str, dict] = {}
|
|
1095
|
+
if stale is not None:
|
|
1096
|
+
if not isinstance(stale, list):
|
|
1097
|
+
raise AdapterError("--stale is not classify-stale JSON (a list)")
|
|
1098
|
+
classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
|
|
1099
|
+
for name in _names(cov, "stale", "--input"):
|
|
1100
|
+
item = classified.get(name) or {}
|
|
1101
|
+
cited = item.get("source")
|
|
1102
|
+
if item.get("fabricated") is True:
|
|
1103
|
+
if not isinstance(cited, str) or not cited:
|
|
1104
|
+
# a Critical gap needs the line it cites: never downgrade it
|
|
1105
|
+
raise AdapterError(f"--stale: {name!r} is fabricated with no source")
|
|
1106
|
+
records.append(_gap(
|
|
1107
|
+
"Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
|
|
1108
|
+
f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
|
|
1109
|
+
f"defines: no file its provenance cites defines it (`{cited}`).",
|
|
1110
|
+
issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
|
|
1111
|
+
"file does not define",
|
|
1112
|
+
export=name))
|
|
1113
|
+
continue
|
|
1114
|
+
if not outside_read:
|
|
1115
|
+
outside, outside_read = _outside_scope(surface), True
|
|
1116
|
+
extra = _documented_extra_at(name, item, outside, skill_dir)
|
|
1117
|
+
doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
|
|
1118
|
+
if extra:
|
|
1119
|
+
declared, among = extra
|
|
1120
|
+
# a homonym: the one declaring file the skill's own citations name
|
|
1121
|
+
picked = (f"; of the {among} files that declare it, the skill cites only "
|
|
1122
|
+
f"`{declared.rsplit(':', 1)[0]}`") if among else ""
|
|
1123
|
+
# never routed: update-skill leaves an `observation` alone. Signature scoring compares a
|
|
1124
|
+
# documented signature of a name on the surface's records (a nested sub-package's) alone.
|
|
1125
|
+
checked = ("Signature scoring compares its documented signature with the surface's record of it."
|
|
1126
|
+
if name in scored else
|
|
1127
|
+
"Its documented signature was not checked against that declaration.")
|
|
1128
|
+
records.append(_gap(
|
|
1129
|
+
"Info", "observation", f"Documented extra: {name}", declared,
|
|
1130
|
+
f"No change: `{name}` is a documented extra, a name the source still declares at "
|
|
1131
|
+
f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
|
|
1132
|
+
issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
|
|
1133
|
+
f"surface lacks and the source still declares{picked}",
|
|
1134
|
+
export=name))
|
|
1135
|
+
continue
|
|
1136
|
+
if outside is not None and name in outside and _scoped_out_by(outside[name]):
|
|
1137
|
+
# an entry point exports it from a file the brief scopes out (#796)
|
|
1138
|
+
records.append(_scoped_out_gap(name, outside[name], doc, _brief_of(surface)))
|
|
1139
|
+
continue
|
|
1140
|
+
records.append(_gap(
|
|
1141
|
+
"Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
|
|
1142
|
+
f"Check `{name}` against the source and update or remove its documentation at "
|
|
1143
|
+
f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
|
|
1144
|
+
issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
|
|
1145
|
+
export=name))
|
|
1146
|
+
return records
|
|
1147
|
+
|
|
1148
|
+
|
|
1149
|
+
def _residual_gap(cov: dict, meta: str) -> list[dict]:
|
|
1150
|
+
"""A counted set's residual: the exports the stored count counts beyond the names the set lists, one gap
|
|
1151
|
+
when `missingCount` exceeds the listed `missing` names."""
|
|
1152
|
+
listed = len(_names(cov, "missing", "--input"))
|
|
1153
|
+
missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
|
|
1154
|
+
residual = (missing or 0) - listed
|
|
1155
|
+
if residual <= 0:
|
|
1156
|
+
return []
|
|
1157
|
+
set_name = cov.get("surfaceSet") if isinstance(cov.get("surfaceSet"), str) else "named"
|
|
1158
|
+
size = _count(cov.get("surfaceSetSize"))
|
|
1159
|
+
names = f"{size} names" if size is not None else "names"
|
|
1160
|
+
return [_gap(
|
|
1161
|
+
"Medium", "missing-export", f"{residual} of {denominator} exports not documented beyond the {set_name} set",
|
|
1162
|
+
meta,
|
|
1163
|
+
f"Find the {residual} exports the stored count (`stats.effective_denominator`) counts beyond the {names} "
|
|
1164
|
+
f"of the `{set_name}` set (`{meta}` lists the exports) and document each with its signature; a stale "
|
|
1165
|
+
"stored count is recounted by re-running Create Skill.",
|
|
1166
|
+
issue=f"of the {denominator} exports the stored count counts, {missing} are not documented: the "
|
|
1167
|
+
f"`{set_name}` set names {listed} of them, and the other {residual} are exports the count holds "
|
|
1168
|
+
"beyond the set's names")]
|
|
1169
|
+
|
|
1170
|
+
|
|
781
1171
|
def from_coverage(cov: object, surface: object = None, signatures: object = None, numerator: object = None,
|
|
782
1172
|
stale: object = None, provenance: object = None, skill_dir: str | None = None,
|
|
783
1173
|
metadata: str | None = None) -> list[dict]:
|
|
@@ -787,42 +1177,16 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
|
|
|
787
1177
|
branch = cov["branch"]
|
|
788
1178
|
records: list[dict] = []
|
|
789
1179
|
if branch == "enumerated":
|
|
790
|
-
types =
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
"missingTypes", "--signatures"))
|
|
794
|
-
where = _surface_sources(_object(surface, "--surface (load-coverage-inputs.py surface)", "exports")
|
|
795
|
-
if surface is not None else None)
|
|
796
|
-
for name in _names(cov, "missing", "--input"):
|
|
797
|
-
records.append(_missing_export(name, where.get(name), meta, "type" if name in types else "export"))
|
|
798
|
-
classified: dict[str, dict] = {}
|
|
799
|
-
if stale is not None:
|
|
800
|
-
if not isinstance(stale, list):
|
|
801
|
-
raise AdapterError("--stale is not classify-stale JSON (a list)")
|
|
802
|
-
classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
|
|
803
|
-
for name in _names(cov, "stale", "--input"):
|
|
804
|
-
item = classified.get(name) or {}
|
|
805
|
-
cited = item.get("source")
|
|
806
|
-
if item.get("fabricated") is True:
|
|
807
|
-
if not isinstance(cited, str) or not cited:
|
|
808
|
-
# a Critical gap needs the line it cites: never downgrade it
|
|
809
|
-
raise AdapterError(f"--stale: {name!r} is fabricated with no source")
|
|
810
|
-
records.append(_gap(
|
|
811
|
-
"Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
|
|
812
|
-
f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
|
|
813
|
-
f"defines: no file its provenance cites defines it (`{cited}`).",
|
|
814
|
-
issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
|
|
815
|
-
"file does not define",
|
|
816
|
-
export=name))
|
|
817
|
-
continue
|
|
818
|
-
doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
|
|
819
|
-
records.append(_gap(
|
|
820
|
-
"Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
|
|
821
|
-
f"Check `{name}` against the source and update or remove its documentation at "
|
|
822
|
-
f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
|
|
823
|
-
issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
|
|
824
|
-
export=name))
|
|
1180
|
+
surface, types, scored = _named_inputs(surface, signatures)
|
|
1181
|
+
records += _missing_gaps(cov, surface, types, meta)
|
|
1182
|
+
records += _stale_gaps(cov, surface, stale, scored, skill_dir)
|
|
825
1183
|
elif branch in ("scalar", "stack"):
|
|
1184
|
+
# A scalar result that counts its set lists its missing names, and one on a surface read from the
|
|
1185
|
+
# source its stale names: each is recorded as the barrel branch's are.
|
|
1186
|
+
counted = branch == "scalar" and cov.get("numeratorSource") == "surface"
|
|
1187
|
+
stale_named = branch == "scalar" and cov.get("staleApplicable") is True
|
|
1188
|
+
if counted or stale_named:
|
|
1189
|
+
surface, types, scored = _named_inputs(surface, signatures)
|
|
826
1190
|
if branch == "scalar" and cov.get("numeratorSource") == "verified":
|
|
827
1191
|
if numerator is None:
|
|
828
1192
|
raise AdapterError("the scalar count used the verified numerator: pass its result with "
|
|
@@ -832,6 +1196,9 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
|
|
|
832
1196
|
where = _provenance_sources(_object(provenance, "--provenance (provenance map)", "entries")
|
|
833
1197
|
if provenance is not None else None)
|
|
834
1198
|
records += [_missing_export(name, where.get(name), meta) for name in absent]
|
|
1199
|
+
elif counted:
|
|
1200
|
+
records += _missing_gaps(cov, surface, types, meta)
|
|
1201
|
+
records += _residual_gap(cov, meta)
|
|
835
1202
|
else:
|
|
836
1203
|
missing, denominator = _count(cov.get("missingCount")), _count(cov.get("denominator"))
|
|
837
1204
|
if missing:
|
|
@@ -841,13 +1208,15 @@ def from_coverage(cov: object, surface: object = None, signatures: object = None
|
|
|
841
1208
|
f"name (`{meta}` lists the exports) and document each with its signature.",
|
|
842
1209
|
issue=f"SKILL.md and references/ name {cov.get('documented')} of the {denominator} exports "
|
|
843
1210
|
f"the {branch} denominator counts"))
|
|
1211
|
+
if stale_named:
|
|
1212
|
+
records += _stale_gaps(cov, surface, stale, scored, skill_dir)
|
|
844
1213
|
elif branch != "docsOnly":
|
|
845
1214
|
raise AdapterError(f"--input (reconcile-coverage.py): unknown branch {branch!r}")
|
|
846
1215
|
return records
|
|
847
1216
|
|
|
848
1217
|
|
|
849
1218
|
def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
|
|
850
|
-
"""load-coverage-inputs.py surface's deflation and
|
|
1219
|
+
"""load-coverage-inputs.py surface's deflation, inflation and stale-scope guards."""
|
|
851
1220
|
surface = _object(surface, "--input (load-coverage-inputs.py surface)", "guards", "inputs")
|
|
852
1221
|
guards = surface["guards"]
|
|
853
1222
|
if guards is None:
|
|
@@ -858,31 +1227,147 @@ def from_guards(surface: object, metadata: str | None = None) -> list[dict]:
|
|
|
858
1227
|
meta = metadata or inputs.get("metadata") or _DEFAULT_METADATA
|
|
859
1228
|
brief = inputs.get("brief") or _DEFAULT_BRIEF
|
|
860
1229
|
records: list[dict] = []
|
|
1230
|
+
stale = guards.get("staleScope") or {}
|
|
1231
|
+
label = "--input (load-coverage-inputs.py surface): guards.staleScope"
|
|
1232
|
+
# A tier A glob that matches no file counts no name, and so does one that
|
|
1233
|
+
# matches only excluded files or files that define no name: when the
|
|
1234
|
+
# guards fire, the brief has no tier A glob, or none of its globs counts a
|
|
1235
|
+
# name. `tierAInclude` (the brief's tier A globs) is absent from a surface
|
|
1236
|
+
# older than #712, where only the stale globs say the brief has some.
|
|
1237
|
+
tier_a_globs = _names(stale, "unmatchedTierAInclude", label)
|
|
1238
|
+
brief_tier_a = list(dict.fromkeys(
|
|
1239
|
+
_names(guards, "tierAInclude", "--input (load-coverage-inputs.py surface): guards") + tier_a_globs))
|
|
1240
|
+
if not brief_tier_a:
|
|
1241
|
+
no_tier_a = "the brief has no `scope.tier_a_include`"
|
|
1242
|
+
elif set(brief_tier_a) <= set(tier_a_globs):
|
|
1243
|
+
no_tier_a = "no `scope.tier_a_include` glob in the brief matches a file"
|
|
1244
|
+
else:
|
|
1245
|
+
no_tier_a = "no `scope.tier_a_include` glob in the brief counts a public name"
|
|
861
1246
|
deflation = guards.get("deflation") or {}
|
|
862
|
-
|
|
1247
|
+
inflation = guards.get("inflation") or {}
|
|
1248
|
+
stored = _count(deflation.get("effectiveDenominator")) or 0
|
|
1249
|
+
# No remediation sets `stats.effective_denominator` by hand: Update Skill's recount drops such a value (#803).
|
|
1250
|
+
# Both guards on the scope.include union name one fix, the brief's tier A list (#788).
|
|
1251
|
+
both = (deflation.get("fires") is True and inflation.get("fires") is True
|
|
1252
|
+
and deflation.get("basis") != "tier_a_include")
|
|
1253
|
+
# On an umbrella barrel the names are re-exports, each counted at the file that defines it, never the barrel.
|
|
1254
|
+
umbrella = (" rather than the root barrel, an umbrella of re-exports"
|
|
1255
|
+
if (guards.get("umbrella") or {}).get("umbrella") is True else "")
|
|
1256
|
+
verb = (f"Edit `scope.tier_a_include` in `{brief}` so its globs list" if brief_tier_a
|
|
1257
|
+
else f"Add `scope.tier_a_include` to `{brief}`, listing")
|
|
1258
|
+
# Only Create Skill always writes metadata.json, and it stores the runner's count only under this rule.
|
|
1259
|
+
stores = ("when `effective_denominator_decision.store` holds and the brief's `scope.notes` documents the "
|
|
1260
|
+
"stratification, and otherwise drops the stored count")
|
|
1261
|
+
joint = (f"{verb} the files that define the authored names{umbrella}: the `scope.include` union is itself "
|
|
1262
|
+
"inflated, so neither it nor the smaller stored count measures the authored surface. Then re-run "
|
|
1263
|
+
f"Create Skill to recount `stats.effective_denominator` in `{meta}` from the tier A set: it stores "
|
|
1264
|
+
f"that count {stores}.")
|
|
1265
|
+
if deflation.get("fires") is True and deflation.get("basis") == "tier_a_include":
|
|
1266
|
+
# A stored count the brief's tier A set disagrees with, either way: stale, so count it again (#813).
|
|
1267
|
+
pct = deflation.get("pct")
|
|
1268
|
+
if pct is None: # a stored 0: no percentage against it
|
|
1269
|
+
against = f" against a `stats.effective_denominator` of {stored}"
|
|
1270
|
+
else:
|
|
1271
|
+
below = isinstance(pct, (int, float)) and pct < 0
|
|
1272
|
+
against = (f", {-pct if below else pct}% {'below' if below else 'above'} `stats.effective_denominator` "
|
|
1273
|
+
f"({deflation.get('effectiveDenominator')})")
|
|
1274
|
+
# Until then: the set the denominator actually counts (a ladder with no tier A rung counts scope.include),
|
|
1275
|
+
# or, on an incomplete extraction's tier A set below the stored count, the stored count.
|
|
1276
|
+
denominator = surface.get("denominator") if isinstance(surface.get("denominator"), dict) else {}
|
|
1277
|
+
extraction = surface.get("extraction") if isinstance(surface.get("extraction"), dict) else {}
|
|
1278
|
+
if denominator.get("branch") == "scalar":
|
|
1279
|
+
until = " Until then the stored count stays the denominator" + (
|
|
1280
|
+
": the extraction is incomplete, so the tier A set can miss names." if extraction.get("status")
|
|
1281
|
+
== "incomplete" else ".")
|
|
1282
|
+
elif isinstance(denominator.get("surfaceSet"), str):
|
|
1283
|
+
until = f" Until then Test Skill counts the `{denominator['surfaceSet']}` set as the denominator."
|
|
1284
|
+
else:
|
|
1285
|
+
until = ""
|
|
1286
|
+
records.append(_gap(
|
|
1287
|
+
"Medium", "metadata-drift",
|
|
1288
|
+
"denominator drift: effective_denominator disagrees with the brief's tier_a_include set", meta,
|
|
1289
|
+
f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}`: it stores the runner's count "
|
|
1290
|
+
f"of the brief's `scope.tier_a_include` set {stores}.{until}",
|
|
1291
|
+
issue=f"the brief's `scope.tier_a_include` set counts {deflation.get('rederived')} exports{against}"))
|
|
1292
|
+
elif deflation.get("fires") is True:
|
|
1293
|
+
if deflation.get("pct") is None: # a stored 0: no percentage above it
|
|
1294
|
+
above = f" against a `stats.effective_denominator` of {stored}"
|
|
1295
|
+
else:
|
|
1296
|
+
above = (f", {deflation.get('pct')}% above `stats.effective_denominator` "
|
|
1297
|
+
f"({deflation.get('effectiveDenominator')})")
|
|
1298
|
+
if both:
|
|
1299
|
+
fix = joint
|
|
1300
|
+
elif deflation.get("pct") is None: # a 0 measures no tier: count it again
|
|
1301
|
+
first = ("Edit `scope.tier_a_include` in the brief so its globs match the files that define the "
|
|
1302
|
+
"authored tier" if brief_tier_a else "Check `scope.include` in the brief")
|
|
1303
|
+
fix = (f"{first}, then re-run Create Skill so `stats.effective_denominator` in `{meta}` is counted "
|
|
1304
|
+
"again.")
|
|
1305
|
+
elif brief_tier_a:
|
|
1306
|
+
fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
|
|
1307
|
+
f"the source re-derives (it stores that count {stores}), or edit `scope.tier_a_include` in the "
|
|
1308
|
+
"brief so its globs match the files of the authored tier the smaller count measures.")
|
|
1309
|
+
else:
|
|
1310
|
+
fix = (f"Re-run Create Skill to recount `stats.effective_denominator` in `{meta}` from the public surface "
|
|
1311
|
+
f"the source re-derives (it stores that count {stores}), or add `scope.tier_a_include` to the "
|
|
1312
|
+
"brief to name the authored tier the smaller count measures.")
|
|
863
1313
|
records.append(_gap(
|
|
864
1314
|
"Medium", "metadata-drift",
|
|
865
1315
|
"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`"))
|
|
872
|
-
inflation = guards.get("inflation") or {}
|
|
1316
|
+
meta, fix,
|
|
1317
|
+
issue=f"the re-derived surface counts {deflation.get('rederived')} exports{above}, and {no_tier_a}"))
|
|
873
1318
|
if inflation.get("fires") is True:
|
|
874
|
-
if
|
|
875
|
-
fix =
|
|
876
|
-
|
|
1319
|
+
if both:
|
|
1320
|
+
fix = joint
|
|
1321
|
+
elif brief_tier_a:
|
|
1322
|
+
fix = (f"Edit `scope.tier_a_include` in `{brief}` so its globs match the files of the authored "
|
|
1323
|
+
f"surface{umbrella}: the denominator then counts it instead of the coarse `scope.include` union.")
|
|
877
1324
|
else:
|
|
878
|
-
fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface,
|
|
879
|
-
"the denominator counts it instead of the coarse `scope.include` union.")
|
|
1325
|
+
fix = (f"Add `scope.tier_a_include` to `{brief}`, listing the files of the authored surface{umbrella}, "
|
|
1326
|
+
"so the denominator counts it instead of the coarse `scope.include` union.")
|
|
880
1327
|
records.append(_gap(
|
|
881
1328
|
"Medium", "denominator-inflation",
|
|
882
1329
|
"denominator inflation: coarse scope.include union exceeds authored surface", brief, fix,
|
|
883
1330
|
issue=f"the `scope.include` union counts {inflation.get('scopeIncludeUnion')} exports, "
|
|
884
1331
|
f"{inflation.get('pct')}% above the {inflation.get('provenanceEntries')} provenance entries, "
|
|
885
|
-
"and
|
|
1332
|
+
f"and {no_tier_a}"))
|
|
1333
|
+
if stale.get("fires") is True:
|
|
1334
|
+
globs = _names(stale, "unmatchedInclude", label)
|
|
1335
|
+
restored = sorted({(r["name"], r["file"] if isinstance(r.get("file"), str) else "")
|
|
1336
|
+
for r in _list(stale, "restored", label)
|
|
1337
|
+
if isinstance(r, dict) and isinstance(r.get("name"), str) and r["name"]})
|
|
1338
|
+
names = ", ".join(f"`{name}` in `{file}`" if file else f"`{name}`" for name, file in restored)
|
|
1339
|
+
if len(globs) == 1:
|
|
1340
|
+
unmatched = f"the `scope.include` glob {_code(globs)} matches no file in the source tested"
|
|
1341
|
+
else:
|
|
1342
|
+
unmatched = f"the `scope.include` globs {_code(globs)} match no file in the source tested"
|
|
1343
|
+
if not restored:
|
|
1344
|
+
back = "no root export was restored"
|
|
1345
|
+
elif len(restored) == 1:
|
|
1346
|
+
back = f"the `all` set restores 1 root export, defined in a file no include glob covers: {names}"
|
|
1347
|
+
else:
|
|
1348
|
+
back = (f"the `all` set restores {len(restored)} root exports, defined in files no include glob "
|
|
1349
|
+
f"covers: {names}")
|
|
1350
|
+
if len(tier_a_globs) == 1:
|
|
1351
|
+
tier_a = (f"the `scope.tier_a_include` glob {_code(tier_a_globs)} matches no file in the source "
|
|
1352
|
+
"tested, so it counts no name")
|
|
1353
|
+
else:
|
|
1354
|
+
tier_a = (f"the `scope.tier_a_include` globs {_code(tier_a_globs)} match no file in the source "
|
|
1355
|
+
"tested, so they count no name")
|
|
1356
|
+
exclude = ", and add to `scope.exclude` any restored file the brief meant to leave out"
|
|
1357
|
+
if not tier_a_globs: # the include globs alone, as #677 wrote the gap
|
|
1358
|
+
lists, rewrite, clauses = ["scope.include"], "an include glob", [unmatched, back]
|
|
1359
|
+
elif not globs: # no include glob is stale, so nothing was restored
|
|
1360
|
+
lists, rewrite, clauses, exclude = ["scope.tier_a_include"], "a tier A glob", [tier_a], ""
|
|
1361
|
+
else:
|
|
1362
|
+
lists = ["scope.include", "scope.tier_a_include"]
|
|
1363
|
+
rewrite, clauses = "an include or tier A glob", [unmatched, back, tier_a]
|
|
1364
|
+
edit = " and ".join(f"`{key}`" for key in lists)
|
|
1365
|
+
records.append(_gap(
|
|
1366
|
+
"Medium", "brief-scope-stale", f"stale brief scope: {' and '.join(lists)} globs match no source file",
|
|
1367
|
+
brief,
|
|
1368
|
+
f"Edit {edit} in `{brief}` by hand so each glob matches the files it meant in this version of the "
|
|
1369
|
+
f"source (update-skill does not rewrite {rewrite}){exclude}.",
|
|
1370
|
+
issue="; ".join(clauses)))
|
|
886
1371
|
return records
|
|
887
1372
|
|
|
888
1373
|
|
|
@@ -899,10 +1384,11 @@ def from_numerator(numerator: object, metadata: str | None = None) -> list[dict]
|
|
|
899
1384
|
f"numerator inflation: {declared - verified} of {declared} declared exports absent from "
|
|
900
1385
|
"SKILL.md/references",
|
|
901
1386
|
meta,
|
|
902
|
-
f"Document the absent exports in SKILL.md
|
|
903
|
-
f"{
|
|
904
|
-
issue=f"`stats.exports_documented
|
|
905
|
-
f"from SKILL.md and
|
|
1387
|
+
f"Document the absent exports in SKILL.md or references/: the skill documents {verified} of its "
|
|
1388
|
+
f"{declared} declared exports.",
|
|
1389
|
+
issue=f"a declared count (`stats.exports_documented`, or the provenance map's distinct entry names) "
|
|
1390
|
+
f"equals `stats.effective_denominator`, and these declared exports are absent from SKILL.md and "
|
|
1391
|
+
f"references/: {_code(absent)}")]
|
|
906
1392
|
|
|
907
1393
|
|
|
908
1394
|
def from_metadata_coherence(result: object, metadata: str | None = None) -> list[dict]:
|
|
@@ -914,11 +1400,24 @@ def from_metadata_coherence(result: object, metadata: str | None = None) -> list
|
|
|
914
1400
|
if not isinstance(finding, dict) or not isinstance(finding.get("title"), str):
|
|
915
1401
|
raise AdapterError(f"--input: findings[{index}] has no title")
|
|
916
1402
|
detail = finding.get("detail") if isinstance(finding.get("detail"), str) else None
|
|
917
|
-
if finding.get("severity") == "Medium":
|
|
1403
|
+
if finding.get("severity") == "Medium" and "documented-surface" in finding["title"]:
|
|
1404
|
+
# `stats.exports_documented` and the confidence_distribution sum count every provenance entry, while
|
|
1405
|
+
# the named-export count leaves out `Type::method` entries: a re-run reproduces that difference
|
|
1406
|
+
records.append(_gap(
|
|
1407
|
+
"Medium", "metadata-drift", finding["title"], meta,
|
|
1408
|
+
f"No count in `{meta}` needs a fix when `stats.exports_documented` and the `confidence_distribution` "
|
|
1409
|
+
"sum agree: both count every provenance map entry, while the named-export count leaves out the "
|
|
1410
|
+
"`Type::method` entries, so the counts differ by the methods the skill documents. When those two "
|
|
1411
|
+
"disagree, Update Skill or Create Skill recounts both from the provenance map the next time it "
|
|
1412
|
+
"writes the skill; a hand edit does not hold.",
|
|
1413
|
+
issue=detail))
|
|
1414
|
+
elif finding.get("severity") == "Medium":
|
|
918
1415
|
records.append(_gap(
|
|
919
1416
|
"Medium", "metadata-drift", finding["title"], meta,
|
|
920
|
-
f"
|
|
921
|
-
"
|
|
1417
|
+
f"Re-run Create Skill, which rebuilds `exports[]` and `stats.exports_public_api` in `{meta}` from "
|
|
1418
|
+
"the source. Update Skill does not repair this drift: each time it writes the skill it recounts "
|
|
1419
|
+
"`stats.exports_public_api` from the source and keeps `exports[]`, adding or dropping only the "
|
|
1420
|
+
"names its provenance map changes, so a metadata patch does not hold.",
|
|
922
1421
|
issue=detail))
|
|
923
1422
|
elif finding.get("severity") == "Info":
|
|
924
1423
|
records.append(_gap(
|
|
@@ -1329,10 +1828,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1329
1828
|
"(structure takes it more than once)")
|
|
1330
1829
|
p.add_argument("--from", dest="adapter", choices=tuple(ADAPTER_OPTIONS),
|
|
1331
1830
|
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
|
|
1831
|
+
p.add_argument("--surface", help="coverage: load-coverage-inputs.py surface's result (Sources, kinds, "
|
|
1832
|
+
"the names outside scope)")
|
|
1333
1833
|
p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
|
|
1334
1834
|
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)")
|
|
1835
|
+
p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras)")
|
|
1336
1836
|
p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
|
|
1337
1837
|
p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
|
|
1338
1838
|
p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")
|