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.
Files changed (93) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/examples.md +1 -1
  4. package/docs/troubleshooting.md +8 -0
  5. package/docs/verifying-a-skill.md +7 -3
  6. package/docs/workflows.md +3 -3
  7. package/package.json +1 -1
  8. package/src/knowledge/qmd-registry.md +1 -1
  9. package/src/knowledge/version-paths.md +1 -1
  10. package/src/shared/health-check.md +2 -50
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  12. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
  13. package/src/shared/scripts/skf-atomic-write.py +212 -59
  14. package/src/shared/scripts/skf-build-change-manifest.py +593 -47
  15. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  16. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  17. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  18. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  19. package/src/shared/scripts/skf-extract-public-api.py +1309 -157
  20. package/src/shared/scripts/skf-extraction-inventory.py +580 -46
  21. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  22. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  23. package/src/shared/scripts/skf-github-probe.py +47 -8
  24. package/src/shared/scripts/skf-hash-content.py +14 -8
  25. package/src/shared/scripts/skf-load-provenance.py +3 -1
  26. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  27. package/src/shared/scripts/skf-preflight.py +15 -1
  28. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  29. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  30. package/src/shared/scripts/skf-structural-diff.py +133 -18
  31. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  32. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  33. package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
  34. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  35. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  36. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  37. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  38. package/src/skf-audit-skill/references/health-check.md +2 -0
  39. package/src/skf-audit-skill/references/init.md +2 -1
  40. package/src/skf-audit-skill/references/re-index.md +14 -9
  41. package/src/skf-audit-skill/references/report.md +2 -2
  42. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  43. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  44. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  45. package/src/skf-brief-skill/SKILL.md +1 -1
  46. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  47. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
  48. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  49. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  50. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  51. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  52. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  53. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  54. package/src/skf-create-skill/assets/skill-sections.md +6 -6
  55. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  56. package/src/skf-create-skill/references/compile.md +31 -60
  57. package/src/skf-create-skill/references/component-extraction.md +3 -3
  58. package/src/skf-create-skill/references/enrich.md +2 -2
  59. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  60. package/src/skf-create-skill/references/extract.md +22 -19
  61. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  62. package/src/skf-create-skill/references/extraction-patterns.md +8 -9
  63. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  64. package/src/skf-create-skill/references/report.md +16 -5
  65. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  66. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  67. package/src/skf-drop-skill/references/execute.md +5 -1
  68. package/src/skf-drop-skill/references/health-check.md +2 -0
  69. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  70. package/src/skf-drop-skill/references/report.md +2 -2
  71. package/src/skf-drop-skill/references/select.md +7 -3
  72. package/src/skf-test-skill/references/coherence-check.md +1 -1
  73. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  74. package/src/skf-test-skill/references/coverage-check.md +39 -57
  75. package/src/skf-test-skill/references/init.md +3 -5
  76. package/src/skf-test-skill/references/report.md +10 -8
  77. package/src/skf-test-skill/references/scoring-rules.md +6 -5
  78. package/src/skf-test-skill/references/source-access-protocol.md +14 -17
  79. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  80. package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
  81. package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
  82. package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
  83. package/src/skf-test-skill/scripts/score-signatures.py +36 -16
  84. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  85. package/src/skf-update-skill/references/detect-changes.md +32 -13
  86. package/src/skf-update-skill/references/gap-driven.md +3 -3
  87. package/src/skf-update-skill/references/init.md +6 -2
  88. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  89. package/src/skf-update-skill/references/merge.md +2 -2
  90. package/src/skf-update-skill/references/re-extract.md +2 -2
  91. package/src/skf-update-skill/references/report.md +3 -1
  92. package/src/skf-update-skill/references/write.md +43 -23
  93. 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, 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,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). A t3_items entry whose
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: export_name,
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
- Output (stdout, exit 0), for every subcommand but rules and provenance:
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, "t1": 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", ...] # set
233
+ "set": ["key", ...], "dropped": ["name"], # set
234
+ "not_public": [{"name", "file"}, ...] # summary
141
235
  }
142
236
 
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`.
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. rules
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 merge.
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 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
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
- notes = [n for n in data.get("t2_annotations") or [] if isinstance(n, dict)]
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 in them may be missing")
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, the functions among them with no params)."""
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
- added, duplicates = add_map_entries(provenance, _stdin_json())
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