bmad-module-skill-forge 7.0.0 → 8.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 (68) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +1 -1
  3. package/docs/_data/pinned.yaml +9 -10
  4. package/docs/campaign.md +2 -2
  5. package/docs/skill-model.md +3 -3
  6. package/docs/verifying-a-skill.md +17 -18
  7. package/docs/workflows.md +7 -7
  8. package/package.json +1 -1
  9. package/src/shared/references/description-guard-protocol.md +12 -10
  10. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
  11. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
  12. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
  13. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
  14. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
  16. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
  17. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
  18. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
  19. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  20. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
  21. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
  22. package/src/shared/scripts/skf-parse-gaps.py +4 -1
  23. package/src/shared/scripts/skf-quick-batch.py +8 -4
  24. package/src/shared/scripts/skf-verify-provenance-completeness.py +727 -179
  25. package/src/skf-audit-skill/customize.toml +4 -3
  26. package/src/skf-audit-skill/references/report.md +1 -1
  27. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  28. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  29. package/src/skf-campaign/references/step-06-batch.md +2 -2
  30. package/src/skf-campaign/references/step-07-capstone.md +1 -1
  31. package/src/skf-campaign/references/step-08-verify.md +1 -1
  32. package/src/skf-campaign/references/step-09-refine.md +1 -1
  33. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  34. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  35. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  36. package/src/skf-campaign/scripts/campaign-state.py +67 -19
  37. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  38. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  39. package/src/skf-create-skill/references/report.md +2 -2
  40. package/src/skf-create-skill/references/validate.md +49 -29
  41. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  42. package/src/skf-create-stack-skill/references/report.md +3 -3
  43. package/src/skf-create-stack-skill/references/validate.md +9 -4
  44. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  45. package/src/skf-export-skill/references/summary.md +1 -1
  46. package/src/skf-quick-skill/references/batch-mode.md +28 -2
  47. package/src/skf-quick-skill/references/compile.md +2 -0
  48. package/src/skf-quick-skill/references/finalize.md +3 -2
  49. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  50. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  51. package/src/skf-quick-skill/references/write-and-validate.md +11 -5
  52. package/src/skf-refine-architecture/customize.toml +1 -1
  53. package/src/skf-refine-architecture/references/report.md +2 -2
  54. package/src/skf-rename-skill/references/report.md +1 -1
  55. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  56. package/src/skf-test-skill/references/coverage-check.md +2 -2
  57. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  58. package/src/skf-test-skill/references/report.md +1 -1
  59. package/src/skf-test-skill/references/scoring-rules.md +1 -1
  60. package/src/skf-test-skill/references/signature-comparison-rules.md +4 -1
  61. package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
  62. package/src/skf-test-skill/scripts/load-coverage-inputs.py +12 -6
  63. package/src/skf-update-skill/references/gap-driven.md +1 -1
  64. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  65. package/src/skf-update-skill/references/merge.md +1 -1
  66. package/src/skf-update-skill/references/report.md +18 -7
  67. package/src/skf-update-skill/references/write.md +24 -12
  68. package/src/skf-verify-stack/references/report.md +1 -1
@@ -4,13 +4,13 @@ Load this file only from coverage-check.md §2, and follow the one section §2 n
4
4
 
5
5
  ## Quick Tier
6
6
 
7
- - **A skill quick-skill built** (`metadata.json` `generated_by` is `quick-skill`), or **any skill in Python, JavaScript or TypeScript**, **from local source** (State 1): parse the entry points with `{extractPublicApiHelper}` `--mode quick`. quick-skill built its export list with that parser, so both sides count one surface, and in those three languages it gives each name the file that defines it (`defined_in`), so the brief's globs place the name as the recipe runner does. Resolve it ← first existing path in `{extractPublicApiProbeOrder}` and run it once per package in scope, with its manifest and one `--entry-file` for each entry-point file the Source API Surface Definition makes its surface, each path relative to `{source_path}` (`<n>` counts the packages from 1):
7
+ - **A skill quick-skill built** (`metadata.json` `generated_by` is `quick-skill`), or **any skill in Python, JavaScript or TypeScript**, **from local source** (State 1): parse the entry points with `{extractPublicApiHelper}` `--mode quick`. quick-skill built its export list with that parser, so both sides count one surface (except names quick-skill took from a README for a star re-export from another package: the parser gives that star no name, so those names read as stale), and in those three languages it gives each name the file that defines it (`defined_in`), so the brief's globs place the name as the recipe runner does. Resolve it ← first existing path in `{extractPublicApiProbeOrder}` and run it once per package in scope, with its manifest and one `--entry-file` for each entry-point file the Source API Surface Definition makes its surface, each path relative to `{source_path}` (`<n>` counts the packages from 1):
8
8
 
9
9
  ```bash
10
10
  uv run {extractPublicApiHelper} --mode quick --language <language> --source-root "{source_path}" --manifest-file <manifest path> --entry-file <entry path> > "{run_dir}/quick-<n>.json"
11
11
  ```
12
12
 
13
- Its `warnings` name each statement whose names the entry file alone cannot give. Read by eye only the statements its warnings name, save the names they give as a per-file result for the entry file that holds the statement, `{"file": "<entry path>", "exports_found": [<each name>], "signature_mismatches": []}`, and list those names in the Coverage Analysis section as read by eye. Give `surface` one `--quick` per package, one `--per-file` per saved result, the brief when the skill has one (its globs give the `scope.include` and `tier_a_include` sets), the metadata and, when init.md §2 bound one, the provenance map (the baselines of the §2b candidates and guards; they add no name):
13
+ Its `warnings` name each statement whose names the entry file alone cannot give. Read by eye only the statements its warnings name, save the names they give as a per-file result for the entry file that holds the statement, `{"file": "<entry path>", "exports_found": [<each name>], "signature_mismatches": []}`, and list those names in the Coverage Analysis section as read by eye. A warning on an `export *` or `export type *` from another package (a specifier that is no relative path; the warning writes either as `export *`) gives no name, as the recipe runner leaves such a star unresolved: list the statement as the source writes it, `<line>: <statement>`, in the `unresolved_stars` of that entry file's per-file result, and in the Coverage Analysis section as unresolved. Give `surface` one `--quick` per package, one `--per-file` per saved result that lists a name, the brief when the skill has one (its globs give the `scope.include` and `tier_a_include` sets), the metadata and, when init.md §2 bound one, the provenance map (the baselines of the §2b candidates and guards; they add no name):
14
14
 
15
15
  ```bash
16
16
  uv run {coverageInputsScript} surface --quick "{run_dir}/quick-<n>.json" [--per-file "{run_dir}/per-file-<n>.json"] [--brief "{forge_data_folder}/{skill_name}/skill-brief.yaml"] --metadata "{resolved_skill_package}/metadata.json" [--provenance "{forge_provenance_map}"] [--monorepo] [--pattern-reference-intent] [--curated-subset] [--barrel <state>] --output "{run_dir}/surface.json"
@@ -26,7 +26,7 @@ The recipe runner cannot be used, so subagents read the files in scope one at a
26
26
  uv run {scoreSignaturesScript} plan --inventory "{run_dir}/inventory.json" --output "{run_dir}/signature-plan.json"
27
27
  ```
28
28
 
29
- For each source file that defines public API exports, delegate a subagent that gets that map with the file and the absolute path of `{signatureRulesFile}` (under `{skill-root}`). It gets the `documentedAlternatives` map too, whose forms it compares as that file says. It extracts the symbols the file exports, with their full signatures, with ast-grep (or reads the file when `extraction.fallback.reason` says no ast-grep can run; list those files in the Coverage Analysis section as read by eye), leaves out a name the file only passes on from another module of the source being tested (`export { x } from './x'`, `export * from`, an `__init__.py` import, a Rust `pub use`), which that module's own result counts, keeps a name it passes on from another package (`export { z } from 'zod'`), which no other result counts, compares each with the map's entry by the rules of that file (overload sets and `difference` tags included), gives each mismatch the `line` that defines the export, and returns only `{"file", "exports_found": [...], "types_found": [<the exported interfaces, type aliases, enums and classes>], "signature_mismatches": [...]}`, each mismatch in the shape §2's signature comparison returns. Save each response to `{run_dir}/per-file-<n>.txt` with the Write tool and build the surface from them, one `--per-file` each and `--fallback-scan` (each names the file that defines its exports, so a `scope.tier_a_include` list that counts none of them does not turn the guards off), with the same baselines as at Quick tier, and `--extraction` when the runner's JSON sent you here (`extraction.fallback.needed`): it adds no name, only its scope and the globs it lists as matching no file, so the guards still run:
29
+ For each source file that defines public API exports, delegate a subagent that gets that map with the file and the absolute path of `{signatureRulesFile}` (under `{skill-root}`). It gets the `documentedAlternatives` map too, whose forms it compares as that file says. It extracts the symbols the file exports, with their full signatures, with ast-grep (or reads the file when `extraction.fallback.reason` says no ast-grep can run; list those files in the Coverage Analysis section as read by eye), leaves out a name the file only passes on from another module of the source being tested (`export { x } from './x'`, `export * from`, an `__init__.py` import, a Rust `pub use`), which that module's own result counts, keeps a name it passes on from another package (`export { z } from 'zod'`), which no other result counts, gives no name for an `export * from` or `export type * from` another package (a specifier that is no relative path; `export * as ns` still gives `ns`), which the recipe runner leaves unresolved, listing each such statement as `<line>: <statement>` in `unresolved_stars`, compares each with the map's entry by the rules of that file (overload sets and `difference` tags included), gives each mismatch the `line` that defines the export, and returns only `{"file", "exports_found": [...], "types_found": [<the exported interfaces, type aliases, enums and classes>], "signature_mismatches": [...], "unresolved_stars": [...]}`, each mismatch in the shape §2's signature comparison returns. List each response's `unresolved_stars` in the Coverage Analysis section as unresolved. Save each response to `{run_dir}/per-file-<n>.txt` with the Write tool and build the surface from them, one `--per-file` each and `--fallback-scan` (each names the file that defines its exports, so a `scope.tier_a_include` list that counts none of them does not turn the guards off), with the same baselines as at Quick tier, and `--extraction` when the runner's JSON sent you here (`extraction.fallback.needed`): it adds no name, only its scope and the globs it lists as matching no file, so the guards still run:
30
30
 
31
31
  ```bash
32
32
  uv run {coverageInputsScript} surface --per-file "{run_dir}/per-file-<n>.txt" --fallback-scan [--extraction "{run_dir}/extract-full.json"] [--brief "{forge_data_folder}/{skill_name}/skill-brief.yaml"] --metadata "{resolved_skill_package}/metadata.json" [--provenance "{forge_provenance_map}"] [--monorepo] [--pattern-reference-intent] [--curated-subset] [--barrel <state>] --output "{run_dir}/surface.json"
@@ -107,7 +107,7 @@ Only when the validated inventory's `references` is non-empty. Each `cross_check
107
107
 
108
108
  ### 2. Analyze Source Code (Tier-Dependent)
109
109
 
110
- Identify the public API surface from the package entry points (0b), as deep as the tier allows. Whichever branch runs, `{coverageInputsScript} surface` writes it to `{run_dir}/surface.json`, which §2b, §2c, §4 and step 5 read. Names read by eye reach it as per-file results, `{"file": "<path>", "exports_found": [<each name>], "signature_mismatches": []}` for each file, saved to `{run_dir}/per-file-<n>.json` with the Write tool and given to `surface` with one `--per-file` each.
110
+ Identify the public API surface from the package entry points (0b), as deep as the tier allows. Whichever branch runs, `{coverageInputsScript} surface` writes it to `{run_dir}/surface.json`, which §2b, §2c, §4 and step 5 read. Names read by eye reach it as per-file results, `{"file": "<path>", "exports_found": [<each name>], "signature_mismatches": []}` for each file, saved to `{run_dir}/per-file-<n>.json` with the Write tool and given to `surface` with one `--per-file` each. An `export * from` or `export type * from` another package adds no name: list `<line>: <statement>` in `unresolved_stars` and Coverage Analysis.
111
111
 
112
112
  **Exits.** Every `{coverageInputsScript}` and `{scoreSignaturesScript}` command in §2 and §2b exits 0, or exits 1 with its error on stderr: HALT with it (`halt_reason: "helper-failed"`, phase `coverage-check:surface`). `surface` with a `--per-file`, and `score`, also exit 2 when a subagent response breaks the schema: re-dispatch that subagent once with its `violations[]` appended (or correct a result the main thread wrote), save the new response over the old one and run the command again; a second failure HALTs the same way.
113
113
 
@@ -298,7 +298,7 @@ uv run {gapLedgerScript} append --ledger "{ledgerFile}" --stage coverage-check -
298
298
  uv run {gapLedgerScript} append --ledger "{ledgerFile}" --stage coverage-check --from guards --input "{run_dir}/surface.json" --metadata "{resolved_skill_package}/metadata.json"
299
299
  uv run {gapLedgerScript} append --ledger "{ledgerFile}" --stage coverage-check --from numerator --input "{run_dir}/numerator.json" --metadata "{resolved_skill_package}/metadata.json"
300
300
  uv run {gapLedgerScript} append --ledger "{ledgerFile}" --stage coverage-check --from metadata-coherence --input "{run_dir}/metadata-coherence.json" --metadata "{resolved_skill_package}/metadata.json"
301
- uv run {gapLedgerScript} append --ledger "{ledgerFile}" --stage coverage-check --from provenance-line --input "{run_dir}/provenance-verify.json"
301
+ uv run {gapLedgerScript} append --ledger "{ledgerFile}" --stage coverage-check --from provenance-line --input "{run_dir}/provenance-verify.json" [--stale "{run_dir}/stale.json"]
302
302
  ```
303
303
 
304
304
  **A split-body mismatch's Source** is a line inside the skill package: when §1b found one, load `{coverageBranchesFile}` and follow its **Split-Body Sources** section.
@@ -59,7 +59,7 @@ When the emitter itself is not installed (init.md §0), no line can be printed:
59
59
 
60
60
  ## Result Files
61
61
 
62
- report.md §4c runs the emitter with `--result-dir "{forge_version}"`. It writes the per-run record `skf-test-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name, `-2`, `-3` appended when a run already took that second's name) and then its copy `skf-test-skill-result-latest.json`, the record of the last run whose emitter wrote this copy, at a stable path (a copy, not a symlink), each atomically. The record holds the envelope's fields, plus `skill`, `outputs[]` (the published report), `summary` (`score`, `threshold`, `result` (`PASS`, `PASS_WITH_DRIFT`, `FAIL` or `INCONCLUSIVE`), `testMode`, `activeCategories[]`, `inconclusiveReasons[]` when present, `threshold_fallback`, `original_threshold` and `evidence_report_path` when the threshold fallback fired, `gapCounts` with the gap ledger's gaps by severity, and `hardGate`), `runId` and `healthCheckDispatched`, and the emitter stamps `timestamp`, `run_id`, `headless_decisions` and `warnings` into it. A run the hard gate blocked writes it too, as a FAIL record, so its `skf-test-skill-result-latest.json` holds that FAIL, not an earlier run's verdict. A HALT writes no result file, and neither does a run cut off before report.md §4c: the copy then still holds an earlier run's verdict.
62
+ report.md §4c runs the emitter with `--result-dir "{forge_version}"`. It writes the per-run record `skf-test-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name, `-2`, `-3` appended when a run already took that second's name) and then its copy `skf-test-skill-result-latest.json`, the record of the last run whose emitter wrote this copy, at a stable path (a copy, not a symlink), each atomically. The record holds the envelope's fields, plus `skill`, `outputs[]` (the published report), `summary` (`score`, `threshold`, `result` (`PASS`, `PASS_WITH_DRIFT`, `FAIL` or `INCONCLUSIVE`), `testMode`, `activeCategories[]`, `inconclusiveReasons[]` when present, `threshold_fallback`, `original_threshold` and `evidence_report_path` when the threshold fallback fired, `gapCounts` with the gap ledger's gaps by severity, and `hardGate`), `runId` and `healthCheckDispatched`, and the emitter stamps `timestamp`, `run_id`, `headless_decisions` and `warnings` into it. A run the hard gate blocked writes it too, as a FAIL record, so its `skf-test-skill-result-latest.json` holds that FAIL, not an earlier run's verdict, unless the run's line holds a `result_file_write_failed` warning (the emitter stops at the first write that fails). A HALT writes no result file, and neither does a run cut off before report.md §4c: the copy then still holds an earlier run's verdict.
63
63
 
64
64
  ## Emitting a Halt
65
65
 
@@ -235,7 +235,7 @@ and bind `{report_file}` ← `{publishedReportFile}`. If the move fails, HALT (`
235
235
  uv run {emitEnvelopeHelper} emit --workflow skf-test-skill --run-dir "{run_dir}" --result-dir "{forge_version}" --target {emit_target} < "{run_dir}/result-context.json"
236
236
  ```
237
237
 
238
- It writes `{forge_version}/skf-test-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and then its `skf-test-skill-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path: a copy, not a symlink; a caller reads its own run's `SKF_TEST_RESULT_JSON` line), each atomically, so a partial write is never observable; stamps the timestamp, `run_id`, `headless_decisions` and `warnings` into both; and prints the `SKF_TEST_RESULT_JSON:` line. Bind `{result_line}` ← that line and `{result_path}` ← its `result_path`. A run the hard gate blocked writes the record too, as a FAIL (`status: "error"`, `verdict: "FAIL"`, `halt_reason: "hard-gate-blocked"`, `exit_code` 2), so after a blocked run `skf-test-skill-result-latest.json` holds its FAIL, never an earlier run's verdict. A result file that could not be written leaves `result_path` null and a `result_file_write_failed` warning in the line, and the run still finishes. If the emitter exits non-zero, run it once more; if it fails again, HALT (`halt_reason: "write-failed"`, phase `report:result-contract`) with its stderr: the run's result contract could not be written. `references/invocation-contract.md` lists the record's fields.
238
+ It writes `{forge_version}/skf-test-skill-result-{YYYYMMDD-HHmmss}.json` (UTC; it picks the name) and then its `skf-test-skill-result-latest.json` copy (the record of the last run whose emitter wrote this copy, at a stable path: a copy, not a symlink; a caller reads its own run's `SKF_TEST_RESULT_JSON` line), each atomically, so a partial write is never observable; stamps the timestamp, `run_id`, `headless_decisions` and `warnings` into both; and prints the `SKF_TEST_RESULT_JSON:` line. Bind `{result_line}` ← that line and `{result_path}` ← its `result_path`. A run the hard gate blocked writes the record too, as a FAIL (`status: "error"`, `verdict: "FAIL"`, `halt_reason: "hard-gate-blocked"`, `exit_code` 2), so after a blocked run `skf-test-skill-result-latest.json` holds its FAIL, never an earlier run's verdict, unless the run's line holds a `result_file_write_failed` warning (the emitter stops at the first write that fails). A per-run record that could not be written leaves `result_path` null, and a `-latest` copy that could not be written leaves it set; either adds a `result_file_write_failed` warning to the line, and the run still finishes. If the emitter exits non-zero, run it once more; if it fails again, HALT (`halt_reason: "write-failed"`, phase `report:result-contract`) with its stderr: the run's result contract could not be written. `references/invocation-contract.md` lists the record's fields.
239
239
 
240
240
  Once the result files are written, append `'report'` to `stepsCompleted` in the `{outputFile}` frontmatter (it stays out of the expected set above).
241
241
 
@@ -45,7 +45,7 @@ This table is the single source of truth for classifying a gap. Every stage reco
45
45
  | Low | `metadata` | Missing optional metadata or examples |
46
46
  | Low | `description` | Description trigger optimization recommended (third-person voice, negative triggers, or keyword coverage gaps), a Tessl Review description suggestion included |
47
47
  | Low | `external-validator` | A skill-check diagnostic or a Tessl Review validation finding the run left unresolved (external-validators §5b) |
48
- | Info | `observation` | Style suggestions, non-blocking observations, and a documented extra: a documented name outside the enumerated surface that is not a fabricated signature and that the source still declares at column 0 (a module so named counts, unless the inventory gives the name a `function`, `hook`, `class`, `interface`, `enum` or `type` kind, no provenance map entry of the name types it a `module` or `package` and the module is not a TS/JS file that is itself the export, with an `export default` or a `module.exports =`; among several declaring files, the one the skill cites on a line naming it; an `aliased` name, which an export-list item or a Python `from X import` item renames from a name its cited file declares, at that item's line, a multi-line list's item at its own line), found only in a Python or TS/JS source where classify-stale ran (the Forge, Forge+ or Deep tier, `analysis_confidence` `full`, `workspaceDrift` not `overridden` and a bound provenance map) and `surface.json` has a complete `extraction`: `Documented extra: {name}`, at that line, which update-skill never routes (coverage-check §2c). Also a partial signature: a mismatch the signature judge tags `omitted-optional` (defined in signature-comparison-rules.md, Difference tags) on a form the skill labels partial (simplified, key parameters, a `...` that stands in for omitted members, not rest or variadic syntax), which the inventory marks `signature_form: "partial"`, as score-signatures.py checks: `Partial signature: {name}`, at the declaration, counted as matching in Signature Accuracy (coverage-check §2 and §2b). Also a return annotation the body contradicts: a mismatch the signature judge tags `annotation-contradicted` (defined in signature-comparison-rules.md, Difference tags) whose documented signature states a return type and writes the source's parameters, and whose `evidence_line`, the body line that returns the documented type, comes after the declaration and before the next one and, with the local source, starts with `return` (or is a one-line declaration's own expression body), as score-signatures.py checks: `Annotation contradicted: {name}`, at the declaration, counted as matching in Signature Accuracy, with no change to the skill (the annotation is reported upstream) (coverage-check §2 and §2b) Also a documentation error a Deep-tier run sees that no script checks (an import path whose module does not export the name, a type member that differs from its declaration): `Not checked by script: {name}`, at the documenting line, written by hand (coverage-check §2 and §5b) |
48
+ | Info | `observation` | Style suggestions, non-blocking observations, and a documented extra: a documented name outside the enumerated surface that is not a fabricated signature and that the source still declares at column 0 (a module so named counts, unless the inventory gives the name a `function`, `hook`, `class`, `interface`, `enum` or `type` kind, no provenance map entry of the name types it a `module` or `package` and the module is not a TS/JS file that is itself the export, with an `export default` or a `module.exports =`; among several declaring files, the one the skill cites on a line naming it; an `aliased` name, which a line elsewhere renames from the declaration its cited file holds (an export-list item `L as NAME` (`export { default as NAME } from` included, a whole-string side unquoted, a comment allowed before `export`), a plain `export { NAME }` of an import that renames (`import { L as NAME }`, `import NAME from`), a `const`, `let`, `var` or `type NAME = L` with a bare identifier `L`, a Python `from X import L as NAME` at column 0 or one level in a module-level `try`, `except`, `else`, `if` or `elif` block, or a Python `NAME = L`), at that line, a multi-line list's item at its own line; when the rename's relative or in-package `from` module, or the import that binds `L`, resolves to a file that declares `L`, that file must be the cited one, so a rename of another file's same-named declaration stays a fabricated signature, as do a type assertion, any other expression and an import inside a function or a class), found only in a Python or TS/JS source where classify-stale ran (the Forge, Forge+ or Deep tier, `analysis_confidence` `full`, `workspaceDrift` not `overridden` and a bound provenance map) and `surface.json` has a complete `extraction`: `Documented extra: {name}`, at that line, which update-skill never routes (coverage-check §2c). Also a partial signature: a mismatch the signature judge tags `omitted-optional` (defined in signature-comparison-rules.md, Difference tags) on a form the skill labels partial (simplified, key parameters, a `...` that stands in for omitted members, not rest or variadic syntax), which the inventory marks `signature_form: "partial"`, as score-signatures.py checks: `Partial signature: {name}`, at the declaration, counted as matching in Signature Accuracy (coverage-check §2 and §2b). Also a return annotation the body contradicts: a mismatch the signature judge tags `annotation-contradicted` (defined in signature-comparison-rules.md, Difference tags) whose documented signature states a return type and writes the source's parameters, and whose `evidence_line`, the body line that returns the documented type, comes after the declaration and before the next one and, with the local source, starts with `return` (or is a one-line declaration's own expression body), as score-signatures.py checks: `Annotation contradicted: {name}`, at the declaration, counted as matching in Signature Accuracy, with no change to the skill (the annotation is reported upstream) (coverage-check §2 and §2b) Also a documentation error a Deep-tier run sees that no script checks (an import path whose module does not export the name, a type member that differs from its declaration): `Not checked by script: {name}`, at the documenting line, written by hand (coverage-check §2 and §5b) |
49
49
  | Info | `provenance-unverified` | Provenance line not verified: the line-check rules found no definition line, or the recorded line is not a line number (`line-invalid`) (coverage-check §4c) |
50
50
  | Info | `migration-section` | Historical migration content in Section 4b with no T2-future annotation (Case 2 of `migration-section-rules.md`) |
51
51
  | Info | `multi-denominator` | Multi-denominator reporting: barrel and documented-surface clusters diverge by design (>10% cross-cluster) |
@@ -26,6 +26,8 @@ The names of Python's `*args` and `**kwargs` are never compared (`**kwargs` agai
26
26
 
27
27
  The source's declaration decides: the documented form is compared with the parameters, the optionality and the annotations the signature declares, with one exception, a return annotation the body contradicts (Annotations).
28
28
 
29
+ A const annotated with a function type declares that type's parameters: `export const build: ViteBuilder['build'] = async ({ options }) => ...` takes the one parameter `ViteBuilder['build']` names, and the arrow's destructuring is that same parameter. A form may name it as the type does or destructure it as the arrow does, and a destructuring that names only some of the type's properties, in the arrow or in the form, does not read as a narrower type. A declaration whose parameter list is written only in a package outside the source tree (a binding destructured from a value cast to `typeof domTestingLibrary`, or `const spyOn: typeof vitestSpyOn = (...args) => ...`) has its parameters, their optionality and its return left uncompared and counts as matching, as a component whose props type lives there does (Components).
30
+
29
31
  In the split-body cross-check both forms are documented: each is read by these rules, a type, return type or type arguments one of them leaves out are not compared, and a parameter's optionality is compared only when both forms state it. An ellipsis claims nothing about the members it stands for, required ones included. A call, a list of bare names or a form its own file labels partial (each side's label is read from that side's file, not from the export's one `signature_form`) may leave out a parameter the other form does not state as required, unless leaving it out shifts a caller's positions (a language without keyword arguments, as above); any other form claims its list is complete, so leaving out a parameter the other writes is a mismatch (inventory-contract.md's examples). Each export's form is read alone: what the rows of other exports write, in the same table or file, states nothing about it, so `(action, sink, tracking_record, child_invalidation)` matches `child_invalidation: X | None = None` even when every other row of its table writes its defaults.
30
32
 
31
33
  A name the skill documents in several forms (a brief usage in SKILL.md, a full props list in a reference file) comes with `alternatives`: coverage-check.md §2's check lists them after its `documented` form, and the fallback per-file scan's `documentedAlternatives` holds them by name. Compare the declaration with each form the check offers, each by its own reading rules, and report a mismatch for each form that does not match, with its index as `form` (0 is `documented`, 1 the first alternative), its `documented_sig` written as that form and its `difference` tagged against it. With `candidates`, a form matches when it matches any candidate, and its mismatch is reported at the closest candidate.
@@ -38,7 +40,7 @@ A component is an export the source declares as one: a function that takes one p
38
40
 
39
41
  A component's own props are the members written in its package: its interface or object type, object literals intersected into it, and members computed from its own code (`VariantProps<typeof buttonVariants>`). Members of a type from another package (`React.ComponentProps<"button">`, `HTMLAttributes<...>`, `ComponentProps<typeof Link>`) are inherited, as are the `ref` a `forwardRef` result takes and the optional `children` of `PropsWithChildren<P>`. A prop the form writes must exist on `P`, own or inherited. A component whose props type lives in a package outside the source tree (`const Tabs = TabsPrimitive.Root`) has its props left uncompared: only that it is a component is checked.
40
42
 
41
- A prop the form writes that `P` lacks, or a type or an optionality it writes that the source contradicts, is a mismatch. So is a required own prop it leaves out, and a required inherited one (a `Link`'s `href`) left out of a full form. An optional own prop a typed props list or a table leaves out is an `omitted-optional` difference; one a list of bare names or a usage leaves out, and an optional inherited prop, is no difference. `| null` and `| undefined` on an optional prop are no difference (`VariantProps` adds both). A props type name the form writes that the source does not declare (`Button(props: ButtonProps)` against a source with no `ButtonProps`) is not compared by name, only the props the form lists. Values in a usage example (`variant="subtle"`) are not compared.
43
+ A prop the form writes that `P` lacks, or a type or an optionality it writes that the source contradicts, is a mismatch. So is a required own prop it leaves out, and a required inherited one (a `Link`'s `href`) left out of a full form. An optional own prop a typed props list or a table leaves out is an `omitted-optional` difference; one a list of bare names or a usage leaves out, and an optional inherited prop, is no difference. A rest element in a component's destructured props form (`({ className, variant = "default", ...props }: NativeButtonProps)`) stands for every prop of `P` the form does not name, own or inherited, required ones included, so the form leaves none of them out, and the rest's own name is not compared as a prop. `| null` and `| undefined` on an optional prop are no difference (`VariantProps` adds both). A props type name the form writes that the source does not declare (`Button(props: ButtonProps)` against a source with no `ButtonProps`) is not compared by name, only the props the form lists. Values in a usage example (`variant="subtle"`) are not compared.
42
44
 
43
45
  A props list states optionality as Reading a form says: `({ of, meta, story })`, or a list of names, states none, and in an object type literal (`{ of?: X; meta: M }`) that states it for any member, each member written with no mark claims to be required. A table states optionality by a Required column, a `?` on a prop's name or a Default cell that gives a value; its Type column states types, never optionality. A Default cell with a value marks only its own row optional and an empty one states nothing, so Create Skill's `| Prop | Type | Default | Description |` table never reads a prop with an empty Default cell as required. A destructuring default (`({ of, meta = {} })`) likewise marks only its own member, and the others state no optionality.
44
46
 
@@ -46,6 +48,7 @@ A props list states optionality as Reading a form says: `({ of, meta, story })`,
46
48
 
47
49
  - A parameter written without a type is not compared on type: its name and order are, and its optionality as Reading a form says. Bare `*args` matches `*args: P.args`, and `**kwargs` matches `**kwargs: P.kwargs`.
48
50
  - A return type the form does not state is not compared. A form states one when its entry has a non-blank `return_type` or the form writes one: after `->`, `=>`, `→` or `):`, or in its language's own position (Go's result list after the parameters, the type before the name in C, C++ and Java). A blank `return_type` (`""`) states none, so an entry with `params: ""` and no return type matches `def f() -> None`.
51
+ - A return written as names of values rather than types, a parenthesized list of names such as `→ (nodes, edges)` (after the arrow or in `return_type`), states no return type, so no return is compared. A type name written alone stays a type, lowercase ones included: `-> list` and `→ str` state one.
49
52
  - A type the source leaves unannotated, on a parameter or a return, is not compared, except that a TS/JS parameter with a literal default has that literal's type: `pageSize = 20` is a `number`, so `pageSize?: string` is a mismatch. An unannotated return stays uncompared.
50
53
  - A documented parameter type that names some of the members of the source's union is no mismatch, since a caller passing it makes a valid call (`schema: VectorSchemaProvider` against `VectorSchemaProvider | MultiVectorSchemaProvider`); a documented return type must cover every member the source can return.
51
54
  - Type arguments the form leaves out are not compared: `dict` matches `dict[str, Any]`, and `list[dict]` matches `list[dict[str, Any]]`. Type arguments it writes are: `dict[str, int]` against `dict[str, str]` is a mismatch.
@@ -115,25 +115,46 @@ cites (never read; default `metadata.json`):
115
115
  name it (outside the citations), or on the next non-blank line
116
116
  after one, cite exactly one of those files: the gap
117
117
  is at that file's `declared_in` item, and two cited or none
118
- leave it Medium. The documented extra's issue names the line
119
- that documents it (with --skill-dir) and its remediation asks no
118
+ leave it Medium. The documented extra's remediation asks no
120
119
  change (update-skill never routes it) and says whether signature
121
120
  scoring compared its documented signature (a name the
122
121
  --signatures result lists in `comparedNames`). `excluded` is
123
122
  read once a stale name that is not fabricated needs it, and a
124
123
  malformed one is then INVALID_INPUT. Any other is a Medium `stale-documentation` gap
125
- `Stale documentation: {name}` at the first line of SKILL.md,
126
- then of references/*.md in path order, that writes the name
127
- (validate-inventory.py's match, with --skill-dir), else
128
- `SKILL.md`. One whose `excluded.outsideScope` row says the brief
124
+ `Stale documentation: {name}` at the line that documents the
125
+ name (with --skill-dir), else `SKILL.md`. That line is the
126
+ first that writes it as an export, the first tier that holds
127
+ the name winning: (1) its first origin in the documented
128
+ inventory's floor (load-coverage-inputs.py floor); (2) a
129
+ column-0 declaration of it in a fenced block of a language
130
+ floor's `_declared_names` reads (JS/TS, Python, Rust, Go),
131
+ cited or not, outside an HTML comment and a data block (json, yaml...);
132
+ (3) a heading, or the first cell of a table whose header's
133
+ first cell names exports (floor's table headers), whose text is
134
+ the name or holds a code span floor reads as the name; (4) a
135
+ line whose inline code span is the name, or the name directly
136
+ followed by `(`. Tiers 2 to 4 read SKILL.md, then every .md file
137
+ under references/, subfolders included, in path order, outside
138
+ the frontmatter (3 and 4 outside fenced code and HTML comments
139
+ too); within a tier the first file, then its lowest line, wins.
140
+ A bare word, or a span that holds the name among other words
141
+ (`uitripled add`), documents nothing; with no such line, or
142
+ without --skill-dir, the Source is `SKILL.md`. Floor's
143
+ InputError (no SKILL.md, or a top-level file it cannot read or
144
+ decode) leaves tier 1 empty, and a skill file that cannot be
145
+ read leaves tiers 2 to 4 empty. One whose
146
+ `excluded.outsideScope` row says the brief
129
147
  scopes its file out (`excludeGlob`, the `scope.exclude` glob
130
148
  that matches it, or `outsideInclude`, no `scope.include` glob
131
149
  covering it) has its own issue, naming the entry point and the
132
150
  file and line, and remediation: to keep the documentation,
133
151
  narrow that exclude glob (a removal needs a `demoted-exclude`
134
152
  amendment) or widen `scope.include`, else remove it. A row the
135
- brief does not explain keeps the wording above. Scalar and
136
- stack branches: a
153
+ brief does not explain keeps the wording above. Every stale
154
+ name's issue (fabricated, documented extra, stale documentation
155
+ or scoped out) opens with the line that documents it, as
156
+ `file:line` (with --skill-dir), else with `the skill`, whatever
157
+ its Source. Scalar and stack branches: a
137
158
  `missingCount` above 0 is one Medium `missing-export` gap
138
159
  `{missingCount} of {denominator} exports not documented` at
139
160
  `{meta}`, except that a scalar count taken from the verified
@@ -194,15 +215,26 @@ cites (never read; default `metadata.json`):
194
215
  entries its counts differ by; a barrel one sends the fix to
195
216
  Create Skill, which rebuilds `exports[]` from the source.
196
217
  provenance-line
197
- --input skf-verify-provenance-completeness.py verify's result.
218
+ --input skf-verify-provenance-completeness.py verify's result;
219
+ --stale.
198
220
  Each `stale[]` item, at its `{source_file}:{source_line}` with
199
221
  `export`, by `reason`: `line-not-definition` is a Low
200
222
  `provenance-line` gap `Provenance line is not the definition of
201
223
  {export_name}` when `definition_lines` holds a line, else an Info
202
224
  `provenance-unverified` gap `Provenance line not verified for
203
225
  {export_name}`, and its issue names the base of a `folded_to`
204
- variant; `file-missing` is a Low `provenance-line` gap
205
- `Provenance file is missing for {export_name}`;
226
+ variant. When the --stale result (classify-stale) marks
227
+ `{export_name}` `aliased` with a `file:line` `defined_at`, that
228
+ Info gap's issue and remediation name the alias at `defined_at`,
229
+ and the remediation asks first to record the rename if no other
230
+ entry already has its `export_name` (`export_name` the item's
231
+ `renames`, else "a name a cited file declares", `reexported_as`
232
+ the export), else to cite the alias, moving the citations either
233
+ way; when `renames` is `default`, which the map keys on
234
+ `export_name` and so holds once, it asks only to cite the alias.
235
+ A --stale result that is not a list is INVALID_INPUT.
236
+ `file-missing` is a Low `provenance-line`
237
+ gap `Provenance file is missing for {export_name}`;
206
238
  `line-out-of-bounds` a Low `provenance-line` gap `Provenance line
207
239
  is out of range for {export_name}`; `line-invalid` an Info
208
240
  `provenance-unverified` gap `Provenance line not verified for
@@ -747,7 +779,7 @@ ADAPTER_OPTIONS: dict[str, frozenset[str]] = {
747
779
  "guards": frozenset({"metadata"}),
748
780
  "numerator": frozenset({"metadata"}),
749
781
  "metadata-coherence": frozenset({"metadata"}),
750
- "provenance-line": frozenset(),
782
+ "provenance-line": frozenset({"stale"}),
751
783
  "coherence": frozenset(),
752
784
  "structure": frozenset({"served"}),
753
785
  }
@@ -776,8 +808,10 @@ _SIBLINGS: dict[str, object] = {}
776
808
 
777
809
  def _sibling(filename: str):
778
810
  """A script beside this one, loaded once: validate-inventory.py (the
779
- documented-name match the stale-documentation Source uses, and the
780
- skill-citation scan the homonym pick reads)."""
811
+ skill-citation scan the homonym pick reads), load-coverage-inputs.py
812
+ (the documented inventory's floor, and its fence and table readers)
813
+ and locate-export-segments.py (the skill's files and line model), the
814
+ last two for the line that documents a stale name."""
781
815
  module = _SIBLINGS.get(filename)
782
816
  if module is None:
783
817
  path = Path(__file__).resolve().parent / filename
@@ -872,21 +906,90 @@ def _provenance_sources(provenance: dict | None) -> dict[str, str]:
872
906
  return sources
873
907
 
874
908
 
909
+ def _documenting_lines(skill_dir: Path) -> dict[str, str]:
910
+ """name -> the `file:line` that documents it as an export, the first tier
911
+ that holds the name winning:
912
+
913
+ 1. its first origin in the documented inventory's floor
914
+ (load-coverage-inputs.py `floor`: SKILL.md and the top-level
915
+ references/*.md);
916
+ 2. a column-0 public declaration of it in a fenced code block (floor's
917
+ `_declared_names`), cited or not, outside an HTML comment and a data
918
+ block;
919
+ 3. a heading, or the first cell of a table whose header's first cell is
920
+ one of floor's `FLOOR_TABLE_HEADERS`, whose stripped text is the name
921
+ or holds a code span floor's `_floor_name` reads as the name;
922
+ 4. a line whose inline code span, stripped, is the name, or the name
923
+ directly followed by `(`.
924
+
925
+ Tiers 2 to 4 read SKILL.md, then every .md file under references/,
926
+ subfolders included, in path order (locate-export-segments.py
927
+ `skill_files`), never the frontmatter, tiers 3 and 4 outside fenced code
928
+ and HTML comments; within a tier the first file, then its lowest line,
929
+ wins. A bare word, or a span that holds the name among other words
930
+ (`uitripled add`), documents nothing. An input a tier cannot read is
931
+ skipped: floor's InputError (no SKILL.md, a file that is not UTF-8)
932
+ leaves tier 1 empty, an unreadable file tiers 2 to 4."""
933
+ coverage = _sibling("load-coverage-inputs.py")
934
+ segments = _sibling("locate-export-segments.py")
935
+ tiers: list[dict[str, str]] = [{}, {}, {}, {}]
936
+ try:
937
+ for entry in coverage.floor(str(skill_dir))["names"]:
938
+ origin = entry["origins"][0]
939
+ tiers[0].setdefault(entry["name"], f"{origin['file']}:{origin['line']}")
940
+ except coverage.InputError:
941
+ pass
942
+ try:
943
+ files = segments.skill_files(skill_dir)
944
+ except OSError:
945
+ files = []
946
+ # floor's fence filter: a fence in an HTML comment or a data block declares nothing, and a Svelte block's
947
+ # `export let` is a component prop. Unlike floor, an unheaded fence counts.
948
+ js_family = segments.JS_FAMILY - {"svelte"}
949
+ for rel, lines in files:
950
+ start = segments._frontmatter_end(lines)
951
+ blocks, code = segments._fenced_blocks(lines, start)
952
+ comments = coverage._html_comment_lines(lines, start, code)
953
+ for opening, closing, info in blocks:
954
+ language = segments._language(info)
955
+ if opening in comments or language in segments.DATA_LANGUAGES:
956
+ continue
957
+ masked = segments.mask_code("\n".join(lines[opening + 1:closing]), language).split("\n")
958
+ for offset, declared in coverage._declared_names(masked, language, js_family):
959
+ tiers[1].setdefault(declared, f"{rel}:{opening + offset + 2}")
960
+ headings, prose = segments._prose(lines, start, code)
961
+ shaped = [(index, text) for index, _level, text, _above in headings]
962
+ written = list(shaped)
963
+ for block in prose:
964
+ written += [(i, lines[i]) for i in range(block["start"], block["end"] + 1)]
965
+ if block["kind"] == "table-row" and block["header"] is not None:
966
+ if coverage._header_word(coverage._cells(block["header"])[0]) in coverage.FLOOR_TABLE_HEADERS:
967
+ shaped.append((block["start"], coverage._cells(lines[block["start"]])[0]))
968
+ for index, text in sorted(shaped):
969
+ for key in [text.strip()] + [coverage._floor_name(span) for span in segments._inline_code(text)]:
970
+ if key:
971
+ tiers[2].setdefault(key, f"{rel}:{index + 1}")
972
+ for index, text in sorted(written):
973
+ for span in map(str.strip, segments._inline_code(text)):
974
+ for key in (span, span.split("(", 1)[0] if "(" in span else None):
975
+ if key:
976
+ tiers[3].setdefault(key, f"{rel}:{index + 1}")
977
+ found: dict[str, str] = {}
978
+ for tier in tiers:
979
+ for key, line in tier.items():
980
+ found.setdefault(key, line)
981
+ return found
982
+
983
+
875
984
  def _documenting_line(skill_dir: Path, name: str) -> str | None:
876
- """The first line of SKILL.md, then of references/*.md in path order,
877
- that writes `name` (validate-inventory.py's match), as `file:line`."""
878
- pattern = _sibling("validate-inventory.py").name_pattern(name)
879
- refs = skill_dir / "references"
880
- files = [skill_dir / "SKILL.md"] + (sorted(refs.glob("*.md")) if refs.is_dir() else [])
881
- for path in files:
882
- try:
883
- text = path.read_bytes().decode("utf-8-sig", errors="replace")
884
- except OSError:
885
- continue
886
- for number, line in enumerate(text.split("\n"), start=1):
887
- if pattern.search(line):
888
- return f"{path.relative_to(skill_dir).as_posix()}:{number}"
889
- return None
985
+ """The `file:line` that documents `name` as an export in the skill at
986
+ `skill_dir` (`_documenting_lines`), else None."""
987
+ return _documenting_lines(skill_dir).get(name)
988
+
989
+
990
+ def _documents(doc: str | None, name: str) -> str:
991
+ """The subject of a stale name's Issue: the line that documents it, else the skill."""
992
+ return f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`"
890
993
 
891
994
 
892
995
  def _scoped_out_by(row: dict) -> bool:
@@ -1069,8 +1172,8 @@ def _scoped_out_gap(name: str, row: dict, doc: str | None, brief: str) -> dict:
1069
1172
  f"Decide whether `{name}` belongs on the surface: `{brief}` scopes out `{at}`, where it is defined, since "
1070
1173
  f"{' and '.join(causes)}. To keep its documentation, {' and '.join(fixes)}; else remove the documentation "
1071
1174
  f"of `{name}` at {doc or 'SKILL.md'}.",
1072
- issue=f"{f'`{doc}`' if doc else 'SKILL.md'} documents `{name}`, which {who} exports and `{at}` defines, a "
1073
- f"file `{brief}` scopes out (`excluded.outsideScope`)",
1175
+ issue=f"{_documents(doc, name)}, which {who} exports and `{at}` defines, a file `{brief}` scopes out "
1176
+ "(`excluded.outsideScope`)",
1074
1177
  export=name)
1075
1178
 
1076
1179
 
@@ -1113,9 +1216,13 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1113
1216
  if not isinstance(stale, list):
1114
1217
  raise AdapterError("--stale is not classify-stale JSON (a list)")
1115
1218
  classified = {i["name"]: i for i in stale if isinstance(i, dict) and isinstance(i.get("name"), str)}
1116
- for name in _names(cov, "stale", "--input"):
1219
+ names = _names(cov, "stale", "--input")
1220
+ # the lines that document each name, read once for every stale name
1221
+ documenting = _documenting_lines(Path(skill_dir)) if skill_dir and names else {}
1222
+ for name in names:
1117
1223
  item = classified.get(name) or {}
1118
1224
  cited = item.get("source")
1225
+ doc = documenting.get(name)
1119
1226
  if item.get("fabricated") is True:
1120
1227
  if not isinstance(cited, str) or not cited:
1121
1228
  # a Critical gap needs the line it cites: never downgrade it
@@ -1124,14 +1231,13 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1124
1231
  "Critical", "fabricated-signature", f"Fabricated signature: {name}", cited,
1125
1232
  f"Remove `{name}` from SKILL.md and references/, or correct it to the export the source "
1126
1233
  f"defines: no file its provenance cites defines it (`{cited}`).",
1127
- issue=f"SKILL.md documents `{name}`, which the source API surface lacks and the cited "
1128
- "file does not define",
1234
+ issue=f"{_documents(doc, name)}, which the source API surface lacks and the cited file does "
1235
+ "not define",
1129
1236
  export=name))
1130
1237
  continue
1131
1238
  if not outside_read:
1132
1239
  outside, outside_read = _outside_scope(surface), True
1133
1240
  extra = _documented_extra_at(name, item, outside, skill_dir)
1134
- doc = _documenting_line(Path(skill_dir), name) if skill_dir else None
1135
1241
  if extra:
1136
1242
  declared, among = extra
1137
1243
  # a homonym: the one declaring file the skill's own citations name
@@ -1146,8 +1252,8 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1146
1252
  "Info", "observation", f"Documented extra: {name}", declared,
1147
1253
  f"No change: `{name}` is a documented extra, a name the source still declares at "
1148
1254
  f"`{declared}` outside the enumerated surface, and its documentation stays. {checked}",
1149
- issue=f"{f'`{doc}`' if doc else 'the skill'} documents `{name}`, which the enumerated source API "
1150
- f"surface lacks and the source still declares{picked}",
1255
+ issue=f"{_documents(doc, name)}, which the enumerated source API surface lacks and the source "
1256
+ f"still declares{picked}",
1151
1257
  export=name))
1152
1258
  continue
1153
1259
  if outside is not None and name in outside and _scoped_out_by(outside[name]):
@@ -1158,7 +1264,7 @@ def _stale_gaps(cov: dict, surface: dict | None, stale: object, scored: set[str]
1158
1264
  "Medium", "stale-documentation", f"Stale documentation: {name}", doc or "SKILL.md",
1159
1265
  f"Check `{name}` against the source and update or remove its documentation at "
1160
1266
  f"{doc or 'SKILL.md'}: the enumerated source surface does not export it.",
1161
- issue=f"SKILL.md documents `{name}`, which the enumerated source API surface lacks",
1267
+ issue=f"{_documents(doc, name)}, which the enumerated source API surface lacks",
1162
1268
  export=name))
1163
1269
  return records
1164
1270
 
@@ -1448,10 +1554,48 @@ def from_metadata_coherence(result: object, metadata: str | None = None) -> list
1448
1554
  return records
1449
1555
 
1450
1556
 
1451
- def from_provenance_line(result: object) -> list[dict]:
1557
+ def _aliased_items(stale: object) -> dict[str, dict]:
1558
+ """name -> its classify-stale item (`--stale`) when it is `aliased` with a `file:line` `defined_at`: a rename
1559
+ elsewhere declares the name, so a provenance line gap of it names the alias. Raises AdapterError, as the
1560
+ coverage adapter does, when the result is not a list."""
1561
+ if stale is None:
1562
+ return {}
1563
+ if not isinstance(stale, list):
1564
+ raise AdapterError("--stale is not classify-stale JSON (a list)")
1565
+ return {i["name"]: i for i in stale
1566
+ if isinstance(i, dict) and isinstance(i.get("name"), str) and i.get("reason") == "aliased"
1567
+ and isinstance(i.get("defined_at"), str) and i["defined_at"].rpartition(":")[0]
1568
+ and i["defined_at"].rpartition(":")[2].isdigit()}
1569
+
1570
+
1571
+ def _alias_fix(name: str, file: str, item: dict) -> str:
1572
+ """The remediation of an unverified provenance line of a name classify-stale found `aliased` (`item`): record
1573
+ the rename if no other entry already has its `export_name`, else cite the alias. The map keys on
1574
+ `export_name`, so it holds one `default` entry at most (a barrel of `export { default as X }` lines renames
1575
+ several): a rename of `default` takes the alias alone. It never says which cited file declares the renamed
1576
+ name: any checked entry's may."""
1577
+ alias, local = item["defined_at"], item.get("renames")
1578
+ alias_file, _, alias_line = alias.rpartition(":")
1579
+ named = isinstance(local, str) and bool(local)
1580
+ renamed = f"`{local}`" if named else "a name a cited file declares"
1581
+ head = f"`{name}` is an alias: `{alias}` renames {renamed} to it, and no line of `{file}` defines `{name}`. "
1582
+ cite = (f"the alias: set `source_file` to `{alias_file}` and `source_line` to `{alias_line}`, and move the "
1583
+ f"citations of `{name}` there.")
1584
+ if local == "default":
1585
+ return f"{head}Cite {cite}"
1586
+ guard = f"`export_name` {renamed}" if named else "that name as its `export_name`"
1587
+ return (f"{head}If no other entry already has {guard}, record the rename in this provenance entry: set "
1588
+ f"`export_name` to {renamed}, add `reexported_as` `{name}`, cite the line that declares {renamed} and "
1589
+ f"move the citations of `{name}` to that line. Or cite {cite}")
1590
+
1591
+
1592
+ def from_provenance_line(result: object, stale: object = None) -> list[dict]:
1452
1593
  """verify's `stale[]` items: provenance line gaps, one per citation that does not resolve or is not the
1453
- definition line."""
1594
+ definition line. `stale` (classify-stale's result, `--stale`, or None) gives the names a rename declares: an
1595
+ unverified line of one names the alias and, unless the name it renames is `default`, how to record the rename
1596
+ (#911)."""
1454
1597
  result = _object(result, "--input (skf-verify-provenance-completeness.py verify)", "stale")
1598
+ aliased = _aliased_items(stale)
1455
1599
  records: list[dict] = []
1456
1600
  for item in _list(result, "stale", "--input"):
1457
1601
  if not isinstance(item, dict):
@@ -1506,12 +1650,16 @@ def from_provenance_line(result: object) -> list[dict]:
1506
1650
  f"it; the lines that define it are {lines}.{folded}",
1507
1651
  export=name))
1508
1652
  else:
1509
- records.append(_gap(
1510
- "Info", "provenance-unverified", f"Provenance line not verified for {name}", where,
1653
+ alias = aliased[name] if isinstance(name, str) and name in aliased else None
1654
+ fix = _alias_fix(name, file, alias) if alias else (
1511
1655
  f"Open `{file}` and confirm that line {line} defines `{base or name}`; if another line does, set "
1512
- "the provenance `source_line` to it.",
1513
- issue=f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand"
1514
- + (f".{folded}" if folded else ""),
1656
+ "the provenance `source_line` to it.")
1657
+ issue = (f"no line of `{file}` defines `{name}`: the source declares it only as the alias at "
1658
+ f"`{alias['defined_at']}`" if alias else
1659
+ f"the line-check rules found no definition line for `{name}` in `{file}`; check by hand")
1660
+ records.append(_gap(
1661
+ "Info", "provenance-unverified", f"Provenance line not verified for {name}", where, fix,
1662
+ issue=issue + (f".{folded}" if folded else ""),
1515
1663
  export=name))
1516
1664
  return records
1517
1665
 
@@ -1680,7 +1828,8 @@ def build_records(args: argparse.Namespace) -> list[dict]:
1680
1828
  if kind == "metadata-coherence":
1681
1829
  return from_metadata_coherence(data, args.metadata)
1682
1830
  if kind == "provenance-line":
1683
- return from_provenance_line(data)
1831
+ stale = read_result(args.stale, "--stale") if args.stale is not None else None
1832
+ return from_provenance_line(data, stale)
1684
1833
  return from_coherence(data)
1685
1834
 
1686
1835
 
@@ -1897,7 +2046,8 @@ def _build_parser() -> argparse.ArgumentParser:
1897
2046
  "the names outside scope)")
1898
2047
  p.add_argument("--signatures", help="coverage: score-signatures.py score's result (missingTypes)")
1899
2048
  p.add_argument("--numerator", help="coverage: verify-declared-numerator.py's result (absent names)")
1900
- p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras)")
2049
+ p.add_argument("--stale", help="coverage: classify-stale's result (fabricated signatures, documented extras); "
2050
+ "provenance-line: the same (the names a rename declares)")
1901
2051
  p.add_argument("--provenance", help="coverage: the provenance map (an absent name's Source)")
1902
2052
  p.add_argument("--skill-dir", help="coverage: the skill package (a stale name's documenting line)")
1903
2053
  p.add_argument("--metadata", help="the metadata.json path a record cites as its Source (not read)")
@@ -25,9 +25,11 @@ reading files and writing one JSON object:
25
25
  signature is read only where the place writes it in a fixed
26
26
  form (Signatures, below). A name is a plain
27
27
  identifier (`[A-Za-z_$][\\w$]*`) once a leading `new ` and
28
- everything from the first `(` or `<` are cut, so `Meta<T>`,
29
- `fetch(url): Promise<T>` and `new Client(opts)` give `Meta`,
30
- `fetch` and `Client`. A dotted or `::` name gives none, so a
28
+ everything from the first `(`, `<` or `[` are cut, so
29
+ `Meta<T>`, `App[P, R]`, `fetch(url): Promise<T>` and
30
+ `new Client(opts)` give `Meta`, `App`, `fetch` and `Client`;
31
+ an empty `[]` at the cut (a TS array type, `Foo[]`) gives
32
+ none. A dotted or `::` name gives none, so a
31
33
  namespace-style skill such as zod (`z.object`) gets no table
32
34
  floor. The forms, read outside the frontmatter and HTML
33
35
  comments (a table row or heading inside fenced code gives none):
@@ -614,12 +616,16 @@ def census(skill_dir: str) -> dict:
614
616
 
615
617
 
616
618
  def _floor_name(text: str) -> str | None:
617
- """The plain identifier `text` writes once a leading `new ` and everything from the first `(` or `<` are
618
- cut; None for any other text, a dotted or `::` name among them."""
619
+ """The plain identifier `text` writes once a leading `new ` and everything from the first `(`, `<` or `[` are
620
+ cut; None for any other text, a dotted or `::` name and an array type (`Foo[]`, an empty `[]` at the cut)
621
+ among them."""
619
622
  text = text.strip()
620
623
  if text.startswith("new "):
621
624
  text = text[4:]
622
- text = re.split(r"[(<]", text, maxsplit=1)[0].strip()
625
+ cut = re.match(r"([^(<\[]*)(\[\s*\])?", text)
626
+ if cut.group(2):
627
+ return None
628
+ text = cut.group(1).strip()
623
629
  return text if PLAIN_IDENTIFIER_RE.fullmatch(text) else None
624
630
 
625
631
 
@@ -99,7 +99,7 @@ Read the gaps of the test report at `{test_report_path}` and translate them into
99
99
 
100
100
  - `stale-documentation` (the skill documents a name the source's enumerated surface lacks and that is not a documented extra, an Info `observation`): the source has no line to repair the name from, so a person removes its documentation when the name is gone from the source, fixes the brief's scope when the surface missed it, or, when the source still declares the name, keeps the documentation and re-runs Test Skill at a tier that classifies it.
101
101
  - `metadata-drift` (divergent export counts in the metadata): update-skill recounts `stats.exports_public_api`, `stats.exports_documented` and `stats.effective_denominator` each time it writes metadata.json, so a metadata patch to them does not hold (under `--allow-workspace-drift` and for a docs-only skill it keeps the recorded public API counts and `stats.effective_denominator` instead); Create Skill rebuilds diverging barrel counts from the source and recounts a `stats.effective_denominator` the brief's `scope.tier_a_include` set disagrees with (denominator drift), a person fixes denominator deflation on a brief with no tier A list by adding `scope.tier_a_include` to it, and documented-surface counts that differ by the `Type::method` entries the named-export count leaves out need no fix.
102
- - `provenance-unverified`: the line-check rules could not verify the line, or the recorded line is not a number, so a person checks by hand which line defines the export and, when it is not the recorded one, sets its provenance `source_line` to that line.
102
+ - `provenance-unverified`: the line-check rules could not verify the line, or the recorded line is not a number, so a person checks by hand which line defines the export and, when it is not the recorded one, sets its provenance `source_line` to that line; when the gap names an alias, no line of the cited file defines the export, so a person records the rename in its provenance entry (`export_name` the name the alias renames, `reexported_as` the export) only when no other entry has that `export_name` and the alias does not rename a default export, and otherwise cites the alias line, as the remediation says.
103
103
  - `denominator-inflation`: it concerns the brief's scope and the count design, which a person decides in the skill brief, as the gap's remediation says.
104
104
  - `brief-scope-stale`: a person edits the brief's scope globs by hand so each matches the files it meant, since update-skill does not rewrite them.
105
105
  - `multi-denominator`: no fix is needed, since the barrel and the documented surface measure different sets by design and the report records both counts.
@@ -9,4 +9,4 @@ The argument set, gate map, outputs and headless result line of `skf-update-skil
9
9
  | **Gates** | step 1: init.md §4's degraded-rebuild gate [D]/[X] when no provenance map is found (headless: halts, or takes [D] under `--allow-degraded`; a gap-driven run halts `blocked` there instead); init.md §4b's [G]/[S] test-report offer, interactive only (headless warns `unconsumed-test-report`); Confirm Gate [C] (init.md §8) | step 2, each only when triggered: detect-changes.md §1b's promote prompt and §1c's scope-expansion prompt [P]/[S]/[U] (headless: defers each candidate to a person), §2.2's deletion-ratio gate [C]/[B]/[A] (headless: [C]) | step 2g (gap-driven): gap-driven.md §1 rule R1's [D]/[R] for each gap whose remediation names removal (headless: [D], or [R] only when the export is internal or `#[doc(hidden)]`) | step 4: Confirm Gate [C] only after the user resolved [MANUAL] conflicts (merge.md §4); a clean merge auto-proceeds, and a headless run with conflicts halts `halted-for-manual-mismatch` before anything is written |
10
10
  | **Outputs** | A new version folder `{skill_group}/{new_version}/` and its forge folder, beside the unchanged previous version (a gap-driven repair writes the current version in place), and a finished run's `update-skill-result-{YYYYMMDD-HHmmss}.json` and `update-skill-result-latest.json` in that version's forge folder (a run that found no change: the current one's). `--detect-only` and `--dry-run` write none of these, nor the skill brief. Run state goes to a run folder under `{project-root}/_bmad-output/.skf-run/` that step 7 removes; a halt keeps it |
11
11
  | **Concurrency** | A second real update of the same skill halts `halted-for-concurrent-run` while the first holds its run lock (init.md §1b); `--detect-only` and `--dry-run` take no lock. write.md §8 halts with the same status when another flip of the `active` link holds its lock for 5 seconds. A remote skill is read from a private tree, and no mode writes to the shared workspace clone before write.md §9. |
12
- | **Headless** | No gate prompts: each resolves as the Gates row says, and the run records the decision. One `SKF_UPDATE_RESULT_JSON` line (report.md §5b), at step 6 or at a HALT, printed by the shared emitter and checked against `shared/scripts/schemas/skf-update-result-envelope.v1.json`. It is the run's last line: the shared health check displays it after everything else; a HALT displays its line and stops. Pipelines branch on `skf_update.status`: `success`, `no-changes`, `detect-only` and `dry-run` are successful exits. A caller reads the run's own `SKF_UPDATE_RESULT_JSON` line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `update-skill-result-latest.json` may then describe an earlier run, so it is never read in its place. |
12
+ | **Headless** | No gate prompts: each resolves as the Gates row says, and the run records the decision. One `SKF_UPDATE_RESULT_JSON` line (report.md §5b), at step 6 or at a HALT, printed by the shared emitter and checked against `shared/scripts/schemas/skf-update-result-envelope.v1.json`. It is the run's last line: the shared health check displays it after everything else; a HALT displays its line and stops. Pipelines branch on `skf_update.status`: `success`, `no-changes`, `detect-only` and `dry-run` are successful exits. A headless run whose emitter refused its result line for good ends on a `halted-for-write-failure` line instead, with `error.phase` `report:result-contract` and an `error.reason` that starts `result-contract-not-written`, and nothing it wrote is undone. A caller reads the run's own `SKF_UPDATE_RESULT_JSON` line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `update-skill-result-latest.json` may then describe an earlier run, so it is never read in its place. |
@@ -299,7 +299,7 @@ uv run {runLockHelper} acquire \
299
299
  ```
300
300
 
301
301
  It renames the staged folder to `{skill_group}/{new_version}/` in one step, so no reader ever sees a half-copied version.
302
- 6. **Create the forge folder** `{forge_data_folder}/{skill_name}/{new_version}/` and copy `provenance-map.json`, `evidence-report.md` and `extraction-rules.yaml` from `{forge_version}` into it, each when it exists: step 5 updates the provenance map and appends this update to the evidence report there, and audit-skill reads the extraction rules there. Test reports, drift reports and result files belong to the version they were made for and stay where they are.
302
+ 6. **Create the forge folder** `{forge_data_folder}/{skill_name}/{new_version}/` and copy into it each of `provenance-map.json`, `evidence-report.md` and `extraction-rules.yaml`: from `{forge_version}` when it is there, else from `{forge_data_folder}/{skill_name}/` when it is there and init.md §4 bound `{provenance_map_path}` to that flat folder's map (an older skill's flat forge data, `knowledge/version-paths.md`). Copy, never move: the previous version's files and the flat ones stay as they are. Step 5 updates the provenance map and appends this update to the evidence report there; the extraction rules record how the skill was extracted. Test reports, drift reports and result files belong to the version they were made for and stay where they are.
303
303
  7. **Rebind** `{skill_package}` ← `{skill_group}/{new_version}/{skill_name}` and `{forge_version}` ← `{forge_data_folder}/{skill_name}/{new_version}`. Every later step reads and writes the new version there; `{manual_inventory}` keeps the amended path §4 bound.
304
304
 
305
305
  If no `{atomicWriteProbeOrder}` candidate resolves, or the stage, copy, write, publish or forge-folder step fails, HALT with status `halted-for-write-failure`, naming the failed step (for a missing helper: "skf-atomic-write.py is missing; re-install SKF"). The halt procedure takes `phase: "merge:new-version-folder"`, `path: "{skill_group}/{new_version}"`, `reason: "<the failed step>: <its error>"`; its rollback removes what this section created, `{version_staging}`, `{skill_group}/{new_version}/` and `{forge_data_folder}/{skill_name}/{new_version}/`, none of which existed before `begin` recorded them. The previous version is unchanged.