craftar 0.14.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/dist/cli.js CHANGED
@@ -6,6 +6,8 @@ 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";
10
+ import { runDoctor } from "./core/doctor.js";
9
11
  import { resolveHome } from "./core/home-lock.js";
10
12
  import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
11
13
  import { resolve, sectionKey } from "./core/resolve.js";
@@ -15,9 +17,9 @@ import { canonicalValue } from "./core/sections.js";
15
17
  import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
16
18
  import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
17
19
  import { hashNormalized, toLf, stripBom } from "./core/text.js";
18
- import { exists, gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
20
+ import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
19
21
  import { fingerprintDir } from "./core/fingerprint.js";
20
- 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";
21
23
  import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
22
24
  import { planInit } from "./core/init.js";
23
25
  import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
@@ -32,7 +34,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
32
34
  */
33
35
  const DIFF_EXIT_CODE_FETCH_MODE = "sync";
34
36
  const program = new Command();
35
- program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.14.0");
37
+ program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.16.0");
36
38
  /* ---------------------------------------------------------------- import */
37
39
  program
38
40
  .command("import")
@@ -414,6 +416,40 @@ workspaces
414
416
  for (const p of gone)
415
417
  console.log(`pruned ${p}`);
416
418
  });
419
+ /* ---------------------------------------------------------------- doctor */
420
+ program
421
+ .command("doctor")
422
+ .description("Check this machine (Node, git, $CRAFTAR_HOME, the registry, the Forge cache) and, inside one, this workspace (configuration and Forge, plan, declared parameters, MCP environment variables, lock, status, registration): one line per finding — ok, warn or error — each with its fix. Reports only; reads without the network unless --fetch")
423
+ // No commander default: an explicit --workspace without craftar.yaml exits 1, the current directory without one runs the machine checks (spec 24 §4.1).
424
+ .option("--workspace <dir>", "the workspace to check (default: the current directory, when it holds craftar.yaml)")
425
+ .option("--fetch", "fetch a remote Forge first instead of reading the cached copy", false)
426
+ .option("--strict", "exit 1 on a warn too, not only on an error", false)
427
+ .option("--json", "machine-readable output", false)
428
+ .action(async (o) => {
429
+ let workspace = null;
430
+ if (o.workspace !== undefined) {
431
+ if (!(await exists(path.join(o.workspace, WORKSPACE_FILE))))
432
+ fail(`no ${WORKSPACE_FILE} in ${path.resolve(o.workspace)} — run doctor inside a workspace, or without --workspace for the machine checks`);
433
+ workspace = o.workspace;
434
+ }
435
+ else if (await exists(path.join(process.cwd(), WORKSPACE_FILE)))
436
+ workspace = process.cwd();
437
+ const report = await runDoctor({
438
+ version: program.version() ?? "unknown",
439
+ home: craftarHome(),
440
+ workspace,
441
+ fetch: o.fetch,
442
+ strict: o.strict,
443
+ registryOff: registryOff(),
444
+ });
445
+ if (o.json)
446
+ console.log(JSON.stringify(report, null, 2));
447
+ else
448
+ printDoctor(report);
449
+ // exitCode, not process.exit: a piped --json is never cut (spec 24 §5.1).
450
+ if (report.summary.error > 0 || (o.strict && report.summary.warn > 0))
451
+ process.exitCode = 1;
452
+ });
417
453
  /* ---------------------------------------------------------------- recipes */
418
454
  program
419
455
  .command("recipes")
@@ -834,6 +870,94 @@ forge
834
870
  const orphanNote = orphans.length ? `, ${orphans.length} orphan${orphans.length === 1 ? "" : "s"}` : "";
835
871
  console.log(`\n ${groups.length} base${groups.length === 1 ? "" : "s"} with variants, ${total} variant${total === 1 ? "" : "s"} total${orphanNote}`);
836
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
+ });
837
961
  forge
838
962
  .command("diff")
839
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")
@@ -895,10 +1019,16 @@ forge
895
1019
  .option("--take <side>", "resolve every decision to base or variant")
896
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)")
897
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")
898
1024
  .option("--forge <dir>", "Forge directory (instead of --workspace)")
899
1025
  .option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
900
1026
  .option("--json", "machine-readable output", false)
901
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
+ }
902
1032
  const frontEnds = [o.take, o.plan, o.savePlan].filter((v) => v !== undefined);
903
1033
  if (frontEnds.length !== 1)
904
1034
  fail("pass exactly one of --take, --plan or --save-plan");
@@ -1013,6 +1143,10 @@ forge
1013
1143
  // Ruling 33, dry pass: every recipe `ingredients` rewrite the cascade will make is checked before
1014
1144
  // the first byte is written, so a refusal (an aliased reference) leaves the Forge untouched.
1015
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
+ : [];
1016
1150
  // Spec 09 & 12: the Forge-level rows and YAML edits for parameter and section extractions, rendered before any write.
1017
1151
  const paramWrites = result.params.length || result.sections.length
1018
1152
  ? await checkParamWrites(f, base, variant, o.profile, result.params, result.sections)
@@ -1021,7 +1155,9 @@ forge
1021
1155
  // check above cannot see ignored files (a Forge its enclosing repo ignores, an ignored file in
1022
1156
  // a variant) nor untracked ones under `status.showUntrackedFiles=no` — so every path this run
1023
1157
  // will overwrite or delete is checked on its own, before the first write.
1024
- const mustHold = [base.dir, ...(result.resolved ? [variant.dir, ...cascadeFiles] : []), ...(paramWrites?.mustHold ?? [])];
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];
1025
1161
  const unheld = await gitUnheld(f.root, mustHold);
1026
1162
  if (unheld.length) {
1027
1163
  // Name every such path (the first few, then a count), so one run shows the whole problem.
@@ -1031,6 +1167,25 @@ forge
1031
1167
  lines.push(` … and ${unheld.length - SHOWN} more`);
1032
1168
  fail(`unify can only change files git can restore — ${unheld.length} path(s) under ${f.root} are not:\n${lines.join("\n")}`);
1033
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
+ }
1034
1189
  // Order: merged files, then the recipe cascade, then removal of the variant directory
1035
1190
  // (Ruling 21) — a late failure leaves the variant in place, never a recipe naming a removed ingredient.
1036
1191
  // Spec 12 §6.7: the manifest is first when it moves to schema: 2; each prefix is emission-neutral.
@@ -1062,8 +1217,72 @@ forge
1062
1217
  // writing began: name what was touched and how git undoes it, and still exit 1.
1063
1218
  throw new Error(lateFailure(e, f.root, journal), { cause: e });
1064
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
+ }
1065
1278
  // Ruling 28: a metadata difference leaves the variant in place; say which fields and why.
1066
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);
1067
1286
  if (result.metaDiffers.length) {
1068
1287
  warnings.push(`ingredient.yaml differs in ${result.metaDiffers.join(", ")} — unify cannot merge ingredient.yaml, so ${variant.ref} stays; ` +
1069
1288
  `resolve it by hand, or use --take base to discard the variant`);
@@ -1071,30 +1290,142 @@ forge
1071
1290
  // Ruling 42: a suffixed recipe left identical to its sibling is reported, never deleted — a
1072
1291
  // workspace's recipes.add or an extends chain may name the sibling, and removing the suffixed
1073
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
1074
1294
  const suffix = `--${o.profile}`;
1295
+ const prunedRecipes = new Set(pruneResult.pruned.map((p) => p.recipe));
1075
1296
  for (const rn of cascade.identicalToSibling) {
1297
+ // Skip if this was pruned
1298
+ if (prunedRecipes.has(rn))
1299
+ continue;
1076
1300
  const sibling = rn.slice(0, -suffix.length);
1077
- warnings.push(`recipe ${rn} is now identical to ${sibling} — it can be removed by hand after repointing the profiles, extends and ` +
1301
+ const baseText = `recipe ${rn} is now identical to ${sibling} — it can be removed by hand after repointing the profiles, extends and ` +
1078
1302
  `workspace recipes lists that name it; unify does not, because a workspace or an extends chain may also name ` +
1079
- `${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);
1080
1309
  }
1081
- // Ruling 38: `resolve()` matches overrides.ingredients.disable by ref, so once the variant ref
1082
- // is gone a workspace that disabled it gets the base back, enabled, with no error.
1083
- // Spec 09 W1: what a parameter extraction cannot check from inside the Forge (W2 went with spec 10).
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
1084
1347
  for (const e of result.params.filter((p) => !p.reused)) {
1085
- warnings.push(`${e.key} is now a parameter of ${base.ref} — a workspace that sets overrides.params.${e.key} (craftar.yaml or ` +
1086
- `craftar.local.yaml) now overrides ${base.ref} too; unify cannot reach workspaces`);
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);
1087
1364
  }
1088
- // Spec 12 W3: a new section's name may be cited by a workspace's overrides.sections that was inert until now.
1365
+ // Spec 12 W3: a new section may be filled by a workspace's overrides.sections
1089
1366
  for (const s of result.sections.filter((sec) => !sec.existing)) {
1090
- warnings.push(`${s.name} is now a section of ${base.ref} — a workspace that sets overrides.sections.${s.key}.${s.name} (craftar.yaml or ` +
1091
- `craftar.local.yaml) now applies there; unify cannot reach workspaces`);
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);
1092
1389
  }
1390
+ // Ruling 38: a removed variant may be disabled by a workspace's overrides.ingredients.disable
1093
1391
  if (variantRemoved) {
1094
- warnings.push(`${variant.ref} was removed — a workspace that disables it in overrides.ingredients.disable (craftar.yaml or craftar.local.yaml) ` +
1095
- `must now name ${base.ref}, or the base comes back enabled; unify cannot reach workspaces`);
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`);
1096
1416
  }
1097
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
+ }));
1098
1429
  return console.log(JSON.stringify({
1099
1430
  base: base.ref,
1100
1431
  profile: o.profile,
@@ -1108,6 +1439,8 @@ forge
1108
1439
  recipes: {
1109
1440
  rewritten: cascade.rewritten,
1110
1441
  identicalToSibling: cascade.identicalToSibling,
1442
+ pruned: pruneResult.pruned.map((p) => ({ recipe: p.recipe, sibling: p.sibling, profiles: p.profiles })),
1443
+ kept: pruneResult.kept,
1111
1444
  },
1112
1445
  metaDiffers: result.metaDiffers,
1113
1446
  // Spec 09 §4.5: only what this run wrote — [] when every key was already declared and valued.
@@ -1125,6 +1458,8 @@ forge
1125
1458
  manifestEdited: paramWrites?.manifest !== null && paramWrites?.manifest !== undefined,
1126
1459
  profileEdited: paramWrites?.profile ? path.relative(f.root, paramWrites.profile.abs).split(path.sep).join("/") : null,
1127
1460
  warnings,
1461
+ // Spec 25 §4.2: always present, [] when no workspace was checked
1462
+ impact,
1128
1463
  }, null, 2));
1129
1464
  }
1130
1465
  console.log(pc.bold(`craftar forge unify ${ref} ↔ ${o.profile}`));
@@ -1155,9 +1490,42 @@ forge
1155
1490
  if (cascade.identicalToSibling.length) {
1156
1491
  console.log(` recipes now identical to a sibling: ${cascade.identicalToSibling.join(", ")}`);
1157
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
+ }
1158
1500
  for (const w of warnings)
1159
1501
  console.log(` ${pc.yellow("warn")} ${w}`);
1160
- console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
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
+ }
1161
1529
  });
1162
1530
  program.parseAsync().catch((e) => fail(e instanceof Error ? e.message : String(e)));
1163
1531
  /* ---------------------------------------------------------------- helpers */
@@ -1179,6 +1547,16 @@ function collect(value, previous) {
1179
1547
  function craftarHome() {
1180
1548
  return resolveHome(process.env.CRAFTAR_HOME || undefined);
1181
1549
  }
1550
+ /** The text report of `craftar doctor` (spec 24 §4.4) — presentation, not contract. */
1551
+ function printDoctor(r) {
1552
+ console.log(pc.bold(`craftar doctor — craftar ${r.version} · ${r.workspace ?? "no workspace (machine checks only)"}`));
1553
+ const paint = (l) => (l === "error" ? pc.red : l === "warn" ? pc.yellow : pc.dim)(l.padEnd(6));
1554
+ // A message that already ends in its fix (a refused schema says "— upgrade craftar") is not suffixed twice.
1555
+ const suffix = (c) => (c.fix && c.level !== "ok" && !c.message.endsWith(c.fix) ? ` — ${c.fix}` : "");
1556
+ for (const c of r.checks)
1557
+ console.log(` ${paint(c.level)} ${c.id.padEnd(13)} ${c.message}${suffix(c)}`);
1558
+ console.log(`summary: ${r.summary.ok} ok, ${r.summary.warn} warn, ${r.summary.error} error`);
1559
+ }
1182
1560
  /** `CRAFTAR_NO_REGISTRY` — "no registry", read like NO_COLOR: any non-empty value turns it off (spec 21 §4.1). */
1183
1561
  function registryOff() {
1184
1562
  return Boolean(process.env.CRAFTAR_NO_REGISTRY);
@@ -1227,6 +1605,20 @@ function warnStderr(warnings) {
1227
1605
  for (const w of warnings)
1228
1606
  console.error(`${pc.yellow("warn")} ${w}`);
1229
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
+ }
1230
1622
  /** The header line naming a remote Forge (spec 13 §4.1); null for a path Forge, whose headers keep their shape. */
1231
1623
  function forgeLine(ws, lock) {
1232
1624
  const o = ws.origin;