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
@@ -18,7 +18,8 @@ CLI:
18
18
  uv run skf-extraction-snapshot.py build --source-root <dir> --tier <tier> \\
19
19
  --date <timestamp> [--source-path <recorded root>] \\
20
20
  [--provenance-map <map>] [--extraction <runner.json>] \\
21
- [--details <by-eye.json>]... -o <extraction-snapshot.json>
21
+ [--details <by-eye.json>]... [--scope-type <type>] \\
22
+ -o <extraction-snapshot.json>
22
23
  uv run skf-extraction-snapshot.py relocate <extraction-snapshot.json> \\
23
24
  --diff <structural-diff.json> --extraction <runner.json>
24
25
 
@@ -66,6 +67,24 @@ build
66
67
  map gives a library (`source_library_by_file`) carries it as
67
68
  `source_library`, which step 3's `--group-by source_library` diff reads.
68
69
 
70
+ Public surface (--scope-type public-api, in any case). Each export of a
71
+ language family whose entry points are a barrel is marked `public`, true
72
+ or false, by skf-extraction-inventory.py's public-surface rule
73
+ (`public_surface` over the runner's JSON, with the scope type this flag
74
+ gives, and `public(name, file, language)`, the language of the runner's
75
+ export of that name and file, else the file's extension): the rule
76
+ create-skill keeps a public-api skill's provenance map to, and
77
+ update-skill's change manifest too. Every export stays in the snapshot,
78
+ so a map entry off the surface is never reported removed; step 3's diff
79
+ lists an export marked false that pairs with no map entry under
80
+ `not_public[]`, not `added[]`. With no surface the rule can read (no
81
+ runner JSON, as at Quick tier, a runner that could not run, an
82
+ incomplete run, one with errors, or an entry point the runner could not
83
+ trace) nothing is marked, and `public_surface.problem` says why. Any
84
+ other scope type, and no flag, mark nothing and give `public_surface`
85
+ null; only --scope-type public-api loads skf-extraction-inventory.py,
86
+ from beside this script.
87
+
69
88
  Details file: {"files": [paths read by eye], "exports": [export objects]}
70
89
  (either key may be absent). Give --details once per file: one per worker.
71
90
 
@@ -87,7 +106,8 @@ build
87
106
  "hash-tracked": N, "missing": N,
88
107
  "parse-failed": N, "unread": N},
89
108
  "ast_fallback_files": ["<file>", ...],
90
- "extraction_gaps": [{"name", "file", "line", "entry"}, ...],
109
+ "extraction_gaps": [{"name", "file", "line", "entry",
110
+ "export_type"?}, ...],
91
111
  "truncated": bool, # a recipe hit the runner's head cap
92
112
  "cap_hits": ["<recipe id>", ...],
93
113
  "outside_scope": [{"path": "<file>", "names": [...],
@@ -96,7 +116,9 @@ build
96
116
  "t1": N, "t1_low": N},
97
117
  "exports": [{"name", "type", "signature", "file", "line",
98
118
  "confidence", "extraction_method", "ast_node_type",
99
- "source_library"?}, ...],
119
+ "source_library"?, "public"?}, ...],
120
+ "public_surface": {"problem": "<why no surface was read>" | null,
121
+ "marked_not_public": N} | null,
100
122
  "relocations": [{"name", "file", "line"}, ...], # relocate's
101
123
  "warnings": ["..."]
102
124
  }
@@ -106,7 +128,27 @@ build
106
128
  ["all files (ast-grep unavailable)"], and [] at the Quick tier.
107
129
  `extraction_gaps` lists the names a package entry point exports that no
108
130
  recipe found (the runner's `entry_point_diff.extraction_gaps`), less the
109
- ones an export of the snapshot now holds: read them by eye.
131
+ ones an export of the snapshot now holds, then, with --provenance-map,
132
+ the baseline gaps: each entry of the map the snapshot lacks whose file
133
+ still declares it (the runner leaves out a Python or Rust underscore
134
+ name such as `__version__`, and a module), which would otherwise read as
135
+ removed. A baseline gap gives the line that declares the name, `entry`
136
+ null and, when the entry has one, its `export_type`. It is checked only
137
+ for an entry whose file is `extracted` or `read-by-eye`, whose name is
138
+ not dotted, and that the snapshot holds neither under its name nor under
139
+ its `reexported_as` target at that file. The selection and the
140
+ declaration are skf-verify-provenance-completeness.py's (`baseline_gaps`,
141
+ by `declared_line`), loaded from beside this script, the rule
142
+ update-skill's Category B applies through skf-build-change-manifest.py
143
+ baseline-gaps: a line at column 0 of a Python or TS/JS file that the
144
+ definition-line rules match with imports left out (an alias that
145
+ renames counts; a plain or star import and a re-export never do),
146
+ and for a `module` or `package` entry the module or package so named
147
+ (at its line 1). An entry no rule can check (its file neither Python
148
+ nor TS/JS, a file the lookup does not read, such as a test file or one
149
+ with a symlink on its path, or a dotted `module` or `package` name) is
150
+ named in one warning, as `name (file)`. Each name and file is listed
151
+ once, and each file is read once. Read them by eye.
110
152
  `outside_scope` groups the runner's `entry_point_diff.outside_scope` by
111
153
  the file that defines each name: public API a package entry point exports
112
154
  from a file the skill does not cover, which the drift report lists under
@@ -115,8 +157,8 @@ build
115
157
  Prints {"status": "ok", "output", "runner_status", "complete",
116
158
  "to_read": [{"file", "status", "issue"}, ...] (the `parse-failed` and
117
159
  `unread` files), "extraction_gaps", "ast_fallback_files",
118
- "files_by_status", "counts"} on stdout: what step 2 acts on, so it never
119
- opens the runner's JSON.
160
+ "files_by_status", "counts", "public_surface"} on stdout: what step 2
161
+ acts on, so it never opens the runner's JSON.
120
162
 
121
163
  relocate
122
164
 
@@ -130,9 +172,9 @@ relocate
130
172
  "names": [...]}; added 0 changes nothing.
131
173
 
132
174
  Exit codes: 0 the file was written (or relocate found nothing to add); 2
133
- an input that cannot be read or parsed, or an output that cannot be
134
- written ({"status": "error", "error"} on stdout), or a usage error
135
- (argparse, on stderr).
175
+ an input that cannot be read or parsed, a helper beside this script that
176
+ cannot be loaded, or an output that cannot be written ({"status":
177
+ "error", "error"} on stdout), or a usage error (argparse, on stderr).
136
178
  """
137
179
 
138
180
  from __future__ import annotations
@@ -147,7 +189,11 @@ from pathlib import Path
147
189
  STATUSES = ("extracted", "read-by-eye", "hash-tracked", "missing", "parse-failed", "unread")
148
190
  TO_READ = ("parse-failed", "unread")
149
191
  RUNNER_READ = frozenset({"ok", "incomplete"})
192
+ # The files whose provenance entries a baseline gap checks: the ones read.
193
+ BASELINE_GAP_STATUSES = ("extracted", "read-by-eye")
150
194
  AST_GREP_UNAVAILABLE = "all files (ast-grep unavailable)"
195
+ # The scope type whose exports are marked by the public-surface rule.
196
+ PUBLIC_API = "public-api"
151
197
  EXIT_ERROR = 2
152
198
 
153
199
 
@@ -155,24 +201,72 @@ class SnapshotError(Exception):
155
201
  """An input or output the helper cannot use (exit 2)."""
156
202
 
157
203
 
158
- _PROVENANCE = None
204
+ _SIBLINGS: dict[str, object] = {}
159
205
 
160
206
 
161
- def _provenance():
162
- """skf-load-provenance.py, installed beside this script, loaded once."""
163
- global _PROVENANCE
164
- if _PROVENANCE is None:
165
- path = Path(__file__).resolve().parent / "skf-load-provenance.py"
166
- spec = importlib.util.spec_from_file_location("skf_load_provenance", path)
167
- if spec is None or spec.loader is None:
168
- raise SnapshotError(f"cannot load skf-load-provenance.py beside {Path(__file__).name}")
169
- module = importlib.util.module_from_spec(spec)
207
+ def _sibling(filename: str, *needs: str):
208
+ """A script installed beside this one, loaded once. Any failure to load
209
+ it, or a script without each name `needs` lists, is a SnapshotError
210
+ (exit 2)."""
211
+ if filename not in _SIBLINGS:
212
+ path = Path(__file__).resolve().parent / filename
170
213
  try:
214
+ spec = importlib.util.spec_from_file_location(filename[:-3].replace("-", "_"), path)
215
+ if spec is None or spec.loader is None:
216
+ raise ImportError(f"no loader for {path}")
217
+ module = importlib.util.module_from_spec(spec)
171
218
  spec.loader.exec_module(module)
172
- except (OSError, ImportError, SyntaxError) as exc:
173
- raise SnapshotError(f"cannot load skf-load-provenance.py: {exc}") from exc
174
- _PROVENANCE = module
175
- return _PROVENANCE
219
+ except Exception as exc: # whatever the script raises, the build reports it
220
+ raise SnapshotError(f"cannot load {filename} beside {Path(__file__).name}: {exc}") from exc
221
+ missing = [need for need in needs if getattr(module, need, None) is None]
222
+ if missing:
223
+ raise SnapshotError(f"cannot load {filename} beside {Path(__file__).name}: "
224
+ f"it has no {', '.join(missing)}")
225
+ _SIBLINGS[filename] = module
226
+ return _SIBLINGS[filename]
227
+
228
+
229
+ def _provenance():
230
+ """skf-load-provenance.py, for the scan list and the libraries (the
231
+ verifier's `baseline_gaps` reads the re-export map itself)."""
232
+ return _sibling("skf-load-provenance.py", "bounded_scan_files", "source_library_by_file")
233
+
234
+
235
+ def _verifier():
236
+ """skf-verify-provenance-completeness.py, for its baseline gaps
237
+ (`baseline_gaps`, by its declaration rule `declared_line`)."""
238
+ return _sibling("skf-verify-provenance-completeness.py", "baseline_gaps", "declared_line")
239
+
240
+
241
+ def _public_api(scope_type: str | None) -> bool:
242
+ return isinstance(scope_type, str) and scope_type.strip().lower() == PUBLIC_API
243
+
244
+
245
+ def _public_surface(runner: dict, scope_type: str):
246
+ """(surface, problem) by skf-extraction-inventory.py's `public_surface` over the runner's JSON with the scope
247
+ type --scope-type gives (an empty one when no runner ran, so the rule names its own problem)."""
248
+ scope = runner.get("scope") if isinstance(runner.get("scope"), dict) else {}
249
+ extraction = {**runner, "scope": {**scope, "type": scope_type}}
250
+ return _sibling("skf-extraction-inventory.py", "public_surface").public_surface(extraction)
251
+
252
+
253
+ def _mark_public(exports: list[dict], runner: dict, scope_type: str | None) -> dict | None:
254
+ """Mark each export `public` by the public-surface rule for a public-api scope type; the snapshot's
255
+ `public_surface` (None for any other scope type)."""
256
+ if not _public_api(scope_type):
257
+ return None
258
+ surface, problem = _public_surface(runner, scope_type)
259
+ marked_not_public = 0
260
+ if surface is not None:
261
+ languages = {(e.get("export_name"), _posix(str(e.get("source_file") or ""))): e.get("language")
262
+ for e in runner.get("exports") or [] if isinstance(e, dict)}
263
+ for record in exports:
264
+ mark = surface.public(record["name"], record["file"], languages.get((record["name"], record["file"])))
265
+ if mark is not None:
266
+ record["public"] = mark
267
+ if mark is False:
268
+ marked_not_public += 1
269
+ return {"problem": problem, "marked_not_public": marked_not_public}
176
270
 
177
271
 
178
272
  def _posix(path: str) -> str:
@@ -278,20 +372,33 @@ def _outside_scope(runner: dict) -> list[dict]:
278
372
 
279
373
 
280
374
  def _extraction_gaps(runner: dict, exports: list[dict]) -> list[dict]:
281
- """The runner's extraction gaps no export of the snapshot holds yet."""
375
+ """The runner's extraction gaps no export of the snapshot holds yet, one
376
+ per name and file."""
282
377
  held = {(e["name"], e["file"]) for e in exports}
283
378
  names = {e["name"] for e in exports}
284
- gaps = []
379
+ gaps: dict[tuple, dict] = {}
285
380
  for item in _entry_point_diff(runner).get("extraction_gaps") or []:
286
381
  if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"]:
287
382
  continue
288
383
  file = _posix(item["file"]) if isinstance(item.get("file"), str) else None
289
384
  if (item["name"], file) in held or (file is None and item["name"] in names):
290
385
  continue
291
- gap = {"name": item["name"], "file": file, "line": item.get("line"), "entry": item.get("entry")}
292
- if gap not in gaps:
293
- gaps.append(gap)
294
- return gaps
386
+ gaps.setdefault((item["name"], file),
387
+ {"name": item["name"], "file": file, "line": item.get("line"), "entry": item.get("entry")})
388
+ return list(gaps.values())
389
+
390
+
391
+ def _baseline_gaps(provenance: dict, source_root: Path, statuses: dict[str, str], exports: list[dict],
392
+ listed: set[tuple]) -> tuple[list[dict], list[str]]:
393
+ """The provenance entries the snapshot lacks whose cited file still
394
+ declares them, and the entries no rule can check, by the verifier's
395
+ `baseline_gaps` (the selection and the declaration rule both live
396
+ there, beside `declared_line`, for update-skill's `baseline-gaps` too):
397
+ the entries of the files `extracted` or `read-by-eye`, against the
398
+ snapshot's exports, less the gaps `listed` already holds."""
399
+ files = {file for file, status in statuses.items() if status in BASELINE_GAP_STATUSES}
400
+ held = {(e["name"], e["file"]) for e in exports}
401
+ return _verifier().baseline_gaps(provenance, source_root, held, files, listed)
295
402
 
296
403
 
297
404
  def _ordered(exports) -> list[dict]:
@@ -306,7 +413,8 @@ def _counts(exports: list[dict]) -> dict:
306
413
 
307
414
 
308
415
  def build(source_root: Path, tier: str, date: str, provenance: dict | None, runner: dict | None,
309
- details_files: list[str], details_exports: list[dict], root_text: str | None = None) -> dict:
416
+ details_files: list[str], details_exports: list[dict], root_text: str | None = None,
417
+ scope_type: str | None = None) -> dict:
310
418
  """The snapshot (see the module docstring)."""
311
419
  warnings: list[str] = []
312
420
  runner = runner or {}
@@ -378,6 +486,7 @@ def build(source_root: Path, tier: str, date: str, provenance: dict | None, runn
378
486
  "fallback": ran and runner_said in TO_READ, "exports": per_file[rel]})
379
487
 
380
488
  ordered = _ordered(exports.values())
489
+ surface_state = _mark_public(ordered, runner, scope_type)
381
490
  for record in ordered:
382
491
  library = libraries.get(record["file"])
383
492
  if library is not None:
@@ -389,6 +498,17 @@ def build(source_root: Path, tier: str, date: str, provenance: dict | None, runn
389
498
  fallback_files = [f["file"] for f in files if f["fallback"]]
390
499
  else:
391
500
  fallback_files = [] if tier.strip().lower() == "quick" else [AST_GREP_UNAVAILABLE]
501
+ gaps = _extraction_gaps(runner, ordered) if ran else []
502
+ if provenance is not None:
503
+ baseline, unchecked = _baseline_gaps(provenance, source_root, {f["file"]: f["status"] for f in files},
504
+ ordered, {(g["name"], g["file"]) for g in gaps})
505
+ gaps += baseline
506
+ if unchecked:
507
+ warnings.append(f"{len(unchecked)} map entr{'y' if len(unchecked) == 1 else 'ies'} the snapshot "
508
+ f"lacks that no rule can check (in a file neither Python nor TS/JS, a file the "
509
+ f"declaration lookup does not read, or a dotted module name), so step 3 may report "
510
+ f"{'it' if len(unchecked) == 1 else 'them'} removed: "
511
+ + ", ".join(f"{u['name']} ({u['file']})" for u in unchecked))
392
512
  return {
393
513
  "extraction_date": date,
394
514
  "confidence_tier": tier,
@@ -402,12 +522,13 @@ def build(source_root: Path, tier: str, date: str, provenance: dict | None, runn
402
522
  "files": files,
403
523
  "files_by_status": {status: by_status[status] for status in STATUSES},
404
524
  "ast_fallback_files": fallback_files,
405
- "extraction_gaps": _extraction_gaps(runner, ordered) if ran else [],
525
+ "extraction_gaps": gaps,
406
526
  "truncated": bool(runner.get("truncated")) or bool(cap_hits),
407
527
  "cap_hits": cap_hits,
408
528
  "outside_scope": _outside_scope(runner) if ran else [],
409
529
  "counts": _counts(ordered),
410
530
  "exports": ordered,
531
+ "public_surface": surface_state,
411
532
  "relocations": [],
412
533
  "warnings": warnings,
413
534
  }
@@ -471,14 +592,15 @@ def _cmd_build(args: argparse.Namespace) -> dict:
471
592
  details_files += files
472
593
  details_exports += exports
473
594
  snapshot = build(root, args.tier, args.date, provenance, runner, details_files, details_exports,
474
- root_text=args.source_path or args.source_root)
595
+ root_text=args.source_path or args.source_root, scope_type=args.scope_type)
475
596
  _write(Path(args.output), snapshot)
476
597
  return {"status": "ok", "output": args.output, "runner_status": snapshot["runner_status"],
477
598
  "complete": snapshot["complete"],
478
599
  "to_read": [{"file": f["file"], "status": f["status"], "issue": f["issue"]}
479
600
  for f in snapshot["files"] if f["status"] in TO_READ],
480
601
  "extraction_gaps": snapshot["extraction_gaps"], "ast_fallback_files": snapshot["ast_fallback_files"],
481
- "files_by_status": snapshot["files_by_status"], "counts": snapshot["counts"]}
602
+ "files_by_status": snapshot["files_by_status"], "counts": snapshot["counts"],
603
+ "public_surface": snapshot["public_surface"]}
482
604
 
483
605
 
484
606
  def _cmd_relocate(args: argparse.Namespace) -> dict:
@@ -514,6 +636,9 @@ def _build_parser() -> argparse.ArgumentParser:
514
636
  snap.add_argument("--extraction", help="the JSON skf-extract-public-api.py --mode full wrote")
515
637
  snap.add_argument("--details", action="append",
516
638
  help="the exports read by eye: {\"files\": [...], \"exports\": [...]}; repeat per file")
639
+ snap.add_argument("--scope-type",
640
+ help="the skill's scope type: public-api marks each export `public` by the public-surface "
641
+ "rule; any other marks nothing")
517
642
  snap.add_argument("-o", "--output", required=True, help="the snapshot to write")
518
643
  snap.set_defaults(func=_cmd_build)
519
644
  move = sub.add_parser("relocate", help="add the exports the runner found under removed names elsewhere")
@@ -106,7 +106,9 @@ that cannot be taken, 3 when another call held the registry lock for the
106
106
  whole --lock-timeout; nothing was written).
107
107
 
108
108
  Cross-platform: pure stdlib + PyYAML. Atomic writes via temp + rename
109
- mirror skf-atomic-write.py's pattern.
109
+ mirror skf-atomic-write.py's pattern. On Windows, which refuses to rename
110
+ over a file another process has open, a refused rename is retried for up
111
+ to 5 seconds before the write fails.
110
112
 
111
113
  Registry lock: every subcommand that rewrites forge-tier.yaml
112
114
  (write-tools, register-qmd-collection, remove-qmd-collection,
@@ -148,6 +150,7 @@ import json
148
150
  import math
149
151
  import os
150
152
  import sys
153
+ import time
151
154
  from datetime import datetime, timezone
152
155
  from pathlib import Path
153
156
 
@@ -160,6 +163,16 @@ REGISTRY_LOCK_WAIT_SEC = 30.0
160
163
  # A call holds the registry lock for milliseconds; one this old was killed.
161
164
  REGISTRY_LOCK_STALE_SEC = 15.0
162
165
  EXIT_LOCK_BUSY = 3
166
+ # Windows refuses to replace a file another process has open, even one that
167
+ # only reads its attributes: a call about to take the lock checks the file
168
+ # exists, a `read` call opens it with no lock, and a virus scanner may open a
169
+ # just-written file for a moment. _atomic_write retries the rename there for
170
+ # up to this long before failing.
171
+ # The retry runs while the registry lock is held, so it stays well under
172
+ # REGISTRY_LOCK_STALE_SEC, after which a waiting call takes the lock as stale.
173
+ _IS_WINDOWS = os.name == "nt"
174
+ _REPLACE_WAIT_SECONDS = 5.0
175
+ _REPLACE_POLL_SECONDS = 0.02
163
176
  # The tool keys write-tools records, in forge-tier.yaml order.
164
177
  TOOL_KEYS = ("ast_grep", "gh_cli", "qmd", "ccc")
165
178
 
@@ -192,7 +205,9 @@ def _read_yaml(path: Path) -> dict | None:
192
205
 
193
206
 
194
207
  def _atomic_write(target: Path, content: str) -> None:
195
- """Crash-safe write via temp + fsync + rename. Mirrors skf-atomic-write.py."""
208
+ """Crash-safe write via temp + fsync + rename, as skf-atomic-write.py does.
209
+ On Windows, a rename refused while another process has the file open is
210
+ retried for up to _REPLACE_WAIT_SECONDS."""
196
211
  target.parent.mkdir(parents=True, exist_ok=True)
197
212
  tmp = target.with_name(target.name + ".skf-tmp")
198
213
  # O_BINARY (Windows only; 0 elsewhere) suppresses the text-mode \n -> \r\n
@@ -205,7 +220,15 @@ def _atomic_write(target: Path, content: str) -> None:
205
220
  os.fsync(fd)
206
221
  finally:
207
222
  os.close(fd)
208
- os.replace(tmp, target)
223
+ deadline = time.monotonic() + _REPLACE_WAIT_SECONDS
224
+ while True:
225
+ try:
226
+ os.replace(tmp, target)
227
+ break
228
+ except PermissionError:
229
+ if not _IS_WINDOWS or time.monotonic() >= deadline:
230
+ raise
231
+ time.sleep(_REPLACE_POLL_SECONDS)
209
232
  except OSError as e:
210
233
  if tmp.exists():
211
234
  try:
@@ -15,7 +15,7 @@ repository, and otherwise names the cause.
15
15
  CLI:
16
16
  uv run skf-github-probe.py repo --repo <repo> [--timeout <seconds>]
17
17
  uv run skf-github-probe.py tags --repo <repo> [--want <tag>]... \\
18
- [--version <version> [--name <name>]] [--limit <n>] \\
18
+ [--version <version> [--name <name>]] [--limit <n>] [--ref <ref>] \\
19
19
  [--timeout <seconds>]
20
20
  uv run skf-github-probe.py tree --repo <repo> [--ref <ref>] \\
21
21
  [--out <file>] [--timeout <seconds>]
@@ -71,9 +71,15 @@ Output (one ASCII JSON line; every key of the command is always present):
71
71
  that names --version, else null), found ({tag: commit} for the
72
72
  --want tags that exist), missing (the --want tags the listing
73
73
  lacks), nearest (with --version, up to five tags that sort next
74
- to it by version, newest first, else empty). When the listing
75
- could not be read, found, missing and nearest are empty: only a
76
- listing says a tag is absent.
74
+ to it by version, newest first, else empty), release (the tag a
75
+ version is read from, {"tag", "version"}, the version being the
76
+ tag's name less a leading `v`, else null: at --ref HEAD, the
77
+ default, the newest tag whose name is a release version, X.Y.Z
78
+ with no pre-release or build; at another --ref, that ref when the
79
+ listing holds it as a tag whose name is a version, a pre-release
80
+ included, so a branch or a commit names none). When the listing
81
+ could not be read, found, missing and nearest are empty and
82
+ release is null: only a listing says a tag is absent.
77
83
  A tag names --version in the forms skf-source-tree.py's
78
84
  match_tags tries, in its order: the version as given,
79
85
  `v<version>` and, with --name, `<name>@<v>`,
@@ -453,7 +459,7 @@ class Probe:
453
459
  # found and missing stay empty then, since no listing says a tag is absent.
454
460
  _EMPTY = {
455
461
  "repo": {"private": None, "default_branch": None},
456
- "tags": {"tags": [], "count": 0, "match": None, "found": {}, "missing": [], "nearest": []},
462
+ "tags": {"tags": [], "count": 0, "match": None, "found": {}, "missing": [], "nearest": [], "release": None},
457
463
  "tree": {"ref": "HEAD", "tree": [], "count": 0, "truncated": False},
458
464
  }
459
465
 
@@ -501,8 +507,38 @@ def _sorted_tags(names) -> list[str]:
501
507
  return [name for _key, name in versioned] + sorted(name for _key, name in other)
502
508
 
503
509
 
510
+ # A tag whose name is a version (`v1.2.3`, `1.2.3-rc.1`), and one whose name
511
+ # is a release version (X.Y.Z, no pre-release or build); a leading v is not
512
+ # part of the version.
513
+ _VERSION_TAG_RE = re.compile(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)")
514
+ _RELEASE_TAG_RE = re.compile(r"v?(\d+\.\d+\.\d+)")
515
+
516
+
517
+ def tag_version(name: str, release: bool = False) -> str | None:
518
+ """The version a tag's name is, less a leading `v` (`v1.2.3` gives
519
+ 1.2.3), else None; with `release`, a pre-release or a build is none."""
520
+ m = (_RELEASE_TAG_RE if release else _VERSION_TAG_RE).fullmatch(name)
521
+ return m.group(1) if m else None
522
+
523
+
524
+ def release_of(ordered: list[str], ref: str | None) -> dict | None:
525
+ """The tag a version is read from at `ref`, as {"tag", "version"}: at
526
+ HEAD the newest of `ordered` (newest version first) whose name is a
527
+ release version; at another ref, that ref when `ordered` holds it and
528
+ its name is a version; else None."""
529
+ ref = _tree_ref(ref)
530
+ if ref != "HEAD":
531
+ version = tag_version(ref) if ref in ordered else None
532
+ return {"tag": ref, "version": version} if version else None
533
+ for name in ordered:
534
+ version = tag_version(name, release=True)
535
+ if version:
536
+ return {"tag": name, "version": version}
537
+ return None
538
+
539
+
504
540
  def probe_tags(owner: str, repo: str, wants: list[str], limit: int, version: str | None = None,
505
- package: str | None = None) -> dict:
541
+ package: str | None = None, ref: str | None = None) -> dict:
506
542
  p = Probe(owner, repo)
507
543
  tags: dict[str, str | None] | None = None
508
544
  via = None
@@ -544,7 +580,8 @@ def probe_tags(owner: str, repo: str, wants: list[str], limit: int, version: str
544
580
  return p.output("ok", via, tags=ordered[:limit] if limit > 0 else ordered, count=len(ordered),
545
581
  match=next((tag for tag in (*wanted, *named) if tag in tags), None), found=found,
546
582
  missing=[tag for tag in wanted if tag not in tags],
547
- nearest=_sibling().nearest_tags(tags, version) if version else [])
583
+ nearest=_sibling().nearest_tags(tags, version) if version else [],
584
+ release=release_of(ordered, ref))
548
585
 
549
586
 
550
587
  def _tree_ref(value: str | None) -> str:
@@ -621,6 +658,8 @@ def _build_parser() -> argparse.ArgumentParser:
621
658
  p_tags.add_argument("--name", help="with --version: the package name a monorepo's tags carry")
622
659
  p_tags.add_argument("--limit", type=int, default=DEFAULT_TAG_LIMIT,
623
660
  help="tags to list, newest first (default 20; 0 for all)")
661
+ p_tags.add_argument("--ref", default="HEAD",
662
+ help="the ref `release` reads a version at (default HEAD: the newest release tag)")
624
663
  p_tree = sub.add_parser("tree", help="list the repository's files at a ref")
625
664
  common(p_tree)
626
665
  p_tree.add_argument("--ref", default="HEAD", help="branch, tag or commit (default: the default branch)")
@@ -644,7 +683,7 @@ def main(argv: list[str] | None = None) -> int:
644
683
  elif args.cmd == "repo":
645
684
  out = probe_repo(*parsed)
646
685
  elif args.cmd == "tags":
647
- out = probe_tags(*parsed, args.want, args.limit, args.version, args.name)
686
+ out = probe_tags(*parsed, args.want, args.limit, args.version, args.name, args.ref)
648
687
  else:
649
688
  out = probe_tree(*parsed, args.ref)
650
689
  if args.cmd == "tree" and args.out:
@@ -17,7 +17,8 @@ Two patterns recur across the update-skill workflow's stage prose:
17
17
  content hash, compare against stored hash" and classify entries as
18
18
  MODIFIED_FILE / DELETED_FILE / (UNCHANGED). Same hashing primitive,
19
19
  iterated, plus a stat-and-compare. NEW_FILE detection is intentionally
20
- out of scope here — that lives in `skf-detect-scripts-assets.py`.
20
+ out of scope here: that lives in `skf-detect-scripts-assets.py`,
21
+ piped into update-skill's `skf-new-file-diff.py`.
21
22
 
22
23
  3. **[MANUAL]-section integrity** — the update-skill workflow's headline
23
24
  Workflow Rule is "[MANUAL] sections survive regeneration with zero
@@ -49,7 +50,11 @@ Subcommands:
49
50
  "stats": {"total": N, "unchanged": U, "modified": M, "deleted": D}
50
51
  }
51
52
  The provenance file must contain `file_entries` as a top-level array
52
- OR be the array itself (handles both schemas).
53
+ OR be the array itself (handles both schemas). A map with no
54
+ `file_entries` field, or a null one, tracks no file (a skill with no
55
+ script, asset or promoted document, or a map written before the
56
+ field existed): it compares no row. A `file_entries` that is neither
57
+ null nor an array is an error.
53
58
 
54
59
  manual-inventory <skill-md>
55
60
  Extract every `<!-- [MANUAL:name] --> … <!-- [/MANUAL:name] -->` block
@@ -238,11 +243,14 @@ def load_file_entries(provenance_path: Path) -> list[dict]:
238
243
  """Extract the file_entries list from a provenance file.
239
244
 
240
245
  Accepts two shapes:
241
- - top-level object with a `file_entries` key (canonical)
246
+ - top-level object with a `file_entries` key (canonical); with no
247
+ such key, or a null one, the map tracks no file and the list is
248
+ empty
242
249
  - top-level array of entries (already-extracted)
243
250
 
244
- Returns a copy of the list. Raises ValueError on malformed JSON or
245
- missing key.
251
+ Returns a copy of the list. Raises ValueError on malformed JSON, a
252
+ `file_entries` that is not an array, or a top level that is neither
253
+ an object nor an array.
246
254
  """
247
255
  try:
248
256
  data = json.loads(provenance_path.read_text(encoding="utf-8"))
@@ -254,9 +262,7 @@ def load_file_entries(provenance_path: Path) -> list[dict]:
254
262
  if isinstance(data, dict):
255
263
  entries = data.get("file_entries")
256
264
  if entries is None:
257
- raise ValueError(
258
- f"provenance file {provenance_path} has no `file_entries` field"
259
- )
265
+ return [] # no tracked file: a map without (or with a null) file_entries
260
266
  if not isinstance(entries, list):
261
267
  raise ValueError(
262
268
  f"`file_entries` in {provenance_path} is not an array"
@@ -23,7 +23,9 @@ skf-structural-diff.py derives the re-export map from the provenance map
23
23
  itself and applies the quote-style, stdlib-prefix and re-export transforms
24
24
  on both sides (structural-diff.md §1), so no step carries `reexport_map`
25
25
  from here; the field stays for a caller that passes it to the diff as
26
- `--reexport-map`.
26
+ `--reexport-map`. The diff also takes a semantic kind a by-eye read
27
+ records as the runner's base kind for the same declaration (its
28
+ export-type comparison).
27
29
 
28
30
  The transforms here are pure projections — no I/O against the source tree,
29
31
  no comparisons. The script reads one JSON, emits one JSON.