bmad-module-skill-forge 3.0.0 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/examples.md +1 -1
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +7 -3
- package/docs/workflows.md +3 -3
- package/package.json +1 -1
- package/src/knowledge/qmd-registry.md +1 -1
- package/src/knowledge/version-paths.md +1 -1
- package/src/shared/health-check.md +2 -50
- package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
- package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +2 -2
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +593 -47
- package/src/shared/scripts/skf-classify-changed-files.py +78 -0
- package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
- package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
- package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
- package/src/shared/scripts/skf-extract-public-api.py +1309 -157
- package/src/shared/scripts/skf-extraction-inventory.py +580 -46
- package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
- package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
- package/src/shared/scripts/skf-github-probe.py +47 -8
- package/src/shared/scripts/skf-hash-content.py +14 -8
- package/src/shared/scripts/skf-load-provenance.py +3 -1
- package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
- package/src/shared/scripts/skf-preflight.py +15 -1
- package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-structural-diff.py +133 -18
- package/src/shared/scripts/skf-update-active-symlink.py +115 -62
- package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
- package/src/shared/scripts/skf-verify-provenance-completeness.py +977 -49
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
- package/src/skf-audit-skill/references/headless-contract.md +1 -1
- package/src/skf-audit-skill/references/health-check.md +2 -0
- package/src/skf-audit-skill/references/init.md +2 -1
- package/src/skf-audit-skill/references/re-index.md +14 -9
- package/src/skf-audit-skill/references/report.md +2 -2
- package/src/skf-audit-skill/references/semantic-diff.md +15 -15
- package/src/skf-audit-skill/references/structural-diff.md +10 -5
- package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
- package/src/skf-brief-skill/SKILL.md +1 -1
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -9
- package/src/skf-brief-skill/references/analyze-target.md +6 -6
- package/src/skf-brief-skill/references/confirm-brief.md +1 -1
- package/src/skf-brief-skill/references/scope-definition.md +4 -4
- package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
- package/src/skf-brief-skill/references/version-resolution.md +5 -9
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +6 -6
- package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
- package/src/skf-create-skill/references/compile.md +31 -60
- package/src/skf-create-skill/references/component-extraction.md +3 -3
- package/src/skf-create-skill/references/enrich.md +2 -2
- package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
- package/src/skf-create-skill/references/extract.md +22 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +8 -9
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +16 -5
- package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
- package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
- package/src/skf-drop-skill/references/execute.md +5 -1
- package/src/skf-drop-skill/references/health-check.md +2 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +2 -2
- package/src/skf-drop-skill/references/select.md +7 -3
- package/src/skf-test-skill/references/coherence-check.md +1 -1
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +39 -57
- package/src/skf-test-skill/references/init.md +3 -5
- package/src/skf-test-skill/references/report.md +10 -8
- package/src/skf-test-skill/references/scoring-rules.md +6 -5
- package/src/skf-test-skill/references/source-access-protocol.md +14 -17
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +315 -30
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +534 -96
- package/src/skf-test-skill/scripts/reconcile-coverage.py +276 -2
- package/src/skf-test-skill/scripts/score-signatures.py +36 -16
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +3 -3
- package/src/skf-update-skill/references/init.md +6 -2
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +2 -2
- package/src/skf-update-skill/references/re-extract.md +2 -2
- package/src/skf-update-skill/references/report.md +3 -1
- package/src/skf-update-skill/references/write.md +43 -23
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
|
@@ -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>]... -
|
|
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
|
|
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 an underscore name such as
|
|
134
|
+
`__version__`, and a module), which would otherwise read as removed. A
|
|
135
|
+
baseline gap gives the line that declares the name, `entry` null and,
|
|
136
|
+
when the entry has one, its `export_type`. It is checked only for an
|
|
137
|
+
entry whose file is `extracted` or `read-by-eye`, whose name is not
|
|
138
|
+
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
|
|
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,
|
|
134
|
-
|
|
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
|
-
|
|
204
|
+
_SIBLINGS: dict[str, object] = {}
|
|
159
205
|
|
|
160
206
|
|
|
161
|
-
def
|
|
162
|
-
"""
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
173
|
-
raise SnapshotError(f"cannot load
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
|
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":
|
|
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
|
|
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
|
-
|
|
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)
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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
|
|
245
|
-
|
|
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
|
-
|
|
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.
|