artshelf 0.13.1 → 0.15.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.
@@ -0,0 +1,204 @@
1
+ import * as fs from "node:fs";
2
+ import { existsSync, lstatSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { shellArg } from "./shared/shell-quote.js";
5
+ import { ageOf, now as currentTime } from "./time.js";
6
+ const DIRECTORY_SIZE_MAX_ENTRIES = 10_000;
7
+ const openDirectorySync = fs.opendirSync;
8
+ export function buildInspectReport(record, options) {
9
+ const at = options.now ?? currentTime();
10
+ const subjectPath = subjectPathOf(record);
11
+ const node = describeNode(subjectPath, record.status === "active");
12
+ const dueState = classifyDueState(record, at, node.existence);
13
+ const { recommendation, nextAction } = recommend(record, node.existence, dueState, options.ledgerPath);
14
+ return {
15
+ schemaVersion: 1,
16
+ id: record.id,
17
+ path: record.path,
18
+ subjectPath,
19
+ kind: record.kind,
20
+ owner: record.owner,
21
+ labels: record.labels,
22
+ status: record.status,
23
+ cleanup: record.cleanup,
24
+ reason: record.reason,
25
+ existence: node.existence,
26
+ nodeKind: node.nodeKind,
27
+ byteSize: node.byteSize,
28
+ byteSizeTruncated: node.byteSizeTruncated,
29
+ age: ageOf(at, record.createdAt),
30
+ retention: record.retention,
31
+ retainUntil: record.retainUntil ?? null,
32
+ dueState,
33
+ recommendation,
34
+ nextAction
35
+ };
36
+ }
37
+ function subjectPathOf(record) {
38
+ if (record.status === "trashed" && record.targetPath)
39
+ return record.targetPath;
40
+ return record.path;
41
+ }
42
+ function describeNode(subjectPath, followSymlinkExistence = false) {
43
+ if (followSymlinkExistence && !existsSync(subjectPath)) {
44
+ return { existence: "missing", nodeKind: null, byteSize: null, byteSizeTruncated: false };
45
+ }
46
+ let stat;
47
+ try {
48
+ stat = lstatSync(subjectPath);
49
+ }
50
+ catch {
51
+ return { existence: "missing", nodeKind: null, byteSize: null, byteSizeTruncated: false };
52
+ }
53
+ if (stat.isSymbolicLink())
54
+ return { existence: "present", nodeKind: "other", byteSize: null, byteSizeTruncated: false };
55
+ if (stat.isFile())
56
+ return { existence: "present", nodeKind: "file", byteSize: stat.size, byteSizeTruncated: false };
57
+ if (stat.isDirectory()) {
58
+ const size = directorySize(subjectPath);
59
+ return { existence: "present", nodeKind: "directory", byteSize: size.bytes, byteSizeTruncated: size.truncated };
60
+ }
61
+ return { existence: "present", nodeKind: "other", byteSize: null, byteSizeTruncated: false };
62
+ }
63
+ // Bounded recursive size: sums regular-file bytes, never follows symlinks, and stops at
64
+ // a fixed entry budget so a pathological tree cannot stall a read-only inspect.
65
+ function directorySize(root) {
66
+ let total = 0;
67
+ let visited = 0;
68
+ let incomplete = false;
69
+ const stack = [root];
70
+ while (stack.length > 0) {
71
+ const dir = stack.pop();
72
+ if (dir === undefined)
73
+ break;
74
+ let entries;
75
+ try {
76
+ entries = openDirectorySync(dir);
77
+ }
78
+ catch {
79
+ incomplete = true;
80
+ continue;
81
+ }
82
+ try {
83
+ while (true) {
84
+ let entry;
85
+ try {
86
+ entry = entries.readSync();
87
+ }
88
+ catch {
89
+ incomplete = true;
90
+ break;
91
+ }
92
+ if (entry === null)
93
+ break;
94
+ if (visited >= DIRECTORY_SIZE_MAX_ENTRIES)
95
+ return { bytes: total, truncated: true };
96
+ visited += 1;
97
+ const child = join(dir, entry.name);
98
+ let stat;
99
+ try {
100
+ stat = lstatSync(child);
101
+ }
102
+ catch {
103
+ incomplete = true;
104
+ continue;
105
+ }
106
+ if (stat.isSymbolicLink())
107
+ continue;
108
+ if (stat.isDirectory()) {
109
+ stack.push(child);
110
+ continue;
111
+ }
112
+ if (stat.isFile())
113
+ total += stat.size;
114
+ }
115
+ }
116
+ finally {
117
+ try {
118
+ entries.closeSync();
119
+ }
120
+ catch {
121
+ incomplete = true;
122
+ }
123
+ }
124
+ }
125
+ return { bytes: total, truncated: incomplete };
126
+ }
127
+ // Mirrors ledger due classification, but reuses the existence already observed so a
128
+ // trashed/terminal record never re-stats. Only active records carry a due state.
129
+ function classifyDueState(record, at, existence) {
130
+ if (record.status !== "active")
131
+ return null;
132
+ if (existence === "missing")
133
+ return "missing-path";
134
+ if (record.retention.mode === "manual-review")
135
+ return "manual-review";
136
+ if (!record.retainUntil)
137
+ return "due";
138
+ return new Date(record.retainUntil).getTime() <= at.getTime() ? "due" : "kept";
139
+ }
140
+ function recommend(record, existence, dueState, ledgerPath) {
141
+ const idArg = shellArg(record.id);
142
+ const ledgerArg = shellArg(ledgerPath);
143
+ if (record.status === "resolved") {
144
+ return { recommendation: "keep", nextAction: "Already resolved — no action needed." };
145
+ }
146
+ if (record.status === "trashed") {
147
+ if (existence === "missing") {
148
+ return {
149
+ recommendation: "resolve-only",
150
+ nextAction: `Trashed target is missing — confirm the artifact is gone, then run \`artshelf resolve ${idArg} --ledger ${ledgerArg} --status resolved --reason '<why>'\` (ledger-only).`
151
+ };
152
+ }
153
+ return {
154
+ recommendation: "keep",
155
+ nextAction: "Already trashed — permanent removal is the separate approval-gated `artshelf trash purge` flow."
156
+ };
157
+ }
158
+ if (record.status === "review-required") {
159
+ return {
160
+ recommendation: "blocked",
161
+ nextAction: "A cleanup run flagged this for manual review — inspect the artifact, then resolve or re-plan deliberately."
162
+ };
163
+ }
164
+ if (record.status === "cleanup-refused") {
165
+ return {
166
+ recommendation: "blocked",
167
+ nextAction: "A prior cleanup refused this record — handle it manually; Artshelf will not retry automatically."
168
+ };
169
+ }
170
+ if (existence === "missing" || dueState === "missing-path") {
171
+ return {
172
+ recommendation: "resolve-only",
173
+ nextAction: `Path is missing — confirm the artifact is gone, then run \`artshelf resolve ${idArg} --ledger ${ledgerArg} --status resolved --reason '<why>'\` (ledger-only).`
174
+ };
175
+ }
176
+ if (dueState === "kept") {
177
+ return {
178
+ recommendation: "snooze",
179
+ nextAction: `Retention holds until ${record.retainUntil ?? "the configured date"} — re-inspect after it expires; nothing is due now.`
180
+ };
181
+ }
182
+ if (dueState === "manual-review") {
183
+ return {
184
+ recommendation: "keep",
185
+ nextAction: "Held for manual review — keep it, change its retention, or resolve it explicitly; no cleanup is scheduled."
186
+ };
187
+ }
188
+ if (record.cleanup === "trash") {
189
+ return {
190
+ recommendation: "trash-safe",
191
+ nextAction: `Due and disposable — run \`artshelf cleanup --dry-run --ledger ${ledgerArg}\`, then approve the reviewed plan id.`
192
+ };
193
+ }
194
+ if (record.cleanup === "delete") {
195
+ return {
196
+ recommendation: "blocked",
197
+ nextAction: "Due with cleanup=delete, which Artshelf refuses — switch it to cleanup=trash and plan a cleanup, or resolve it manually."
198
+ };
199
+ }
200
+ return {
201
+ recommendation: "keep",
202
+ nextAction: "Due and held for review — keep it, change its retention, resolve it, or switch cleanup to trash and plan a cleanup."
203
+ };
204
+ }
@@ -0,0 +1,259 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { assertSafeGeneratedId } from "./ledger.js";
5
+ import { withPathLock } from "./locks.js";
6
+ import { listRegisteredLedgers, normalizeRegistryPath, removeRegisteredLedgers } from "./registry.js";
7
+ import { now, toIso } from "./time.js";
8
+ // Classify the registry into prune findings (read-only). A registration is prunable
9
+ // when its ledger file is missing — the same "missing/stale" signal `ledgers list`
10
+ // reports via existence of the ledger path. Registrations whose resolved path appears
11
+ // more than once are ambiguous: pruning one would silently drop a sibling, so they are
12
+ // blocked for manual resolution instead of pruned. Present ledger files yield nothing.
13
+ export function classifyRegistryPruneFindings(registryPath) {
14
+ const entries = listRegisteredLedgers(normalizeRegistryPath(registryPath));
15
+ const pathCounts = new Map();
16
+ for (const entry of entries) {
17
+ pathCounts.set(entry.path, (pathCounts.get(entry.path) ?? 0) + 1);
18
+ }
19
+ const findings = [];
20
+ for (const entry of entries) {
21
+ if ((pathCounts.get(entry.path) ?? 0) > 1) {
22
+ findings.push(finding(entry, "blocked", "ambiguous duplicate registry path; resolve manually before pruning"));
23
+ continue;
24
+ }
25
+ if (existsSync(entry.path))
26
+ continue;
27
+ findings.push(finding(entry, "prune", "registered ledger file is missing"));
28
+ }
29
+ return findings;
30
+ }
31
+ // Build the registry-prune plan without persisting anything (dry-run preview). Fully
32
+ // read-only: it classifies the registry and returns the plan a `--dry-run` would
33
+ // create, but never writes a plan file or mutates the registry. A plan with no
34
+ // actionable entries collapses to the not-created shape so callers can render
35
+ // "nothing to prune" the same way cleanup and reconcile do.
36
+ export function previewRegistryPrunePlan(registryPath) {
37
+ const plan = buildRegistryPrunePlan(normalizeRegistryPath(registryPath));
38
+ return plan.entries.length === 0 ? noCreatedRegistryPrunePlan(plan) : plan;
39
+ }
40
+ // Create (or reuse) a reviewed registry-prune plan (dry-run). This is the only part of
41
+ // dry-run that writes, and it only writes the plan file — never the registry. When an
42
+ // earlier plan already covers the same prunable entries it is reused verbatim (stable
43
+ // plan id), and when nothing is actionable no plan artifact is created at all, keeping
44
+ // dry-run side-effect-free in that case. The plan file lives next to the registry under
45
+ // `registry-prune-plans/` so a later `--execute` can discover it by exact plan id.
46
+ export function createRegistryPrunePlan(registryPath) {
47
+ const normalized = normalizeRegistryPath(registryPath);
48
+ const plan = buildRegistryPrunePlan(normalized);
49
+ if (plan.entries.length === 0)
50
+ return noCreatedRegistryPrunePlan(plan);
51
+ const existing = matchingExistingRegistryPrunePlan(normalized, plan);
52
+ const reviewed = existing ? { ...plan, planId: existing.planId, planPath: existing.planPath } : plan;
53
+ if (!reviewed.planPath)
54
+ throw new Error("registry prune plan path was not created");
55
+ writeRegistryPrunePlanFile(reviewed.planPath, reviewed);
56
+ return reviewed;
57
+ }
58
+ // Apply a reviewed registry-prune plan (NGX-481 `ledgers prune --execute`). This is the
59
+ // only mutating registry-prune entrypoint and it is deliberately conservative:
60
+ // * It refuses up front when the plan id is missing, the registry is absent, the plan
61
+ // file is absent, or the plan file's declared id/registry does not match the scoped
62
+ // request (no fresh plan, no `--all`; it binds to one exact reviewed plan id against
63
+ // one exact registry path).
64
+ // * Inside one registry lock it re-classifies the live registry and only removes a
65
+ // planned entry that still classifies as prunable; entries whose ledger file
66
+ // reappeared or whose path became an ambiguous duplicate are skipped, not removed.
67
+ // * It writes a rollback copy of the registry before mutating and a receipt after,
68
+ // then verifies the removed registrations are actually gone.
69
+ export function executeRegistryPrunePlan(registryPath, planId) {
70
+ if (!planId)
71
+ throw new Error("ledgers prune --execute requires --plan-id");
72
+ const normalized = normalizeRegistryPath(registryPath);
73
+ if (!existsSync(normalized))
74
+ throw new Error(`Registry not found: ${normalized}`);
75
+ const planPath = registryPrunePlanPath(normalized, planId);
76
+ if (!existsSync(planPath))
77
+ throw new Error(`Registry prune plan not found: ${planId}`);
78
+ const plan = JSON.parse(readFileSync(planPath, "utf8"));
79
+ assertRegistryPrunePlanExecutable(plan, planId, normalized);
80
+ const receiptPath = registryPruneReceiptPath(normalized, planId);
81
+ const rollbackPath = registryPruneRollbackPath(normalized, planId);
82
+ return withPathLock(normalized, () => {
83
+ const existingReceipt = readExistingRegistryPruneReceipt(receiptPath, planId, normalized);
84
+ if (existingReceipt)
85
+ return existingReceipt;
86
+ const liveByKey = new Map(classifyRegistryPruneFindings(normalized).map((item) => [pruneKey(item.name, item.path), item]));
87
+ const removable = [];
88
+ const skipped = [];
89
+ for (const entry of plan.entries) {
90
+ if (liveByKey.get(pruneKey(entry.name, entry.path))?.status === "prune")
91
+ removable.push(entry);
92
+ else
93
+ skipped.push(removal(entry.name, entry.path, entry.scope));
94
+ }
95
+ let removedEntries = [];
96
+ const receiptRollbackPath = removable.length > 0 ? rollbackPath : null;
97
+ if (removable.length > 0) {
98
+ copyRegistrySnapshot(normalized, rollbackPath);
99
+ removedEntries = removeRegisteredLedgers(normalized, removable.map((entry) => ({ name: entry.name, path: entry.path })));
100
+ }
101
+ const removed = removedEntries.map((entry) => removal(entry.name, entry.path, entry.scope));
102
+ const verification = verifyRegistryPrune(normalized, removed);
103
+ const receipt = {
104
+ planId,
105
+ registryPath: normalized,
106
+ executedAt: toIso(now()),
107
+ rollbackPath: receiptRollbackPath,
108
+ removed,
109
+ skipped,
110
+ verification,
111
+ receiptPath
112
+ };
113
+ writeRegistryPruneReceiptFile(receiptPath, receipt);
114
+ return receipt;
115
+ }, "Artshelf ledger registry");
116
+ }
117
+ function finding(entry, status, reason) {
118
+ return { name: entry.name, path: entry.path, scope: entry.scope, status, reason };
119
+ }
120
+ function buildRegistryPrunePlan(registryPath) {
121
+ const generatedAt = now();
122
+ const findings = classifyRegistryPruneFindings(registryPath);
123
+ const entries = findings.filter((item) => item.status === "prune").map(planEntry);
124
+ const skipped = findings.filter((item) => item.status === "blocked").map(planEntry);
125
+ const planId = makeRegistryPrunePlanId(generatedAt);
126
+ return {
127
+ planId,
128
+ generatedAt: toIso(generatedAt),
129
+ registryPath,
130
+ entries,
131
+ skipped,
132
+ planPath: registryPrunePlanPath(registryPath, planId)
133
+ };
134
+ }
135
+ function planEntry(item) {
136
+ return { name: item.name, path: item.path, scope: item.scope, reason: item.reason };
137
+ }
138
+ function noCreatedRegistryPrunePlan(plan) {
139
+ return { ...plan, planId: "not-created", planPath: null };
140
+ }
141
+ // Reuse an unexecuted earlier plan whose prunable entries match this one's, so repeated
142
+ // dry-runs converge on a single stable plan id (mirrors cleanup/reconcile plan reuse).
143
+ // Only the structural entry fields are fingerprinted; volatile fields (generatedAt) and
144
+ // the review-only skipped list do not affect reuse.
145
+ function matchingExistingRegistryPrunePlan(registryPath, plan) {
146
+ const plansDir = join(dirname(registryPath), "registry-prune-plans");
147
+ if (!existsSync(plansDir))
148
+ return null;
149
+ const filenames = readdirSync(plansDir).filter((name) => name.endsWith(".json")).sort().reverse();
150
+ for (const filename of filenames) {
151
+ const planPath = join(plansDir, filename);
152
+ try {
153
+ const candidate = JSON.parse(readFileSync(planPath, "utf8"));
154
+ if (candidate.registryPath !== registryPath)
155
+ continue;
156
+ if (registryPrunePlanFingerprint(candidate) !== registryPrunePlanFingerprint(plan))
157
+ continue;
158
+ if (existsSync(registryPruneReceiptPath(registryPath, candidate.planId)))
159
+ continue;
160
+ return { ...candidate, planPath };
161
+ }
162
+ catch {
163
+ continue;
164
+ }
165
+ }
166
+ return null;
167
+ }
168
+ function registryPrunePlanFingerprint(plan) {
169
+ return JSON.stringify(plan.entries.map((entry) => ({ name: entry.name, path: entry.path, scope: entry.scope })));
170
+ }
171
+ function writeRegistryPrunePlanFile(planPath, plan) {
172
+ mkdirSync(dirname(planPath), { recursive: true });
173
+ writeFileSync(planPath, `${JSON.stringify(plan, null, 2)}\n`);
174
+ }
175
+ function makeRegistryPrunePlanId(date) {
176
+ return `registry-prune_${toIso(date).replace(/[-:]/g, "").replace("T", "_").replace("Z", "")}_${randomBytes(2).toString("hex")}`;
177
+ }
178
+ function registryPrunePlanPath(registryPath, planId) {
179
+ assertSafeGeneratedId(planId, "registry prune plan id");
180
+ return join(dirname(registryPath), "registry-prune-plans", `${planId}.json`);
181
+ }
182
+ // Bind a loaded registry-prune plan to the request before any registry mutation,
183
+ // mirroring reconcile's assertReconcilePlanExecutable: the plan must declare the
184
+ // requested id, belong to the executing registry, and carry well-formed entries.
185
+ function assertRegistryPrunePlanExecutable(plan, planId, registryPath) {
186
+ if (plan.planId !== planId) {
187
+ throw new Error(`Registry prune plan id mismatch: plan file declares ${plan.planId}, requested ${planId}`);
188
+ }
189
+ if (plan.registryPath !== registryPath) {
190
+ throw new Error(`Registry prune plan registry mismatch: plan was created for ${plan.registryPath}, executing ${registryPath}`);
191
+ }
192
+ if (!Array.isArray(plan.entries)) {
193
+ throw new Error(`Registry prune plan entries are malformed: ${planId}`);
194
+ }
195
+ for (const entry of plan.entries) {
196
+ if (!entry || typeof entry.name !== "string" || typeof entry.path !== "string") {
197
+ throw new Error(`Registry prune plan entries are malformed: ${planId}`);
198
+ }
199
+ }
200
+ }
201
+ // Re-scan the registry after mutation and confirm every removed registration is gone.
202
+ // `ok` stays true only when none of them resurface; `remainingPrunable` counts any
203
+ // prunable registrations left registry-wide so the receipt reflects whether the
204
+ // registry is fully clean or other plans still have work.
205
+ function verifyRegistryPrune(registryPath, removed) {
206
+ const live = classifyRegistryPruneFindings(registryPath);
207
+ const stillPresent = removed.filter((entry) => live.some((item) => item.name === entry.name && item.path === entry.path));
208
+ const remainingPrunable = live.filter((item) => item.status === "prune").length;
209
+ return {
210
+ ok: stillPresent.length === 0,
211
+ remainingPrunable,
212
+ detail: stillPresent.length === 0
213
+ ? "removed registrations are gone; registry re-scan is clean of them"
214
+ : `still registered after prune: ${stillPresent.map((entry) => entry.name).join(", ")}`
215
+ };
216
+ }
217
+ // Snapshot the registry verbatim before mutation so the receipt's rollbackPath points
218
+ // at a restorable copy. The registry is always UTF-8 JSON, so a read/write round-trip
219
+ // reproduces it byte-for-byte and keeps file I/O consistent with the rest of the code.
220
+ function copyRegistrySnapshot(registryPath, rollbackPath) {
221
+ mkdirSync(dirname(rollbackPath), { recursive: true });
222
+ writeFileSync(rollbackPath, readFileSync(registryPath, "utf8"));
223
+ }
224
+ function readExistingRegistryPruneReceipt(receiptPath, planId, registryPath) {
225
+ if (!existsSync(receiptPath))
226
+ return null;
227
+ let receipt;
228
+ try {
229
+ receipt = JSON.parse(readFileSync(receiptPath, "utf8"));
230
+ }
231
+ catch {
232
+ throw new Error(`Registry prune receipt already exists but is unreadable: ${receiptPath}`);
233
+ }
234
+ if (receipt.planId !== planId || receipt.registryPath !== registryPath || receipt.receiptPath !== receiptPath) {
235
+ throw new Error(`Registry prune receipt already exists for a different execution: ${receiptPath}`);
236
+ }
237
+ if (!Array.isArray(receipt.removed) || !Array.isArray(receipt.skipped) || !receipt.verification) {
238
+ throw new Error(`Registry prune receipt already exists but is malformed: ${receiptPath}`);
239
+ }
240
+ return receipt;
241
+ }
242
+ function removal(name, path, scope) {
243
+ return { name, path, scope };
244
+ }
245
+ function pruneKey(name, path) {
246
+ return JSON.stringify([name, path]);
247
+ }
248
+ function registryPruneReceiptPath(registryPath, planId) {
249
+ assertSafeGeneratedId(planId, "registry prune plan id");
250
+ return join(dirname(registryPath), "registry-prune-receipts", `${planId}.json`);
251
+ }
252
+ function registryPruneRollbackPath(registryPath, planId) {
253
+ assertSafeGeneratedId(planId, "registry prune plan id");
254
+ return join(dirname(registryPath), "registry-prune-rollbacks", `${planId}.json`);
255
+ }
256
+ function writeRegistryPruneReceiptFile(receiptPath, receipt) {
257
+ mkdirSync(dirname(receiptPath), { recursive: true });
258
+ writeFileSync(receiptPath, `${JSON.stringify(receipt, null, 2)}\n`);
259
+ }
@@ -51,6 +51,33 @@ export function registerLedger(input) {
51
51
  return entry;
52
52
  });
53
53
  }
54
+ // Remove registrations matching the given (name, path) targets, returning the entries
55
+ // actually removed. The approval-gated registry prune execute composes this under its
56
+ // own registry lock (re-entrant), so classification, rollback copy, mutation, and
57
+ // verification all stay inside one critical section. Matching on both name and path
58
+ // keeps removal precise when two registrations happen to share a path. The registry is
59
+ // only rewritten when something is actually removed, so a no-op target list is inert.
60
+ export function removeRegisteredLedgers(registryPath, targets) {
61
+ const normalized = normalizeRegistryPath(registryPath);
62
+ return withRegistryLock(normalized, () => {
63
+ const registry = readRegistry(normalized);
64
+ const wanted = new Set(targets.map((target) => removalKey(target.name, resolve(target.path))));
65
+ const removed = [];
66
+ const kept = [];
67
+ for (const entry of registry.ledgers) {
68
+ if (wanted.has(removalKey(entry.name, entry.path)))
69
+ removed.push(entry);
70
+ else
71
+ kept.push(entry);
72
+ }
73
+ if (removed.length > 0)
74
+ writeRegistry(normalized, { version: 1, ledgers: kept });
75
+ return removed;
76
+ });
77
+ }
78
+ function removalKey(name, path) {
79
+ return JSON.stringify([name, path]);
80
+ }
54
81
  function writeRegistry(registryPath, registry) {
55
82
  mkdirSync(dirname(registryPath), { recursive: true });
56
83
  const tmpPath = `${registryPath}.${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}.tmp`;
@@ -5,7 +5,17 @@ function doctorAttention(summary) {
5
5
  }
6
6
  function doctorNextAction(blockers, summary, registryPath) {
7
7
  if (blockers.length > 0) {
8
- return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
8
+ const fixes = [];
9
+ if (summary.stale > 0) {
10
+ fixes.push(`run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${summary.stale} missing/stale registration(s)`);
11
+ }
12
+ if (summary.invalid > 0) {
13
+ fixes.push(`repair ${summary.invalid} invalid ledger file(s) above`);
14
+ }
15
+ if (fixes.length === 0) {
16
+ return `repair ${blockers.length} registry/ledger issue(s) above, then re-run \`artshelf doctor\``;
17
+ }
18
+ return `${fixes.join("; ")}, then re-run \`artshelf doctor\``;
9
19
  }
10
20
  if (summary.warnings > 0) {
11
21
  return `healthy, but ${summary.warnings} warning(s) noted — run \`artshelf reconcile --dry-run --all --registry ${registryPath}\` to prepare reconcile-ready approvals, then run \`artshelf review --all --registry ${registryPath}\`; nothing is auto-executed`;
@@ -0,0 +1,108 @@
1
+ import { shellArg } from "../shared/shell-quote.js";
2
+ import { attentionGlyph } from "./attention.js";
3
+ // Buckets whose next-safe action is something other than "leave it alone" get the
4
+ // attention glyph so a scanned card reads as a decision, not just a status line.
5
+ const ATTENTION_RECOMMENDATIONS = new Set([
6
+ "trash-safe",
7
+ "resolve-only",
8
+ "blocked"
9
+ ]);
10
+ export function buildInspectAgentPacket(report, ledgerPath) {
11
+ return {
12
+ schemaVersion: 1,
13
+ command: "get",
14
+ mode: "inspect",
15
+ ledgerPath,
16
+ inspect: report,
17
+ safety: {
18
+ readOnly: true,
19
+ noFileMoves: true,
20
+ noLedgerMutation: true
21
+ },
22
+ nextAction: report.nextAction,
23
+ verification: `artshelf get ${shellArg(report.id)} --inspect --agent --ledger ${shellArg(ledgerPath)}`
24
+ };
25
+ }
26
+ export function printInspect(report, ledgerPath) {
27
+ const glyph = attentionGlyph(ATTENTION_RECOMMENDATIONS.has(report.recommendation));
28
+ const labels = report.labels.length > 0 ? report.labels.map(sanitizeHumanLine).join(", ") : "none";
29
+ const lines = [];
30
+ lines.push(`${glyph} ${sanitizeHumanLine(report.id)} [${sanitizeHumanLine(report.kind)}] — ${sanitizeHumanLine(report.recommendation)}`);
31
+ lines.push(`path: ${sanitizeHumanLine(report.path)}`);
32
+ // Trashed records point `path` at the now-empty original; existence and size
33
+ // describe the trash target, so name it explicitly when the two differ.
34
+ if (report.subjectPath !== report.path)
35
+ lines.push(`trash target: ${sanitizeHumanLine(report.subjectPath)}`);
36
+ lines.push(`status: ${sanitizeHumanLine(report.status)} · cleanup: ${sanitizeHumanLine(report.cleanup)} · owner: ${sanitizeHumanLine(report.owner)} · labels: ${labels}`);
37
+ lines.push(`existence: ${sanitizeHumanLine(formatExistence(report))} · age: ${sanitizeHumanLine(report.age)} · retention: ${sanitizeHumanLine(formatRetention(report.retention))} · due: ${sanitizeHumanLine(report.dueState ?? "n/a")}`);
38
+ lines.push(`reason: ${sanitizeHumanLine(report.reason)}`);
39
+ lines.push(`next: ${sanitizeHumanLine(report.nextAction)}`);
40
+ lines.push(`ledger: ${sanitizeHumanLine(ledgerPath)}`);
41
+ process.stdout.write(`${lines.join("\n")}\n`);
42
+ }
43
+ function formatExistence(report) {
44
+ if (report.existence === "missing")
45
+ return "missing";
46
+ if (report.nodeKind === "file" || report.nodeKind === "directory") {
47
+ const size = report.byteSize === null ? "size unavailable" : formatBytes(report.byteSize);
48
+ if (report.nodeKind === "directory" && report.byteSizeTruncated) {
49
+ return `present (directory, at least ${size}; scan capped)`;
50
+ }
51
+ return `present (${report.nodeKind}, ${size})`;
52
+ }
53
+ return "present (other)";
54
+ }
55
+ function formatRetention(retention) {
56
+ if (retention.mode === "ttl")
57
+ return `ttl ${retention.ttl}`;
58
+ if (retention.mode === "retain-until")
59
+ return `retain-until ${retention.retainUntil}`;
60
+ return "manual-review";
61
+ }
62
+ function formatBytes(bytes) {
63
+ if (bytes < 1024)
64
+ return `${bytes} B`;
65
+ if (bytes < 1024 * 1024)
66
+ return `${(bytes / 1024).toFixed(1)} KB`;
67
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
68
+ }
69
+ function sanitizeHumanLine(value) {
70
+ let out = "";
71
+ for (const char of value) {
72
+ const code = char.codePointAt(0);
73
+ if (code === undefined)
74
+ continue;
75
+ if (char === "\\") {
76
+ out += "\\\\";
77
+ }
78
+ else if (char === "\n") {
79
+ out += "\\n";
80
+ }
81
+ else if (char === "\r") {
82
+ out += "\\r";
83
+ }
84
+ else if (char === "\t") {
85
+ out += "\\t";
86
+ }
87
+ else if (code < 32 || (code >= 0x7f && code <= 0x9f) || isUnicodeFormatControl(code)) {
88
+ out += `\\u${code.toString(16).padStart(4, "0")}`;
89
+ }
90
+ else {
91
+ out += char;
92
+ }
93
+ }
94
+ return out;
95
+ }
96
+ function isUnicodeFormatControl(code) {
97
+ if (code === 0x00ad || code === 0x061c)
98
+ return true;
99
+ if (code >= 0x200b && code <= 0x200f)
100
+ return true;
101
+ if (code >= 0x202a && code <= 0x202e)
102
+ return true;
103
+ if (code >= 0x2060 && code <= 0x206f)
104
+ return true;
105
+ if (code >= 0xfe00 && code <= 0xfe0f)
106
+ return true;
107
+ return code >= 0xfff9 && code <= 0xfffb;
108
+ }
@@ -11,6 +11,14 @@ export function reviewNextAction(summary, scope, ledgerPath, registryPath) {
11
11
  const broken = summary.invalid + summary.stale;
12
12
  const review = statusCommand(scope, "review", ledgerPath);
13
13
  if (broken > 0) {
14
+ if (scope === "all" && summary.stale > 0 && registryPath) {
15
+ const fixes = [
16
+ `run \`artshelf ledgers prune --dry-run --registry ${registryPath}\` to review removing ${summary.stale} missing/stale registration(s)`
17
+ ];
18
+ if (summary.invalid > 0)
19
+ fixes.push(`repair ${summary.invalid} invalid ledger(s) above (re-register or fix the file)`);
20
+ return `${fixes.join("; ")}, then re-run \`${review}\``;
21
+ }
14
22
  const repair = scope === "all" ? "re-register or fix the file" : "fix the file";
15
23
  return `repair ${broken} broken ledger(s) above (${repair}), then re-run \`${review}\``;
16
24
  }
@@ -45,7 +53,7 @@ export function printReview(results) {
45
53
  process.stdout.write(`ledger: ${result.ledger.path}\n`);
46
54
  }
47
55
  }
48
- function buildReviewDecisions(results, scope) {
56
+ function buildReviewDecisions(results, scope, registryPath) {
49
57
  const readyForApproval = [];
50
58
  const needsReviewFirst = [];
51
59
  const blocked = [];
@@ -54,6 +62,20 @@ function buildReviewDecisions(results, scope) {
54
62
  const { ledger, validate, due } = result;
55
63
  if (!validate.ok) {
56
64
  const status = result.ledgerExists ? "invalid" : "missing";
65
+ // A missing registered ledger is repaired through the approval-gated registry-prune
66
+ // flow rather than hand-editing the registry; an invalid-but-present file still needs
67
+ // a manual re-register/fix.
68
+ if (scope === "all" && status === "missing" && registryPath) {
69
+ blocked.push({
70
+ label: `Prune ${ledger.name} registration (missing)`,
71
+ itemIds: [],
72
+ actionType: "fix-registry",
73
+ approvalTarget: null,
74
+ reason: validate.errors[0] ?? "the registered ledger file is missing",
75
+ nextStep: `run \`artshelf ledgers prune --dry-run --registry ${registryPath} --json\` to review removing it, then approve \`approve artshelf ledgers prune registry ${registryPath} plan <plan-id>\``
76
+ });
77
+ continue;
78
+ }
57
79
  const repair = scope === "all" ? `re-register or fix ${ledger.path}` : `fix ${ledger.path}`;
58
80
  blocked.push({
59
81
  label: `Repair ${ledger.name} ledger (${status})`,
@@ -177,7 +199,7 @@ function reviewCounts(summary) {
177
199
  };
178
200
  }
179
201
  export function buildReviewAgentPacketAll(results, summary, registry) {
180
- const groups = buildReviewDecisions(results, "all");
202
+ const groups = buildReviewDecisions(results, "all", registry.path);
181
203
  return {
182
204
  schemaVersion: 1,
183
205
  command: "review",