bmad-module-skill-forge 4.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 (85) 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 +32 -25
  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/verifying-a-skill.md +41 -20
  12. package/docs/why-skf.md +1 -1
  13. package/docs/workflows.md +2 -2
  14. package/package.json +1 -1
  15. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +11 -3
  16. package/src/shared/scripts/schemas/skill-brief.v1.json +1 -1
  17. package/src/shared/scripts/skf-build-change-manifest.py +881 -168
  18. package/src/shared/scripts/skf-count-tokens.py +54 -10
  19. package/src/shared/scripts/skf-detect-workspaces.py +67 -0
  20. package/src/shared/scripts/skf-emit-result-envelope.py +6 -5
  21. package/src/shared/scripts/skf-extract-public-api.py +439 -107
  22. package/src/shared/scripts/skf-extraction-inventory.py +10 -6
  23. package/src/shared/scripts/skf-extraction-snapshot.py +6 -6
  24. package/src/shared/scripts/skf-parse-gaps.py +135 -26
  25. package/src/shared/scripts/skf-render-metadata-stats.py +1 -1
  26. package/src/shared/scripts/skf-resolve-customization.py +197 -0
  27. package/src/shared/scripts/skf-shard-body.py +239 -41
  28. package/src/shared/scripts/skf-validate-output.py +12 -3
  29. package/src/shared/scripts/skf-verify-provenance-completeness.py +470 -38
  30. package/src/shared/tool-requirements.yaml +8 -2
  31. package/src/skf-analyze-source/SKILL.md +2 -2
  32. package/src/skf-analyze-source/assets/skill-brief-schema.md +1 -1
  33. package/src/skf-audit-skill/SKILL.md +2 -2
  34. package/src/skf-audit-skill/references/re-index.md +2 -2
  35. package/src/skf-brief-skill/SKILL.md +2 -2
  36. package/src/skf-brief-skill/assets/skill-brief-schema.md +2 -2
  37. package/src/skf-brief-skill/references/scope-definition.md +4 -4
  38. package/src/skf-campaign/SKILL.md +2 -2
  39. package/src/skf-campaign/references/step-05-skill-loop.md +6 -1
  40. package/src/skf-create-skill/SKILL.md +2 -2
  41. package/src/skf-create-skill/assets/skill-sections.md +1 -1
  42. package/src/skf-create-skill/references/extract.md +2 -1
  43. package/src/skf-create-skill/references/extraction-patterns.md +3 -3
  44. package/src/skf-create-skill/references/report.md +6 -1
  45. package/src/skf-create-stack-skill/SKILL.md +2 -2
  46. package/src/skf-create-stack-skill/references/validate.md +1 -1
  47. package/src/skf-drop-skill/SKILL.md +2 -2
  48. package/src/skf-export-skill/SKILL.md +2 -2
  49. package/src/skf-export-skill/assets/snippet-format.md +5 -5
  50. package/src/skf-export-skill/references/generate-snippet.md +22 -15
  51. package/src/skf-export-skill/references/load-skill.md +1 -1
  52. package/src/skf-export-skill/references/token-report.md +29 -9
  53. package/src/skf-forger/SKILL.md +2 -2
  54. package/src/skf-quick-skill/SKILL.md +2 -2
  55. package/src/skf-refine-architecture/SKILL.md +6 -8
  56. package/src/skf-rename-skill/SKILL.md +2 -2
  57. package/src/skf-setup/SKILL.md +2 -2
  58. package/src/skf-test-skill/SKILL.md +2 -2
  59. package/src/skf-test-skill/assets/output-section-formats.md +6 -4
  60. package/src/skf-test-skill/references/coherence-check.md +4 -3
  61. package/src/skf-test-skill/references/coverage-check-branches.md +61 -0
  62. package/src/skf-test-skill/references/coverage-check-tiers.md +1 -1
  63. package/src/skf-test-skill/references/coverage-check.md +45 -69
  64. package/src/skf-test-skill/references/init.md +2 -1
  65. package/src/skf-test-skill/references/migration-section-rules.md +26 -14
  66. package/src/skf-test-skill/references/report.md +2 -2
  67. package/src/skf-test-skill/references/scoring-rules.md +5 -4
  68. package/src/skf-test-skill/references/source-access-protocol.md +6 -6
  69. package/src/skf-test-skill/scripts/gap-ledger.py +378 -163
  70. package/src/skf-test-skill/scripts/load-coverage-inputs.py +171 -70
  71. package/src/skf-test-skill/scripts/locate-export-segments.py +101 -20
  72. package/src/skf-test-skill/scripts/reconcile-coverage.py +192 -39
  73. package/src/skf-test-skill/scripts/score-signatures.py +329 -54
  74. package/src/skf-test-skill/scripts/stage-helper-payload.py +64 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +108 -9
  76. package/src/skf-test-skill/templates/test-report-template.md +1 -0
  77. package/src/skf-update-skill/SKILL.md +2 -2
  78. package/src/skf-update-skill/references/detect-changes.md +1 -1
  79. package/src/skf-update-skill/references/gap-driven.md +36 -21
  80. package/src/skf-update-skill/references/merge.md +15 -10
  81. package/src/skf-update-skill/references/re-extract.md +3 -1
  82. package/src/skf-update-skill/references/report.md +5 -4
  83. package/src/skf-update-skill/references/write.md +39 -18
  84. package/src/skf-verify-stack/SKILL.md +2 -2
  85. package/tools/cli/lib/tool-check.js +158 -2
@@ -15,20 +15,33 @@ 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`) in the signature
19
- universe, grouped by source file, with the line that defines it in
20
- the source and the documented signature. The signature universe is
21
- the surface's `all` set plus every name on its export records, so
22
- the names of a Python sub-package load-coverage-inputs.py keeps out
23
- of `all` (excluded.nestedEntries) keep their checks. Also the whole
24
- documented-signature map, for the fallback scan that has no surface
25
- 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.
26
39
  score the categories. Reads every subagent result, checks its schema,
27
40
  and computes:
28
41
  Signature Accuracy = matching / compared * 100, where compared is
29
42
  the documented signatures of the signature universe (with a
30
43
  fallback scan's exports_found) and matching those no result
31
- names in signature_mismatches[]: a wrong signature is wrong
44
+ names in a Critical mismatch: a wrong signature is wrong
32
45
  whichever set the denominator counts
33
46
  Type Coverage = documented types / total types * 100, where
34
47
  total types counts the names of the --surface-set set (the
@@ -38,11 +51,35 @@ ratios and the gap records of the wrong signatures.
38
51
  interface) and documented types those the inventory documents
39
52
  A category with nothing to compare scores 100 (nothing documented
40
53
  is wrong) and says so in `warnings`. Each signature mismatch of a
41
- compared name becomes a Critical `signature-mismatch` record in the
42
- gap ledger's record format (gap-ledger.py append --input reads
43
- --gaps-output); a mismatch a result reports for any other name (one
44
- the skill does not document, or the surface lacks) is listed in
45
- `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.
46
83
 
47
84
  Inputs, all files in the run folder:
48
85
  --inventory the validated inventory validate-inventory.py wrote (--output)
@@ -58,29 +95,45 @@ Inputs, all files in the run folder:
58
95
  "exports_found": ["..."], (fallback scan only)
59
96
  "types_found": ["..."], (fallback scan only)
60
97
  "signature_mismatches": [
61
- {"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
62
110
 
63
111
  Rounding is compute-score.py's (JS-compatible half-up, two decimals).
64
112
 
65
113
  Usage:
66
- uv run score-signatures.py plan --inventory <file> [--surface <file>] [--output <file>]
67
- 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>] \\
68
116
  --results <file> [--results <file> ...] [--output <file>] [--gaps-output <file>]
69
117
 
70
118
  Output (stdout; --output also writes it, --gaps-output writes the records):
71
119
  plan: {"compared": N, "files": [{"file", "checks": [{"name", "line",
72
- "signatureLine", "documented": {...}}]}], "documentedSignatures": {...}}
120
+ "signatureLine", "documented": {...}, "candidates": [{"file",
121
+ "line", "signatureLine"}] (only when the citation does not settle
122
+ the declaration)}]}], "documentedSignatures": {...}}
73
123
  score: {"valid": true, "signatureAccuracy", "matchingSignatures",
74
124
  "totalDocumented", "comparedNames": [the names compared],
75
125
  "typeCoverage", "documentedTypes", "totalTypes",
76
- "missingTypes": [...], "mismatches": [...], "gapRecords": [...],
126
+ "missingTypes": [...], "mismatches": [{"name", "file", "line",
127
+ "source_sig", "documented_sig", "issue", "difference" (null for
128
+ none), "severity" (Critical, Low or Info)}], "gapRecords": [...],
77
129
  "warnings": [...]}
78
130
  or {"valid": false, "violations": [...]} when a result breaks the schema
79
131
 
80
132
  Exit codes:
81
133
  0 the result was printed
82
- 1 an input file cannot be read or is not the JSON it should be, or the
83
- 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
84
137
  2 a subagent result breaks the schema (violations[] names each breach;
85
138
  nothing is written to --output or --gaps-output)
86
139
  """
@@ -92,13 +145,28 @@ import importlib.util
92
145
  import json
93
146
  import math
94
147
  import sys
95
- from pathlib import Path
148
+ from pathlib import Path, PurePosixPath
96
149
 
97
150
  # Surface kinds that count toward total_types. A Rust struct is a class and a
98
151
  # trait an interface; `type` is a type alias.
99
152
  TYPE_KINDS = frozenset({"interface", "type", "enum", "class", "struct", "trait"})
100
153
  SIGNATURE_FIELDS = ("params", "return_type", "usage_signature")
101
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")
102
170
 
103
171
  _INVENTORY_MODULE = None
104
172
 
@@ -169,6 +237,29 @@ def load_surface(path: str, surface_set: str) -> tuple[list[str], list[dict], li
169
237
  return name_set(surface_set), [e for e in exports if isinstance(e, dict)], name_set("all")
170
238
 
171
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
+
172
263
  def check_result(item: object, where: str) -> list[str]:
173
264
  """The schema breaches of one per-file result object."""
174
265
  if not isinstance(item, dict):
@@ -199,6 +290,12 @@ def check_result(item: object, where: str) -> list[str]:
199
290
  for field in ("name", "source_sig", "documented_sig", "issue"):
200
291
  if not (isinstance(entry[field], str) and entry[field].strip()):
201
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'])}")
202
299
  return errors
203
300
 
204
301
 
@@ -232,21 +329,66 @@ def load_results(paths: list[str]) -> tuple[list[dict], list[str]]:
232
329
 
233
330
 
234
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
235
338
  return any(
236
339
  isinstance(entry.get(f), list) or (isinstance(entry.get(f), str) and entry[f].strip())
237
340
  for f in SIGNATURE_FIELDS
238
341
  )
239
342
 
240
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
+
241
372
  def documented_signatures(inventory: list[dict]) -> dict[str, dict]:
242
- """{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."""
243
382
  out: dict[str, dict] = {}
244
383
  for entry in inventory:
245
384
  name = entry.get("name")
246
- 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):
247
386
  continue
248
- if _has_signature(entry):
249
- 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
250
392
  return out
251
393
 
252
394
 
@@ -255,9 +397,17 @@ def documented_names(inventory: list[dict]) -> set[str]:
255
397
  if isinstance(e.get("name"), str) and e["name"] and e.get("kind") != "method"}
256
398
 
257
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
+
258
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."""
259
409
  by_name: dict[str, list[dict]] = {}
260
- 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)):
261
411
  name = record.get("name")
262
412
  if isinstance(name, str) and name:
263
413
  by_name.setdefault(name, []).append(record)
@@ -272,23 +422,54 @@ def signature_universe(names: list[str], exports: list[dict]) -> set[str]:
272
422
  return set(names) | set(_records_by_name(exports))
273
423
 
274
424
 
275
- def plan(inventory: list[dict], names: list[str] | None, exports: list[dict]) -> dict:
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:
276
451
  """The comparisons over the signature universe of `names` (the `all`
277
- set) and `exports`; none without a surface (`names` None)."""
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)."""
278
455
  signatures = documented_signatures(inventory)
279
456
  files: dict[str, list[dict]] = {}
280
457
  compared = 0
281
458
  if names is not None:
282
459
  by_name = _records_by_name(exports)
460
+ texts = _inventory().skill_markdown_lines(Path(skill_dir)) if skill_dir is not None else None
283
461
  for name in sorted(signature_universe(names, exports) & set(signatures)):
284
- record = (by_name.get(name) or [{}])[0]
462
+ record, candidates = _check_at(name, by_name.get(name) or [], skill_dir, texts)
285
463
  compared += 1
286
- files.setdefault(str(record.get("file") or ""), []).append({
464
+ check = {
287
465
  "name": name,
288
466
  "line": record.get("line"),
289
467
  "signatureLine": record.get("signatureLine"),
290
468
  "documented": signatures[name],
291
- })
469
+ }
470
+ if candidates:
471
+ check["candidates"] = candidates
472
+ files.setdefault(_record_file(record), []).append(check)
292
473
  return {
293
474
  "compared": compared,
294
475
  "files": [{"file": f, "checks": checks} for f, checks in sorted(files.items())],
@@ -296,26 +477,86 @@ def plan(inventory: list[dict], names: list[str] | None, exports: list[dict]) ->
296
477
  }
297
478
 
298
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
+
299
522
  def _gap_record(m: dict) -> dict:
300
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)")
301
542
  return {
302
- "severity": "Critical",
543
+ "severity": m["severity"],
303
544
  "category": "signature-mismatch",
304
545
  "title": f"Signature mismatch: {name}",
305
546
  "source": where,
306
547
  "export": name,
307
- "issue": f"documented `{m['documented_sig']}`, source `{m['source_sig']}`: {m['issue']}",
308
- "remediation": (f"Update the documented signature of `{name}` from `{m['documented_sig']}` "
309
- f"to `{m['source_sig']}`, the signature the source defines at `{where}`."),
548
+ "issue": issue,
549
+ "remediation": remediation,
310
550
  }
311
551
 
312
552
 
313
553
  def score(inventory: list[dict], names: list[str], exports: list[dict], results: list[dict],
314
- type_names: list[str] | None = None) -> dict:
554
+ type_names: list[str] | None = None, offered: dict[str, list[str]] | None = None) -> dict:
315
555
  """The two categories: Signature Accuracy over the signature universe of
316
556
  `names` (the `all` set) and `exports`, Type Coverage over `type_names`
317
557
  when given, else over `names`. A fallback scan's exports_found join the
318
- universe, and `names` when no `type_names` is given."""
558
+ universe, and `names` when no `type_names` is given. `offered` (read
559
+ from --plan) names the files the plan offered each check at."""
319
560
  by_name = _records_by_name(exports)
320
561
  # A fallback scan reports its own names and types.
321
562
  fallback_types = {n for r in results for n in r.get("types_found") or []}
@@ -325,25 +566,51 @@ def score(inventory: list[dict], names: list[str], exports: list[dict], results:
325
566
  compared = (surface | signature_universe(names, exports)) & set(signatures)
326
567
  warnings = []
327
568
 
328
- mismatches = []
569
+ graded: dict[tuple, dict] = {}
329
570
  seen = set()
330
571
  for result in results:
331
572
  for entry in result["signature_mismatches"]:
332
- key = (entry["name"], result["file"], entry["line"])
333
- 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)
334
592
  continue
335
- seen.add(key)
336
- if entry["name"] not in compared:
337
- warnings.append(f"{result['file']}:{entry['line']}: a signature mismatch for "
338
- f"`{entry['name']}`, which is no documented signature on the source surface: "
339
- "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):
340
599
  continue
341
- mismatches.append({"name": entry["name"], "file": result["file"], "line": entry["line"],
342
- "source_sig": entry["source_sig"], "documented_sig": entry["documented_sig"],
343
- "issue": entry["issue"]})
344
- mismatches.sort(key=lambda m: (m["file"], m["line"], m["name"]))
345
-
346
- 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"}
347
614
  if compared:
348
615
  signature_accuracy = round2((len(compared) - len(wrong)) / len(compared) * 100)
349
616
  else:
@@ -397,7 +664,11 @@ def _cmd_plan(args: argparse.Namespace) -> tuple[dict, int]:
397
664
  names, exports = (None, [])
398
665
  if args.surface is not None:
399
666
  _, exports, names = load_surface(args.surface, args.surface_set)
400
- out = plan(inventory, names, exports)
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)
401
672
  if args.output:
402
673
  _write_json(args.output, out)
403
674
  return out, 0
@@ -412,7 +683,8 @@ def _cmd_score(args: argparse.Namespace) -> tuple[dict, int]:
412
683
  if path:
413
684
  Path(path).unlink(missing_ok=True)
414
685
  return {"valid": False, "violations": violations}, 2
415
- 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)
416
688
  if args.output:
417
689
  _write_json(args.output, out)
418
690
  if args.gaps_output:
@@ -433,6 +705,8 @@ def _build_parser() -> argparse.ArgumentParser:
433
705
  p.add_argument("--surface-set", default="all",
434
706
  help="a surface name set, which must exist (default: all); the plan compares the signature "
435
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")
436
710
  p.add_argument("--output", help="also write the plan to this file")
437
711
  p.set_defaults(func=_cmd_plan)
438
712
 
@@ -441,6 +715,7 @@ def _build_parser() -> argparse.ArgumentParser:
441
715
  p.add_argument("--surface", required=True, help="load-coverage-inputs.py surface output")
442
716
  p.add_argument("--surface-set", default="all",
443
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")
444
719
  p.add_argument("--results", action="append", help="a subagent result file (repeatable)")
445
720
  p.add_argument("--output", help="also write the scores to this file")
446
721
  p.add_argument("--gaps-output", help="write the gap ledger records to this file")