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
@@ -15,18 +15,34 @@ ratios and the gap records of the wrong signatures.
15
15
 
16
16
  plan the comparisons to make. For each documented export with a
17
17
  signature (an inventory entry of any kind but `method` that carries
18
- `params`, `return_type` or `usage_signature`) that the source
19
- surface lists, grouped by source file, with the line that defines
20
- it in the source and the documented signature. Also the whole
21
- documented-signature map, for the fallback scan that has no surface
22
- lines yet.
18
+ `params`, `return_type` or `usage_signature`; a function's `params`
19
+ counts when empty, its form for one that takes none) in the
20
+ signature universe, grouped by source file, with the line that
21
+ defines it in the source and the documented signature (with the
22
+ entry's `signature_form` when it has one; of several entries of one
23
+ name, one that lists parameters or gives a usage form before one
24
+ that does neither, then a full one before a partial one, then, of
25
+ two full or two partial forms, one that lists parameters before one
26
+ that gives only a usage form, else the first). The signature
27
+ universe is the surface's `all` set plus every name on its export
28
+ records, so the names of a Python sub-package load-coverage-inputs.py
29
+ keeps out of `all` (excluded.nestedEntries) keep their checks. A check sits at the
30
+ name's first surface record (one with a file before one with none
31
+ or a blank one). For a name the surface records in more than one
32
+ file, --skill-dir (the skill package) picks the record whose file
33
+ the skill cites on a line that names it
34
+ (validate-inventory.py's _skill_cited_files) when exactly one of
35
+ those files is cited; otherwise the check stays at the first record
36
+ and lists every record with a file as `candidates` for the judge to
37
+ read. Also the whole documented-signature map, for the fallback scan
38
+ that has no surface lines yet.
23
39
  score the categories. Reads every subagent result, checks its schema,
24
40
  and computes:
25
41
  Signature Accuracy = matching / compared * 100, where compared is
26
- the documented signatures the whole surface lists (the `all`
27
- set, with a fallback scan's exports_found) and matching those
28
- no result names in signature_mismatches[]: a wrong signature is
29
- wrong whichever set the denominator counts
42
+ the documented signatures of the signature universe (with a
43
+ fallback scan's exports_found) and matching those no result
44
+ names in a Critical mismatch: a wrong signature is wrong
45
+ whichever set the denominator counts
30
46
  Type Coverage = documented types / total types * 100, where
31
47
  total types counts the names of the --surface-set set (the
32
48
  denominator set coverage-check section 2b picked) whose kind is
@@ -35,18 +51,43 @@ ratios and the gap records of the wrong signatures.
35
51
  interface) and documented types those the inventory documents
36
52
  A category with nothing to compare scores 100 (nothing documented
37
53
  is wrong) and says so in `warnings`. Each signature mismatch of a
38
- compared name becomes a Critical `signature-mismatch` record in the
39
- gap ledger's record format (gap-ledger.py append --input reads
40
- --gaps-output); a mismatch a result reports for any other name (one
41
- the skill does not document, or the surface lacks) is listed in
42
- `warnings` instead, and no gap.
54
+ compared name is graded (grade()) and becomes a record in the gap
55
+ ledger's record format (gap-ledger.py append --input reads
56
+ --gaps-output). The judge's `difference` tag alone never lowers a
57
+ grade: the script checks the fact each one rests on.
58
+ Low `signature-mismatch` `difference` "parameter-name" reported
59
+ at a file the plan offered for the name (the file it sent the
60
+ check to, or a candidate), when that file is of a language
61
+ whose callers cannot pass an argument by its name
62
+ (POSITIONAL_EXTENSIONS: TS/JS, Go, Rust, Java, C, C++; never
63
+ Python, whose positional-only parameters the script cannot see,
64
+ nor a Vue component, whose props callers set by name)
65
+ Info `observation`, titled `Partial signature: {name}`
66
+ `difference` "omitted-optional" on a documented signature whose
67
+ inventory entry is `signature_form` "partial"
68
+ Critical `signature-mismatch` every other mismatch
69
+ A mismatch's `file` (normalized: `/` separated, no `./`) is the file
70
+ it is graded and recorded at (the gap's Source) when the plan
71
+ offered that file for the name: the file of its check or one of the
72
+ check's candidates, read from --plan, else every file the surface
73
+ records for the name. Any other `file` is listed in `warnings`, and
74
+ the result's `file` stands, as it does when there is none. Results
75
+ that report one declaration (name, file and line) more than once
76
+ keep its most severe grade: Critical, then Low, then Info. Of the
77
+ mismatches of a check that offered several files, the least severe
78
+ stands (the closest candidate), the first by file and line on a
79
+ tie: one documented signature, one gap. A Low or an Info mismatch
80
+ still counts as matching. A mismatch a result reports for any
81
+ other name (one the skill does not document, or the surface lacks)
82
+ is listed in `warnings` instead, and no gap.
43
83
 
44
84
  Inputs, all files in the run folder:
45
85
  --inventory the validated inventory validate-inventory.py wrote (--output)
46
86
  --surface load-coverage-inputs.py surface output: `exports[]` (name,
47
87
  kind, file, line, signatureLine) and the name `sets`;
48
- --surface-set picks the set (default "all"): plan's
49
- comparisons and score's Type Coverage
88
+ --surface-set picks the set (default "all") score's Type
89
+ Coverage counts, while plan and Signature Accuracy compare
90
+ the signature universe whichever set it picks
50
91
  --results a subagent result, as the subagent returned it (a wrapping
51
92
  markdown fence is stripped), repeatable. One object, or a
52
93
  list of them:
@@ -54,28 +95,45 @@ Inputs, all files in the run folder:
54
95
  "exports_found": ["..."], (fallback scan only)
55
96
  "types_found": ["..."], (fallback scan only)
56
97
  "signature_mismatches": [
57
- {"name", "line", "source_sig", "documented_sig", "issue"}]}
98
+ {"name", "line", "source_sig", "documented_sig", "issue",
99
+ "difference", "file"}]} (difference optional: one of
100
+ DIFFERENCES, absent or null for none;
101
+ file optional: the candidate the
102
+ mismatch was found at, a non-empty
103
+ string, absent or null for the
104
+ result's `file`)
105
+ --skill-dir plan only: the skill package under test (the folder that
106
+ holds SKILL.md), whose citations pick the declaration of a
107
+ name the surface records in several files
108
+ --plan score only: the plan's output, whose checks name the files a
109
+ mismatch of each name may be recorded at
58
110
 
59
111
  Rounding is compute-score.py's (JS-compatible half-up, two decimals).
60
112
 
61
113
  Usage:
62
- uv run score-signatures.py plan --inventory <file> [--surface <file>] [--output <file>]
63
- uv run score-signatures.py score --inventory <file> --surface <file> [--surface-set <set>] \\
114
+ uv run score-signatures.py plan --inventory <file> [--surface <file>] [--skill-dir <dir>] [--output <file>]
115
+ uv run score-signatures.py score --inventory <file> --surface <file> [--surface-set <set>] [--plan <file>] \\
64
116
  --results <file> [--results <file> ...] [--output <file>] [--gaps-output <file>]
65
117
 
66
118
  Output (stdout; --output also writes it, --gaps-output writes the records):
67
119
  plan: {"compared": N, "files": [{"file", "checks": [{"name", "line",
68
- "signatureLine", "documented": {...}}]}], "documentedSignatures": {...}}
120
+ "signatureLine", "documented": {...}, "candidates": [{"file",
121
+ "line", "signatureLine"}] (only when the citation does not settle
122
+ the declaration)}]}], "documentedSignatures": {...}}
69
123
  score: {"valid": true, "signatureAccuracy", "matchingSignatures",
70
- "totalDocumented", "typeCoverage", "documentedTypes", "totalTypes",
71
- "missingTypes": [...], "mismatches": [...], "gapRecords": [...],
124
+ "totalDocumented", "comparedNames": [the names compared],
125
+ "typeCoverage", "documentedTypes", "totalTypes",
126
+ "missingTypes": [...], "mismatches": [{"name", "file", "line",
127
+ "source_sig", "documented_sig", "issue", "difference" (null for
128
+ none), "severity" (Critical, Low or Info)}], "gapRecords": [...],
72
129
  "warnings": [...]}
73
130
  or {"valid": false, "violations": [...]} when a result breaks the schema
74
131
 
75
132
  Exit codes:
76
133
  0 the result was printed
77
- 1 an input file cannot be read or is not the JSON it should be, or the
78
- surface has no --surface-set set
134
+ 1 an input file cannot be read or is not the JSON it should be, the
135
+ surface has no --surface-set set, or the --skill-dir folder holds no
136
+ readable SKILL.md
79
137
  2 a subagent result breaks the schema (violations[] names each breach;
80
138
  nothing is written to --output or --gaps-output)
81
139
  """
@@ -87,13 +145,28 @@ import importlib.util
87
145
  import json
88
146
  import math
89
147
  import sys
90
- from pathlib import Path
148
+ from pathlib import Path, PurePosixPath
91
149
 
92
150
  # Surface kinds that count toward total_types. A Rust struct is a class and a
93
151
  # trait an interface; `type` is a type alias.
94
152
  TYPE_KINDS = frozenset({"interface", "type", "enum", "class", "struct", "trait"})
95
153
  SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
96
154
  MISMATCH_FIELDS = ("name", "line", "source_sig", "documented_sig", "issue")
155
+ # The optional `difference` a judge tags a mismatch with (coverage-check.md
156
+ # section 2): only the parameter names differ, or only members the documented
157
+ # form leaves out that the source marks optional.
158
+ DIFFERENCES = ("parameter-name", "omitted-optional")
159
+ # Source files of the languages with no keyword arguments, where a caller
160
+ # never writes a parameter's name: TS/JS, Go, Rust, Java, C and C++. Not
161
+ # Python, which has keyword arguments (the script cannot see which
162
+ # parameters a caller must pass by position), nor Kotlin, C#, Swift or
163
+ # Scala, which name arguments at the call, nor a Vue component, whose
164
+ # documented parameters are props callers set by name.
165
+ POSITIONAL_EXTENSIONS = frozenset({".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs", ".go", ".rs",
166
+ ".java", ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp"})
167
+ # The grades of a mismatch, most severe first: one declaration reported
168
+ # twice keeps the most severe.
169
+ GRADES = ("Critical", "Low", "Info")
97
170
 
98
171
  _INVENTORY_MODULE = None
99
172
 
@@ -164,6 +237,29 @@ def load_surface(path: str, surface_set: str) -> tuple[list[str], list[dict], li
164
237
  return name_set(surface_set), [e for e in exports if isinstance(e, dict)], name_set("all")
165
238
 
166
239
 
240
+ def load_plan(path: str) -> dict[str, list[str]]:
241
+ """{name: the files plan offered its check at, the planned one first,
242
+ then its candidates'} from plan's output."""
243
+ data = _read_json(path, "--plan")
244
+ groups = data.get("files") if isinstance(data, dict) else None
245
+ if not isinstance(groups, list):
246
+ raise InputError(f"--plan {path} is not score-signatures.py plan output (no `files` list)")
247
+ offered: dict[str, list[str]] = {}
248
+ for group in groups:
249
+ checks = group.get("checks") if isinstance(group, dict) else None
250
+ for check in checks if isinstance(checks, list) else []:
251
+ if not (isinstance(check, dict) and isinstance(check.get("name"), str)):
252
+ continue
253
+ files = [str(group.get("file") or "")]
254
+ candidates = check.get("candidates")
255
+ for candidate in candidates if isinstance(candidates, list) else []:
256
+ file = candidate.get("file") if isinstance(candidate, dict) else None
257
+ if isinstance(file, str) and file.strip() and file not in files:
258
+ files.append(file)
259
+ offered[check["name"]] = files
260
+ return offered
261
+
262
+
167
263
  def check_result(item: object, where: str) -> list[str]:
168
264
  """The schema breaches of one per-file result object."""
169
265
  if not isinstance(item, dict):
@@ -194,6 +290,12 @@ def check_result(item: object, where: str) -> list[str]:
194
290
  for field in ("name", "source_sig", "documented_sig", "issue"):
195
291
  if not (isinstance(entry[field], str) and entry[field].strip()):
196
292
  errors.append(f"{at}: `{field}` must be a non-empty string")
293
+ if entry.get("difference") is not None and entry["difference"] not in DIFFERENCES:
294
+ errors.append(f"{at}: `difference` must be one of {', '.join(DIFFERENCES)} when present, "
295
+ f"not {json.dumps(entry['difference'])}")
296
+ # The candidate a mismatch was found at, when the plan handed the judge several.
297
+ if entry.get("file") is not None and not (isinstance(entry["file"], str) and entry["file"].strip()):
298
+ errors.append(f"{at}: `file` must be a non-empty string when present, not {json.dumps(entry['file'])}")
197
299
  return errors
198
300
 
199
301
 
@@ -227,21 +329,66 @@ def load_results(paths: list[str]) -> tuple[list[dict], list[str]]:
227
329
 
228
330
 
229
331
  def _has_signature(entry: dict) -> bool:
332
+ """`params`, `return_type` or `usage_signature` as a list or a non-blank
333
+ string, or, for a kind the inventory contract gives `params`
334
+ (validate-inventory.py's SIGNATURE_KINDS), an empty `params` string: the
335
+ form of one that takes none."""
336
+ if entry.get("kind") in _inventory().SIGNATURE_KINDS and isinstance(entry.get("params"), str):
337
+ return True
230
338
  return any(
231
339
  isinstance(entry.get(f), list) or (isinstance(entry.get(f), str) and entry[f].strip())
232
340
  for f in SIGNATURE_FIELDS
233
341
  )
234
342
 
235
343
 
344
+ def _partial(fields: dict) -> bool:
345
+ return fields.get("signature_form") == "partial"
346
+
347
+
348
+ def _lists_params(fields: dict) -> bool:
349
+ params = fields.get("params")
350
+ return (isinstance(params, str) and bool(params.strip())) or (isinstance(params, list) and bool(params))
351
+
352
+
353
+ def _gives_usage(fields: dict) -> bool:
354
+ """A non-blank `usage_signature` string, or a list holding one."""
355
+ usage = fields.get("usage_signature")
356
+ return any(isinstance(u, str) and bool(u.strip()) for u in (usage if isinstance(usage, list) else [usage]))
357
+
358
+
359
+ def _lists_members(fields: dict) -> bool:
360
+ """A non-blank `params` string or non-empty list, or a non-blank
361
+ `usage_signature` string or a list holding one: an entry that says what
362
+ the name takes (a component's usage form names its props even when its
363
+ `params` is empty)."""
364
+ return _lists_params(fields) or _gives_usage(fields)
365
+
366
+
367
+ def _rank(fields: dict) -> tuple[bool, bool, bool]:
368
+ """How one of several entries of a name ranks, lowest compared first."""
369
+ return not _lists_members(fields), _partial(fields), not _lists_params(fields)
370
+
371
+
236
372
  def documented_signatures(inventory: list[dict]) -> dict[str, dict]:
237
- """{name: its documented signature fields} for each documented export with one."""
373
+ """{name: its documented signature fields} for each documented export
374
+ with one, with the entry's `signature_form` when it has one. Of several
375
+ entries of one name, one that lists parameters or gives a usage form
376
+ (_lists_members) is compared before one that does neither (empty or no
377
+ `params` and no usage form: it never hides one that says what the name
378
+ takes), then a full one (no `signature_form`, or `full`) before a
379
+ partial one (a form the skill also gives in full is held to it), then,
380
+ of two full or two partial forms, one that lists parameters is
381
+ compared before one that gives only a usage form, else the first."""
238
382
  out: dict[str, dict] = {}
239
383
  for entry in inventory:
240
384
  name = entry.get("name")
241
- if entry.get("kind") == "method" or not (isinstance(name, str) and name) or name in out:
385
+ if entry.get("kind") == "method" or not (isinstance(name, str) and name) or not _has_signature(entry):
242
386
  continue
243
- if _has_signature(entry):
244
- out[name] = {f: entry[f] for f in SIGNATURE_FIELDS if f in entry}
387
+ fields = {f: entry[f] for f in SIGNATURE_FIELDS if f in entry}
388
+ if entry.get("signature_form") is not None:
389
+ fields["signature_form"] = entry["signature_form"]
390
+ if name not in out or _rank(fields) < _rank(out[name]):
391
+ out[name] = fields
245
392
  return out
246
393
 
247
394
 
@@ -250,30 +397,79 @@ def documented_names(inventory: list[dict]) -> set[str]:
250
397
  if isinstance(e.get("name"), str) and e["name"] and e.get("kind") != "method"}
251
398
 
252
399
 
400
+ def _record_file(record: dict) -> str:
401
+ """A surface record's file, "" for none or a blank one."""
402
+ file = str(record.get("file") or "")
403
+ return file if file.strip() else ""
404
+
405
+
253
406
  def _records_by_name(exports: list[dict]) -> dict[str, list[dict]]:
407
+ """{name: its surface records}, by file then line, a record with no file
408
+ last: a by-eye record never stands in for the file an extraction names."""
254
409
  by_name: dict[str, list[dict]] = {}
255
- for record in sorted(exports, key=lambda r: (str(r.get("file") or ""), r.get("line") or 0)):
410
+ for record in sorted(exports, key=lambda r: (not _record_file(r), _record_file(r), r.get("line") or 0)):
256
411
  name = record.get("name")
257
412
  if isinstance(name, str) and name:
258
413
  by_name.setdefault(name, []).append(record)
259
414
  return by_name
260
415
 
261
416
 
262
- def plan(inventory: list[dict], names: list[str] | None, exports: list[dict]) -> dict:
417
+ def signature_universe(names: list[str], exports: list[dict]) -> set[str]:
418
+ """The names whose documented signature is compared: the `all` set
419
+ (`names`) plus every name on the surface's export records. A nested
420
+ Python top's names leave `all` but keep their records, so a documented
421
+ one still has its signature checked."""
422
+ return set(names) | set(_records_by_name(exports))
423
+
424
+
425
+ def _path(file: str) -> str:
426
+ """A path as a citation, a candidate and a surface record compare (validate-inventory.py's _cited_path)."""
427
+ return _inventory()._cited_path(file)
428
+
429
+
430
+ def _check_at(name: str, records: list[dict], skill_dir: str | None,
431
+ texts: list[list[str]] | None) -> tuple[dict, list[dict] | None]:
432
+ """(the surface record the check of `name` is planned at, the candidates
433
+ the judge reads, or None). One file among `records`: the first record.
434
+ Several: the record whose file the skill cites on a line that names
435
+ `name` when exactly one of them is cited (the first such record), else
436
+ the first record, with every record that names a file as a candidate.
437
+ `texts` is the skill's Markdown, read once (skill_markdown_lines)."""
438
+ first = records[0] if records else {}
439
+ filed = [r for r in records if _record_file(r)]
440
+ if len(filed) < 2 or len({_path(_record_file(r)) for r in filed}) < 2:
441
+ return first, None
442
+ if skill_dir is not None:
443
+ cited = {_path(_record_file(r)) for r in filed} & _inventory()._skill_cited_files(Path(skill_dir), name, texts)
444
+ if len(cited) == 1:
445
+ return next(r for r in filed if _path(_record_file(r)) in cited), None
446
+ return first, [{"file": _record_file(r), "line": r.get("line"), "signatureLine": r.get("signatureLine")}
447
+ for r in filed]
448
+
449
+
450
+ def plan(inventory: list[dict], names: list[str] | None, exports: list[dict], skill_dir: str | None = None) -> dict:
451
+ """The comparisons over the signature universe of `names` (the `all`
452
+ set) and `exports`; none without a surface (`names` None). `skill_dir`,
453
+ the skill package, settles which declaration a name the surface records
454
+ in several files is compared with (_check_at)."""
263
455
  signatures = documented_signatures(inventory)
264
456
  files: dict[str, list[dict]] = {}
265
457
  compared = 0
266
458
  if names is not None:
267
459
  by_name = _records_by_name(exports)
268
- for name in sorted(set(names) & set(signatures)):
269
- record = (by_name.get(name) or [{}])[0]
460
+ texts = _inventory().skill_markdown_lines(Path(skill_dir)) if skill_dir is not None else None
461
+ for name in sorted(signature_universe(names, exports) & set(signatures)):
462
+ record, candidates = _check_at(name, by_name.get(name) or [], skill_dir, texts)
270
463
  compared += 1
271
- files.setdefault(str(record.get("file") or ""), []).append({
464
+ check = {
272
465
  "name": name,
273
466
  "line": record.get("line"),
274
467
  "signatureLine": record.get("signatureLine"),
275
468
  "documented": signatures[name],
276
- })
469
+ }
470
+ if candidates:
471
+ check["candidates"] = candidates
472
+ files.setdefault(_record_file(record), []).append(check)
277
473
  return {
278
474
  "compared": compared,
279
475
  "files": [{"file": f, "checks": checks} for f, checks in sorted(files.items())],
@@ -281,52 +477,140 @@ def plan(inventory: list[dict], names: list[str] | None, exports: list[dict]) ->
281
477
  }
282
478
 
283
479
 
480
+ def _extension(file: str) -> str:
481
+ """The lower-case extension of a source path, `/` or `\\` separated."""
482
+ return PurePosixPath(file.replace("\\", "/")).suffix.lower()
483
+
484
+
485
+ def _offered_files(offered: dict[str, list[str]] | None, by_name: dict[str, list[dict]], name: str) -> list[str]:
486
+ """The files a mismatch of `name` may be recorded at, the planned one
487
+ first: the plan's check of `name` (its file, then its candidates') when
488
+ `offered` (read from --plan) holds it, else every file the surface
489
+ records for `name` ("" for a record with none); [] for a name the
490
+ surface lacks (a fallback scan's exports_found name)."""
491
+ if offered is not None and name in offered:
492
+ return offered[name]
493
+ files: list[str] = []
494
+ for record in by_name.get(name) or []:
495
+ if _record_file(record) not in files:
496
+ files.append(_record_file(record))
497
+ return files
498
+
499
+
500
+ def _is_offered_file(files: list[str], file: str) -> bool:
501
+ """Whether `file` is one of `files` (_offered_files), however either writes its path."""
502
+ return _path(file) in {_path(f) for f in files}
503
+
504
+
505
+ def grade(difference: str | None, file: str, documented: dict, declared: str | None = None) -> str:
506
+ """The severity of one mismatch the result reports at `file`. The judge's
507
+ `difference` lowers it only where the script confirms the fact it rests
508
+ on: a parameter-name difference is Low when `declared`, the file the
509
+ plan offered the check at (else `file`), is the mismatch's file and of a
510
+ language with no keyword arguments; an omitted-optional difference on a
511
+ form the inventory marks partial is Info; every other mismatch is
512
+ Critical."""
513
+ if difference == "parameter-name":
514
+ at = file if declared is None else declared
515
+ if at.replace("\\", "/") == file.replace("\\", "/") and _extension(at) in POSITIONAL_EXTENSIONS:
516
+ return "Low"
517
+ if difference == "omitted-optional" and _partial(documented):
518
+ return "Info"
519
+ return "Critical"
520
+
521
+
284
522
  def _gap_record(m: dict) -> dict:
285
523
  name, where = m["name"], f"{m['file']}:{m['line']}"
524
+ issue = f"documented `{m['documented_sig']}`, source `{m['source_sig']}`: {m['issue']}"
525
+ remediation = (f"Update the documented signature of `{name}` from `{m['documented_sig']}` "
526
+ f"to `{m['source_sig']}`, the signature the source defines at `{where}`.")
527
+ if m["severity"] == "Info":
528
+ return {
529
+ "severity": "Info",
530
+ "category": "observation",
531
+ "title": f"Partial signature: {name}",
532
+ "source": where,
533
+ "export": name,
534
+ "issue": (f"the skill labels its documented form of `{name}` partial, and it leaves out only "
535
+ f"members the source marks optional: {issue}"),
536
+ "remediation": (f"No change: the documented form of `{name}` is labelled partial and leaves out "
537
+ f"only optional members of the signature the source defines at `{where}`."),
538
+ }
539
+ if m["severity"] == "Low":
540
+ issue = (f"{issue} (only the parameter names differ, and a caller of a `{_extension(m['file'])}` "
541
+ "file passes its arguments by position)")
286
542
  return {
287
- "severity": "Critical",
543
+ "severity": m["severity"],
288
544
  "category": "signature-mismatch",
289
545
  "title": f"Signature mismatch: {name}",
290
546
  "source": where,
291
547
  "export": name,
292
- "issue": f"documented `{m['documented_sig']}`, source `{m['source_sig']}`: {m['issue']}",
293
- "remediation": (f"Update the documented signature of `{name}` from `{m['documented_sig']}` "
294
- f"to `{m['source_sig']}`, the signature the source defines at `{where}`."),
548
+ "issue": issue,
549
+ "remediation": remediation,
295
550
  }
296
551
 
297
552
 
298
553
  def score(inventory: list[dict], names: list[str], exports: list[dict], results: list[dict],
299
- type_names: list[str] | None = None) -> dict:
300
- """The two categories: Signature Accuracy over `names` (the whole surface),
301
- Type Coverage over `type_names` when given, else over `names` too."""
554
+ type_names: list[str] | None = None, offered: dict[str, list[str]] | None = None) -> dict:
555
+ """The two categories: Signature Accuracy over the signature universe of
556
+ `names` (the `all` set) and `exports`, Type Coverage over `type_names`
557
+ when given, else over `names`. A fallback scan's exports_found join the
558
+ universe, and `names` when no `type_names` is given. `offered` (read
559
+ from --plan) names the files the plan offered each check at."""
302
560
  by_name = _records_by_name(exports)
303
561
  # A fallback scan reports its own names and types.
304
562
  fallback_types = {n for r in results for n in r.get("types_found") or []}
305
563
  surface = set(names) | {n for r in results for n in r.get("exports_found") or []}
306
564
  documented = documented_names(inventory)
307
565
  signatures = documented_signatures(inventory)
308
- compared = surface & set(signatures)
566
+ compared = (surface | signature_universe(names, exports)) & set(signatures)
309
567
  warnings = []
310
568
 
311
- mismatches = []
569
+ graded: dict[tuple, dict] = {}
312
570
  seen = set()
313
571
  for result in results:
314
572
  for entry in result["signature_mismatches"]:
315
- key = (entry["name"], result["file"], entry["line"])
316
- if key in seen:
573
+ name = entry["name"]
574
+ files = _offered_files(offered, by_name, name)
575
+ # A mismatch found at one of a check's candidates names that file; any other file is not taken.
576
+ file = result["file"]
577
+ if entry.get("file") is not None:
578
+ if _is_offered_file(files, entry["file"]):
579
+ file = entry["file"]
580
+ elif name in compared:
581
+ warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for `{name}` names "
582
+ f"`{entry['file']}`, no file the plan offered its check at: recorded at "
583
+ "the result's file")
584
+ file = _path(file)
585
+ key = (name, file, entry["line"])
586
+ if name not in compared:
587
+ if key not in seen:
588
+ warnings.append(f"{file}:{entry['line']}: a signature mismatch for "
589
+ f"`{name}`, which is no documented signature on the source surface: "
590
+ "not scored, no gap")
591
+ seen.add(key)
317
592
  continue
318
- seen.add(key)
319
- if entry["name"] not in compared:
320
- warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for "
321
- f"`{entry['name']}`, which is no documented signature on the source surface: "
322
- "not scored, no gap")
593
+ difference = entry.get("difference")
594
+ # Graded at the file only when the plan offered it: never Low at a namesake it did not offer.
595
+ declared = (file if _is_offered_file(files, file) else files[0]) if files else None
596
+ severity = grade(difference, file, signatures[name], declared)
597
+ # Every entry is graded: one declaration reported twice keeps its most severe grade.
598
+ if key in graded and GRADES.index(graded[key]["severity"]) <= GRADES.index(severity):
323
599
  continue
324
- mismatches.append({"name": entry["name"], "file": result["file"], "line": entry["line"],
325
- "source_sig": entry["source_sig"], "documented_sig": entry["documented_sig"],
326
- "issue": entry["issue"]})
327
- mismatches.sort(key=lambda m: (m["file"], m["line"], m["name"]))
328
-
329
- wrong = {m["name"] for m in mismatches}
600
+ graded[key] = {"name": name, "file": file, "line": entry["line"],
601
+ "source_sig": entry["source_sig"], "documented_sig": entry["documented_sig"],
602
+ "issue": entry["issue"], "difference": difference, "severity": severity}
603
+ # A check that offered several files keeps one mismatch, its least severe (the closest candidate).
604
+ mismatches, closest = [], {}
605
+ for m in sorted(graded.values(), key=lambda m: (m["file"], m["line"], m["name"])):
606
+ if len({_path(f) for f in _offered_files(offered, by_name, m["name"])}) < 2:
607
+ mismatches.append(m)
608
+ elif m["name"] not in closest or GRADES.index(m["severity"]) > GRADES.index(closest[m["name"]]["severity"]):
609
+ closest[m["name"]] = m
610
+ mismatches = sorted(mismatches + list(closest.values()), key=lambda m: (m["file"], m["line"], m["name"]))
611
+
612
+ # A Low or an Info mismatch is a caller-invisible difference: it still matches.
613
+ wrong = {m["name"] for m in mismatches if m["severity"] == "Critical"}
330
614
  if compared:
331
615
  signature_accuracy = round2((len(compared) - len(wrong)) / len(compared) * 100)
332
616
  else:
@@ -353,6 +637,7 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
353
637
  "signatureAccuracy": signature_accuracy,
354
638
  "matchingSignatures": len(compared) - len(wrong),
355
639
  "totalDocumented": len(compared),
640
+ "comparedNames": sorted(compared),
356
641
  "typeCoverage": type_coverage,
357
642
  "documentedTypes": len(documented_types),
358
643
  "totalTypes": len(types),
@@ -378,8 +663,12 @@ def _cmd_plan(args: argparse.Namespace) -> tuple[dict, int]:
378
663
  inventory = load_inventory(args.inventory)
379
664
  names, exports = (None, [])
380
665
  if args.surface is not None:
381
- names, exports, _ = load_surface(args.surface, args.surface_set)
382
- out = plan(inventory, names, exports)
666
+ _, exports, names = load_surface(args.surface, args.surface_set)
667
+ if args.skill_dir is not None:
668
+ if not args.skill_dir.strip():
669
+ raise InputError("--skill-dir is empty: pass the skill package (the folder that holds SKILL.md)")
670
+ _read_text(str(Path(args.skill_dir) / "SKILL.md"), "--skill-dir")
671
+ out = plan(inventory, names, exports, args.skill_dir)
383
672
  if args.output:
384
673
  _write_json(args.output, out)
385
674
  return out, 0
@@ -394,7 +683,8 @@ def _cmd_score(args: argparse.Namespace) -> tuple[dict, int]:
394
683
  if path:
395
684
  Path(path).unlink(missing_ok=True)
396
685
  return {"valid": False, "violations": violations}, 2
397
- out = score(inventory, names, exports, results, None if args.surface_set == "all" else chosen)
686
+ offered = load_plan(args.plan) if args.plan is not None else None
687
+ out = score(inventory, names, exports, results, None if args.surface_set == "all" else chosen, offered)
398
688
  if args.output:
399
689
  _write_json(args.output, out)
400
690
  if args.gaps_output:
@@ -412,7 +702,11 @@ def _build_parser() -> argparse.ArgumentParser:
412
702
  p = sub.add_parser("plan", help="the signature comparisons for the subagents to make")
413
703
  p.add_argument("--inventory", required=True, help="the validated inventory file")
414
704
  p.add_argument("--surface", help="load-coverage-inputs.py surface output")
415
- p.add_argument("--surface-set", default="all", help="the surface name set (default: all)")
705
+ p.add_argument("--surface-set", default="all",
706
+ help="a surface name set, which must exist (default: all); the plan compares the signature "
707
+ "universe whichever set it names")
708
+ p.add_argument("--skill-dir", help="the skill package under test: its citations pick the declaration of a name "
709
+ "the surface records in several files")
416
710
  p.add_argument("--output", help="also write the plan to this file")
417
711
  p.set_defaults(func=_cmd_plan)
418
712
 
@@ -421,6 +715,7 @@ def _build_parser() -> argparse.ArgumentParser:
421
715
  p.add_argument("--surface", required=True, help="load-coverage-inputs.py surface output")
422
716
  p.add_argument("--surface-set", default="all",
423
717
  help="the surface name set Type Coverage counts (default: all)")
718
+ p.add_argument("--plan", help="the plan's output: the files each check offered a mismatch at")
424
719
  p.add_argument("--results", action="append", help="a subagent result file (repeatable)")
425
720
  p.add_argument("--output", help="also write the scores to this file")
426
721
  p.add_argument("--gaps-output", help="write the gap ledger records to this file")