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.
- package/.claude-plugin/marketplace.json +1 -1
- package/README.md +3 -3
- package/docs/_data/pinned.yaml +19 -13
- package/docs/architecture.md +2 -2
- package/docs/concepts.md +4 -4
- package/docs/examples.md +33 -26
- package/docs/getting-started.md +3 -3
- package/docs/how-it-works.md +2 -2
- package/docs/index.md +1 -1
- package/docs/skill-model.md +41 -39
- package/docs/troubleshooting.md +8 -0
- package/docs/verifying-a-skill.md +46 -21
- package/docs/why-skf.md +1 -1
- package/docs/workflows.md +5 -5
- 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 +12 -4
- package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
- package/src/shared/scripts/skf-atomic-write.py +212 -59
- package/src/shared/scripts/skf-build-change-manifest.py +1437 -178
- 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-count-tokens.py +54 -10
- 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-detect-workspaces.py +67 -0
- package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
- package/src/shared/scripts/skf-extract-public-api.py +1673 -189
- package/src/shared/scripts/skf-extraction-inventory.py +584 -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-parse-gaps.py +135 -26
- 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-render-metadata-stats.py +1 -1
- package/src/shared/scripts/skf-resolve-authoritative-files.py +29 -3
- package/src/shared/scripts/skf-resolve-customization.py +197 -0
- package/src/shared/scripts/skf-shard-body.py +239 -41
- 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-validate-output.py +12 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +1439 -79
- package/src/shared/scripts/skf-write-skill-brief.py +41 -8
- package/src/shared/tool-requirements.yaml +8 -2
- package/src/skf-analyze-source/SKILL.md +2 -2
- package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
- package/src/skf-analyze-source/references/map-unit-exports.md +1 -1
- package/src/skf-audit-skill/SKILL.md +2 -2
- 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 +3 -3
- package/src/skf-brief-skill/assets/scope-templates.md +1 -1
- package/src/skf-brief-skill/assets/skill-brief-schema.md +4 -11
- 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-campaign/SKILL.md +2 -2
- package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
- package/src/skf-create-skill/SKILL.md +2 -2
- package/src/skf-create-skill/assets/compile-assembly-rules.md +5 -3
- package/src/skf-create-skill/assets/skill-sections.md +7 -7
- 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 +23 -19
- package/src/skf-create-skill/references/extraction-patterns-tracing.md +9 -9
- package/src/skf-create-skill/references/extraction-patterns.md +10 -11
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +21 -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-create-stack-skill/SKILL.md +2 -2
- package/src/skf-create-stack-skill/references/validate.md +1 -1
- package/src/skf-drop-skill/SKILL.md +2 -2
- 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-export-skill/SKILL.md +2 -2
- package/src/skf-export-skill/assets/snippet-format.md +5 -5
- package/src/skf-export-skill/references/generate-snippet.md +22 -15
- package/src/skf-export-skill/references/load-skill.md +1 -1
- package/src/skf-export-skill/references/token-report.md +29 -9
- package/src/skf-forger/SKILL.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -2
- package/src/skf-refine-architecture/SKILL.md +6 -8
- package/src/skf-rename-skill/SKILL.md +2 -2
- package/src/skf-setup/SKILL.md +2 -2
- package/src/skf-test-skill/SKILL.md +2 -2
- package/src/skf-test-skill/assets/output-section-formats.md +6 -4
- package/src/skf-test-skill/references/coherence-check.md +5 -4
- package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
- package/src/skf-test-skill/references/coverage-check-tiers.md +5 -5
- package/src/skf-test-skill/references/coverage-check.md +69 -111
- package/src/skf-test-skill/references/init.md +5 -6
- package/src/skf-test-skill/references/migration-section-rules.md +26 -14
- package/src/skf-test-skill/references/report.md +11 -9
- package/src/skf-test-skill/references/scoring-rules.md +8 -6
- package/src/skf-test-skill/references/source-access-protocol.md +15 -18
- package/src/skf-test-skill/scripts/build-result-context.py +114 -21
- package/src/skf-test-skill/scripts/gap-ledger.py +580 -80
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +646 -107
- package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
- package/src/skf-test-skill/scripts/reconcile-coverage.py +450 -23
- package/src/skf-test-skill/scripts/score-signatures.py +356 -61
- package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
- package/src/skf-test-skill/scripts/verify-declared-numerator.py +7 -5
- package/src/skf-test-skill/templates/test-report-template.md +1 -0
- package/src/skf-update-skill/SKILL.md +2 -2
- package/src/skf-update-skill/references/detect-changes.md +32 -13
- package/src/skf-update-skill/references/gap-driven.md +37 -22
- 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 +17 -12
- package/src/skf-update-skill/references/re-extract.md +4 -2
- package/src/skf-update-skill/references/report.md +7 -4
- package/src/skf-update-skill/references/write.md +77 -36
- package/src/skf-update-skill/scripts/skf-new-file-diff.py +157 -42
- package/src/skf-verify-stack/SKILL.md +2 -2
- 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
|
|
11
|
-
`skf-brief-skill/references/step-auto-brief.md` §3
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
return 2
|
|
343
|
+
raise InputError(f"invalid JSON on stdin: {exc}") from exc
|
|
246
344
|
if not isinstance(payload, dict):
|
|
247
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
is the
|
|
96
|
-
|
|
97
|
-
|
|
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
|
|
146
|
-
entry,
|
|
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
|
|
154
|
-
|
|
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,
|
|
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": [...]}.
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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
|
|
989
|
-
|
|
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",
|
|
1001
|
-
|
|
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
|
-
|
|
1004
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
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 =
|
|
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
|
|
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,
|
|
113
|
-
#
|
|
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 =
|
|
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}"
|