bmad-module-skill-forge 6.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/workflows.md +4 -4
- package/package.json +1 -1
- package/src/shared/references/output-contract-schema.md +1 -1
- package/src/shared/scripts/skf-quick-batch.py +16 -3
- package/src/shared/scripts/skf-verify-provenance-completeness.py +209 -33
- 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 +2 -2
- package/src/skf-analyze-source/references/step-auto-scope.md +2 -2
- package/src/skf-campaign/references/campaign-contracts.md +1 -1
- 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/scripts/campaign-state.py +22 -5
- package/src/skf-create-skill/SKILL.md +2 -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/report.md +7 -2
- package/src/skf-create-skill/references/validate.md +1 -1
- package/src/skf-create-stack-skill/references/validate.md +12 -4
- package/src/skf-quick-skill/SKILL.md +1 -1
- package/src/skf-quick-skill/references/batch-mode.md +2 -0
- package/src/skf-quick-skill/references/write-and-validate.md +19 -9
- package/src/skf-test-skill/references/coverage-check.md +2 -2
- package/src/skf-test-skill/references/inventory-contract.md +1 -1
- package/src/skf-test-skill/references/scoring-rules.md +2 -2
- package/src/skf-test-skill/references/signature-comparison-rules.md +3 -1
- package/src/skf-test-skill/scripts/load-coverage-inputs.py +241 -11
- package/src/skf-test-skill/scripts/score-signatures.py +9 -13
- package/src/skf-test-skill/scripts/validate-inventory.py +91 -12
- package/src/skf-update-skill/customize.toml +1 -1
- package/src/skf-update-skill/references/report.md +10 -3
- package/src/skf-update-skill/references/write.md +1 -1
package/docs/_data/pinned.yaml
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
# Enforced two ways: (1) release.yaml bumps this line in the same commit
|
|
33
33
|
# as package.json + marketplace.json on every release; (2) validate-docs-drift.js
|
|
34
34
|
# cross-checks this value against package.json.version and fails on mismatch.
|
|
35
|
-
skf_version: "
|
|
35
|
+
skf_version: "7.0.0"
|
|
36
36
|
|
|
37
37
|
# Path to the oh-my-skills repo, resolved relative to this repo's root.
|
|
38
38
|
# Override at runtime with the OMS environment variable if oh-my-skills
|
package/docs/workflows.md
CHANGED
|
@@ -73,7 +73,7 @@ Each workflow is also a skill you can run directly, without Ferris: `/skf-setup`
|
|
|
73
73
|
|
|
74
74
|
**Which brief:** Name the brief by its path or by the skill's name. With no brief path, skill name or `--batch`, CS loads the only brief in `forge_data_folder` and names it in its opening banner. When the folder holds several, an interactive run asks which one to compile, and a headless run stops with exit `2` (`brief-missing`), naming them.
|
|
75
75
|
|
|
76
|
-
**Batch mode:** The paths after `--batch` name `skill-brief.yaml` files or folders of briefs; with no path, CS takes the briefs in `forge_data_folder`. Every brief is validated before the first one compiles, and the plan lists the briefs to compile and each refused brief: one that fails the brief schema, with the validator's first message, or one whose `name` an earlier brief of the batch already has. A HARD HALT ends only its own brief. The batch writes `create-skill-batch-<timestamp>.json` and `create-skill-batch-latest.json` under `<skills_output_folder>/_batch/` and exits with the highest exit code among its briefs, `0` when every brief finished. Run again with the same briefs, a batch that an ended session left goes on where it stopped.
|
|
76
|
+
**Batch mode:** The paths after `--batch` name `skill-brief.yaml` files or folders of briefs; with no path, CS takes the briefs in `forge_data_folder`. Every brief is validated before the first one compiles, and the plan lists the briefs to compile and each refused brief: one that fails the brief schema, with the validator's first message, or one whose `name` an earlier brief of the batch already has. A HARD HALT ends only its own brief. The batch writes `create-skill-batch-<timestamp>.json` and `create-skill-batch-latest.json` under `<skills_output_folder>/_batch/` and exits with the highest exit code among its briefs, `0` when every brief finished. Its result is the file its `batch_summary` event's `summary_path` names: the `-latest.json` copy holds the last batch summary written, which may be another batch's. Run again with the same briefs, a batch that an ended session left goes on where it stopped.
|
|
77
77
|
|
|
78
78
|
**Safety:** Writes a version only into a skill folder SKF generated, or a new one. When `skills_output_folder` already holds a folder with the skill's name that SKF did not generate, or a version folder SKF did not generate, it stops before writing anything (`not-skf-output`); an SKF skill still in the old flat layout stops with `flat-layout` until `@Ferris TS` moves it. Set a different `name` in the brief to create the skill beside a folder SKF did not generate.
|
|
79
79
|
|
|
@@ -549,7 +549,7 @@ Setup is not the only workflow whose result line is the run's final message: a h
|
|
|
549
549
|
|
|
550
550
|
4. **Success result contract.** A finished run prints one `SKF_QUICK_SKILL_RESULT_JSON: {…}` line on `stdout`. Its `summary` carries `quality_score` (the skill-check score of the written package, not a Test Skill score), `validation_issues`, `repo_shape`, `language_resolution` and `active_pointer`, beside the top-level `headless_decisions` and `warnings`, such as `also_found_in` when a headless run kept the first registry's pick of a package name another registry also holds. The run also writes `quick-skill-result-latest.json` in the skill package.
|
|
551
551
|
|
|
552
|
-
**Batch mode (`--batch <file>`).** Drives N targets in sequence: each target runs steps 1 to 6, and the health check runs once, after the batch summary. Input format: one target per line, `#` comments and blank lines ignored, optional per-line modifiers `language=<lang>` and `scope=<path>`. Each target's output lands in its own `{skill_package}/`; an aggregated summary writes to `{skills_output_folder}/_batch/quick-skill-batch-{ts}.json` (with `quick-skill-batch-latest.json` copy). Per-target boundary events (`{"batch":N,"target":"…","status":"start|done|fail",…}`) and a final `{"batch_summary":true,…}` event extend the per-step event stream above. The batch exits with the highest exit code among its failed targets (`0` when none failed); `--fail-fast` stops it at the first failed target and exits with that target's code. A batch refused before its first target writes no summary and exits `2`, or `4` when its run folder cannot be written. Full input grammar, summary schema, and exit-code rules in [`src/skf-quick-skill/references/batch-mode.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-quick-skill/references/batch-mode.md).
|
|
552
|
+
**Batch mode (`--batch <file>`).** Drives N targets in sequence: each target runs steps 1 to 6, and the health check runs once, after the batch summary. Input format: one target per line, `#` comments and blank lines ignored, optional per-line modifiers `language=<lang>` and `scope=<path>`. Each target's output lands in its own `{skill_package}/`; an aggregated summary writes to `{skills_output_folder}/_batch/quick-skill-batch-{ts}.json` (with `quick-skill-batch-latest.json` copy). Per-target boundary events (`{"batch":N,"target":"…","status":"start|done|fail",…}`) and a final `{"batch_summary":true,…}` event extend the per-step event stream above. The batch's result is the file that event's `summary_path` names: the `-latest` copy holds the last batch summary written, which may be another batch's. The batch exits with the highest exit code among its failed targets (`0` when none failed); `--fail-fast` stops it at the first failed target and exits with that target's code. A batch refused before its first target writes no summary and exits `2`, or `4` when its run folder cannot be written. Full input grammar, summary schema, and exit-code rules in [`src/skf-quick-skill/references/batch-mode.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-quick-skill/references/batch-mode.md).
|
|
553
553
|
|
|
554
554
|
**Exception: `/skf-brief-skill` headless emits a final result envelope and supports presets.** Headless `/skf-brief-skill` is a first-class building block for scripted brief generation (for example, seeding briefs for N SaaS SDKs that share scope and authority defaults). Three operational contracts go beyond per-gate auto-proceed:
|
|
555
555
|
|
|
@@ -576,7 +576,7 @@ Setup is not the only workflow whose result line is the run's final message: a h
|
|
|
576
576
|
- **`/skf-rename-skill` (RS)**: exit `2` `input-missing`/`input-invalid`, `3` resolution-failure (`manifest-corrupt`, `nothing-to-rename`), `4` write-failure (`copy-failed`, `write-failed`, `manifest-write-failed`; the context-file rebuild is best-effort and never halts), `5` state-conflict (`name-collision`, `source-authority-blocked`, `halted-for-concurrent-run` while another rename of the same skill holds its run lock or when this run's own lock went stale while it waited at a prompt, `not-skf-output` for a folder SKF did not generate, or a forge folder that also holds other files or that is a link, is not a folder, or cannot be listed; `flat-layout` for a skill not yet migrated; `verify-failed` when the old name is still in the renamed files, which rolls the rename back with the old skill intact, see [Troubleshooting](/docs/troubleshooting.md)). The run lock is `.skf-rename-<name>.lock` in `forge_data_folder`, and it goes stale 60 minutes after it was taken or renewed: when no other rename of that skill is running, delete the lock file the message names, or wait until the time it gives, then run the rename again. When this run's own lock went stale at a prompt, nothing was changed: run the rename again. Exit codes in [`src/skf-rename-skill/references/exit-codes.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-rename-skill/references/exit-codes.md); the envelope in [`src/skf-rename-skill/references/invocation-contract.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-rename-skill/references/invocation-contract.md) § "Result Contract (Headless)".
|
|
577
577
|
- **`/skf-refine-architecture` (RA)**: exit `2` `input-missing`/`input-invalid` (`input-invalid` also covers a Verify Stack report with a `schemaVersion` other than `1.0`, a missing or doubled verdict table, or an unknown verdict token), `3` resolution-failure (`output-folder-unconfigured`, `forge-folder-unconfigured`, and `resolution-failure` when the result emitter or `scripts/skf-check-preservation.py` cannot start, when the refinement rules cannot be read, lack one of their six tables or break a tier rule (phase `init:rules`), or when the skill inventory helper (phase `init:inventory`) or the co-mention helper (phase `gap-analysis:comention`) is missing or its call fails, or the Verify Stack report validator (phase `init:feasibility-validator`) is missing), `4` write-failure (`write-failed`, and `preservation-failed` when the draft would lose or change a line of the original: at once in a headless run, or when the promotion still finds it after one rebuild), `5` state-conflict (`insufficient-skills`: no skill SKF generated was found), `7` `inventory-unreliable`, `8` `recovery-failed` (at step 3, the Verify Stack report cannot be read again, Verify Stack rewrote it during the run, or the refinement rules no longer pass step 1's check; the saved state is not enough to rebuild the findings; or a record of the draft's build or promotion is missing at step 6). Exit codes and the envelope in [`src/skf-refine-architecture/references/exit-codes.md`](https://github.com/armelhbobdad/bmad-module-skill-forge/blob/main/src/skf-refine-architecture/references/exit-codes.md).
|
|
578
578
|
|
|
579
|
-
**Reading the result lines.** Read the run's own line: a run that printed none has no result to read, whatever the process exit code (`claude -p` exits `0` when the agent ends its turn early): it was cut off, or could not write its result. Treat it as a failure, and never read a `-latest.json` result file in its place, since that file may then describe an earlier run. A cut-off run can also leave its run lock until the lock goes stale, 60 minutes after it was taken or renewed: Test Skill's `.test-skill.lock`, Update Skill's `.skf-update.lock` or Rename Skill's `.skf-rename-<name>.lock` ([Troubleshooting](/docs/troubleshooting.md#test-skill-stops-with-another-run-active)). Check each line's `status` field first (for US, `skf_update.status`). For most workflows it only says whether the run finished or stopped on an error, so read the outcome from the fields below: a TS run whose skill scores below its threshold still reports `status: "success"`, with `verdict: "FAIL"`. AN, CS, SS, AS, TS, EX and VS also give each outcome a stable exit code, which the line carries as `exit_code` (a CS halt has `2` to `6`).
|
|
579
|
+
**Reading the result lines.** Read the run's own line: a run that printed none has no result to read, whatever the process exit code (`claude -p` exits `0` when the agent ends its turn early): it was cut off, or could not write its result. Treat it as a failure, and never read a `-latest.json` result file in its place, since that file may then describe an earlier run. A Quick Skill or Create Skill `--batch` run is read from its `batch_summary` event and the summary file that event's `summary_path` names (the event plus the batch run folder's `batch.jsonl` when `summary_path` is null), never from `quick-skill-batch-latest.json` or `create-skill-batch-latest.json`: each holds the last batch summary written, which may be another batch's. The event comes before the health check, so a caller reads it from the session's output stream (`claude -p --output-format stream-json`) or transcript, not from the final message. A printed `batch_summary` event stays the batch's result even if a halt line follows it. A batch that printed no `batch_summary` event has no batch result, whatever the process exit code: a halt line of its own (phase `batch-mode`, or `on-activation` for Quick Skill) says why it stopped, and with no halt line it was cut off or could not print its halt line. Each target it printed a `done` or `fail` event for ended as that event says, and a target with only a `start` event has no result. After a cut-off batch, run it again: a Quick Skill batch starts over from the first target, and a Create Skill batch run with the same briefs goes on with the brief it was working on. A cut-off run can also leave its run lock until the lock goes stale, 60 minutes after it was taken or renewed: Test Skill's `.test-skill.lock`, Update Skill's `.skf-update.lock` or Rename Skill's `.skf-rename-<name>.lock` ([Troubleshooting](/docs/troubleshooting.md#test-skill-stops-with-another-run-active)). Check each line's `status` field first (for US, `skf_update.status`). For most workflows it only says whether the run finished or stopped on an error, so read the outcome from the fields below: a TS run whose skill scores below its threshold still reports `status: "success"`, with `verdict: "FAIL"`. AN, CS, SS, AS, TS, EX and VS also give each outcome a stable exit code, which the line carries as `exit_code` (a CS halt has `2` to `6`).
|
|
580
580
|
|
|
581
581
|
| Workflow | Result line | Fields worth reading |
|
|
582
582
|
| --- | --- | --- |
|
|
@@ -605,7 +605,7 @@ Settings every workflow has:
|
|
|
605
605
|
|
|
606
606
|
- `persistent_facts`: rules the workflow keeps in mind for the whole run. Every workflow except Setup Forge loads any `project-context.md` in your project by default. In a team or personal override, an entry that starts with `!` drops each earlier entry it names and loads nothing itself. To turn that default off for one workflow, add `persistent_facts = ["!file:{project-root}/**/project-context.md"]` under `[workflow]` in `_bmad/custom/<skill-name>.toml`. Every workflow that loads the default honours such an entry, and Campaign's kickoff loader drops the default from each skill's kickoff the same way.
|
|
607
607
|
- `activation_steps_prepend` and `activation_steps_append`: extra steps to run before or after start-up.
|
|
608
|
-
- `on_complete`: a command to run after the workflow writes its result file, such as a notifier. Every workflow except Campaign, Stack Skill, Verify Stack and Setup Forge (Quick Skill included) calls it with `--result-path=<result file>` (for Brief Skill, the `skill-brief.yaml` it wrote), and the hook is skipped when the emitter wrote no result file. Quick Skill's hook gets `--result-path=<skill package>/quick-skill-result-latest.json`, where the skill package is that file's folder, and is skipped when the emitter wrote no result file, or did not write that `-latest` copy. Campaign passes `--report-path=<report>` when it wrote its report, Stack Skill runs the command with no argument, and Setup Forge and Verify Stack carry it out as an instruction once they finish. A failing command never fails the workflow.
|
|
608
|
+
- `on_complete`: a command to run after the workflow writes its result file, such as a notifier. Every workflow except Campaign, Stack Skill, Verify Stack and Setup Forge (Quick Skill included) calls it with `--result-path=<result file>` (for Brief Skill, the `skill-brief.yaml` it wrote), and the hook is skipped when the emitter wrote no result file. Quick Skill's hook gets `--result-path=<skill package>/quick-skill-result-latest.json`, where the skill package is that file's folder, and is skipped when the emitter wrote no result file, or did not write that `-latest` copy. Analyze Source, Create Skill and Update Skill pass their `-latest` copy too (`<forge_data_folder>/analyze-source-result-latest.json`, `<version folder>/create-skill-result-latest.json` and `<version folder>/update-skill-result-latest.json`), and skip the hook, with a line that says so, when the run did not write it. Campaign passes `--report-path=<report>` when it wrote its report, Stack Skill runs the command with no argument, and Setup Forge and Verify Stack carry it out as an instruction once they finish. A failing command never fails the workflow.
|
|
609
609
|
|
|
610
610
|
Useful settings in specific workflows:
|
|
611
611
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "bmad-module-skill-forge",
|
|
4
|
-
"version": "
|
|
4
|
+
"version": "7.0.0",
|
|
5
5
|
"description": "BMAD module — Turn code and docs into instructions AI agents can actually follow. Progressive capability tiers (Quick/Forge/Forge+/Deep).",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"bmad",
|
|
@@ -38,7 +38,7 @@ Each run writes **two files** to `{output_dir}`:
|
|
|
38
38
|
|
|
39
39
|
Write the per-run record first, then copy it to the `-latest.json` path. If the copy fails, the per-run record still exists — the run is not lost.
|
|
40
40
|
|
|
41
|
-
**Consumers (forger, CI, chained workflows):** a caller that started a run reads that run's own `SKF_<NAME>_RESULT_JSON` envelope line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `{skill-name}-result-latest.json` may then describe an earlier run, so it is never read in its place. A tool that looks up the last run of a skill rather than one it started (skf-find-test-report.py, forge-status.py) may read the copy. Do not enumerate timestamped files unless inspecting prior-run history.
|
|
41
|
+
**Consumers (forger, CI, chained workflows):** a caller that started a run reads that run's own `SKF_<NAME>_RESULT_JSON` envelope line. A run that printed no envelope line has no result to read, whatever the process exit code: it was cut off, or could not write its result, and `{skill-name}-result-latest.json` may then describe an earlier run, so it is never read in its place. A tool that looks up the last run of a skill rather than one it started (skf-find-test-report.py, forge-status.py) may read the copy. Do not enumerate timestamped files unless inspecting prior-run history. Under `--batch` (skf-quick-skill, skf-create-skill), a batch's result is its `batch_summary` event and the summary file its `summary_path` names, never the `quick-skill-batch-latest.json` or `create-skill-batch-latest.json` copy: that copy holds the last batch summary written, which may be another batch's, and a batch that printed no `batch_summary` event has no batch result (each workflow's `references/batch-mode.md`).
|
|
42
42
|
|
|
43
43
|
## Writing the Files
|
|
44
44
|
|
|
@@ -124,6 +124,15 @@ summarize
|
|
|
124
124
|
"succeeded", "failed", "status", "fail_fast_triggered", "exit_code",
|
|
125
125
|
"summary_path"}, with "warnings" when a summary file could not be
|
|
126
126
|
written. A batch already summarized prints its event again.
|
|
127
|
+
The batch's result is that event and the file its summary_path names
|
|
128
|
+
(with summary_path null, when the per-batch file could not be written,
|
|
129
|
+
the event and batch.jsonl; a summary_write_failed warning beside a set
|
|
130
|
+
summary_path means only the copy failed): <stem>-latest.json holds the
|
|
131
|
+
last batch summary written, which may be another batch's, so it never
|
|
132
|
+
stands for this batch, and a batch cut off before summarize leaves it in
|
|
133
|
+
place. A summarize on a run folder that is gone (a batch whose targets
|
|
134
|
+
all succeeded, summarized and removed) exits 1, naming the per-batch
|
|
135
|
+
file whose input_file (or input_briefs) is this batch's.
|
|
127
136
|
|
|
128
137
|
The batch file is one JSON object per line, so a write cut short spoils at
|
|
129
138
|
most its own line, which is skipped when read: the next write ends that
|
|
@@ -628,10 +637,14 @@ def summary_event(summary: dict) -> dict:
|
|
|
628
637
|
|
|
629
638
|
def cmd_summarize(run_dir: Path, output_dir: Path) -> dict:
|
|
630
639
|
if not (run_dir / BATCH_FILE).exists() and not run_dir.exists():
|
|
631
|
-
|
|
640
|
+
create = run_dir.name.startswith(f"{CREATE_WORKFLOW}-")
|
|
641
|
+
stem = SUMMARY_STEMS[CREATE_WORKFLOW if create else WORKFLOW]
|
|
642
|
+
names = "input_briefs name its briefs" if create else "input_file names its batch file"
|
|
632
643
|
raise BatchError(f"no batch in {run_dir.as_posix()}: a batch whose targets all succeeded removes its "
|
|
633
|
-
f"run folder once summarized; its summary is "
|
|
634
|
-
f"{
|
|
644
|
+
f"run folder once summarized; its summary is the {stem}-<YYYYMMDD-HHmmss>.json in "
|
|
645
|
+
f"{output_dir.as_posix()} whose {names}, and "
|
|
646
|
+
f"{(output_dir / f'{stem}-latest.json').as_posix()} holds the last batch summary "
|
|
647
|
+
f"written, which may be another batch's")
|
|
635
648
|
state = load(run_dir)
|
|
636
649
|
name = workflow(state)
|
|
637
650
|
if state["summary"] is not None:
|
|
@@ -455,7 +455,8 @@ Subcommands:
|
|
|
455
455
|
"source": "<source_file>:<source_line>" | "<source_file>" | null,
|
|
456
456
|
"defined_at": "<path>:<line>" | null,
|
|
457
457
|
"declared_in": ["<path>:<line>", ...] | null,
|
|
458
|
-
"reason": "not-defined" | "
|
|
458
|
+
"reason": "not-defined" | "aliased" | "defined" | "unchecked"
|
|
459
|
+
| "no-entry",
|
|
459
460
|
"entries": [{"source_file": "<as recorded>",
|
|
460
461
|
"source_line": <as recorded>,
|
|
461
462
|
"line_check": "checked" | "file-missing"
|
|
@@ -469,9 +470,30 @@ Subcommands:
|
|
|
469
470
|
`fabricated` is true, with reason `not-defined`, when the name has an
|
|
470
471
|
entry and every entry's `line_check` is `file-missing`, or `checked`
|
|
471
472
|
with an empty `definition_lines`: the export is in neither the surface
|
|
472
|
-
nor the cited file
|
|
473
|
-
|
|
474
|
-
|
|
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).
|
|
475
497
|
An entry with no `source_file` (update-skill writes one for an
|
|
476
498
|
`unknown` NEW_EXPORT) cites no file to check: its `line_check` is
|
|
477
499
|
`no-file`, which, like a skipped or unreadable check, neither proves
|
|
@@ -483,7 +505,8 @@ Subcommands:
|
|
|
483
505
|
`defined_at` is where the source declares a name that is not
|
|
484
506
|
fabricated, so test-skill can tell a documented extra (a name outside
|
|
485
507
|
the surface that the source still declares) from stale documentation;
|
|
486
|
-
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
|
|
487
510
|
column 0 that the definition-line rules match with imports left out:
|
|
488
511
|
a `def`, `class`, assignment, annotation, type alias, TS/JS
|
|
489
512
|
declaration, `exports.NAME =`, or an alias that renames
|
|
@@ -498,9 +521,12 @@ Subcommands:
|
|
|
498
521
|
`const NAME = require(...)`, `const { NAME } = require(...)`,
|
|
499
522
|
`exports.NAME = require(...)`, `{ NAME: require(...) }`), an
|
|
500
523
|
indented line and any line of a Python
|
|
501
|
-
file that does not tokenize never count
|
|
502
|
-
|
|
503
|
-
`
|
|
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
|
|
504
530
|
declares NAME, unless `--inventory` gives NAME a `function`, `hook`,
|
|
505
531
|
`class`, `interface`, `enum` or `type` kind (any of its kinds, when it
|
|
506
532
|
gives several; a `method` kind is not read), no entry of NAME is a
|
|
@@ -524,10 +550,12 @@ Subcommands:
|
|
|
524
550
|
and `defined_at` is the one item when the list has exactly one and no
|
|
525
551
|
walked Python file that does not tokenize holds the name (a homonym,
|
|
526
552
|
two files or more, gives null: test-skill decides it by the files the
|
|
527
|
-
skill cites)
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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
|
|
531
559
|
one, never the source root itself; TS/JS: the nearest folder at or
|
|
532
560
|
above it that holds `package.json`), leaving out the entries whose
|
|
533
561
|
file lies outside every package; the source root when none lies in
|
|
@@ -551,9 +579,12 @@ Subcommands:
|
|
|
551
579
|
skips, those walk-only ones aside. The path is relative to the source
|
|
552
580
|
root. A dotted name, or a map whose entries cite files
|
|
553
581
|
but no Python or TS/JS one, is not looked up (`defined_at` and
|
|
554
|
-
`declared_in` null, as for a fabricated name)
|
|
555
|
-
during the lookup
|
|
556
|
-
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.
|
|
557
588
|
|
|
558
589
|
kind-at --file <path> --line <n> --recipes <recipes>
|
|
559
590
|
[--language <lang>] [--name <export_name>]
|
|
@@ -2360,6 +2391,8 @@ STALE_NOT_DEFINED = "not-defined"
|
|
|
2360
2391
|
STALE_DEFINED = "defined"
|
|
2361
2392
|
STALE_UNCHECKED = "unchecked"
|
|
2362
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"
|
|
2363
2396
|
|
|
2364
2397
|
|
|
2365
2398
|
def stale_names(data: object) -> list[str]:
|
|
@@ -2745,6 +2778,68 @@ def baseline_gaps(
|
|
|
2745
2778
|
return gaps, unchecked
|
|
2746
2779
|
|
|
2747
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
|
+
|
|
2748
2843
|
class _DeclarationLookup:
|
|
2749
2844
|
"""Where the source declares the stale names (classify-stale's
|
|
2750
2845
|
`defined_at`): a column-0 declaration, an alias that renames included, a
|
|
@@ -2766,7 +2861,10 @@ class _DeclarationLookup:
|
|
|
2766
2861
|
on its path and the same skips but the walk-only ones, judged below the
|
|
2767
2862
|
walk root when it lies there. The walk runs once for all the names no
|
|
2768
2863
|
cited file settles: each file is read once, and only a file whose text
|
|
2769
|
-
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
|
|
2770
2868
|
(`_LOOKUP_ERRORS`)."""
|
|
2771
2869
|
|
|
2772
2870
|
def __init__(self, entries: list[dict], source_root: Path) -> None:
|
|
@@ -2786,6 +2884,9 @@ class _DeclarationLookup:
|
|
|
2786
2884
|
# the cited files read, kept for the walk: a few files at most
|
|
2787
2885
|
self._cited: dict[tuple[str, ...], list[str] | None] = {}
|
|
2788
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
|
|
2789
2890
|
|
|
2790
2891
|
# -- the walk root ------------------------------------------------------
|
|
2791
2892
|
|
|
@@ -2921,8 +3022,8 @@ class _DeclarationLookup:
|
|
|
2921
3022
|
found.append((*below, filename))
|
|
2922
3023
|
return sorted(found)
|
|
2923
3024
|
|
|
2924
|
-
@staticmethod
|
|
2925
3025
|
def _first(
|
|
3026
|
+
self,
|
|
2926
3027
|
parts: tuple[str, ...],
|
|
2927
3028
|
lines: list[str],
|
|
2928
3029
|
name: str,
|
|
@@ -2931,11 +3032,16 @@ class _DeclarationLookup:
|
|
|
2931
3032
|
) -> str | None:
|
|
2932
3033
|
"""`file:line` of the first line of a file that declares `name`, else,
|
|
2933
3034
|
with `module_fallback`, its line 1 when it is the module or package
|
|
2934
|
-
so named (`declared_line`).
|
|
3035
|
+
so named (`declared_line`). After `keep_declaring`, the file's lines
|
|
3036
|
+
are kept for `renamed`."""
|
|
2935
3037
|
line = declared_line(
|
|
2936
3038
|
parts, name, lines=lines, cache=cache, module_fallback=module_fallback
|
|
2937
3039
|
)
|
|
2938
|
-
|
|
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}"
|
|
2939
3045
|
|
|
2940
3046
|
# -- the answer ---------------------------------------------------------
|
|
2941
3047
|
|
|
@@ -3018,6 +3124,36 @@ class _DeclarationLookup:
|
|
|
3018
3124
|
hits[name].append(hit)
|
|
3019
3125
|
return {name: sorted(_without_stubs(h)) for name, h in hits.items()}, unsure
|
|
3020
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
|
+
|
|
3021
3157
|
|
|
3022
3158
|
def inventory_kinds(data: object, path: str) -> dict[str, frozenset[str]]:
|
|
3023
3159
|
"""name -> the kinds a documented inventory gives it (`--inventory`):
|
|
@@ -3054,18 +3190,26 @@ def classify_stale(
|
|
|
3054
3190
|
"""The fabricated-signature test for each stale name: the definition-lines
|
|
3055
3191
|
rules at every provenance entry whose raw `export_name` is the name, and
|
|
3056
3192
|
for a name that is not fabricated, where the source declares it
|
|
3057
|
-
(`defined_at`, and `declared_in` when the walk looked for it).
|
|
3058
|
-
|
|
3059
|
-
a
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
`
|
|
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.
|
|
3064
3206
|
"""
|
|
3065
3207
|
entries = [e for e in prov.get("entries") or [] if isinstance(e, dict)]
|
|
3066
3208
|
results: list[dict] = []
|
|
3067
|
-
# the names to look up
|
|
3209
|
+
# the names to look up that are not fabricated: name -> its cited files
|
|
3068
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]] = {}
|
|
3069
3213
|
# the names a module so named does not declare
|
|
3070
3214
|
strict: set[str] = set()
|
|
3071
3215
|
for name in names:
|
|
@@ -3097,7 +3241,12 @@ def classify_stale(
|
|
|
3097
3241
|
reason = STALE_UNCHECKED
|
|
3098
3242
|
source = next(filter(None, map(_entry_citation, mine)), None)
|
|
3099
3243
|
fabricated = reason == STALE_NOT_DEFINED
|
|
3100
|
-
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:
|
|
3101
3250
|
cited[name] = [f for f in map(_entry_file, mine) if f is not None]
|
|
3102
3251
|
documented = (kinds or {}).get(name, frozenset())
|
|
3103
3252
|
if documented & NO_MODULE_KINDS and not any(map(_module_entry, mine)):
|
|
@@ -3113,16 +3262,43 @@ def classify_stale(
|
|
|
3113
3262
|
"entries": checks,
|
|
3114
3263
|
}
|
|
3115
3264
|
)
|
|
3265
|
+
lookup: _DeclarationLookup | None = None
|
|
3116
3266
|
try:
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
)
|
|
3267
|
+
lookup = _DeclarationLookup(entries, source_root)
|
|
3268
|
+
found, declared = lookup.find(cited, frozenset(strict))
|
|
3120
3269
|
except _LOOKUP_ERRORS:
|
|
3121
3270
|
# a lookup that fails proves nothing: every name stays null
|
|
3122
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 = {}, {}
|
|
3123
3291
|
for item in results:
|
|
3124
|
-
|
|
3125
|
-
|
|
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)
|
|
3126
3302
|
return results
|
|
3127
3303
|
|
|
3128
3304
|
|
|
@@ -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
|
|
|
@@ -287,11 +287,11 @@ The shared emitter writes the result files and prints the envelope; this section
|
|
|
287
287
|
uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
|
|
288
288
|
```
|
|
289
289
|
|
|
290
|
-
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 (the record of the last run whose emitter wrote this copy, at a stable path; a caller reads its own run's `SKF_ANALYZE_RESULT_JSON` line), and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim: it is the success signal of a headless run. A
|
|
290
|
+
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 (the record of the last run whose emitter wrote this copy, at a stable path; a caller reads its own run's `SKF_ANALYZE_RESULT_JSON` line), and prints the `SKF_ANALYZE_RESULT_JSON:` line on stdout (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim: it is the success signal of a headless run. 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 `generate-briefs:9`, path `{forge_data_folder}`, with `brief_paths` and `unit_counts` from the payload): "The result contract could not be written: {its message}."
|
|
291
291
|
|
|
292
292
|
### 9b. On-Complete Hook (pipeline integration)
|
|
293
293
|
|
|
294
|
-
If `{onCompleteCommand}` is non-empty
|
|
294
|
+
If `{onCompleteCommand}` is non-empty, the §9 line's `result_path` is not null and its `warnings` hold no `result_file_write_failed` entry naming `analyze-source-result-latest.json` (the emitter wrote the timestamped result JSON and its `analyze-source-result-latest.json` copy), invoke it now:
|
|
295
295
|
|
|
296
296
|
```
|
|
297
297
|
{onCompleteCommand} --result-path={forge_data_folder}/analyze-source-result-latest.json
|
|
@@ -303,8 +303,10 @@ The stable `-latest` path is preferred over the timestamped copy so downstream c
|
|
|
303
303
|
- On non-zero exit / process error: display the hook failure (`on_complete hook failed (exit {code}): {stderr_snippet}`); it never fails the run.
|
|
304
304
|
- **Never fail the workflow on hook errors** — the hook is for pipeline integration (Slack, dashboards, CI), not for gating skill-brief production.
|
|
305
305
|
|
|
306
|
+
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." The hook never gets a `-latest` copy this run failed to write.
|
|
307
|
+
|
|
306
308
|
If `{onCompleteCommand}` is empty, skip this section entirely (default behavior — no hook configured).
|
|
307
309
|
|
|
308
310
|
### 10. Chain to Health Check
|
|
309
311
|
|
|
310
|
-
After the briefs are written (or skipped, or none was confirmed), the report updated, the summary presented, the run ended in §9 and the on-complete hook invoked (or skipped
|
|
312
|
+
After the briefs are written (or skipped, or none was confirmed), the report updated, the summary presented, the run ended in §9 and the on-complete hook invoked (or skipped as §9b says), delete the run folder, whose payloads the emitter has read: `rm -rf "{run_dir}"`. Then load, read the full file, and execute `{nextStepFile}`. The health-check step is the true terminal step: continue past the summary even though it reads as final.
|
|
@@ -17,7 +17,7 @@ SKF_ANALYZE_RESULT_JSON: {"status":"success|error|redirect|skipped","report_path
|
|
|
17
17
|
- `brief_paths`: absolute path of every `skill-brief.yaml` the run wrote (empty array if none). A brief is written only after it passed the brief writer and, outside the docs-only branch, the schema gate.
|
|
18
18
|
- `unit_counts` — `confirmed` (units approved for briefs) and `skipped` (rejected in step 5, or the skipped target in coexistence §0c) counts; auto mode reports `confirmed:N, skipped:0`. `maybe` is a reserved slot, currently always `0`.
|
|
19
19
|
- `mode`: `"auto"` when the invocation carries `[auto]`, from activation through step 1a and the docs-only branch; `"interactive"` otherwise, also in the step-by-step chain an `[auto]` run falls back to (step 1a §3, exit 1).
|
|
20
|
-
- `result_path`: the per-run result file the emitter wrote, stamped by it; `null` for a HALT, which writes no result file, and when the
|
|
20
|
+
- `result_path`: the per-run result file the emitter wrote, stamped by it; `null` for a HALT, which writes no result file, when `--result-dir` names no folder (with no warning), and when the per-run record could not be written (a `result_file_write_failed` warning then says why); a `-latest` copy that could not be written leaves it set, and a `result_file_write_failed` warning names the copy.
|
|
21
21
|
- `error`: on a HALT only, `{"phase", "reason"}` plus `path` when the halt names one.
|
|
22
22
|
- `headless_decisions`: present when the run recorded an auto-decision: step 1a's §3b merge-or-split decision (gate `auto-scope.cohesion`), its §4 choices of shape for a repository on a framework or a parser library (gate `auto-scope.shape`) and its §5 choice of the documented language (gate `auto-scope.language`), and each gate default a headless run of the step-by-step chain took: `scan-project.scan`, `identify-units.classifications`, `map-and-detect.findings` (with the composite merges it accepted), `recommend.recommendations` and `generate-briefs.write`.
|
|
23
23
|
- `warnings`: present when the run recorded at least one.
|
|
@@ -32,7 +32,7 @@ The step that ends the run stages `{run_dir}/result-context.json` with the envel
|
|
|
32
32
|
uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
The emitter stamps the UTC time, the run id, the decisions and warnings the run recorded into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the line. A
|
|
35
|
+
The emitter stamps the UTC time, the run id, the decisions and warnings the run recorded into the record, writes `{forge_data_folder}/analyze-source-result-{YYYYMMDD-HHmmss}.json` and its `analyze-source-result-latest.json` copy, and prints the line. 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, the step corrects the payload from the message on its stderr and runs it once more; if it fails again, it HARD HALTs with exit code 4 (`write-failed`). Then `{onCompleteCommand}` runs, when it is set, `result_path` is not null and no `result_file_write_failed` warning names `analyze-source-result-latest.json`, with `--result-path={forge_data_folder}/analyze-source-result-latest.json`, and a hook failure is displayed and never fails the run; otherwise a set hook is skipped with "on_complete skipped: `analyze-source-result-latest.json` was not written." Last, the step deletes `{run_dir}` and chains to step 7.
|
|
36
36
|
|
|
37
37
|
## Halt Envelope
|
|
38
38
|
|
|
@@ -447,8 +447,8 @@ Add `"coexistence": "alongside"` when `{coexistence_suffix}` is non-empty ([A]lo
|
|
|
447
447
|
uv run {emitEnvelopeHelper} emit --workflow skf-analyze-source --run-dir "{run_dir}" --result-dir "{forge_data_folder}" < "{run_dir}/result-context.json"
|
|
448
448
|
```
|
|
449
449
|
|
|
450
|
-
The emitter stamps the UTC time, the run id and the decisions and warnings the run recorded 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 (field rules in `references/headless-contract.md`). When `{headless_mode}` is true, display the line verbatim. A
|
|
450
|
+
The emitter stamps the UTC time, the run id and the decisions and warnings the run recorded 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 (field rules in `references/headless-contract.md`). 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 `step-auto-scope:9`, path `{forge_data_folder}`): "The result contract could not be written: {its message}."
|
|
451
451
|
|
|
452
|
-
If `{onCompleteCommand}` is non-empty
|
|
452
|
+
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."
|
|
453
453
|
|
|
454
454
|
Then delete the run folder, whose payloads the emitter has read: `rm -rf "{run_dir}"`. Load, read fully, then execute {nextStepFile} to run the shared workflow health check.
|