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
|
@@ -5,10 +5,12 @@
|
|
|
5
5
|
# ///
|
|
6
6
|
"""SKF test-skill coverage inputs: the counts, name sets and candidates coverage-check reads.
|
|
7
7
|
|
|
8
|
-
coverage-check.md decides
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
8
|
+
coverage-check.md decides whether a bookkeeping variant is a real export,
|
|
9
|
+
and makes the judgments the denominator clause needs where no file holds
|
|
10
|
+
them (--pattern-reference-intent, and --curated-subset and --barrel where
|
|
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. Three subcommands, each
|
|
13
|
+
reading files and writing one JSON object:
|
|
12
14
|
|
|
13
15
|
census docs-only detection. Counts the citations SKILL.md and each .md
|
|
14
16
|
file directly in references/ write, by form: `[EXT:` against
|
|
@@ -22,7 +24,10 @@ subcommands, each reading files and writing one JSON object:
|
|
|
22
24
|
`confidence_distribution`) under the keys
|
|
23
25
|
check-metadata-coherence.py reads; the provenance named exports
|
|
24
26
|
(`::` impl-block methods left out); the inflation signature
|
|
25
|
-
(`stats.exports_documented` equals `stats.effective_denominator
|
|
27
|
+
(`stats.exports_documented` equals `stats.effective_denominator`,
|
|
28
|
+
or, for a map with a named entry and no `::` entry, its distinct
|
|
29
|
+
entry names do: the stored count counts each name once, the map
|
|
30
|
+
holds a name passed on from two files twice)
|
|
26
31
|
with the declared names verify-declared-numerator.py looks up
|
|
27
32
|
(the `exports[]` names, else the provenance named exports); and,
|
|
28
33
|
for a stack, its composition surface: the distinct provenance
|
|
@@ -34,17 +39,63 @@ subcommands, each reading files and writing one JSON object:
|
|
|
34
39
|
surface the enumerated source surface, from every input given (their
|
|
35
40
|
union):
|
|
36
41
|
--extraction skf-extract-public-api.py --mode full output:
|
|
37
|
-
the names in entry_point_diff.public,
|
|
38
|
-
|
|
39
|
-
|
|
42
|
+
the names in entry_point_diff.public, each
|
|
43
|
+
other in-scope definition of one
|
|
44
|
+
(entry_point_diff.other_definitions, #830) a
|
|
45
|
+
record of its own at its file, a
|
|
46
|
+
namespace with a `members` list given as its
|
|
47
|
+
members (the names its module passes on, each
|
|
48
|
+
at its file, once with a public name of the
|
|
49
|
+
same name and file), less the outside_scope
|
|
50
|
+
names (defined in a file out of scope), which
|
|
51
|
+
`excluded.outsideScope` lists with their file,
|
|
52
|
+
line and entry point and, when the brief
|
|
53
|
+
scopes the file out, why: the scope.exclude
|
|
54
|
+
glob that matches it (`excludeGlob`) or no
|
|
55
|
+
scope.include glob covering it
|
|
56
|
+
(`outsideInclude`); exports[] only gives each name's
|
|
40
57
|
kind, line and signature line, looked up by the
|
|
41
58
|
name its defining file gives it (an aliased
|
|
42
59
|
re-export's `local`) and that file.
|
|
43
|
-
extraction_gaps are listed to read by eye
|
|
44
|
-
|
|
60
|
+
extraction_gaps are listed to read by eye, and
|
|
61
|
+
the runner's warnings join `warnings`. A Python
|
|
62
|
+
top __init__.py inside the folder of another top
|
|
63
|
+
that exports a public name (a sub-package
|
|
64
|
+
behind a folder with no __init__.py) is no
|
|
65
|
+
barrel, unless scope.include or tier_a_include
|
|
66
|
+
lists its file: its names leave `all`, `root`
|
|
67
|
+
and the umbrella ratio and are listed in
|
|
68
|
+
`excluded.nestedEntries`, but keep their records
|
|
69
|
+
(so the scope.include and tier_a_include sets)
|
|
70
|
+
and their outside_scope and extraction_gaps
|
|
71
|
+
rows. When the runner lists an
|
|
72
|
+
include glob that matches no file
|
|
73
|
+
(scope.unmatched_include: a brief older than the
|
|
74
|
+
source), each outside_scope name a root entry
|
|
75
|
+
point exports is restored, with the file and
|
|
76
|
+
line of its outside_scope row, when no include,
|
|
77
|
+
exclude or declined scope-expansion amendment
|
|
78
|
+
(latest action skipped or demoted-include, from
|
|
79
|
+
--brief) matches its file. A tier_a_include glob
|
|
80
|
+
the runner lists as matching no file
|
|
81
|
+
(scope.unmatched_tier_a_include, [] when the key
|
|
82
|
+
is absent) counts no name. An extraction the
|
|
83
|
+
per-file scan replaces (extraction.fallback.needed)
|
|
84
|
+
gives no name and no set: only its scope, its
|
|
85
|
+
stale globs and its `extraction` object, so the
|
|
86
|
+
fallback scan passes it beside its results.
|
|
87
|
+
--quick skf-extract-public-api.py --mode quick output,
|
|
88
|
+
each name at its defined_in (the file that
|
|
89
|
+
defines it), else at its source_file
|
|
45
90
|
--per-file a subagent's per-file result (exports_found and
|
|
46
91
|
types_found), schema-checked as
|
|
47
92
|
score-signatures.py checks it
|
|
93
|
+
--fallback-scan
|
|
94
|
+
the --per-file results are the fallback
|
|
95
|
+
per-file scan's, each at the file that defines
|
|
96
|
+
its names (a Quick-tier or State 4 result is at
|
|
97
|
+
its entry file): as with an extraction, no
|
|
98
|
+
tier_a_include set is no tier A list
|
|
48
99
|
--name a name read by eye whose file is not known
|
|
49
100
|
--provenance the provenance entries' named exports (State 2)
|
|
50
101
|
--metadata the metadata.json exports[] names (States 2, 3)
|
|
@@ -58,51 +109,157 @@ subcommands, each reading files and writing one JSON object:
|
|
|
58
109
|
names to their base name (`X_def`, `X_exact`, `a11y_X`, and
|
|
59
110
|
`<prefix>X` for each --fold-prefix when `X` is itself a name)
|
|
60
111
|
unless --keep names the variant as a real export, and
|
|
61
|
-
`canonical` gives the provenance map's fold summary
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
112
|
+
`canonical` gives the provenance map's fold summary; a map
|
|
113
|
+
whose distinct named exports number
|
|
114
|
+
`stats.effective_denominator` (--metadata) is already canonical
|
|
115
|
+
and is not folded (`canonical.skipped`), and `residual` is the
|
|
116
|
+
canonical count less that stored count. --brief gives the
|
|
117
|
+
skill brief's scope, scope type, language and scope-expansion
|
|
118
|
+
amendments; an extraction's recorded scope wins over its scope.
|
|
119
|
+
|
|
120
|
+
Name sets (surface `sets`, each sorted): `all` (the union above, less a
|
|
121
|
+
nested Python top's names);
|
|
66
122
|
`scope.include` and `tier_a_include` when the scope has those globs and a
|
|
67
|
-
record names a file: the names
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
and `
|
|
73
|
-
and
|
|
123
|
+
record names a file: the names with a file (any of the name's records: an
|
|
124
|
+
extraction's definition file, each other in-scope definition's included,
|
|
125
|
+
the quick output's defined_in, else its source_file, a per-file result's
|
|
126
|
+
file, a provenance entry's source_file) that matches a glob of the list
|
|
127
|
+
and no scope.exclude glob,
|
|
128
|
+
and for `tier_a_include` a scope.include glob too when the scope has one,
|
|
129
|
+
so a name with no file is in `all` only and a restored name is in no
|
|
130
|
+
tier_a_include set (a tier_a_include glob an extraction lists as matching
|
|
131
|
+
no file is left out of the list, and so is one whose folder or file
|
|
132
|
+
differs from every scope.include glob's before any `*` or `?`, such as
|
|
133
|
+
`gen/**` beside `src/**`, and so is one a scope.exclude glob covers
|
|
134
|
+
segment by segment, such as `src/gen/**` or `src/gen/extra.ts` beside an
|
|
135
|
+
exclude of `src/gen/**`; a list that counts no name, each glob left out
|
|
136
|
+
or matching only excluded files, files scope.include leaves out or files
|
|
137
|
+
no record names, gives no tier_a_include set); and, from an
|
|
138
|
+
extraction only, `subpaths` (the names a non-root `exports` map subpath
|
|
139
|
+
without `*` reaches) and `root` (the names the root entry points reach),
|
|
140
|
+
each through the entry point of any of the name's definitions.
|
|
141
|
+
reconcile-coverage.py and score-signatures.py --surface-set pick one.
|
|
74
142
|
|
|
75
143
|
Candidates and guards (with an extraction, --brief or --metadata): each
|
|
76
144
|
set's count, the metadata `stats.effective_denominator` (--metadata), and
|
|
77
145
|
the extractor's own effective_denominator and its basis. Both guards run
|
|
78
|
-
only on a surface read from the source. The deflation guard compares
|
|
79
|
-
re-derived
|
|
80
|
-
|
|
81
|
-
|
|
146
|
+
only on a surface read from the source. The deflation guard compares a
|
|
147
|
+
re-derived set with `stats.effective_denominator` and names it in `basis`.
|
|
148
|
+
It is the tier_a_include set (`tier_a_include`) when the clause's ladder
|
|
149
|
+
holds a stored count (stratified-scope, specific-modules, multi-entry), the
|
|
150
|
+
surface has that set and each name sits at the file that defines it (a
|
|
151
|
+
runner extraction, --fallback-scan, or quick output whose every name
|
|
152
|
+
carries its defined_in, with no --per-file or --name beside it): it fires
|
|
153
|
+
when the stored count differs from the set by more than DEFLATION_PCT
|
|
154
|
+
either way, since Create Skill stores the runner's tier A count. Otherwise
|
|
155
|
+
it is the scope.include set (`scope.include`, else `all` when the surface
|
|
156
|
+
has no scope.include set), firing above DEFLATION_PCT when there is no tier
|
|
157
|
+
A list (with an extraction or --fallback-scan, no tier_a_include set;
|
|
158
|
+
otherwise no tier_a_include glob left in the list, since a name read by eye
|
|
159
|
+
at Quick tier or State 4, and a quick name with no defined_in, carries its
|
|
160
|
+
entry file, not the file that defines it). A stored 0 is deflation, not a
|
|
161
|
+
denominator (`pct` null), and fires under its basis's condition when any
|
|
162
|
+
name is re-derived. The inflation guard compares the scope.include set
|
|
82
163
|
with the provenance entry count (--provenance) and fires above
|
|
83
|
-
INFLATION_PCT when
|
|
164
|
+
INFLATION_PCT when there is no tier A list, as the scope.include basis
|
|
165
|
+
reads it; `guards.tierAInclude` lists the scope's
|
|
166
|
+
tier_a_include globs ([] when it has none) for gap-ledger.py's wording;
|
|
167
|
+
the umbrella ratio (an
|
|
84
168
|
extraction only) is the share of the root entry points' names that are
|
|
85
|
-
re-exports
|
|
169
|
+
re-exports (a namespace with `members` counting as its members there
|
|
170
|
+
too, each a re-export, and an other in-scope definition left out), and
|
|
171
|
+
above UMBRELLA_RATIO the barrel is an
|
|
172
|
+
umbrella; the
|
|
173
|
+
stale-scope guard (an extraction with scope.include or tier_a_include only)
|
|
174
|
+
fires when the runner lists an include or tier_a_include glob that matches
|
|
175
|
+
no file, and lists those globs (`unmatchedInclude`,
|
|
176
|
+
`unmatchedTierAInclude`) and the names restored for the include globs, each
|
|
177
|
+
with its file and line (`restored`). Globs follow
|
|
86
178
|
skf-extract-public-api.py: `**` spans any number of path segments, none
|
|
87
179
|
included, and `*` and `?` stay inside one.
|
|
88
180
|
|
|
181
|
+
Denominator (surface `denominator`): the Source API Surface Definition's
|
|
182
|
+
clause (source-access-protocol.md), the branch reconcile-coverage.py takes
|
|
183
|
+
and the set it and score-signatures.py --surface-set read: {clause, branch,
|
|
184
|
+
surfaceSet, ladderStep, absentSet, rebuild, barrel, setCounted}.
|
|
185
|
+
`setCounted` (null off the scalar branch) says whether the scalar branch
|
|
186
|
+
counts its `surfaceSet` as the numerator's set: true only for a scope set
|
|
187
|
+
(not `all`) whose every name sits at the file that defines it, as the
|
|
188
|
+
deflation guard's tier A basis requires (a runner extraction, the fallback
|
|
189
|
+
scan, quick output whose every name carries its defined_in), read whole (an
|
|
190
|
+
extraction neither `incomplete` nor truncated); otherwise the scalar branch
|
|
191
|
+
keeps its looked-up numerator. The inputs: the scope
|
|
192
|
+
type (the brief's, else the extraction's, else metadata.json's
|
|
193
|
+
`scope_type`), metadata.json's `skill_type` and `stats.effective_denominator`,
|
|
194
|
+
the extraction's `arms`, the deflation guard, and:
|
|
195
|
+
monorepo --monorepo (the workspace layout skf-detect-workspaces.py
|
|
196
|
+
found), or the extraction's `arms.monorepo`.
|
|
197
|
+
curated the extraction's `counts.curated_subset` (the rule Create Skill
|
|
198
|
+
stores the count by), else --curated-subset (the workflow's
|
|
199
|
+
judgment that a monorepo's scope.include lists a curated subset).
|
|
200
|
+
barrel (`barrel`: "barrel", "empty-barrel", "no-entry-point" or null)
|
|
201
|
+
a complete extraction's `entry_points.status`; for one the
|
|
202
|
+
per-file scan replaces, "no-entry-point" when it read a file in
|
|
203
|
+
scope and found no entry point (`entry_points.files` empty), as
|
|
204
|
+
a complete run says; a quick output's (an export is a barrel,
|
|
205
|
+
none and no warning an empty one); else --barrel (`barrel`,
|
|
206
|
+
`empty` or `none`, what the workflow read); else null.
|
|
207
|
+
--pattern-reference-intent the workflow's judgment that scope.notes
|
|
208
|
+
flags pattern-reference intent.
|
|
209
|
+
The first clause that applies:
|
|
210
|
+
stack skill_type stack: branch `stack`, no set.
|
|
211
|
+
pattern-reference not a monorepo, no non-empty barrel, a scope type other
|
|
212
|
+
than specific-modules, component-library, docs-only and
|
|
213
|
+
reference-app, not public-api with a multi-subpath
|
|
214
|
+
`exports` map, and either the intent flag or no entry
|
|
215
|
+
point at all with no such map: the tier_a_include set,
|
|
216
|
+
else `all` with `rebuild` `provenance-fold` on a surface
|
|
217
|
+
read from the source (build it again from the provenance
|
|
218
|
+
map alone, folded), `all` on one that is not.
|
|
219
|
+
empty-barrel an empty barrel: the scope.include set.
|
|
220
|
+
stratified-scope --monorepo and --curated-subset, not full-library.
|
|
221
|
+
specific-modules scope type specific-modules, not --monorepo.
|
|
222
|
+
multi-entry arms.multi_subpath_exports, not --monorepo, a
|
|
223
|
+
full-library or public-api scope.
|
|
224
|
+
standard-barrel any other: the `all` set.
|
|
225
|
+
The last three walk a ladder, `ladderStep` naming the rung taken: rung 1,
|
|
226
|
+
`stats.effective_denominator` when metadata.json has one above 0 (a stored
|
|
227
|
+
0 is no denominator), is the `scalar` branch, whose `surfaceSet` (the set score-signatures.py scores) is
|
|
228
|
+
tier_a_include, else scope.include, else all, unless the deflation guard
|
|
229
|
+
fires: then the barrel branch over tier_a_include when the ladder has that
|
|
230
|
+
rung (stratified-scope, multi-entry) and the surface has the set, else over
|
|
231
|
+
scope.include (when the inflation guard fires too, that union is itself
|
|
232
|
+
inflated, and both gaps ask for the brief's tier A list, never a stored
|
|
233
|
+
count set by hand). On an incomplete extraction, which can miss tier A
|
|
234
|
+
names, a tier A set below the stored count fires the guard and leaves the
|
|
235
|
+
scalar branch. The next rungs are sets,
|
|
236
|
+
each taken when the surface has it, the last taken in any case:
|
|
237
|
+
tier_a_include then scope.include (stratified-scope), scope.include
|
|
238
|
+
(specific-modules), tier_a_include then subpaths (multi-entry). A set the
|
|
239
|
+
surface lacks is counted as `all`, and `absentSet` names it.
|
|
240
|
+
|
|
89
241
|
Fallback verdict: `extraction.fallback.needed` is true when the extractor
|
|
90
242
|
found no ast-grep (`no-ast-grep`), read no file in scope, read files only
|
|
91
243
|
in languages it has no recipe for (`files_without_recipes` and no export),
|
|
92
244
|
or found files no recipe reads while the skill's language (the --brief
|
|
93
245
|
`language`, else the metadata.json `language`) is not one RECIPE_LANGUAGES
|
|
94
|
-
holds, so the per-file scan has to run instead.
|
|
95
|
-
its
|
|
96
|
-
|
|
246
|
+
holds, so the per-file scan has to run instead. A run keeps its exports
|
|
247
|
+
whatever its status; `extraction.readByEye` lists the files to read by eye,
|
|
248
|
+
at any status: each file the runner could not parse or decode (a
|
|
249
|
+
`syntax-errors` or `not-utf8` file issue) and, on an `incomplete` run, each
|
|
250
|
+
file a failed call names. `extraction.unread` lists the files nobody can
|
|
251
|
+
read (a `missing` or `unreadable` file issue); a `no-recipes` file, such as
|
|
252
|
+
a stylesheet, holds nothing to read and is in neither list.
|
|
97
253
|
|
|
98
254
|
Usage:
|
|
99
255
|
uv run load-coverage-inputs.py census --skill-dir <dir> [--output <file>]
|
|
100
256
|
uv run load-coverage-inputs.py metadata --metadata <metadata.json> \\
|
|
101
257
|
[--provenance <provenance-map.json>] [--output <file>]
|
|
102
258
|
uv run load-coverage-inputs.py surface [--extraction <file>] [--quick <file>]... \\
|
|
103
|
-
[--per-file <file>]... [--name <name>]... [--provenance <file>]
|
|
104
|
-
[--brief <skill-brief.yaml>] [--fold] [--fold-prefix <prefix>]...
|
|
105
|
-
[--
|
|
259
|
+
[--per-file <file>]... [--fallback-scan] [--name <name>]... [--provenance <file>] \\
|
|
260
|
+
[--metadata <file>] [--brief <skill-brief.yaml>] [--fold] [--fold-prefix <prefix>]... \\
|
|
261
|
+
[--keep <name>]... [--monorepo] [--pattern-reference-intent] [--curated-subset] \\
|
|
262
|
+
[--barrel barrel|empty|none] [--output <file>]
|
|
106
263
|
|
|
107
264
|
Exit codes:
|
|
108
265
|
0 the result was printed (and written to --output)
|
|
@@ -119,6 +276,7 @@ import argparse
|
|
|
119
276
|
import importlib.util
|
|
120
277
|
import json
|
|
121
278
|
import math
|
|
279
|
+
import posixpath
|
|
122
280
|
import re
|
|
123
281
|
import sys
|
|
124
282
|
from pathlib import Path
|
|
@@ -140,6 +298,19 @@ LOCAL_FORMS = ("AST", "SRC", "from skill")
|
|
|
140
298
|
FOLD_SUFFIXES = ("_def", "_exact")
|
|
141
299
|
FOLD_PREFIX = "a11y_"
|
|
142
300
|
ROOT_SUBPATHS = (None, ".")
|
|
301
|
+
# The latest action of a scope-expansion amendment that keeps its path out of scope.
|
|
302
|
+
DECLINED_ACTIONS = ("skipped", "demoted-include")
|
|
303
|
+
# The rungs each laddered clause tries in order, `scalar` being stats.effective_denominator.
|
|
304
|
+
CLAUSE_LADDERS = {
|
|
305
|
+
"stratified-scope": ("scalar", "tier_a_include", "scope.include"),
|
|
306
|
+
"specific-modules": ("scalar", "scope.include"),
|
|
307
|
+
"multi-entry": ("scalar", "tier_a_include", "subpaths"),
|
|
308
|
+
}
|
|
309
|
+
# --barrel: what the workflow read of the entry points, where surface cannot tell -> the extractor's status.
|
|
310
|
+
BARREL_FLAG = {"barrel": "barrel", "empty": "empty-barrel", "none": "no-entry-point"}
|
|
311
|
+
# The scope types the pattern-reference clause never applies to (a reference app's brief carries its own
|
|
312
|
+
# pattern-surface denominator).
|
|
313
|
+
NOT_PATTERN_REFERENCE = frozenset({"specific-modules", "component-library", "docs-only", "reference-app"})
|
|
143
314
|
# The languages skf-extract-public-api.py's recipes read (its LANGUAGE_FAMILIES).
|
|
144
315
|
RECIPE_LANGUAGES = frozenset({"python", "rust", "go", "golang", "typescript", "ts", "tsx", "javascript", "js",
|
|
145
316
|
"jsx", "vue"})
|
|
@@ -158,17 +329,21 @@ class SchemaError(Exception):
|
|
|
158
329
|
|
|
159
330
|
|
|
160
331
|
_SIBLINGS: dict[str, object] = {}
|
|
332
|
+
SHARED_SCRIPTS = Path(__file__).resolve().parent.parent.parent / "shared" / "scripts"
|
|
161
333
|
|
|
162
334
|
|
|
163
|
-
def _sibling(filename: str):
|
|
335
|
+
def _sibling(filename: str, folder: Path | None = None):
|
|
164
336
|
"""A script beside this one, loaded once: validate-inventory.py (the doc
|
|
165
|
-
text) and score-signatures.py (the per-file result schema)
|
|
337
|
+
text) and score-signatures.py (the per-file result schema); or, with
|
|
338
|
+
`folder`, one in that folder: SHARED_SCRIPTS'
|
|
339
|
+
skf-resolve-authoritative-files.py (a skill brief's YAML)."""
|
|
166
340
|
module = _SIBLINGS.get(filename)
|
|
167
341
|
if module is None:
|
|
168
|
-
path = Path(__file__).resolve().parent / filename
|
|
342
|
+
path = (folder or Path(__file__).resolve().parent) / filename
|
|
169
343
|
spec = importlib.util.spec_from_file_location("skf_" + filename[:-3].replace("-", "_"), path)
|
|
170
344
|
if spec is None or spec.loader is None or not path.is_file():
|
|
171
|
-
|
|
345
|
+
where = f"in {folder}" if folder else f"beside {Path(__file__).name}"
|
|
346
|
+
raise InputError(f"{filename} not found {where}")
|
|
172
347
|
module = importlib.util.module_from_spec(spec)
|
|
173
348
|
spec.loader.exec_module(module)
|
|
174
349
|
_SIBLINGS[filename] = module
|
|
@@ -306,7 +481,13 @@ def metadata_inputs(meta: dict, provenance: dict | None) -> dict:
|
|
|
306
481
|
"confidenceDistribution": dist or None,
|
|
307
482
|
"namedExports": {"count": len(named), "names": named} if entry_names is not None else None,
|
|
308
483
|
"effectiveDenominator": effective,
|
|
309
|
-
|
|
484
|
+
# The stored count counts each name once, while the map (so stats.exports_documented) holds an entry for
|
|
485
|
+
# each name and file: a name the entry points pass on from two files is two entries, one name. Only a map
|
|
486
|
+
# with no `::` entry takes that arm: the stored count holds no method, so a map with methods could match it
|
|
487
|
+
# only by chance, and its methods would stand in for an undocumented name.
|
|
488
|
+
"inflationSignature": effective is not None and (
|
|
489
|
+
documented == effective or (bool(entry_names) and not any(METHOD_SEPARATOR in n for n in entry_names)
|
|
490
|
+
and len(set(entry_names)) == effective)),
|
|
310
491
|
"declaredNames": sorted(set(names)) if names else named,
|
|
311
492
|
"stack": stack,
|
|
312
493
|
}
|
|
@@ -359,13 +540,16 @@ def _scope(include, exclude, tier_a_include) -> dict:
|
|
|
359
540
|
|
|
360
541
|
|
|
361
542
|
def read_brief(path: str) -> dict:
|
|
362
|
-
"""A skill brief's scope globs and language, the fields skf-extract-public-api.py reads
|
|
543
|
+
"""A skill brief's scope globs, scope type and language, the fields skf-extract-public-api.py reads, and the
|
|
544
|
+
latest action of each scope-expansion amendment by its path (`amendments`), a legacy headless skip
|
|
545
|
+
read as a deferral as skf-resolve-authoritative-files.py reads it."""
|
|
363
546
|
try:
|
|
364
547
|
import yaml # --brief only; `uv run` installs it from the header
|
|
365
548
|
except ImportError as exc:
|
|
366
549
|
raise InputError("--brief is read with PyYAML: run the script with `uv run`") from exc
|
|
550
|
+
resolver = _sibling("skf-resolve-authoritative-files.py", SHARED_SCRIPTS)
|
|
367
551
|
try:
|
|
368
|
-
data =
|
|
552
|
+
data = resolver.parse_brief_yaml(Path(path).read_bytes().decode("utf-8-sig"))
|
|
369
553
|
except (OSError, UnicodeDecodeError) as exc:
|
|
370
554
|
raise InputError(f"cannot read --brief {path}: {exc}") from exc
|
|
371
555
|
except yaml.YAMLError as exc:
|
|
@@ -373,8 +557,17 @@ def read_brief(path: str) -> dict:
|
|
|
373
557
|
if not isinstance(data, dict):
|
|
374
558
|
raise InputError(f"--brief {path} is not a YAML mapping")
|
|
375
559
|
scope = data.get("scope") if isinstance(data.get("scope"), dict) else {}
|
|
560
|
+
amendments: dict[str, str] = {}
|
|
561
|
+
for amend in scope.get("amendments") if isinstance(scope.get("amendments"), list) else []:
|
|
562
|
+
if isinstance(amend, dict) and amend.get("category") == "scope-expansion":
|
|
563
|
+
amended = _norm(amend["path"]) if isinstance(amend.get("path"), str) else ""
|
|
564
|
+
action = resolver._amendment_action(amend)
|
|
565
|
+
if amended and action is not None:
|
|
566
|
+
amendments[amended] = action
|
|
376
567
|
return {"scope": _scope(scope.get("include"), scope.get("exclude"), scope.get("tier_a_include")),
|
|
377
|
-
"
|
|
568
|
+
"type": scope.get("type") if isinstance(scope.get("type"), str) else None,
|
|
569
|
+
"language": data.get("language") if isinstance(data.get("language"), str) else None,
|
|
570
|
+
"amendments": amendments}
|
|
378
571
|
|
|
379
572
|
|
|
380
573
|
def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list[str]:
|
|
@@ -384,70 +577,246 @@ def matching(records: list[dict], globs: list[str], excludes: list[str]) -> list
|
|
|
384
577
|
and not any(glob_match(_norm(r["file"]), g) for g in excludes)})
|
|
385
578
|
|
|
386
579
|
|
|
387
|
-
def
|
|
388
|
-
"""
|
|
580
|
+
def _apart(a: str, b: str) -> bool:
|
|
581
|
+
"""True when, before either has a `*` or `?`, the two globs spell a different segment at the same
|
|
582
|
+
place, so no file can match both. Any other pair counts as overlapping, even one no file can match
|
|
583
|
+
both of (`src` beside `src/index.ts`), so this never parts two that can share a file."""
|
|
584
|
+
for x, y in zip(a.split("/"), b.split("/")):
|
|
585
|
+
if any(c in x or c in y for c in "*?"):
|
|
586
|
+
return False
|
|
587
|
+
if x != y:
|
|
588
|
+
return True
|
|
589
|
+
return False
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _inside(a: str, b: str) -> bool:
|
|
593
|
+
"""True when glob `b` matches every path glob `a` can match: segment by segment each of `b`'s is
|
|
594
|
+
`**`, the same as `a`'s, or `*` beside one of `a`'s that is not `**`, and `a` has as many segments
|
|
595
|
+
as `b`, or more when `b` ends in `**`. Any other pair counts as not inside, even one where `b`
|
|
596
|
+
does cover `a` (`gen/**` in `**/gen/**`), so this never drops a glob that can count a name."""
|
|
597
|
+
xs, ys = a.split("/"), b.split("/")
|
|
598
|
+
if len(xs) < len(ys) or (len(xs) > len(ys) and ys[-1] != "**"):
|
|
599
|
+
return False
|
|
600
|
+
return all(y == "**" or x == y or (y == "*" and x != "**") for x, y in zip(xs, ys))
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _nested_tops(entry_files: list[dict], named: set[str], exporting: set[str]) -> dict[str, str]:
|
|
604
|
+
"""{top: the outermost top whose folder holds it} for each Python top
|
|
605
|
+
__init__.py inside the folder of another top that `exporting` lists (one
|
|
606
|
+
that exports at least one public name), a sub-package behind a folder
|
|
607
|
+
with no __init__.py, unless `named` lists its file. A top that exports
|
|
608
|
+
nothing (a stray __init__.py at the source root, say) nests no other."""
|
|
609
|
+
tops = sorted({e["file"] for e in entry_files
|
|
610
|
+
if e.get("language") == "python" and isinstance(e.get("file"), str) and e["file"]})
|
|
611
|
+
nested = {}
|
|
612
|
+
for top in tops:
|
|
613
|
+
folder = posixpath.dirname(top)
|
|
614
|
+
outer = [o for o in tops if o != top and o in exporting
|
|
615
|
+
and (not posixpath.dirname(o) or folder.startswith(posixpath.dirname(o) + "/"))]
|
|
616
|
+
if outer and _norm(top) not in named:
|
|
617
|
+
nested[top] = min(outer, key=lambda o: (o.count("/"), o))
|
|
618
|
+
return nested
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _members_in_place(public: list[dict]) -> list[dict]:
|
|
622
|
+
"""The public names, a namespace item with a `members` list (the names
|
|
623
|
+
its module passes on, `pkg.sub.name`) given as its members, each with
|
|
624
|
+
the namespace's entry and language and `via: namespace`. A member whose
|
|
625
|
+
name and file another public name, or an earlier member, has is left
|
|
626
|
+
out; a namespace whose members the runner could not read (null), or one
|
|
627
|
+
from a runner that lists none, stays itself."""
|
|
628
|
+
expanded = [p for p in public if not (p.get("via") == "namespace" and isinstance(p.get("members"), list))]
|
|
629
|
+
seen = {(p["name"], p.get("file")) for p in expanded}
|
|
630
|
+
for item in public:
|
|
631
|
+
if item.get("via") != "namespace" or not isinstance(item.get("members"), list):
|
|
632
|
+
continue
|
|
633
|
+
for member in item["members"]:
|
|
634
|
+
if not isinstance(member, dict) or not isinstance(member.get("name"), str) or not member["name"]:
|
|
635
|
+
continue
|
|
636
|
+
key = (member["name"], member.get("file"))
|
|
637
|
+
if key in seen:
|
|
638
|
+
continue
|
|
639
|
+
seen.add(key)
|
|
640
|
+
expanded.append({"name": member["name"], "language": item.get("language"), "entry": item.get("entry"),
|
|
641
|
+
"via": "namespace", "local": member.get("local"), "file": member.get("file"),
|
|
642
|
+
"line": member.get("line")})
|
|
643
|
+
return expanded
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
# The runner's file issues for a file it could not open at all: no one can read it by eye either.
|
|
647
|
+
UNREADABLE_ISSUES = frozenset({"missing", "unreadable"})
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _extraction_summary(data: dict, language: str | None) -> dict:
|
|
651
|
+
"""The surface's `extraction` object: the runner's status and counts,
|
|
652
|
+
the files to read by eye, the files no one can read and the fallback
|
|
653
|
+
verdict."""
|
|
654
|
+
counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
|
|
655
|
+
arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
|
|
656
|
+
entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
|
|
657
|
+
without = data.get("files_without_recipes") if isinstance(data.get("files_without_recipes"), dict) else {}
|
|
658
|
+
status = data.get("status")
|
|
659
|
+
reason = None
|
|
660
|
+
if status == "no-ast-grep":
|
|
661
|
+
reason = "no ast-grep the runner can run"
|
|
662
|
+
elif not data.get("files_in_scope"):
|
|
663
|
+
reason = "no file in scope"
|
|
664
|
+
elif without and not data.get("exports"):
|
|
665
|
+
reason = f"no recipe reads the files in scope ({', '.join(sorted(without))})"
|
|
666
|
+
elif without and language and language.strip().lower() not in RECIPE_LANGUAGES:
|
|
667
|
+
reason = (f"no recipe reads the skill's language, {language} "
|
|
668
|
+
f"({', '.join(sorted(without))} in scope)")
|
|
669
|
+
errors = [e for e in data.get("errors") or [] if isinstance(e, dict)]
|
|
670
|
+
issues = [i for i in data.get("file_issues") or [] if isinstance(i, dict) and i.get("file")]
|
|
671
|
+
# A file missing from disk or one the runner could not open is no file to read by eye.
|
|
672
|
+
unread = sorted({str(i["file"]) for i in issues if i.get("issue") in UNREADABLE_ISSUES})
|
|
673
|
+
read_by_eye = sorted({str(i["file"]) for i in issues
|
|
674
|
+
if i.get("issue") != "no-recipes" and i.get("issue") not in UNREADABLE_ISSUES}
|
|
675
|
+
| {str(e["first_file"]) for e in errors if e.get("first_file")}
|
|
676
|
+
| {str(f) for e in errors if isinstance(e.get("unread"), list) for f in e["unread"] if f})
|
|
677
|
+
return {
|
|
678
|
+
"status": status,
|
|
679
|
+
"filesInScope": data.get("files_in_scope"),
|
|
680
|
+
"truncated": data.get("truncated"),
|
|
681
|
+
"effectiveDenominator": _count(counts.get("effective_denominator")),
|
|
682
|
+
"effectiveDenominatorBasis": counts.get("effective_denominator_basis"),
|
|
683
|
+
"exportsPublicApi": _count(counts.get("exports_public_api")),
|
|
684
|
+
"arms": arms,
|
|
685
|
+
"curatedSubset": counts.get("curated_subset") if isinstance(counts.get("curated_subset"), bool) else None,
|
|
686
|
+
"entryPointStatus": entry_points.get("status"),
|
|
687
|
+
# A run that read a file in scope and found no entry point says so whether or not ast-grep ran.
|
|
688
|
+
"entryPointsFound": (bool(entry_points["files"]) if isinstance(entry_points.get("files"), list)
|
|
689
|
+
and data.get("files_in_scope") else None),
|
|
690
|
+
"readByEye": read_by_eye,
|
|
691
|
+
"unread": unread,
|
|
692
|
+
"fallback": {"needed": reason is not None, "reason": reason},
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
NO_UMBRELLA = {"entries": [], "names": 0, "reexported": 0, "ratio": None, "umbrella": False}
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _outside_row(row: dict, scope: dict) -> dict:
|
|
700
|
+
"""An outside_scope row as `excluded.outsideScope` lists it: its name,
|
|
701
|
+
file, line and entry point, and why the brief scopes the file out when
|
|
702
|
+
it does (#796): `excludeGlob`, the first scope.exclude glob that matches
|
|
703
|
+
the file, and `outsideInclude`, true when scope.include has globs and
|
|
704
|
+
none matches it. Neither (null and false) when the brief does not
|
|
705
|
+
explain it, such as a file no recipe reads or no file at all."""
|
|
706
|
+
file = row.get("file") if isinstance(row.get("file"), str) and row.get("file") else None
|
|
707
|
+
path = _norm(file) if file else None
|
|
708
|
+
return {"name": row.get("name"), "file": file, "line": row.get("line"), "entry": row.get("entry"),
|
|
709
|
+
"excludeGlob": next((g for g in scope["exclude"] if glob_match(path, g)), None) if path else None,
|
|
710
|
+
"outsideInclude": bool(path and scope["include"]
|
|
711
|
+
and not any(glob_match(path, g) for g in scope["include"]))}
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def from_extraction(data: dict, language: str | None = None, brief: dict | None = None) -> dict:
|
|
715
|
+
"""The public surface of an extraction, less what lies outside scope,
|
|
716
|
+
plus the root exports a stale scope.include glob left out. A nested
|
|
717
|
+
Python top's records come apart (`nestedRecords`): they count in the
|
|
718
|
+
scope sets, not in `all`. An extraction the per-file scan replaces
|
|
719
|
+
(`extraction.fallback.needed`) gives only its scope, its stale globs and
|
|
720
|
+
its `extraction` object: no name and no set."""
|
|
389
721
|
if data.get("mode") != "full":
|
|
390
722
|
raise InputError("--extraction is not skf-extract-public-api.py --mode full output")
|
|
723
|
+
|
|
724
|
+
# The scope: the one the extractor recorded, else the brief's.
|
|
725
|
+
recorded = data.get("scope") if isinstance(data.get("scope"), dict) else {}
|
|
726
|
+
scope = _scope(recorded.get("include"), recorded.get("exclude"), recorded.get("tier_a_include"))
|
|
727
|
+
if not any(scope.values()) and brief is not None:
|
|
728
|
+
scope = brief["scope"]
|
|
729
|
+
unmatched = _globs(recorded.get("unmatched_include"))
|
|
730
|
+
# A tier A glob that matches no file: the runner's list, never re-matched here.
|
|
731
|
+
unmatched_tier_a = _globs(recorded.get("unmatched_tier_a_include"))
|
|
732
|
+
extraction = _extraction_summary(data, language)
|
|
733
|
+
if extraction["fallback"]["needed"]:
|
|
734
|
+
return {"records": [], "nestedRecords": [], "sets": {}, "outside": [], "nested": [],
|
|
735
|
+
"staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
|
|
736
|
+
"restored": []},
|
|
737
|
+
"gaps": [], "scope": scope, "scopeType": recorded.get("type"), "umbrella": dict(NO_UMBRELLA),
|
|
738
|
+
"extraction": extraction}
|
|
739
|
+
|
|
391
740
|
diff = data.get("entry_point_diff") if isinstance(data.get("entry_point_diff"), dict) else {}
|
|
392
|
-
|
|
741
|
+
# Each in-scope definition of a public name is a record of its own (#830): the runner's other
|
|
742
|
+
# definitions join the public rows, so a set holds a name when any of its files matches, and
|
|
743
|
+
# the signature plan reads every declaration.
|
|
744
|
+
rows = {key: [p for p in diff.get(key) or [] if isinstance(p, dict) and isinstance(p.get("name"), str)]
|
|
745
|
+
for key in ("public", "other_definitions")}
|
|
746
|
+
public = _members_in_place(rows["public"] + rows["other_definitions"])
|
|
393
747
|
outside = [o for o in diff.get("outside_scope") or [] if isinstance(o, dict)]
|
|
394
748
|
gaps = [g for g in diff.get("extraction_gaps") or [] if isinstance(g, dict)]
|
|
749
|
+
|
|
750
|
+
entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
|
|
751
|
+
entry_files = [e for e in entry_points.get("files") or [] if isinstance(e, dict)]
|
|
752
|
+
# A Python top nested in another that exports a name is no barrel: its
|
|
753
|
+
# names leave `all`, `root` and the umbrella ratio, and keep their
|
|
754
|
+
# records (so the scope sets a specific-modules or stratified brief
|
|
755
|
+
# counts) and their outside_scope and extraction_gaps rows.
|
|
756
|
+
nested = _nested_tops(entry_files, set(scope["include"]) | set(scope["tier_a_include"]),
|
|
757
|
+
{p.get("entry") for p in rows["public"]})
|
|
758
|
+
nested_names = {top: sorted({p["name"] for p in public if p.get("entry") == top}) for top in nested}
|
|
759
|
+
|
|
760
|
+
subpath_files = {e.get("file") for e in entry_files
|
|
761
|
+
if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
|
|
762
|
+
root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS} - set(nested)
|
|
763
|
+
|
|
764
|
+
# A scope.include glob that matches no file: the brief predates the
|
|
765
|
+
# source, so a root export defined in a file no glob covers comes back,
|
|
766
|
+
# unless the brief excludes or declined that file.
|
|
767
|
+
declined = [path for path, action in ((brief or {}).get("amendments") or {}).items()
|
|
768
|
+
if action in DECLINED_ACTIONS]
|
|
769
|
+
restored = [o for o in outside if unmatched and o.get("entry") in root_files
|
|
770
|
+
and isinstance(o.get("name"), str) and isinstance(o.get("file"), str) and o["file"]
|
|
771
|
+
and not any(glob_match(_norm(o["file"]), g)
|
|
772
|
+
for g in scope["include"] + scope["exclude"] + declined)]
|
|
773
|
+
restored_at = {(o["name"], o["file"]): o.get("line") for o in restored}
|
|
774
|
+
outside = [o for o in outside if (o.get("name"), o.get("file")) not in restored_at]
|
|
775
|
+
|
|
395
776
|
outside_keys = {(o.get("name"), o.get("file")) for o in outside}
|
|
396
777
|
index = {(e.get("export_name"), e.get("source_file")): e
|
|
397
778
|
for e in data.get("exports") or [] if isinstance(e, dict)}
|
|
398
|
-
records = []
|
|
779
|
+
records, nested_records = [], []
|
|
399
780
|
for info in public:
|
|
400
781
|
name, file = info["name"], info.get("file")
|
|
401
782
|
if (name, file) in outside_keys:
|
|
402
783
|
continue
|
|
784
|
+
if (name, file) in restored_at:
|
|
785
|
+
# No recipe reads a file out of scope, so its kind is unknown: the
|
|
786
|
+
# name carries the file and line of its outside_scope row.
|
|
787
|
+
records.append(_record(name, None, file, restored_at[(name, file)], None, "restored"))
|
|
788
|
+
continue
|
|
403
789
|
# An aliased re-export (`export { Options as Config }`) is recorded
|
|
404
790
|
# under the name its defining file gives it, `local`.
|
|
405
791
|
found = index.get((info.get("local") or name, file)) or index.get((name, file))
|
|
406
792
|
kind = found.get("export_type") if found else None
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
793
|
+
(nested_records if info.get("entry") in nested else records).append(
|
|
794
|
+
_record(name, None if kind == "re-export" else kind, file,
|
|
795
|
+
found.get("source_line") if found else info.get("line"),
|
|
796
|
+
found.get("signature_line") if found else None, "extraction"))
|
|
410
797
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
entry_points = data.get("entry_points") if isinstance(data.get("entry_points"), dict) else {}
|
|
414
|
-
entry_files = [e for e in entry_points.get("files") or [] if isinstance(e, dict)]
|
|
415
|
-
subpath_files = {e.get("file") for e in entry_files
|
|
416
|
-
if e.get("subpath") not in ROOT_SUBPATHS and "*" not in str(e.get("subpath"))}
|
|
417
|
-
root_files = {e.get("file") for e in entry_files if e.get("subpath") in ROOT_SUBPATHS}
|
|
418
|
-
root_names = [p for p in public if p.get("entry") in root_files]
|
|
798
|
+
# The umbrella ratio reads the public rows alone: an other definition is no root barrel name there.
|
|
799
|
+
root_names = [p for p in _members_in_place(rows["public"]) if p.get("entry") in root_files]
|
|
419
800
|
reexported = [p for p in root_names if p.get("via") not in (None, "declaration")]
|
|
420
801
|
|
|
421
802
|
sets = {}
|
|
422
803
|
if subpath_files:
|
|
423
804
|
sets["subpaths"] = sorted({p["name"] for p in public if p.get("entry") in subpath_files})
|
|
424
805
|
if root_files:
|
|
425
|
-
sets["root"] = sorted({p["name"] for p in
|
|
806
|
+
sets["root"] = sorted({p["name"] for p in public if p.get("entry") in root_files})
|
|
426
807
|
|
|
427
|
-
counts = data.get("counts") if isinstance(data.get("counts"), dict) else {}
|
|
428
|
-
arms = data.get("arms") if isinstance(data.get("arms"), dict) else {}
|
|
429
|
-
without = data.get("files_without_recipes") if isinstance(data.get("files_without_recipes"), dict) else {}
|
|
430
|
-
status = data.get("status")
|
|
431
|
-
reason = None
|
|
432
|
-
if status == "no-ast-grep":
|
|
433
|
-
reason = "no ast-grep the runner can run"
|
|
434
|
-
elif not data.get("files_in_scope"):
|
|
435
|
-
reason = "no file in scope"
|
|
436
|
-
elif without and not data.get("exports"):
|
|
437
|
-
reason = f"no recipe reads the files in scope ({', '.join(sorted(without))})"
|
|
438
|
-
elif without and language and language.strip().lower() not in RECIPE_LANGUAGES:
|
|
439
|
-
reason = (f"no recipe reads the skill's language, {language} "
|
|
440
|
-
f"({', '.join(sorted(without))} in scope)")
|
|
441
|
-
read_by_eye = sorted({str(i["file"]) for i in data.get("file_issues") or []
|
|
442
|
-
if isinstance(i, dict) and i.get("file") and i.get("issue") != "no-recipes"}
|
|
443
|
-
| {str(e["first_file"]) for e in data.get("errors") or []
|
|
444
|
-
if isinstance(e, dict) and e.get("first_file")})
|
|
445
808
|
return {
|
|
446
809
|
"records": records,
|
|
810
|
+
"nestedRecords": nested_records,
|
|
447
811
|
"sets": sets,
|
|
448
|
-
"outside": [
|
|
812
|
+
"outside": [_outside_row(o, scope) for o in outside],
|
|
813
|
+
"nested": [{"entry": top, "within": nested[top], "names": nested_names[top]} for top in sorted(nested)],
|
|
814
|
+
"staleScope": {"unmatchedInclude": unmatched, "unmatchedTierAInclude": unmatched_tier_a,
|
|
815
|
+
"restored": [{"name": o["name"], "file": o["file"], "line": o.get("line")}
|
|
816
|
+
for o in sorted(restored, key=lambda o: (o["name"], o["file"]))]},
|
|
449
817
|
"gaps": [{"name": g.get("name"), "file": g.get("file"), "line": g.get("line")} for g in gaps],
|
|
450
|
-
"scope":
|
|
818
|
+
"scope": scope,
|
|
819
|
+
"scopeType": recorded.get("type") if isinstance(recorded.get("type"), str) else None,
|
|
451
820
|
"umbrella": {
|
|
452
821
|
"entries": sorted(f for f in root_files if f),
|
|
453
822
|
"names": len(root_names),
|
|
@@ -455,17 +824,7 @@ def from_extraction(data: dict, language: str | None = None) -> dict:
|
|
|
455
824
|
"ratio": round2(len(reexported) / len(root_names)) if root_names else None,
|
|
456
825
|
"umbrella": bool(root_names) and len(reexported) / len(root_names) > UMBRELLA_RATIO,
|
|
457
826
|
},
|
|
458
|
-
"extraction":
|
|
459
|
-
"status": status,
|
|
460
|
-
"filesInScope": data.get("files_in_scope"),
|
|
461
|
-
"truncated": data.get("truncated"),
|
|
462
|
-
"effectiveDenominator": _count(counts.get("effective_denominator")),
|
|
463
|
-
"effectiveDenominatorBasis": counts.get("effective_denominator_basis"),
|
|
464
|
-
"exportsPublicApi": _count(counts.get("exports_public_api")),
|
|
465
|
-
"arms": arms,
|
|
466
|
-
"readByEye": read_by_eye,
|
|
467
|
-
"fallback": {"needed": reason is not None, "reason": reason},
|
|
468
|
-
},
|
|
827
|
+
"extraction": extraction,
|
|
469
828
|
}
|
|
470
829
|
|
|
471
830
|
|
|
@@ -473,7 +832,7 @@ def from_quick(data: dict) -> list[dict]:
|
|
|
473
832
|
exports = data.get("exports")
|
|
474
833
|
if not isinstance(exports, list):
|
|
475
834
|
raise InputError("--quick is not skf-extract-public-api.py --mode quick output (no exports list)")
|
|
476
|
-
return [_record(e["name"], e.get("type"), e.get("source_file"), None, None, "quick")
|
|
835
|
+
return [_record(e["name"], e.get("type"), e.get("defined_in") or e.get("source_file"), None, None, "quick")
|
|
477
836
|
for e in exports if isinstance(e, dict) and isinstance(e.get("name"), str) and e["name"]]
|
|
478
837
|
|
|
479
838
|
|
|
@@ -525,15 +884,74 @@ def fold_names(names: list[str], prefixes: list[str], keep: set[str],
|
|
|
525
884
|
return variants, tally
|
|
526
885
|
|
|
527
886
|
|
|
528
|
-
|
|
887
|
+
def _over(clause: str, sets: dict, name: str, step: int | None, rebuild: str | None = None) -> dict:
|
|
888
|
+
"""The barrel branch over set `name`, the `all` set when the surface lacks it."""
|
|
889
|
+
present = name in sets
|
|
890
|
+
return {"clause": clause, "branch": "barrel", "surfaceSet": name if present else "all", "ladderStep": step,
|
|
891
|
+
"absentSet": None if present else name, "rebuild": rebuild}
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
def pick_denominator(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
|
|
895
|
+
scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
|
|
896
|
+
curated: bool, source_read: bool, set_trusted: bool = False) -> dict:
|
|
897
|
+
"""The Source API Surface Definition's clause for a skill, with the branch
|
|
898
|
+
and the name set reconcile-coverage.py and score-signatures.py read, the
|
|
899
|
+
entry points' state it was picked by and, on the scalar branch, whether
|
|
900
|
+
it counts that set (`set_trusted`: each name at its defining file, read
|
|
901
|
+
whole; see "Denominator" above)."""
|
|
902
|
+
picked = _pick(sets, stats_eff, deflation_fires, barrel, arms, scope_type, skill_type, monorepo,
|
|
903
|
+
pattern_reference, curated, source_read)
|
|
904
|
+
counted = (set_trusted and picked["surfaceSet"] not in (None, "all")) if picked["branch"] == "scalar" else None
|
|
905
|
+
return {**picked, "barrel": barrel, "setCounted": counted}
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def _pick(sets: dict, stats_eff: int | None, deflation_fires: bool, barrel: str | None, arms: dict,
|
|
909
|
+
scope_type: str | None, skill_type: str | None, monorepo: bool, pattern_reference: bool,
|
|
910
|
+
curated: bool, source_read: bool) -> dict:
|
|
911
|
+
if skill_type == "stack":
|
|
912
|
+
return {"clause": "stack", "branch": "stack", "surfaceSet": None, "ladderStep": None, "absentSet": None,
|
|
913
|
+
"rebuild": None}
|
|
914
|
+
multi = bool(arms.get("multi_subpath_exports"))
|
|
915
|
+
if (not monorepo and barrel != "barrel" and scope_type not in NOT_PATTERN_REFERENCE
|
|
916
|
+
and not (multi and scope_type == "public-api")
|
|
917
|
+
and (pattern_reference or (barrel == "no-entry-point" and not multi))):
|
|
918
|
+
if "tier_a_include" in sets:
|
|
919
|
+
return _over("pattern-reference", sets, "tier_a_include", None)
|
|
920
|
+
# The canonicalized provenance map: a surface read from the source is built again from the map alone.
|
|
921
|
+
return _over("pattern-reference", sets, "all", None, "provenance-fold" if source_read else None)
|
|
922
|
+
if barrel == "empty-barrel":
|
|
923
|
+
return _over("empty-barrel", sets, "scope.include", None)
|
|
924
|
+
if monorepo and curated and scope_type != "full-library":
|
|
925
|
+
clause = "stratified-scope"
|
|
926
|
+
elif scope_type == "specific-modules" and not monorepo:
|
|
927
|
+
clause = "specific-modules"
|
|
928
|
+
elif multi and not monorepo and scope_type in ("full-library", "public-api"):
|
|
929
|
+
clause = "multi-entry"
|
|
930
|
+
else:
|
|
931
|
+
return _over("standard-barrel", sets, "all", None)
|
|
932
|
+
ladder = CLAUSE_LADDERS[clause]
|
|
933
|
+
for step, rung in enumerate(ladder, 1):
|
|
934
|
+
if rung != "scalar":
|
|
935
|
+
if rung in sets or step == len(ladder):
|
|
936
|
+
return _over(clause, sets, rung, step)
|
|
937
|
+
elif stats_eff:
|
|
938
|
+
# A stored 0 is no denominator: the next rung.
|
|
939
|
+
if deflation_fires:
|
|
940
|
+
# The ladder's tier A rung when it has one and the surface has the set, else scope.include.
|
|
941
|
+
rung = "tier_a_include" if "tier_a_include" in ladder and "tier_a_include" in sets else "scope.include"
|
|
942
|
+
return _over(clause, sets, rung, step)
|
|
943
|
+
scored = next((name for name in ("tier_a_include", "scope.include") if name in sets), "all")
|
|
944
|
+
return {"clause": clause, "branch": "scalar", "surfaceSet": scored, "ladderStep": step,
|
|
945
|
+
"absentSet": None, "rebuild": None}
|
|
946
|
+
raise AssertionError(f"the {clause} ladder ends on a set")
|
|
529
947
|
|
|
530
948
|
|
|
531
949
|
def surface(args: argparse.Namespace) -> dict:
|
|
532
950
|
records: list[dict] = []
|
|
533
951
|
sets: dict[str, list[str]] = {}
|
|
534
952
|
warnings: list[str] = []
|
|
535
|
-
out: dict = {"candidates": None, "guards": None, "state2": None, "canonical": None,
|
|
536
|
-
"extraction": None, "extractionGaps": [], "excluded": {"outsideScope": []}}
|
|
953
|
+
out: dict = {"candidates": None, "guards": None, "denominator": None, "state2": None, "canonical": None,
|
|
954
|
+
"extraction": None, "extractionGaps": [], "excluded": {"outsideScope": [], "nestedEntries": []}}
|
|
537
955
|
|
|
538
956
|
meta = _object(args.metadata, "--metadata") if args.metadata else None
|
|
539
957
|
provenance = _object(args.provenance, "--provenance") if args.provenance else None
|
|
@@ -541,17 +959,23 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
541
959
|
language = (brief or {}).get("language")
|
|
542
960
|
if not language and meta is not None and isinstance(meta.get("language"), str):
|
|
543
961
|
language = meta["language"]
|
|
962
|
+
stats = meta.get("stats") if isinstance(meta, dict) and isinstance(meta.get("stats"), dict) else {}
|
|
963
|
+
stats_eff = _count(stats.get("effective_denominator"))
|
|
544
964
|
|
|
545
965
|
extracted = None
|
|
546
966
|
if args.extraction:
|
|
547
|
-
|
|
967
|
+
data = _object(args.extraction, "--extraction")
|
|
968
|
+
extracted = from_extraction(data, language, brief)
|
|
548
969
|
records += extracted["records"]
|
|
549
970
|
sets.update(extracted["sets"])
|
|
550
971
|
out["extraction"] = extracted["extraction"]
|
|
551
972
|
out["extractionGaps"] = extracted["gaps"]
|
|
552
|
-
out["excluded"]
|
|
973
|
+
out["excluded"] = {"outsideScope": extracted["outside"], "nestedEntries": extracted["nested"]}
|
|
974
|
+
warnings += [f"{Path(args.extraction).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
|
|
975
|
+
quick_outputs = []
|
|
553
976
|
for path in args.quick or []:
|
|
554
977
|
data = _object(path, "--quick")
|
|
978
|
+
quick_outputs.append(data)
|
|
555
979
|
records += from_quick(data)
|
|
556
980
|
warnings += [f"{Path(path).name}: {w}" for w in data.get("warnings") or [] if isinstance(w, str)]
|
|
557
981
|
violations: list[str] = []
|
|
@@ -574,11 +998,15 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
574
998
|
exports = meta.get("exports") if meta is not None else None
|
|
575
999
|
meta_names = sorted({n for n in (_export_name(e) for e in exports) if n}) if isinstance(exports, list) else []
|
|
576
1000
|
base_of: dict[str, str] = {}
|
|
1001
|
+
# A map whose raw count is the stored effective_denominator is already canonical: no fold.
|
|
1002
|
+
skipped = bool(args.fold) and provenance is not None and stats_eff == len(prov_names)
|
|
577
1003
|
if args.fold:
|
|
578
1004
|
# One fold over both lists, so a variant folded out of the map
|
|
579
1005
|
# never comes back through metadata.json.
|
|
580
1006
|
base_of, tally = fold_names(sorted(set(prov_names) | set(meta_names)), args.fold_prefix or [],
|
|
581
1007
|
set(args.keep or []), tallied=set(prov_names))
|
|
1008
|
+
if skipped:
|
|
1009
|
+
base_of, tally = {}, dict.fromkeys(tally, 0)
|
|
582
1010
|
if provenance is not None:
|
|
583
1011
|
canonical = sorted({base_of.get(n, n) for n in prov_names})
|
|
584
1012
|
out["canonical"] = {
|
|
@@ -586,10 +1014,14 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
586
1014
|
"canonical": len(canonical),
|
|
587
1015
|
"folded": tally,
|
|
588
1016
|
"variants": {n: base_of[n] for n in prov_names if n in base_of},
|
|
1017
|
+
"skipped": skipped,
|
|
1018
|
+
"residual": None if stats_eff is None else len(canonical) - stats_eff,
|
|
589
1019
|
"summary": (f"Provenance-map canonicalization: {len(prov_names)} raw entries → "
|
|
590
1020
|
f"{len(canonical)} canonical bases ({len(prov_names) - len(canonical)} "
|
|
591
1021
|
f"bookkeeping variants folded: _def×{tally['_def']}, _exact×{tally['_exact']}, "
|
|
592
|
-
f"a11y_×{tally[FOLD_PREFIX]}, other×{tally['other']})"
|
|
1022
|
+
f"a11y_×{tally[FOLD_PREFIX]}, other×{tally['other']})"
|
|
1023
|
+
+ (": the raw count equals `stats.effective_denominator`, so the map is already "
|
|
1024
|
+
"canonical" if skipped else "")),
|
|
593
1025
|
}
|
|
594
1026
|
seen = set()
|
|
595
1027
|
for e in named:
|
|
@@ -609,22 +1041,78 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
609
1041
|
"metadataOnly": sorted(set(meta_bases) - seen),
|
|
610
1042
|
}
|
|
611
1043
|
|
|
612
|
-
records.sort(key=lambda r: (r["name"], str(r["file"] or ""), r["origin"]))
|
|
613
1044
|
sets["all"] = sorted({r["name"] for r in records})
|
|
1045
|
+
# A nested Python top's records count in the scope sets, never in `all`.
|
|
1046
|
+
records += extracted["nestedRecords"] if extracted is not None else []
|
|
1047
|
+
records.sort(key=lambda r: (r["name"], str(r["file"] or ""), r["origin"]))
|
|
614
1048
|
|
|
615
1049
|
# The scope: the one the extractor recorded, else the brief's.
|
|
616
1050
|
scope = extracted["scope"] if extracted is not None and any(extracted["scope"].values()) else None
|
|
617
1051
|
if scope is None:
|
|
618
1052
|
scope = brief["scope"] if brief is not None else _scope(None, None, None)
|
|
1053
|
+
# A tier A glob the runner lists as matching no file counts no name, and
|
|
1054
|
+
# so does one whose folder or file differs from every scope.include
|
|
1055
|
+
# glob's before any `*` or `?` (`gen/**` beside `src/**`) or one a
|
|
1056
|
+
# scope.exclude glob covers segment by segment (`src/gen/**` beside an
|
|
1057
|
+
# exclude of `src/gen/**`), whatever the tree holds: the set comes from
|
|
1058
|
+
# the other globs, over the names whose file a scope.include glob matches
|
|
1059
|
+
# too (any file with none), so a restored name is in no set. A tier A
|
|
1060
|
+
# list that counts no name (every glob stale, or matching only excluded
|
|
1061
|
+
# files, files scope.include leaves out or files that define no name)
|
|
1062
|
+
# narrows nothing: there is no set.
|
|
1063
|
+
stale_tier_a = set(extracted["staleScope"]["unmatchedTierAInclude"]) if extracted is not None else set()
|
|
1064
|
+
tier_a = [g for g in scope["tier_a_include"] if g not in stale_tier_a
|
|
1065
|
+
and not (scope["include"] and all(_apart(g, i) for i in scope["include"]))
|
|
1066
|
+
and not any(_inside(g, e) for e in scope["exclude"])]
|
|
619
1067
|
filed = [r for r in records if r["file"]]
|
|
620
1068
|
if filed and scope["include"]:
|
|
621
1069
|
sets["scope.include"] = matching(filed, scope["include"], scope["exclude"])
|
|
622
|
-
|
|
623
|
-
|
|
1070
|
+
in_include = [r for r in filed if not scope["include"]
|
|
1071
|
+
or any(glob_match(_norm(r["file"]), g) for g in scope["include"])]
|
|
1072
|
+
tier_a_names = matching(in_include, tier_a, scope["exclude"]) if tier_a else []
|
|
1073
|
+
if tier_a_names:
|
|
1074
|
+
sets["tier_a_include"] = tier_a_names
|
|
1075
|
+
|
|
1076
|
+
# The clause: an extraction's entry-point status (one the per-file scan replaces says only that it found no
|
|
1077
|
+
# entry point), else a quick output's (an export is a barrel; none, and no warning, an empty one), else what
|
|
1078
|
+
# the workflow read (--barrel).
|
|
1079
|
+
barrel = None
|
|
1080
|
+
summary = extracted["extraction"] if extracted is not None else None
|
|
1081
|
+
if summary is not None and not summary["fallback"]["needed"]:
|
|
1082
|
+
barrel = summary["entryPointStatus"]
|
|
1083
|
+
elif summary is not None and summary["entryPointsFound"] is False:
|
|
1084
|
+
barrel = "no-entry-point"
|
|
1085
|
+
elif quick_outputs:
|
|
1086
|
+
barrel = "barrel" if any(q["exports"] for q in quick_outputs) else (
|
|
1087
|
+
None if any(q.get("warnings") for q in quick_outputs) else "empty-barrel")
|
|
1088
|
+
if barrel is None and args.barrel:
|
|
1089
|
+
barrel = BARREL_FLAG[args.barrel]
|
|
1090
|
+
recorded_type = extracted["scopeType"] if extracted is not None else None
|
|
1091
|
+
meta_type = meta.get("scope_type") if isinstance(meta, dict) and isinstance(meta.get("scope_type"), str) else None
|
|
1092
|
+
arms = summary["arms"] if summary is not None else {}
|
|
1093
|
+
# The extraction's verdict, the rule Create Skill stores the count by, else the workflow's judgment.
|
|
1094
|
+
curated = summary["curatedSubset"] if summary is not None and summary["curatedSubset"] is not None \
|
|
1095
|
+
else bool(args.curated_subset)
|
|
1096
|
+
scope_type = (brief or {}).get("type") or recorded_type or meta_type
|
|
1097
|
+
skill_type = meta.get("skill_type") if isinstance(meta, dict) else None
|
|
1098
|
+
monorepo = bool(args.monorepo or arms.get("monorepo"))
|
|
1099
|
+
# The clause alone (no stored count, no guard): only a clause whose ladder holds a stored count measures it
|
|
1100
|
+
# against the tier A set.
|
|
1101
|
+
clause = _pick(sets, None, False, barrel, arms, scope_type, skill_type, monorepo,
|
|
1102
|
+
bool(args.pattern_reference_intent), curated, source_read)["clause"]
|
|
1103
|
+
swap = False
|
|
1104
|
+
# Each name sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every
|
|
1105
|
+
# name carries its defined_in); a name read by eye, or a quick name with no defined_in, sits at its entry file.
|
|
1106
|
+
runner = extracted is not None and not extracted["extraction"]["fallback"]["needed"]
|
|
1107
|
+
quick_traced = (bool(quick_outputs) and not args.per_file and not args.name
|
|
1108
|
+
and all(isinstance(e.get("defined_in"), str) and e["defined_in"]
|
|
1109
|
+
for q in quick_outputs for e in q["exports"] if isinstance(e, dict)))
|
|
1110
|
+
defining = runner or bool(args.fallback_scan) or quick_traced
|
|
1111
|
+
# The scalar branch counts its scope set only from such names read whole: an incomplete or truncated
|
|
1112
|
+
# extraction can miss names (#791, #792).
|
|
1113
|
+
whole = summary is None or (summary["status"] != "incomplete" and not summary["truncated"])
|
|
624
1114
|
|
|
625
1115
|
if extracted is not None or brief is not None or meta is not None:
|
|
626
|
-
stats = meta.get("stats") if isinstance(meta, dict) and isinstance(meta.get("stats"), dict) else {}
|
|
627
|
-
stats_eff = _count(stats.get("effective_denominator"))
|
|
628
1116
|
include_union = len(sets["scope.include"]) if "scope.include" in sets else len(sets["all"])
|
|
629
1117
|
extraction = extracted["extraction"] if extracted is not None else {}
|
|
630
1118
|
out["candidates"] = {
|
|
@@ -636,12 +1124,34 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
636
1124
|
"extractorEffectiveDenominator": extraction.get("effectiveDenominator"),
|
|
637
1125
|
"extractorBasis": extraction.get("effectiveDenominatorBasis"),
|
|
638
1126
|
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
1127
|
+
# With an extraction or the fallback scan's results (each at the file
|
|
1128
|
+
# that defines its names), no tier A set is no tier A list; otherwise
|
|
1129
|
+
# the globs left in the list decide (a name read by eye at Quick tier
|
|
1130
|
+
# or State 4, and a quick name with no defined_in, carries its entry
|
|
1131
|
+
# file, not the file that defines it).
|
|
1132
|
+
no_tier_a = ("tier_a_include" not in sets if extracted is not None or args.fallback_scan
|
|
1133
|
+
else not tier_a)
|
|
1134
|
+
# A clause whose ladder holds a stored count checks it against the tier A set, both ways, when each name
|
|
1135
|
+
# sits at the file that defines it (a runner extraction, the fallback scan, quick output whose every name
|
|
1136
|
+
# carries its defined_in): Create Skill stores the runner's tier A count, so one beyond the threshold is
|
|
1137
|
+
# stale (#813). A name read by eye, or a quick name with no defined_in, sits at its entry file.
|
|
1138
|
+
tier_a_basis = defining and clause in CLAUSE_LADDERS and "tier_a_include" in sets
|
|
1139
|
+
rederived = len(sets["tier_a_include"]) if tier_a_basis else include_union
|
|
1140
|
+
basis = "tier_a_include" if tier_a_basis else "scope.include" if "scope.include" in sets else "all"
|
|
1141
|
+
deflation = {"applicable": source_read and stats_eff is not None, "basis": basis,
|
|
1142
|
+
"effectiveDenominator": stats_eff, "rederived": rederived if source_read else None, "pct": None,
|
|
1143
|
+
"fires": False}
|
|
1144
|
+
if deflation["applicable"] and stats_eff:
|
|
1145
|
+
deflation["pct"] = round2((rederived - stats_eff) / stats_eff * 100)
|
|
1146
|
+
deflation["fires"] = (abs(deflation["pct"]) > DEFLATION_PCT if tier_a_basis
|
|
1147
|
+
else no_tier_a and deflation["pct"] > DEFLATION_PCT)
|
|
1148
|
+
elif deflation["applicable"]:
|
|
1149
|
+
# A stored 0 is deflation, not a denominator, once a name is re-derived.
|
|
1150
|
+
deflation["fires"] = (tier_a_basis or no_tier_a) and rederived > 0
|
|
1151
|
+
# An incomplete extraction can miss tier A names: a tier A set below the stored count is drift to report,
|
|
1152
|
+
# not a denominator to take.
|
|
1153
|
+
incomplete = runner and extracted["extraction"]["status"] == "incomplete"
|
|
1154
|
+
swap = deflation["fires"] and not (incomplete and tier_a_basis and (deflation["pct"] or 0) < 0)
|
|
645
1155
|
entry_count = len(_entries(provenance)) if provenance is not None else 0
|
|
646
1156
|
inflation = {"applicable": source_read and no_tier_a and bool(scope["include"]) and entry_count > 0,
|
|
647
1157
|
"scopeIncludeUnion": include_union if scope["include"] else None,
|
|
@@ -650,14 +1160,25 @@ def surface(args: argparse.Namespace) -> dict:
|
|
|
650
1160
|
if inflation["applicable"]:
|
|
651
1161
|
inflation["pct"] = round2((include_union - entry_count) / entry_count * 100)
|
|
652
1162
|
inflation["fires"] = inflation["pct"] > INFLATION_PCT
|
|
1163
|
+
stale_scope = {"applicable": extracted is not None and bool(scope["include"] or scope["tier_a_include"]),
|
|
1164
|
+
"fires": False, "unmatchedInclude": [], "unmatchedTierAInclude": [], "restored": []}
|
|
1165
|
+
if stale_scope["applicable"]:
|
|
1166
|
+
stale_scope.update(extracted["staleScope"])
|
|
1167
|
+
stale_scope["fires"] = bool(stale_scope["unmatchedInclude"] or stale_scope["unmatchedTierAInclude"])
|
|
653
1168
|
out["guards"] = {
|
|
654
1169
|
"deflation": deflation,
|
|
655
1170
|
"inflation": inflation,
|
|
1171
|
+
"staleScope": stale_scope,
|
|
1172
|
+
"tierAInclude": list(scope["tier_a_include"]),
|
|
656
1173
|
"umbrella": extracted["umbrella"] if extracted is not None else dict(NO_UMBRELLA),
|
|
657
1174
|
"thresholds": {"deflationPct": DEFLATION_PCT, "inflationPct": INFLATION_PCT,
|
|
658
1175
|
"umbrellaRatio": UMBRELLA_RATIO},
|
|
659
1176
|
}
|
|
660
1177
|
|
|
1178
|
+
out["denominator"] = pick_denominator(sets, stats_eff, swap, barrel, arms, scope_type, skill_type, monorepo,
|
|
1179
|
+
bool(args.pattern_reference_intent), curated, source_read,
|
|
1180
|
+
defining and whole)
|
|
1181
|
+
|
|
661
1182
|
return {
|
|
662
1183
|
"inputs": {
|
|
663
1184
|
"extraction": args.extraction, "quick": args.quick or [], "perFile": args.per_file or [],
|
|
@@ -708,6 +1229,13 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
708
1229
|
p.add_argument("--extraction", help="skf-extract-public-api.py --mode full output")
|
|
709
1230
|
p.add_argument("--quick", action="append", help="skf-extract-public-api.py --mode quick output (repeatable)")
|
|
710
1231
|
p.add_argument("--per-file", action="append", help="a subagent per-file result (repeatable)")
|
|
1232
|
+
p.add_argument("--fallback-scan", action="store_true",
|
|
1233
|
+
help="the --per-file results are the fallback scan's, each at the file that defines its "
|
|
1234
|
+
"names: as with --extraction, no tier_a_include set is no tier A list, so the deflation "
|
|
1235
|
+
"and inflation guards run; without either, a tier_a_include glob keeps them off unless "
|
|
1236
|
+
"its folder or file differs from every scope.include glob's before any wildcard "
|
|
1237
|
+
"(gen/** beside src/**) or a scope.exclude glob covers it segment by segment "
|
|
1238
|
+
"(src/gen/** beside an exclude of src/gen/**)")
|
|
711
1239
|
p.add_argument("--name", action="append", help="a name read by eye whose file is not known (repeatable)")
|
|
712
1240
|
p.add_argument("--provenance", help="the provenance map: its named exports")
|
|
713
1241
|
p.add_argument("--metadata", help="metadata.json: its exports[] names, stats and language")
|
|
@@ -715,6 +1243,17 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
715
1243
|
p.add_argument("--fold", action="store_true", help="fold the provenance and metadata bookkeeping variants")
|
|
716
1244
|
p.add_argument("--fold-prefix", action="append", help="a renderer prefix to fold (repeatable)")
|
|
717
1245
|
p.add_argument("--keep", action="append", help="a variant that is a real export (repeatable)")
|
|
1246
|
+
p.add_argument("--monorepo", action="store_true",
|
|
1247
|
+
help="the source is a monorepo (skf-detect-workspaces.py's is_monorepo)")
|
|
1248
|
+
p.add_argument("--pattern-reference-intent", action="store_true",
|
|
1249
|
+
help="the brief's scope.notes flags pattern-reference intent (your judgment)")
|
|
1250
|
+
p.add_argument("--curated-subset", action="store_true",
|
|
1251
|
+
help="with --monorepo: the brief's scope.include lists a curated subset rather than the full "
|
|
1252
|
+
"workspace (your judgment; an extraction's counts.curated_subset wins)")
|
|
1253
|
+
p.add_argument("--barrel", choices=tuple(BARREL_FLAG),
|
|
1254
|
+
help="the entry points as you read them, where no extraction or quick output tells: none (no "
|
|
1255
|
+
"barrel file at any recognized entry-point path), empty (the primary one exports nothing) "
|
|
1256
|
+
"or barrel")
|
|
718
1257
|
p.add_argument("--output", help="also write the result to this file")
|
|
719
1258
|
p.set_defaults(func=surface)
|
|
720
1259
|
return parser
|