craftar 0.15.0 → 0.16.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/README.md +13 -3
- package/dist/cli.js +364 -17
- package/dist/cli.js.map +1 -1
- package/dist/core/impact.js +377 -0
- package/dist/core/impact.js.map +1 -0
- package/dist/core/param-writes.js +1 -1
- package/dist/core/param-writes.js.map +1 -1
- package/dist/core/registry.js +1 -1
- package/dist/core/registry.js.map +1 -1
- package/dist/core/sync.js +1 -1
- package/dist/core/sync.js.map +1 -1
- package/dist/core/unify.js +403 -6
- package/dist/core/unify.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Craft, sync and convert AI-coding workspace harnesses — rules, agents, commands, skills, MCP servers — across every client workspace you maintain and every AI coder each team uses.
|
|
4
4
|
|
|
5
|
-
> Status: **phase 0 prototype**. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
|
|
5
|
+
> Status: **phase 0 prototype**. `import`, `sync`, `status`, `diff`, `explain`, `ls` work end-to-end for the `claude-code` and `kiro` targets and are validated byte-for-byte against a real workspace (see *Oracle*). The Forge commands `forge variants`, `forge diff` (with a suggested class per hunk) and `forge unify` (`--take base|variant`, `take: param` to turn a value into a `{{key}}` parameter, and `take: section` to turn a client block into a section) also work, covered by the unit and golden suites rather than the oracle, and `import` is render-aware: importing into an existing Forge reuses a base whose render equals the workspace file, and edits an existing profile and the recipes it owns in place. Ingredient bodies can hold client-specific **sections** (`<!-- craftar:section <name> -->` … `<!-- /craftar:section -->`), overridden by a profile's `sections` and a workspace's `overrides.sections`; import infers each client's content and `forge unify` can extract a section from a variant, and a Forge that uses them declares `schema: 2` (see *Forge layout*). The read-only catalogue — `recipes`, `ingredients` and `targets` with the capability matrix — works too, and a workspace can name its Forge by git URL with an optional `ref`, read from a per-machine cache, with a lock that records the ref, commit, recipes and targets it was generated from (see *Remote Forge*). `add recipe` / `remove recipe` change a workspace's recipe choice in `craftar.yaml`, refusing an edit that would not resolve. A writing `sync` records each workspace in a per-machine registry, and `craftar workspaces` lists them all with their status (see *Workspace registry*). `craftar forge impact` lists the workspaces registered on this machine that read a Forge (by path, by a git remote of the clone, or through another clone of the same remote) with what their next sync would do against it, and `forge unify` plans them before and after its writes, names the ones its warnings concern, and with `--prune-recipes` deletes a recipe left identical to its sibling once every one of them plans the same. `craftar doctor` reports, one line per finding, what is off on this machine and in this workspace. Everything else in the spec (profiles with IDP/PM-tool integrations, local services, `dotnet new` project templates, `craftar ui`, `craftar mcp`) is not built yet.
|
|
6
6
|
|
|
7
7
|
## The idea in one paragraph
|
|
8
8
|
|
|
@@ -79,8 +79,9 @@ From then on, change a rule in `forge/ingredients/rules/<name>/rule.md`, run `cr
|
|
|
79
79
|
| `craftar add recipe <name...> [--replace] [--workspace <dir>] [--offline]` | Adds recipes to the workspace's choice: deletes each from `craftar.yaml`'s `recipes.remove` when it is there, and appends it to `recipes.add` when the workspace does not resolve it yet. A recipe that would share a `slot` with another resolved one is refused, naming the holder; `--replace` removes every other holder in the same edit, as `remove recipe` would. Names apply in order, all or nothing, and the edit is written only once the workspace resolves and plans with it. Writes `craftar.yaml` only — in place, keeping its comments and style (a document the YAML writer does not reproduce byte for byte, such as `{ add: [] }` with inner spaces or a column-aligned comment, is refused: reformat it by hand) — and never syncs: it prints the net edit, the new recipe order and what the next `sync` would do (`next sync: 1 new, 1 orphan — run \`craftar sync\``), or `nothing to change`. Refused when `craftar.local.yaml` sets `recipes` (its lists would replace the edited ones). |
|
|
80
80
|
| `craftar remove recipe <name...> [--workspace <dir>] [--offline]` | Removes recipes from the workspace's choice: deletes each from `recipes.add`, and appends a profile recipe to `recipes.remove`. A recipe another one still brings in through `extends` is refused, naming the top-level recipe to remove instead. A name the Forge does not hold is accepted only to clean it out of `recipes.add` or `recipes.remove`. Same output, refusals and all-or-nothing rule as `add recipe`. |
|
|
81
81
|
| `craftar forge variants [--forge <dir> \| --workspace <dir>] [--json]` | Lists ingredients that have variants, nearest first, with the profile each came from, its distance to the base and its hunks counted by suggested class (`[1 evolution · 2 block]`), then any variant whose base is missing from the Forge. Read-only; exits 0 either way. `--json` prints `{groups, orphans}`; each variant carries `classes: {evolution, value, block}`, which add up to `distance.hunks`. |
|
|
82
|
+
| `craftar forge impact [--forge <dir> \| --workspace <dir>] [--json]` | Lists every workspace registered on this machine that reads this Forge — by its path, by a git remote of this clone, or through another clone of the same remote — with what its next `sync` would do against the Forge as it is now: a header, then one line per workspace sorted by path (`unchanged`, the next-sync counts, `missing` or `error: <message>`; ` · after push (<remote>)` for a remote match, ` · after push and pull (<dir>)` for a clone match; `, pins <ref>` when the workspace pins a `ref`). When no registered workspace reads this Forge, the short header (`craftar forge impact — <forge>`) then one line: `no registered workspace reads this Forge on this machine`; when the registry is off, the short header then one line: `the registry is off (CRAFTAR_NO_REGISTRY)`; when the registry cannot be read, exits 1. A workspace whose Forge is remote is refused with the same message as `forge unify`. A remote URL holding credentials is not matched — one warning per remote, `remote <name> of <dir> holds credentials in its URL — not matched` (the URL is never printed). Read-only; no fetch. `--json` is a contract: `{forge, registry, workspaces: [{path, profile, match, via, ref, state, counts, error}]}` (every key always present). |
|
|
82
83
|
| `craftar forge diff <type/name> [--against <profile>] [--forge <dir> \| --workspace <dir>] [--json]` | Shows the differences between a base ingredient and each of its variants: a header carrying the same distance `forge variants` reports, then hunk by hunk, each with a suggested class and reason — `evolution` (one side is newer text), `value` (an identifier-like token swapped in shared prose, with a suggested `param.<slug>`) or `block` (lines only one side has) — then the files that exist on only one side. A suggestion never decides anything. It compares the raw Forge text, section marker lines included, so a base with markers shows them as hunks against a variant that has none. Read-only. `--json` prints an array of `{ref, profile, distance, diff}`; each hunk carries `suggestion: {class, reason, tokens?}` next to its `kind`. |
|
|
83
|
-
| `craftar forge unify <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--forge <dir> \| --workspace <dir>] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-<n>`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged. A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "<from>-<to>"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a `take: param` or `take: section` on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never deletes a recipe
|
|
84
|
+
| `craftar forge unify <type/name> --profile <p> (--take base\|variant \| --plan <file> \| --save-plan <file>) [--no-impact] [--prune-recipes] [--forge <dir> \| --workspace <dir>] [--json]` | Resolves one variant back into its base, hunk by hunk. `--save-plan` writes a reviewable plan with every decision set to `keep`, each hunk annotated with its suggested class (which `--plan` ignores) and, for a `value` hunk, a pre-filled `params` list (`token` → suggested `key`); on a `block` hunk, a pre-filled `section: { name }` from the Markdown heading above (or `section-<n>`); and on a hunk touching a section the base already has, that section's name. It refuses a path that resolves inside the Forge (after `..` segments and symlinks) and a path that already exists, so it never overwrites a Forge file or a plan you already edited; it writes nothing else. A hunk set to `take: param` turns each listed token into `{{key}}` in the base, declares the base's text as the key's default in the base's `ingredient.yaml` and writes the variant's text into the variant profile's `profile.yaml` `params` — only after proving that both the base's and the variant's text render back exactly, and only when the same plan resolves the variant; it refuses (with the Forge untouched) a key another layer, profile or ingredient already uses, a variant another profile also uses, a whitespace-only difference, and a YAML file that would not round-trip unchanged. A hunk set to `take: section` (with `section: { name, lines? }`) wraps the base's lines in section markers as the default and writes the variant's lines into the variant profile's `sections` — consecutive hunks with one name form one section, `lines: "<from>-<to>"` widens it over equal base lines, and hunks inside a section the base already has only write the profile value; it is proved (both sides render back exactly) and needs the same plan to resolve the variant; `take: param` and `take: section` may share a plan, never a hunk. It refuses (Forge untouched) a `take: param` or `take: section` on a file no target emits, a variant that holds markers, a profile that already sets that section (another profile for a new section, or the variant's profile with other content), a span that cuts a hunk or crosses another section, a span that covers a hunk outside the run (including `take: base` on a hunk inside a section the plan fills), a hunk mixing lines inside and outside an existing section, and a span reaching a missing final newline; when it adds a section to a Forge still at `schema: 1` it sets `schema: 2` in `craftar.forge.yaml` in place (refused if the file does not round-trip). `--plan` applies an edited plan, refusing when it is not for this exact ingredient/profile or either side's fingerprint moved since it was saved. `--take base\|variant` resolves every decision to that side. Writes the merged base and, once every difference is resolved, removes the variant and rewrites every recipe reference to it (`ingredients`) to name the base. It never edits an `extends`, and deletes a recipe or repoints a profile's recipe list only under `--prune-recipes`; otherwise it edits a profile only to write the values of a `take: param` or `take: section` extraction into the variant's own profile. A `<recipe>--<profile>` left identical to `<recipe>` is reported as a warning unless pruned; `--prune-recipes` deletes such a recipe and repoints every profile that names it — the candidates are found in a dry pass, their profile files join the git check, and pruning is refused when another recipe's `extends` names the candidate, when the registry is off or unreadable, when the registry did not check every workspace (no registered workspace, a missing entry, one whose configuration did not load), when a registered workspace's plan throws, or when a workspace's `recipes.add` or `recipes.remove` names the suffixed recipe, or when a profile's recipe list cannot be edited in place (a YAML document that does not round-trip); each remaining candidate is deleted and its profiles repointed only when every registered workspace plans the same files (the same paths and the same bytes) with and without it — any difference refuses that one candidate, and the Forge stays as `unify` left it. `--prune-recipes` with `--no-impact` is refused before anything is read. `unify` plans every registered workspace of the Forge before its writes and again after them, and reports one `impact:` line per workspace — `no effect`, `1 file changes — <path>` / `<n> files change — <paths>`, `missing` or `error: <message>` (each line carries the same ` · after push (<remote>)` / ` · after push and pull (<dir>)` / `, pins <ref>` suffix as `forge impact`) — replacing the `next: run craftar status …` hint when at least one workspace was checked; an unreadable registry is one warning. The three warnings — a removed variant a workspace may name in `overrides.ingredients.disable`, a new parameter a workspace's `overrides.params` now overrides, a new section a workspace's `overrides.sections` now fills — now name the concerned workspaces with the file that holds the entry, vanish when the registry proves none is concerned, or keep their previous text with a reason in parentheses (the registry could not check N workspaces, no workspace registered, registry off, registry unreadable). `--no-impact` skips both impact passes and restores the previous warning text (the identical-to-sibling warning keeps its new ending). Every recipe rewrite is checked before the first write, so one that cannot land (a reference behind a YAML alias) is refused with the Forge untouched; a failure after writing began (an I/O error, a locked file) names the paths already touched and the `git checkout` / `git clean` commands that undo them. A merge never adds, removes or changes a section marker other than the sections the plan declares; a plan or `--take variant` that would is refused before the first write — take `base` for the marker lines, or `take: section` to fill the section; the citation checks of `take: param` also read the profiles' section values, which can cite `{{key}}`. `ingredient.yaml` is never merged: when the two sides' metadata differs (beyond `name`, `as` and `origin`), the variant stays unresolved and the differing fields are named — edit it by hand, or `--take base` to discard the variant, metadata included. Requires a clean git checkout in the Forge with at least one commit (`--save-plan` excepted) — the Forge has no lock, so git is the undo. It also refuses when any path it would overwrite or delete (the base, the variant, the recipe files it rewrites, the profile a parameter or section extraction edits, the profiles `--prune-recipes` repoints, `craftar.forge.yaml` when it is bumped) is ignored, untracked, modified, or flagged skip-worktree or assume-unchanged in the index, since git could not restore it. `--json` prints `{base, profile, resolved, written, removed, unresolved, variantRemoved, recipes: {rewritten, identicalToSibling, pruned, kept}, metaDiffers, params, profileEdited, sections, manifestEdited, impact, warnings}` for `--take`/`--plan` (every key always present, `[]` when empty), or `{base, profile, plan, unresolved}` for `--save-plan`. |
|
|
84
85
|
|
|
85
86
|
All commands but `workspaces` take `--workspace <dir>` (default: current directory); the `forge` commands, `recipes` and `ingredients` also take `--forge <dir>` as an alternative to it — a directory: a URL there is refused — and `targets` needs no Forge. With a remote Forge (see *Remote Forge*), `status`, `sync`, `diff`, `explain`, `ls`, `recipes`, `ingredients`, `add recipe`, `remove recipe`, `forge variants` and `forge diff` fetch first and take `--offline` to use the cached copy; `targets` never fetches; `forge unify` refuses a workspace whose Forge is remote — clone it and pass `--forge <dir>`; `import --write-config` keeps a remote `forge:` and sets only `profile` and `targets`.
|
|
86
87
|
|
|
@@ -285,14 +286,23 @@ A writing `sync` records the workspace in `$CRAFTAR_HOME/registry.json` (`~/.cra
|
|
|
285
286
|
- **`CRAFTAR_NO_REGISTRY`** — read the name as "no registry", the way `NO_COLOR` means "no colour". Set to any non-empty value, it turns the registry off: `sync` neither reads nor writes it, and `craftar workspaces` exits 1. An empty value counts as unset. Set it on CI runners and throwaway checkouts, whose directories would only pile up as `missing`.
|
|
286
287
|
- **A registry that cannot be written** (an unwritable `$CRAFTAR_HOME`, a busy lock after 60 s, a file this craftar cannot read) leaves `sync`'s files, lock and exit code as they would be, and adds one warning. Craftar never overwrites a registry it cannot read: repair or remove it yourself.
|
|
287
288
|
- The registry is per machine: paths are native and absolute, and SSH and HTTPS spellings of one remote are two Forges in it, as they are two cache entries.
|
|
289
|
+
- **The registry also tells a Forge its workspaces**: `forge impact` and `forge unify` read it (never write it); a workspace synced on CI, another machine or under `CRAFTAR_NO_REGISTRY` is not known to them, which is why `--prune-recipes` says so.
|
|
288
290
|
|
|
289
291
|
## Roadmap (from the functional spec)
|
|
290
292
|
|
|
291
|
-
Phase 0 (this): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock); the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`.
|
|
293
|
+
Phase 0 (this): schema, import, sync/status/diff/explain, claude-code + kiro + agents-md targets, lock, drift, orphans; the read-only catalogue (`recipes`, `ingredients`, `targets` with the capability matrix); remote Forge (git URL + `ref`, a per-machine cache, the schema 2 lock); the workspace registry and `craftar workspaces`; `craftar add recipe` / `remove recipe`; `craftar init` with flags; `craftar doctor`; `craftar forge impact` and `forge unify --prune-recipes`.
|
|
292
294
|
Next: an interactive `craftar init` (choose the Forge and the profile); cloning a profile's repositories at init; profile-driven integrations (PM tool → MCP, IDP → MCP); `service` ingredients with compose fragments (`craftar services up`); `dotnet new` template registration; rulesync bridge for Codex/Kimi/Cursor specifics; `craftar docs validate`; GitHub Action and Azure Pipelines task around `sync --check`; `craftar ui`; `craftar mcp` + Claude Code / Agent Plugin packaging.
|
|
293
295
|
|
|
294
296
|
## Upgrading
|
|
295
297
|
|
|
298
|
+
### to 0.16.0
|
|
299
|
+
|
|
300
|
+
- **New command `craftar forge impact`**: lists every workspace registered on this machine that reads a Forge with what its next sync would do against it. Its `--json` output is a tooling contract.
|
|
301
|
+
- **`forge unify` plans the registered workspaces of the Forge before and after writing**: `impact:` lines replace the `next:` hint when at least one workspace was checked; `--no-impact` restores the previous output except the identical-to-sibling warning's new ending; the three "unify cannot reach workspaces" warnings name the concerned workspaces, vanish when none is concerned and every workspace was checked, or gain the reason in parentheses.
|
|
302
|
+
- **`forge unify --json` gains `impact` and `recipes.pruned` / `recipes.kept`** (additive; always present).
|
|
303
|
+
- **New flag `--prune-recipes`**: deletes a recipe left identical to its sibling and repoints the profiles that name it, only when every registered workspace plans the same files.
|
|
304
|
+
- **No byte change**: no emitter, `plan`, `status`, `sync` or lock changed. No workspace sees `update`. A prune changes `craftar.lock`'s `recipes` at the next `sync` of a workspace on that profile, and that workspace's registry entry (`recipes`, `stack`) with it.
|
|
305
|
+
|
|
296
306
|
### to 0.15.0
|
|
297
307
|
|
|
298
308
|
- **New command `craftar doctor`**: reports, one line per finding, what is off on this machine and in this workspace. Its `--json` output is a tooling contract, like `status --json`.
|
package/dist/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import YAML from "yaml";
|
|
|
6
6
|
import { importClaudeCode } from "./importers/claude-code.js";
|
|
7
7
|
import { classifyForge } from "./core/remote.js";
|
|
8
8
|
import { forget, listWorkspaces, prune, register, registryFile } from "./core/registry.js";
|
|
9
|
+
import { forgeWorkspaces, planAll, nextSync, impactOf, concerned, refineRegistryState } from "./core/impact.js";
|
|
9
10
|
import { runDoctor } from "./core/doctor.js";
|
|
10
11
|
import { resolveHome } from "./core/home-lock.js";
|
|
11
12
|
import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
@@ -16,9 +17,9 @@ import { canonicalValue } from "./core/sections.js";
|
|
|
16
17
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
17
18
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
18
19
|
import { hashNormalized, toLf, stripBom } from "./core/text.js";
|
|
19
|
-
import { exists, gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
|
|
20
|
+
import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
|
|
20
21
|
import { fingerprintDir } from "./core/fingerprint.js";
|
|
21
|
-
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, } from "./core/unify.js";
|
|
22
|
+
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
|
|
22
23
|
import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
|
|
23
24
|
import { planInit } from "./core/init.js";
|
|
24
25
|
import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
|
|
@@ -33,7 +34,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
33
34
|
*/
|
|
34
35
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
35
36
|
const program = new Command();
|
|
36
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
37
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.16.0");
|
|
37
38
|
/* ---------------------------------------------------------------- import */
|
|
38
39
|
program
|
|
39
40
|
.command("import")
|
|
@@ -869,6 +870,94 @@ forge
|
|
|
869
870
|
const orphanNote = orphans.length ? `, ${orphans.length} orphan${orphans.length === 1 ? "" : "s"}` : "";
|
|
870
871
|
console.log(`\n ${groups.length} base${groups.length === 1 ? "" : "s"} with variants, ${total} variant${total === 1 ? "" : "s"} total${orphanNote}`);
|
|
871
872
|
});
|
|
873
|
+
forge
|
|
874
|
+
.command("impact")
|
|
875
|
+
.description("List every workspace registered on this machine that reads this Forge — by its path, by a git remote of this clone, or through another clone of the same remote — with what its next sync would do against the Forge as it is now. Read-only; no fetch")
|
|
876
|
+
.option("--forge <dir>", "Forge directory (instead of --workspace)")
|
|
877
|
+
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
|
|
878
|
+
.option("--json", "machine-readable output", false)
|
|
879
|
+
.action(async (o) => {
|
|
880
|
+
const f = await resolveForge({
|
|
881
|
+
forge: o.forge,
|
|
882
|
+
workspace: o.workspace,
|
|
883
|
+
refuseRemote: (url) => `the Forge of this workspace is remote (${url}) — clone it and pass --forge <dir>`,
|
|
884
|
+
});
|
|
885
|
+
let fw;
|
|
886
|
+
try {
|
|
887
|
+
fw = await forgeWorkspaces(craftarHome(), f.root);
|
|
888
|
+
}
|
|
889
|
+
catch (e) {
|
|
890
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
891
|
+
}
|
|
892
|
+
warnStderr(fw.warnings);
|
|
893
|
+
const planned = await planAll(fw.workspaces, f);
|
|
894
|
+
const results = await Promise.all(planned.map((p) => nextSync(p)));
|
|
895
|
+
// Refine the registry state: "partial" when any entry is missing or stage-config error (spec 25 §3)
|
|
896
|
+
const registryState = refineRegistryState(fw.state, planned);
|
|
897
|
+
if (o.json) {
|
|
898
|
+
const workspaces = fw.workspaces.map((ws, i) => {
|
|
899
|
+
const r = results[i];
|
|
900
|
+
return {
|
|
901
|
+
path: ws.entry.path,
|
|
902
|
+
profile: ws.entry.profile,
|
|
903
|
+
match: ws.match,
|
|
904
|
+
via: ws.via,
|
|
905
|
+
ref: ws.ref,
|
|
906
|
+
state: r.state,
|
|
907
|
+
counts: r.counts,
|
|
908
|
+
error: r.error,
|
|
909
|
+
};
|
|
910
|
+
});
|
|
911
|
+
console.log(JSON.stringify({ forge: fw.realForge, registry: registryState, workspaces }, null, 2));
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
// Text output
|
|
915
|
+
const parts = [];
|
|
916
|
+
const byMatch = {};
|
|
917
|
+
for (const ws of fw.workspaces)
|
|
918
|
+
byMatch[ws.match] = (byMatch[ws.match] ?? 0) + 1;
|
|
919
|
+
if (byMatch.path)
|
|
920
|
+
parts.push(`${byMatch.path} by path`);
|
|
921
|
+
if (byMatch.remote)
|
|
922
|
+
parts.push(`${byMatch.remote} by remote`);
|
|
923
|
+
if (byMatch.clone)
|
|
924
|
+
parts.push(`${byMatch.clone} by clone`);
|
|
925
|
+
if (fw.state === "none") {
|
|
926
|
+
console.log(`craftar forge impact — ${fw.realForge}`);
|
|
927
|
+
console.log(` no registered workspace reads this Forge on this machine`);
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
if (fw.state === "off") {
|
|
931
|
+
console.log(`craftar forge impact — ${fw.realForge}`);
|
|
932
|
+
console.log(` the registry is off (CRAFTAR_NO_REGISTRY)`);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
const n = fw.workspaces.length;
|
|
936
|
+
console.log(`craftar forge impact — ${fw.realForge} · ${n} registered workspace${n === 1 ? "" : "s"} (${parts.join(", ")})`);
|
|
937
|
+
const maxPath = Math.max(...fw.workspaces.map((ws) => ws.entry.path.length));
|
|
938
|
+
const maxProfile = Math.max(...fw.workspaces.map((ws) => ws.entry.profile.length));
|
|
939
|
+
for (let i = 0; i < fw.workspaces.length; i++) {
|
|
940
|
+
const ws = fw.workspaces[i];
|
|
941
|
+
const r = results[i];
|
|
942
|
+
let stateStr;
|
|
943
|
+
if (r.state === "unchanged") {
|
|
944
|
+
stateStr = "unchanged";
|
|
945
|
+
}
|
|
946
|
+
else if (r.state === "changed") {
|
|
947
|
+
// Counts in NEXT_SYNC order
|
|
948
|
+
const countParts = NEXT_SYNC_STATES.map((k) => (r.counts[k] ? `${r.counts[k]} ${k}` : "")).filter(Boolean);
|
|
949
|
+
stateStr = countParts.join(", ");
|
|
950
|
+
}
|
|
951
|
+
else if (r.state === "missing") {
|
|
952
|
+
stateStr = "missing";
|
|
953
|
+
}
|
|
954
|
+
else {
|
|
955
|
+
stateStr = `error: ${r.error}`;
|
|
956
|
+
}
|
|
957
|
+
const suffix = matchSuffix(ws);
|
|
958
|
+
console.log(` ${ws.entry.path.padEnd(maxPath)} ${ws.entry.profile.padEnd(maxProfile)} ${stateStr}${suffix}`);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
872
961
|
forge
|
|
873
962
|
.command("diff")
|
|
874
963
|
.description("Show the distance and the differences between a base ingredient and each of its variants, each hunk with a suggested class (evolution, value, block) that never decides anything. Read-only")
|
|
@@ -930,10 +1019,16 @@ forge
|
|
|
930
1019
|
.option("--take <side>", "resolve every decision to base or variant")
|
|
931
1020
|
.option("--plan <file>", "apply the decisions in this plan file (a hunk may be take: param with its params list, or take: section with its section name)")
|
|
932
1021
|
.option("--save-plan <file>", "write a plan with every decision deferred, each hunk annotated with its suggested class (which --plan ignores), value hunks pre-filled with params, block hunks pre-filled with a section name, and hunks touching an existing section with its name, to a new file outside the Forge, and stop")
|
|
1022
|
+
.option("--no-impact", "skip planning the registered workspaces of this Forge before and after the writes (the warnings then keep their previous text)")
|
|
1023
|
+
.option("--prune-recipes", "delete each suffixed recipe the cascade leaves identical to its sibling and repoint the profiles that name it — only when every workspace registered on this machine plans byte for byte the same; needs the impact passes")
|
|
933
1024
|
.option("--forge <dir>", "Forge directory (instead of --workspace)")
|
|
934
1025
|
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
|
|
935
1026
|
.option("--json", "machine-readable output", false)
|
|
936
1027
|
.action(async (ref, o) => {
|
|
1028
|
+
// Spec 25 §4.4: --prune-recipes with --no-impact is refused before anything is read
|
|
1029
|
+
if (o.pruneRecipes && !o.impact) {
|
|
1030
|
+
fail("--prune-recipes needs the impact passes — drop --no-impact");
|
|
1031
|
+
}
|
|
937
1032
|
const frontEnds = [o.take, o.plan, o.savePlan].filter((v) => v !== undefined);
|
|
938
1033
|
if (frontEnds.length !== 1)
|
|
939
1034
|
fail("pass exactly one of --take, --plan or --save-plan");
|
|
@@ -1048,6 +1143,10 @@ forge
|
|
|
1048
1143
|
// Ruling 33, dry pass: every recipe `ingredients` rewrite the cascade will make is checked before
|
|
1049
1144
|
// the first byte is written, so a refusal (an aliased reference) leaves the Forge untouched.
|
|
1050
1145
|
const cascadeFiles = result.resolved ? await checkRecipeCascade(f, base.ref, variant.ref) : [];
|
|
1146
|
+
// Spec 25 §4.4: prune candidates, computed in the dry pass
|
|
1147
|
+
const candidates = o.pruneRecipes && result.resolved
|
|
1148
|
+
? await pruneCandidates(f, base.ref, variant.ref, o.profile)
|
|
1149
|
+
: [];
|
|
1051
1150
|
// Spec 09 & 12: the Forge-level rows and YAML edits for parameter and section extractions, rendered before any write.
|
|
1052
1151
|
const paramWrites = result.params.length || result.sections.length
|
|
1053
1152
|
? await checkParamWrites(f, base, variant, o.profile, result.params, result.sections)
|
|
@@ -1056,7 +1155,9 @@ forge
|
|
|
1056
1155
|
// check above cannot see ignored files (a Forge its enclosing repo ignores, an ignored file in
|
|
1057
1156
|
// a variant) nor untracked ones under `status.showUntrackedFiles=no` — so every path this run
|
|
1058
1157
|
// will overwrite or delete is checked on its own, before the first write.
|
|
1059
|
-
|
|
1158
|
+
// Spec 25 §4.4: prune candidates' profile files join mustHold
|
|
1159
|
+
const candidateProfiles = candidates.flatMap((c) => c.profiles.map((p) => p.abs));
|
|
1160
|
+
const mustHold = [base.dir, ...(result.resolved ? [variant.dir, ...cascadeFiles] : []), ...(paramWrites?.mustHold ?? []), ...candidateProfiles];
|
|
1060
1161
|
const unheld = await gitUnheld(f.root, mustHold);
|
|
1061
1162
|
if (unheld.length) {
|
|
1062
1163
|
// Name every such path (the first few, then a count), so one run shows the whole problem.
|
|
@@ -1066,6 +1167,25 @@ forge
|
|
|
1066
1167
|
lines.push(` … and ${unheld.length - SHOWN} more`);
|
|
1067
1168
|
fail(`unify can only change files git can restore — ${unheld.length} path(s) under ${f.root} are not:\n${lines.join("\n")}`);
|
|
1068
1169
|
}
|
|
1170
|
+
let impactState = "skipped";
|
|
1171
|
+
let impactEntries = [];
|
|
1172
|
+
let impactBefore = [];
|
|
1173
|
+
let impactWarnings = [];
|
|
1174
|
+
let impactUnreadableReason = null;
|
|
1175
|
+
if (o.impact) {
|
|
1176
|
+
try {
|
|
1177
|
+
const fw = await forgeWorkspaces(craftarHome(), f.root);
|
|
1178
|
+
impactEntries = fw.workspaces;
|
|
1179
|
+
impactWarnings = fw.warnings;
|
|
1180
|
+
impactBefore = await planAll(fw.workspaces, f);
|
|
1181
|
+
// Refine the state: "partial" when any before entry is missing or stage-config error
|
|
1182
|
+
impactState = refineRegistryState(fw.state, impactBefore);
|
|
1183
|
+
}
|
|
1184
|
+
catch (e) {
|
|
1185
|
+
impactState = "unreadable";
|
|
1186
|
+
impactUnreadableReason = e instanceof Error ? e.message : String(e);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1069
1189
|
// Order: merged files, then the recipe cascade, then removal of the variant directory
|
|
1070
1190
|
// (Ruling 21) — a late failure leaves the variant in place, never a recipe naming a removed ingredient.
|
|
1071
1191
|
// Spec 12 §6.7: the manifest is first when it moves to schema: 2; each prefix is emission-neutral.
|
|
@@ -1097,8 +1217,72 @@ forge
|
|
|
1097
1217
|
// writing began: name what was touched and how git undoes it, and still exit 1.
|
|
1098
1218
|
throw new Error(lateFailure(e, f.root, journal), { cause: e });
|
|
1099
1219
|
}
|
|
1220
|
+
// Spec 25 §4.2: after pass — planAll against the Forge as unify left it, then impactOf per entry
|
|
1221
|
+
let impactAfter = [];
|
|
1222
|
+
let impactResults = [];
|
|
1223
|
+
if (o.impact && impactState !== "skipped" && impactState !== "unreadable" && impactEntries.length > 0) {
|
|
1224
|
+
let afterForge = f;
|
|
1225
|
+
try {
|
|
1226
|
+
afterForge = await loadForge(f.root);
|
|
1227
|
+
}
|
|
1228
|
+
catch (e) {
|
|
1229
|
+
// If loadForge fails, treat every entry as error with the actual message
|
|
1230
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1231
|
+
for (let i = 0; i < impactEntries.length; i++) {
|
|
1232
|
+
impactAfter.push({ kind: "error", stage: "plan", message: msg });
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
if (impactAfter.length === 0) {
|
|
1236
|
+
impactAfter = await planAll(impactEntries, afterForge);
|
|
1237
|
+
}
|
|
1238
|
+
for (let i = 0; i < impactEntries.length; i++) {
|
|
1239
|
+
impactResults.push(impactOf(impactBefore[i], impactAfter[i]));
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
// Spec 25 §4.4: prune step — after unify's writes and the after pass, prove and apply prunes
|
|
1243
|
+
let pruneResult = { pruned: [], kept: [] };
|
|
1244
|
+
if (o.pruneRecipes && candidates.length > 0) {
|
|
1245
|
+
// Map the state: "skipped" cannot happen here (--prune-recipes requires --impact)
|
|
1246
|
+
const pruneState = impactState === "unreadable" ? "unreadable" : impactState;
|
|
1247
|
+
try {
|
|
1248
|
+
pruneResult = await pruneRecipes(f.root, candidates, {
|
|
1249
|
+
state: pruneState,
|
|
1250
|
+
entries: impactEntries,
|
|
1251
|
+
after: impactAfter,
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
catch (e) {
|
|
1255
|
+
// A throw in pruneRecipes (e.g. loadForge fails) after unify's writes: name what was touched.
|
|
1256
|
+
throw new Error(lateFailure(e, f.root, journal), { cause: e });
|
|
1257
|
+
}
|
|
1258
|
+
// Apply the prune writes
|
|
1259
|
+
for (const p of pruneResult.pruned) {
|
|
1260
|
+
try {
|
|
1261
|
+
// Write profile files first, then delete the recipe file
|
|
1262
|
+
for (const w of p.writes) {
|
|
1263
|
+
if (w.content !== null) {
|
|
1264
|
+
await note(journal, w.abs);
|
|
1265
|
+
await fs.writeFile(w.abs, w.content);
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
journal.push({ abs: w.abs, created: false });
|
|
1269
|
+
await fs.rm(w.abs, { force: true });
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
catch (e) {
|
|
1274
|
+
throw new Error(lateFailure(e, f.root, journal), { cause: e });
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1100
1278
|
// Ruling 28: a metadata difference leaves the variant in place; say which fields and why.
|
|
1101
1279
|
const warnings = [];
|
|
1280
|
+
// Spec 25 §4.2: warning for unreadable registry
|
|
1281
|
+
if (impactUnreadableReason !== null) {
|
|
1282
|
+
warnings.push(`the registry could not be read (${impactUnreadableReason}) — no workspace was checked`);
|
|
1283
|
+
}
|
|
1284
|
+
// Spec 25 §4.2: push warnings from forgeWorkspaces (credential lines)
|
|
1285
|
+
warnings.push(...impactWarnings);
|
|
1102
1286
|
if (result.metaDiffers.length) {
|
|
1103
1287
|
warnings.push(`ingredient.yaml differs in ${result.metaDiffers.join(", ")} — unify cannot merge ingredient.yaml, so ${variant.ref} stays; ` +
|
|
1104
1288
|
`resolve it by hand, or use --take base to discard the variant`);
|
|
@@ -1106,30 +1290,142 @@ forge
|
|
|
1106
1290
|
// Ruling 42: a suffixed recipe left identical to its sibling is reported, never deleted — a
|
|
1107
1291
|
// workspace's recipes.add or an extends chain may name the sibling, and removing the suffixed
|
|
1108
1292
|
// one would shift recipe order or param precedence there.
|
|
1293
|
+
// Spec 25 §4.4: only printed for candidates NOT pruned; without --prune-recipes, ends with a hint
|
|
1109
1294
|
const suffix = `--${o.profile}`;
|
|
1295
|
+
const prunedRecipes = new Set(pruneResult.pruned.map((p) => p.recipe));
|
|
1110
1296
|
for (const rn of cascade.identicalToSibling) {
|
|
1297
|
+
// Skip if this was pruned
|
|
1298
|
+
if (prunedRecipes.has(rn))
|
|
1299
|
+
continue;
|
|
1111
1300
|
const sibling = rn.slice(0, -suffix.length);
|
|
1112
|
-
|
|
1301
|
+
const baseText = `recipe ${rn} is now identical to ${sibling} — it can be removed by hand after repointing the profiles, extends and ` +
|
|
1113
1302
|
`workspace recipes lists that name it; unify does not, because a workspace or an extends chain may also name ` +
|
|
1114
|
-
`${sibling} and the recipe order or param precedence would change
|
|
1303
|
+
`${sibling} and the recipe order or param precedence would change`;
|
|
1304
|
+
// Without --prune-recipes: add the hint to rerun with it
|
|
1305
|
+
const warning = o.pruneRecipes
|
|
1306
|
+
? baseText
|
|
1307
|
+
: `${baseText} — or restore the Forge with git and rerun this unify with --prune-recipes`;
|
|
1308
|
+
warnings.push(warning);
|
|
1115
1309
|
}
|
|
1116
|
-
//
|
|
1117
|
-
|
|
1118
|
-
|
|
1310
|
+
// Spec 25 §4.3: helper to build conditional warnings based on registry state and concerned workspaces
|
|
1311
|
+
const unchecked = impactBefore.filter((p) => p.kind === "missing" || (p.kind === "error" && p.stage === "config")).length;
|
|
1312
|
+
const reach = (text, test) => {
|
|
1313
|
+
// --no-impact → today's text
|
|
1314
|
+
if (!o.impact)
|
|
1315
|
+
return `${text}; unify cannot reach workspaces`;
|
|
1316
|
+
const c = concerned(impactBefore, impactEntries, test);
|
|
1317
|
+
// concerned non-empty → name them
|
|
1318
|
+
if (c.concerned.length > 0) {
|
|
1319
|
+
const names = c.concerned.map((w) => `${w.path} (${w.file})`).join(", ");
|
|
1320
|
+
const uncheckedNote = c.unchecked > 0 ? ` (the registry could not check ${c.unchecked} workspace${c.unchecked > 1 ? "s" : ""})` : "";
|
|
1321
|
+
return `${text} — concerned: ${names}${uncheckedNote}`;
|
|
1322
|
+
}
|
|
1323
|
+
// none concerned and registry state read → no warning
|
|
1324
|
+
if (impactState === "read")
|
|
1325
|
+
return null;
|
|
1326
|
+
// otherwise → today's text plus suffix by state
|
|
1327
|
+
let stateSuffix;
|
|
1328
|
+
switch (impactState) {
|
|
1329
|
+
case "partial":
|
|
1330
|
+
stateSuffix = ` (the registry could not check ${unchecked} workspace${unchecked > 1 ? "s" : ""})`;
|
|
1331
|
+
break;
|
|
1332
|
+
case "none":
|
|
1333
|
+
stateSuffix = " (no workspace of this Forge is registered on this machine)";
|
|
1334
|
+
break;
|
|
1335
|
+
case "off":
|
|
1336
|
+
stateSuffix = " (the registry is off)";
|
|
1337
|
+
break;
|
|
1338
|
+
case "unreadable":
|
|
1339
|
+
stateSuffix = " (the registry could not be read)";
|
|
1340
|
+
break;
|
|
1341
|
+
default:
|
|
1342
|
+
stateSuffix = "";
|
|
1343
|
+
}
|
|
1344
|
+
return `${text}; unify cannot reach workspaces${stateSuffix}`;
|
|
1345
|
+
};
|
|
1346
|
+
// Spec 09 W1: a new parameter may override a workspace's overrides.params
|
|
1119
1347
|
for (const e of result.params.filter((p) => !p.reused)) {
|
|
1120
|
-
|
|
1121
|
-
`craftar.local.yaml) now overrides ${base.ref} too
|
|
1348
|
+
const text = `${e.key} is now a parameter of ${base.ref} — a workspace that sets overrides.params.${e.key} (craftar.yaml or ` +
|
|
1349
|
+
`craftar.local.yaml) now overrides ${base.ref} too`;
|
|
1350
|
+
const testW1 = (doc) => {
|
|
1351
|
+
if (typeof doc !== "object" || doc === null)
|
|
1352
|
+
return false;
|
|
1353
|
+
const d = doc;
|
|
1354
|
+
if (typeof d.overrides !== "object" || d.overrides === null)
|
|
1355
|
+
return false;
|
|
1356
|
+
const ov = d.overrides;
|
|
1357
|
+
if (typeof ov.params !== "object" || ov.params === null)
|
|
1358
|
+
return false;
|
|
1359
|
+
return Object.hasOwn(ov.params, e.key);
|
|
1360
|
+
};
|
|
1361
|
+
const warn = reach(text, testW1);
|
|
1362
|
+
if (warn !== null)
|
|
1363
|
+
warnings.push(warn);
|
|
1122
1364
|
}
|
|
1123
|
-
// Spec 12 W3: a new section
|
|
1365
|
+
// Spec 12 W3: a new section may be filled by a workspace's overrides.sections
|
|
1124
1366
|
for (const s of result.sections.filter((sec) => !sec.existing)) {
|
|
1125
|
-
|
|
1126
|
-
`craftar.local.yaml) now applies there
|
|
1367
|
+
const text = `${s.name} is now a section of ${base.ref} — a workspace that sets overrides.sections.${s.key}.${s.name} (craftar.yaml or ` +
|
|
1368
|
+
`craftar.local.yaml) now applies there`;
|
|
1369
|
+
const testW3 = (doc) => {
|
|
1370
|
+
if (typeof doc !== "object" || doc === null)
|
|
1371
|
+
return false;
|
|
1372
|
+
const d = doc;
|
|
1373
|
+
if (typeof d.overrides !== "object" || d.overrides === null)
|
|
1374
|
+
return false;
|
|
1375
|
+
const ov = d.overrides;
|
|
1376
|
+
if (typeof ov.sections !== "object" || ov.sections === null)
|
|
1377
|
+
return false;
|
|
1378
|
+
const sec = ov.sections;
|
|
1379
|
+
if (!Object.hasOwn(sec, s.key))
|
|
1380
|
+
return false;
|
|
1381
|
+
const keyObj = sec[s.key];
|
|
1382
|
+
if (typeof keyObj !== "object" || keyObj === null)
|
|
1383
|
+
return false;
|
|
1384
|
+
return Object.hasOwn(keyObj, s.name);
|
|
1385
|
+
};
|
|
1386
|
+
const warn = reach(text, testW3);
|
|
1387
|
+
if (warn !== null)
|
|
1388
|
+
warnings.push(warn);
|
|
1127
1389
|
}
|
|
1390
|
+
// Ruling 38: a removed variant may be disabled by a workspace's overrides.ingredients.disable
|
|
1128
1391
|
if (variantRemoved) {
|
|
1129
|
-
|
|
1130
|
-
`must now name ${base.ref}, or the base comes back enabled
|
|
1392
|
+
const text = `${variant.ref} was removed — a workspace that disables it in overrides.ingredients.disable (craftar.yaml or craftar.local.yaml) ` +
|
|
1393
|
+
`must now name ${base.ref}, or the base comes back enabled`;
|
|
1394
|
+
const testDisable = (doc) => {
|
|
1395
|
+
if (typeof doc !== "object" || doc === null)
|
|
1396
|
+
return false;
|
|
1397
|
+
const d = doc;
|
|
1398
|
+
if (typeof d.overrides !== "object" || d.overrides === null)
|
|
1399
|
+
return false;
|
|
1400
|
+
const ov = d.overrides;
|
|
1401
|
+
if (typeof ov.ingredients !== "object" || ov.ingredients === null)
|
|
1402
|
+
return false;
|
|
1403
|
+
const ing = ov.ingredients;
|
|
1404
|
+
if (!Array.isArray(ing.disable))
|
|
1405
|
+
return false;
|
|
1406
|
+
return ing.disable.includes(variant.ref);
|
|
1407
|
+
};
|
|
1408
|
+
const warn = reach(text, testDisable);
|
|
1409
|
+
if (warn !== null)
|
|
1410
|
+
warnings.push(warn);
|
|
1411
|
+
}
|
|
1412
|
+
// Spec 25 §4.4: when at least one recipe was pruned, add coverage warning
|
|
1413
|
+
if (pruneResult.pruned.length > 0) {
|
|
1414
|
+
const n = impactEntries.length;
|
|
1415
|
+
warnings.push(`pruned against the ${n} workspace${n === 1 ? "" : "s"} registered on this machine — a workspace synced elsewhere (CI, another machine, CRAFTAR_NO_REGISTRY) is not covered`);
|
|
1131
1416
|
}
|
|
1132
1417
|
if (o.json) {
|
|
1418
|
+
// Spec 25 §4.2: impact array in JSON output
|
|
1419
|
+
const impact = impactResults.map((r, i) => ({
|
|
1420
|
+
path: impactEntries[i].entry.path,
|
|
1421
|
+
profile: impactEntries[i].entry.profile,
|
|
1422
|
+
match: impactEntries[i].match,
|
|
1423
|
+
via: impactEntries[i].via,
|
|
1424
|
+
ref: impactEntries[i].ref,
|
|
1425
|
+
state: r.state,
|
|
1426
|
+
files: r.files,
|
|
1427
|
+
error: r.error,
|
|
1428
|
+
}));
|
|
1133
1429
|
return console.log(JSON.stringify({
|
|
1134
1430
|
base: base.ref,
|
|
1135
1431
|
profile: o.profile,
|
|
@@ -1143,6 +1439,8 @@ forge
|
|
|
1143
1439
|
recipes: {
|
|
1144
1440
|
rewritten: cascade.rewritten,
|
|
1145
1441
|
identicalToSibling: cascade.identicalToSibling,
|
|
1442
|
+
pruned: pruneResult.pruned.map((p) => ({ recipe: p.recipe, sibling: p.sibling, profiles: p.profiles })),
|
|
1443
|
+
kept: pruneResult.kept,
|
|
1146
1444
|
},
|
|
1147
1445
|
metaDiffers: result.metaDiffers,
|
|
1148
1446
|
// Spec 09 §4.5: only what this run wrote — [] when every key was already declared and valued.
|
|
@@ -1160,6 +1458,8 @@ forge
|
|
|
1160
1458
|
manifestEdited: paramWrites?.manifest !== null && paramWrites?.manifest !== undefined,
|
|
1161
1459
|
profileEdited: paramWrites?.profile ? path.relative(f.root, paramWrites.profile.abs).split(path.sep).join("/") : null,
|
|
1162
1460
|
warnings,
|
|
1461
|
+
// Spec 25 §4.2: always present, [] when no workspace was checked
|
|
1462
|
+
impact,
|
|
1163
1463
|
}, null, 2));
|
|
1164
1464
|
}
|
|
1165
1465
|
console.log(pc.bold(`craftar forge unify ${ref} ↔ ${o.profile}`));
|
|
@@ -1190,9 +1490,42 @@ forge
|
|
|
1190
1490
|
if (cascade.identicalToSibling.length) {
|
|
1191
1491
|
console.log(` recipes now identical to a sibling: ${cascade.identicalToSibling.join(", ")}`);
|
|
1192
1492
|
}
|
|
1493
|
+
// Spec 25 §4.4: pruned and kept recipes, after the identical-to-sibling line
|
|
1494
|
+
for (const p of pruneResult.pruned) {
|
|
1495
|
+
console.log(` pruned recipe ${p.recipe} → ${p.sibling} (${p.profiles.join(", ")})`);
|
|
1496
|
+
}
|
|
1497
|
+
for (const k of pruneResult.kept) {
|
|
1498
|
+
console.log(` kept recipe ${k.recipe} — ${k.reason}`);
|
|
1499
|
+
}
|
|
1193
1500
|
for (const w of warnings)
|
|
1194
1501
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
1195
|
-
|
|
1502
|
+
// Spec 25 §4.2: impact lines, one per workspace, with suffix format
|
|
1503
|
+
for (let i = 0; i < impactResults.length; i++) {
|
|
1504
|
+
const ws = impactEntries[i];
|
|
1505
|
+
const r = impactResults[i];
|
|
1506
|
+
let stateStr;
|
|
1507
|
+
if (r.state === "no-effect") {
|
|
1508
|
+
stateStr = "no effect";
|
|
1509
|
+
}
|
|
1510
|
+
else if (r.state === "changed") {
|
|
1511
|
+
const n = r.files.length;
|
|
1512
|
+
stateStr = n === 1 ? `1 file changes — ${r.files[0]}` : `${n} files change — ${r.files.join(", ")}`;
|
|
1513
|
+
}
|
|
1514
|
+
else if (r.state === "missing") {
|
|
1515
|
+
stateStr = "missing";
|
|
1516
|
+
}
|
|
1517
|
+
else {
|
|
1518
|
+
stateStr = `error: ${r.error}`;
|
|
1519
|
+
}
|
|
1520
|
+
const impactSuffix = matchSuffix(ws);
|
|
1521
|
+
console.log(` impact: ${ws.entry.path} (${ws.entry.profile}) ${stateStr}${impactSuffix}`);
|
|
1522
|
+
}
|
|
1523
|
+
// Spec 25 §4.2: the next: hint is printed only when no workspace was checked
|
|
1524
|
+
// (every entry missing/stage-config error, or no entries, or --no-impact, or unreadable)
|
|
1525
|
+
const someChecked = impactBefore.some((p) => p.kind !== "missing" && !(p.kind === "error" && p.stage === "config"));
|
|
1526
|
+
if (!someChecked) {
|
|
1527
|
+
console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
|
|
1528
|
+
}
|
|
1196
1529
|
});
|
|
1197
1530
|
program.parseAsync().catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
|
1198
1531
|
/* ---------------------------------------------------------------- helpers */
|
|
@@ -1272,6 +1605,20 @@ function warnStderr(warnings) {
|
|
|
1272
1605
|
for (const w of warnings)
|
|
1273
1606
|
console.error(`${pc.yellow("warn")} ${w}`);
|
|
1274
1607
|
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Spec 25 §4.1–§4.2: the suffix format for a workspace that is not a path match.
|
|
1610
|
+
* Used by both `forge impact` and `forge unify` so the format is shared.
|
|
1611
|
+
*/
|
|
1612
|
+
function matchSuffix(ws) {
|
|
1613
|
+
let suffix = "";
|
|
1614
|
+
if (ws.match === "remote")
|
|
1615
|
+
suffix = ` · after push (${ws.via})`;
|
|
1616
|
+
if (ws.match === "clone")
|
|
1617
|
+
suffix = ` · after push and pull (${ws.via})`;
|
|
1618
|
+
if (ws.ref)
|
|
1619
|
+
suffix += `, pins ${ws.ref}`;
|
|
1620
|
+
return suffix;
|
|
1621
|
+
}
|
|
1275
1622
|
/** The header line naming a remote Forge (spec 13 §4.1); null for a path Forge, whose headers keep their shape. */
|
|
1276
1623
|
function forgeLine(ws, lock) {
|
|
1277
1624
|
const o = ws.origin;
|