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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/_data/pinned.yaml +1 -1
- package/docs/agents.md +1 -1
- package/docs/architecture.md +3 -3
- package/docs/campaign.md +1 -1
- package/docs/troubleshooting.md +9 -3
- package/docs/verifying-a-skill.md +9 -9
- package/docs/workflows.md +7 -7
- package/package.json +1 -1
- package/src/knowledge/tool-resolution.md +6 -6
- package/src/shared/references/headless-gate-convention.md +4 -1
- package/src/shared/references/output-contract-schema.md +3 -3
- package/src/shared/references/pipeline-contracts.md +2 -1
- package/src/shared/scripts/schemas/skf-test-result-envelope.v1.json +1 -1
- package/src/shared/scripts/skf-build-change-manifest.py +332 -54
- package/src/shared/scripts/skf-classify-changed-files.py +15 -10
- package/src/shared/scripts/skf-emit-result-envelope.py +58 -15
- package/src/shared/scripts/skf-extract-public-api.py +33 -13
- package/src/shared/scripts/skf-parse-gaps.py +3 -2
- package/src/shared/scripts/skf-quick-batch.py +16 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +317 -44
- package/src/skf-analyze-source/SKILL.md +1 -0
- package/src/skf-analyze-source/customize.toml +3 -2
- package/src/skf-analyze-source/references/auto-docs-only.md +2 -2
- package/src/skf-analyze-source/references/generate-briefs.md +5 -3
- package/src/skf-analyze-source/references/headless-contract.md +3 -3
- package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
- package/src/skf-audit-skill/SKILL.md +2 -1
- package/src/skf-audit-skill/references/report.md +1 -1
- package/src/skf-brief-skill/SKILL.md +1 -0
- package/src/skf-brief-skill/references/invocation-contract.md +2 -2
- package/src/skf-campaign/SKILL.md +1 -0
- package/src/skf-campaign/references/campaign-contracts.md +3 -1
- package/src/skf-campaign/references/step-05-skill-loop.md +2 -2
- package/src/skf-campaign/references/step-07-capstone.md +3 -3
- package/src/skf-campaign/references/step-08-verify.md +3 -3
- package/src/skf-campaign/references/step-09-refine.md +3 -3
- package/src/skf-campaign/references/step-10-export.md +3 -2
- package/src/skf-campaign/scripts/campaign-state.py +22 -5
- package/src/skf-create-skill/SKILL.md +3 -2
- package/src/skf-create-skill/customize.toml +4 -4
- package/src/skf-create-skill/references/batch-mode.md +3 -1
- package/src/skf-create-skill/references/ccc-index-check.md +1 -1
- package/src/skf-create-skill/references/component-extraction.md +1 -1
- package/src/skf-create-skill/references/extract.md +1 -1
- package/src/skf-create-skill/references/generate-artifacts.md +1 -1
- package/src/skf-create-skill/references/report.md +9 -4
- package/src/skf-create-skill/references/sub/ccc-discover.md +1 -1
- package/src/skf-create-skill/references/validate.md +2 -2
- package/src/skf-create-stack-skill/SKILL.md +1 -0
- package/src/skf-create-stack-skill/references/generate-output.md +1 -1
- package/src/skf-create-stack-skill/references/invocation-contract.md +1 -1
- package/src/skf-create-stack-skill/references/parallel-extract.md +2 -2
- package/src/skf-create-stack-skill/references/report.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +12 -4
- package/src/skf-drop-skill/SKILL.md +1 -0
- package/src/skf-drop-skill/references/invocation-contract.md +1 -1
- package/src/skf-drop-skill/references/report.md +1 -1
- package/src/skf-export-skill/SKILL.md +1 -0
- package/src/skf-export-skill/references/invocation-contract.md +1 -1
- package/src/skf-export-skill/references/summary.md +1 -1
- package/src/skf-forger/SKILL.md +1 -0
- package/src/skf-forger/references/pipeline-mode.md +2 -2
- package/src/skf-quick-skill/SKILL.md +2 -1
- package/src/skf-quick-skill/references/batch-mode.md +2 -0
- package/src/skf-quick-skill/references/finalize.md +1 -1
- package/src/skf-quick-skill/references/halt-contract.md +2 -2
- package/src/skf-quick-skill/references/write-and-validate.md +20 -10
- package/src/skf-refine-architecture/SKILL.md +2 -1
- package/src/skf-refine-architecture/customize.toml +1 -1
- package/src/skf-refine-architecture/references/report.md +1 -1
- package/src/skf-rename-skill/SKILL.md +1 -0
- package/src/skf-rename-skill/references/invocation-contract.md +2 -2
- package/src/skf-setup/SKILL.md +1 -0
- package/src/skf-setup/references/ccc-index.md +1 -1
- package/src/skf-setup/references/invocation-contract.md +1 -1
- package/src/skf-test-skill/SKILL.md +1 -0
- package/src/skf-test-skill/assets/output-section-formats.md +4 -4
- package/src/skf-test-skill/references/coherence-check.md +8 -6
- package/src/skf-test-skill/references/coverage-check-branches.md +23 -3
- package/src/skf-test-skill/references/coverage-check-tiers.md +3 -3
- package/src/skf-test-skill/references/coverage-check.md +30 -43
- package/src/skf-test-skill/references/init.md +1 -1
- package/src/skf-test-skill/references/inventory-contract.md +27 -0
- package/src/skf-test-skill/references/invocation-contract.md +3 -3
- package/src/skf-test-skill/references/report.md +3 -3
- package/src/skf-test-skill/references/scoring-rules.md +7 -7
- package/src/skf-test-skill/references/signature-comparison-rules.md +74 -0
- package/src/skf-test-skill/references/source-access-protocol.md +12 -8
- package/src/skf-test-skill/references/step-hard-gate.md +1 -0
- package/src/skf-test-skill/scripts/build-result-context.py +20 -3
- package/src/skf-test-skill/scripts/gap-ledger.py +78 -13
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +575 -9
- package/src/skf-test-skill/scripts/locate-export-segments.py +591 -63
- package/src/skf-test-skill/scripts/score-signatures.py +937 -76
- package/src/skf-test-skill/scripts/validate-inventory.py +325 -30
- package/src/skf-update-skill/SKILL.md +1 -0
- package/src/skf-update-skill/customize.toml +1 -1
- package/src/skf-update-skill/references/gap-driven.md +28 -22
- package/src/skf-update-skill/references/invocation-contract.md +1 -1
- package/src/skf-update-skill/references/merge.md +4 -4
- package/src/skf-update-skill/references/report.md +11 -4
- package/src/skf-update-skill/references/write.md +6 -5
- 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.
|
|
75
|
-
`
|
|
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>, ...]
|
|
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
|
|
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
|
|
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" | "
|
|
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
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
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
|
|
488
|
-
|
|
489
|
-
`
|
|
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)
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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)
|
|
541
|
-
during the lookup
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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).
|
|
2969
|
-
|
|
2970
|
-
a
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
`
|
|
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
|
|
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
|
|
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
|
-
|
|
3029
|
-
|
|
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
|
-
|
|
3036
|
-
|
|
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
|
-
|
|
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
|
|
63
|
-
#
|
|
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
|
|
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
|
|
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
|
|