craftar 0.15.0 → 0.17.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 +22 -4
- package/dist/cli.js +452 -17
- package/dist/cli.js.map +1 -1
- package/dist/core/cache.js +226 -0
- package/dist/core/cache.js.map +1 -0
- package/dist/core/doctor.js +3 -3
- package/dist/core/doctor.js.map +1 -1
- package/dist/core/home-lock.js +25 -4
- package/dist/core/home-lock.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/remote.js +204 -31
- package/dist/core/remote.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/dist/cli.js
CHANGED
|
@@ -6,7 +6,9 @@ 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";
|
|
11
|
+
import { pruneCache } from "./core/cache.js";
|
|
10
12
|
import { resolveHome } from "./core/home-lock.js";
|
|
11
13
|
import { loadWorkspace, plan, readLock, status, apply, resolveForge, resolveForgeSource, WORKSPACE_FILE, LOCAL_FILE } from "./core/sync.js";
|
|
12
14
|
import { resolve, sectionKey } from "./core/resolve.js";
|
|
@@ -16,9 +18,9 @@ import { canonicalValue } from "./core/sections.js";
|
|
|
16
18
|
import { renderDiff, NO_EOF_NEWLINE_MARKER } from "./core/diff.js";
|
|
17
19
|
import { diffIngredients, listVariants, profileOf } from "./core/variants.js";
|
|
18
20
|
import { hashNormalized, toLf, stripBom } from "./core/text.js";
|
|
19
|
-
import { exists, gitDirty, gitIsRepo, gitUnheld } from "./core/forge.js";
|
|
21
|
+
import { exists, gitDirty, gitIsRepo, gitUnheld, loadForge } from "./core/forge.js";
|
|
20
22
|
import { fingerprintDir } from "./core/fingerprint.js";
|
|
21
|
-
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, } from "./core/unify.js";
|
|
23
|
+
import { hunkAt, planFrom, applyPlan, writeUnified, checkRecipeCascade, rewriteRecipes, pruneCandidates, pruneRecipes, note, } from "./core/unify.js";
|
|
22
24
|
import { checkParamWrites, writeParamFile } from "./core/param-writes.js";
|
|
23
25
|
import { planInit } from "./core/init.js";
|
|
24
26
|
import { editRecipesText, planRecipeEdit, recipeDiffLine } from "./core/recipe-edit.js";
|
|
@@ -33,7 +35,7 @@ process.stdout.on("error", (e) => { if (e.code === "EPIPE")
|
|
|
33
35
|
*/
|
|
34
36
|
const DIFF_EXIT_CODE_FETCH_MODE = "sync";
|
|
35
37
|
const program = new Command();
|
|
36
|
-
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.
|
|
38
|
+
program.name("craftar").description("Craft, sync and convert AI-coding workspace harnesses.").version("0.17.0");
|
|
37
39
|
/* ---------------------------------------------------------------- import */
|
|
38
40
|
program
|
|
39
41
|
.command("import")
|
|
@@ -869,6 +871,94 @@ forge
|
|
|
869
871
|
const orphanNote = orphans.length ? `, ${orphans.length} orphan${orphans.length === 1 ? "" : "s"}` : "";
|
|
870
872
|
console.log(`\n ${groups.length} base${groups.length === 1 ? "" : "s"} with variants, ${total} variant${total === 1 ? "" : "s"} total${orphanNote}`);
|
|
871
873
|
});
|
|
874
|
+
forge
|
|
875
|
+
.command("impact")
|
|
876
|
+
.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")
|
|
877
|
+
.option("--forge <dir>", "Forge directory (instead of --workspace)")
|
|
878
|
+
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
|
|
879
|
+
.option("--json", "machine-readable output", false)
|
|
880
|
+
.action(async (o) => {
|
|
881
|
+
const f = await resolveForge({
|
|
882
|
+
forge: o.forge,
|
|
883
|
+
workspace: o.workspace,
|
|
884
|
+
refuseRemote: (url) => `the Forge of this workspace is remote (${url}) — clone it and pass --forge <dir>`,
|
|
885
|
+
});
|
|
886
|
+
let fw;
|
|
887
|
+
try {
|
|
888
|
+
fw = await forgeWorkspaces(craftarHome(), f.root);
|
|
889
|
+
}
|
|
890
|
+
catch (e) {
|
|
891
|
+
fail(e instanceof Error ? e.message : String(e));
|
|
892
|
+
}
|
|
893
|
+
warnStderr(fw.warnings);
|
|
894
|
+
const planned = await planAll(fw.workspaces, f);
|
|
895
|
+
const results = await Promise.all(planned.map((p) => nextSync(p)));
|
|
896
|
+
// Refine the registry state: "partial" when any entry is missing or stage-config error (spec 25 §3)
|
|
897
|
+
const registryState = refineRegistryState(fw.state, planned);
|
|
898
|
+
if (o.json) {
|
|
899
|
+
const workspaces = fw.workspaces.map((ws, i) => {
|
|
900
|
+
const r = results[i];
|
|
901
|
+
return {
|
|
902
|
+
path: ws.entry.path,
|
|
903
|
+
profile: ws.entry.profile,
|
|
904
|
+
match: ws.match,
|
|
905
|
+
via: ws.via,
|
|
906
|
+
ref: ws.ref,
|
|
907
|
+
state: r.state,
|
|
908
|
+
counts: r.counts,
|
|
909
|
+
error: r.error,
|
|
910
|
+
};
|
|
911
|
+
});
|
|
912
|
+
console.log(JSON.stringify({ forge: fw.realForge, registry: registryState, workspaces }, null, 2));
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
// Text output
|
|
916
|
+
const parts = [];
|
|
917
|
+
const byMatch = {};
|
|
918
|
+
for (const ws of fw.workspaces)
|
|
919
|
+
byMatch[ws.match] = (byMatch[ws.match] ?? 0) + 1;
|
|
920
|
+
if (byMatch.path)
|
|
921
|
+
parts.push(`${byMatch.path} by path`);
|
|
922
|
+
if (byMatch.remote)
|
|
923
|
+
parts.push(`${byMatch.remote} by remote`);
|
|
924
|
+
if (byMatch.clone)
|
|
925
|
+
parts.push(`${byMatch.clone} by clone`);
|
|
926
|
+
if (fw.state === "none") {
|
|
927
|
+
console.log(`craftar forge impact — ${fw.realForge}`);
|
|
928
|
+
console.log(` no registered workspace reads this Forge on this machine`);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
if (fw.state === "off") {
|
|
932
|
+
console.log(`craftar forge impact — ${fw.realForge}`);
|
|
933
|
+
console.log(` the registry is off (CRAFTAR_NO_REGISTRY)`);
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
const n = fw.workspaces.length;
|
|
937
|
+
console.log(`craftar forge impact — ${fw.realForge} · ${n} registered workspace${n === 1 ? "" : "s"} (${parts.join(", ")})`);
|
|
938
|
+
const maxPath = Math.max(...fw.workspaces.map((ws) => ws.entry.path.length));
|
|
939
|
+
const maxProfile = Math.max(...fw.workspaces.map((ws) => ws.entry.profile.length));
|
|
940
|
+
for (let i = 0; i < fw.workspaces.length; i++) {
|
|
941
|
+
const ws = fw.workspaces[i];
|
|
942
|
+
const r = results[i];
|
|
943
|
+
let stateStr;
|
|
944
|
+
if (r.state === "unchanged") {
|
|
945
|
+
stateStr = "unchanged";
|
|
946
|
+
}
|
|
947
|
+
else if (r.state === "changed") {
|
|
948
|
+
// Counts in NEXT_SYNC order
|
|
949
|
+
const countParts = NEXT_SYNC_STATES.map((k) => (r.counts[k] ? `${r.counts[k]} ${k}` : "")).filter(Boolean);
|
|
950
|
+
stateStr = countParts.join(", ");
|
|
951
|
+
}
|
|
952
|
+
else if (r.state === "missing") {
|
|
953
|
+
stateStr = "missing";
|
|
954
|
+
}
|
|
955
|
+
else {
|
|
956
|
+
stateStr = `error: ${r.error}`;
|
|
957
|
+
}
|
|
958
|
+
const suffix = matchSuffix(ws);
|
|
959
|
+
console.log(` ${ws.entry.path.padEnd(maxPath)} ${ws.entry.profile.padEnd(maxProfile)} ${stateStr}${suffix}`);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
872
962
|
forge
|
|
873
963
|
.command("diff")
|
|
874
964
|
.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 +1020,16 @@ forge
|
|
|
930
1020
|
.option("--take <side>", "resolve every decision to base or variant")
|
|
931
1021
|
.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
1022
|
.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")
|
|
1023
|
+
.option("--no-impact", "skip planning the registered workspaces of this Forge before and after the writes (the warnings then keep their previous text)")
|
|
1024
|
+
.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
1025
|
.option("--forge <dir>", "Forge directory (instead of --workspace)")
|
|
934
1026
|
.option("--workspace <dir>", "workspace whose craftar.yaml names the Forge (default: .)")
|
|
935
1027
|
.option("--json", "machine-readable output", false)
|
|
936
1028
|
.action(async (ref, o) => {
|
|
1029
|
+
// Spec 25 §4.4: --prune-recipes with --no-impact is refused before anything is read
|
|
1030
|
+
if (o.pruneRecipes && !o.impact) {
|
|
1031
|
+
fail("--prune-recipes needs the impact passes — drop --no-impact");
|
|
1032
|
+
}
|
|
937
1033
|
const frontEnds = [o.take, o.plan, o.savePlan].filter((v) => v !== undefined);
|
|
938
1034
|
if (frontEnds.length !== 1)
|
|
939
1035
|
fail("pass exactly one of --take, --plan or --save-plan");
|
|
@@ -1048,6 +1144,10 @@ forge
|
|
|
1048
1144
|
// Ruling 33, dry pass: every recipe `ingredients` rewrite the cascade will make is checked before
|
|
1049
1145
|
// the first byte is written, so a refusal (an aliased reference) leaves the Forge untouched.
|
|
1050
1146
|
const cascadeFiles = result.resolved ? await checkRecipeCascade(f, base.ref, variant.ref) : [];
|
|
1147
|
+
// Spec 25 §4.4: prune candidates, computed in the dry pass
|
|
1148
|
+
const candidates = o.pruneRecipes && result.resolved
|
|
1149
|
+
? await pruneCandidates(f, base.ref, variant.ref, o.profile)
|
|
1150
|
+
: [];
|
|
1051
1151
|
// Spec 09 & 12: the Forge-level rows and YAML edits for parameter and section extractions, rendered before any write.
|
|
1052
1152
|
const paramWrites = result.params.length || result.sections.length
|
|
1053
1153
|
? await checkParamWrites(f, base, variant, o.profile, result.params, result.sections)
|
|
@@ -1056,7 +1156,9 @@ forge
|
|
|
1056
1156
|
// check above cannot see ignored files (a Forge its enclosing repo ignores, an ignored file in
|
|
1057
1157
|
// a variant) nor untracked ones under `status.showUntrackedFiles=no` — so every path this run
|
|
1058
1158
|
// will overwrite or delete is checked on its own, before the first write.
|
|
1059
|
-
|
|
1159
|
+
// Spec 25 §4.4: prune candidates' profile files join mustHold
|
|
1160
|
+
const candidateProfiles = candidates.flatMap((c) => c.profiles.map((p) => p.abs));
|
|
1161
|
+
const mustHold = [base.dir, ...(result.resolved ? [variant.dir, ...cascadeFiles] : []), ...(paramWrites?.mustHold ?? []), ...candidateProfiles];
|
|
1060
1162
|
const unheld = await gitUnheld(f.root, mustHold);
|
|
1061
1163
|
if (unheld.length) {
|
|
1062
1164
|
// Name every such path (the first few, then a count), so one run shows the whole problem.
|
|
@@ -1066,6 +1168,25 @@ forge
|
|
|
1066
1168
|
lines.push(` … and ${unheld.length - SHOWN} more`);
|
|
1067
1169
|
fail(`unify can only change files git can restore — ${unheld.length} path(s) under ${f.root} are not:\n${lines.join("\n")}`);
|
|
1068
1170
|
}
|
|
1171
|
+
let impactState = "skipped";
|
|
1172
|
+
let impactEntries = [];
|
|
1173
|
+
let impactBefore = [];
|
|
1174
|
+
let impactWarnings = [];
|
|
1175
|
+
let impactUnreadableReason = null;
|
|
1176
|
+
if (o.impact) {
|
|
1177
|
+
try {
|
|
1178
|
+
const fw = await forgeWorkspaces(craftarHome(), f.root);
|
|
1179
|
+
impactEntries = fw.workspaces;
|
|
1180
|
+
impactWarnings = fw.warnings;
|
|
1181
|
+
impactBefore = await planAll(fw.workspaces, f);
|
|
1182
|
+
// Refine the state: "partial" when any before entry is missing or stage-config error
|
|
1183
|
+
impactState = refineRegistryState(fw.state, impactBefore);
|
|
1184
|
+
}
|
|
1185
|
+
catch (e) {
|
|
1186
|
+
impactState = "unreadable";
|
|
1187
|
+
impactUnreadableReason = e instanceof Error ? e.message : String(e);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1069
1190
|
// Order: merged files, then the recipe cascade, then removal of the variant directory
|
|
1070
1191
|
// (Ruling 21) — a late failure leaves the variant in place, never a recipe naming a removed ingredient.
|
|
1071
1192
|
// Spec 12 §6.7: the manifest is first when it moves to schema: 2; each prefix is emission-neutral.
|
|
@@ -1097,8 +1218,72 @@ forge
|
|
|
1097
1218
|
// writing began: name what was touched and how git undoes it, and still exit 1.
|
|
1098
1219
|
throw new Error(lateFailure(e, f.root, journal), { cause: e });
|
|
1099
1220
|
}
|
|
1221
|
+
// Spec 25 §4.2: after pass — planAll against the Forge as unify left it, then impactOf per entry
|
|
1222
|
+
let impactAfter = [];
|
|
1223
|
+
let impactResults = [];
|
|
1224
|
+
if (o.impact && impactState !== "skipped" && impactState !== "unreadable" && impactEntries.length > 0) {
|
|
1225
|
+
let afterForge = f;
|
|
1226
|
+
try {
|
|
1227
|
+
afterForge = await loadForge(f.root);
|
|
1228
|
+
}
|
|
1229
|
+
catch (e) {
|
|
1230
|
+
// If loadForge fails, treat every entry as error with the actual message
|
|
1231
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1232
|
+
for (let i = 0; i < impactEntries.length; i++) {
|
|
1233
|
+
impactAfter.push({ kind: "error", stage: "plan", message: msg });
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
if (impactAfter.length === 0) {
|
|
1237
|
+
impactAfter = await planAll(impactEntries, afterForge);
|
|
1238
|
+
}
|
|
1239
|
+
for (let i = 0; i < impactEntries.length; i++) {
|
|
1240
|
+
impactResults.push(impactOf(impactBefore[i], impactAfter[i]));
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
// Spec 25 §4.4: prune step — after unify's writes and the after pass, prove and apply prunes
|
|
1244
|
+
let pruneResult = { pruned: [], kept: [] };
|
|
1245
|
+
if (o.pruneRecipes && candidates.length > 0) {
|
|
1246
|
+
// Map the state: "skipped" cannot happen here (--prune-recipes requires --impact)
|
|
1247
|
+
const pruneState = impactState === "unreadable" ? "unreadable" : impactState;
|
|
1248
|
+
try {
|
|
1249
|
+
pruneResult = await pruneRecipes(f.root, candidates, {
|
|
1250
|
+
state: pruneState,
|
|
1251
|
+
entries: impactEntries,
|
|
1252
|
+
after: impactAfter,
|
|
1253
|
+
});
|
|
1254
|
+
}
|
|
1255
|
+
catch (e) {
|
|
1256
|
+
// A throw in pruneRecipes (e.g. loadForge fails) after unify's writes: name what was touched.
|
|
1257
|
+
throw new Error(lateFailure(e, f.root, journal), { cause: e });
|
|
1258
|
+
}
|
|
1259
|
+
// Apply the prune writes
|
|
1260
|
+
for (const p of pruneResult.pruned) {
|
|
1261
|
+
try {
|
|
1262
|
+
// Write profile files first, then delete the recipe file
|
|
1263
|
+
for (const w of p.writes) {
|
|
1264
|
+
if (w.content !== null) {
|
|
1265
|
+
await note(journal, w.abs);
|
|
1266
|
+
await fs.writeFile(w.abs, w.content);
|
|
1267
|
+
}
|
|
1268
|
+
else {
|
|
1269
|
+
journal.push({ abs: w.abs, created: false });
|
|
1270
|
+
await fs.rm(w.abs, { force: true });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
catch (e) {
|
|
1275
|
+
throw new Error(lateFailure(e, f.root, journal), { cause: e });
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1100
1279
|
// Ruling 28: a metadata difference leaves the variant in place; say which fields and why.
|
|
1101
1280
|
const warnings = [];
|
|
1281
|
+
// Spec 25 §4.2: warning for unreadable registry
|
|
1282
|
+
if (impactUnreadableReason !== null) {
|
|
1283
|
+
warnings.push(`the registry could not be read (${impactUnreadableReason}) — no workspace was checked`);
|
|
1284
|
+
}
|
|
1285
|
+
// Spec 25 §4.2: push warnings from forgeWorkspaces (credential lines)
|
|
1286
|
+
warnings.push(...impactWarnings);
|
|
1102
1287
|
if (result.metaDiffers.length) {
|
|
1103
1288
|
warnings.push(`ingredient.yaml differs in ${result.metaDiffers.join(", ")} — unify cannot merge ingredient.yaml, so ${variant.ref} stays; ` +
|
|
1104
1289
|
`resolve it by hand, or use --take base to discard the variant`);
|
|
@@ -1106,30 +1291,142 @@ forge
|
|
|
1106
1291
|
// Ruling 42: a suffixed recipe left identical to its sibling is reported, never deleted — a
|
|
1107
1292
|
// workspace's recipes.add or an extends chain may name the sibling, and removing the suffixed
|
|
1108
1293
|
// one would shift recipe order or param precedence there.
|
|
1294
|
+
// Spec 25 §4.4: only printed for candidates NOT pruned; without --prune-recipes, ends with a hint
|
|
1109
1295
|
const suffix = `--${o.profile}`;
|
|
1296
|
+
const prunedRecipes = new Set(pruneResult.pruned.map((p) => p.recipe));
|
|
1110
1297
|
for (const rn of cascade.identicalToSibling) {
|
|
1298
|
+
// Skip if this was pruned
|
|
1299
|
+
if (prunedRecipes.has(rn))
|
|
1300
|
+
continue;
|
|
1111
1301
|
const sibling = rn.slice(0, -suffix.length);
|
|
1112
|
-
|
|
1302
|
+
const baseText = `recipe ${rn} is now identical to ${sibling} — it can be removed by hand after repointing the profiles, extends and ` +
|
|
1113
1303
|
`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
|
|
1304
|
+
`${sibling} and the recipe order or param precedence would change`;
|
|
1305
|
+
// Without --prune-recipes: add the hint to rerun with it
|
|
1306
|
+
const warning = o.pruneRecipes
|
|
1307
|
+
? baseText
|
|
1308
|
+
: `${baseText} — or restore the Forge with git and rerun this unify with --prune-recipes`;
|
|
1309
|
+
warnings.push(warning);
|
|
1115
1310
|
}
|
|
1116
|
-
//
|
|
1117
|
-
|
|
1118
|
-
|
|
1311
|
+
// Spec 25 §4.3: helper to build conditional warnings based on registry state and concerned workspaces
|
|
1312
|
+
const unchecked = impactBefore.filter((p) => p.kind === "missing" || (p.kind === "error" && p.stage === "config")).length;
|
|
1313
|
+
const reach = (text, test) => {
|
|
1314
|
+
// --no-impact → today's text
|
|
1315
|
+
if (!o.impact)
|
|
1316
|
+
return `${text}; unify cannot reach workspaces`;
|
|
1317
|
+
const c = concerned(impactBefore, impactEntries, test);
|
|
1318
|
+
// concerned non-empty → name them
|
|
1319
|
+
if (c.concerned.length > 0) {
|
|
1320
|
+
const names = c.concerned.map((w) => `${w.path} (${w.file})`).join(", ");
|
|
1321
|
+
const uncheckedNote = c.unchecked > 0 ? ` (the registry could not check ${c.unchecked} workspace${c.unchecked > 1 ? "s" : ""})` : "";
|
|
1322
|
+
return `${text} — concerned: ${names}${uncheckedNote}`;
|
|
1323
|
+
}
|
|
1324
|
+
// none concerned and registry state read → no warning
|
|
1325
|
+
if (impactState === "read")
|
|
1326
|
+
return null;
|
|
1327
|
+
// otherwise → today's text plus suffix by state
|
|
1328
|
+
let stateSuffix;
|
|
1329
|
+
switch (impactState) {
|
|
1330
|
+
case "partial":
|
|
1331
|
+
stateSuffix = ` (the registry could not check ${unchecked} workspace${unchecked > 1 ? "s" : ""})`;
|
|
1332
|
+
break;
|
|
1333
|
+
case "none":
|
|
1334
|
+
stateSuffix = " (no workspace of this Forge is registered on this machine)";
|
|
1335
|
+
break;
|
|
1336
|
+
case "off":
|
|
1337
|
+
stateSuffix = " (the registry is off)";
|
|
1338
|
+
break;
|
|
1339
|
+
case "unreadable":
|
|
1340
|
+
stateSuffix = " (the registry could not be read)";
|
|
1341
|
+
break;
|
|
1342
|
+
default:
|
|
1343
|
+
stateSuffix = "";
|
|
1344
|
+
}
|
|
1345
|
+
return `${text}; unify cannot reach workspaces${stateSuffix}`;
|
|
1346
|
+
};
|
|
1347
|
+
// Spec 09 W1: a new parameter may override a workspace's overrides.params
|
|
1119
1348
|
for (const e of result.params.filter((p) => !p.reused)) {
|
|
1120
|
-
|
|
1121
|
-
`craftar.local.yaml) now overrides ${base.ref} too
|
|
1349
|
+
const text = `${e.key} is now a parameter of ${base.ref} — a workspace that sets overrides.params.${e.key} (craftar.yaml or ` +
|
|
1350
|
+
`craftar.local.yaml) now overrides ${base.ref} too`;
|
|
1351
|
+
const testW1 = (doc) => {
|
|
1352
|
+
if (typeof doc !== "object" || doc === null)
|
|
1353
|
+
return false;
|
|
1354
|
+
const d = doc;
|
|
1355
|
+
if (typeof d.overrides !== "object" || d.overrides === null)
|
|
1356
|
+
return false;
|
|
1357
|
+
const ov = d.overrides;
|
|
1358
|
+
if (typeof ov.params !== "object" || ov.params === null)
|
|
1359
|
+
return false;
|
|
1360
|
+
return Object.hasOwn(ov.params, e.key);
|
|
1361
|
+
};
|
|
1362
|
+
const warn = reach(text, testW1);
|
|
1363
|
+
if (warn !== null)
|
|
1364
|
+
warnings.push(warn);
|
|
1122
1365
|
}
|
|
1123
|
-
// Spec 12 W3: a new section
|
|
1366
|
+
// Spec 12 W3: a new section may be filled by a workspace's overrides.sections
|
|
1124
1367
|
for (const s of result.sections.filter((sec) => !sec.existing)) {
|
|
1125
|
-
|
|
1126
|
-
`craftar.local.yaml) now applies there
|
|
1368
|
+
const text = `${s.name} is now a section of ${base.ref} — a workspace that sets overrides.sections.${s.key}.${s.name} (craftar.yaml or ` +
|
|
1369
|
+
`craftar.local.yaml) now applies there`;
|
|
1370
|
+
const testW3 = (doc) => {
|
|
1371
|
+
if (typeof doc !== "object" || doc === null)
|
|
1372
|
+
return false;
|
|
1373
|
+
const d = doc;
|
|
1374
|
+
if (typeof d.overrides !== "object" || d.overrides === null)
|
|
1375
|
+
return false;
|
|
1376
|
+
const ov = d.overrides;
|
|
1377
|
+
if (typeof ov.sections !== "object" || ov.sections === null)
|
|
1378
|
+
return false;
|
|
1379
|
+
const sec = ov.sections;
|
|
1380
|
+
if (!Object.hasOwn(sec, s.key))
|
|
1381
|
+
return false;
|
|
1382
|
+
const keyObj = sec[s.key];
|
|
1383
|
+
if (typeof keyObj !== "object" || keyObj === null)
|
|
1384
|
+
return false;
|
|
1385
|
+
return Object.hasOwn(keyObj, s.name);
|
|
1386
|
+
};
|
|
1387
|
+
const warn = reach(text, testW3);
|
|
1388
|
+
if (warn !== null)
|
|
1389
|
+
warnings.push(warn);
|
|
1127
1390
|
}
|
|
1391
|
+
// Ruling 38: a removed variant may be disabled by a workspace's overrides.ingredients.disable
|
|
1128
1392
|
if (variantRemoved) {
|
|
1129
|
-
|
|
1130
|
-
`must now name ${base.ref}, or the base comes back enabled
|
|
1393
|
+
const text = `${variant.ref} was removed — a workspace that disables it in overrides.ingredients.disable (craftar.yaml or craftar.local.yaml) ` +
|
|
1394
|
+
`must now name ${base.ref}, or the base comes back enabled`;
|
|
1395
|
+
const testDisable = (doc) => {
|
|
1396
|
+
if (typeof doc !== "object" || doc === null)
|
|
1397
|
+
return false;
|
|
1398
|
+
const d = doc;
|
|
1399
|
+
if (typeof d.overrides !== "object" || d.overrides === null)
|
|
1400
|
+
return false;
|
|
1401
|
+
const ov = d.overrides;
|
|
1402
|
+
if (typeof ov.ingredients !== "object" || ov.ingredients === null)
|
|
1403
|
+
return false;
|
|
1404
|
+
const ing = ov.ingredients;
|
|
1405
|
+
if (!Array.isArray(ing.disable))
|
|
1406
|
+
return false;
|
|
1407
|
+
return ing.disable.includes(variant.ref);
|
|
1408
|
+
};
|
|
1409
|
+
const warn = reach(text, testDisable);
|
|
1410
|
+
if (warn !== null)
|
|
1411
|
+
warnings.push(warn);
|
|
1412
|
+
}
|
|
1413
|
+
// Spec 25 §4.4: when at least one recipe was pruned, add coverage warning
|
|
1414
|
+
if (pruneResult.pruned.length > 0) {
|
|
1415
|
+
const n = impactEntries.length;
|
|
1416
|
+
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
1417
|
}
|
|
1132
1418
|
if (o.json) {
|
|
1419
|
+
// Spec 25 §4.2: impact array in JSON output
|
|
1420
|
+
const impact = impactResults.map((r, i) => ({
|
|
1421
|
+
path: impactEntries[i].entry.path,
|
|
1422
|
+
profile: impactEntries[i].entry.profile,
|
|
1423
|
+
match: impactEntries[i].match,
|
|
1424
|
+
via: impactEntries[i].via,
|
|
1425
|
+
ref: impactEntries[i].ref,
|
|
1426
|
+
state: r.state,
|
|
1427
|
+
files: r.files,
|
|
1428
|
+
error: r.error,
|
|
1429
|
+
}));
|
|
1133
1430
|
return console.log(JSON.stringify({
|
|
1134
1431
|
base: base.ref,
|
|
1135
1432
|
profile: o.profile,
|
|
@@ -1143,6 +1440,8 @@ forge
|
|
|
1143
1440
|
recipes: {
|
|
1144
1441
|
rewritten: cascade.rewritten,
|
|
1145
1442
|
identicalToSibling: cascade.identicalToSibling,
|
|
1443
|
+
pruned: pruneResult.pruned.map((p) => ({ recipe: p.recipe, sibling: p.sibling, profiles: p.profiles })),
|
|
1444
|
+
kept: pruneResult.kept,
|
|
1146
1445
|
},
|
|
1147
1446
|
metaDiffers: result.metaDiffers,
|
|
1148
1447
|
// Spec 09 §4.5: only what this run wrote — [] when every key was already declared and valued.
|
|
@@ -1160,6 +1459,8 @@ forge
|
|
|
1160
1459
|
manifestEdited: paramWrites?.manifest !== null && paramWrites?.manifest !== undefined,
|
|
1161
1460
|
profileEdited: paramWrites?.profile ? path.relative(f.root, paramWrites.profile.abs).split(path.sep).join("/") : null,
|
|
1162
1461
|
warnings,
|
|
1462
|
+
// Spec 25 §4.2: always present, [] when no workspace was checked
|
|
1463
|
+
impact,
|
|
1163
1464
|
}, null, 2));
|
|
1164
1465
|
}
|
|
1165
1466
|
console.log(pc.bold(`craftar forge unify ${ref} ↔ ${o.profile}`));
|
|
@@ -1190,9 +1491,70 @@ forge
|
|
|
1190
1491
|
if (cascade.identicalToSibling.length) {
|
|
1191
1492
|
console.log(` recipes now identical to a sibling: ${cascade.identicalToSibling.join(", ")}`);
|
|
1192
1493
|
}
|
|
1494
|
+
// Spec 25 §4.4: pruned and kept recipes, after the identical-to-sibling line
|
|
1495
|
+
for (const p of pruneResult.pruned) {
|
|
1496
|
+
console.log(` pruned recipe ${p.recipe} → ${p.sibling} (${p.profiles.join(", ")})`);
|
|
1497
|
+
}
|
|
1498
|
+
for (const k of pruneResult.kept) {
|
|
1499
|
+
console.log(` kept recipe ${k.recipe} — ${k.reason}`);
|
|
1500
|
+
}
|
|
1193
1501
|
for (const w of warnings)
|
|
1194
1502
|
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
1195
|
-
|
|
1503
|
+
// Spec 25 §4.2: impact lines, one per workspace, with suffix format
|
|
1504
|
+
for (let i = 0; i < impactResults.length; i++) {
|
|
1505
|
+
const ws = impactEntries[i];
|
|
1506
|
+
const r = impactResults[i];
|
|
1507
|
+
let stateStr;
|
|
1508
|
+
if (r.state === "no-effect") {
|
|
1509
|
+
stateStr = "no effect";
|
|
1510
|
+
}
|
|
1511
|
+
else if (r.state === "changed") {
|
|
1512
|
+
const n = r.files.length;
|
|
1513
|
+
stateStr = n === 1 ? `1 file changes — ${r.files[0]}` : `${n} files change — ${r.files.join(", ")}`;
|
|
1514
|
+
}
|
|
1515
|
+
else if (r.state === "missing") {
|
|
1516
|
+
stateStr = "missing";
|
|
1517
|
+
}
|
|
1518
|
+
else {
|
|
1519
|
+
stateStr = `error: ${r.error}`;
|
|
1520
|
+
}
|
|
1521
|
+
const impactSuffix = matchSuffix(ws);
|
|
1522
|
+
console.log(` impact: ${ws.entry.path} (${ws.entry.profile}) ${stateStr}${impactSuffix}`);
|
|
1523
|
+
}
|
|
1524
|
+
// Spec 25 §4.2: the next: hint is printed only when no workspace was checked
|
|
1525
|
+
// (every entry missing/stage-config error, or no entries, or --no-impact, or unreadable)
|
|
1526
|
+
const someChecked = impactBefore.some((p) => p.kind !== "missing" && !(p.kind === "error" && p.stage === "config"));
|
|
1527
|
+
if (!someChecked) {
|
|
1528
|
+
console.log(` next: run \`craftar status --workspace <dir>\` in a workspace on profile ${o.profile} to see what moved`);
|
|
1529
|
+
}
|
|
1530
|
+
});
|
|
1531
|
+
/* ---------------------------------------------------------------- cache ---------------------------------------------------------------- */
|
|
1532
|
+
const cache = program.command("cache").description("The per-machine Forge cache in $CRAFTAR_HOME/forges/");
|
|
1533
|
+
cache
|
|
1534
|
+
.command("prune")
|
|
1535
|
+
.description("Remove the Forge cache entries nothing uses — no registered workspace and not this one — the entries whose first fetch never completed, and trees unused for 14 days; --dry-run only shows. Never touches a workspace, a lock or the registry; no network")
|
|
1536
|
+
// No commander default: an explicit --workspace without craftar.yaml exits 1, the current directory without one runs (spec 26 §4.1).
|
|
1537
|
+
.option("--dry-run", "show what would be removed; remove nothing", false)
|
|
1538
|
+
.option("--workspace <dir>", "the workspace whose Forge counts as used besides the registry (default: the current directory, when it holds craftar.yaml)")
|
|
1539
|
+
.option("--json", "machine-readable output", false)
|
|
1540
|
+
.action(async (o) => {
|
|
1541
|
+
let workspace = null;
|
|
1542
|
+
if (o.workspace !== undefined) {
|
|
1543
|
+
if (!(await exists(path.join(o.workspace, WORKSPACE_FILE))))
|
|
1544
|
+
fail(`no ${WORKSPACE_FILE} in ${path.resolve(o.workspace)} — run cache prune inside a workspace, or without --workspace`);
|
|
1545
|
+
workspace = o.workspace;
|
|
1546
|
+
}
|
|
1547
|
+
else if (await exists(path.join(process.cwd(), WORKSPACE_FILE)))
|
|
1548
|
+
workspace = process.cwd();
|
|
1549
|
+
const result = await pruneCache(craftarHome(), {
|
|
1550
|
+
dryRun: o.dryRun,
|
|
1551
|
+
workspace,
|
|
1552
|
+
registryOff: registryOff(),
|
|
1553
|
+
});
|
|
1554
|
+
if (o.json)
|
|
1555
|
+
console.log(JSON.stringify(result.report, null, 2));
|
|
1556
|
+
else
|
|
1557
|
+
printPrune(result);
|
|
1196
1558
|
});
|
|
1197
1559
|
program.parseAsync().catch((e) => fail(e instanceof Error ? e.message : String(e)));
|
|
1198
1560
|
/* ---------------------------------------------------------------- helpers */
|
|
@@ -1224,6 +1586,65 @@ function printDoctor(r) {
|
|
|
1224
1586
|
console.log(` ${paint(c.level)} ${c.id.padEnd(13)} ${c.message}${suffix(c)}`);
|
|
1225
1587
|
console.log(`summary: ${r.summary.ok} ok, ${r.summary.warn} warn, ${r.summary.error} error`);
|
|
1226
1588
|
}
|
|
1589
|
+
/** The text report of `craftar cache prune` (spec 26 §4.4) — presentation, not contract. */
|
|
1590
|
+
function printPrune(result) {
|
|
1591
|
+
const mb = (b) => `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
|
1592
|
+
if (result.header === null) {
|
|
1593
|
+
console.log("nothing to prune");
|
|
1594
|
+
for (const w of result.report.warnings)
|
|
1595
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
1596
|
+
return;
|
|
1597
|
+
}
|
|
1598
|
+
const { forges, entries, bytes } = result.header;
|
|
1599
|
+
console.log(pc.bold(`craftar cache prune — ${forges} (${entries} ${entries === 1 ? "entry" : "entries"}, ${mb(bytes)})`));
|
|
1600
|
+
// Compute label width: for entries/leftovers, just the key; for trees, `<key>/trees/<first 10 of commit>…`.
|
|
1601
|
+
const labelOf = (r) => {
|
|
1602
|
+
const p = r.path;
|
|
1603
|
+
if ("kind" in r && r.kind === "tree") {
|
|
1604
|
+
// forges/<key>/trees/<commit> → <key>/trees/<commit10>…
|
|
1605
|
+
const parts = p.split("/");
|
|
1606
|
+
return `${parts[1]}/trees/${parts[3].slice(0, 10)}…`;
|
|
1607
|
+
}
|
|
1608
|
+
// forges/<key> or forges/~removing-<...> → last segment
|
|
1609
|
+
return p.split("/").pop();
|
|
1610
|
+
};
|
|
1611
|
+
const whyRemoved = (r) => {
|
|
1612
|
+
if (r.reason === "orphan")
|
|
1613
|
+
return "orphan: nothing names it";
|
|
1614
|
+
if (r.reason === "incomplete")
|
|
1615
|
+
return "incomplete: a fetch never completed";
|
|
1616
|
+
if (r.reason === "unused")
|
|
1617
|
+
return "unused for 14 days or more";
|
|
1618
|
+
return "leftover of an interrupted prune";
|
|
1619
|
+
};
|
|
1620
|
+
const whyKept = (k) => {
|
|
1621
|
+
if (k.reason === "named")
|
|
1622
|
+
return `named by ${k.namedBy.join(", ")}`;
|
|
1623
|
+
if (k.reason === "busy")
|
|
1624
|
+
return "in use";
|
|
1625
|
+
if (k.reason === "held-open")
|
|
1626
|
+
return "held open";
|
|
1627
|
+
return "not checked";
|
|
1628
|
+
};
|
|
1629
|
+
// Compute column widths W (label) and V (why) across removed and kept rows.
|
|
1630
|
+
const allLabels = [...result.report.removed, ...result.report.kept].map(labelOf);
|
|
1631
|
+
const removedWhys = result.report.removed.map(whyRemoved);
|
|
1632
|
+
const keptWhys = result.report.kept.map(whyKept);
|
|
1633
|
+
const W = Math.max(1, ...allLabels.map((l) => l.length));
|
|
1634
|
+
const V = Math.max(1, ...removedWhys.map((w) => w.length), ...keptWhys.map((w) => w.length));
|
|
1635
|
+
const verb = result.report.dryRun ? "would remove" : "removed";
|
|
1636
|
+
for (const r of result.report.removed) {
|
|
1637
|
+
console.log(` ${verb.padEnd(12)} ${labelOf(r).padEnd(W)} ${whyRemoved(r).padEnd(V)} ${mb(r.bytes)}`);
|
|
1638
|
+
}
|
|
1639
|
+
for (const k of result.report.kept) {
|
|
1640
|
+
console.log(` ${"kept".padEnd(12)} ${labelOf(k).padEnd(W)} ${whyKept(k)}`);
|
|
1641
|
+
}
|
|
1642
|
+
for (const w of result.report.warnings) {
|
|
1643
|
+
console.log(` ${pc.yellow("warn")} ${w}`);
|
|
1644
|
+
}
|
|
1645
|
+
const freedVerb = result.report.dryRun ? "would free" : "freed";
|
|
1646
|
+
console.log(`${freedVerb} ${mb(result.report.freedBytes)}`);
|
|
1647
|
+
}
|
|
1227
1648
|
/** `CRAFTAR_NO_REGISTRY` — "no registry", read like NO_COLOR: any non-empty value turns it off (spec 21 §4.1). */
|
|
1228
1649
|
function registryOff() {
|
|
1229
1650
|
return Boolean(process.env.CRAFTAR_NO_REGISTRY);
|
|
@@ -1272,6 +1693,20 @@ function warnStderr(warnings) {
|
|
|
1272
1693
|
for (const w of warnings)
|
|
1273
1694
|
console.error(`${pc.yellow("warn")} ${w}`);
|
|
1274
1695
|
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Spec 25 §4.1–§4.2: the suffix format for a workspace that is not a path match.
|
|
1698
|
+
* Used by both `forge impact` and `forge unify` so the format is shared.
|
|
1699
|
+
*/
|
|
1700
|
+
function matchSuffix(ws) {
|
|
1701
|
+
let suffix = "";
|
|
1702
|
+
if (ws.match === "remote")
|
|
1703
|
+
suffix = ` · after push (${ws.via})`;
|
|
1704
|
+
if (ws.match === "clone")
|
|
1705
|
+
suffix = ` · after push and pull (${ws.via})`;
|
|
1706
|
+
if (ws.ref)
|
|
1707
|
+
suffix += `, pins ${ws.ref}`;
|
|
1708
|
+
return suffix;
|
|
1709
|
+
}
|
|
1275
1710
|
/** The header line naming a remote Forge (spec 13 §4.1); null for a path Forge, whose headers keep their shape. */
|
|
1276
1711
|
function forgeLine(ws, lock) {
|
|
1277
1712
|
const o = ws.origin;
|