bmad-module-skill-forge 6.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 (82) 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 +10 -10
  8. package/package.json +1 -1
  9. package/src/shared/references/description-guard-protocol.md +12 -10
  10. package/src/shared/references/output-contract-schema.md +1 -1
  11. package/src/shared/scripts/schemas/skf-analyze-result-envelope.v1.json +1 -1
  12. package/src/shared/scripts/schemas/skf-audit-result-envelope.v1.json +1 -1
  13. package/src/shared/scripts/schemas/skf-campaign-result-envelope.v1.json +38 -0
  14. package/src/shared/scripts/schemas/skf-create-skill-result-envelope.v1.json +5 -0
  15. package/src/shared/scripts/schemas/skf-drop-skill-result-envelope.v1.json +1 -1
  16. package/src/shared/scripts/schemas/skf-export-result-envelope.v1.json +1 -1
  17. package/src/shared/scripts/schemas/skf-quick-skill-result-envelope.v1.json +8 -3
  18. package/src/shared/scripts/schemas/skf-refine-architecture-result-envelope.v1.json +1 -1
  19. package/src/shared/scripts/schemas/skf-stack-result-envelope.v1.json +6 -1
  20. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  21. package/src/shared/scripts/schemas/skf-update-result-envelope.v1.json +7 -2
  22. package/src/shared/scripts/schemas/skf-verify-stack-result-envelope.v1.json +1 -1
  23. package/src/shared/scripts/skf-parse-gaps.py +4 -1
  24. package/src/shared/scripts/skf-quick-batch.py +24 -7
  25. package/src/shared/scripts/skf-verify-provenance-completeness.py +809 -85
  26. package/src/skf-analyze-source/customize.toml +3 -2
  27. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  28. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  29. package/src/skf-analyze-source/references/headless-contract.md +2 -2
  30. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  31. package/src/skf-audit-skill/customize.toml +4 -3
  32. package/src/skf-audit-skill/references/report.md +1 -1
  33. package/src/skf-campaign/assets/campaign-state-schema.json +58 -0
  34. package/src/skf-campaign/references/campaign-contracts.md +1 -1
  35. package/src/skf-campaign/references/step-06-batch.md +2 -2
  36. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  37. package/src/skf-campaign/references/step-08-verify.md +3 -3
  38. package/src/skf-campaign/references/step-09-refine.md +3 -3
  39. package/src/skf-campaign/references/step-11-maintenance.md +1 -1
  40. package/src/skf-campaign/scripts/campaign-render-batch.py +43 -10
  41. package/src/skf-campaign/scripts/campaign-report.py +60 -4
  42. package/src/skf-campaign/scripts/campaign-state.py +82 -17
  43. package/src/skf-campaign/templates/campaign-report-template.md +4 -0
  44. package/src/skf-create-skill/SKILL.md +2 -2
  45. package/src/skf-create-skill/assets/skill-sections.md +2 -2
  46. package/src/skf-create-skill/customize.toml +4 -4
  47. package/src/skf-create-skill/references/batch-mode.md +3 -1
  48. package/src/skf-create-skill/references/report.md +9 -4
  49. package/src/skf-create-skill/references/validate.md +49 -29
  50. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  51. package/src/skf-create-stack-skill/references/report.md +3 -3
  52. package/src/skf-create-stack-skill/references/validate.md +21 -8
  53. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  54. package/src/skf-export-skill/references/summary.md +1 -1
  55. package/src/skf-quick-skill/SKILL.md +1 -1
  56. package/src/skf-quick-skill/references/batch-mode.md +30 -2
  57. package/src/skf-quick-skill/references/compile.md +2 -0
  58. package/src/skf-quick-skill/references/finalize.md +3 -2
  59. package/src/skf-quick-skill/references/halt-contract.md +3 -3
  60. package/src/skf-quick-skill/references/quick-extract.md +2 -1
  61. package/src/skf-quick-skill/references/write-and-validate.md +27 -11
  62. package/src/skf-refine-architecture/customize.toml +1 -1
  63. package/src/skf-refine-architecture/references/report.md +2 -2
  64. package/src/skf-rename-skill/references/report.md +1 -1
  65. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  66. package/src/skf-test-skill/references/coverage-check.md +4 -4
  67. package/src/skf-test-skill/references/inventory-contract.md +1 -1
  68. package/src/skf-test-skill/references/invocation-contract.md +1 -1
  69. package/src/skf-test-skill/references/report.md +1 -1
  70. package/src/skf-test-skill/references/scoring-rules.md +2 -2
  71. package/src/skf-test-skill/references/signature-comparison-rules.md +7 -2
  72. package/src/skf-test-skill/scripts/gap-ledger.py +196 -46
  73. package/src/skf-test-skill/scripts/load-coverage-inputs.py +253 -17
  74. package/src/skf-test-skill/scripts/score-signatures.py +9 -13
  75. package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
  76. package/src/skf-update-skill/customize.toml +1 -1
  77. package/src/skf-update-skill/references/gap-driven.md +1 -1
  78. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  79. package/src/skf-update-skill/references/merge.md +1 -1
  80. package/src/skf-update-skill/references/report.md +27 -9
  81. package/src/skf-update-skill/references/write.md +24 -12
  82. package/src/skf-verify-stack/references/report.md +1 -1
@@ -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)")