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
|
@@ -63,6 +63,20 @@ Matching:
|
|
|
63
63
|
listed in ambiguous_names[] with the files on each side, for a reviewer
|
|
64
64
|
to judge.
|
|
65
65
|
|
|
66
|
+
Public surface:
|
|
67
|
+
A current entry may carry `public`, true or false: the mark
|
|
68
|
+
skf-extraction-snapshot.py build --scope-type public-api gives each
|
|
69
|
+
export by the public-surface rule a public-api skill's provenance map is
|
|
70
|
+
kept to. After the pairing above, a current entry marked false that
|
|
71
|
+
pairs with none (no baseline entry of its name and file, and no move)
|
|
72
|
+
goes to not_public[], not added[]: create-skill and update-skill never
|
|
73
|
+
document an export off the surface, so it is no drift. One whose name is
|
|
74
|
+
in ambiguous_names[] stays in added[], where a reviewer pairs it. A
|
|
75
|
+
matched or moved entry is compared whatever its mark, so a baseline
|
|
76
|
+
entry off the surface is never reported removed. An entry with no mark
|
|
77
|
+
(any other scope type, a family with no barrel, an older snapshot) is
|
|
78
|
+
added as before. not_public[] never counts toward the exit code.
|
|
79
|
+
|
|
66
80
|
File scope (--files FILE):
|
|
67
81
|
For a caller that re-extracted only the files that changed (update-skill's
|
|
68
82
|
Category B), --files limits the diff to the exports of the files FILE
|
|
@@ -116,12 +130,27 @@ Canonicalization (applied symmetrically to BOTH sides before matching):
|
|
|
116
130
|
` = default`), so a runner's export compares with the map's entry.
|
|
117
131
|
|
|
118
132
|
Change detection:
|
|
119
|
-
The diffed fields are type
|
|
120
|
-
|
|
121
|
-
|
|
133
|
+
The diffed fields are type (compared through export-type, below),
|
|
134
|
+
signature, params, return_type and line. A field is only compared when
|
|
135
|
+
present (non-null) on BOTH sides: a field absent on one side means
|
|
136
|
+
"insufficient data", never an asserted change.
|
|
122
137
|
This avoids false positives when the two inventories carry different
|
|
123
138
|
metadata. The line of a moved export is not compared: a move changes it.
|
|
124
139
|
|
|
140
|
+
export-type: no record is rewritten, but when a matched export's two
|
|
141
|
+
types differ, a semantic kind a by-eye read records is the base kind
|
|
142
|
+
the recipe runner records for the same declaration when the base-kind
|
|
143
|
+
side's canonical signature shows that form: `async_function` is
|
|
144
|
+
`function` for an `async def`, `async function`, `async fn` or
|
|
145
|
+
`async unsafe fn`, or a `const`, `let` or `var` bound to an `async`
|
|
146
|
+
arrow or function (after `export`, `default`, `declare` or `pub`);
|
|
147
|
+
`decorator` is `function` for a Python `def` or `async def`; `enum` is
|
|
148
|
+
`class` for a Python class with a stdlib `Enum`, `IntEnum`, `StrEnum`,
|
|
149
|
+
`ReprEnum`, `Flag` or `IntFlag` base. Either side may hold the by-eye
|
|
150
|
+
kind; with no signature on the base-kind side the types differ. Each
|
|
151
|
+
export whose types it takes as one counts once as `export-type` in
|
|
152
|
+
applied_transforms.
|
|
153
|
+
|
|
125
154
|
A signature has two parts, the parameters and the return type. A side
|
|
126
155
|
carries the parameters as params or in its signature, and the return
|
|
127
156
|
type as return_type or in its signature. When both sides carry a part
|
|
@@ -145,7 +174,7 @@ Output:
|
|
|
145
174
|
JSON object:
|
|
146
175
|
summary: { added, removed, changed, moved, unchanged,
|
|
147
176
|
label_changes, ambiguous_names,
|
|
148
|
-
signature_unverified }
|
|
177
|
+
signature_unverified, not_public }
|
|
149
178
|
added: list of entries present in current but not baseline
|
|
150
179
|
removed: list of entries present in baseline but not current
|
|
151
180
|
changed: list of { name, field, baseline_value, current_value,
|
|
@@ -168,17 +197,26 @@ Output:
|
|
|
168
197
|
params and return_type each side carries.
|
|
169
198
|
Informational: never a change, never in the exit
|
|
170
199
|
code
|
|
200
|
+
not_public: list of current entries marked `public: false`
|
|
201
|
+
that pair with no baseline entry and whose name is
|
|
202
|
+
not ambiguous (see Public surface): exports off a
|
|
203
|
+
public-api skill's public surface, left out of
|
|
204
|
+
added[]. Not drift, never in the exit code
|
|
171
205
|
unchanged_count: number of entries that matched with no field change
|
|
172
206
|
(a label change or an unverified signature alone
|
|
173
207
|
leaves an entry unchanged)
|
|
174
208
|
applied_transforms: list of { transform, count }: which canonicalization
|
|
175
|
-
transforms
|
|
176
|
-
|
|
209
|
+
transforms, and the export-type comparison,
|
|
210
|
+
actually fired and how many values each touched
|
|
211
|
+
(export-type: how many exports; empty when none
|
|
212
|
+
fired). Surfaced by audit
|
|
177
213
|
step 6's Provenance section so a reviewer can tell
|
|
178
214
|
which differences the diff collapsed.
|
|
179
215
|
|
|
180
|
-
added[] and
|
|
181
|
-
params, return_type, file, line, confidence and
|
|
216
|
+
added[], removed[] and not_public[] hold normalized records: name, type,
|
|
217
|
+
signature, params, return_type, file, line, confidence and
|
|
218
|
+
extraction_method, and `public` when the entry carries a true or false
|
|
219
|
+
mark (see Public surface).
|
|
182
220
|
|
|
183
221
|
With --group-by source_library, each side is split by its entries'
|
|
184
222
|
source_library (a stack's libraries; an entry without one falls in a
|
|
@@ -208,8 +246,9 @@ Exit codes:
|
|
|
208
246
|
inventory whose entries all lack the field while the other's have it,
|
|
209
247
|
an output that cannot be written, or a usage error ({status: "error",
|
|
210
248
|
error} on stdout, or argparse's usage on stderr)
|
|
211
|
-
Label differences (label_changes)
|
|
212
|
-
(signature_unverified)
|
|
249
|
+
Label differences (label_changes), unverified signatures
|
|
250
|
+
(signature_unverified) and exports off the public surface (not_public) do
|
|
251
|
+
not count toward the exit code.
|
|
213
252
|
"""
|
|
214
253
|
|
|
215
254
|
from __future__ import annotations
|
|
@@ -275,6 +314,37 @@ _STDLIB_PREFIX_RE = re.compile(
|
|
|
275
314
|
r"(?<![\w.])(?:typing|dataclasses|collections\.abc|collections|enum)\."
|
|
276
315
|
)
|
|
277
316
|
|
|
317
|
+
# export-type: a semantic kind a by-eye read records, against the base kind
|
|
318
|
+
# the recipe runner records for the same declaration, names one kind when
|
|
319
|
+
# the base-kind side's canonical signature shows that form. Any other pair
|
|
320
|
+
# of types, or a base-kind side with no signature, is compared as written.
|
|
321
|
+
# An async form: `async def`, `async function`, `async fn` or `async unsafe
|
|
322
|
+
# fn`, or a `const` / `let` / `var` bound to an `async` arrow or function
|
|
323
|
+
# (`export const add = async (x) => x;`), after any `export`, `default`,
|
|
324
|
+
# `declare` or `pub` modifier.
|
|
325
|
+
_ASYNC_FORM_RE = re.compile(
|
|
326
|
+
r"^\s*(?:(?:export|default|declare|pub(?:\s*\([^()]*\))?)\s+)*"
|
|
327
|
+
r"(?:async\s+(?:unsafe\s+)?(?:def|function|fn)\b"
|
|
328
|
+
r"|(?:const|let|var)\s+[\w$]+\s*(?::(?:[^=]|=>)*)?=\s*async\b)"
|
|
329
|
+
)
|
|
330
|
+
_DEF_FORM_RE = re.compile(r"^\s*(?:async\s+)?def\s")
|
|
331
|
+
_CLASS_BASES_RE = re.compile(r"^\s*class\s+\w+\s*\(([^()]*)\)")
|
|
332
|
+
_ENUM_BASES = frozenset({"Enum", "IntEnum", "StrEnum", "ReprEnum", "Flag", "IntFlag"})
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _enum_class_form(signature: str) -> bool:
|
|
336
|
+
"""True when a Python class declaration lists a stdlib enum base."""
|
|
337
|
+
m = _CLASS_BASES_RE.match(signature)
|
|
338
|
+
return m is not None and any(base.strip() in _ENUM_BASES for base in m.group(1).split(","))
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
# (by-eye kind, base kind) -> the test of the base-kind side's signature.
|
|
342
|
+
EXPORT_TYPE_FORMS = {
|
|
343
|
+
("async_function", "function"): lambda signature: _ASYNC_FORM_RE.match(signature) is not None,
|
|
344
|
+
("decorator", "function"): lambda signature: _DEF_FORM_RE.match(signature) is not None,
|
|
345
|
+
("enum", "class"): _enum_class_form,
|
|
346
|
+
}
|
|
347
|
+
|
|
278
348
|
|
|
279
349
|
# --------------------------------------------------------------------------
|
|
280
350
|
# Loading
|
|
@@ -561,6 +631,10 @@ def _normalize_entries(
|
|
|
561
631
|
"confidence": entry.get("confidence"),
|
|
562
632
|
"extraction_method": entry.get("extraction_method"),
|
|
563
633
|
}
|
|
634
|
+
# The snapshot's public-surface mark, kept only when it is one, so an
|
|
635
|
+
# unmarked inventory's records are as before.
|
|
636
|
+
if isinstance(entry.get("public"), bool):
|
|
637
|
+
record["public"] = entry["public"]
|
|
564
638
|
key = (name, _file_key(record["file"]))
|
|
565
639
|
kept = result.get(key)
|
|
566
640
|
if kept is not None and not (
|
|
@@ -616,6 +690,21 @@ def _label_change(base_rec: dict, curr_rec: dict) -> dict | None:
|
|
|
616
690
|
return None
|
|
617
691
|
|
|
618
692
|
|
|
693
|
+
def _export_type_collapses(base_rec: dict, curr_rec: dict) -> bool:
|
|
694
|
+
"""True when two matched records' types name one kind (EXPORT_TYPE_FORMS):
|
|
695
|
+
one side's by-eye kind against the other's base kind, whose canonical
|
|
696
|
+
signature shows that form. Either side may hold the by-eye kind."""
|
|
697
|
+
for by_eye, base in ((base_rec, curr_rec), (curr_rec, base_rec)):
|
|
698
|
+
kinds = (by_eye.get("type"), base.get("type"))
|
|
699
|
+
signature = base.get("signature")
|
|
700
|
+
if not all(isinstance(kind, str) for kind in kinds) or not isinstance(signature, str):
|
|
701
|
+
continue
|
|
702
|
+
form = EXPORT_TYPE_FORMS.get(kinds)
|
|
703
|
+
if form is not None and form(signature):
|
|
704
|
+
return True
|
|
705
|
+
return False
|
|
706
|
+
|
|
707
|
+
|
|
619
708
|
def _signature_fields(rec: dict) -> list[str]:
|
|
620
709
|
return [field for field in SIGNATURE_FIELDS if rec.get(field) is not None]
|
|
621
710
|
|
|
@@ -641,7 +730,8 @@ def _unverified_signature(base_rec: dict, curr_rec: dict) -> dict | None:
|
|
|
641
730
|
# --------------------------------------------------------------------------
|
|
642
731
|
|
|
643
732
|
|
|
644
|
-
LISTS = ["added", "removed", "changed", "moved", "ambiguous_names", "label_changes", "signature_unverified"
|
|
733
|
+
LISTS = ["added", "removed", "changed", "moved", "ambiguous_names", "label_changes", "signature_unverified",
|
|
734
|
+
"not_public"]
|
|
645
735
|
|
|
646
736
|
|
|
647
737
|
def _key_order(key: tuple) -> tuple:
|
|
@@ -656,14 +746,18 @@ def _diff_records(
|
|
|
656
746
|
baseline: dict[tuple, dict],
|
|
657
747
|
current: dict[tuple, dict],
|
|
658
748
|
unchanged_names: collections.Counter | None = None,
|
|
749
|
+
transform_counts: collections.Counter | None = None,
|
|
659
750
|
) -> dict:
|
|
660
751
|
"""Diff two (name, file)-keyed record sets: the LISTS, plus the counts
|
|
661
752
|
unchanged_count and changed_exports (exports with a changed field).
|
|
662
753
|
|
|
663
754
|
unchanged_names counts the names of exports left out of both sets as
|
|
664
755
|
unchanged (--files): each counts on both sides when a move needs a name
|
|
665
|
-
unique on both sides.
|
|
756
|
+
unique on both sides. transform_counts gains one export-type for each
|
|
757
|
+
matched export whose types name one kind (_export_type_collapses).
|
|
666
758
|
"""
|
|
759
|
+
if transform_counts is None:
|
|
760
|
+
transform_counts = collections.Counter()
|
|
667
761
|
removed_keys = sorted((k for k in baseline if k not in current), key=_key_order)
|
|
668
762
|
added_keys = sorted((k for k in current if k not in baseline), key=_key_order)
|
|
669
763
|
|
|
@@ -697,8 +791,20 @@ def _diff_records(
|
|
|
697
791
|
|
|
698
792
|
# Emit normalized entries (uniform shape) so added[] and removed[] render
|
|
699
793
|
# into the same report table even though they originate from the
|
|
700
|
-
# snapshot and provenance-map shapes.
|
|
701
|
-
|
|
794
|
+
# snapshot and provenance-map shapes. After the pairing, a leftover
|
|
795
|
+
# current entry marked off the public surface is no addition, unless its
|
|
796
|
+
# name is ambiguous: a reviewer may pair it as a move.
|
|
797
|
+
ambiguous = {item["name"] for item in ambiguous_names}
|
|
798
|
+
added: list[dict] = []
|
|
799
|
+
not_public: list[dict] = []
|
|
800
|
+
for key in added_keys:
|
|
801
|
+
if key in paired_curr:
|
|
802
|
+
continue
|
|
803
|
+
rec = current[key]
|
|
804
|
+
if rec.get("public") is False and rec["name"] not in ambiguous:
|
|
805
|
+
not_public.append(rec)
|
|
806
|
+
else:
|
|
807
|
+
added.append(rec)
|
|
702
808
|
removed = [baseline[k] for k in removed_keys if k not in paired_base]
|
|
703
809
|
|
|
704
810
|
changed: list[dict] = []
|
|
@@ -736,6 +842,11 @@ def _diff_records(
|
|
|
736
842
|
if base_val is None or curr_val is None:
|
|
737
843
|
continue
|
|
738
844
|
if base_val != curr_val:
|
|
845
|
+
# A by-eye kind and the runner's base kind of one declaration
|
|
846
|
+
# are one type: compared pairwise, never rewritten.
|
|
847
|
+
if field == "type" and _export_type_collapses(base_rec, curr_rec):
|
|
848
|
+
transform_counts["export-type"] += 1
|
|
849
|
+
continue
|
|
739
850
|
changed.append({
|
|
740
851
|
"name": curr_rec["name"],
|
|
741
852
|
"field": field,
|
|
@@ -771,6 +882,7 @@ def _diff_records(
|
|
|
771
882
|
"ambiguous_names": ambiguous_names,
|
|
772
883
|
"label_changes": label_changes,
|
|
773
884
|
"signature_unverified": signature_unverified,
|
|
885
|
+
"not_public": not_public,
|
|
774
886
|
"unchanged_count": unchanged_count,
|
|
775
887
|
"changed_exports": changed_exports,
|
|
776
888
|
}
|
|
@@ -786,6 +898,7 @@ def _summary(part: dict) -> dict:
|
|
|
786
898
|
"label_changes": len(part["label_changes"]),
|
|
787
899
|
"ambiguous_names": len(part["ambiguous_names"]),
|
|
788
900
|
"signature_unverified": len(part["signature_unverified"]),
|
|
901
|
+
"not_public": len(part["not_public"]),
|
|
789
902
|
}
|
|
790
903
|
|
|
791
904
|
|
|
@@ -813,10 +926,11 @@ def diff_inventories(
|
|
|
813
926
|
"""Compute the structural diff between two export inventories.
|
|
814
927
|
|
|
815
928
|
Returns a dict with keys: summary, added, removed, changed, moved,
|
|
816
|
-
ambiguous_names, label_changes, signature_unverified,
|
|
817
|
-
applied_transforms; with group_by, group_by and
|
|
818
|
-
file_scope (see the module docstring). files
|
|
819
|
-
exports of those files, and every other export
|
|
929
|
+
ambiguous_names, label_changes, signature_unverified, not_public,
|
|
930
|
+
unchanged_count, applied_transforms; with group_by, group_by and
|
|
931
|
+
groups; and with files, file_scope (see the module docstring). files
|
|
932
|
+
limits the diff to the exports of those files, and every other export
|
|
933
|
+
is taken as unchanged.
|
|
820
934
|
|
|
821
935
|
Raises ValueError when group_by is set and one side has entries but
|
|
822
936
|
none with a group_by value while the other side has some; the message
|
|
@@ -869,6 +983,7 @@ def diff_inventories(
|
|
|
869
983
|
_normalize_entries(base_part, reexport_map, transform_counts),
|
|
870
984
|
_normalize_entries(curr_part, reexport_map, transform_counts),
|
|
871
985
|
unchanged_names,
|
|
986
|
+
transform_counts,
|
|
872
987
|
)
|
|
873
988
|
if group_by:
|
|
874
989
|
for name in LISTS:
|
|
@@ -16,12 +16,17 @@ to verify it points where expected and halts on divergence.
|
|
|
16
16
|
This helper consolidates the two steps into one workflow-specific
|
|
17
17
|
invocation:
|
|
18
18
|
|
|
19
|
+
- holds {skill-group}/active.skf-lock, the lock skf-atomic-write.py
|
|
20
|
+
flip-link takes, from the read through the re-read, so one flip of
|
|
21
|
+
the link runs at a time; while another flip holds it, waits up to
|
|
22
|
+
FLIP_LOCK_WAIT_SECONDS, then returns busy without touching the link
|
|
19
23
|
- detects whether a flip is required (idempotent: already pointing
|
|
20
24
|
at the target → no-op)
|
|
21
|
-
- performs the flip
|
|
22
|
-
|
|
25
|
+
- performs the flip through skf-atomic-write.py's replace_link, the
|
|
26
|
+
code flip-link runs (link at a temp name → os.replace), so on POSIX
|
|
27
|
+
concurrent readers never see a missing symlink
|
|
23
28
|
- verifies the post-state and surfaces ok / flipped / mismatch
|
|
24
|
-
/ missing-target
|
|
29
|
+
/ missing-target / busy
|
|
25
30
|
|
|
26
31
|
Two subcommands:
|
|
27
32
|
|
|
@@ -41,7 +46,7 @@ silently breaks downstream consumers that expect it to resolve.
|
|
|
41
46
|
|
|
42
47
|
Output JSON (stdout):
|
|
43
48
|
{
|
|
44
|
-
"status": "ok" | "flipped" | "mismatch" | "missing-target",
|
|
49
|
+
"status": "ok" | "flipped" | "mismatch" | "missing-target" | "busy",
|
|
45
50
|
"skill_group": "<abs path>",
|
|
46
51
|
"active_link": "<abs path to active>",
|
|
47
52
|
"expected_target": "<version>",
|
|
@@ -52,19 +57,28 @@ Output JSON (stdout):
|
|
|
52
57
|
}
|
|
53
58
|
|
|
54
59
|
Exit codes:
|
|
55
|
-
0
|
|
56
|
-
1
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
0: ok (already correct) or flipped (just updated)
|
|
61
|
+
1: script error, one `error:` line on stderr and nothing on stdout
|
|
62
|
+
(group path invalid, a real folder or file at {skill-group}/active,
|
|
63
|
+
skf-atomic-write.py not loadable from this script's folder, or the
|
|
64
|
+
link could not be moved); argparse exits 2 on bad arguments
|
|
65
|
+
2: mismatch (verify mode and target diverges), missing-target
|
|
66
|
+
(the {version} directory doesn't exist; caller must halt), or
|
|
67
|
+
busy (update: another flip held the link's lock for the whole
|
|
68
|
+
wait; the link is unchanged, caller must halt)
|
|
69
|
+
|
|
70
|
+
Platform: Linux, macOS and native Windows, as skf-atomic-write.py's
|
|
71
|
+
flip-link. On Windows the flip removes the old link first whenever the
|
|
72
|
+
rename over it is refused, which it always is over a directory link, so
|
|
73
|
+
it is not atomic there; without symlink privilege the new link is a
|
|
74
|
+
directory junction. A junction reads back as the name of the version folder it
|
|
75
|
+
resolves to, so `ok` means the link names {version} whichever kind it is.
|
|
63
76
|
"""
|
|
64
77
|
|
|
65
78
|
from __future__ import annotations
|
|
66
79
|
|
|
67
80
|
import argparse
|
|
81
|
+
import importlib.util
|
|
68
82
|
import json
|
|
69
83
|
import os
|
|
70
84
|
import sys
|
|
@@ -72,6 +86,23 @@ from pathlib import Path
|
|
|
72
86
|
|
|
73
87
|
|
|
74
88
|
ACTIVE_NAME = "active"
|
|
89
|
+
ATOMIC_WRITE_HELPER = Path(__file__).resolve().parent / "skf-atomic-write.py"
|
|
90
|
+
# A flip holds <link>.skf-lock for well under a millisecond, so update waits
|
|
91
|
+
# this long for another flip to let it go before it returns busy.
|
|
92
|
+
FLIP_LOCK_WAIT_SECONDS = 5.0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _load_atomic_write():
|
|
96
|
+
"""Load skf-atomic-write.py, which installs into the same folder as this script."""
|
|
97
|
+
try:
|
|
98
|
+
spec = importlib.util.spec_from_file_location("skf_atomic_write", ATOMIC_WRITE_HELPER)
|
|
99
|
+
if spec is None or spec.loader is None:
|
|
100
|
+
raise ImportError("no import spec")
|
|
101
|
+
module = importlib.util.module_from_spec(spec)
|
|
102
|
+
spec.loader.exec_module(module)
|
|
103
|
+
except (OSError, ImportError, SyntaxError) as e:
|
|
104
|
+
raise OSError(f"cannot load the flip-lock helper {ATOMIC_WRITE_HELPER}: {e}") from e
|
|
105
|
+
return module
|
|
75
106
|
|
|
76
107
|
|
|
77
108
|
# --------------------------------------------------------------------------
|
|
@@ -80,46 +111,51 @@ ACTIVE_NAME = "active"
|
|
|
80
111
|
|
|
81
112
|
|
|
82
113
|
def read_link_target(link_path: Path) -> str | None:
|
|
83
|
-
"""Return the
|
|
84
|
-
|
|
85
|
-
(
|
|
86
|
-
|
|
114
|
+
"""Return what the link at `link_path` names, or None if nothing is there.
|
|
115
|
+
|
|
116
|
+
A symlink gives its stored target (`1.6.2` for the links SKF makes). A
|
|
117
|
+
Windows junction, which Path.is_symlink() does not report and whose
|
|
118
|
+
os.readlink is an absolute `\\\\?\\` path, gives the name of the folder it
|
|
119
|
+
resolves to when that folder sits beside it, else its stored path (a
|
|
120
|
+
junction whose folder is gone, one that leads elsewhere, or a loop). Raises
|
|
121
|
+
ValueError if `link_path` exists but is not a link (someone replaced it
|
|
122
|
+
with a regular dir/file)."""
|
|
123
|
+
if not os.path.lexists(link_path):
|
|
87
124
|
return None
|
|
88
|
-
if
|
|
125
|
+
if link_path.is_symlink():
|
|
126
|
+
return os.readlink(link_path)
|
|
127
|
+
try:
|
|
128
|
+
stored = os.readlink(link_path)
|
|
129
|
+
except (OSError, ValueError):
|
|
89
130
|
raise ValueError(
|
|
90
131
|
f"{link_path} exists but is not a symlink — refusing to overwrite "
|
|
91
132
|
"(manual recovery required)"
|
|
92
|
-
)
|
|
93
|
-
|
|
133
|
+
) from None
|
|
134
|
+
try:
|
|
135
|
+
resolved = link_path.resolve(strict=True)
|
|
136
|
+
except (OSError, RuntimeError): # Python 3.11 and 3.12 raise RuntimeError on a loop
|
|
137
|
+
return stored
|
|
138
|
+
if resolved.parent == link_path.parent.resolve():
|
|
139
|
+
return resolved.name
|
|
140
|
+
return stored
|
|
94
141
|
|
|
95
142
|
|
|
96
143
|
def atomic_flip_symlink(link_path: Path, target_name: str) -> None:
|
|
97
|
-
"""
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
rename is atomic for symlinks, so concurrent
|
|
105
|
-
the old target or the new one, never a missing file.
|
|
144
|
+
"""Point `link_path` at `target_name` using the create-temp + rename
|
|
145
|
+
pattern. `target_name` is a relative path interpreted in the parent of
|
|
146
|
+
`link_path` (same convention as {skill_group}/active → version-dir-sibling).
|
|
147
|
+
|
|
148
|
+
Implementation: skf-atomic-write.py's replace_link, the code flip-link
|
|
149
|
+
runs, with `.{name}.skf-symlink.tmp` as the temp name: it removes a stale
|
|
150
|
+
temp from a prior crash, writes the new link there and os.replace renames
|
|
151
|
+
it over link_path. POSIX rename is atomic for symlinks, so concurrent
|
|
152
|
+
readers either see the old target or the new one, never a missing file.
|
|
153
|
+
On Windows the old link is removed first whenever the rename over it is
|
|
154
|
+
refused, which it always is over a directory link; without symlink
|
|
155
|
+
privilege the new link is a junction.
|
|
106
156
|
"""
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
# Clean up a stale temp from a prior crash, if any.
|
|
110
|
-
if tmp.is_symlink() or tmp.exists():
|
|
111
|
-
os.unlink(tmp)
|
|
112
|
-
try:
|
|
113
|
-
os.symlink(target_name, tmp)
|
|
114
|
-
os.replace(tmp, link_path)
|
|
115
|
-
except OSError:
|
|
116
|
-
# Best-effort cleanup; raise the original error.
|
|
117
|
-
try:
|
|
118
|
-
if tmp.is_symlink() or tmp.exists():
|
|
119
|
-
os.unlink(tmp)
|
|
120
|
-
except OSError:
|
|
121
|
-
pass
|
|
122
|
-
raise
|
|
157
|
+
tmp = link_path.parent / f".{link_path.name}.skf-symlink.tmp"
|
|
158
|
+
_load_atomic_write().replace_link(link_path, target_name, tmp)
|
|
123
159
|
|
|
124
160
|
|
|
125
161
|
# --------------------------------------------------------------------------
|
|
@@ -191,8 +227,25 @@ def _envelope_mismatch(skill_group: Path, version: str, current: str | None) ->
|
|
|
191
227
|
f"Active symlink divergence. `{skill_group / ACTIVE_NAME}` "
|
|
192
228
|
f"resolves to `{cur}` but metadata.json reports `version: "
|
|
193
229
|
f"{version}`. The flip did not apply. Re-point the symlink manually "
|
|
194
|
-
f"(`
|
|
195
|
-
"update-skill, then re-verify."
|
|
230
|
+
f"(`uv run \"{ATOMIC_WRITE_HELPER}\" flip-link --link \"{skill_group / ACTIVE_NAME}\" "
|
|
231
|
+
f"--target \"{version}\"`) or re-run update-skill, then re-verify."
|
|
232
|
+
),
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _envelope_busy(skill_group: Path, version: str, message: str) -> dict:
|
|
237
|
+
return {
|
|
238
|
+
"status": "busy",
|
|
239
|
+
"skill_group": str(skill_group),
|
|
240
|
+
"active_link": str(skill_group / ACTIVE_NAME),
|
|
241
|
+
"expected_target": version,
|
|
242
|
+
"current_target": None,
|
|
243
|
+
"action_taken": "halt",
|
|
244
|
+
"log_message": f"active_symlink_update: busy ({message})",
|
|
245
|
+
"halt_message": (
|
|
246
|
+
f"Another flip of `{skill_group / ACTIVE_NAME}` held its lock for "
|
|
247
|
+
f"{FLIP_LOCK_WAIT_SECONDS:g} s, so the link is unchanged. "
|
|
248
|
+
"Re-run once the other run ends."
|
|
196
249
|
),
|
|
197
250
|
}
|
|
198
251
|
|
|
@@ -204,16 +257,24 @@ def update(skill_group: Path, version: str) -> dict:
|
|
|
204
257
|
return _envelope_missing_target(skill_group, version)
|
|
205
258
|
|
|
206
259
|
link_path = skill_group / ACTIVE_NAME
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
260
|
+
atomic_write = _load_atomic_write()
|
|
261
|
+
try:
|
|
262
|
+
lock_fd = atomic_write.take_flip_lock(link_path, wait=FLIP_LOCK_WAIT_SECONDS)
|
|
263
|
+
except atomic_write.FlipLockHeld as exc:
|
|
264
|
+
return _envelope_busy(skill_group, version, str(exc))
|
|
265
|
+
try:
|
|
266
|
+
current = read_link_target(link_path)
|
|
267
|
+
if current == version:
|
|
268
|
+
return _envelope_ok(skill_group, version, current)
|
|
210
269
|
|
|
211
|
-
|
|
270
|
+
atomic_flip_symlink(link_path, version)
|
|
212
271
|
|
|
213
|
-
|
|
214
|
-
|
|
272
|
+
# Re-read to confirm
|
|
273
|
+
post = read_link_target(link_path)
|
|
274
|
+
finally:
|
|
275
|
+
atomic_write.release_flip_lock(link_path, lock_fd)
|
|
215
276
|
if post != version:
|
|
216
|
-
#
|
|
277
|
+
# Only a writer that skips the lock, such as a hand-run `ln -sfn`, gets here.
|
|
217
278
|
return _envelope_mismatch(skill_group, version, post)
|
|
218
279
|
return _envelope_flipped(skill_group, version, current)
|
|
219
280
|
|
|
@@ -235,7 +296,7 @@ def verify(skill_group: Path, version: str) -> dict:
|
|
|
235
296
|
def _exit_code_for_status(status: str) -> int:
|
|
236
297
|
if status in ("ok", "flipped"):
|
|
237
298
|
return 0
|
|
238
|
-
if status in ("mismatch", "missing-target"):
|
|
299
|
+
if status in ("mismatch", "missing-target", "busy"):
|
|
239
300
|
return 2
|
|
240
301
|
return 1
|
|
241
302
|
|
|
@@ -298,14 +359,6 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
298
359
|
|
|
299
360
|
|
|
300
361
|
def main(argv: list[str] | None = None) -> int:
|
|
301
|
-
if sys.platform.startswith("win"):
|
|
302
|
-
print(
|
|
303
|
-
"error: native Windows symlinks require admin/developer mode; "
|
|
304
|
-
"use WSL2 to run update-skill (same constraint as "
|
|
305
|
-
"skf-atomic-write.py flip-link)",
|
|
306
|
-
file=sys.stderr,
|
|
307
|
-
)
|
|
308
|
-
return 1
|
|
309
362
|
parser = _build_parser()
|
|
310
363
|
args = parser.parse_args(argv)
|
|
311
364
|
return args.func(args)
|
|
@@ -52,6 +52,7 @@ from __future__ import annotations
|
|
|
52
52
|
|
|
53
53
|
import argparse
|
|
54
54
|
import datetime
|
|
55
|
+
import importlib.util
|
|
55
56
|
import json
|
|
56
57
|
import sys
|
|
57
58
|
from pathlib import Path
|
|
@@ -62,6 +63,23 @@ from jsonschema import Draft202012Validator
|
|
|
62
63
|
|
|
63
64
|
|
|
64
65
|
SCHEMA_PATH = Path(__file__).resolve().parent / "schemas" / "skill-brief.v1.json"
|
|
66
|
+
RESOLVER_PATH = Path(__file__).resolve().parent / "skf-resolve-authoritative-files.py"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_resolver():
|
|
70
|
+
"""skf-resolve-authoritative-files.py from this folder: its
|
|
71
|
+
parse_brief_yaml reads a brief's YAML, an SKF 1.x brief's trailing `---`
|
|
72
|
+
included. A missing file raises OSError, which skf-quick-batch.py
|
|
73
|
+
reports as a validator that cannot be loaded."""
|
|
74
|
+
spec = importlib.util.spec_from_file_location("skf_resolve_authoritative_files", RESOLVER_PATH)
|
|
75
|
+
if spec is None or spec.loader is None:
|
|
76
|
+
raise ImportError(f"cannot load {RESOLVER_PATH.name} beside {Path(__file__).name}")
|
|
77
|
+
module = importlib.util.module_from_spec(spec)
|
|
78
|
+
spec.loader.exec_module(module)
|
|
79
|
+
return module
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
_RESOLVER = _load_resolver()
|
|
65
83
|
|
|
66
84
|
|
|
67
85
|
# --------------------------------------------------------------------------
|
|
@@ -77,7 +95,7 @@ def load_schema() -> dict:
|
|
|
77
95
|
def load_brief_text(text: str) -> tuple[dict | None, str | None]:
|
|
78
96
|
"""Parse YAML text. Returns (brief, error_message)."""
|
|
79
97
|
try:
|
|
80
|
-
brief =
|
|
98
|
+
brief = _RESOLVER.parse_brief_yaml(text)
|
|
81
99
|
except yaml.YAMLError as exc:
|
|
82
100
|
return None, f"Brief is not valid YAML: {exc}"
|
|
83
101
|
if brief is None:
|
|
@@ -146,6 +146,14 @@ def validate_body_structure(content):
|
|
|
146
146
|
return issues
|
|
147
147
|
|
|
148
148
|
|
|
149
|
+
# The context snippet's length bounds, in `len(text)//4` tokens. The ceiling is
|
|
150
|
+
# the hard 300-token ceiling export-skill's generate-snippet.md §4 sets (a Deep
|
|
151
|
+
# snippet may pass the ~80-120 target when its gotchas carry load-bearing
|
|
152
|
+
# breaking-change notices), so a snippet that step accepts is never flagged.
|
|
153
|
+
SNIPPET_TOKEN_FLOOR = 40
|
|
154
|
+
SNIPPET_TOKEN_CEILING = 300
|
|
155
|
+
|
|
156
|
+
|
|
149
157
|
def validate_context_snippet(content):
|
|
150
158
|
"""Validate context-snippet.md format. Returns list of issues."""
|
|
151
159
|
issues = []
|
|
@@ -169,10 +177,11 @@ def validate_context_snippet(content):
|
|
|
169
177
|
|
|
170
178
|
# Approximate token count (rough: ~4 chars per token)
|
|
171
179
|
approx_tokens = len(content) // 4
|
|
172
|
-
if approx_tokens <
|
|
180
|
+
if approx_tokens < SNIPPET_TOKEN_FLOOR:
|
|
173
181
|
issues.append({"severity": "low", "field": "length", "message": f"Context snippet may be too short (~{approx_tokens} tokens)"})
|
|
174
|
-
elif approx_tokens >
|
|
175
|
-
issues.append({"severity": "low", "field": "length",
|
|
182
|
+
elif approx_tokens > SNIPPET_TOKEN_CEILING:
|
|
183
|
+
issues.append({"severity": "low", "field": "length",
|
|
184
|
+
"message": f"Context snippet is above the {SNIPPET_TOKEN_CEILING}-token ceiling (~{approx_tokens} tokens)"})
|
|
176
185
|
|
|
177
186
|
return issues
|
|
178
187
|
|