bmad-module-skill-forge 4.0.0 → 6.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/agents.md +1 -1
- package/docs/architecture.md +5 -5
- package/docs/campaign.md +1 -1
- package/docs/concepts.md +4 -4
- package/docs/examples.md +32 -25
- 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 +9 -3
- package/docs/verifying-a-skill.md +42 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +6 -6
- package/package.json +1 -1
- package/src/knowledge/tool-resolution.md +6 -6
- package/src/shared/references/headless-gate-convention.md +4 -1
- package/src/shared/references/output-contract-schema.md +3 -3
- package/src/shared/references/pipeline-contracts.md +2 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +1180 -189
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-count-tokens.py +54 -10
- package/src/shared/scripts/skf-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +64 -20
- package/src/shared/scripts/skf-extract-public-api.py +472 -120
- package/src/shared/scripts/skf-extraction-inventory.py +10 -6
- package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
- package/src/shared/scripts/skf-parse-gaps.py +136 -26
- package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
- 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-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +574 -45
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +3 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/generate-briefs.md +1 -1
- package/src/skf-analyze-source/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/SKILL.md +4 -3
- package/src/skf-audit-skill/references/re-index.md +2 -2
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +3 -2
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-campaign/SKILL.md +3 -2
- package/src/skf-campaign/references/campaign-contracts.md +2 -0
- package/src/skf-campaign/references/step-05-skill-loop.md +8 -3
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-create-skill/SKILL.md +4 -3
- package/src/skf-create-skill/assets/skill-sections.md +1 -1
- package/src/skf-create-skill/references/ccc-index-check.md +1 -1
- package/src/skf-create-skill/references/component-extraction.md +1 -1
- package/src/skf-create-skill/references/extract.md +3 -2
- package/src/skf-create-skill/references/extraction-patterns.md +3 -3
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +8 -3
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/SKILL.md +3 -2
- package/src/skf-create-stack-skill/references/generate-output.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
- package/src/skf-create-stack-skill/references/report.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +3 -2
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +1 -1
- package/src/skf-export-skill/SKILL.md +3 -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/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +3 -2
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +4 -3
- package/src/skf-quick-skill/references/finalize.md +1 -1
- package/src/skf-quick-skill/references/halt-contract.md +2 -2
- package/src/skf-quick-skill/references/write-and-validate.md +1 -1
- package/src/skf-refine-architecture/SKILL.md +8 -9
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +3 -2
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +3 -2
- package/src/skf-setup/references/ccc-index.md +1 -1
- package/src/skf-setup/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/SKILL.md +3 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +11 -8
- package/src/skf-test-skill/references/coverage-check-branches.md +81 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +60 -97
- package/src/skf-test-skill/references/init.md +2 -1
- package/src/skf-test-skill/references/inventory-contract.md +27 -0
- package/src/skf-test-skill/references/invocation-contract.md +3 -3
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +5 -5
- package/src/skf-test-skill/references/scoring-rules.md +8 -7
- package/src/skf-test-skill/references/signature-comparison-rules.md +72 -0
- package/src/skf-test-skill/references/source-access-protocol.md +14 -10
- package/src/skf-test-skill/references/step-hard-gate.md +1 -0
- package/src/skf-test-skill/scripts/build-result-context.py +20 -3
- package/src/skf-test-skill/scripts/gap-ledger.py +456 -176
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +516 -79
- package/src/skf-test-skill/scripts/locate-export-segments.py +685 -76
- package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
- package/src/skf-test-skill/scripts/score-signatures.py +1205 -65
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +350 -35
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +3 -2
- package/src/skf-update-skill/references/detect-changes.md +1 -1
- package/src/skf-update-skill/references/gap-driven.md +54 -33
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +15 -10
- package/src/skf-update-skill/references/re-extract.md +3 -1
- package/src/skf-update-skill/references/report.md +6 -5
- package/src/skf-update-skill/references/write.md +42 -20
- package/src/skf-verify-stack/SKILL.md +4 -3
- package/tools/cli/lib/tool-check.js +158 -2
|
@@ -9,7 +9,7 @@ coverage-check.md decides whether a bookkeeping variant is a real export,
|
|
|
9
9
|
and makes the judgments the denominator clause needs where no file holds
|
|
10
10
|
them (--pattern-reference-intent, and --curated-subset and --barrel where
|
|
11
11
|
the extraction does not say); the clause, everything it counts, folds or
|
|
12
|
-
unions on the way runs here, so no count is made by hand.
|
|
12
|
+
unions on the way runs here, so no count is made by hand. Four subcommands, each
|
|
13
13
|
reading files and writing one JSON object:
|
|
14
14
|
|
|
15
15
|
census docs-only detection. Counts the citations SKILL.md and each .md
|
|
@@ -18,6 +18,65 @@ reading files and writing one JSON object:
|
|
|
18
18
|
(`[QMD:` and `[DOC:` are counted and decide nothing). A skill is
|
|
19
19
|
docs-only when it cites `[EXT:` and no local form.
|
|
20
20
|
|
|
21
|
+
floor the documented inventory's floor (#790): every name SKILL.md
|
|
22
|
+
and each .md file directly in references/ (a subfolder is not
|
|
23
|
+
read) write in a fixed form, which validate-inventory.py --floor
|
|
24
|
+
refuses an inventory for leaving out. It owns names only: no
|
|
25
|
+
kind is mapped and no signature read. A name is a plain
|
|
26
|
+
identifier (`[A-Za-z_$][\\w$]*`) once a leading `new ` and
|
|
27
|
+
everything from the first `(` or `<` are cut, so `Meta<T>`,
|
|
28
|
+
`fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
|
|
29
|
+
`fetch` and `Client`. A dotted or `::` name gives none, so a
|
|
30
|
+
namespace-style skill such as zod (`z.object`) gets no table
|
|
31
|
+
floor. The forms, read outside the frontmatter and HTML
|
|
32
|
+
comments (a table row or heading inside fenced code gives none):
|
|
33
|
+
table-row each inline code span in the first cell of a
|
|
34
|
+
table row (cells split on an unescaped `|`)
|
|
35
|
+
whose header row's first cell is, in any case,
|
|
36
|
+
singular or plural and bare or wrapped in `**`
|
|
37
|
+
or `__`, Export, Symbol, Function, Class,
|
|
38
|
+
Interface, Type, Hook, Component, Helper,
|
|
39
|
+
Constant, Enum or API (a Name, Method, Prop,
|
|
40
|
+
Param, Import or Subpath table gives none). The
|
|
41
|
+
row's Kind cell, as written, is the name's
|
|
42
|
+
`kindLabel`: the skill's own label, never mapped
|
|
43
|
+
to a kind (the first in origin order when two
|
|
44
|
+
rows label the name differently; null with no
|
|
45
|
+
Kind column)
|
|
46
|
+
heading an ATX heading whose whole text is one code
|
|
47
|
+
span, as in ``### `Meta<T>` `` (a heading with
|
|
48
|
+
other words, ``## Testing with `mount` ``, gives
|
|
49
|
+
none)
|
|
50
|
+
declaration a column-0 public declaration in a fenced block
|
|
51
|
+
whose first non-blank line is a comment (`//`,
|
|
52
|
+
`#`, `/* */`, `--`, `<!-- -->` or `;`) holding an
|
|
53
|
+
`[AST:` or `[SRC:` citation, or naming a path one
|
|
54
|
+
such citation of the skill names (its `:12`,
|
|
55
|
+
`:L12` or `:12-30` left out). A citation counts
|
|
56
|
+
only with its `:L<n>` (`[AST:src/a.ts:L12]`, not
|
|
57
|
+
`[AST:src/a.ts]`), and one on the line above the
|
|
58
|
+
fence heads nothing. Comments and strings are
|
|
59
|
+
masked first. TS/JS (not Svelte, whose `export
|
|
60
|
+
let` is a prop): `export` then `function`,
|
|
61
|
+
`const`, `let`, `var`, `class`, `interface`,
|
|
62
|
+
`type` or `enum` (`declare`, `abstract` and
|
|
63
|
+
`async` may come between), or `export default
|
|
64
|
+
function` or `class` with a name; Python: `def`,
|
|
65
|
+
`async def` or `class`, a name not starting with
|
|
66
|
+
`_`; Rust: a `pub` (not `pub(crate)`) `fn`,
|
|
67
|
+
`struct`, `enum`, `trait`, `type`, `const` or
|
|
68
|
+
`static`, and a `macro_rules!` under
|
|
69
|
+
`#[macro_export]`; Go: `func`, `type`, `var` or
|
|
70
|
+
`const` with a capitalized name, each name of a
|
|
71
|
+
`type (`, `var (` or `const (` group too. A
|
|
72
|
+
block in another language, an untagged one or a
|
|
73
|
+
data block (json, yaml, text...) gives none.
|
|
74
|
+
Output: {"files": [...], "names": [{"name", "kindLabel",
|
|
75
|
+
"origins": [{"file", "line", "form"}]}], "summary": {"names",
|
|
76
|
+
"byForm"}}, names sorted, each one's origins in file order then
|
|
77
|
+
line, `byForm` counting the origins of each form; the same skill
|
|
78
|
+
gives the same bytes.
|
|
79
|
+
|
|
21
80
|
metadata the metadata.json and provenance-map counts. Cluster A
|
|
22
81
|
(`stats.exports_public_api`, the `exports[]` length) and Cluster
|
|
23
82
|
B (`stats.exports_documented`, the provenance entry names, the
|
|
@@ -39,14 +98,25 @@ reading files and writing one JSON object:
|
|
|
39
98
|
surface the enumerated source surface, from every input given (their
|
|
40
99
|
union):
|
|
41
100
|
--extraction skf-extract-public-api.py --mode full output:
|
|
42
|
-
the names in entry_point_diff.public,
|
|
101
|
+
the names in entry_point_diff.public, each
|
|
102
|
+
other in-scope definition of one
|
|
103
|
+
(entry_point_diff.other_definitions, #830) a
|
|
104
|
+
record of its own at its file, a
|
|
43
105
|
namespace with a `members` list given as its
|
|
44
106
|
members (the names its module passes on, each
|
|
45
107
|
at its file, once with a public name of the
|
|
46
108
|
same name and file), less the outside_scope
|
|
47
|
-
names (defined in a file
|
|
48
|
-
|
|
49
|
-
|
|
109
|
+
names (defined in a file out of scope), which
|
|
110
|
+
`excluded.outsideScope` lists with their file,
|
|
111
|
+
line and entry point and, when the brief
|
|
112
|
+
scopes the file out, why: the scope.exclude
|
|
113
|
+
glob that matches it (`excludeGlob`) or no
|
|
114
|
+
scope.include glob covering it
|
|
115
|
+
(`outsideInclude`); exports[] only gives each name's
|
|
116
|
+
kind, line and signature line (a local export
|
|
117
|
+
list's item: its binding's declaration_line and
|
|
118
|
+
declaration_signature_line, when the extractor
|
|
119
|
+
records them), looked up by the
|
|
50
120
|
name its defining file gives it (an aliased
|
|
51
121
|
re-export's `local`) and that file.
|
|
52
122
|
extraction_gaps are listed to read by eye, and
|
|
@@ -112,10 +182,11 @@ reading files and writing one JSON object:
|
|
|
112
182
|
Name sets (surface `sets`, each sorted): `all` (the union above, less a
|
|
113
183
|
nested Python top's names);
|
|
114
184
|
`scope.include` and `tier_a_include` when the scope has those globs and a
|
|
115
|
-
record names a file: the names
|
|
185
|
+
record names a file: the names with a file (any of the name's records: an
|
|
186
|
+
extraction's definition file, each other in-scope definition's included,
|
|
116
187
|
the quick output's defined_in, else its source_file, a per-file result's
|
|
117
|
-
file, a provenance entry's source_file) matches a glob of the list
|
|
118
|
-
scope.exclude glob,
|
|
188
|
+
file, a provenance entry's source_file) that matches a glob of the list
|
|
189
|
+
and no scope.exclude glob,
|
|
119
190
|
and for `tier_a_include` a scope.include glob too when the scope has one,
|
|
120
191
|
so a name with no file is in `all` only and a restored name is in no
|
|
121
192
|
tier_a_include set (a tier_a_include glob an extraction lists as matching
|
|
@@ -127,29 +198,45 @@ exclude of `src/gen/**`; a list that counts no name, each glob left out
|
|
|
127
198
|
or matching only excluded files, files scope.include leaves out or files
|
|
128
199
|
no record names, gives no tier_a_include set); and, from an
|
|
129
200
|
extraction only, `subpaths` (the names a non-root `exports` map subpath
|
|
130
|
-
without `*` reaches) and `root` (the names the root entry points reach)
|
|
201
|
+
without `*` reaches) and `root` (the names the root entry points reach),
|
|
202
|
+
each through the entry point of any of the name's definitions.
|
|
131
203
|
reconcile-coverage.py and score-signatures.py --surface-set pick one.
|
|
132
204
|
|
|
133
205
|
Candidates and guards (with an extraction, --brief or --metadata): each
|
|
134
206
|
set's count, the metadata `stats.effective_denominator` (--metadata), and
|
|
135
|
-
the extractor's own effective_denominator and its basis
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
207
|
+
the extractor's own effective_denominator and its basis; for the
|
|
208
|
+
Denominator annotations, `tierAIncludeFiles` and `scopeIncludeFiles` (the
|
|
209
|
+
distinct files the set's names sit at: the files that define them when
|
|
210
|
+
each name sits at its defining file) and `subpathEntries` (an extraction's
|
|
211
|
+
distinct in-scope non-root, non-wildcard subpaths, each package's subpath
|
|
212
|
+
once however many files it names), each null when its set is absent.
|
|
213
|
+
Both guards run
|
|
214
|
+
only on a surface read from the source. The deflation guard compares a
|
|
215
|
+
re-derived set with `stats.effective_denominator` and names it in `basis`.
|
|
216
|
+
It is the tier_a_include set (`tier_a_include`) when the clause's ladder
|
|
217
|
+
holds a stored count (stratified-scope, specific-modules, multi-entry), the
|
|
218
|
+
surface has that set and each name sits at the file that defines it (a
|
|
219
|
+
runner extraction, --fallback-scan, or quick output whose every name
|
|
220
|
+
carries its defined_in, with no --per-file or --name beside it): it fires
|
|
221
|
+
when the stored count differs from the set by more than DEFLATION_PCT
|
|
222
|
+
either way, since Create Skill stores the runner's tier A count. Otherwise
|
|
223
|
+
it is the scope.include set (`scope.include`, else `all` when the surface
|
|
224
|
+
has no scope.include set), firing above DEFLATION_PCT when there is no tier
|
|
225
|
+
A list (with an extraction or --fallback-scan, no tier_a_include set;
|
|
226
|
+
otherwise no tier_a_include glob left in the list, since a name read by eye
|
|
227
|
+
at Quick tier or State 4, and a quick name with no defined_in, carries its
|
|
228
|
+
entry file, not the file that defines it). A stored 0 is deflation, not a
|
|
229
|
+
denominator (`pct` null), and fires under its basis's condition when any
|
|
230
|
+
name is re-derived. The inflation guard compares the scope.include set
|
|
231
|
+
with the provenance entry count (--provenance) and fires above
|
|
232
|
+
INFLATION_PCT when there is no tier A list, as the scope.include basis
|
|
233
|
+
reads it; `guards.tierAInclude` lists the scope's
|
|
148
234
|
tier_a_include globs ([] when it has none) for gap-ledger.py's wording;
|
|
149
235
|
the umbrella ratio (an
|
|
150
236
|
extraction only) is the share of the root entry points' names that are
|
|
151
237
|
re-exports (a namespace with `members` counting as its members there
|
|
152
|
-
too, each a re-export
|
|
238
|
+
too, each a re-export, and an other in-scope definition left out), and
|
|
239
|
+
above UMBRELLA_RATIO the barrel is an
|
|
153
240
|
umbrella; the
|
|
154
241
|
stale-scope guard (an extraction with scope.include or tier_a_include only)
|
|
155
242
|
fires when the runner lists an include or tier_a_include glob that matches
|
|
@@ -162,7 +249,14 @@ included, and `*` and `?` stay inside one.
|
|
|
162
249
|
Denominator (surface `denominator`): the Source API Surface Definition's
|
|
163
250
|
clause (source-access-protocol.md), the branch reconcile-coverage.py takes
|
|
164
251
|
and the set it and score-signatures.py --surface-set read: {clause, branch,
|
|
165
|
-
surfaceSet, ladderStep, absentSet, rebuild, barrel}.
|
|
252
|
+
surfaceSet, ladderStep, absentSet, rebuild, barrel, setCounted}.
|
|
253
|
+
`setCounted` (null off the scalar branch) says whether the scalar branch
|
|
254
|
+
counts its `surfaceSet` as the numerator's set: true only for a scope set
|
|
255
|
+
(not `all`) whose every name sits at the file that defines it, as the
|
|
256
|
+
deflation guard's tier A basis requires (a runner extraction, the fallback
|
|
257
|
+
scan, quick output whose every name carries its defined_in), read whole (an
|
|
258
|
+
extraction neither `incomplete` nor truncated); otherwise the scalar branch
|
|
259
|
+
keeps its looked-up numerator. The inputs: the scope
|
|
166
260
|
type (the brief's, else the extraction's, else metadata.json's
|
|
167
261
|
`scope_type`), metadata.json's `skill_type` and `stats.effective_denominator`,
|
|
168
262
|
the extraction's `arms`, the deflation guard, and:
|
|
@@ -200,7 +294,13 @@ The last three walk a ladder, `ladderStep` naming the rung taken: rung 1,
|
|
|
200
294
|
`stats.effective_denominator` when metadata.json has one above 0 (a stored
|
|
201
295
|
0 is no denominator), is the `scalar` branch, whose `surfaceSet` (the set score-signatures.py scores) is
|
|
202
296
|
tier_a_include, else scope.include, else all, unless the deflation guard
|
|
203
|
-
fires: then the barrel branch over
|
|
297
|
+
fires: then the barrel branch over tier_a_include when the ladder has that
|
|
298
|
+
rung (stratified-scope, multi-entry) and the surface has the set, else over
|
|
299
|
+
scope.include (when the inflation guard fires too, that union is itself
|
|
300
|
+
inflated, and both gaps ask for the brief's tier A list, never a stored
|
|
301
|
+
count set by hand). On an incomplete extraction, which can miss tier A
|
|
302
|
+
names, a tier A set below the stored count fires the guard and leaves the
|
|
303
|
+
scalar branch. The next rungs are sets,
|
|
204
304
|
each taken when the surface has it, the last taken in any case:
|
|
205
305
|
tier_a_include then scope.include (stratified-scope), scope.include
|
|
206
306
|
(specific-modules), tier_a_include then subpaths (multi-entry). A set the
|
|
@@ -211,12 +311,17 @@ found no ast-grep (`no-ast-grep`), read no file in scope, read files only
|
|
|
211
311
|
in languages it has no recipe for (`files_without_recipes` and no export),
|
|
212
312
|
or found files no recipe reads while the skill's language (the --brief
|
|
213
313
|
`language`, else the metadata.json `language`) is not one RECIPE_LANGUAGES
|
|
214
|
-
holds, so the per-file scan has to run instead.
|
|
215
|
-
its
|
|
216
|
-
|
|
314
|
+
holds, so the per-file scan has to run instead. A run keeps its exports
|
|
315
|
+
whatever its status; `extraction.readByEye` lists the files to read by eye,
|
|
316
|
+
at any status: each file the runner could not parse or decode (a
|
|
317
|
+
`syntax-errors` or `not-utf8` file issue) and, on an `incomplete` run, each
|
|
318
|
+
file a failed call names. `extraction.unread` lists the files nobody can
|
|
319
|
+
read (a `missing` or `unreadable` file issue); a `no-recipes` file, such as
|
|
320
|
+
a stylesheet, holds nothing to read and is in neither list.
|
|
217
321
|
|
|
218
322
|
Usage:
|
|
219
323
|
uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
|
|
324
|
+
uv run load-coverage-inputs.py floor --skill-dir <dir> [--output <file>]
|
|
220
325
|
uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
|
|
221
326
|
[--provenance <provenance-map.json>] [--output <file>]
|
|
222
327
|
uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
|
|
@@ -228,7 +333,8 @@ Usage:
|
|
|
228
333
|
Exit codes:
|
|
229
334
|
0 the result was printed (and written to --output)
|
|
230
335
|
1 an input file is missing, unreadable or not the JSON (or the YAML
|
|
231
|
-
brief) it should be
|
|
336
|
+
brief) it should be, or the --skill-dir holds no SKILL.md or a skill
|
|
337
|
+
file that is not UTF-8 (one line on stderr)
|
|
232
338
|
2 a per-file result breaks the schema: {"valid": false, "violations":
|
|
233
339
|
[...]} names each breach and nothing is written to --output; or a
|
|
234
340
|
usage error (argparse)
|
|
@@ -258,6 +364,40 @@ CITATION_FORMS = {
|
|
|
258
364
|
"DOC": "[DOC:",
|
|
259
365
|
}
|
|
260
366
|
LOCAL_FORMS = ("AST", "SRC", "from skill")
|
|
367
|
+
# floor: the forms a documented name takes, in the order an origin on one line sorts by.
|
|
368
|
+
FLOOR_FORMS = ("table-row", "heading", "declaration")
|
|
369
|
+
# The first header cells (lower case, singular or plural) of a table whose first column names exports.
|
|
370
|
+
FLOOR_TABLE_HEADERS = frozenset({
|
|
371
|
+
"export", "exports", "symbol", "symbols", "function", "functions", "class", "classes", "interface", "interfaces",
|
|
372
|
+
"type", "types", "hook", "hooks", "component", "components", "helper", "helpers", "constant", "constants",
|
|
373
|
+
"enum", "enums", "api", "apis"})
|
|
374
|
+
# A header cell wrapped in `**` or `__` (group 2 its text).
|
|
375
|
+
EMPHASIS_RE = re.compile(r"(\*\*|__)(.+)\1")
|
|
376
|
+
PLAIN_IDENTIFIER_RE = re.compile(r"[A-Za-z_$][\w$]*")
|
|
377
|
+
TABLE_CELL_RE = re.compile(r"(?<!\\)\|")
|
|
378
|
+
# A comment's opening marker: `//`, `#`, `/*`, `--`, `<!--` or `;`.
|
|
379
|
+
COMMENT_MARKER_RE = re.compile(r"[ \t]*(?://+|#+|/\*+|--|<!--|;+)")
|
|
380
|
+
# A cited path's line suffix: `:12`, `:L12`, `:12-30` or `:L12-L30`.
|
|
381
|
+
LINE_SUFFIX_RE = re.compile(r":L?\d+(?:-L?\d+)?$")
|
|
382
|
+
PYTHON_LANGUAGES = frozenset({"python", "py", "python3"})
|
|
383
|
+
RUST_LANGUAGES = frozenset({"rust", "rs"})
|
|
384
|
+
GO_LANGUAGES = frozenset({"go", "golang"})
|
|
385
|
+
# Column-0 public declarations (group 1 the name), matched on the block with comments and strings masked.
|
|
386
|
+
JS_DECLARATION_RE = re.compile(
|
|
387
|
+
r"export\s+(?:default\s+(?:abstract\s+)?(?:async\s+)?(?:function(?:\s*\*\s*|\s+)|class\s+)"
|
|
388
|
+
r"|(?:declare\s+)?(?:abstract\s+)?(?:async\s+)?"
|
|
389
|
+
r"(?:function(?:\s*\*\s*|\s+)|const\s+enum\s+|const\s+|let\s+|var\s+|class\s+|interface\s+|type\s+|enum\s+))"
|
|
390
|
+
r"(?!(?:extends|implements)(?![\w$]))([A-Za-z_$][\w$]*)")
|
|
391
|
+
PYTHON_DECLARATION_RE = re.compile(r"(?:async\s+def|def|class)\s+([A-Za-z_]\w*)")
|
|
392
|
+
RUST_DECLARATION_RE = re.compile(
|
|
393
|
+
r"pub\s+(?:(?:async|unsafe|const|extern)\s+)*(?:fn|struct|enum|trait|type|const|static)\s+(?:mut\s+)?"
|
|
394
|
+
r"([A-Za-z_]\w*)")
|
|
395
|
+
RUST_MACRO_EXPORT_RE = re.compile(r"#\[macro_export\]")
|
|
396
|
+
RUST_MACRO_RE = re.compile(r"(?:#\[[^\]]*\][ \t]*)*macro_rules![ \t]*([A-Za-z_]\w*)")
|
|
397
|
+
# Go: a single declaration (group 1 its keyword, group 2 its names) or a group's opening line (group 1).
|
|
398
|
+
GO_DECLARATION_RE = re.compile(r"(func|type|var|const)[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
|
|
399
|
+
GO_GROUP_RE = re.compile(r"(type|var|const)[ \t]*\([ \t]*$")
|
|
400
|
+
GO_GROUP_NAMES_RE = re.compile(r"[ \t]+([A-Za-z_]\w*(?:[ \t]*,[ \t]*[A-Za-z_]\w*)*)")
|
|
261
401
|
# Bookkeeping variants folded to their base name, in rule order.
|
|
262
402
|
FOLD_SUFFIXES = ("_def", "_exact")
|
|
263
403
|
FOLD_PREFIX = "a11y_"
|
|
@@ -298,7 +438,9 @@ SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scr
|
|
|
298
438
|
|
|
299
439
|
def _sibling(filename: str, folder: Path | None = None):
|
|
300
440
|
"""A script beside this one, loaded once: validate-inventory.py (the doc
|
|
301
|
-
text
|
|
441
|
+
text, and the skill citations floor reads), locate-export-segments.py
|
|
442
|
+
(the line model floor reads the skill with) and score-signatures.py (the
|
|
443
|
+
per-file result schema); or, with
|
|
302
444
|
`folder`, one in that folder: SHARED_SCRIPTS'
|
|
303
445
|
skf-resolve-authoritative-files.py (a skill brief's YAML)."""
|
|
304
446
|
module = _SIBLINGS.get(filename)
|
|
@@ -385,6 +527,199 @@ def census(skill_dir: str) -> dict:
|
|
|
385
527
|
}
|
|
386
528
|
|
|
387
529
|
|
|
530
|
+
# --------------------------------------------------------------------------
|
|
531
|
+
# floor
|
|
532
|
+
# --------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _floor_name(text: str) -> str | None:
|
|
536
|
+
"""The plain identifier `text` writes once a leading `new ` and everything from the first `(` or `<` are
|
|
537
|
+
cut; None for any other text, a dotted or `::` name among them."""
|
|
538
|
+
text = text.strip()
|
|
539
|
+
if text.startswith("new "):
|
|
540
|
+
text = text[4:]
|
|
541
|
+
text = re.split(r"[(<]", text, maxsplit=1)[0].strip()
|
|
542
|
+
return text if PLAIN_IDENTIFIER_RE.fullmatch(text) else None
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _header_word(cell: str) -> str:
|
|
546
|
+
"""A header cell's word, lower case, unwrapped from `**` or `__`."""
|
|
547
|
+
m = EMPHASIS_RE.fullmatch(cell)
|
|
548
|
+
return (m.group(2) if m else cell).strip().lower()
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _cells(row: str) -> list[str]:
|
|
552
|
+
"""A table row's cells, split on each unescaped `|`, each stripped."""
|
|
553
|
+
row = row.strip()
|
|
554
|
+
if row.startswith("|"):
|
|
555
|
+
row = row[1:]
|
|
556
|
+
if row.endswith("|") and not row.endswith("\\|"):
|
|
557
|
+
row = row[:-1]
|
|
558
|
+
return [cell.strip() for cell in TABLE_CELL_RE.split(row)]
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _whole_span(text: str, segments) -> str | None:
|
|
562
|
+
"""The code span `text` is, when the whole of it is one; else None."""
|
|
563
|
+
spans = segments._inline_code(text)
|
|
564
|
+
if len(spans) != 1:
|
|
565
|
+
return None
|
|
566
|
+
run = len(text) - len(text.lstrip("`"))
|
|
567
|
+
return spans[0] if text == "`" * run + spans[0] + "`" * run else None
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _comment_text(line: str) -> str | None:
|
|
571
|
+
"""A comment line's text, its markers stripped (`// x`, `# x`, `/* x */`, `-- x`, `<!-- x -->`, `; x`);
|
|
572
|
+
None for a line that is no comment. Linear in the line's length."""
|
|
573
|
+
m = COMMENT_MARKER_RE.match(line)
|
|
574
|
+
if not m:
|
|
575
|
+
return None
|
|
576
|
+
text = line[m.end():].strip()
|
|
577
|
+
if text.endswith("-->"):
|
|
578
|
+
text = text[:-3]
|
|
579
|
+
elif text.endswith("*/"):
|
|
580
|
+
text = text[:-2].rstrip("*")
|
|
581
|
+
return text.strip()
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _headed(lines: list[str], opening: int, closing: int, cited: set[str], inventory) -> bool:
|
|
585
|
+
"""Whether a source citation heads the fenced block opening at `opening`: its first non-blank line is a
|
|
586
|
+
comment holding a citation, or naming a path the skill cites (a `:12`, `:L12` or `:12-30` suffix left
|
|
587
|
+
out). A citation on the line above the fence heads nothing."""
|
|
588
|
+
first = next((lines[i] for i in range(opening + 1, closing) if lines[i].strip()), None)
|
|
589
|
+
text = _comment_text(first) if first is not None else None
|
|
590
|
+
if not text:
|
|
591
|
+
return False
|
|
592
|
+
if inventory._SKILL_CITATION_RE.search(text):
|
|
593
|
+
return True
|
|
594
|
+
suffix = LINE_SUFFIX_RE.search(text)
|
|
595
|
+
path = text[:suffix.start()] if suffix else text
|
|
596
|
+
return bool(path) and not any(c.isspace() for c in path) and inventory._cited_path(path) in cited
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _html_comment_lines(lines: list[str], start: int, code: list[bool]) -> set[int]:
|
|
600
|
+
"""The lines inside an HTML comment, the fenced lines it holds included, read as _prose reads one: a line
|
|
601
|
+
outside fenced code that starts `<!--` opens it, the next such line holding `-->` closes it."""
|
|
602
|
+
inside, open_ = set(), False
|
|
603
|
+
for i in range(start, len(lines)):
|
|
604
|
+
if open_:
|
|
605
|
+
inside.add(i)
|
|
606
|
+
open_ = code[i] or "-->" not in lines[i]
|
|
607
|
+
elif not code[i] and lines[i].strip().startswith("<!--"):
|
|
608
|
+
inside.add(i)
|
|
609
|
+
open_ = "-->" not in lines[i].strip()[4:]
|
|
610
|
+
return inside
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _names(text: str) -> list[str]:
|
|
614
|
+
return [name.strip() for name in text.split(",")]
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _declared_names(lines: list[str], language: str, js_family) -> list[tuple[int, str]]:
|
|
618
|
+
"""(offset, name) of each column-0 public declaration in a block's masked lines, by its language."""
|
|
619
|
+
found: list[tuple[int, str]] = []
|
|
620
|
+
if language in js_family:
|
|
621
|
+
found = [(i, m.group(1)) for i, line in enumerate(lines) if (m := JS_DECLARATION_RE.match(line))]
|
|
622
|
+
elif language in PYTHON_LANGUAGES:
|
|
623
|
+
found = [(i, m.group(1)) for i, line in enumerate(lines)
|
|
624
|
+
if (m := PYTHON_DECLARATION_RE.match(line)) and not m.group(1).startswith("_")]
|
|
625
|
+
elif language in RUST_LANGUAGES:
|
|
626
|
+
exported = False # a `#[macro_export]` above, with only blank or attribute lines between
|
|
627
|
+
for i, line in enumerate(lines):
|
|
628
|
+
if m := RUST_DECLARATION_RE.match(line):
|
|
629
|
+
found.append((i, m.group(1)))
|
|
630
|
+
exported = exported or bool(RUST_MACRO_EXPORT_RE.match(line))
|
|
631
|
+
macro = RUST_MACRO_RE.match(line)
|
|
632
|
+
if macro and exported:
|
|
633
|
+
found.append((i, macro.group(1)))
|
|
634
|
+
if macro or (line.strip() and not line.startswith("#[")):
|
|
635
|
+
exported = False
|
|
636
|
+
elif language in GO_LANGUAGES:
|
|
637
|
+
group, depth = False, 0 # inside a `type (`, `var (` or `const (` group, and its bracket depth there
|
|
638
|
+
for i, line in enumerate(lines):
|
|
639
|
+
if group:
|
|
640
|
+
if depth == 0 and line.lstrip().startswith(")"):
|
|
641
|
+
group = False
|
|
642
|
+
continue
|
|
643
|
+
if depth == 0 and (m := GO_GROUP_NAMES_RE.match(line)):
|
|
644
|
+
found += [(i, name) for name in _names(m.group(1)) if name[0].isupper()]
|
|
645
|
+
depth = max(0, depth + sum(map(line.count, "{([")) - sum(map(line.count, "})]")))
|
|
646
|
+
elif GO_GROUP_RE.match(line):
|
|
647
|
+
group, depth = True, 0
|
|
648
|
+
elif m := GO_DECLARATION_RE.match(line):
|
|
649
|
+
found += [(i, name) for name in _names(m.group(2)) if name[0].isupper()]
|
|
650
|
+
return found
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def floor(skill_dir: str) -> dict:
|
|
654
|
+
"""The names the skill documents in a fixed form, each with its origins (see the module docstring)."""
|
|
655
|
+
root = Path(skill_dir)
|
|
656
|
+
if not (root / "SKILL.md").is_file():
|
|
657
|
+
raise InputError(f"--skill-dir {skill_dir} holds no SKILL.md")
|
|
658
|
+
segments = _sibling("locate-export-segments.py")
|
|
659
|
+
inventory = _sibling("validate-inventory.py")
|
|
660
|
+
refs = root / "references"
|
|
661
|
+
files = ["SKILL.md"] + ([f"references/{p.name}" for p in sorted(refs.glob("*.md")) if p.is_file()]
|
|
662
|
+
if refs.is_dir() else [])
|
|
663
|
+
texts = []
|
|
664
|
+
for rel in files:
|
|
665
|
+
try:
|
|
666
|
+
texts.append(segments._split_lines((root / rel).read_bytes().decode("utf-8-sig")))
|
|
667
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
668
|
+
raise InputError(f"cannot read {rel} under {skill_dir}: {exc}") from exc
|
|
669
|
+
cited = {inventory._cited_path(m.group(2))
|
|
670
|
+
for lines in texts for line in lines for m in inventory._SKILL_CITATION_RE.finditer(line)}
|
|
671
|
+
|
|
672
|
+
found: dict[str, set[tuple[int, int, str, str | None]]] = {}
|
|
673
|
+
|
|
674
|
+
def add(name: str | None, file_index: int, index: int, form: str, label: str | None = None) -> None:
|
|
675
|
+
if name:
|
|
676
|
+
found.setdefault(name, set()).add((file_index, index + 1, form, label))
|
|
677
|
+
|
|
678
|
+
js_family = segments.JS_FAMILY - {"svelte"} # a Svelte block's `export let` is a component prop
|
|
679
|
+
for file_index, lines in enumerate(texts):
|
|
680
|
+
start = segments._frontmatter_end(lines)
|
|
681
|
+
blocks, code = segments._fenced_blocks(lines, start)
|
|
682
|
+
headings, prose = segments._prose(lines, start, code)
|
|
683
|
+
for block in prose:
|
|
684
|
+
if block["kind"] != "table-row" or block["header"] is None:
|
|
685
|
+
continue
|
|
686
|
+
header = _cells(block["header"])
|
|
687
|
+
if _header_word(header[0]) not in FLOOR_TABLE_HEADERS:
|
|
688
|
+
continue
|
|
689
|
+
row = _cells(lines[block["start"]])
|
|
690
|
+
kind_at = next((i for i, cell in enumerate(header) if _header_word(cell) == "kind"), None)
|
|
691
|
+
label = row[kind_at] if kind_at is not None and kind_at < len(row) and row[kind_at] else None
|
|
692
|
+
for span in segments._inline_code(row[0]):
|
|
693
|
+
add(_floor_name(span), file_index, block["start"], "table-row", label)
|
|
694
|
+
for index, _level, text, _above in headings:
|
|
695
|
+
span = _whole_span(text, segments)
|
|
696
|
+
if span is not None:
|
|
697
|
+
add(_floor_name(span), file_index, index, "heading")
|
|
698
|
+
comments = _html_comment_lines(lines, start, code)
|
|
699
|
+
for opening, closing, info in blocks:
|
|
700
|
+
language = segments._language(info)
|
|
701
|
+
if (opening in comments or language in segments.DATA_LANGUAGES
|
|
702
|
+
or not _headed(lines, opening, closing, cited, inventory)):
|
|
703
|
+
continue
|
|
704
|
+
masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
|
|
705
|
+
for offset, name in _declared_names(masked, language, js_family):
|
|
706
|
+
add(name, file_index, opening + 1 + offset, "declaration")
|
|
707
|
+
|
|
708
|
+
names = []
|
|
709
|
+
by_form = dict.fromkeys(FLOOR_FORMS, 0)
|
|
710
|
+
for name in sorted(found):
|
|
711
|
+
origins = sorted(found[name], key=lambda o: (o[0], o[1], FLOOR_FORMS.index(o[2]), o[3] or ""))
|
|
712
|
+
seen, kept = set(), []
|
|
713
|
+
for file_index, line, form, _label in origins:
|
|
714
|
+
if (file_index, line, form) not in seen:
|
|
715
|
+
seen.add((file_index, line, form))
|
|
716
|
+
kept.append({"file": files[file_index], "line": line, "form": form})
|
|
717
|
+
by_form[form] += 1
|
|
718
|
+
label = next((o[3] for o in origins if o[2] == "table-row" and o[3]), None)
|
|
719
|
+
names.append({"name": name, "kindLabel": label, "origins": kept})
|
|
720
|
+
return {"files": files, "names": names, "summary": {"names": len(names), "byForm": by_form}}
|
|
721
|
+
|
|
722
|
+
|
|
388
723
|
# --------------------------------------------------------------------------
|
|
389
724
|
# metadata
|
|
390
725
|
# --------------------------------------------------------------------------
|
|
@@ -541,6 +876,14 @@ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list
|
|
|
541
876
|
and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
|
|
542
877
|
|
|
543
878
|
|
|
879
|
+
def matching_files(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
|
|
880
|
+
"""The distinct files `matching` takes its names from: each record's file one of `globs` matches and no
|
|
881
|
+
exclude glob does."""
|
|
882
|
+
return sorted({r["file"] for r in records if r.get("file")
|
|
883
|
+
and any(glob_match(_norm(r["file"]), g) for g in globs)
|
|
884
|
+
and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
|
|
885
|
+
|
|
886
|
+
|
|
544
887
|
def _apart(a: str, b: str) -> bool:
|
|
545
888
|
"""True when, before either has a `*` or `?`, the two globs spell a different segment at the same
|
|
546
889
|
place, so no file can match both. Any other pair counts as overlapping, even one no file can match
|
|
@@ -607,9 +950,14 @@ def _members_in_place(public: list[dict]) -> list[dict]:
|
|
|
607
950
|
return expanded
|
|
608
951
|
|
|
609
952
|
|
|
953
|
+
# The runner's file issues for a file it could not open at all: no one can read it by eye either.
|
|
954
|
+
UNREADABLE_ISSUES = frozenset({"missing", "unreadable"})
|
|
955
|
+
|
|
956
|
+
|
|
610
957
|
def _extraction_summary(data: dict, language: str | None) -> dict:
|
|
611
958
|
"""The surface's `extraction` object: the runner's status and counts,
|
|
612
|
-
the files to read by eye and the fallback
|
|
959
|
+
the files to read by eye, the files no one can read and the fallback
|
|
960
|
+
verdict."""
|
|
613
961
|
counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
|
|
614
962
|
arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
|
|
615
963
|
entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
|
|
@@ -626,8 +974,11 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
|
|
|
626
974
|
reason = (f"no recipe reads the skill's language, {language} "
|
|
627
975
|
f"({', '.join(sorted(without))} in scope)")
|
|
628
976
|
errors = [e for e in data.get("errors") or [] if isinstance(e, dict)]
|
|
629
|
-
|
|
630
|
-
|
|
977
|
+
issues = [i for i in data.get("file_issues") or [] if isinstance(i, dict) and i.get("file")]
|
|
978
|
+
# A file missing from disk or one the runner could not open is no file to read by eye.
|
|
979
|
+
unread = sorted({str(i["file"]) for i in issues if i.get("issue") in UNREADABLE_ISSUES})
|
|
980
|
+
read_by_eye = sorted({str(i["file"]) for i in issues
|
|
981
|
+
if i.get("issue") != "no-recipes" and i.get("issue") not in UNREADABLE_ISSUES}
|
|
631
982
|
| {str(e["first_file"]) for e in errors if e.get("first_file")}
|
|
632
983
|
| {str(f) for e in errors if isinstance(e.get("unread"), list) for f in e["unread"] if f})
|
|
633
984
|
return {
|
|
@@ -644,6 +995,7 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
|
|
|
644
995
|
"entryPointsFound": (bool(entry_points["files"]) if isinstance(entry_points.get("files"), list)
|
|
645
996
|
and data.get("files_in_scope") else None),
|
|
646
997
|
"readByEye": read_by_eye,
|
|
998
|
+
"unread": unread,
|
|
647
999
|
"fallback": {"needed": reason is not None, "reason": reason},
|
|
648
1000
|
}
|
|
649
1001
|
|
|
@@ -651,6 +1003,21 @@ def _extraction_summary(data: dict, language: str | None) -> dict:
|
|
|
651
1003
|
NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
|
|
652
1004
|
|
|
653
1005
|
|
|
1006
|
+
def _outside_row(row: dict, scope: dict) -> dict:
|
|
1007
|
+
"""An outside_scope row as `excluded.outsideScope` lists it: its name,
|
|
1008
|
+
file, line and entry point, and why the brief scopes the file out when
|
|
1009
|
+
it does (#796): `excludeGlob`, the first scope.exclude glob that matches
|
|
1010
|
+
the file, and `outsideInclude`, true when scope.include has globs and
|
|
1011
|
+
none matches it. Neither (null and false) when the brief does not
|
|
1012
|
+
explain it, such as a file no recipe reads or no file at all."""
|
|
1013
|
+
file = row.get("file") if isinstance(row.get("file"), str) and row.get("file") else None
|
|
1014
|
+
path = _norm(file) if file else None
|
|
1015
|
+
return {"name": row.get("name"), "file": file, "line": row.get("line"), "entry": row.get("entry"),
|
|
1016
|
+
"excludeGlob": next((g for g in scope["exclude"] if glob_match(path, g)), None) if path else None,
|
|
1017
|
+
"outsideInclude": bool(path and scope["include"]
|
|
1018
|
+
and not any(glob_match(path, g) for g in scope["include"]))}
|
|
1019
|
+
|
|
1020
|
+
|
|
654
1021
|
def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
|
|
655
1022
|
"""The public surface of an extraction, less what lies outside scope,
|
|
656
1023
|
plus the root exports a stale scope.include glob left out. A nested
|
|
@@ -675,11 +1042,15 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
675
1042
|
"staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
|
|
676
1043
|
"restored": []},
|
|
677
1044
|
"gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
|
|
678
|
-
"extraction": extraction}
|
|
1045
|
+
"subpathEntries": None, "extraction": extraction}
|
|
679
1046
|
|
|
680
1047
|
diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
|
|
681
|
-
public
|
|
682
|
-
|
|
1048
|
+
# Each in-scope definition of a public name is a record of its own (#830): the runner's other
|
|
1049
|
+
# definitions join the public rows, so a set holds a name when any of its files matches, and
|
|
1050
|
+
# the signature plan reads every declaration.
|
|
1051
|
+
rows = {key: [p for p in diff.get(key) or [] if isinstance(p, dict) and isinstance(p.get("name"), str)]
|
|
1052
|
+
for key in ("public", "other_definitions")}
|
|
1053
|
+
public = _members_in_place(rows["public"] + rows["other_definitions"])
|
|
683
1054
|
outside = [o for o in diff.get("outside_scope") or [] if isinstance(o, dict)]
|
|
684
1055
|
gaps = [g for g in diff.get("extraction_gaps") or [] if isinstance(g, dict)]
|
|
685
1056
|
|
|
@@ -690,12 +1061,17 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
690
1061
|
# records (so the scope sets a specific-modules or stratified brief
|
|
691
1062
|
# counts) and their outside_scope and extraction_gaps rows.
|
|
692
1063
|
nested = _nested_tops(entry_files, set(scope["include"]) | set(scope["tier_a_include"]),
|
|
693
|
-
{p.get("entry") for p in public})
|
|
1064
|
+
{p.get("entry") for p in rows["public"]})
|
|
694
1065
|
nested_names = {top: sorted({p["name"] for p in public if p.get("entry") == top}) for top in nested}
|
|
695
1066
|
|
|
696
1067
|
subpath_files = {e.get("file") for e in entry_files
|
|
697
1068
|
if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
|
|
698
1069
|
root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
|
|
1070
|
+
# The distinct in-scope subpaths of those (a package's subpath once, however many files its conditions
|
|
1071
|
+
# name), for the multi-entry annotation.
|
|
1072
|
+
subpath_entries = {(e.get("package"), e.get("subpath")) for e in entry_files
|
|
1073
|
+
if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))
|
|
1074
|
+
and e.get("in_scope") is not False}
|
|
699
1075
|
|
|
700
1076
|
# A scope.include glob that matches no file: the brief predates the
|
|
701
1077
|
# source, so a root export defined in a file no glob covers comes back,
|
|
@@ -726,25 +1102,32 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
726
1102
|
# under the name its defining file gives it, `local`.
|
|
727
1103
|
found = index.get((info.get("local") or name, file)) or index.get((name, file))
|
|
728
1104
|
kind = found.get("export_type") if found else None
|
|
1105
|
+
# A local `export { ... }` list's item is planned at the declaration it
|
|
1106
|
+
# names (#836): the line and the signature line both come from that pair.
|
|
1107
|
+
if found and isinstance(found.get("declaration_line"), int):
|
|
1108
|
+
line, signature_line = found["declaration_line"], found.get("declaration_signature_line")
|
|
1109
|
+
elif found:
|
|
1110
|
+
line, signature_line = found.get("source_line"), found.get("signature_line")
|
|
1111
|
+
else:
|
|
1112
|
+
line, signature_line = info.get("line"), None
|
|
729
1113
|
(nested_records if info.get("entry") in nested else records).append(
|
|
730
|
-
_record(name, None if kind == "re-export" else kind, file,
|
|
731
|
-
found.get("source_line") if found else info.get("line"),
|
|
732
|
-
found.get("signature_line") if found else None, "extraction"))
|
|
1114
|
+
_record(name, None if kind == "re-export" else kind, file, line, signature_line, "extraction"))
|
|
733
1115
|
|
|
734
|
-
|
|
1116
|
+
# The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
|
|
1117
|
+
root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
|
|
735
1118
|
reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
|
|
736
1119
|
|
|
737
1120
|
sets = {}
|
|
738
1121
|
if subpath_files:
|
|
739
1122
|
sets["subpaths"] = sorted({p["name"] for p in public if p.get("entry") in subpath_files})
|
|
740
1123
|
if root_files:
|
|
741
|
-
sets["root"] = sorted({p["name"] for p in
|
|
1124
|
+
sets["root"] = sorted({p["name"] for p in public if p.get("entry") in root_files})
|
|
742
1125
|
|
|
743
1126
|
return {
|
|
744
1127
|
"records": records,
|
|
745
1128
|
"nestedRecords": nested_records,
|
|
746
1129
|
"sets": sets,
|
|
747
|
-
"outside": [
|
|
1130
|
+
"outside": [_outside_row(o, scope) for o in outside],
|
|
748
1131
|
"nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
|
|
749
1132
|
"staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
|
|
750
1133
|
"restored": [{"name": o["name"], "file": o["file"], "line": o.get("line")}
|
|
@@ -759,6 +1142,8 @@ def from_extraction(data: dict, language: str | None = None, brief: dict | None
|
|
|
759
1142
|
"ratio": round2(len(reexported) / len(root_names)) if root_names else None,
|
|
760
1143
|
"umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
|
|
761
1144
|
},
|
|
1145
|
+
# The in-scope subpaths the `subpaths` set reads (None when there is no such set).
|
|
1146
|
+
"subpathEntries": len(subpath_entries) if subpath_files else None,
|
|
762
1147
|
"extraction": extraction,
|
|
763
1148
|
}
|
|
764
1149
|
|
|
@@ -828,12 +1213,16 @@ def _over(clause: str, sets: dict, name: str, step: int | None, rebuild: str | N
|
|
|
828
1213
|
|
|
829
1214
|
def pick_denominator(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
|
|
830
1215
|
scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
|
|
831
|
-
curated: bool, source_read: bool) -> dict:
|
|
1216
|
+
curated: bool, source_read: bool, set_trusted: bool = False) -> dict:
|
|
832
1217
|
"""The Source API Surface Definition's clause for a skill, with the branch
|
|
833
|
-
and the name set reconcile-coverage.py and score-signatures.py read,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1218
|
+
and the name set reconcile-coverage.py and score-signatures.py read, the
|
|
1219
|
+
entry points' state it was picked by and, on the scalar branch, whether
|
|
1220
|
+
it counts that set (`set_trusted`: each name at its defining file, read
|
|
1221
|
+
whole; see "Denominator" above)."""
|
|
1222
|
+
picked = _pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
|
|
1223
|
+
pattern_reference, curated, source_read)
|
|
1224
|
+
counted = (set_trusted and picked["surfaceSet"] not in (None, "all")) if picked["branch"] == "scalar" else None
|
|
1225
|
+
return {**picked, "barrel": barrel, "setCounted": counted}
|
|
837
1226
|
|
|
838
1227
|
|
|
839
1228
|
def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
|
|
@@ -868,7 +1257,9 @@ def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str
|
|
|
868
1257
|
elif stats_eff:
|
|
869
1258
|
# A stored 0 is no denominator: the next rung.
|
|
870
1259
|
if deflation_fires:
|
|
871
|
-
|
|
1260
|
+
# The ladder's tier A rung when it has one and the surface has the set, else scope.include.
|
|
1261
|
+
rung = "tier_a_include" if "tier_a_include" in ladder and "tier_a_include" in sets else "scope.include"
|
|
1262
|
+
return _over(clause, sets, rung, step)
|
|
872
1263
|
scored = next((name for name in ("tier_a_include", "scope.include") if name in sets), "all")
|
|
873
1264
|
return {"clause": clause, "branch": "scalar", "surfaceSet": scored, "ladderStep": step,
|
|
874
1265
|
"absentSet": None, "rebuild": None}
|
|
@@ -994,13 +1385,56 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
994
1385
|
and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
|
|
995
1386
|
and not any(_inside(g, e) for e in scope["exclude"])]
|
|
996
1387
|
filed = [r for r in records if r["file"]]
|
|
1388
|
+
# The files each scope set's names sit at, for the Denominator annotations' file counts.
|
|
1389
|
+
set_files: dict[str, list[str]] = {}
|
|
997
1390
|
if filed and scope["include"]:
|
|
998
1391
|
sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
|
|
1392
|
+
set_files["scope.include"] = matching_files(filed, scope["include"], scope["exclude"])
|
|
999
1393
|
in_include = [r for r in filed if not scope["include"]
|
|
1000
1394
|
or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
|
|
1001
1395
|
tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
|
|
1002
1396
|
if tier_a_names:
|
|
1003
1397
|
sets["tier_a_include"] = tier_a_names
|
|
1398
|
+
set_files["tier_a_include"] = matching_files(in_include, tier_a, scope["exclude"])
|
|
1399
|
+
|
|
1400
|
+
# The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
|
|
1401
|
+
# entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
|
|
1402
|
+
# the workflow read (--barrel).
|
|
1403
|
+
barrel = None
|
|
1404
|
+
summary = extracted["extraction"] if extracted is not None else None
|
|
1405
|
+
if summary is not None and not summary["fallback"]["needed"]:
|
|
1406
|
+
barrel = summary["entryPointStatus"]
|
|
1407
|
+
elif summary is not None and summary["entryPointsFound"] is False:
|
|
1408
|
+
barrel = "no-entry-point"
|
|
1409
|
+
elif quick_outputs:
|
|
1410
|
+
barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
|
|
1411
|
+
None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
|
|
1412
|
+
if barrel is None and args.barrel:
|
|
1413
|
+
barrel = BARREL_FLAG[args.barrel]
|
|
1414
|
+
recorded_type = extracted["scopeType"] if extracted is not None else None
|
|
1415
|
+
meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
|
|
1416
|
+
arms = summary["arms"] if summary is not None else {}
|
|
1417
|
+
# The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
|
|
1418
|
+
curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
|
|
1419
|
+
else bool(args.curated_subset)
|
|
1420
|
+
scope_type = (brief or {}).get("type") or recorded_type or meta_type
|
|
1421
|
+
skill_type = meta.get("skill_type") if isinstance(meta, dict) else None
|
|
1422
|
+
monorepo = bool(args.monorepo or arms.get("monorepo"))
|
|
1423
|
+
# The clause alone (no stored count, no guard): only a clause whose ladder holds a stored count measures it
|
|
1424
|
+
# against the tier A set.
|
|
1425
|
+
clause = _pick(sets, None, False, barrel, arms, scope_type, skill_type, monorepo,
|
|
1426
|
+
bool(args.pattern_reference_intent), curated, source_read)["clause"]
|
|
1427
|
+
swap = False
|
|
1428
|
+
# Each name sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every
|
|
1429
|
+
# name carries its defined_in); a name read by eye, or a quick name with no defined_in, sits at its entry file.
|
|
1430
|
+
runner = extracted is not None and not extracted["extraction"]["fallback"]["needed"]
|
|
1431
|
+
quick_traced = (bool(quick_outputs) and not args.per_file and not args.name
|
|
1432
|
+
and all(isinstance(e.get("defined_in"), str) and e["defined_in"]
|
|
1433
|
+
for q in quick_outputs for e in q["exports"] if isinstance(e, dict)))
|
|
1434
|
+
defining = runner or bool(args.fallback_scan) or quick_traced
|
|
1435
|
+
# The scalar branch counts its scope set only from such names read whole: an incomplete or truncated
|
|
1436
|
+
# extraction can miss names (#791, #792).
|
|
1437
|
+
whole = summary is None or (summary["status"] != "incomplete" and not summary["truncated"])
|
|
1004
1438
|
|
|
1005
1439
|
if extracted is not None or brief is not None or meta is not None:
|
|
1006
1440
|
include_union = len(sets["scope.include"]) if "scope.include" in sets else len(sets["all"])
|
|
@@ -1009,7 +1443,10 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
1009
1443
|
"statsEffectiveDenominator": stats_eff,
|
|
1010
1444
|
"tierAIncludeUnion": len(sets["tier_a_include"]) if "tier_a_include" in sets else None,
|
|
1011
1445
|
"scopeIncludeUnion": len(sets["scope.include"]) if "scope.include" in sets else None,
|
|
1446
|
+
"tierAIncludeFiles": len(set_files["tier_a_include"]) if "tier_a_include" in sets else None,
|
|
1447
|
+
"scopeIncludeFiles": len(set_files["scope.include"]) if "scope.include" in sets else None,
|
|
1012
1448
|
"subpathUnion": len(sets["subpaths"]) if "subpaths" in sets else None,
|
|
1449
|
+
"subpathEntries": extracted["subpathEntries"] if extracted is not None else None,
|
|
1013
1450
|
"rootBarrel": len(sets["root"]) if "root" in sets else None,
|
|
1014
1451
|
"extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
|
|
1015
1452
|
"extractorBasis": extraction.get("effectiveDenominatorBasis"),
|
|
@@ -1021,14 +1458,27 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
1021
1458
|
# file, not the file that defines it).
|
|
1022
1459
|
no_tier_a = ("tier_a_include" not in sets if extracted is not None or args.fallback_scan
|
|
1023
1460
|
else not tier_a)
|
|
1024
|
-
|
|
1025
|
-
|
|
1461
|
+
# A clause whose ladder holds a stored count checks it against the tier A set, both ways, when each name
|
|
1462
|
+
# sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every name
|
|
1463
|
+
# carries its defined_in): Create Skill stores the runner's tier A count, so one beyond the threshold is
|
|
1464
|
+
# stale (#813). A name read by eye, or a quick name with no defined_in, sits at its entry file.
|
|
1465
|
+
tier_a_basis = defining and clause in CLAUSE_LADDERS and "tier_a_include" in sets
|
|
1466
|
+
rederived = len(sets["tier_a_include"]) if tier_a_basis else include_union
|
|
1467
|
+
basis = "tier_a_include" if tier_a_basis else "scope.include" if "scope.include" in sets else "all"
|
|
1468
|
+
deflation = {"applicable": source_read and stats_eff is not None, "basis": basis,
|
|
1469
|
+
"effectiveDenominator": stats_eff, "rederived": rederived if source_read else None, "pct": None,
|
|
1470
|
+
"fires": False}
|
|
1026
1471
|
if deflation["applicable"] and stats_eff:
|
|
1027
|
-
deflation["pct"] = round2((
|
|
1028
|
-
deflation["fires"] =
|
|
1472
|
+
deflation["pct"] = round2((rederived - stats_eff) / stats_eff * 100)
|
|
1473
|
+
deflation["fires"] = (abs(deflation["pct"]) > DEFLATION_PCT if tier_a_basis
|
|
1474
|
+
else no_tier_a and deflation["pct"] > DEFLATION_PCT)
|
|
1029
1475
|
elif deflation["applicable"]:
|
|
1030
1476
|
# A stored 0 is deflation, not a denominator, once a name is re-derived.
|
|
1031
|
-
deflation["fires"] = no_tier_a and
|
|
1477
|
+
deflation["fires"] = (tier_a_basis or no_tier_a) and rederived > 0
|
|
1478
|
+
# An incomplete extraction can miss tier A names: a tier A set below the stored count is drift to report,
|
|
1479
|
+
# not a denominator to take.
|
|
1480
|
+
incomplete = runner and extracted["extraction"]["status"] == "incomplete"
|
|
1481
|
+
swap = deflation["fires"] and not (incomplete and tier_a_basis and (deflation["pct"] or 0) < 0)
|
|
1032
1482
|
entry_count = len(_entries(provenance)) if provenance is not None else 0
|
|
1033
1483
|
inflation = {"applicable": source_read and no_tier_a and bool(scope["include"]) and entry_count > 0,
|
|
1034
1484
|
"scopeIncludeUnion": include_union if scope["include"] else None,
|
|
@@ -1052,31 +1502,9 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
1052
1502
|
"umbrellaRatio": UMBRELLA_RATIO},
|
|
1053
1503
|
}
|
|
1054
1504
|
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
barrel = None
|
|
1059
|
-
summary = extracted["extraction"] if extracted is not None else None
|
|
1060
|
-
if summary is not None and not summary["fallback"]["needed"]:
|
|
1061
|
-
barrel = summary["entryPointStatus"]
|
|
1062
|
-
elif summary is not None and summary["entryPointsFound"] is False:
|
|
1063
|
-
barrel = "no-entry-point"
|
|
1064
|
-
elif quick_outputs:
|
|
1065
|
-
barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
|
|
1066
|
-
None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
|
|
1067
|
-
if barrel is None and args.barrel:
|
|
1068
|
-
barrel = BARREL_FLAG[args.barrel]
|
|
1069
|
-
recorded_type = extracted["scopeType"] if extracted is not None else None
|
|
1070
|
-
meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
|
|
1071
|
-
arms = summary["arms"] if summary is not None else {}
|
|
1072
|
-
# The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
|
|
1073
|
-
curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
|
|
1074
|
-
else bool(args.curated_subset)
|
|
1075
|
-
out["denominator"] = pick_denominator(
|
|
1076
|
-
sets, stats_eff, bool((out["guards"] or {}).get("deflation", {}).get("fires")), barrel, arms,
|
|
1077
|
-
(brief or {}).get("type") or recorded_type or meta_type,
|
|
1078
|
-
meta.get("skill_type") if isinstance(meta, dict) else None, bool(args.monorepo or arms.get("monorepo")),
|
|
1079
|
-
bool(args.pattern_reference_intent), curated, source_read)
|
|
1505
|
+
out["denominator"] = pick_denominator(sets, stats_eff, swap, barrel, arms, scope_type, skill_type, monorepo,
|
|
1506
|
+
bool(args.pattern_reference_intent), curated, source_read,
|
|
1507
|
+
defining and whole)
|
|
1080
1508
|
|
|
1081
1509
|
return {
|
|
1082
1510
|
"inputs": {
|
|
@@ -1100,6 +1528,10 @@ def _cmd_census(args: argparse.Namespace) -> dict:
|
|
|
1100
1528
|
return census(args.skill_dir)
|
|
1101
1529
|
|
|
1102
1530
|
|
|
1531
|
+
def _cmd_floor(args: argparse.Namespace) -> dict:
|
|
1532
|
+
return floor(args.skill_dir)
|
|
1533
|
+
|
|
1534
|
+
|
|
1103
1535
|
def _cmd_metadata(args: argparse.Namespace) -> dict:
|
|
1104
1536
|
meta = _object(args.metadata, "--metadata")
|
|
1105
1537
|
provenance = _object(args.provenance, "--provenance") if args.provenance else None
|
|
@@ -1118,6 +1550,11 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
1118
1550
|
p.add_argument("--output", help="also write the result to this file")
|
|
1119
1551
|
p.set_defaults(func=_cmd_census)
|
|
1120
1552
|
|
|
1553
|
+
p = sub.add_parser("floor", help="the names the skill documents in a fixed form, which the inventory keeps")
|
|
1554
|
+
p.add_argument("--skill-dir", required=True, help="the skill package: SKILL.md and references/")
|
|
1555
|
+
p.add_argument("--output", help="also write the result to this file")
|
|
1556
|
+
p.set_defaults(func=_cmd_floor)
|
|
1557
|
+
|
|
1121
1558
|
p = sub.add_parser("metadata", help="the metadata.json and provenance-map counts")
|
|
1122
1559
|
p.add_argument("--metadata", required=True, help="the skill's metadata.json")
|
|
1123
1560
|
p.add_argument("--provenance", help="the provenance map, when there is one")
|