bmad-module-skill-forge 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (141) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +3 -3
  3. package/docs/_data/pinned.yaml +19 -13
  4. package/docs/architecture.md +2 -2
  5. package/docs/concepts.md +4 -4
  6. package/docs/examples.md +33 -26
  7. package/docs/getting-started.md +3 -3
  8. package/docs/how-it-works.md +2 -2
  9. package/docs/index.md +1 -1
  10. package/docs/skill-model.md +41 -39
  11. package/docs/troubleshooting.md +8 -0
  12. package/docs/verifying-a-skill.md +46 -21
  13. package/docs/why-skf.md +1 -1
  14. package/docs/workflows.md +5 -5
  15. package/package.json +1 -1
  16. package/src/knowledge/qmd-registry.md +1 -1
  17. package/src/knowledge/version-paths.md +1 -1
  18. package/src/shared/health-check.md +2 -50
  19. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +2 -2
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +12 -4
  21. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  22. package/src/shared/scripts/skf-atomic-write.py +212 -59
  23. package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
  24. package/src/shared/scripts/skf-classify-changed-files.py +78 -0
  25. package/src/shared/scripts/skf-compare-file-hashes.py +266 -104
  26. package/src/shared/scripts/skf-count-tokens.py +54 -10
  27. package/src/shared/scripts/skf-derive-assembly-shape.py +18 -1
  28. package/src/shared/scripts/skf-detect-scripts-assets.py +196 -54
  29. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  30. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  31. package/src/shared/scripts/skf-extract-public-api.py +1673 -189
  32. package/src/shared/scripts/skf-extraction-inventory.py +584 -46
  33. package/src/shared/scripts/skf-extraction-snapshot.py +158 -33
  34. package/src/shared/scripts/skf-forge-tier-rw.py +26 -3
  35. package/src/shared/scripts/skf-github-probe.py +47 -8
  36. package/src/shared/scripts/skf-hash-content.py +14 -8
  37. package/src/shared/scripts/skf-load-provenance.py +3 -1
  38. package/src/shared/scripts/skf-merge-doc-urls.py +142 -19
  39. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  40. package/src/shared/scripts/skf-preflight.py +15 -1
  41. package/src/shared/scripts/skf-provenance-gap-dispatch.py +25 -1
  42. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  43. package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
  44. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  45. package/src/shared/scripts/skf-shard-body.py +239 -41
  46. package/src/shared/scripts/skf-structural-diff.py +133 -18
  47. package/src/shared/scripts/skf-update-active-symlink.py +115 -62
  48. package/src/shared/scripts/skf-validate-brief-schema.py +19 -1
  49. package/src/shared/scripts/skf-validate-output.py +12 -3
  50. package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
  51. package/src/shared/scripts/skf-write-skill-brief.py +41 -8
  52. package/src/shared/tool-requirements.yaml +8 -2
  53. package/src/skf-analyze-source/SKILL.md +2 -2
  54. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  55. package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
  56. package/src/skf-audit-skill/SKILL.md +2 -2
  57. package/src/skf-audit-skill/assets/drift-report-template.md +1 -0
  58. package/src/skf-audit-skill/references/headless-contract.md +1 -1
  59. package/src/skf-audit-skill/references/health-check.md +2 -0
  60. package/src/skf-audit-skill/references/init.md +2 -1
  61. package/src/skf-audit-skill/references/re-index.md +14 -9
  62. package/src/skf-audit-skill/references/report.md +2 -2
  63. package/src/skf-audit-skill/references/semantic-diff.md +15 -15
  64. package/src/skf-audit-skill/references/structural-diff.md +10 -5
  65. package/src/skf-audit-skill/scripts/render-drift-tables.py +29 -2
  66. package/src/skf-brief-skill/SKILL.md +3 -3
  67. package/src/skf-brief-skill/assets/scope-templates.md +1 -1
  68. package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
  69. package/src/skf-brief-skill/references/analyze-target.md +6 -6
  70. package/src/skf-brief-skill/references/confirm-brief.md +1 -1
  71. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  72. package/src/skf-brief-skill/references/step-auto-brief.md +11 -29
  73. package/src/skf-brief-skill/references/version-resolution.md +5 -9
  74. package/src/skf-campaign/SKILL.md +2 -2
  75. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  76. package/src/skf-create-skill/SKILL.md +2 -2
  77. package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
  78. package/src/skf-create-skill/assets/skill-sections.md +7 -7
  79. package/src/skf-create-skill/references/authoritative-files-protocol.md +2 -23
  80. package/src/skf-create-skill/references/compile.md +31 -60
  81. package/src/skf-create-skill/references/component-extraction.md +3 -3
  82. package/src/skf-create-skill/references/enrich.md +2 -2
  83. package/src/skf-create-skill/references/entry-points-by-hand.md +24 -7
  84. package/src/skf-create-skill/references/extract.md +23 -19
  85. package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
  86. package/src/skf-create-skill/references/extraction-patterns.md +10 -11
  87. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  88. package/src/skf-create-skill/references/report.md +21 -5
  89. package/src/skf-create-skill/references/source-resolution-protocols.md +12 -17
  90. package/src/skf-create-skill/references/sub/fetch-docs.md +1 -1
  91. package/src/skf-create-stack-skill/SKILL.md +2 -2
  92. package/src/skf-create-stack-skill/references/validate.md +1 -1
  93. package/src/skf-drop-skill/SKILL.md +2 -2
  94. package/src/skf-drop-skill/references/execute.md +5 -1
  95. package/src/skf-drop-skill/references/health-check.md +2 -0
  96. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  97. package/src/skf-drop-skill/references/report.md +2 -2
  98. package/src/skf-drop-skill/references/select.md +7 -3
  99. package/src/skf-export-skill/SKILL.md +2 -2
  100. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  101. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  102. package/src/skf-export-skill/references/load-skill.md +1 -1
  103. package/src/skf-export-skill/references/token-report.md +29 -9
  104. package/src/skf-forger/SKILL.md +2 -2
  105. package/src/skf-quick-skill/SKILL.md +2 -2
  106. package/src/skf-refine-architecture/SKILL.md +6 -8
  107. package/src/skf-rename-skill/SKILL.md +2 -2
  108. package/src/skf-setup/SKILL.md +2 -2
  109. package/src/skf-test-skill/SKILL.md +2 -2
  110. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  111. package/src/skf-test-skill/references/coherence-check.md +5 -4
  112. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  113. package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
  114. package/src/skf-test-skill/references/coverage-check.md +69 -111
  115. package/src/skf-test-skill/references/init.md +5 -6
  116. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  117. package/src/skf-test-skill/references/report.md +11 -9
  118. package/src/skf-test-skill/references/scoring-rules.md +8 -6
  119. package/src/skf-test-skill/references/source-access-protocol.md +15 -18
  120. package/src/skf-test-skill/scripts/build-result-context.py +114 -21
  121. package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
  122. package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
  123. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  124. package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
  125. package/src/skf-test-skill/scripts/score-signatures.py +356 -61
  126. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  127. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  128. package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
  129. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  130. package/src/skf-update-skill/SKILL.md +2 -2
  131. package/src/skf-update-skill/references/detect-changes.md +32 -13
  132. package/src/skf-update-skill/references/gap-driven.md +37 -22
  133. package/src/skf-update-skill/references/init.md +6 -2
  134. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  135. package/src/skf-update-skill/references/merge.md +17 -12
  136. package/src/skf-update-skill/references/re-extract.md +4 -2
  137. package/src/skf-update-skill/references/report.md +7 -4
  138. package/src/skf-update-skill/references/write.md +77 -36
  139. package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
  140. package/src/skf-verify-stack/SKILL.md +2 -2
  141. package/tools/cli/lib/tool-check.js +158 -2
@@ -1,18 +1,25 @@
1
1
  # /// script
2
2
  # requires-python = ">=3.11"
3
- # dependencies = []
3
+ # dependencies = ["pyyaml"]
4
4
  # ///
5
5
  """SKF Merge Doc URLs — merge corpora-seeded + detected docs for a skill brief.
6
6
 
7
7
  Deterministic merge of an upstream brief's `doc_urls` (registry-seeded
8
8
  companion corpora, source: language-registry) with the docs freshly
9
9
  discovered by `skf-detect-docs.py` (mapped to the brief `{url, label, source}`
10
- contract). Replaces the prose dedup/suppression that lived inline in
11
- `skf-brief-skill/references/step-auto-brief.md` §3 — that logic is fiddly
10
+ contract). Replaces the prose mapping, dedup and suppression that lived inline
11
+ in `skf-brief-skill/references/step-auto-brief.md` §3. That logic is fiddly
12
12
  (normalized-URL collisions, non-corpus path segments, locale duplicates) and
13
13
  must behave identically on every run, so it belongs in a tested script.
14
14
 
15
- Three operations, in order:
15
+ With --detected-file, each skf-detect-docs.py entry {url, detected_via,
16
+ content_type} is first mapped to {url, label, source}: `source` from
17
+ `detected_via` and `label` from `content_type`, by the two tables below (a
18
+ content_type they do not list gives the label ""). An entry whose
19
+ `detected_via` they do not list is never merged: `suppressed` lists it with
20
+ reason unknown-detected-via.
21
+
22
+ Three merge operations, in order:
16
23
 
17
24
  1. Normalized-URL dedup — lowercase host, strip a trailing `/index.html`
18
25
  and any trailing `/` before comparing, so a seeded `…/book/` and a
@@ -41,10 +48,18 @@ DEGRADED case is intentionally out of scope (there is no canonical corpus host
41
48
  to anchor suppression against).
42
49
 
43
50
  CLI:
51
+ uv run src/shared/scripts/skf-merge-doc-urls.py --scope-type <type> \
52
+ --detected-file <detect-docs.json> --existing-brief <skill-brief.yaml>
44
53
  echo '{"scope_type": "...", "existing": [...], "detected": [...]}' \
45
54
  | uv run src/shared/scripts/skf-merge-doc-urls.py
46
55
 
47
- Input (JSON object on stdin):
56
+ Files form (stdin is not read): --detected-file holds skf-detect-docs.py's
57
+ JSON array, mapped as above; --existing-brief is a skill-brief.yaml whose
58
+ `doc_urls` are the existing entries (none when it has no doc_urls), read as
59
+ skf-resolve-authoritative-files.py reads a brief; --scope-type is the scope
60
+ type. Each flag is optional: an absent one gives no entries (or "").
61
+
62
+ Input of the stdin form (a JSON object), whose entries are merged as given:
48
63
  {
49
64
  "scope_type": "full-library",
50
65
  "existing": [{"url": "...", "label": "...", "source": "language-registry"}, ...],
@@ -54,22 +69,42 @@ Input (JSON object on stdin):
54
69
  Output (JSON object on stdout):
55
70
  {
56
71
  "doc_urls": [{"url": "...", "label": "...", "source": "..."}, ...],
57
- "suppressed": [{"url": "...", "reason": "non-corpus-segment|non-primary-locale-dup"}, ...]
72
+ "suppressed": [{"url": "...", "reason":
73
+ "unknown-detected-via|non-corpus-segment|non-primary-locale-dup"}, ...]
58
74
  }
59
75
 
60
76
  Exit codes:
61
77
  0 success (merged list emitted)
62
- 2 error (bad JSON, wrong input shape)
78
+ 2 error (bad JSON, wrong input shape, a file that cannot be read, a brief
79
+ that is not a YAML mapping)
63
80
  """
64
81
 
65
82
  from __future__ import annotations
66
83
 
84
+ import argparse
85
+ import importlib.util
67
86
  import json
68
87
  import re
69
88
  import sys
89
+ from pathlib import Path
70
90
  from typing import Any
71
91
  from urllib.parse import urlsplit
72
92
 
93
+ # skf-detect-docs.py's `detected_via` -> the brief's coarse provenance, the
94
+ # skill-brief.v1.json doc_urls[].source enum (a test pins it to the schema).
95
+ _SOURCE_BY_DETECTED_VIA = {
96
+ "homepageUrl": "homepage",
97
+ "readme_link": "readme-detection",
98
+ "pages_api": "pages-api",
99
+ "docs_folder": "docs-folder",
100
+ }
101
+ # skf-detect-docs.py's `content_type` -> the entry's display label.
102
+ _LABEL_BY_CONTENT_TYPE = {
103
+ "api-docs": "API Documentation",
104
+ "guide": "Guide",
105
+ "reference": "Reference",
106
+ }
107
+
73
108
  # Path components (case-insensitive) that mark a non-corpus page on a doc host:
74
109
  # changelogs/news, contribution guides, wikis. Matched as whole path components
75
110
  # (or component + a separator), never as a bare substring.
@@ -153,6 +188,28 @@ def _matches_non_corpus_segment(path: str) -> bool:
153
188
  return False
154
189
 
155
190
 
191
+ def map_detected(raw: list[Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
192
+ """skf-detect-docs.py entries as brief doc_urls entries {url, label, source}.
193
+
194
+ Returns (mapped, suppressed): an entry whose detected_via maps to no
195
+ source is suppressed with reason unknown-detected-via; one with no url is
196
+ skipped, as the merge skips it.
197
+ """
198
+ mapped: list[dict[str, Any]] = []
199
+ suppressed: list[dict[str, Any]] = []
200
+ for d in raw:
201
+ if not isinstance(d, dict) or not d.get("url"):
202
+ continue
203
+ via, content_type = d.get("detected_via"), d.get("content_type")
204
+ source = _SOURCE_BY_DETECTED_VIA.get(via) if isinstance(via, str) else None
205
+ if source is None:
206
+ suppressed.append({"url": d["url"], "reason": "unknown-detected-via"})
207
+ continue
208
+ label = _LABEL_BY_CONTENT_TYPE.get(content_type, "") if isinstance(content_type, str) else ""
209
+ mapped.append({"url": d["url"], "label": label, "source": source})
210
+ return mapped, suppressed
211
+
212
+
156
213
  def merge_doc_urls(
157
214
  scope_type: str,
158
215
  existing: list[dict[str, Any]],
@@ -232,30 +289,96 @@ def _force_utf8(*streams) -> None:
232
289
  stream.reconfigure(encoding="utf-8", errors=errors)
233
290
 
234
291
 
235
- def main() -> int:
236
- _force_utf8(sys.stdin, sys.stdout)
292
+ class InputError(Exception):
293
+ """An input the merge cannot read (exit 2)."""
294
+
295
+
296
+ def _read_detected(path: str) -> list[Any]:
297
+ """The JSON array skf-detect-docs.py wrote to `path`."""
298
+ try:
299
+ data = json.loads(Path(path).read_bytes().decode("utf-8-sig"))
300
+ except OSError as exc:
301
+ raise InputError(f"cannot read --detected-file {path}: {exc.strerror or exc}") from exc
302
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
303
+ raise InputError(f"--detected-file {path} is not JSON: {exc}") from exc
304
+ if not isinstance(data, list):
305
+ raise InputError(f"--detected-file {path} must hold a JSON array (skf-detect-docs.py output)")
306
+ return data
307
+
308
+
309
+ def _brief_doc_urls(path: str) -> list[Any]:
310
+ """The doc_urls of the skill brief at `path`, read by
311
+ skf-resolve-authoritative-files.py's parse_brief_yaml (beside this file)."""
312
+ resolver_path = Path(__file__).resolve().parent / "skf-resolve-authoritative-files.py"
313
+ spec = importlib.util.spec_from_file_location("skf_resolve_authoritative_files", resolver_path)
314
+ if spec is None or spec.loader is None:
315
+ raise InputError(f"cannot load {resolver_path.name} beside {Path(__file__).name}")
316
+ resolver = importlib.util.module_from_spec(spec)
317
+ try:
318
+ spec.loader.exec_module(resolver)
319
+ except (OSError, ImportError, SyntaxError) as exc:
320
+ raise InputError(f"cannot load {resolver_path.name} beside {Path(__file__).name}: {exc}") from exc
321
+ try:
322
+ brief = resolver.parse_brief_yaml(Path(path).read_bytes().decode("utf-8-sig"))
323
+ except OSError as exc:
324
+ raise InputError(f"cannot read --existing-brief {path}: {exc.strerror or exc}") from exc
325
+ except (UnicodeDecodeError, resolver.yaml.YAMLError) as exc:
326
+ raise InputError(f"--existing-brief {path} is not YAML: {exc}") from exc
327
+ if not isinstance(brief, dict):
328
+ raise InputError(f"--existing-brief {path} holds no brief mapping")
329
+ doc_urls = brief.get("doc_urls") or []
330
+ if not isinstance(doc_urls, list):
331
+ raise InputError(f"the doc_urls of --existing-brief {path} must be a list")
332
+ return doc_urls
333
+
334
+
335
+ def _stdin_payload() -> tuple[str, list[Any], list[Any]]:
336
+ """(scope_type, existing, detected) of the stdin form."""
237
337
  raw = sys.stdin.read()
238
338
  if not raw or not raw.strip():
239
- sys.stderr.write("skf-merge-doc-urls: empty stdin (expected JSON object)\n")
240
- return 2
339
+ raise InputError("empty stdin (expected JSON object)")
241
340
  try:
242
341
  payload = json.loads(raw)
243
342
  except json.JSONDecodeError as exc:
244
- sys.stderr.write(f"skf-merge-doc-urls: invalid JSON on stdin: {exc}\n")
245
- return 2
343
+ raise InputError(f"invalid JSON on stdin: {exc}") from exc
246
344
  if not isinstance(payload, dict):
247
- sys.stderr.write("skf-merge-doc-urls: input must be a JSON object\n")
248
- return 2
249
-
250
- scope_type = payload.get("scope_type") or ""
345
+ raise InputError("input must be a JSON object")
251
346
  existing = payload.get("existing") or []
252
347
  detected = payload.get("detected") or []
253
348
  if not isinstance(existing, list) or not isinstance(detected, list):
254
- sys.stderr.write("skf-merge-doc-urls: 'existing' and 'detected' must be arrays\n")
349
+ raise InputError("'existing' and 'detected' must be arrays")
350
+ return payload.get("scope_type") or "", existing, detected
351
+
352
+
353
+ def main(argv: list[str] | None = None) -> int:
354
+ _force_utf8(sys.stdin, sys.stdout)
355
+ parser = argparse.ArgumentParser(
356
+ prog="skf-merge-doc-urls",
357
+ description="Merge a brief's doc_urls with the docs skf-detect-docs.py found. With no flag, read the "
358
+ "{scope_type, existing, detected} JSON object on stdin.",
359
+ )
360
+ parser.add_argument("--detected-file", metavar="PATH",
361
+ help="skf-detect-docs.py's JSON array, mapped to {url, label, source}")
362
+ parser.add_argument("--existing-brief", metavar="PATH",
363
+ help="the skill-brief.yaml whose doc_urls are the existing entries")
364
+ parser.add_argument("--scope-type", metavar="TYPE", help="the brief's scope.type")
365
+ args = parser.parse_args(argv)
366
+ unknown: list[dict[str, Any]] = []
367
+ try:
368
+ if args.detected_file is None and args.existing_brief is None and args.scope_type is None:
369
+ scope_type, existing, detected = _stdin_payload()
370
+ else:
371
+ scope_type = args.scope_type or ""
372
+ existing = _brief_doc_urls(args.existing_brief) if args.existing_brief is not None else []
373
+ detected = []
374
+ if args.detected_file is not None:
375
+ detected, unknown = map_detected(_read_detected(args.detected_file))
376
+ except InputError as exc:
377
+ sys.stderr.write(f"skf-merge-doc-urls: {exc}\n")
255
378
  return 2
256
379
 
257
380
  doc_urls, suppressed = merge_doc_urls(scope_type, existing, detected)
258
- json.dump({"doc_urls": doc_urls, "suppressed": suppressed}, sys.stdout, ensure_ascii=False)
381
+ json.dump({"doc_urls": doc_urls, "suppressed": unknown + suppressed}, sys.stdout, ensure_ascii=False)
259
382
  sys.stdout.write("\n")
260
383
  return 0
261
384
 
@@ -91,10 +91,13 @@ provenance map): for a gap with an `export` (null for one with none),
91
91
  "candidates": [{"source_file", "source_line", "export_type"}, ...]}
92
92
 
93
93
  `candidates` holds every entries[] row whose export_name is the export, in
94
- the map's order. With a `source_citation`, only the rows whose source_file
95
- is the citation's file count (both compared stripped, with backslashes read
96
- as `/` and no leading `./`), and of several such rows the one whose
97
- source_line is the citation's line. Without one, every candidate counts.
94
+ the map's order; when no row has that export_name, every row whose
95
+ `reexported_as` is the export (one the map holds under its declared name,
96
+ which a gap names by the public name a re-export gives it), each view then
97
+ adding the row's own `export_name`. With a `source_citation`, only the
98
+ rows whose source_file is the citation's file count (both compared
99
+ stripped, with backslashes read as `/` and no leading `./`), and of
100
+ several such rows the one whose source_line is the citation's line. Without one, every candidate counts.
98
101
  One row left is `found` (its `entry`), several are `ambiguous` (a spot-check
99
102
  of the wrong one would move another export's line) and none is `not-found`.
100
103
  `match` prints {"status": "ok", "name", "citation", "map_match"} for a name
@@ -142,16 +145,34 @@ Translate (update-skill gap-driven.md §1): `translate` turns the gaps
142
145
  `parse` wrote (--gaps) into the change manifest, written to -o as
143
146
  {"mode": "gap-driven", "entries": [...]}. Each gap is routed by its
144
147
  `category` through CHANGE_CATEGORIES (gap-driven.md §1's table), never by
145
- its severity; a category the table does not hold is not routed. Each
146
- entry, in this key order:
148
+ its severity; a category the table does not hold is not routed, and takes
149
+ no entry, so nothing is written for it. A `stale-documentation` gap is not
150
+ routed: the source has no line to repair the name from, and removing its
151
+ documentation unchecked could delete a name the surface missed or one the
152
+ source still declares. Nor is a `metadata-drift` gap: write.md §2's
153
+ `metadata` call recounts the counts a patch would set (except under the
154
+ drift override and for a docs-only skill, where it passes the counts
155
+ metadata.json records), so no metadata patch holds. Each entry, in this
156
+ key order:
147
157
 
148
158
  name the gap's `export`, else the answered `name`
149
159
  gap_id, category the gap's `id`, its `category` (else the
150
160
  answered one), `severity`
151
161
  severity
152
162
  source_citation the gap's, left out when it is null or names a
153
- line inside the skill package (`SKILL.md` or a
154
- `references/*.md` file, under any folder)
163
+ line inside the skill package: `SKILL.md` or a
164
+ `.md` file under `references/`, written relative
165
+ to the package, after `{skill_package}/`, or
166
+ after the package folder of SKF's layout
167
+ (`skills/{name}/{version}/{name}/` or
168
+ `skills/{name}/`, after any leading folders)
169
+ package_citation a `split-body-mismatch` gap's in-package
170
+ citation, {"file", "line"}, its file relative to
171
+ the package (`{skill_package}/` dropped): the
172
+ file merge Priority 8 edits. Left out for any
173
+ other gap, and for one whose category is a
174
+ judgment's answer: an older report's Source may
175
+ name the body it kept
155
176
  remediation_paths, resolved_paths, rejected_paths
156
177
  the gap's ([] when parse had no --source-root);
157
178
  a `provenance-completeness` gap with no
@@ -193,14 +214,20 @@ the gap's own text to judge from, each need one of:
193
214
  `provenance-completeness` gap with no path to scan
194
215
 
195
216
  --judgments answers them: a JSON object keyed by gap id, each value an
196
- object of `category` (a slug, or null: not routed), `name` (a string, or
217
+ object of `category` (a slug, one the table does not route giving that
218
+ category's reason, or null: not routed), `name` (a string, or
197
219
  null: not routed), `rescope` (true or false; given for any `missing-export`
198
220
  or `missing-type` gap, it is honoured) and `source_file` (a path relative
199
221
  to the source root, or null). Other keys are left for gap-records. With
200
222
  every need answered it writes the manifest and prints {"status":
201
223
  "written", "output", "gap_count", "not_routed": [{"id", "title",
202
- "category"}], "warnings": [...]}. `warnings` holds parse's warnings as
203
- `test-report: <warning>`, `test-report: not routed: <id> (<category>)` for
224
+ "category", "reason"}], "warnings": [...]}. A not-routed gap's `reason`
225
+ says why update-skill does not repair it and who does: NOT_ROUTED_REASONS
226
+ gives one for each ledger category the table does not route (gap-driven.md
227
+ §1's Not routed list), NO_REPAIR is the reason of a gap with no
228
+ category or one no table holds, and NO_EXPORT that of a gap whose export
229
+ is answered null. `warnings` holds parse's warnings as `test-report:
230
+ <warning>`, `test-report: not routed: <id> (<category>): <reason>` for
204
231
  each gap not routed, and, with --read-only, `proposed-amendment: excluded
205
232
  <path> (scope-expansion); not written: --<mode>` for each rescope.
206
233
 
@@ -865,10 +892,16 @@ def _line_number(value: object) -> int | None:
865
892
  def map_match(entries: list, name: str, citation: dict | None) -> dict:
866
893
  """The provenance map entry an export names, keyed on its citation (see "Map match" in the module docstring)."""
867
894
  def view(entry: dict) -> dict:
868
- return {key: entry.get(key) for key in ("source_file", "source_line", "export_type")}
895
+ out = {key: entry.get(key) for key in ("source_file", "source_line", "export_type")}
896
+ if entry["export_name"].strip() != name.strip(): # matched by its reexported_as: the name to check
897
+ out["export_name"] = entry["export_name"]
898
+ return out
869
899
 
870
- named = [e for e in entries if isinstance(e, dict) and isinstance(e.get("export_name"), str)
871
- and e["export_name"].strip() == name.strip()]
900
+ def named_by(key: str) -> list[dict]:
901
+ return [e for e in entries if isinstance(e, dict) and isinstance(e.get("export_name"), str)
902
+ and isinstance(e.get(key), str) and e[key].strip() == name.strip()]
903
+
904
+ named = named_by("export_name") or named_by("reexported_as")
872
905
  left = named
873
906
  if citation:
874
907
  cited = _norm_file(citation.get("file"))
@@ -959,7 +992,6 @@ CHANGE_CATEGORIES = {
959
992
  "provenance-completeness": "NEW_EXPORT",
960
993
  "signature-mismatch": "MODIFIED_EXPORT",
961
994
  "fabricated-signature": "MODIFIED_EXPORT",
962
- "stale-documentation": "MODIFIED_EXPORT",
963
995
  "provenance-line": "MOVED_EXPORT",
964
996
  "split-body-mismatch": "STRUCTURAL_FIX",
965
997
  "structural": "STRUCTURAL_FIX",
@@ -969,9 +1001,50 @@ CHANGE_CATEGORIES = {
969
1001
  "integration-pattern": "STRUCTURAL_FIX",
970
1002
  "migration-section": "STRUCTURAL_FIX",
971
1003
  "scripts-assets": "STRUCTURAL_FIX",
972
- "metadata-drift": "metadata update",
973
1004
  "metadata": "metadata update",
974
1005
  }
1006
+ # gap-driven.md §1's Not routed list, word for word: why update-skill repairs no gap of each other ledger category,
1007
+ # and who does. A gap takes NO_REPAIR when it has no category or one no table holds, and NO_EXPORT when its export
1008
+ # is answered null.
1009
+ NOT_ROUTED_REASONS = {
1010
+ "stale-documentation": (
1011
+ "the source has no line to repair the name from, so a person removes its documentation when the name is "
1012
+ "gone from the source, fixes the brief's scope when the surface missed it, or, when the source still "
1013
+ "declares the name, keeps the documentation and re-runs Test Skill at a tier that classifies it"),
1014
+ "metadata-drift": (
1015
+ "update-skill recounts `stats.exports_public_api`, `stats.exports_documented` and "
1016
+ "`stats.effective_denominator` each time it writes metadata.json, so a metadata patch to them does not hold "
1017
+ "(under `--allow-workspace-drift` and for a docs-only skill it keeps the recorded public API counts and "
1018
+ "`stats.effective_denominator` instead); Create Skill rebuilds diverging barrel counts from the source and "
1019
+ "recounts a `stats.effective_denominator` the brief's `scope.tier_a_include` set disagrees with "
1020
+ "(denominator drift), a person fixes denominator deflation on a brief with no tier A list by adding "
1021
+ "`scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the "
1022
+ "named-export count leaves out need no fix"),
1023
+ "provenance-unverified": (
1024
+ "the line-check rules could not verify the line, so a person checks by hand that it defines the export and, "
1025
+ "when another line does, sets its provenance `source_line` to that line"),
1026
+ "denominator-inflation": (
1027
+ "it concerns the brief's scope and the count design, which a person decides in the skill brief, as the "
1028
+ "gap's remediation says"),
1029
+ "brief-scope-stale": (
1030
+ "a person edits the brief's scope globs by hand so each matches the files it meant, since update-skill "
1031
+ "does not rewrite them"),
1032
+ "multi-denominator": (
1033
+ "no fix is needed, since the barrel and the documented surface measure different sets by design and the "
1034
+ "report records both counts"),
1035
+ "numerator-inflation": "a person documents the absent exports in SKILL.md or references/, reading each from "
1036
+ "the source",
1037
+ "scripts-assets-provenance": "a person adds the file's row to the provenance map's `file_entries`, or removes "
1038
+ "the file from the skill",
1039
+ "observation": "a person applies it only when they agree, since it is advisory, and a documented extra needs no "
1040
+ "change",
1041
+ "discovery": "a person improves the skill's description so realistic prompts route to it, or runs the discovery "
1042
+ "test Test Skill skipped",
1043
+ "description": "a person edits the description in the SKILL.md frontmatter as the gap suggests",
1044
+ "external-validator": "a person applies the skill-check diagnostic or Tessl Review suggestion to SKILL.md",
1045
+ }
1046
+ NO_REPAIR = "update-skill has no repair for this category, so a person follows the gap's remediation"
1047
+ NO_EXPORT = "it names no export for update-skill to repair, so a person follows the gap's remediation"
975
1048
  EXPORT_CATEGORIES = ("NEW_EXPORT", "MODIFIED_EXPORT", "MOVED_EXPORT", "DELETED_EXPORT")
976
1049
  RESCOPE_CATEGORIES = ("missing-export", "missing-type") # rule R1
977
1050
  COMPLETENESS = "provenance-completeness" # rule R3
@@ -985,8 +1058,14 @@ _LEDGER_REMEDIATION = re.compile(
985
1058
  r"|Find the \d+ exports the denominator counts that SKILL\.md and references/ do not name "
986
1059
  r"\(`[^`\n]+` lists the exports\) and document each with its signature\."
987
1060
  )
988
- # A citation of generated markdown: the skill's SKILL.md or a references/*.md file.
989
- _PACKAGE_FILE = re.compile(r"(?:^|/)(?:SKILL\.md|references/[^/]+\.md)$")
1061
+ # A citation of generated markdown: the skill's SKILL.md or a .md file under its references/, relative to the
1062
+ # package, after `{skill_package}/`, or after the package folder of SKF's layout as a project-relative path
1063
+ # (`skills/{name}/{version}/{name}/`, or the flat `skills/{name}/`, after any leading folders). A path under any
1064
+ # other folder (`docs/references/guide.md`) is source.
1065
+ _PACKAGE_FILE = re.compile(
1066
+ r"(?:\{skill_package\}/|(?:[^/]+/)*skills/(?P<name>[^/]+)/(?:[^/]+/(?P=name)/)?)?"
1067
+ r"(?P<file>SKILL\.md|references/(?:[^/]+/)*[^/]+\.md)")
1068
+ SPLIT_BODY = "split-body-mismatch"
990
1069
  # The answers translate reads, with the JSON types each takes.
991
1070
  _ANSWER_TYPES = {"category": (str, type(None)), "name": (str, type(None)), "rescope": (bool,),
992
1071
  "source_file": (str, type(None))}
@@ -994,14 +1073,38 @@ _IS_WINDOWS = os.name == "nt"
994
1073
  _REPLACE_WAIT_SECONDS = 5.0
995
1074
 
996
1075
 
1076
+ def _package_path(citation: dict | None) -> str | None:
1077
+ """The citation's file relative to the skill package, or None when it names no file of the package."""
1078
+ path = _norm_file(citation.get("file")) if isinstance(citation, dict) else None
1079
+ path = path.strip("`") if path else None
1080
+ found = _PACKAGE_FILE.fullmatch(path) if path and ".." not in path.split("/") else None
1081
+ return found.group("file") if found else None
1082
+
1083
+
997
1084
  def in_skill_package(citation: dict | None) -> bool:
998
1085
  """True when a citation names a line of generated markdown, not source.
999
1086
 
1000
- >>> [in_skill_package({"file": f, "line": 1}) for f in ("SKILL.md", "{skill_package}/references/api.md", "src/a.ts")]
1001
- [True, True, False]
1087
+ >>> [in_skill_package({"file": f, "line": 1}) for f in ("SKILL.md", "{skill_package}/references/api.md",
1088
+ ... "references/integrations/a-b.md", "src/a.ts",
1089
+ ... "skills/oms-cognee/0.5.8/oms-cognee/SKILL.md",
1090
+ ... "docs/references/guide.md")]
1091
+ [True, True, True, False, True, False]
1002
1092
  """
1003
- path = _norm_file(citation.get("file")) if isinstance(citation, dict) else None
1004
- return bool(path and _PACKAGE_FILE.search(path))
1093
+ return _package_path(citation) is not None
1094
+
1095
+
1096
+ def package_citation(citation: dict | None) -> dict | None:
1097
+ """An in-package citation with its file relative to the package, else None.
1098
+
1099
+ >>> for f in ("{skill_package}/references/api.md", "SKILL.md", "skills/lib/references/api.md", "src/a.ts"):
1100
+ ... print(package_citation({"file": f, "line": 3}))
1101
+ {'file': 'references/api.md', 'line': 3}
1102
+ {'file': 'SKILL.md', 'line': 3}
1103
+ {'file': 'references/api.md', 'line': 3}
1104
+ None
1105
+ """
1106
+ path = _package_path(citation)
1107
+ return {"file": path, "line": citation.get("line")} if path else None
1005
1108
 
1006
1109
 
1007
1110
  def written_remediation(text: object) -> bool:
@@ -1051,9 +1154,10 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
1051
1154
  entries: list[dict] = []
1052
1155
  not_routed: list[dict] = []
1053
1156
 
1054
- def skip(gap: dict, category: str | None) -> None:
1055
- not_routed.append({"id": gap["id"], "title": gap.get("title"), "category": category})
1056
- warnings.append(f"test-report: not routed: {gap['id']} ({category or 'no category'})")
1157
+ def skip(gap: dict, category: str | None, reason: str | None = None) -> None:
1158
+ reason = reason or NOT_ROUTED_REASONS.get(category or "", NO_REPAIR)
1159
+ not_routed.append({"id": gap["id"], "title": gap.get("title"), "category": category, "reason": reason})
1160
+ warnings.append(f"test-report: not routed: {gap['id']} ({category or 'no category'}): {reason}")
1057
1161
 
1058
1162
  def ask(gap: dict, category: str | None, want: list[str]) -> None:
1059
1163
  needs.append({"gap_id": gap["id"], "title": gap.get("title"), "category": category,
@@ -1079,11 +1183,14 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
1079
1183
  name = export or _answer_text(answers.get("name"))
1080
1184
  if change in EXPORT_CATEGORIES and name is None:
1081
1185
  if "name" in answers: # answered null: no export to repair
1082
- skip(gap, category)
1186
+ skip(gap, category, NO_EXPORT)
1083
1187
  else:
1084
1188
  ask(gap, category, ["name"])
1085
1189
  continue
1086
1190
  citation = gap.get("source_citation")
1191
+ # the gap's own category, which the ledger recorded: a judged one is an older report's, whose Source may
1192
+ # name the body that report still kept
1193
+ package = package_citation(citation) if gap.get("category") == SPLIT_BODY else None
1087
1194
  if in_skill_package(citation):
1088
1195
  citation = None
1089
1196
  match = map_match(map_entries, name, citation) if name else None
@@ -1123,6 +1230,8 @@ def translate_gaps(parsed: dict, map_entries: list, judgments: dict, *, read_onl
1123
1230
  entry = {"name": name, "gap_id": gap["id"], "category": category, "severity": gap.get("severity")}
1124
1231
  if citation:
1125
1232
  entry["source_citation"] = citation
1233
+ if package:
1234
+ entry["package_citation"] = package
1126
1235
  entry["remediation_paths"] = list(gap.get("remediation_paths") or [])
1127
1236
  entry["resolved_paths"] = resolved
1128
1237
  entry["rejected_paths"] = rejected
@@ -49,6 +49,7 @@ SIDECAR_MISSING.
49
49
 
50
50
  from __future__ import annotations
51
51
 
52
+ import datetime
52
53
  import json
53
54
  import re
54
55
  import stat
@@ -82,6 +83,19 @@ PLACEHOLDER_RE = re.compile(r"\{[^{}]*\}")
82
83
  FOLDER_KEYS = ("output_folder", "skills_output_folder", "forge_data_folder")
83
84
 
84
85
 
86
+ def _json_default(value):
87
+ """Print a date or time as ISO 8601.
88
+
89
+ YAML reads an unquoted timestamp as a date or datetime object. SKF 1.x
90
+ wrote ``tier_detected_at`` and ``ccc_index.last_indexed`` in
91
+ forge-tier.yaml unquoted, so a sidecar from before 2.0.0 holds them; any
92
+ other value json cannot print is still an error.
93
+ """
94
+ if isinstance(value, (datetime.date, datetime.time)):
95
+ return value.isoformat()
96
+ raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")
97
+
98
+
85
99
  def load_yaml_file(path):
86
100
  """Load a YAML mapping, returning (data, None) or (None, error_string).
87
101
 
@@ -304,5 +318,5 @@ if __name__ == "__main__":
304
318
  cfg_path = sys.argv[idx + 1]
305
319
 
306
320
  result = run_preflight(proj_root, cfg_path, allow_missing_sidecar="--allow-missing-sidecar" in sys.argv)
307
- print(json.dumps(result, indent=2))
321
+ print(json.dumps(result, indent=2, default=_json_default))
308
322
  sys.exit(1 if result["status"] == "hard-halt" else 0)
@@ -78,6 +78,7 @@ Exit codes:
78
78
  from __future__ import annotations
79
79
 
80
80
  import argparse
81
+ import importlib.util
81
82
  import json
82
83
  import re
83
84
  import sys
@@ -256,14 +257,37 @@ _DEFERRED = "deferred-headless"
256
257
  _LEGACY_HEADLESS_REASON = "headless: no user to prompt"
257
258
 
258
259
 
260
+ _RESOLVER = None
261
+
262
+
263
+ def _resolver():
264
+ """skf-resolve-authoritative-files.py from this folder, loaded once: its
265
+ parse_brief_yaml reads a brief's YAML, an SKF 1.x brief's trailing `---`
266
+ included. Raises ValueError when it cannot be loaded."""
267
+ global _RESOLVER
268
+ if _RESOLVER is None:
269
+ path = Path(__file__).resolve().parent / "skf-resolve-authoritative-files.py"
270
+ spec = importlib.util.spec_from_file_location("skf_resolve_authoritative_files", path)
271
+ if spec is None or spec.loader is None:
272
+ raise ValueError(f"cannot load {path.name} beside {Path(__file__).name}")
273
+ module = importlib.util.module_from_spec(spec)
274
+ try:
275
+ spec.loader.exec_module(module)
276
+ except (OSError, ImportError, SyntaxError) as exc:
277
+ raise ValueError(f"cannot load {path.name} beside {Path(__file__).name}: {exc}") from exc
278
+ _RESOLVER = module
279
+ return _RESOLVER
280
+
281
+
259
282
  def load_brief(brief_path: Path) -> dict:
260
283
  """Parse a skill-brief.yaml file. Raises ValueError on load failure."""
261
284
  try:
262
285
  text = brief_path.read_text(encoding="utf-8")
263
286
  except OSError as exc:
264
287
  raise ValueError(f"cannot read brief at {brief_path}: {exc}") from exc
288
+ parse_brief_yaml = _resolver().parse_brief_yaml
265
289
  try:
266
- brief = yaml.safe_load(text)
290
+ brief = parse_brief_yaml(text)
267
291
  except yaml.YAMLError as exc:
268
292
  raise ValueError(f"brief at {brief_path} is not valid YAML: {exc}") from exc
269
293
  if not isinstance(brief, dict):
@@ -73,7 +73,7 @@ Judgment payload (stdin JSON, compute mode)::
73
73
 
74
74
  {
75
75
  "exports_public_api": 40, # int — public-entry-point export count (judgment)
76
- "exports_internal": 19, # int — non-underscore internal export count (judgment)
76
+ "exports_internal": 19, # int: internal export count, Python/Rust/Dart `_` names left out (judgment)
77
77
  "effective_denominator": 12, # int|null — optional, curated-subset scopes only
78
78
  "pattern_surfaces_documented": 8, # int — reference-app only (exports_documented proxy)
79
79
  "exports_documented": 0, # int — stack only (own-barrel count, usually 0)
@@ -109,8 +109,17 @@ import yaml
109
109
 
110
110
  # skf-classify-changed-files.py loads this file for load_brief,
111
111
  # extract_scope, glob_match, scope_match, normalize_rel_path and
112
- # EXCLUDED_DIR_NAMES, and skf-detect-registry.py for glob_match, load_brief
113
- # and extract_scope: keep those names and what they take and return.
112
+ # EXCLUDED_DIR_NAMES (its load_scope, the brief-scope test that
113
+ # skf-new-file-diff.py, skf-detect-scripts-assets.py --brief and
114
+ # skf-compare-file-hashes.py --brief load from it, reads the brief through
115
+ # them), skf-detect-registry.py for glob_match, load_brief
116
+ # and extract_scope, skf-extract-public-api.py for glob_match and
117
+ # parse_brief_yaml, and every other script that reads a skill brief
118
+ # (skf-validate-brief-schema.py, skf-provenance-gap-dispatch.py,
119
+ # skf-derive-assembly-shape.py, skf-write-skill-brief.py and skf-test-skill's
120
+ # load-coverage-inputs.py) for parse_brief_yaml, load-coverage-inputs.py
121
+ # also for _amendment_action: keep those names and what they take and
122
+ # return.
114
123
 
115
124
 
116
125
  # --------------------------------------------------------------------------
@@ -270,13 +279,30 @@ def scope_match(
270
279
  # --------------------------------------------------------------------------
271
280
 
272
281
 
282
+ def parse_brief_yaml(text: str):
283
+ """The one document a skill brief's YAML text holds, or None when it holds none.
284
+
285
+ SKF 1.x closed every brief with a trailing `---`, which YAML reads as a
286
+ second, empty document, so `yaml.safe_load` refuses such a brief. A
287
+ document that holds no value (empty, or an explicit null such as `~`) is
288
+ skipped wherever it stands; more than one document that holds a value is
289
+ refused with yaml.YAMLError, as is text that is not YAML.
290
+ """
291
+ documents = [doc for doc in yaml.safe_load_all(text) if doc is not None]
292
+ if len(documents) > 1:
293
+ raise yaml.YAMLError(
294
+ f"a skill brief is a single document; this text holds {len(documents)}"
295
+ )
296
+ return documents[0] if documents else None
297
+
298
+
273
299
  def load_brief(brief_path: Path) -> dict:
274
300
  try:
275
301
  text = brief_path.read_text(encoding="utf-8")
276
302
  except OSError as exc:
277
303
  raise ValueError(f"cannot read brief at {brief_path}: {exc}") from exc
278
304
  try:
279
- brief = yaml.safe_load(text)
305
+ brief = parse_brief_yaml(text)
280
306
  except yaml.YAMLError as exc:
281
307
  raise ValueError(
282
308
  f"brief at {brief_path} is not valid YAML: {exc}"