bmad-module-skill-forge 5.0.0 → 7.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 (104) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/docs/_data/pinned.yaml +1 -1
  3. package/docs/agents.md +1 -1
  4. package/docs/architecture.md +3 -3
  5. package/docs/campaign.md +1 -1
  6. package/docs/troubleshooting.md +9 -3
  7. package/docs/verifying-a-skill.md +9 -9
  8. package/docs/workflows.md +7 -7
  9. package/package.json +1 -1
  10. package/src/knowledge/tool-resolution.md +6 -6
  11. package/src/shared/references/headless-gate-convention.md +4 -1
  12. package/src/shared/references/output-contract-schema.md +3 -3
  13. package/src/shared/references/pipeline-contracts.md +2 -1
  14. package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
  15. package/src/shared/scripts/skf-build-change-manifest.py +332 -54
  16. package/src/shared/scripts/skf-classify-changed-files.py +15 -10
  17. package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
  18. package/src/shared/scripts/skf-extract-public-api.py +33 -13
  19. package/src/shared/scripts/skf-parse-gaps.py +3 -2
  20. package/src/shared/scripts/skf-quick-batch.py +16 -3
  21. package/src/shared/scripts/skf-verify-provenance-completeness.py +317 -44
  22. package/src/skf-analyze-source/SKILL.md +1 -0
  23. package/src/skf-analyze-source/customize.toml +3 -2
  24. package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
  25. package/src/skf-analyze-source/references/generate-briefs.md +5 -3
  26. package/src/skf-analyze-source/references/headless-contract.md +3 -3
  27. package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
  28. package/src/skf-audit-skill/SKILL.md +2 -1
  29. package/src/skf-audit-skill/references/report.md +1 -1
  30. package/src/skf-brief-skill/SKILL.md +1 -0
  31. package/src/skf-brief-skill/references/invocation-contract.md +2 -2
  32. package/src/skf-campaign/SKILL.md +1 -0
  33. package/src/skf-campaign/references/campaign-contracts.md +3 -1
  34. package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
  35. package/src/skf-campaign/references/step-07-capstone.md +3 -3
  36. package/src/skf-campaign/references/step-08-verify.md +3 -3
  37. package/src/skf-campaign/references/step-09-refine.md +3 -3
  38. package/src/skf-campaign/references/step-10-export.md +3 -2
  39. package/src/skf-campaign/scripts/campaign-state.py +22 -5
  40. package/src/skf-create-skill/SKILL.md +3 -2
  41. package/src/skf-create-skill/customize.toml +4 -4
  42. package/src/skf-create-skill/references/batch-mode.md +3 -1
  43. package/src/skf-create-skill/references/ccc-index-check.md +1 -1
  44. package/src/skf-create-skill/references/component-extraction.md +1 -1
  45. package/src/skf-create-skill/references/extract.md +1 -1
  46. package/src/skf-create-skill/references/generate-artifacts.md +1 -1
  47. package/src/skf-create-skill/references/report.md +9 -4
  48. package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
  49. package/src/skf-create-skill/references/validate.md +2 -2
  50. package/src/skf-create-stack-skill/SKILL.md +1 -0
  51. package/src/skf-create-stack-skill/references/generate-output.md +1 -1
  52. package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
  53. package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
  54. package/src/skf-create-stack-skill/references/report.md +1 -1
  55. package/src/skf-create-stack-skill/references/validate.md +12 -4
  56. package/src/skf-drop-skill/SKILL.md +1 -0
  57. package/src/skf-drop-skill/references/invocation-contract.md +1 -1
  58. package/src/skf-drop-skill/references/report.md +1 -1
  59. package/src/skf-export-skill/SKILL.md +1 -0
  60. package/src/skf-export-skill/references/invocation-contract.md +1 -1
  61. package/src/skf-export-skill/references/summary.md +1 -1
  62. package/src/skf-forger/SKILL.md +1 -0
  63. package/src/skf-forger/references/pipeline-mode.md +2 -2
  64. package/src/skf-quick-skill/SKILL.md +2 -1
  65. package/src/skf-quick-skill/references/batch-mode.md +2 -0
  66. package/src/skf-quick-skill/references/finalize.md +1 -1
  67. package/src/skf-quick-skill/references/halt-contract.md +2 -2
  68. package/src/skf-quick-skill/references/write-and-validate.md +20 -10
  69. package/src/skf-refine-architecture/SKILL.md +2 -1
  70. package/src/skf-refine-architecture/customize.toml +1 -1
  71. package/src/skf-refine-architecture/references/report.md +1 -1
  72. package/src/skf-rename-skill/SKILL.md +1 -0
  73. package/src/skf-rename-skill/references/invocation-contract.md +2 -2
  74. package/src/skf-setup/SKILL.md +1 -0
  75. package/src/skf-setup/references/ccc-index.md +1 -1
  76. package/src/skf-setup/references/invocation-contract.md +1 -1
  77. package/src/skf-test-skill/SKILL.md +1 -0
  78. package/src/skf-test-skill/assets/output-section-formats.md +4 -4
  79. package/src/skf-test-skill/references/coherence-check.md +8 -6
  80. package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
  81. package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
  82. package/src/skf-test-skill/references/coverage-check.md +30 -43
  83. package/src/skf-test-skill/references/init.md +1 -1
  84. package/src/skf-test-skill/references/inventory-contract.md +27 -0
  85. package/src/skf-test-skill/references/invocation-contract.md +3 -3
  86. package/src/skf-test-skill/references/report.md +3 -3
  87. package/src/skf-test-skill/references/scoring-rules.md +7 -7
  88. package/src/skf-test-skill/references/signature-comparison-rules.md +74 -0
  89. package/src/skf-test-skill/references/source-access-protocol.md +12 -8
  90. package/src/skf-test-skill/references/step-hard-gate.md +1 -0
  91. package/src/skf-test-skill/scripts/build-result-context.py +20 -3
  92. package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
  93. package/src/skf-test-skill/scripts/load-coverage-inputs.py +575 -9
  94. package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
  95. package/src/skf-test-skill/scripts/score-signatures.py +937 -76
  96. package/src/skf-test-skill/scripts/validate-inventory.py +325 -30
  97. package/src/skf-update-skill/SKILL.md +1 -0
  98. package/src/skf-update-skill/customize.toml +1 -1
  99. package/src/skf-update-skill/references/gap-driven.md +28 -22
  100. package/src/skf-update-skill/references/invocation-contract.md +1 -1
  101. package/src/skf-update-skill/references/merge.md +4 -4
  102. package/src/skf-update-skill/references/report.md +11 -4
  103. package/src/skf-update-skill/references/write.md +6 -5
  104. package/src/skf-verify-stack/SKILL.md +2 -1
@@ -71,8 +71,15 @@ not read as a missing public export nor as an orphan.
71
71
  Definition lines: after a citation resolves (the file exists and the line
72
72
  is in range, so the older reasons win), the line must define the entry's
73
73
  export. NAME is the raw `export_name` (not canonicalized); for a dotted
74
- name (`App.update`) it is the last segment. The rules follow the
75
- `source_file` extension:
74
+ name (`App.update`) it is the last segment. A bookkeeping variant
75
+ (`X_def`, `X_exact`, `a11y_X`, test-skill's Source Access Protocol fold
76
+ without `--fold-prefix` or `--keep`) that the cited file never names (no
77
+ whole identifier in its text) is checked against its base `X` instead,
78
+ its finding carries `folded_to`, and `summary.line_check_folded` counts
79
+ it; the raw name always comes first, so an export really named
80
+ `parse_exact`, or bound in a shape the rules do not cover, is judged as
81
+ written. classify-stale never folds. The rules
82
+ follow the `source_file` extension:
76
83
 
77
84
  - Python (`.py`, `.pyi`): the `def` / `async def` / `class NAME` line
78
85
  itself (a decorator line above it does not count); an assignment
@@ -262,7 +269,9 @@ Subcommands:
262
269
  "source_line": <int|null>,
263
270
  "reason": "file-missing" | "line-out-of-bounds" | "line-invalid"
264
271
  | "line-not-definition",
265
- "definition_lines": [<int>, ...]}, # line-not-definition only
272
+ "definition_lines": [<int>, ...], # line-not-definition only
273
+ "folded_to": "<base name>"}, # line-not-definition only,
274
+ # a variant checked as its base
266
275
  ...
267
276
  ],
268
277
  "citations": [
@@ -302,6 +311,7 @@ Subcommands:
302
311
  "stale_count": <int>,
303
312
  "citations_checked": <int>, # entries whose citation was resolved
304
313
  "line_check_skipped": <int>, # resolved entries with no line rule
314
+ "line_check_folded": <int>, # variants checked against their base
305
315
  "stale_check": "checked" | "skipped-no-source-root",
306
316
  "skill_citations_scanned": <int>, # [AST:]/[SRC:] with a line
307
317
  "skill_citations_matched": <int>, # of those, citing an entry
@@ -413,12 +423,15 @@ Subcommands:
413
423
  | "skipped-language",
414
424
  "definition_lines": [<int>, ...] | null, # checked only
415
425
  "source_line": <int> | null, # --line
416
- "line_is_definition": <bool> | null # --line, checked only
426
+ "line_is_definition": <bool> | null, # --line, checked only
427
+ "folded_to": "<base name>" # a folded variant only
417
428
  }
418
429
 
419
430
  With `--line`, a recorded line that is an indented match the column-0
420
431
  rule dropped joins `definition_lines`, and one that misses a Python
421
- `@overload` group gives its first line alone, as in `verify`.
432
+ `@overload` group gives its first line alone, as in `verify`. A
433
+ bookkeeping variant the file never names is checked against its base,
434
+ as in `verify`, and only then does the key `folded_to` appear.
422
435
 
423
436
  classify-stale --names <coverage.json> --provenance <provenance-map.json>
424
437
  --source-root <path> [--inventory <inventory.json>]
@@ -428,7 +441,8 @@ Subcommands:
428
441
  documented name the enumerated source surface lacks): the
429
442
  definition-lines rules above, run for every provenance entry whose raw
430
443
  `export_name` is the name, at its `source_file` and `source_line`, with
431
- its `export_type`. `--names` holds reconcile-coverage.py's result (its
444
+ its `export_type`, and with no bookkeeping-variant fold (the name is
445
+ judged as written). `--names` holds reconcile-coverage.py's result (its
432
446
  `stale` names) or a JSON array of names. `--inventory` holds the
433
447
  documented inventory, validate-inventory.py's `--output` file or its
434
448
  whole result: the `kind` it gives each name decides the module rule
@@ -441,7 +455,8 @@ Subcommands:
441
455
  "source": "<source_file>:<source_line>" | "<source_file>" | null,
442
456
  "defined_at": "<path>:<line>" | null,
443
457
  "declared_in": ["<path>:<line>", ...] | null,
444
- "reason": "not-defined" | "defined" | "unchecked" | "no-entry",
458
+ "reason": "not-defined" | "aliased" | "defined" | "unchecked"
459
+ | "no-entry",
445
460
  "entries": [{"source_file": "<as recorded>",
446
461
  "source_line": <as recorded>,
447
462
  "line_check": "checked" | "file-missing"
@@ -455,9 +470,30 @@ Subcommands:
455
470
  `fabricated` is true, with reason `not-defined`, when the name has an
456
471
  entry and every entry's `line_check` is `file-missing`, or `checked`
457
472
  with an empty `definition_lines`: the export is in neither the surface
458
- nor the cited file. Otherwise the reason is `defined` (a cited file
459
- defines the name), `unchecked` (no rule could check a cited file, and
460
- none defines it) or `no-entry` (the map has no entry for the name).
473
+ nor the cited file, and no item of an export list or of a Python
474
+ `from X import` clause elsewhere renames to it a name that file
475
+ declares. Such an alias (`export { API_Entry as Entry }` or
476
+ `export type { ... }`, on one line or an item of a multi-line list;
477
+ `from X import L as NAME`, on one line or an item in parentheses or
478
+ after a backslash) gives the reason `aliased` and `fabricated` false:
479
+ the first of the name's declarations the lookup below finds (its
480
+ `declared_in`, else the cited file's) that is such an item
481
+ `L as NAME`, where a file of a `checked` entry of the name declares
482
+ `L` by the same rule (no module or package so named counting), is its
483
+ `defined_at`. A TS/JS line is read with its comments removed and its
484
+ one-line strings blanked, a Python line with its `#` comment cut.
485
+ Neither the rename's `from` module nor `L`'s own binding is resolved,
486
+ so an `L` that another file declares under the same name counts too.
487
+ Any other rename stays fabricated: `export { default as NAME }`,
488
+ `const NAME = L`, `type NAME = L`, an import then an export of NAME,
489
+ Python `NAME = L`, a TS type assertion (`x as NAME`) and `as NAME` in
490
+ a comment or a string; and so does a name whose cited file is the
491
+ module so named and itself the export (a column-0 `export default` or
492
+ `module.exports =`), which settles it at its line 1, so no walk looks
493
+ for an alias elsewhere.
494
+ Otherwise the reason is `defined` (a cited file defines the name),
495
+ `unchecked` (no rule could check a cited file, and none defines it) or
496
+ `no-entry` (the map has no entry for the name).
461
497
  An entry with no `source_file` (update-skill writes one for an
462
498
  `unknown` NEW_EXPORT) cites no file to check: its `line_check` is
463
499
  `no-file`, which, like a skipped or unreadable check, neither proves
@@ -469,7 +505,8 @@ Subcommands:
469
505
  `defined_at` is where the source declares a name that is not
470
506
  fabricated, so test-skill can tell a documented extra (a name outside
471
507
  the surface that the source still declares) from stale documentation;
472
- a fabricated name is not looked up (null). A declaration is a line at
508
+ a fabricated name has none and no `declared_in` (both null), the
509
+ lookup reading it only for an alias. A declaration is a line at
473
510
  column 0 that the definition-line rules match with imports left out:
474
511
  a `def`, `class`, assignment, annotation, type alias, TS/JS
475
512
  declaration, `exports.NAME =`, or an alias that renames
@@ -484,9 +521,12 @@ Subcommands:
484
521
  `const NAME = require(...)`, `const { NAME } = require(...)`,
485
522
  `exports.NAME = require(...)`, `{ NAME: require(...) }`), an
486
523
  indented line and any line of a Python
487
- file that does not tokenize never count. A module or package named
488
- NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS `NAME.<ext>` or
489
- `NAME/index.<ext>`) declares NAME too: at its line 1 when no line of it
524
+ file that does not tokenize never count (an item of a multi-line list
525
+ counts at its own line, the list's opening line at column 0, so an
526
+ `aliased` name's `defined_at` is its rename's line). A module or
527
+ package named NAME (`NAME.py`, `NAME/__init__.py`, a TS/JS
528
+ `NAME.<ext>` or `NAME/index.<ext>`) declares NAME too: at its line 1
529
+ when no line of it
490
530
  declares NAME, unless `--inventory` gives NAME a `function`, `hook`,
491
531
  `class`, `interface`, `enum` or `type` kind (any of its kinds, when it
492
532
  gives several; a `method` kind is not read), no entry of NAME is a
@@ -510,10 +550,12 @@ Subcommands:
510
550
  and `defined_at` is the one item when the list has exactly one and no
511
551
  walked Python file that does not tokenize holds the name (a homonym,
512
552
  two files or more, gives null: test-skill decides it by the files the
513
- skill cites). The walk reads, sorted by path, every file below the
514
- walk root: the deepest folder that the files of the map's entries
515
- share, widened to its package root (Python: the nearest folder at or
516
- above it that holds `__init__.py`, then up while the parent holds
553
+ skill cites), save for an `aliased` name, whose `defined_at` is its
554
+ alias whatever the list holds. The walk reads, sorted by path, every
555
+ file below the walk root: the deepest folder that the files of the
556
+ map's entries share, widened to its package root (Python: the nearest
557
+ folder at or above it that holds `__init__.py`, then up while the
558
+ parent holds
517
559
  one, never the source root itself; TS/JS: the nearest folder at or
518
560
  above it that holds `package.json`), leaving out the entries whose
519
561
  file lies outside every package; the source root when none lies in
@@ -537,9 +579,12 @@ Subcommands:
537
579
  skips, those walk-only ones aside. The path is relative to the source
538
580
  root. A dotted name, or a map whose entries cite files
539
581
  but no Python or TS/JS one, is not looked up (`defined_at` and
540
- `declared_in` null, as for a fabricated name), and a filesystem error
541
- during the lookup gives every name null in both (the exit code stays
542
- 0).
582
+ `declared_in` null, as for a fabricated name). A filesystem error
583
+ during the lookup of the names that are not fabricated gives each of
584
+ them null in both (the exit code stays 0). The `not-defined` names are
585
+ looked up apart, after the others and whether or not that lookup
586
+ failed: an error in their walk or their alias check keeps only them
587
+ fabricated, and clears no other name's answer.
543
588
 
544
589
  kind-at --file <path> --line <n> --recipes <recipes>
545
590
  [--language <lang>] [--name <export_name>]
@@ -1046,6 +1091,28 @@ def definition_name(export_name: str) -> str:
1046
1091
  return export_name.rsplit(".", 1)[-1]
1047
1092
 
1048
1093
 
1094
+ # Bookkeeping variants folded to their base name, in rule order: test-skill's
1095
+ # Source Access Protocol defines the fold (`X_def`, `X_exact`, `a11y_X` -> `X`).
1096
+ # Keep identical to FOLD_SUFFIXES and FOLD_PREFIX in load-coverage-inputs.py.
1097
+ FOLD_SUFFIXES = ("_def", "_exact")
1098
+ FOLD_PREFIX = "a11y_"
1099
+
1100
+
1101
+ def fold_base(export_name: str) -> str | None:
1102
+ """The base name a bookkeeping variant folds to, None for a name that is
1103
+ no variant: a suffix in FOLD_SUFFIXES first, else FOLD_PREFIX, never the
1104
+ whole name (`_def` alone is no variant). The rules of
1105
+ load-coverage-inputs.py `fold_names`, without `--fold-prefix` or
1106
+ `--keep`: the line check tries the raw name first, so a real export
1107
+ named like a variant is never folded."""
1108
+ for suffix in FOLD_SUFFIXES:
1109
+ if export_name.endswith(suffix) and len(export_name) > len(suffix):
1110
+ return export_name[: -len(suffix)]
1111
+ if export_name.startswith(FOLD_PREFIX) and len(export_name) > len(FOLD_PREFIX):
1112
+ return export_name[len(FOLD_PREFIX):]
1113
+ return None
1114
+
1115
+
1049
1116
  def _read_lines(path: Path) -> list[str] | None:
1050
1117
  """Read a text file as lines numbered like `check_citation` counts them
1051
1118
  (universal newlines; a UTF-8 BOM is dropped so it never hides line 1).
@@ -2135,6 +2202,42 @@ def _definition_matches(
2135
2202
  return _line_matches(suffix, path, lines, export_name, cache, declarations_only)
2136
2203
 
2137
2204
 
2205
+ def _folded_matches(
2206
+ source_file: str,
2207
+ export_name: str,
2208
+ source_root: Path,
2209
+ cache: _SourceCache | None = None,
2210
+ ) -> tuple[tuple[list[int], set[int]] | None, str | None]:
2211
+ """(`_definition_matches` of the name the line check judges, the base
2212
+ name a bookkeeping variant was folded to, else None).
2213
+
2214
+ The raw name comes first. Only when the file never names it (no whole
2215
+ identifier in its text, so no shape the rules miss can define it) and
2216
+ `fold_base` gives a base that some line matches are the base's matches
2217
+ returned, with the base; otherwise the raw name's, with None. So a real
2218
+ export named like a variant (`parse_exact` defined as itself, or bound
2219
+ in a shape the rules do not cover) is judged as written, and
2220
+ classify-stale, which never folds, does not call this.
2221
+ """
2222
+ cache = cache if cache is not None else _SourceCache()
2223
+ matches = _definition_matches(source_file, export_name, source_root, cache)
2224
+ if matches is None or matches[1]:
2225
+ return matches, None
2226
+ base = fold_base(export_name)
2227
+ if base is None:
2228
+ return matches, None
2229
+ # the variant's own identifiers: `mockChannel_def`, or `a11y_App` of `a11y_App.update`
2230
+ own = sorted(set(export_name.split(".")) - set(base.split(".")))
2231
+ named = re.compile(r"(?<![\w$])(?:" + "|".join(map(re.escape, own)) + r")(?![\w$])")
2232
+ lines = cache.lines((source_root / source_file.strip().replace("\\", "/")).resolve())
2233
+ if not own or lines is None or any(named.search(text) for text in lines):
2234
+ return matches, None
2235
+ folded = _definition_matches(source_file, base, source_root, cache)
2236
+ if folded is None or not folded[1]:
2237
+ return matches, None
2238
+ return folded, base
2239
+
2240
+
2138
2241
  def _line_matches(
2139
2242
  suffix: str,
2140
2243
  path: Path,
@@ -2223,6 +2326,7 @@ def definition_lines_report(
2223
2326
  source_root: Path,
2224
2327
  line: int | None = None,
2225
2328
  export_type: str | None = None,
2329
+ fold: bool = True,
2226
2330
  ) -> dict:
2227
2331
  """What `verify` would say about one entry's line (the definition-lines
2228
2332
  subcommand). Raises ValueError when the file exists but cannot be read.
@@ -2234,6 +2338,12 @@ def definition_lines_report(
2234
2338
  (`_missed_definition_lines`): an indented match the column-0 rule
2235
2339
  dropped joins `definition_lines`, so the answer carries several lines,
2236
2340
  and a Python `@overload` group gives its first line alone.
2341
+
2342
+ With `fold` (the default, as `verify` checks), a bookkeeping variant the
2343
+ file never names is checked against its base name (`_folded_matches`),
2344
+ and the report gains `folded_to`, the base, only when it folded.
2345
+ classify-stale passes `fold=False`: the fabricated-signature test judges
2346
+ the documented name as written.
2237
2347
  """
2238
2348
  report: dict = {
2239
2349
  "source_file": source_file,
@@ -2253,15 +2363,21 @@ def definition_lines_report(
2253
2363
  report["line_check"] = "skipped-export-type" if covered else "skipped-language"
2254
2364
  return report
2255
2365
  cache = _SourceCache()
2256
- matches = _definition_matches(source_file, export_name, source_root, cache)
2366
+ if fold:
2367
+ matches, folded_to = _folded_matches(source_file, export_name, source_root, cache)
2368
+ else:
2369
+ matches, folded_to = _definition_matches(source_file, export_name, source_root, cache), None
2257
2370
  if matches is None:
2258
2371
  raise ValueError(f"failed to read source file {source_root / rel}")
2372
+ if folded_to is not None:
2373
+ report["folded_to"] = folded_to
2374
+ judged = folded_to or export_name
2259
2375
  defs, every = matches
2260
2376
  if line is not None:
2261
2377
  report["line_is_definition"] = line in defs
2262
2378
  if line not in defs:
2263
2379
  defs = _missed_definition_lines(
2264
- source_file, export_name, source_root, cache, line, defs, every
2380
+ source_file, judged, source_root, cache, line, defs, every
2265
2381
  )
2266
2382
  report["definition_lines"] = defs
2267
2383
  return report
@@ -2275,6 +2391,8 @@ STALE_NOT_DEFINED = "not-defined"
2275
2391
  STALE_DEFINED = "defined"
2276
2392
  STALE_UNCHECKED = "unchecked"
2277
2393
  STALE_NO_ENTRY = "no-entry"
2394
+ # a not-defined name a declaration renames to it from a name a cited file declares
2395
+ STALE_ALIASED = "aliased"
2278
2396
 
2279
2397
 
2280
2398
  def stale_names(data: object) -> list[str]:
@@ -2307,7 +2425,10 @@ def _entry_citation(entry: dict) -> str | None:
2307
2425
  def _stale_line_check(
2308
2426
  entry: dict, name: str, source_root: Path
2309
2427
  ) -> tuple[str, list[int] | None]:
2310
- """(line_check, definition_lines) of one provenance entry of a stale name."""
2428
+ """(line_check, definition_lines) of one provenance entry of a stale name.
2429
+
2430
+ The documented name is judged as written, with no bookkeeping-variant
2431
+ fold: a variant the cited file never defines stays fabricated."""
2311
2432
  source_file = _entry_file(entry)
2312
2433
  if source_file is None:
2313
2434
  # no file to check: it neither proves the name fabricated nor clears it
@@ -2321,6 +2442,7 @@ def _stale_line_check(
2321
2442
  source_root,
2322
2443
  line if isinstance(line, int) else None,
2323
2444
  export_type if isinstance(export_type, str) else None,
2445
+ fold=False,
2324
2446
  )
2325
2447
  except ValueError:
2326
2448
  # the file exists but cannot be read: nothing proves it
@@ -2656,6 +2778,68 @@ def baseline_gaps(
2656
2778
  return gaps, unchecked
2657
2779
 
2658
2780
 
2781
+ # A one-line TS/JS string literal, blanked before an export list is read.
2782
+ _JS_STRING_RE = re.compile(r"'(?:[^'\\]|\\.)*'|\"(?:[^\"\\]|\\.)*\"|`(?:[^`\\]|\\.)*`")
2783
+ _JS_IDENT_RE = re.compile(r"[A-Za-z_$][\w$]*")
2784
+
2785
+
2786
+ def _renamed_locals(lines: list[str], index: int, name: str, family: str) -> list[str]:
2787
+ """The names line `index` (from 0) of a file renames to `name` as an item
2788
+ of an export list (`export { L as NAME }` or `export type { ... }`, on
2789
+ one line or an item of a multi-line list) or of a Python
2790
+ `from X import` clause (on one line, or an item of one in parentheses
2791
+ or continued with a backslash): each `L` an identifier other than
2792
+ `name`, and in TS/JS other than `default` (`export { default as NAME }`
2793
+ renames the default export, no declared name). A TS/JS line is read
2794
+ with its `//` and `/* */` comments removed and each one-line string
2795
+ blanked, a Python line with its `#` comment cut (a `from X import`
2796
+ clause holds no string). The list opens at the nearest column-0
2797
+ `export {` (`from X import`) at or above the line, and must not close
2798
+ above it. Empty for any other line: `as NAME` in a comment or a string,
2799
+ a TS type assertion (`const NAME = x as NAME`), `import { L as NAME }`
2800
+ and Python's `import M as NAME` rename nothing here. Neither the `from`
2801
+ module nor the binding of `L` is resolved: the caller takes `L` as
2802
+ the name a cited file declares."""
2803
+ if not 0 <= index < len(lines) or not re.search(
2804
+ rf"(?<![\w$])as\s+{re.escape(name)}(?![\w$])", lines[index]
2805
+ ):
2806
+ return []
2807
+ python = family == "python"
2808
+ if python:
2809
+ code = [line.split("#", 1)[0] for line in lines[: index + 1]]
2810
+ opener, close, item_re = _PY_FROM_IMPORT_RE, ")", _PY_IMPORT_ITEM_RE
2811
+ else:
2812
+ code = [_JS_STRING_RE.sub('""', line) for line in _js_code_lines(lines[: index + 1])]
2813
+ opener, close, item_re = _JS_EXPORT_LIST_RE, "}", _JS_EXPORT_ITEM_RE
2814
+ for start in range(index, -1, -1):
2815
+ opened = opener.match(code[start]) if _is_column_0(code[start]) else None
2816
+ if opened is not None:
2817
+ break
2818
+ if start < index and close in code[start]:
2819
+ return [] # a list that closes above the line holds it not
2820
+ else:
2821
+ return []
2822
+ head = opened.group(1)
2823
+ if start < index:
2824
+ # an item of a list the opening line leaves open: in parentheses, or
2825
+ # a Python clause each of whose lines above ends with a backslash
2826
+ continued = all(line.rstrip().endswith("\\") for line in code[start:index])
2827
+ if close in head or (python and not head.lstrip().startswith("(") and not continued):
2828
+ return []
2829
+ head = code[index]
2830
+ locals_: list[str] = []
2831
+ for item in head.split(close, 1)[0].replace("(", " ").replace("\\", " ").split(","):
2832
+ found = item_re.match(item.strip())
2833
+ if found is None or found.group(2) != name:
2834
+ continue
2835
+ local = found.group(1)
2836
+ if local == name or (not python and local == "default"):
2837
+ continue
2838
+ if (_PY_IDENT_RE if python else _JS_IDENT_RE).fullmatch(local):
2839
+ locals_.append(local)
2840
+ return locals_
2841
+
2842
+
2659
2843
  class _DeclarationLookup:
2660
2844
  """Where the source declares the stale names (classify-stale's
2661
2845
  `defined_at`): a column-0 declaration, an alias that renames included, a
@@ -2677,7 +2861,10 @@ class _DeclarationLookup:
2677
2861
  on its path and the same skips but the walk-only ones, judged below the
2678
2862
  walk root when it lies there. The walk runs once for all the names no
2679
2863
  cited file settles: each file is read once, and only a file whose text
2680
- holds a name goes through the rules. Any error of the filesystem raises
2864
+ holds a name goes through the rules. `renamed` then tells which of a
2865
+ name's declarations is an alias of a name a cited file declares (an
2866
+ item of an export list or of a Python `from X import` clause), from
2867
+ the lines the lookup read. Any error of the filesystem raises
2681
2868
  (`_LOOKUP_ERRORS`)."""
2682
2869
 
2683
2870
  def __init__(self, entries: list[dict], source_root: Path) -> None:
@@ -2697,6 +2884,9 @@ class _DeclarationLookup:
2697
2884
  # the cited files read, kept for the walk: a few files at most
2698
2885
  self._cited: dict[tuple[str, ...], list[str] | None] = {}
2699
2886
  self._cited_cache = _SourceCache()
2887
+ # during the suspects' pass only (`keep_declaring`): the lines of each
2888
+ # file a declaration was found in, by path, which `renamed` reads
2889
+ self._declaring: dict[str, list[str]] | None = None
2700
2890
 
2701
2891
  # -- the walk root ------------------------------------------------------
2702
2892
 
@@ -2832,8 +3022,8 @@ class _DeclarationLookup:
2832
3022
  found.append((*below, filename))
2833
3023
  return sorted(found)
2834
3024
 
2835
- @staticmethod
2836
3025
  def _first(
3026
+ self,
2837
3027
  parts: tuple[str, ...],
2838
3028
  lines: list[str],
2839
3029
  name: str,
@@ -2842,11 +3032,16 @@ class _DeclarationLookup:
2842
3032
  ) -> str | None:
2843
3033
  """`file:line` of the first line of a file that declares `name`, else,
2844
3034
  with `module_fallback`, its line 1 when it is the module or package
2845
- so named (`declared_line`)."""
3035
+ so named (`declared_line`). After `keep_declaring`, the file's lines
3036
+ are kept for `renamed`."""
2846
3037
  line = declared_line(
2847
3038
  parts, name, lines=lines, cache=cache, module_fallback=module_fallback
2848
3039
  )
2849
- return f"{'/'.join(parts)}:{line}" if line is not None else None
3040
+ if line is None:
3041
+ return None
3042
+ if self._declaring is not None:
3043
+ self._declaring['/'.join(parts)] = lines
3044
+ return f"{'/'.join(parts)}:{line}"
2850
3045
 
2851
3046
  # -- the answer ---------------------------------------------------------
2852
3047
 
@@ -2929,6 +3124,36 @@ class _DeclarationLookup:
2929
3124
  hits[name].append(hit)
2930
3125
  return {name: sorted(_without_stubs(h)) for name, h in hits.items()}, unsure
2931
3126
 
3127
+ def keep_declaring(self) -> None:
3128
+ """From now on, keep the lines of each file a declaration is found in,
3129
+ for `renamed`: the suspects' pass, which comes last."""
3130
+ self._declaring = {}
3131
+
3132
+ def renamed(self, name: str, hits: list[str], files: list[str]) -> str | None:
3133
+ """The first of `hits` (`file:line` declarations of `name` the lookup
3134
+ found, sorted) whose line renames to `name`, as an item of an export
3135
+ list or of a Python `from X import` clause (`_renamed_locals`), a
3136
+ name `L` one of `files` (source_file values, as recorded) declares:
3137
+ `declared_line` gives `L` a line of that file, no module or package
3138
+ so named counting. The lines are the ones the lookup read after
3139
+ `keep_declaring` (`_first`), never read again. None when no hit is
3140
+ such an alias."""
3141
+ cited = list(dict.fromkeys(filter(None, map(_cited_parts, files))))
3142
+ for hit in sorted(hits):
3143
+ file, _, number = hit.rpartition(":")
3144
+ lines, family = (self._declaring or {}).get(file), _rule_family(file)
3145
+ if lines is None or family is None or not number.isdigit():
3146
+ continue
3147
+ for local in _renamed_locals(lines, int(number) - 1, name, family):
3148
+ for parts in cited:
3149
+ source = self._cited_lines(parts)
3150
+ if source is not None and declared_line(
3151
+ parts, local, lines=source, cache=self._cited_cache,
3152
+ module_fallback=False,
3153
+ ) is not None:
3154
+ return hit
3155
+ return None
3156
+
2932
3157
 
2933
3158
  def inventory_kinds(data: object, path: str) -> dict[str, frozenset[str]]:
2934
3159
  """name -> the kinds a documented inventory gives it (`--inventory`):
@@ -2965,18 +3190,26 @@ def classify_stale(
2965
3190
  """The fabricated-signature test for each stale name: the definition-lines
2966
3191
  rules at every provenance entry whose raw `export_name` is the name, and
2967
3192
  for a name that is not fabricated, where the source declares it
2968
- (`defined_at`, and `declared_in` when the walk looked for it). The
2969
- classify-stale section of the module docstring gives each reason, what
2970
- a `no-file` entry means, which entry gives `source` and how
2971
- `defined_at` and `declared_in` are found. `kinds` (`inventory_kinds`,
2972
- None without `--inventory`) keeps a name of a `NO_MODULE_KINDS` kind
2973
- from a module or package so named, unless an entry of the name is a
2974
- `module` or `package` one.
3193
+ (`defined_at`, and `declared_in` when the walk looked for it). A name
3194
+ the cited files do not define is looked up too, after the others and
3195
+ in a lookup of its own (an error there keeps it fabricated and clears
3196
+ no other name), with no module or package so named counting unless it
3197
+ is the export, and is `aliased`, not fabricated, when one of its
3198
+ declarations is an export-list or `from X import` item renaming to it
3199
+ a name a `checked` entry's file declares (`_DeclarationLookup.renamed`);
3200
+ any other such name keeps both null. The classify-stale section of the
3201
+ module docstring gives each reason, what a `no-file` entry means, which
3202
+ entry gives `source` and how `defined_at` and `declared_in` are found.
3203
+ `kinds` (`inventory_kinds`, None without `--inventory`) keeps a name of
3204
+ a `NO_MODULE_KINDS` kind from a module or package so named, unless an
3205
+ entry of the name is a `module` or `package` one.
2975
3206
  """
2976
3207
  entries = [e for e in prov.get("entries") or [] if isinstance(e, dict)]
2977
3208
  results: list[dict] = []
2978
- # the names to look up (never a fabricated one): name -> its cited files
3209
+ # the names to look up that are not fabricated: name -> its cited files
2979
3210
  cited: dict[str, list[str]] = {}
3211
+ # the not-defined names an alias may clear: name -> its checked entries' files
3212
+ suspects: dict[str, list[str]] = {}
2980
3213
  # the names a module so named does not declare
2981
3214
  strict: set[str] = set()
2982
3215
  for name in names:
@@ -3008,7 +3241,12 @@ def classify_stale(
3008
3241
  reason = STALE_UNCHECKED
3009
3242
  source = next(filter(None, map(_entry_citation, mine)), None)
3010
3243
  fabricated = reason == STALE_NOT_DEFINED
3011
- if not fabricated:
3244
+ if fabricated:
3245
+ # a missing file declares nothing an alias could rename
3246
+ checked = [c["source_file"] for c in checks if c["line_check"] == "checked"]
3247
+ if checked:
3248
+ suspects[name] = checked
3249
+ else:
3012
3250
  cited[name] = [f for f in map(_entry_file, mine) if f is not None]
3013
3251
  documented = (kinds or {}).get(name, frozenset())
3014
3252
  if documented & NO_MODULE_KINDS and not any(map(_module_entry, mine)):
@@ -3024,16 +3262,43 @@ def classify_stale(
3024
3262
  "entries": checks,
3025
3263
  }
3026
3264
  )
3265
+ lookup: _DeclarationLookup | None = None
3027
3266
  try:
3028
- found, declared = _DeclarationLookup(entries, source_root).find(
3029
- cited, frozenset(strict)
3030
- )
3267
+ lookup = _DeclarationLookup(entries, source_root)
3268
+ found, declared = lookup.find(cited, frozenset(strict))
3031
3269
  except _LOOKUP_ERRORS:
3032
3270
  # a lookup that fails proves nothing: every name stays null
3033
3271
  found, declared = {}, {}
3272
+ # the suspects' own lookup, after: an error there clears no name settled
3273
+ # above, and keeps every suspect fabricated
3274
+ aliased: dict[str, str] = {}
3275
+ suspect_declared: dict[str, list[str]] = {}
3276
+ if suspects and lookup is not None:
3277
+ try:
3278
+ # a suspect is strict: a cited module so named settles it before
3279
+ # the walk only when it is the export
3280
+ lookup.keep_declaring()
3281
+ suspect_found, suspect_declared = lookup.find(suspects, frozenset(suspects))
3282
+ for name, files in suspects.items():
3283
+ hits = suspect_declared[name] if name in suspect_declared else (
3284
+ [suspect_found[name]] if name in suspect_found else []
3285
+ )
3286
+ hit = lookup.renamed(name, hits, files)
3287
+ if hit is not None:
3288
+ aliased[name] = hit
3289
+ except _LOOKUP_ERRORS:
3290
+ aliased, suspect_declared = {}, {}
3034
3291
  for item in results:
3035
- item["defined_at"] = found.get(item["name"])
3036
- item["declared_in"] = declared.get(item["name"])
3292
+ name = item["name"]
3293
+ if name in suspects:
3294
+ if name in aliased:
3295
+ item.update(
3296
+ fabricated=False, reason=STALE_ALIASED,
3297
+ defined_at=aliased[name], declared_in=suspect_declared.get(name),
3298
+ )
3299
+ continue # any other suspect stays fabricated, both null
3300
+ item["defined_at"] = found.get(name)
3301
+ item["declared_in"] = declared.get(name)
3037
3302
  return results
3038
3303
 
3039
3304
 
@@ -3677,6 +3942,7 @@ def verify(
3677
3942
  stale: list[dict] = []
3678
3943
  citations_checked = 0
3679
3944
  line_check_skipped = 0
3945
+ line_check_folded = 0
3680
3946
  if source_root is not None:
3681
3947
  cache = _SourceCache()
3682
3948
  entries = prov.get("entries")
@@ -3717,10 +3983,14 @@ def verify(
3717
3983
  if not line_rule_applies(sf, entry.get("export_type")):
3718
3984
  line_check_skipped += 1
3719
3985
  continue
3720
- matches = _definition_matches(sf, name, source_root, cache)
3986
+ # A bookkeeping variant the file never names is checked
3987
+ # against its base name (`folded_to`).
3988
+ matches, folded_to = _folded_matches(sf, name, source_root, cache)
3721
3989
  if matches is None:
3722
3990
  line_check_skipped += 1
3723
3991
  continue
3992
+ if folded_to is not None:
3993
+ line_check_folded += 1
3724
3994
  defs, every = matches
3725
3995
  if line not in defs:
3726
3996
  # An indented match the column-0 rule dropped joins
@@ -3728,8 +3998,10 @@ def verify(
3728
3998
  # group gives its first line alone, the move target.
3729
3999
  item["reason"] = STALE_LINE_NOT_DEFINITION
3730
4000
  item["definition_lines"] = _missed_definition_lines(
3731
- sf, name, source_root, cache, line, defs, every
4001
+ sf, folded_to or name, source_root, cache, line, defs, every
3732
4002
  )
4003
+ if folded_to is not None:
4004
+ item["folded_to"] = folded_to
3733
4005
  stale.append(item)
3734
4006
  stale.sort(key=lambda s: (s["export_name"], s.get("source_file") or ""))
3735
4007
 
@@ -3771,6 +4043,7 @@ def verify(
3771
4043
  "stale_count": len(stale),
3772
4044
  "citations_checked": citations_checked,
3773
4045
  "line_check_skipped": line_check_skipped,
4046
+ "line_check_folded": line_check_folded,
3774
4047
  "stale_check": "checked"
3775
4048
  if source_root is not None
3776
4049
  else "skipped-no-source-root",
@@ -28,6 +28,7 @@ You are a source code analyst and decomposition architect collaborating with a d
28
28
  These rules apply to every step in this workflow:
29
29
 
30
30
  - Only load one step file at a time — never preload future steps
31
+ - Run every subagent in the foreground (parallel ones as several calls in one message, never with `run_in_background`) and wait for every long-running command to exit before the next instruction; in a headless run, never end a turn while either still runs, since under `claude -p` a run stops when its turn ends.
31
32
  - Always communicate in `{communication_language}`; the text persisted into `skill-brief.yaml` (each recommendation's `description` and `scope.notes`) is written in `{document_output_language}`
32
33
  - If `{headless_mode}` is true, auto-proceed through confirmation gates with their default action, and record each one in the run sink as the gate says
33
34
 
@@ -59,8 +59,9 @@ analysis_report_template_path = "templates/analysis-report-template.md"
59
59
  # written the result files, at the end of every run that writes them
60
60
  # (generate-briefs.md, step-auto-scope.md and auto-docs-only.md), as:
61
61
  # <on_complete> --result-path=<forge_data_folder>/analyze-source-result-latest.json
62
- # A run whose result file could not be written (result_path null in the
63
- # envelope) and a HALT call no hook. Useful for Slack notifications,
62
+ # A HALT calls no hook, nor does a run whose result file or its -latest
63
+ # copy could not be written (result_path null, or a result_file_write_failed
64
+ # warning naming the copy, in the envelope). Useful for Slack notifications,
64
65
  # dashboard ingest, CI hooks, or chaining into a brief linter. A hook
65
66
  # failure is displayed and never fails the run.
66
67
  #
@@ -145,9 +145,9 @@ Add `"coexistence": "alongside"` when `{coexistence_suffix}` is non-empty ([A]lo
145
145
  uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
146
146
  ```
147
147
 
148
- The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, display the line verbatim. A result file that could not be written leaves the line's `result_path` null and a `result_file_write_failed` warning in it, and the run still finishes. If the emitter exits non-zero, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `auto-docs-only:5`, path `{forge_data_folder}`): "The result contract could not be written: {its message}."
148
+ The emitter stamps the UTC time and the run id into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout. When `{headless_mode}` is true, display the line verbatim. 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, correct `result-context.json` from the message on its stderr and run it once more; if it fails again, HARD HALT (exit code 4, `halt_reason: "write-failed"`, phase `auto-docs-only:5`, path `{forge_data_folder}`): "The result contract could not be written: {its message}."
149
149
 
150
- If `{onCompleteCommand}` is non-empty and the line's `result_path` is not null, invoke it now: `{onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json`. Display a hook failure; it never fails the run.
150
+ If `{onCompleteCommand}` is non-empty, the line's `result_path` is not null and its `warnings` hold no `result_file_write_failed` entry naming `analyze-source-result-latest.json`, invoke it now: `{onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json`. Display a hook failure; it never fails the run. When `{onCompleteCommand}` is non-empty but `result_path` is null or a `result_file_write_failed` warning names `analyze-source-result-latest.json` (the copy alone failed), skip the hook and say so: "on_complete skipped: `analyze-source-result-latest.json` was not written."
151
151
 
152
152
  ### 6. Chain to health check
153
153