bmad-module-skill-forge 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/examples.md +1 -1
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +7 -3
- package/docs/workflows.md +3 -3
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +593 -47
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- package/src/shared/scripts/skf-extract-public-api.py +1309 -157
- package/src/shared/scripts/skf-extraction-inventory.py +580 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +6 -6
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +22 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +8 -9
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +16 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
- package/src/skf-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- package/src/skf-test-skill/references/coherence-check.md +1 -1
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +39 -57
- package/src/skf-test-skill/references/init.md +3 -5
- package/src/skf-test-skill/references/report.md +10 -8
- package/src/skf-test-skill/references/scoring-rules.md +6 -5
- package/src/skf-test-skill/references/source-access-protocol.md +14 -17
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
- package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
- package/src/skf-test-skill/scripts/score-signatures.py +36 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +3 -3
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +2 -2
- package/src/skf-update-skill/references/re-extract.md +2 -2
- package/src/skf-update-skill/references/report.md +3 -1
- package/src/skf-update-skill/references/write.md +43 -23
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# /// script
|
|
2
2
|
# requires-python = ">=3.11"
|
|
3
|
-
# dependencies = []
|
|
3
|
+
# dependencies = ["pyyaml"]
|
|
4
4
|
# ///
|
|
5
5
|
"""SKF Extraction Inventory: write, extend and read create-skill's extraction inventory on disk.
|
|
6
6
|
|
|
@@ -12,8 +12,9 @@ runner's JSON and the script/asset detector's JSON, so the model never
|
|
|
12
12
|
types a record those tools already wrote; the model then sends only what it
|
|
13
13
|
produced itself (`patch`, `add`, `set`). Step 3c adds the T3 items it
|
|
14
14
|
extracts from fetched documentation, step 4 the T2 annotations it finds in
|
|
15
|
-
QMD,
|
|
16
|
-
|
|
15
|
+
QMD, compile writes the provenance map (`provenance`) and metadata.json
|
|
16
|
+
(`metadata`) from it, and step 7 writes `extraction-rules.yaml` from it
|
|
17
|
+
(`rules`). Every write goes through a temporary file and one rename.
|
|
17
18
|
|
|
18
19
|
Subcommands:
|
|
19
20
|
|
|
@@ -32,6 +33,31 @@ Subcommands:
|
|
|
32
33
|
file_issues[] file, each files_without_recipes extension, each
|
|
33
34
|
warnings[] entry). From --detected (skf-detect-scripts-assets.py
|
|
34
35
|
detect): scripts_inventory and assets_inventory.
|
|
36
|
+
Public surface. When the runner's scope.type is public-api (in
|
|
37
|
+
any case) and its entry-point diff can be read, each export of a
|
|
38
|
+
language family whose entry_points.by_language status is
|
|
39
|
+
`barrel` is marked `public: true` when its name and file are a
|
|
40
|
+
pair of the diff's `public` (its name, or the name `local` gives
|
|
41
|
+
it in that file) or `extraction_gaps`, or when a `public` item of
|
|
42
|
+
its name and family has no file; `public: false` otherwise. A
|
|
43
|
+
`public` item with `via` namespace and a `members` list (what
|
|
44
|
+
the submodule passes on, which users reach as `pkg.sub.name`)
|
|
45
|
+
gives its members' pairs (each member's name, or its `local`
|
|
46
|
+
one, and its file; a member with no file its name) in place of
|
|
47
|
+
its own; one whose `members` is null (a module the runner could
|
|
48
|
+
not read), or that has no `members` key (an older runner's),
|
|
49
|
+
gives its own pair, as any item does. It is the rule
|
|
50
|
+
skf-build-change-manifest.py's `records` marks an update's
|
|
51
|
+
exports by (that script loads it from here). The rule's inputs
|
|
52
|
+
are kept as `public_surface`, so `add` marks an export read by
|
|
53
|
+
eye the same way. Any other scope type, and a family with no
|
|
54
|
+
barrel, mark nothing. A public-api run with no entry-point diff,
|
|
55
|
+
an incomplete one, one with errors or one whose
|
|
56
|
+
entry_points.unresolved is not empty marks nothing either
|
|
57
|
+
(`public_surface` null), with one warning that says why: every
|
|
58
|
+
export is then documented. One warning names the exports marked
|
|
59
|
+
`public: false`, each as `name (file)` (the first ten, then how
|
|
60
|
+
many more).
|
|
35
61
|
|
|
36
62
|
add --inventory <file> --field exports|t2_annotations|t3_items|warnings
|
|
37
63
|
Read a JSON list on stdin (objects; strings for warnings) and
|
|
@@ -40,7 +66,16 @@ Subcommands:
|
|
|
40
66
|
added twice and counts in `duplicates`. An exports entry needs an
|
|
41
67
|
export_name; a missing confidence, extraction_method,
|
|
42
68
|
ast_node_type or ast_recipe defaults to T1-low, source-read,
|
|
43
|
-
null and null (an export read by eye).
|
|
69
|
+
null and null (an export read by eye). Its source_file is
|
|
70
|
+
relative to the source root, written and compared with `/`
|
|
71
|
+
separators and no leading `./`, as the runner writes its files
|
|
72
|
+
(`./pkg/api.py` and `pkg\\api.py` are `pkg/api.py`); an absolute
|
|
73
|
+
one (a leading `/` or a drive letter) is refused, and the call
|
|
74
|
+
adds nothing. An exports entry is
|
|
75
|
+
marked `public` by the inventory's `public_surface`, as `init`
|
|
76
|
+
marks the runner's (a `public` key the payload sends is not
|
|
77
|
+
kept), and the warning naming the exports marked `public: false`
|
|
78
|
+
is written again, in its place. A t3_items entry whose
|
|
44
79
|
export_name names an export the inventory already holds is
|
|
45
80
|
dropped, not added: T3 content never overrides a T1, T1-low or
|
|
46
81
|
T2 entry for the same export.
|
|
@@ -54,7 +89,8 @@ Subcommands:
|
|
|
54
89
|
in `unmatched`, one that matches several in `ambiguous`; neither
|
|
55
90
|
changes anything. params lists each parameter as the runner
|
|
56
91
|
records one, {name, type, default, optional}, or as a typed
|
|
57
|
-
string ('userId: string').
|
|
92
|
+
string ('userId: string'). The source_file is compared as `add`
|
|
93
|
+
compares it: `./pkg/api.py` matches the export at `pkg/api.py`.
|
|
58
94
|
|
|
59
95
|
set --inventory <file>
|
|
60
96
|
Read a JSON object on stdin and set each of its keys: top_exports,
|
|
@@ -64,7 +100,9 @@ Subcommands:
|
|
|
64
100
|
and intent_mapping. intent_mapping maps `scripts` and `assets`
|
|
65
101
|
to {"intent": "<free text>", "kept": [source_file, ...]}: the
|
|
66
102
|
inventory keeps only the kept entries of that list and records
|
|
67
|
-
the others in the mapping's `left_out`.
|
|
103
|
+
the others in the mapping's `left_out`. A top_exports name every
|
|
104
|
+
export of which is marked `public: false` is not kept and is
|
|
105
|
+
listed in `dropped`.
|
|
68
106
|
|
|
69
107
|
provenance --inventory <file> --target <provenance-map.json> [--add-entries]
|
|
70
108
|
Write the provenance map to --target, so compile never types an
|
|
@@ -74,7 +112,9 @@ Subcommands:
|
|
|
74
112
|
"2.0"), skill_name (default the inventory's), skill_type
|
|
75
113
|
(default "single") and generated_at (default now, ISO-8601
|
|
76
114
|
UTC); any other key is refused. entries[] holds the entry of
|
|
77
|
-
every export of the inventory, in its order
|
|
115
|
+
every export of the inventory, in its order, but one marked
|
|
116
|
+
`public: false` (off a public-api skill's public surface, which
|
|
117
|
+
neither entries[] nor `unsigned` lists): export_name,
|
|
78
118
|
export_type, params, return_type, source_file, source_line,
|
|
79
119
|
confidence, extraction_method, ast_node_type and
|
|
80
120
|
signature_source, labeled by the tool that found it. An export
|
|
@@ -97,7 +137,52 @@ Subcommands:
|
|
|
97
137
|
T3) and the file entries. An entry whose export_name and
|
|
98
138
|
source_file the map already holds, or a file entry whose
|
|
99
139
|
file_name it holds, is not added and counts in `duplicates`, so
|
|
100
|
-
an inventory export's entry is never replaced.
|
|
140
|
+
an inventory export's entry is never replaced. An entry whose
|
|
141
|
+
export_name and source_file are an inventory export's marked
|
|
142
|
+
`public: false` is not added either and is listed in
|
|
143
|
+
`not_public`, so the map never holds an export off the surface.
|
|
144
|
+
|
|
145
|
+
metadata --inventory <file> --brief <skill-brief.yaml> --stats <file>
|
|
146
|
+
--skf-root <dir> --target <metadata.json>
|
|
147
|
+
Write the skill's metadata.json to --target, so compile types
|
|
148
|
+
only the fields it decides. stdin holds them, a JSON object:
|
|
149
|
+
version and language (non-empty strings), source_root,
|
|
150
|
+
source_commit, source_ref and qmd_version (strings or null),
|
|
151
|
+
each required, and ast_grep_version (a string or null, default
|
|
152
|
+
null) and stats_extra (an object, default {}); any other key is
|
|
153
|
+
refused. The rest comes from the files:
|
|
154
|
+
name, scope_type (the brief's scope.type, verbatim), source_repo
|
|
155
|
+
and source_authority (default "community") from --brief;
|
|
156
|
+
description from the frontmatter of the SKILL.md beside
|
|
157
|
+
--target (compile's staged one); confidence_tier (the
|
|
158
|
+
inventory's tier); exports[], the names of the inventory's
|
|
159
|
+
exports but one marked `internal: true` or `public: false`,
|
|
160
|
+
each once, in its order (a docs-only inventory's t3_items
|
|
161
|
+
names; none for a reference-app brief); ast_node_count and
|
|
162
|
+
tool_versions.ast_grep, only when ast-grep ran: when the
|
|
163
|
+
runner ran it (extraction_rules.ast_grep_version is set), the
|
|
164
|
+
runner's aggregates.exports and that version; otherwise, when
|
|
165
|
+
exports carry extraction_method ast-grep (the recipes run
|
|
166
|
+
without the runner), their number and stdin's
|
|
167
|
+
ast_grep_version, else "unknown" with a warning (stdin's
|
|
168
|
+
ast_grep_version is read in that case only);
|
|
169
|
+
tool_versions.ast_grep is null when ast-grep did not run, and
|
|
170
|
+
ast_node_count left out; scripts[] and assets[], {file:
|
|
171
|
+
scripts/<name> or assets/<name>, purpose, source_file,
|
|
172
|
+
confidence} per scripts_inventory or assets_inventory entry,
|
|
173
|
+
left out when it is empty; and stats and
|
|
174
|
+
confidence_distribution verbatim from --stats (the JSON
|
|
175
|
+
skf-render-metadata-stats.py wrote), with stats_extra's keys
|
|
176
|
+
added: a component-library brief's own stats, or a
|
|
177
|
+
reference-app brief's notes alone (another scope type takes
|
|
178
|
+
none, and a key the stats helper wrote is refused). It stamps
|
|
179
|
+
generation_date (UTC, to the second), reads tool_versions.skf
|
|
180
|
+
under --skf-root (the installed module) as
|
|
181
|
+
skf-render-quick-metadata.py does (package.json's version, else
|
|
182
|
+
the VERSION file's first line, else "unknown", with a warning),
|
|
183
|
+
and writes skill_type "single", spec_version "1.3",
|
|
184
|
+
generated_by "create-skill", dependencies [] and compatibility
|
|
185
|
+
"". The inventory is not changed.
|
|
101
186
|
|
|
102
187
|
rules --inventory <file> --language <language> --target <file>
|
|
103
188
|
Write extraction-rules.yaml (the skill, its language, tier and
|
|
@@ -105,7 +190,9 @@ Subcommands:
|
|
|
105
190
|
--target. The inventory is not changed.
|
|
106
191
|
|
|
107
192
|
summary --inventory <file>
|
|
108
|
-
Print the inventory's counts without changing it
|
|
193
|
+
Print the inventory's counts without changing it, and in
|
|
194
|
+
`not_public` each export marked `public: false` ({name, file}),
|
|
195
|
+
which the evidence report lists.
|
|
109
196
|
|
|
110
197
|
Inventory shape (the keys this helper reads or writes):
|
|
111
198
|
|
|
@@ -120,49 +207,68 @@ Inventory shape (the keys this helper reads or writes):
|
|
|
120
207
|
"co_imports": [...], "promoted_docs": [...],
|
|
121
208
|
"authoritative_files_scan": {...} | null,
|
|
122
209
|
"extraction_rules": {"recipe_set", "recipes", "scope", "ast_grep_version"},
|
|
210
|
+
"public_surface": {"by_language": {...}, "public": [...],
|
|
211
|
+
"extraction_gaps": [...]} | null,
|
|
123
212
|
"t2_annotations": [...], "t3_items": [...], "warnings": ["..."]
|
|
124
213
|
}
|
|
125
214
|
|
|
126
|
-
|
|
215
|
+
An export may carry `internal: true` (the runner's entry_point_diff.internal
|
|
216
|
+
lists it) and `public: true` or `public: false` (see init's Public surface).
|
|
217
|
+
|
|
218
|
+
Output (stdout, exit 0), for every subcommand but rules, provenance and
|
|
219
|
+
metadata:
|
|
127
220
|
|
|
128
221
|
{
|
|
129
222
|
"status": "ok",
|
|
130
223
|
"inventory": "<path, / separators>",
|
|
131
224
|
"extraction_mode": "...",
|
|
132
|
-
"counts": {"files_scanned": N | null, "exports": N, "
|
|
133
|
-
"t1_low": N, "by_type": {"<export_type>": N, ...},
|
|
225
|
+
"counts": {"files_scanned": N | null, "exports": N, "not_public": N,
|
|
226
|
+
"t1": N, "t1_low": N, "by_type": {"<export_type>": N, ...},
|
|
134
227
|
"top_exports": N, "co_imports": N, "scripts": N,
|
|
135
228
|
"assets": N, "t2_annotations": N, "t2_past": N,
|
|
136
229
|
"t2_future": N, "functions_enriched": N, "t3_items": N,
|
|
137
230
|
"items": N},
|
|
138
231
|
"added": N, "duplicates": N, "dropped": ["name"], # add
|
|
139
232
|
"patched": N, "unmatched": [...], "ambiguous": [...], # patch
|
|
140
|
-
"set": ["key", ...]
|
|
233
|
+
"set": ["key", ...], "dropped": ["name"], # set
|
|
234
|
+
"not_public": [{"name", "file"}, ...] # summary
|
|
141
235
|
}
|
|
142
236
|
|
|
143
|
-
|
|
144
|
-
|
|
237
|
+
not_public counts the exports marked `public: false`, which the provenance
|
|
238
|
+
map leaves out: when it equals `exports`, the map holds no export. `items`
|
|
239
|
+
is exports plus t3_items, less not_public: what compile can document. t1
|
|
240
|
+
and t1_low count the exports by their `confidence`, by_type by their
|
|
241
|
+
`export_type`.
|
|
145
242
|
t2_past and t2_future count the T2 annotations by their `temporal`, and
|
|
146
|
-
functions_enriched the distinct export_name values among them
|
|
243
|
+
functions_enriched the distinct export_name values among them; an
|
|
244
|
+
annotation of a name every export of which is marked `public: false`
|
|
245
|
+
counts in none of t2_annotations, t2_past, t2_future and
|
|
246
|
+
functions_enriched. rules
|
|
147
247
|
prints {"status": "ok", "target": "<path>", "bytes": N}, provenance
|
|
148
248
|
{"status": "ok", "target": "<path>", "entries": N, "unsigned": ["name
|
|
149
249
|
(file)", ...]}, and provenance --add-entries {"status": "ok", "target":
|
|
150
|
-
"<path>", "entries": N, "file_entries": N, "added": N, "duplicates": N
|
|
151
|
-
the counts of the map after the
|
|
250
|
+
"<path>", "entries": N, "file_entries": N, "added": N, "duplicates": N,
|
|
251
|
+
"not_public": [{"name", "file"}, ...]}, the counts of the map after the
|
|
252
|
+
merge, and metadata {"status": "ok", "target": "<path>", "exports": N,
|
|
253
|
+
"commit_short": "<source_commit's first 8 characters, or unknown>",
|
|
254
|
+
"tool_versions": {...}, "warnings": [...]}.
|
|
152
255
|
|
|
153
256
|
Exit codes:
|
|
154
257
|
0 success
|
|
155
258
|
1 input error, nothing changed: an inventory that is missing, not JSON
|
|
156
|
-
or has no exports list, an --extraction
|
|
157
|
-
cannot be read, a provenance map at --target that
|
|
158
|
-
cannot read, a
|
|
159
|
-
|
|
259
|
+
or has no exports list, an --extraction, --detected, --brief or
|
|
260
|
+
--stats file that cannot be read, a provenance map at --target that
|
|
261
|
+
--add-entries cannot read, a staged SKILL.md with no frontmatter
|
|
262
|
+
description, a stdin payload of the wrong shape, an exports entry
|
|
263
|
+
whose source_file is absolute, an unknown key or a kept file the
|
|
264
|
+
inventory does not list, or a write that failed
|
|
160
265
|
2 usage error (argparse)
|
|
161
266
|
"""
|
|
162
267
|
|
|
163
268
|
from __future__ import annotations
|
|
164
269
|
|
|
165
270
|
import argparse
|
|
271
|
+
import importlib.util
|
|
166
272
|
import json
|
|
167
273
|
import os
|
|
168
274
|
import re
|
|
@@ -219,9 +325,27 @@ def _read_json(path: Path, label: str):
|
|
|
219
325
|
raise InventoryError(f"cannot read the {label} {path.as_posix()}: {exc}") from exc
|
|
220
326
|
|
|
221
327
|
|
|
328
|
+
def _off_surface_names(data: dict) -> set[str]:
|
|
329
|
+
"""The export names every inventory export of which is marked `public: false`."""
|
|
330
|
+
marks: dict[str, bool] = {}
|
|
331
|
+
for export in data.get("exports") or []:
|
|
332
|
+
if isinstance(export, dict) and isinstance(export.get("export_name"), str):
|
|
333
|
+
name = export["export_name"]
|
|
334
|
+
marks[name] = marks.get(name, True) and export.get("public") is False
|
|
335
|
+
return {name for name, off in marks.items() if off}
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _not_public(data: dict) -> list[dict]:
|
|
339
|
+
"""{name, file} of each export marked `public: false`, in the inventory's order."""
|
|
340
|
+
return [{"name": e.get("export_name"), "file": e.get("source_file")} for e in data.get("exports") or []
|
|
341
|
+
if isinstance(e, dict) and e.get("public") is False]
|
|
342
|
+
|
|
343
|
+
|
|
222
344
|
def counts(data: dict) -> dict:
|
|
223
345
|
exports = [e for e in data["exports"] if isinstance(e, dict)]
|
|
224
|
-
|
|
346
|
+
off = _off_surface_names(data)
|
|
347
|
+
raw_notes = [n for n in data.get("t2_annotations") or [] if isinstance(n, dict)]
|
|
348
|
+
notes = [n for n in raw_notes if not (isinstance(n.get("export_name"), str) and n["export_name"] in off)]
|
|
225
349
|
|
|
226
350
|
def _length(key: str) -> int:
|
|
227
351
|
value = data.get(key)
|
|
@@ -233,9 +357,11 @@ def counts(data: dict) -> dict:
|
|
|
233
357
|
if isinstance(kind, str) and kind:
|
|
234
358
|
by_type[kind] = by_type.get(kind, 0) + 1
|
|
235
359
|
files = data.get("files_scanned")
|
|
360
|
+
not_public = sum(1 for e in exports if e.get("public") is False)
|
|
236
361
|
return {
|
|
237
362
|
"files_scanned": files if isinstance(files, int) else None,
|
|
238
363
|
"exports": len(data["exports"]),
|
|
364
|
+
"not_public": not_public,
|
|
239
365
|
"t1": sum(1 for e in exports if e.get("confidence") == "T1"),
|
|
240
366
|
"t1_low": sum(1 for e in exports if e.get("confidence") == "T1-low"),
|
|
241
367
|
"by_type": dict(sorted(by_type.items())),
|
|
@@ -243,12 +369,12 @@ def counts(data: dict) -> dict:
|
|
|
243
369
|
"co_imports": _length("co_imports"),
|
|
244
370
|
"scripts": _length("scripts_inventory"),
|
|
245
371
|
"assets": _length("assets_inventory"),
|
|
246
|
-
"t2_annotations": _length("t2_annotations"),
|
|
372
|
+
"t2_annotations": _length("t2_annotations") - (len(raw_notes) - len(notes)),
|
|
247
373
|
"t2_past": sum(1 for n in notes if n.get("temporal") == "T2-past"),
|
|
248
374
|
"t2_future": sum(1 for n in notes if n.get("temporal") == "T2-future"),
|
|
249
375
|
"functions_enriched": len({n["export_name"] for n in notes if isinstance(n.get("export_name"), str)}),
|
|
250
376
|
"t3_items": _length("t3_items"),
|
|
251
|
-
"items": len(data["exports"]) + _length("t3_items"),
|
|
377
|
+
"items": len(data["exports"]) - not_public + _length("t3_items"),
|
|
252
378
|
}
|
|
253
379
|
|
|
254
380
|
|
|
@@ -264,7 +390,7 @@ def runner_warnings(extraction: dict) -> list[str]:
|
|
|
264
390
|
for error in extraction.get("errors") or []:
|
|
265
391
|
if isinstance(error, dict):
|
|
266
392
|
lines.append(f"ast-grep did not finish on {error.get('files')} files from `{error.get('first_file')}` "
|
|
267
|
-
f"({error.get('detail')}): exports
|
|
393
|
+
f"({error.get('detail')}): their exports are read by eye")
|
|
268
394
|
for issue in extraction.get("file_issues") or []:
|
|
269
395
|
if isinstance(issue, dict):
|
|
270
396
|
lines.append(f"`{issue.get('file')}` ({issue.get('issue')}): its exports are read by eye")
|
|
@@ -276,13 +402,179 @@ def runner_warnings(extraction: dict) -> list[str]:
|
|
|
276
402
|
return lines
|
|
277
403
|
|
|
278
404
|
|
|
405
|
+
# --------------------------------------------------------------------------
|
|
406
|
+
# The public surface of a public-api skill (see init's "Public surface"):
|
|
407
|
+
# one rule, which skf-build-change-manifest.py loads from here for update-skill
|
|
408
|
+
# --------------------------------------------------------------------------
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _norm_path(path: object) -> str | None:
|
|
412
|
+
"""A file path compared as structural-diff compares it: `/` separators, no leading `./`."""
|
|
413
|
+
if not isinstance(path, str) or not path.strip():
|
|
414
|
+
return None
|
|
415
|
+
text = path.strip().replace("\\", "/")
|
|
416
|
+
while text.startswith("./"):
|
|
417
|
+
text = text[2:]
|
|
418
|
+
return text
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _relative_source(entry: dict) -> bool:
|
|
422
|
+
"""Write an exports entry's source_file in _norm_path's form, the runner's; False for an absolute one."""
|
|
423
|
+
path = _norm_path(entry.get("source_file"))
|
|
424
|
+
if path is None:
|
|
425
|
+
return True
|
|
426
|
+
if path.startswith("/") or re.match(r"[A-Za-z]:/", path):
|
|
427
|
+
return False
|
|
428
|
+
entry["source_file"] = path
|
|
429
|
+
return True
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
PUBLIC_API = "public-api"
|
|
433
|
+
# A record's language family, the one whose entry points declare its names: skf-extract-public-api.py's FAMILY_OF,
|
|
434
|
+
# by the record's `language`, else by its file's extension (that script's EXTENSION_LANGUAGES).
|
|
435
|
+
_FAMILY_OF_LANGUAGE = {"python": "python", "typescript": "javascript", "tsx": "javascript",
|
|
436
|
+
"javascript": "javascript", "vue": "javascript", "rust": "rust", "go": "go"}
|
|
437
|
+
_FAMILY_OF_EXTENSION = {".py": "python", ".pyi": "python", ".rs": "rust", ".go": "go",
|
|
438
|
+
**dict.fromkeys((".ts", ".mts", ".cts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue"),
|
|
439
|
+
"javascript")}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class _PublicSurface:
|
|
443
|
+
"""The public surface of a public-api skill's extraction (see init's "Public surface" in the module
|
|
444
|
+
docstring)."""
|
|
445
|
+
|
|
446
|
+
def __init__(self, diff: dict, statuses: dict):
|
|
447
|
+
self.barrels = {family for family, status in statuses.items() if status == "barrel"}
|
|
448
|
+
self.pairs: set[tuple[str, str]] = set()
|
|
449
|
+
self.unfiled: set[tuple[object, str]] = set() # (family, name) of a public name no file defines
|
|
450
|
+
for key in ("public", "extraction_gaps"):
|
|
451
|
+
for item in diff.get(key) or []:
|
|
452
|
+
if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"]:
|
|
453
|
+
continue
|
|
454
|
+
family = item.get("language")
|
|
455
|
+
members = item.get("members") if key == "public" and item.get("via") == "namespace" else None
|
|
456
|
+
if isinstance(members, list):
|
|
457
|
+
# a namespace whose module the runner read: its members, in place of the module itself
|
|
458
|
+
for member in members:
|
|
459
|
+
if isinstance(member, dict) and isinstance(member.get("name"), str) and member["name"]:
|
|
460
|
+
self._add(family, member["name"], member.get("local"), member.get("file"), True)
|
|
461
|
+
continue
|
|
462
|
+
self._add(family, item["name"], item.get("local") if key == "public" else None, item.get("file"),
|
|
463
|
+
key == "public")
|
|
464
|
+
|
|
465
|
+
def _add(self, family: object, name: str, local: object, path: object, listed_public: bool) -> None:
|
|
466
|
+
"""The pairs one public name (or gap) gives: (name, file) and (local, file), or, with no file, its
|
|
467
|
+
(family, name) when `public` lists it."""
|
|
468
|
+
file = _norm_path(path)
|
|
469
|
+
if not file:
|
|
470
|
+
if listed_public:
|
|
471
|
+
self.unfiled.add((family, name))
|
|
472
|
+
return
|
|
473
|
+
self.pairs.update((n, file) for n in (name, local) if isinstance(n, str) and n)
|
|
474
|
+
|
|
475
|
+
def public(self, name: str, path: str, language: object = None) -> bool | None:
|
|
476
|
+
"""Whether the export is on the public surface; None (no mark) when its family's entry points are no
|
|
477
|
+
barrel."""
|
|
478
|
+
family = _FAMILY_OF_LANGUAGE.get(language) if isinstance(language, str) else None
|
|
479
|
+
if family is None:
|
|
480
|
+
family = _FAMILY_OF_EXTENSION.get(os.path.splitext(path)[1].lower())
|
|
481
|
+
if family not in self.barrels:
|
|
482
|
+
return None
|
|
483
|
+
return (name, path) in self.pairs or (family, name) in self.unfiled
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def public_surface(extraction: object) -> tuple[_PublicSurface | None, str | None]:
|
|
487
|
+
"""(the public surface, None) of the recipe runner's output when its scope type is public-api; (None, why the
|
|
488
|
+
surface cannot be read from it) for a public-api run with no entry-point diff, an incomplete one, one with
|
|
489
|
+
errors or one whose entry points name a module the trace could not follow; (None, None) for any other scope
|
|
490
|
+
type. With no surface every export is kept, as before."""
|
|
491
|
+
if not isinstance(extraction, dict):
|
|
492
|
+
return None, None
|
|
493
|
+
scope, diff, points = extraction.get("scope"), extraction.get("entry_point_diff"), extraction.get("entry_points")
|
|
494
|
+
kind = scope.get("type") if isinstance(scope, dict) else None
|
|
495
|
+
if not (isinstance(kind, str) and kind.strip().lower() == PUBLIC_API):
|
|
496
|
+
return None, None
|
|
497
|
+
statuses = points.get("by_language") if isinstance(points, dict) else None
|
|
498
|
+
if not isinstance(diff, dict) or not isinstance(statuses, dict):
|
|
499
|
+
return None, "the recipe runner gave no entry-point diff (Quick tier, or a run that could not read the tree)"
|
|
500
|
+
if extraction.get("status") == "incomplete" or extraction.get("errors"):
|
|
501
|
+
return None, "the recipe runner's run is incomplete"
|
|
502
|
+
if points.get("unresolved"):
|
|
503
|
+
return None, "an entry point names a module the runner could not trace (entry_points.unresolved)"
|
|
504
|
+
return _PublicSurface(diff, statuses), None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# The fields of an entry_point_diff item the rule reads, which the inventory's public_surface keeps for `add`, and
|
|
508
|
+
# those of each member of a namespace item.
|
|
509
|
+
SURFACE_FIELDS = {"public": ("name", "language", "file", "local", "via", "members"),
|
|
510
|
+
"extraction_gaps": ("name", "language", "file")}
|
|
511
|
+
MEMBER_FIELDS = ("name", "local", "file")
|
|
512
|
+
NOT_PUBLIC_NAMED = 10 # the names the not-public warning spells out, as update-skill's apply does
|
|
513
|
+
NOT_PUBLIC_LEAD = "Off the public surface: "
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _surface_state(extraction: dict) -> dict:
|
|
517
|
+
"""The parts of the runner's JSON the rule reads (public_surface found a surface in it)."""
|
|
518
|
+
diff = extraction["entry_point_diff"]
|
|
519
|
+
state: dict = {"by_language": dict(extraction["entry_points"]["by_language"])}
|
|
520
|
+
for key, fields in SURFACE_FIELDS.items():
|
|
521
|
+
state[key] = [{field: item[field] for field in fields if field in item}
|
|
522
|
+
for item in diff.get(key) or [] if isinstance(item, dict)]
|
|
523
|
+
for item in state["public"]:
|
|
524
|
+
if isinstance(item.get("members"), list):
|
|
525
|
+
item["members"] = [{field: m[field] for field in MEMBER_FIELDS if field in m}
|
|
526
|
+
for m in item["members"] if isinstance(m, dict)]
|
|
527
|
+
return state
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _stored_surface(data: dict) -> _PublicSurface | None:
|
|
531
|
+
"""The public surface the inventory's public_surface keeps, or None when init kept none."""
|
|
532
|
+
state = data.get("public_surface")
|
|
533
|
+
if not isinstance(state, dict) or not isinstance(state.get("by_language"), dict):
|
|
534
|
+
return None
|
|
535
|
+
return _PublicSurface(state, state["by_language"])
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _mark(surface: _PublicSurface | None, export: dict) -> None:
|
|
539
|
+
"""Set the export's `public` mark by the rule; no other `public` value is kept."""
|
|
540
|
+
export.pop("public", None)
|
|
541
|
+
name, path = export.get("export_name"), _norm_path(export.get("source_file"))
|
|
542
|
+
if surface is None or not isinstance(name, str) or not name or path is None:
|
|
543
|
+
return
|
|
544
|
+
mark = surface.public(name, path, export.get("language"))
|
|
545
|
+
if mark is not None:
|
|
546
|
+
export["public"] = mark
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _not_public_warning(names: list[str]) -> str:
|
|
550
|
+
"""The one warning naming the exports marked `public: false` (`name (file)`), for Gate 2 and the evidence
|
|
551
|
+
report."""
|
|
552
|
+
shown = ", ".join(names[:NOT_PUBLIC_NAMED])
|
|
553
|
+
more = f" and {len(names) - NOT_PUBLIC_NAMED} more" if len(names) > NOT_PUBLIC_NAMED else ""
|
|
554
|
+
return (f"{NOT_PUBLIC_LEAD}{len(names)} export(s) of this public-api skill are left out of the provenance map, "
|
|
555
|
+
f"SKILL.md, references/, the context snippet and metadata.json's exports[] (each is marked "
|
|
556
|
+
f"`public: false` in the inventory, and `summary` lists them all): {shown}{more}")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _write_not_public_warning(data: dict) -> None:
|
|
560
|
+
"""Write the not-public warning in place of the one an earlier call wrote (else at the end), or drop it when
|
|
561
|
+
no export is marked `public: false`."""
|
|
562
|
+
names = [f"{item['name']} ({item['file']})" for item in _not_public(data)]
|
|
563
|
+
warnings = data.setdefault("warnings", [])
|
|
564
|
+
at = next((i for i, w in enumerate(warnings) if isinstance(w, str) and w.startswith(NOT_PUBLIC_LEAD)), None)
|
|
565
|
+
if at is not None:
|
|
566
|
+
del warnings[at]
|
|
567
|
+
if names:
|
|
568
|
+
warnings.insert(len(warnings) if at is None else at, _not_public_warning(names))
|
|
569
|
+
|
|
570
|
+
|
|
279
571
|
def init(skill: str, mode: str, tier: str, extraction: dict | None = None, detected: dict | None = None) -> dict:
|
|
280
572
|
"""A new inventory, seeded from the runner's JSON and the detector's JSON."""
|
|
281
573
|
data: dict = {
|
|
282
574
|
"skill_name": skill, "extraction_mode": mode, "tier": tier, "files_scanned": None,
|
|
283
575
|
"exports": [], "top_exports": [], "aggregates": {}, "counts": {}, "arms": {},
|
|
284
576
|
"scripts_inventory": [], "assets_inventory": [], "intent_mapping": {}, "co_imports": [],
|
|
285
|
-
"promoted_docs": [], "authoritative_files_scan": None, "extraction_rules": {},
|
|
577
|
+
"promoted_docs": [], "authoritative_files_scan": None, "extraction_rules": {}, "public_surface": None,
|
|
286
578
|
"t2_annotations": [], "t3_items": [], "warnings": [],
|
|
287
579
|
}
|
|
288
580
|
if extraction is not None:
|
|
@@ -291,6 +583,7 @@ def init(skill: str, mode: str, tier: str, extraction: dict | None = None, detec
|
|
|
291
583
|
diff = extraction.get("entry_point_diff")
|
|
292
584
|
internal = {(i.get("name"), i.get("source_file"))
|
|
293
585
|
for i in (diff.get("internal") or [] if isinstance(diff, dict) else []) if isinstance(i, dict)}
|
|
586
|
+
surface, problem = public_surface(extraction)
|
|
294
587
|
for record in extraction.get("exports") or []:
|
|
295
588
|
if not isinstance(record, dict):
|
|
296
589
|
continue
|
|
@@ -299,6 +592,7 @@ def init(skill: str, mode: str, tier: str, extraction: dict | None = None, detec
|
|
|
299
592
|
export.setdefault("extraction_method", "ast-grep")
|
|
300
593
|
if (export.get("export_name"), export.get("source_file")) in internal:
|
|
301
594
|
export["internal"] = True
|
|
595
|
+
_mark(surface, export)
|
|
302
596
|
data["exports"].append(export)
|
|
303
597
|
files = extraction.get("files_in_scope")
|
|
304
598
|
data["files_scanned"] = files if isinstance(files, int) else None
|
|
@@ -313,6 +607,12 @@ def init(skill: str, mode: str, tier: str, extraction: dict | None = None, detec
|
|
|
313
607
|
"ast_grep_version": ast_grep.get("version") if isinstance(ast_grep, dict) else None,
|
|
314
608
|
}
|
|
315
609
|
data["warnings"] = runner_warnings(extraction)
|
|
610
|
+
if problem:
|
|
611
|
+
data["warnings"].append(f"The public surface of this public-api skill could not be applied ({problem}): "
|
|
612
|
+
"no export is marked `public: false`, so every export found is documented")
|
|
613
|
+
if surface is not None:
|
|
614
|
+
data["public_surface"] = _surface_state(extraction)
|
|
615
|
+
_write_not_public_warning(data)
|
|
316
616
|
if detected is not None:
|
|
317
617
|
if not isinstance(detected, dict):
|
|
318
618
|
raise InventoryError("the --detected file is not a JSON object")
|
|
@@ -335,10 +635,20 @@ def add(data: dict, field: str, entries: list) -> tuple[int, int, list[str]]:
|
|
|
335
635
|
kind = str if field == "warnings" else dict
|
|
336
636
|
if not isinstance(entries, list) or not all(isinstance(e, kind) for e in entries):
|
|
337
637
|
raise InventoryError(f"stdin must hold a JSON list of {'strings' if kind is str else 'objects'}")
|
|
638
|
+
surface = _stored_surface(data) if field == "exports" else None
|
|
338
639
|
if field == "exports":
|
|
339
640
|
if not all(isinstance(e.get("export_name"), str) and e["export_name"] for e in entries):
|
|
340
641
|
raise InventoryError("every exports entry needs an export_name")
|
|
341
642
|
entries = [{**BY_EYE_DEFAULTS, **e} for e in entries]
|
|
643
|
+
absolute = [e for e in entries if not _relative_source(e)]
|
|
644
|
+
if absolute:
|
|
645
|
+
sent = ", ".join(f"{e['export_name']}: source_file {e['source_file']}" for e in absolute[:10])
|
|
646
|
+
sent += f" and {len(absolute) - 10} more" if len(absolute) > 10 else ""
|
|
647
|
+
verb, them = ("is", "it") if len(absolute) == 1 else ("are", "them")
|
|
648
|
+
raise InventoryError(f"{sent} {verb} absolute; send {them} relative to the source root, with / "
|
|
649
|
+
"separators, as the runner writes its files")
|
|
650
|
+
for entry in entries:
|
|
651
|
+
_mark(surface, entry)
|
|
342
652
|
target = data.setdefault(field, [])
|
|
343
653
|
known = {e.get("export_name") for e in data["exports"] if isinstance(e, dict)} - {None}
|
|
344
654
|
added = duplicates = 0
|
|
@@ -348,8 +658,8 @@ def add(data: dict, field: str, entries: list) -> tuple[int, int, list[str]]:
|
|
|
348
658
|
dropped.append(entry["export_name"])
|
|
349
659
|
continue
|
|
350
660
|
if field == "exports":
|
|
351
|
-
key = (entry["export_name"], entry.get("source_file"))
|
|
352
|
-
repeat = any(isinstance(e, dict) and (e.get("export_name"), e.get("source_file")) == key
|
|
661
|
+
key = (entry["export_name"], _norm_path(entry.get("source_file")))
|
|
662
|
+
repeat = any(isinstance(e, dict) and (e.get("export_name"), _norm_path(e.get("source_file"))) == key
|
|
353
663
|
for e in target)
|
|
354
664
|
else:
|
|
355
665
|
repeat = any(_same(entry, e) for e in target)
|
|
@@ -358,6 +668,8 @@ def add(data: dict, field: str, entries: list) -> tuple[int, int, list[str]]:
|
|
|
358
668
|
continue
|
|
359
669
|
target.append(entry)
|
|
360
670
|
added += 1
|
|
671
|
+
if surface is not None:
|
|
672
|
+
_write_not_public_warning(data)
|
|
361
673
|
return added, duplicates, dropped
|
|
362
674
|
|
|
363
675
|
|
|
@@ -378,7 +690,7 @@ def patch(data: dict, entries: list) -> tuple[int, list[str], list[str]]:
|
|
|
378
690
|
for entry in entries:
|
|
379
691
|
name, source = entry["export_name"], entry.get("source_file")
|
|
380
692
|
found = [e for e in data["exports"] if isinstance(e, dict) and e.get("export_name") == name
|
|
381
|
-
and (source is None or e.get("source_file") == source)]
|
|
693
|
+
and (source is None or _norm_path(e.get("source_file")) == _norm_path(source))]
|
|
382
694
|
label = name if source is None else f"{name} ({source})"
|
|
383
695
|
if not found:
|
|
384
696
|
unmatched.append(label)
|
|
@@ -390,8 +702,9 @@ def patch(data: dict, entries: list) -> tuple[int, list[str], list[str]]:
|
|
|
390
702
|
return patched, unmatched, ambiguous
|
|
391
703
|
|
|
392
704
|
|
|
393
|
-
def set_fields(data: dict, values: dict) -> list[str]:
|
|
394
|
-
"""Set the keys of `values` on the inventory (see the module docstring); return the keys set
|
|
705
|
+
def set_fields(data: dict, values: dict) -> tuple[list[str], list[str]]:
|
|
706
|
+
"""Set the keys of `values` on the inventory (see the module docstring); return (the keys set, the top_exports
|
|
707
|
+
names dropped as off the public surface)."""
|
|
395
708
|
if not isinstance(values, dict):
|
|
396
709
|
raise InventoryError("stdin must hold a JSON object")
|
|
397
710
|
unknown = sorted(set(values) - set(SETTABLE))
|
|
@@ -403,6 +716,11 @@ def set_fields(data: dict, values: dict) -> list[str]:
|
|
|
403
716
|
names = " or ".join("null" if t is type(None) else t.__name__ for t in types)
|
|
404
717
|
raise InventoryError(f"{key} must be {names}")
|
|
405
718
|
mapping = _intent_mapping(data, values["intent_mapping"]) if "intent_mapping" in values else {}
|
|
719
|
+
dropped: list[str] = []
|
|
720
|
+
if "top_exports" in values:
|
|
721
|
+
off = _off_surface_names(data)
|
|
722
|
+
dropped = [n for n in values["top_exports"] if isinstance(n, str) and n in off]
|
|
723
|
+
values = {**values, "top_exports": [n for n in values["top_exports"] if not (isinstance(n, str) and n in off)]}
|
|
406
724
|
for key, value in values.items():
|
|
407
725
|
if key == "intent_mapping":
|
|
408
726
|
data["intent_mapping"] = {**(data.get("intent_mapping") or {}), **mapping}
|
|
@@ -410,7 +728,7 @@ def set_fields(data: dict, values: dict) -> list[str]:
|
|
|
410
728
|
data[key] = {**(data.get(key) if isinstance(data.get(key), dict) else {}), **value}
|
|
411
729
|
else:
|
|
412
730
|
data[key] = value
|
|
413
|
-
return list(values)
|
|
731
|
+
return list(values), dropped
|
|
414
732
|
|
|
415
733
|
|
|
416
734
|
def _intent_mapping(data: dict, mapping: dict) -> dict:
|
|
@@ -488,11 +806,12 @@ def _labels(export: dict) -> dict:
|
|
|
488
806
|
|
|
489
807
|
|
|
490
808
|
def provenance_entries(data: dict) -> tuple[list[dict], list[str]]:
|
|
491
|
-
"""(the provenance map entry of every export
|
|
809
|
+
"""(the provenance map entry of every export but one marked `public: false`, the functions among them with no
|
|
810
|
+
params)."""
|
|
492
811
|
entries: list[dict] = []
|
|
493
812
|
unsigned: list[str] = []
|
|
494
813
|
for export in data.get("exports") or []:
|
|
495
|
-
if not isinstance(export, dict):
|
|
814
|
+
if not isinstance(export, dict) or export.get("public") is False:
|
|
496
815
|
continue
|
|
497
816
|
raw = export.get("params")
|
|
498
817
|
params = None
|
|
@@ -512,6 +831,11 @@ def provenance_entries(data: dict) -> tuple[list[dict], list[str]]:
|
|
|
512
831
|
return entries, unsigned
|
|
513
832
|
|
|
514
833
|
|
|
834
|
+
def _utc_now() -> str:
|
|
835
|
+
"""Now, in UTC, to the second (ISO-8601)."""
|
|
836
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
837
|
+
|
|
838
|
+
|
|
515
839
|
def map_header(data: dict, given) -> dict:
|
|
516
840
|
"""The provenance map's header: stdin's keys over the defaults."""
|
|
517
841
|
if not isinstance(given, dict):
|
|
@@ -522,8 +846,7 @@ def map_header(data: dict, given) -> dict:
|
|
|
522
846
|
missing = [key for key, default in MAP_HEADER.items() if default is REQUIRED and key not in given]
|
|
523
847
|
if missing:
|
|
524
848
|
raise InventoryError(f"the map's header needs {', '.join(missing)} (null when unknown)")
|
|
525
|
-
defaults = {"skill_name": data.get("skill_name"),
|
|
526
|
-
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())}
|
|
849
|
+
defaults = {"skill_name": data.get("skill_name"), "generated_at": _utc_now()}
|
|
527
850
|
return {key: given[key] if key in given else defaults.get(key, default) for key, default in MAP_HEADER.items()}
|
|
528
851
|
|
|
529
852
|
|
|
@@ -533,12 +856,15 @@ def provenance_map(data: dict, header) -> tuple[dict, list[str]]:
|
|
|
533
856
|
return {**map_header(data, header), "entries": entries}, unsigned
|
|
534
857
|
|
|
535
858
|
|
|
536
|
-
def add_map_entries(provenance: dict, payload) -> tuple[int, int]:
|
|
537
|
-
"""Append stdin's `entries` and `file_entries` to the map; return (added, duplicates
|
|
859
|
+
def add_map_entries(provenance: dict, payload, off: frozenset = frozenset()) -> tuple[int, int, list[dict]]:
|
|
860
|
+
"""Append stdin's `entries` and `file_entries` to the map; return (added, duplicates, {name, file} of each entry
|
|
861
|
+
not added because `off`, the (export_name, source_file) of the inventory's exports marked `public: false`,
|
|
862
|
+
holds it)."""
|
|
538
863
|
if not isinstance(payload, dict) or not payload or set(payload) - {"entries", "file_entries"}:
|
|
539
864
|
raise InventoryError('stdin must hold {"entries": [...], "file_entries": [...]} (either list, or both)')
|
|
540
865
|
keys = {"entries": ("export_name", "source_file"), "file_entries": ("file_name",)}
|
|
541
866
|
added = duplicates = 0
|
|
867
|
+
not_public: list[dict] = []
|
|
542
868
|
for field, items in payload.items():
|
|
543
869
|
if not isinstance(items, list) or not all(isinstance(e, dict) for e in items):
|
|
544
870
|
raise InventoryError(f"{field} must be a list of objects")
|
|
@@ -551,13 +877,197 @@ def add_map_entries(provenance: dict, payload) -> tuple[int, int]:
|
|
|
551
877
|
held = {tuple(e.get(k) for k in keys[field]) for e in target if isinstance(e, dict)}
|
|
552
878
|
for item in items:
|
|
553
879
|
key = tuple(item.get(k) for k in keys[field])
|
|
880
|
+
if field == "entries" and (item.get("export_name"), _norm_path(item.get("source_file"))) in off:
|
|
881
|
+
not_public.append({"name": item.get("export_name"), "file": item.get("source_file")})
|
|
882
|
+
continue
|
|
554
883
|
if key in held:
|
|
555
884
|
duplicates += 1
|
|
556
885
|
continue
|
|
557
886
|
held.add(key)
|
|
558
887
|
target.append(item)
|
|
559
888
|
added += 1
|
|
560
|
-
return added, duplicates
|
|
889
|
+
return added, duplicates, not_public
|
|
890
|
+
|
|
891
|
+
|
|
892
|
+
_SIBLINGS: dict = {}
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _sibling(filename: str):
|
|
896
|
+
"""A helper installed beside this script, loaded once as a module: skf-resolve-authoritative-files.py (the
|
|
897
|
+
brief's YAML), skf-render-quick-metadata.py (the installed SKF's version) and skf-description-guard.py (the
|
|
898
|
+
staged SKILL.md's description)."""
|
|
899
|
+
if filename not in _SIBLINGS:
|
|
900
|
+
path = Path(__file__).resolve().parent / filename
|
|
901
|
+
spec = importlib.util.spec_from_file_location("skf_" + filename[4:-3].replace("-", "_"), path)
|
|
902
|
+
if spec is None or spec.loader is None:
|
|
903
|
+
raise InventoryError(f"cannot load {filename} beside {Path(__file__).name}")
|
|
904
|
+
module = importlib.util.module_from_spec(spec)
|
|
905
|
+
try:
|
|
906
|
+
spec.loader.exec_module(module)
|
|
907
|
+
except ImportError as exc:
|
|
908
|
+
raise InventoryError(f"cannot load {filename} ({exc}): run this helper with `uv run`") from exc
|
|
909
|
+
except (OSError, SyntaxError) as exc:
|
|
910
|
+
raise InventoryError(f"cannot load {filename} beside {Path(__file__).name}: {exc}") from exc
|
|
911
|
+
_SIBLINGS[filename] = module
|
|
912
|
+
return _SIBLINGS[filename]
|
|
913
|
+
|
|
914
|
+
|
|
915
|
+
def read_brief(path: Path) -> dict:
|
|
916
|
+
"""The skill brief, a YAML mapping."""
|
|
917
|
+
module = _sibling("skf-resolve-authoritative-files.py")
|
|
918
|
+
try:
|
|
919
|
+
brief = module.parse_brief_yaml(path.read_text(encoding="utf-8"))
|
|
920
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
921
|
+
raise InventoryError(f"cannot read the brief {path.as_posix()}: {exc}") from exc
|
|
922
|
+
except module.yaml.YAMLError as exc:
|
|
923
|
+
raise InventoryError(f"the brief {path.as_posix()} is not valid YAML: {exc}") from exc
|
|
924
|
+
if not isinstance(brief, dict):
|
|
925
|
+
raise InventoryError(f"the brief {path.as_posix()} is not a YAML mapping")
|
|
926
|
+
return brief
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
# metadata: the stdin keys, each with the JSON types it takes; METADATA_REQUIRED have no default.
|
|
930
|
+
METADATA_INPUT = {
|
|
931
|
+
"version": (str,), "language": (str,),
|
|
932
|
+
"source_root": (str, type(None)), "source_commit": (str, type(None)), "source_ref": (str, type(None)),
|
|
933
|
+
"qmd_version": (str, type(None)), "ast_grep_version": (str, type(None)), "stats_extra": (dict,),
|
|
934
|
+
}
|
|
935
|
+
METADATA_REQUIRED = ("version", "language", "source_root", "source_commit", "source_ref", "qmd_version")
|
|
936
|
+
NON_EMPTY = ("version", "language")
|
|
937
|
+
REFERENCE_APP = "reference-app"
|
|
938
|
+
COMPONENT_LIBRARY = "component-library"
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def _metadata_input(given) -> dict:
|
|
942
|
+
"""stdin's fields, checked, with the defaults of the optional ones."""
|
|
943
|
+
if not isinstance(given, dict):
|
|
944
|
+
raise InventoryError("stdin must hold a JSON object with " + ", ".join(METADATA_REQUIRED))
|
|
945
|
+
unknown = sorted(set(given) - set(METADATA_INPUT))
|
|
946
|
+
if unknown:
|
|
947
|
+
raise InventoryError(f"metadata takes only {', '.join(METADATA_INPUT)}; not {', '.join(unknown)}")
|
|
948
|
+
missing = [key for key in METADATA_REQUIRED if key not in given]
|
|
949
|
+
if missing:
|
|
950
|
+
raise InventoryError(f"metadata needs {', '.join(missing)} (null when unknown, but for "
|
|
951
|
+
f"{', '.join(NON_EMPTY)})")
|
|
952
|
+
for key, value in given.items():
|
|
953
|
+
if not isinstance(value, METADATA_INPUT[key]) or (key in NON_EMPTY and not value.strip()):
|
|
954
|
+
names = " or ".join("null" if t is type(None) else t.__name__ for t in METADATA_INPUT[key])
|
|
955
|
+
raise InventoryError(f"{key} must be {'a non-empty ' if key in NON_EMPTY else ''}{names}")
|
|
956
|
+
return {"ast_grep_version": None, "stats_extra": {}, **given}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def read_description(skill_md: Path) -> str:
|
|
960
|
+
"""The `description` of the staged SKILL.md's frontmatter, which compile §2a wrote."""
|
|
961
|
+
module = _sibling("skf-description-guard.py")
|
|
962
|
+
try:
|
|
963
|
+
description, _hash = module.read_description(skill_md)
|
|
964
|
+
except (OSError, UnicodeDecodeError, ValueError) as exc:
|
|
965
|
+
raise InventoryError(f"cannot read the description of the staged SKILL.md: {exc} (write SKILL.md, "
|
|
966
|
+
"compile §2, before metadata.json)") from exc
|
|
967
|
+
if not description.strip():
|
|
968
|
+
raise InventoryError(f"the description of {skill_md.as_posix()} is empty")
|
|
969
|
+
return description
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def metadata_exports(data: dict, scope_type: object) -> list[str]:
|
|
973
|
+
"""metadata.json's exports[]: the names of the inventory's exports but one marked internal or not public, each
|
|
974
|
+
once (a docs-only inventory's t3_items names; none for a reference app)."""
|
|
975
|
+
if scope_type == REFERENCE_APP:
|
|
976
|
+
return []
|
|
977
|
+
if data.get("extraction_mode") == "docs-only":
|
|
978
|
+
items = [i for i in data.get("t3_items") or [] if isinstance(i, dict)]
|
|
979
|
+
else:
|
|
980
|
+
items = [e for e in data["exports"] if isinstance(e, dict) and e.get("internal") is not True
|
|
981
|
+
and e.get("public") is not False]
|
|
982
|
+
names = (i.get("export_name") for i in items)
|
|
983
|
+
return list(dict.fromkeys(n for n in names if isinstance(n, str) and n))
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _file_rows(entries: object, folder: str) -> list[dict]:
|
|
987
|
+
"""The scripts[] or assets[] rows of a scripts_inventory or assets_inventory."""
|
|
988
|
+
rows = []
|
|
989
|
+
for entry in entries if isinstance(entries, list) else []:
|
|
990
|
+
if not isinstance(entry, dict):
|
|
991
|
+
continue
|
|
992
|
+
source = entry.get("source_file")
|
|
993
|
+
name = entry.get("name") or (Path(str(source)).name if source else None)
|
|
994
|
+
rows.append({"file": f"{folder}/{name}", "purpose": entry.get("purpose") or "", "source_file": source,
|
|
995
|
+
"confidence": entry.get("confidence") or "T1-low"})
|
|
996
|
+
return rows
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _ast_grep_run(data: dict, given_version: str | None) -> tuple[str | None, int | None, list[str]]:
|
|
1000
|
+
"""(tool_versions.ast_grep, ast_node_count, warnings): the runner's version and match count when it ran
|
|
1001
|
+
ast-grep, else the number of exports labeled ast-grep (the recipes run without the runner) and the version
|
|
1002
|
+
stdin gave, else (None, None) when ast-grep did not run."""
|
|
1003
|
+
rules = data.get("extraction_rules") if isinstance(data.get("extraction_rules"), dict) else {}
|
|
1004
|
+
version = rules.get("ast_grep_version")
|
|
1005
|
+
if isinstance(version, str) and version:
|
|
1006
|
+
aggregates = data.get("aggregates") if isinstance(data.get("aggregates"), dict) else {}
|
|
1007
|
+
matched = aggregates.get("exports")
|
|
1008
|
+
return version, matched if isinstance(matched, int) and not isinstance(matched, bool) else None, []
|
|
1009
|
+
matched = sum(1 for e in data["exports"] if isinstance(e, dict) and e.get("extraction_method") == "ast-grep")
|
|
1010
|
+
if not matched:
|
|
1011
|
+
return None, None, []
|
|
1012
|
+
if given_version and given_version.strip():
|
|
1013
|
+
return given_version.strip(), matched, []
|
|
1014
|
+
return "unknown", matched, [f"tool_versions.ast_grep is unknown: ast-grep matched {matched} exports without "
|
|
1015
|
+
"the recipe runner, and stdin gave no ast_grep_version"]
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def build_metadata(data: dict, brief: dict, stats: object, given, skf_version: str,
|
|
1019
|
+
description: str) -> tuple[dict, list[str]]:
|
|
1020
|
+
"""(the skill's metadata.json, its warnings) (see `metadata` in the module docstring)."""
|
|
1021
|
+
fields = _metadata_input(given)
|
|
1022
|
+
if not isinstance(stats, dict) or not isinstance(stats.get("stats"), dict) \
|
|
1023
|
+
or not isinstance(stats.get("confidence_distribution"), dict):
|
|
1024
|
+
raise InventoryError("the --stats file must hold the stats helper's JSON: a stats and a "
|
|
1025
|
+
"confidence_distribution object")
|
|
1026
|
+
clash = sorted(set(fields["stats_extra"]) & set(stats["stats"]))
|
|
1027
|
+
if clash:
|
|
1028
|
+
raise InventoryError(f"stats_extra may not set {', '.join(clash)}: the stats helper writes it")
|
|
1029
|
+
scope = brief.get("scope") if isinstance(brief.get("scope"), dict) else {}
|
|
1030
|
+
scope_type = scope.get("type")
|
|
1031
|
+
if not isinstance(scope_type, str) or not scope_type:
|
|
1032
|
+
raise InventoryError("the brief has no scope.type, which metadata.json records as scope_type")
|
|
1033
|
+
extra = sorted(fields["stats_extra"])
|
|
1034
|
+
if scope_type == REFERENCE_APP and set(extra) - {"notes"}:
|
|
1035
|
+
raise InventoryError(f"a reference app's stats_extra holds only notes, not {', '.join(extra)}: the stats "
|
|
1036
|
+
"helper writes pattern_surfaces_documented")
|
|
1037
|
+
if extra and scope_type not in (REFERENCE_APP, COMPONENT_LIBRARY):
|
|
1038
|
+
raise InventoryError(f"a {scope_type} skill takes no stats_extra (only a component library or a "
|
|
1039
|
+
"reference app adds stats)")
|
|
1040
|
+
ast_grep, node_count, warnings = _ast_grep_run(data, fields["ast_grep_version"])
|
|
1041
|
+
metadata = {
|
|
1042
|
+
"name": brief.get("name") or data.get("skill_name"),
|
|
1043
|
+
"version": fields["version"],
|
|
1044
|
+
"skill_type": "single",
|
|
1045
|
+
"scope_type": scope_type,
|
|
1046
|
+
"source_authority": brief.get("source_authority") or "community",
|
|
1047
|
+
"source_repo": brief.get("source_repo"),
|
|
1048
|
+
"source_root": fields["source_root"],
|
|
1049
|
+
"source_commit": fields["source_commit"],
|
|
1050
|
+
"source_ref": fields["source_ref"],
|
|
1051
|
+
"confidence_tier": data.get("tier"),
|
|
1052
|
+
"spec_version": "1.3",
|
|
1053
|
+
"generation_date": _utc_now(),
|
|
1054
|
+
"description": description,
|
|
1055
|
+
"language": fields["language"],
|
|
1056
|
+
}
|
|
1057
|
+
if node_count is not None:
|
|
1058
|
+
metadata["ast_node_count"] = node_count
|
|
1059
|
+
metadata["exports"] = metadata_exports(data, scope_type)
|
|
1060
|
+
metadata["tool_versions"] = {"ast_grep": ast_grep, "qmd": fields["qmd_version"], "skf": skf_version}
|
|
1061
|
+
metadata["confidence_distribution"] = stats["confidence_distribution"]
|
|
1062
|
+
metadata["stats"] = {**stats["stats"], **fields["stats_extra"]}
|
|
1063
|
+
for key, folder in (("scripts_inventory", "scripts"), ("assets_inventory", "assets")):
|
|
1064
|
+
rows = _file_rows(data.get(key), folder)
|
|
1065
|
+
if rows:
|
|
1066
|
+
metadata[folder] = rows
|
|
1067
|
+
metadata["generated_by"] = "create-skill"
|
|
1068
|
+
metadata["dependencies"] = []
|
|
1069
|
+
metadata["compatibility"] = ""
|
|
1070
|
+
return metadata, warnings
|
|
561
1071
|
|
|
562
1072
|
|
|
563
1073
|
def rules_yaml(data: dict, language: str) -> str:
|
|
@@ -669,6 +1179,16 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
669
1179
|
p_prov.add_argument("--target", required=True, type=Path, help="the provenance-map.json to write")
|
|
670
1180
|
p_prov.add_argument("--add-entries", action="store_true",
|
|
671
1181
|
help="append stdin's entries and file_entries to the map at --target instead")
|
|
1182
|
+
p_meta = sub.add_parser("metadata", help="write metadata.json from the inventory, the brief and the stats, "
|
|
1183
|
+
"the fields compile decides on stdin")
|
|
1184
|
+
p_meta.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
1185
|
+
p_meta.add_argument("--brief", required=True, type=Path, help="the skill-brief.yaml")
|
|
1186
|
+
p_meta.add_argument("--stats", required=True, type=Path,
|
|
1187
|
+
help="the JSON skf-render-metadata-stats.py wrote (its -o file)")
|
|
1188
|
+
p_meta.add_argument("--skf-root", required=True, type=Path,
|
|
1189
|
+
help="the installed SKF module folder ({project-root}/_bmad/skf), for tool_versions.skf")
|
|
1190
|
+
p_meta.add_argument("--target", required=True, type=Path,
|
|
1191
|
+
help="the metadata.json to write, beside the staged SKILL.md it reads the description of")
|
|
672
1192
|
p_summary = sub.add_parser("summary", help="print the inventory's counts")
|
|
673
1193
|
p_summary.add_argument("--inventory", required=True, type=Path, help=inventory_help)
|
|
674
1194
|
return parser
|
|
@@ -685,21 +1205,35 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
685
1205
|
write(path, data)
|
|
686
1206
|
result = _result(path, data)
|
|
687
1207
|
elif args.command == "provenance" and args.add_entries:
|
|
688
|
-
load(path)
|
|
1208
|
+
data = load(path)
|
|
689
1209
|
provenance = _read_json(args.target, "provenance map")
|
|
690
1210
|
if not isinstance(provenance, dict) or not isinstance(provenance.get("entries"), list):
|
|
691
1211
|
raise InventoryError(f"the provenance map {args.target.as_posix()} has no entries list: "
|
|
692
1212
|
"run provenance without --add-entries first")
|
|
693
|
-
|
|
1213
|
+
off = frozenset((item["name"], _norm_path(item["file"])) for item in _not_public(data))
|
|
1214
|
+
added, duplicates, not_public = add_map_entries(provenance, _stdin_json(), off)
|
|
694
1215
|
write(args.target, provenance)
|
|
695
1216
|
result = {"status": "ok", "target": args.target.as_posix(), "entries": len(provenance["entries"]),
|
|
696
1217
|
"file_entries": len(provenance.get("file_entries") or []), "added": added,
|
|
697
|
-
"duplicates": duplicates}
|
|
1218
|
+
"duplicates": duplicates, "not_public": not_public}
|
|
698
1219
|
elif args.command == "provenance":
|
|
699
1220
|
provenance, unsigned = provenance_map(load(path), _stdin_json())
|
|
700
1221
|
write(args.target, provenance)
|
|
701
1222
|
result = {"status": "ok", "target": args.target.as_posix(), "entries": len(provenance["entries"]),
|
|
702
1223
|
"unsigned": unsigned}
|
|
1224
|
+
elif args.command == "metadata":
|
|
1225
|
+
skf_version = _sibling("skf-render-quick-metadata.py").probe_skf_version(args.skf_root)
|
|
1226
|
+
metadata, warnings = build_metadata(load(path), read_brief(args.brief),
|
|
1227
|
+
_read_json(args.stats, "stats JSON"), _stdin_json(), skf_version,
|
|
1228
|
+
read_description(args.target.parent / "SKILL.md"))
|
|
1229
|
+
write(args.target, metadata)
|
|
1230
|
+
commit = metadata["source_commit"]
|
|
1231
|
+
if skf_version == "unknown":
|
|
1232
|
+
warnings.append(f"tool_versions.skf is unknown: {args.skf_root.as_posix()} holds no package.json "
|
|
1233
|
+
"or VERSION file with a version")
|
|
1234
|
+
result = {"status": "ok", "target": args.target.as_posix(), "exports": len(metadata["exports"]),
|
|
1235
|
+
"commit_short": commit[:8] if commit else "unknown",
|
|
1236
|
+
"tool_versions": metadata["tool_versions"], "warnings": warnings}
|
|
703
1237
|
elif args.command == "rules":
|
|
704
1238
|
text = rules_yaml(load(path), args.language)
|
|
705
1239
|
write(args.target, text)
|
|
@@ -707,7 +1241,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
707
1241
|
else:
|
|
708
1242
|
data = load(path)
|
|
709
1243
|
if args.command == "summary":
|
|
710
|
-
result = _result(path, data)
|
|
1244
|
+
result = _result(path, data, not_public=_not_public(data))
|
|
711
1245
|
elif args.command == "add":
|
|
712
1246
|
payload = _stdin_json()
|
|
713
1247
|
added, duplicates, dropped = add(data, args.field, [] if payload is None else payload)
|
|
@@ -720,9 +1254,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
720
1254
|
result = _result(path, data, patched=patched, unmatched=unmatched, ambiguous=ambiguous)
|
|
721
1255
|
else:
|
|
722
1256
|
payload = _stdin_json()
|
|
723
|
-
keys = set_fields(data, {} if payload is None else payload)
|
|
1257
|
+
keys, dropped = set_fields(data, {} if payload is None else payload)
|
|
724
1258
|
write(path, data)
|
|
725
|
-
result = _result(path, data, set=keys)
|
|
1259
|
+
result = _result(path, data, set=keys, dropped=dropped)
|
|
726
1260
|
except InventoryError as exc:
|
|
727
1261
|
print(json.dumps({"status": "error", "message": str(exc)}), file=sys.stderr)
|
|
728
1262
|
return 1
|