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