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/README.md +19 -3
- package/dist/cli.js +409 -17
- package/dist/cli.js.map +1 -1
- package/dist/core/doctor.js +362 -0
- package/dist/core/doctor.js.map +1 -0
- 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 +28 -2
- package/dist/core/registry.js.map +1 -1
- package/dist/core/remote.js +68 -15
- package/dist/core/remote.js.map +1 -1
- package/dist/core/sync.js +30 -6
- 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/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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1082
|
-
|
|
1083
|
-
|
|
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
|
-
|
|
1086
|
-
`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);
|
|
1087
1364
|
}
|
|
1088
|
-
// Spec 12 W3: a new section
|
|
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
|
-
|
|
1091
|
-
`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);
|
|
1092
1389
|
}
|
|
1390
|
+
// Ruling 38: a removed variant may be disabled by a workspace's overrides.ingredients.disable
|
|
1093
1391
|
if (variantRemoved) {
|
|
1094
|
-
|
|
1095
|
-
`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`);
|
|
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
|
-
|
|
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;
|