etymd 0.1.0 → 0.2.1
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/CHANGELOG.md +32 -1
- package/README.md +111 -12
- package/dist/{approve-WM6OJHQG.js → approve-X3AW7KLD.js} +4 -4
- package/dist/audit-YH74YZFQ.js +9 -0
- package/dist/{brief-2MPNTITQ.js → brief-RT6KE4CN.js} +4 -4
- package/dist/{chunk-FXL4554F.js → chunk-4UZBCCFJ.js} +2 -2
- package/dist/chunk-6W3J4PJ6.js +60 -0
- package/dist/{chunk-I566LDX7.js → chunk-ANCCGYSH.js} +10 -3
- package/dist/{chunk-U33XLT5I.js → chunk-C4Z6NXDO.js} +1 -1
- package/dist/{chunk-TCWKIMCU.js → chunk-E2Q7WFPN.js} +75 -4
- package/dist/{ledger-YFTYCOIC.js → chunk-EUIHYD6F.js} +2 -2
- package/dist/{chunk-SMM2TW35.js → chunk-FZSIOCOZ.js} +1 -1
- package/dist/{chunk-N64ZTBCW.js → chunk-KBF3SW3N.js} +1 -1
- package/dist/{chunk-WVYKYPKS.js → chunk-LBRNZZZF.js} +28 -1
- package/dist/{chunk-5D7WNJ6N.js → chunk-SS7VGLJ5.js} +251 -82
- package/dist/cli.js +73 -13
- package/dist/{context-TT5H76LA.js → context-GYCLR3TG.js} +3 -3
- package/dist/doctor-EAA4IP4Y.js +16 -0
- package/dist/fleet-IS6YWDDV.js +1004 -0
- package/dist/{gates-KPDEUC36.js → gates-3NPKF4IZ.js} +4 -4
- package/dist/index.d.ts +223 -3
- package/dist/index.js +1061 -13
- package/dist/{init-V627UXKU.js → init-ISEFZWPV.js} +5 -5
- package/dist/ledger-RHQJH5EA.js +4 -0
- package/dist/{scan-YL7JHLUA.js → scan-ADZ2RKYY.js} +4 -4
- package/package.json +1 -1
- package/dist/audit-OHOSWOWA.js +0 -8
- package/dist/doctor-NNRJTNIY.js +0 -15
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { dismiss, accept } from './chunk-EUIHYD6F.js';
|
|
3
|
+
import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-SS7VGLJ5.js';
|
|
4
|
+
import { readLedger } from './chunk-KBF3SW3N.js';
|
|
5
|
+
import { DEFAULT_CONFIG } from './chunk-ANCCGYSH.js';
|
|
6
|
+
import './chunk-E2Q7WFPN.js';
|
|
7
|
+
import './chunk-C4Z6NXDO.js';
|
|
8
|
+
import { ETYMD_DIR } from './chunk-FZSIOCOZ.js';
|
|
9
|
+
import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph, pathExists, readText, isDirectory, git } from './chunk-LBRNZZZF.js';
|
|
10
|
+
import { promises } from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { execFile } from 'node:child_process';
|
|
14
|
+
import { promisify } from 'node:util';
|
|
15
|
+
|
|
16
|
+
function expandTilde(p) {
|
|
17
|
+
if (p === "~") return os.homedir();
|
|
18
|
+
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
19
|
+
return p;
|
|
20
|
+
}
|
|
21
|
+
var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"corp","dirs":{"<name>":"~/path/to/worktree"}}';
|
|
22
|
+
var SAFE_NAME = /^[^\s/\\:.][^\s/\\:]*$/;
|
|
23
|
+
function rejectUnsafeName(name, where, manifest, manifestFile) {
|
|
24
|
+
if (SAFE_NAME.test(name)) return false;
|
|
25
|
+
manifest.problems.push({
|
|
26
|
+
kind: "bad-shape",
|
|
27
|
+
file: manifestFile,
|
|
28
|
+
detail: `${where} name ${JSON.stringify(name)} is not a single path-safe segment \u2014 entry skipped (names build finding ids and corp/<name>/ persistence paths)`
|
|
29
|
+
});
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
function asRecord(value) {
|
|
33
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
34
|
+
}
|
|
35
|
+
function asString(value) {
|
|
36
|
+
return typeof value === "string" && value.trim() ? value : void 0;
|
|
37
|
+
}
|
|
38
|
+
function asStringMap(value) {
|
|
39
|
+
const rec = asRecord(value);
|
|
40
|
+
if (!rec) return {};
|
|
41
|
+
const out = {};
|
|
42
|
+
for (const [k, v] of Object.entries(rec)) if (typeof v === "string") out[k] = v;
|
|
43
|
+
return out;
|
|
44
|
+
}
|
|
45
|
+
async function readLocal(localPath, problems) {
|
|
46
|
+
const raw = await readText(localPath);
|
|
47
|
+
if (raw === null) return { present: false, data: {} };
|
|
48
|
+
try {
|
|
49
|
+
const parsed = asRecord(JSON.parse(raw));
|
|
50
|
+
if (!parsed) {
|
|
51
|
+
problems.push({
|
|
52
|
+
kind: "parse-error",
|
|
53
|
+
file: path.basename(localPath),
|
|
54
|
+
detail: "must contain a JSON object \u2014 local mappings unusable"
|
|
55
|
+
});
|
|
56
|
+
return { present: true, data: {} };
|
|
57
|
+
}
|
|
58
|
+
return { present: true, data: parsed };
|
|
59
|
+
} catch (err) {
|
|
60
|
+
problems.push({
|
|
61
|
+
kind: "parse-error",
|
|
62
|
+
file: path.basename(localPath),
|
|
63
|
+
detail: `not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 local mappings unusable`
|
|
64
|
+
});
|
|
65
|
+
return { present: true, data: {} };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
69
|
+
const corpNames = [];
|
|
70
|
+
for (const [i, rawEntry] of projects.entries()) {
|
|
71
|
+
const rec = asRecord(rawEntry);
|
|
72
|
+
const name = rec ? asString(rec.name) : void 0;
|
|
73
|
+
if (!rec || !name) {
|
|
74
|
+
manifest.problems.push({
|
|
75
|
+
kind: "bad-shape",
|
|
76
|
+
file: manifestFile,
|
|
77
|
+
detail: `projects[${i}] has no usable name \u2014 the entry cannot be resolved or audited`
|
|
78
|
+
});
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (rejectUnsafeName(name, `projects[${i}]`, manifest, manifestFile)) continue;
|
|
82
|
+
const profile = rec.profile === "corp" ? "corp" : "personal";
|
|
83
|
+
if (rec.profile !== void 0 && rec.profile !== "corp" && rec.profile !== "personal") {
|
|
84
|
+
manifest.problems.push({
|
|
85
|
+
kind: "bad-shape",
|
|
86
|
+
file: manifestFile,
|
|
87
|
+
detail: `\`${name}\` declares unknown profile \`${String(rec.profile)}\` \u2014 read as personal`
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
const contract = {};
|
|
91
|
+
const contractRec = asRecord(rec.contract);
|
|
92
|
+
if (contractRec) {
|
|
93
|
+
for (const [k, v] of Object.entries(contractRec)) {
|
|
94
|
+
if (typeof v === "string") contract[k] = v;
|
|
95
|
+
else {
|
|
96
|
+
manifest.problems.push({
|
|
97
|
+
kind: "bad-shape",
|
|
98
|
+
file: manifestFile,
|
|
99
|
+
detail: `\`${name}\`.contract.${k} is not a string \u2014 ignored`
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const entry = {
|
|
105
|
+
name,
|
|
106
|
+
kind: asString(rec.kind),
|
|
107
|
+
profile,
|
|
108
|
+
private: rec.private === true,
|
|
109
|
+
path: asString(rec.path),
|
|
110
|
+
upstream: asString(rec.upstream),
|
|
111
|
+
trust: asString(rec.trust),
|
|
112
|
+
staleAfterDays: typeof rec.staleAfterDays === "number" ? rec.staleAfterDays : void 0,
|
|
113
|
+
stateBudget: typeof rec.stateBudget === "number" ? rec.stateBudget : void 0,
|
|
114
|
+
contract,
|
|
115
|
+
links: asStringMap(rec.links)
|
|
116
|
+
};
|
|
117
|
+
if (entry.profile === "corp" || entry.private) {
|
|
118
|
+
corpNames.push(name);
|
|
119
|
+
if (!manifest.localPresent) {
|
|
120
|
+
entry.unresolved = `registry.local.json is absent \u2014 ${REBUILD_RECIPE}`;
|
|
121
|
+
} else if (manifest.machineProfile === "personal") {
|
|
122
|
+
entry.unresolved = 'corp entries do not resolve under machineProfile "personal" \u2014 absent on this machine by design';
|
|
123
|
+
} else {
|
|
124
|
+
const dir = manifest.localDirs[name];
|
|
125
|
+
if (!dir) {
|
|
126
|
+
entry.unresolved = `no \`dirs\` mapping for \`${name}\` in ${path.basename(manifest.localPath)}`;
|
|
127
|
+
} else {
|
|
128
|
+
entry.resolvedRoot = path.resolve(manifest.dir, expandTilde(dir));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
} else if (entry.path) {
|
|
132
|
+
entry.resolvedRoot = path.resolve(manifest.root ?? manifest.dir, expandTilde(entry.path));
|
|
133
|
+
} else {
|
|
134
|
+
entry.unresolved = "personal entry declares no `path` \u2014 nothing to resolve";
|
|
135
|
+
}
|
|
136
|
+
manifest.entries.push(entry);
|
|
137
|
+
}
|
|
138
|
+
if (corpNames.length && !manifest.localPresent) {
|
|
139
|
+
manifest.problems.push({
|
|
140
|
+
kind: "local-missing",
|
|
141
|
+
file: path.basename(manifest.localPath),
|
|
142
|
+
detail: `${corpNames.length} corp/private entr${corpNames.length === 1 ? "y" : "ies"} (${corpNames.join(", ")}) cannot resolve without it \u2014 ${REBUILD_RECIPE}`
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function loadCorpusEntries(sources, manifest, manifestFile) {
|
|
147
|
+
for (const [i, rawEntry] of sources.entries()) {
|
|
148
|
+
const rec = asRecord(rawEntry);
|
|
149
|
+
const name = rec ? asString(rec.name) : void 0;
|
|
150
|
+
if (!rec || !name) {
|
|
151
|
+
manifest.problems.push({
|
|
152
|
+
kind: "bad-shape",
|
|
153
|
+
file: manifestFile,
|
|
154
|
+
detail: `sources[${i}] has no usable name \u2014 the entry cannot be resolved`
|
|
155
|
+
});
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (rejectUnsafeName(name, `sources[${i}]`, manifest, manifestFile)) continue;
|
|
159
|
+
const entry = {
|
|
160
|
+
name,
|
|
161
|
+
kind: asString(rec.shape),
|
|
162
|
+
profile: "personal",
|
|
163
|
+
private: rec.private === true,
|
|
164
|
+
path: asString(rec.path),
|
|
165
|
+
contract: {},
|
|
166
|
+
links: {}
|
|
167
|
+
};
|
|
168
|
+
const localDir = manifest.localDirs[name];
|
|
169
|
+
if (localDir) {
|
|
170
|
+
entry.resolvedRoot = path.resolve(manifest.dir, "..", expandTilde(localDir));
|
|
171
|
+
} else if (entry.path) {
|
|
172
|
+
entry.resolvedRoot = path.resolve(manifest.dir, expandTilde(entry.path));
|
|
173
|
+
} else {
|
|
174
|
+
entry.resolvedRoot = path.resolve(manifest.dir, "..", name);
|
|
175
|
+
}
|
|
176
|
+
manifest.entries.push(entry);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
async function loadFleetManifest(manifestPath) {
|
|
180
|
+
const abs = path.resolve(manifestPath);
|
|
181
|
+
const dir = path.dirname(abs);
|
|
182
|
+
const manifestFile = path.basename(abs);
|
|
183
|
+
const raw = await readText(abs);
|
|
184
|
+
if (raw === null) {
|
|
185
|
+
throw new Error(`cannot read fleet manifest \`${abs}\``);
|
|
186
|
+
}
|
|
187
|
+
const manifest = {
|
|
188
|
+
shape: "registry",
|
|
189
|
+
manifestPath: abs,
|
|
190
|
+
dir,
|
|
191
|
+
corpHosts: [],
|
|
192
|
+
labels: {},
|
|
193
|
+
localDirs: {},
|
|
194
|
+
localPath: path.join(dir, "registry.local.json"),
|
|
195
|
+
localPresent: false,
|
|
196
|
+
entries: [],
|
|
197
|
+
problems: []
|
|
198
|
+
};
|
|
199
|
+
let parsed = null;
|
|
200
|
+
try {
|
|
201
|
+
parsed = asRecord(JSON.parse(raw));
|
|
202
|
+
if (!parsed) {
|
|
203
|
+
manifest.problems.push({
|
|
204
|
+
kind: "parse-error",
|
|
205
|
+
file: manifestFile,
|
|
206
|
+
detail: "must contain a JSON object \u2014 no entries loaded"
|
|
207
|
+
});
|
|
208
|
+
return manifest;
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
manifest.problems.push({
|
|
212
|
+
kind: "parse-error",
|
|
213
|
+
file: manifestFile,
|
|
214
|
+
detail: `not valid JSON (${err instanceof Error ? err.message : String(err)}) \u2014 no entries loaded`
|
|
215
|
+
});
|
|
216
|
+
return manifest;
|
|
217
|
+
}
|
|
218
|
+
if (Array.isArray(parsed.projects)) {
|
|
219
|
+
manifest.shape = "registry";
|
|
220
|
+
const local = await readLocal(manifest.localPath, manifest.problems);
|
|
221
|
+
manifest.localPresent = local.present;
|
|
222
|
+
manifest.localDirs = asStringMap(local.data.dirs);
|
|
223
|
+
manifest.labels = asStringMap(local.data.labels);
|
|
224
|
+
manifest.corpHosts = Array.isArray(local.data.corpHosts) ? local.data.corpHosts.filter((h) => typeof h === "string") : [];
|
|
225
|
+
manifest.machineProfile = local.data.machineProfile === "corp" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
|
|
226
|
+
const rootRaw = asString(local.data.root) ?? asString(parsed.root);
|
|
227
|
+
if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
|
|
228
|
+
loadRegistryEntries(parsed.projects, manifest, manifestFile);
|
|
229
|
+
} else if (Array.isArray(parsed.sources)) {
|
|
230
|
+
manifest.shape = "corpus";
|
|
231
|
+
manifest.localPath = path.join(dir, "sources.local.json");
|
|
232
|
+
const local = await readLocal(manifest.localPath, manifest.problems);
|
|
233
|
+
manifest.localPresent = local.present;
|
|
234
|
+
manifest.localDirs = asStringMap(local.data.dirs);
|
|
235
|
+
loadCorpusEntries(parsed.sources, manifest, manifestFile);
|
|
236
|
+
} else {
|
|
237
|
+
manifest.problems.push({
|
|
238
|
+
kind: "bad-shape",
|
|
239
|
+
file: manifestFile,
|
|
240
|
+
detail: "neither a `projects` (registry) nor a `sources` (corpus) array \u2014 no entries loaded"
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
return manifest;
|
|
244
|
+
}
|
|
245
|
+
var pExecFile = promisify(execFile);
|
|
246
|
+
var FLEET_LENS = "fleet-manifest";
|
|
247
|
+
var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
|
|
248
|
+
var CORP_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
|
|
249
|
+
function recurringClasses(projects) {
|
|
250
|
+
const byClass = /* @__PURE__ */ new Map();
|
|
251
|
+
for (const p of projects) {
|
|
252
|
+
for (const f of p.findings) {
|
|
253
|
+
const classId = f.id.split(":")[0] ?? f.id;
|
|
254
|
+
const entry = byClass.get(classId);
|
|
255
|
+
if (entry) {
|
|
256
|
+
entry.projects.add(p.name);
|
|
257
|
+
if (TIER_ORDER.indexOf(f.tier) < TIER_ORDER.indexOf(entry.tier)) entry.tier = f.tier;
|
|
258
|
+
} else {
|
|
259
|
+
byClass.set(classId, { tier: f.tier, projects: /* @__PURE__ */ new Set([p.name]) });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return [...byClass.entries()].filter(([, v]) => v.projects.size >= 2).map(([classId, v]) => ({ classId, tier: v.tier, projects: [...v.projects].sort() })).sort(
|
|
264
|
+
(a, b) => TIER_ORDER.indexOf(a.tier) - TIER_ORDER.indexOf(b.tier) || b.projects.length - a.projects.length || a.classId.localeCompare(b.classId)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
var TIER_ORDER = ["risk", "gap", "polish"];
|
|
268
|
+
function finding(id, tier, claim, evidence, why, action) {
|
|
269
|
+
return {
|
|
270
|
+
id,
|
|
271
|
+
lens: FLEET_LENS,
|
|
272
|
+
tier,
|
|
273
|
+
claim,
|
|
274
|
+
evidence,
|
|
275
|
+
why,
|
|
276
|
+
...action ? { action } : {},
|
|
277
|
+
effort: "S",
|
|
278
|
+
confidence: "high"
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function corpPersistenceRoot(manifest, name) {
|
|
282
|
+
const corpDir = path.join(manifest.dir, "corp");
|
|
283
|
+
const root = path.resolve(corpDir, name);
|
|
284
|
+
if (path.dirname(root) !== corpDir || root === corpDir) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
`entry name ${JSON.stringify(name)} escapes the corp persistence zone \u2014 refusing to resolve a ledger root for it`
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return root;
|
|
290
|
+
}
|
|
291
|
+
function stateBudgetsFor(entry) {
|
|
292
|
+
const overlay = {};
|
|
293
|
+
if (entry.staleAfterDays !== void 0) overlay.staleAfterDays = entry.staleAfterDays;
|
|
294
|
+
if (entry.stateBudget !== void 0) overlay.maxChars = entry.stateBudget;
|
|
295
|
+
return Object.keys(overlay).length ? overlay : void 0;
|
|
296
|
+
}
|
|
297
|
+
async function gitGrepFiles(root, needle, mode = "fixed") {
|
|
298
|
+
try {
|
|
299
|
+
const matchFlag = mode === "regex" ? "-E" : "-F";
|
|
300
|
+
const { stdout } = await pExecFile("git", ["grep", "-I", "-l", matchFlag, "-e", needle], {
|
|
301
|
+
cwd: root,
|
|
302
|
+
timeout: 8e3
|
|
303
|
+
});
|
|
304
|
+
return stdout.split("\n").filter(Boolean);
|
|
305
|
+
} catch (err) {
|
|
306
|
+
if (err.code === 1) return [];
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
var MACHINE_PATH_RE = /\/Users\/[A-Za-z0-9._@-]{2,}([/"']|$)/m;
|
|
311
|
+
var MACHINE_PATH_GREP = String.raw`/Users/[A-Za-z0-9._@-]{2,}(/|"|'|$)`;
|
|
312
|
+
async function realpathOr(p) {
|
|
313
|
+
try {
|
|
314
|
+
return await promises.realpath(p);
|
|
315
|
+
} catch {
|
|
316
|
+
return path.resolve(p);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
function emailMatchesCorpHosts(email, hosts) {
|
|
320
|
+
const domain = email.split("@")[1]?.toLowerCase();
|
|
321
|
+
if (!domain) return false;
|
|
322
|
+
return hosts.some((h) => {
|
|
323
|
+
const host = h.toLowerCase();
|
|
324
|
+
if (domain === host) return true;
|
|
325
|
+
if (domain.includes(".") && host.endsWith(`.${domain}`) && domain.split(".").length >= host.split(".").length - 1) {
|
|
326
|
+
return true;
|
|
327
|
+
}
|
|
328
|
+
return domain.endsWith(`.${host}`);
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
var PROBLEM_TIER = {
|
|
332
|
+
"parse-error": "risk",
|
|
333
|
+
"bad-shape": "risk",
|
|
334
|
+
"local-missing": "gap"
|
|
335
|
+
};
|
|
336
|
+
async function checkManifest(manifest) {
|
|
337
|
+
const findings = [];
|
|
338
|
+
const disclosures = [];
|
|
339
|
+
const manifestFile = path.basename(manifest.manifestPath);
|
|
340
|
+
const localFile = path.basename(manifest.localPath);
|
|
341
|
+
for (const p of manifest.problems) {
|
|
342
|
+
findings.push(
|
|
343
|
+
finding(
|
|
344
|
+
`${FLEET_LENS}/${p.kind}:${p.file}`,
|
|
345
|
+
PROBLEM_TIER[p.kind] ?? "gap",
|
|
346
|
+
`${p.file}: ${p.detail}`,
|
|
347
|
+
[p.file],
|
|
348
|
+
"A manifest that cannot be read is a fleet whose claims cannot be verified \u2014 every consumer is flying blind.",
|
|
349
|
+
"Fix the file; the manifest pair is the fleet's single source of truth."
|
|
350
|
+
)
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
const seen = /* @__PURE__ */ new Set();
|
|
354
|
+
const flaggedDup = /* @__PURE__ */ new Set();
|
|
355
|
+
for (const entry of manifest.entries) {
|
|
356
|
+
if (seen.has(entry.name) && !flaggedDup.has(entry.name)) {
|
|
357
|
+
flaggedDup.add(entry.name);
|
|
358
|
+
findings.push(
|
|
359
|
+
finding(
|
|
360
|
+
`${FLEET_LENS}/duplicate-name:${entry.name}`,
|
|
361
|
+
"gap",
|
|
362
|
+
`\`${entry.name}\` is registered more than once`,
|
|
363
|
+
[`${manifestFile}: ${entry.name} appears twice`],
|
|
364
|
+
"Resolution is keyed by name \u2014 a duplicate makes every lookup, dismiss, and delta ambiguous.",
|
|
365
|
+
"Rename one of the entries."
|
|
366
|
+
)
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
seen.add(entry.name);
|
|
370
|
+
if (entry.private && entry.path) {
|
|
371
|
+
findings.push(
|
|
372
|
+
finding(
|
|
373
|
+
`${FLEET_LENS}/private-path-leak:${entry.name}`,
|
|
374
|
+
"risk",
|
|
375
|
+
`private entry \`${entry.name}\` carries a \`path\` in the tracked manifest`,
|
|
376
|
+
[`${manifestFile}: ${entry.name}.path = ${entry.path}`],
|
|
377
|
+
"A private entry is an opaque alias by design \u2014 a tracked path ties the alias to a real directory for anyone who can read the manifest.",
|
|
378
|
+
`Delete the path; map the alias in the gitignored ${localFile} instead.`
|
|
379
|
+
)
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
if (entry.resolvedRoot && !await isDirectory(entry.resolvedRoot)) {
|
|
383
|
+
const viaLocal = manifest.localDirs[entry.name] !== void 0;
|
|
384
|
+
findings.push(
|
|
385
|
+
finding(
|
|
386
|
+
`${FLEET_LENS}/${viaLocal ? "dangling-dir" : "dangling-path"}:${entry.name}`,
|
|
387
|
+
"gap",
|
|
388
|
+
`\`${entry.name}\` resolves to a directory that does not exist`,
|
|
389
|
+
[`${viaLocal ? localFile : manifestFile}: ${entry.name} \u2192 ${entry.resolvedRoot}`],
|
|
390
|
+
"A dangling mapping silently drops the project from every sweep \u2014 absent coverage that looks registered.",
|
|
391
|
+
"Fix the mapping or retire the entry."
|
|
392
|
+
)
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "corp" || entry.private)) {
|
|
396
|
+
findings.push(
|
|
397
|
+
finding(
|
|
398
|
+
`${FLEET_LENS}/dangling-dir:${entry.name}`,
|
|
399
|
+
"gap",
|
|
400
|
+
`\`${entry.name}\` has no usable directory mapping`,
|
|
401
|
+
[`${localFile}: ${entry.unresolved}`],
|
|
402
|
+
"An unmapped corp entry silently drops out of every sweep on the machine that most needs it.",
|
|
403
|
+
`Add a \`dirs\` mapping for \`${entry.name}\` in ${localFile}.`
|
|
404
|
+
)
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
for (const entry of manifest.entries) {
|
|
409
|
+
for (const [rel, target] of Object.entries(entry.links)) {
|
|
410
|
+
if (!seen.has(target)) {
|
|
411
|
+
findings.push(
|
|
412
|
+
finding(
|
|
413
|
+
`${FLEET_LENS}/dangling-link:${entry.name}:${target}`,
|
|
414
|
+
"gap",
|
|
415
|
+
`\`${entry.name}\` links \`${rel}\` \u2192 \`${target}\`, which is not a registered name`,
|
|
416
|
+
[`${manifestFile}: ${entry.name}.links.${rel} = ${target}`],
|
|
417
|
+
"Links are the metadata that keeps moves lossless \u2014 a dead target is a relationship claim nothing can follow.",
|
|
418
|
+
"Point the link at a registered name or delete it."
|
|
419
|
+
)
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
for (const name of Object.keys(manifest.localDirs)) {
|
|
425
|
+
if (manifest.shape === "registry" && !seen.has(name)) {
|
|
426
|
+
findings.push(
|
|
427
|
+
finding(
|
|
428
|
+
`${FLEET_LENS}/orphan-dir:${name}`,
|
|
429
|
+
"gap",
|
|
430
|
+
`${localFile} maps \`${name}\`, which is not a registered name`,
|
|
431
|
+
[`${localFile}: dirs.${name}`],
|
|
432
|
+
"An orphaned mapping is a ghost entry \u2014 it looks covered and is swept by nothing.",
|
|
433
|
+
"Register the entry or delete the mapping."
|
|
434
|
+
)
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const rawManifest = await readText(manifest.manifestPath);
|
|
439
|
+
if (rawManifest === null) {
|
|
440
|
+
disclosures.push(`${manifestFile} could not be re-read for the machine-path check \u2014 skipped.`);
|
|
441
|
+
} else if (MACHINE_PATH_RE.test(rawManifest)) {
|
|
442
|
+
findings.push(
|
|
443
|
+
finding(
|
|
444
|
+
`${FLEET_LENS}/registry-machine-path:${manifestFile}`,
|
|
445
|
+
"risk",
|
|
446
|
+
`${manifestFile} contains an absolute /Users/ path`,
|
|
447
|
+
[manifestFile],
|
|
448
|
+
"The tracked manifest is publishable by construction \u2014 a home-directory path ships the machine's identity with it.",
|
|
449
|
+
`Use \`~\` (expanded consumer-side) or a root-relative path; machine paths belong in ${localFile}.`
|
|
450
|
+
)
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (manifest.machineProfile === "personal") {
|
|
454
|
+
const corpCount = manifest.entries.filter((e) => e.profile === "corp" || e.private).length;
|
|
455
|
+
if (corpCount) {
|
|
456
|
+
disclosures.push(
|
|
457
|
+
`machineProfile "personal": ${corpCount} corp entr${corpCount === 1 ? "y" : "ies"} deliberately absent on this machine \u2014 disclosed, not dangling.`
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return { findings, disclosures };
|
|
462
|
+
}
|
|
463
|
+
async function checkCorpWallArtifacts(entries, findings) {
|
|
464
|
+
for (const entry of entries) {
|
|
465
|
+
if (entry.profile !== "corp" || !entry.resolvedRoot) continue;
|
|
466
|
+
for (const artifact of CORP_WALL_ARTIFACTS) {
|
|
467
|
+
if (await pathExists(path.join(entry.resolvedRoot, artifact))) {
|
|
468
|
+
findings.push(
|
|
469
|
+
finding(
|
|
470
|
+
`${FLEET_LENS}/corp-artifact-in-repo:${entry.name}:${artifact}`,
|
|
471
|
+
"risk",
|
|
472
|
+
`corp entry \`${entry.name}\` carries ${artifact} inside its worktree`,
|
|
473
|
+
[`${entry.resolvedRoot}/${artifact}`],
|
|
474
|
+
"Agent artifacts for corp repos live outside the repo by policy \u2014 an in-repo copy is one push away from colleagues' eyes and will diverge from the manifest-side truth.",
|
|
475
|
+
`Move it to the fleet's corp/${entry.name}/ zone and leave a pointer if the harness needs one.`
|
|
476
|
+
)
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async function checkCoverage(manifest, findings, disclosures) {
|
|
483
|
+
if (!manifest.corpHosts.length) {
|
|
484
|
+
disclosures.push(
|
|
485
|
+
"Coverage check skipped \u2014 no corpHosts in the local manifest, so a corp remote cannot be recognized."
|
|
486
|
+
);
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
if (!manifest.root || !await isDirectory(manifest.root)) {
|
|
490
|
+
disclosures.push("Coverage check skipped \u2014 the fleet root is undeclared or missing.");
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const registered = /* @__PURE__ */ new Set();
|
|
494
|
+
for (const entry of manifest.entries) {
|
|
495
|
+
if (entry.resolvedRoot) registered.add(await realpathOr(entry.resolvedRoot));
|
|
496
|
+
}
|
|
497
|
+
registered.add(await realpathOr(manifest.dir));
|
|
498
|
+
const dirents = await promises.readdir(manifest.root, { withFileTypes: true });
|
|
499
|
+
for (const d of dirents) {
|
|
500
|
+
if (!d.isDirectory() || d.name.startsWith(".")) continue;
|
|
501
|
+
const dir = path.join(manifest.root, d.name);
|
|
502
|
+
if (registered.has(await realpathOr(dir))) continue;
|
|
503
|
+
if (!await pathExists(path.join(dir, ".git"))) continue;
|
|
504
|
+
const remotes = await git(dir, ["config", "--get-regexp", "^remote\\..*\\.url"]);
|
|
505
|
+
if (!remotes) continue;
|
|
506
|
+
const corpRemote = manifest.corpHosts.find((h) => remotes.includes(h));
|
|
507
|
+
if (corpRemote) {
|
|
508
|
+
findings.push(
|
|
509
|
+
finding(
|
|
510
|
+
`${FLEET_LENS}/unregistered-corp-remote:${d.name}`,
|
|
511
|
+
"risk",
|
|
512
|
+
`\`${d.name}\` under the fleet root has a corp remote but is not registered`,
|
|
513
|
+
[`${dir}: remote matches ${corpRemote}`],
|
|
514
|
+
"The wall is only as good as its census \u2014 an unregistered employer checkout is corp material no sweep, wall check, or transfer plan knows exists.",
|
|
515
|
+
"Register it as a corp entry (opaque alias + local dirs mapping) or move it out of the fleet root."
|
|
516
|
+
)
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function checkManifestRepoMachinePaths(manifest, findings, disclosures) {
|
|
522
|
+
const isRepo = await git(manifest.dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
523
|
+
if (!isRepo) {
|
|
524
|
+
disclosures.push(
|
|
525
|
+
"Machine-path check skipped \u2014 the manifest directory is not inside a git repository."
|
|
526
|
+
);
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
const files = await gitGrepFiles(manifest.dir, MACHINE_PATH_GREP, "regex");
|
|
530
|
+
if (files === null) {
|
|
531
|
+
disclosures.push(
|
|
532
|
+
"Machine-path check could not run (`git grep` failed) \u2014 undetermined, not clean."
|
|
533
|
+
);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
for (const file of files) {
|
|
537
|
+
findings.push(
|
|
538
|
+
finding(
|
|
539
|
+
`${FLEET_LENS}/machine-path:${file}`,
|
|
540
|
+
"risk",
|
|
541
|
+
`${file} (tracked in the manifest's repo) contains an absolute /Users/ path`,
|
|
542
|
+
[file],
|
|
543
|
+
"The manifest repo is the fleet's publishable surface \u2014 a tracked home-directory path ships the machine's identity in it.",
|
|
544
|
+
"Replace with `~` or a relative path; machine paths belong only in gitignored local files."
|
|
545
|
+
)
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
550
|
+
const publicEntries = manifest.entries.filter((e) => e.trust === "public-repo" && e.resolvedRoot);
|
|
551
|
+
if (!publicEntries.length) return;
|
|
552
|
+
if (!manifest.localPresent) {
|
|
553
|
+
disclosures.push(
|
|
554
|
+
"Hygiene-needle check skipped \u2014 the local manifest (needle source) is absent on this machine."
|
|
555
|
+
);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
const needles = [
|
|
559
|
+
...new Set(
|
|
560
|
+
[
|
|
561
|
+
...Object.values(manifest.labels),
|
|
562
|
+
...Object.values(manifest.localDirs).map((d) => path.basename(expandNeedleDir(d))),
|
|
563
|
+
...manifest.corpHosts
|
|
564
|
+
].filter((n) => n.length >= 3)
|
|
565
|
+
)
|
|
566
|
+
];
|
|
567
|
+
if (!needles.length) {
|
|
568
|
+
disclosures.push("Hygiene-needle check skipped \u2014 the local manifest carries no needles.");
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
for (const entry of publicEntries) {
|
|
572
|
+
const flagged = /* @__PURE__ */ new Set();
|
|
573
|
+
for (const needle of needles) {
|
|
574
|
+
const files = await gitGrepFiles(entry.resolvedRoot, needle);
|
|
575
|
+
if (files === null) {
|
|
576
|
+
disclosures.push(
|
|
577
|
+
`Hygiene-needle check could not run in \`${entry.name}\` \u2014 undetermined, not clean.`
|
|
578
|
+
);
|
|
579
|
+
break;
|
|
580
|
+
}
|
|
581
|
+
for (const file of files) {
|
|
582
|
+
if (flagged.has(file)) continue;
|
|
583
|
+
flagged.add(file);
|
|
584
|
+
findings.push(
|
|
585
|
+
finding(
|
|
586
|
+
`${FLEET_LENS}/hygiene-needle:${entry.name}:${file}`,
|
|
587
|
+
"risk",
|
|
588
|
+
`${file} in public-repo \`${entry.name}\` carries a needle from the fleet's private local manifest`,
|
|
589
|
+
[`${file}: contains "${needle}"`],
|
|
590
|
+
"A public repo that names a private label, corp directory, or corp host joins the public and employer identities for anyone who greps.",
|
|
591
|
+
"Scrub the needle from the tracked file (rewrite history if it already shipped)."
|
|
592
|
+
)
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
function expandNeedleDir(dir) {
|
|
599
|
+
return dir.replace(/\/+$/, "");
|
|
600
|
+
}
|
|
601
|
+
async function checkCorpEmails(manifest, findings, disclosures) {
|
|
602
|
+
if (!manifest.corpHosts.length) {
|
|
603
|
+
disclosures.push(
|
|
604
|
+
"Corp-email check skipped \u2014 no corpHosts in the local manifest, so a corp domain cannot be recognized."
|
|
605
|
+
);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
for (const entry of manifest.entries) {
|
|
609
|
+
if (entry.profile !== "personal" || !entry.resolvedRoot) continue;
|
|
610
|
+
const emails = await git(entry.resolvedRoot, ["log", "-30", "--format=%ae"]);
|
|
611
|
+
if (emails === null) {
|
|
612
|
+
disclosures.push(
|
|
613
|
+
`Corp-email check could not run in \`${entry.name}\` (no readable commit history) \u2014 undetermined, not clean.`
|
|
614
|
+
);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
const lines = emails.split("\n").filter(Boolean);
|
|
618
|
+
const matched = [...new Set(lines.filter((e) => emailMatchesCorpHosts(e, manifest.corpHosts)))];
|
|
619
|
+
if (matched.length) {
|
|
620
|
+
const count = lines.filter((e) => matched.includes(e)).length;
|
|
621
|
+
findings.push(
|
|
622
|
+
finding(
|
|
623
|
+
`${FLEET_LENS}/corp-email:${entry.name}`,
|
|
624
|
+
"risk",
|
|
625
|
+
`recent commits on personal entry \`${entry.name}\` are authored with a corp-host email`,
|
|
626
|
+
[`${count} of the last ${lines.length} commits: ${matched.join(", ")}`],
|
|
627
|
+
"A corp email in a personal repo's history ties the two identities together permanently \u2014 and silently, since the machine's global git identity is usually the corp one.",
|
|
628
|
+
"Set the repo-local identity to the personal one and amend the unpushed commits."
|
|
629
|
+
)
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
async function collectWallFindings(manifest) {
|
|
635
|
+
const findings = [];
|
|
636
|
+
const disclosures = [];
|
|
637
|
+
await checkCorpWallArtifacts(manifest.entries, findings);
|
|
638
|
+
await checkCoverage(manifest, findings, disclosures);
|
|
639
|
+
await checkManifestRepoMachinePaths(manifest, findings, disclosures);
|
|
640
|
+
await checkHygieneNeedles(manifest, findings, disclosures);
|
|
641
|
+
await checkCorpEmails(manifest, findings, disclosures);
|
|
642
|
+
return { findings, disclosures };
|
|
643
|
+
}
|
|
644
|
+
function stateAgeDays(facts) {
|
|
645
|
+
const freshness = facts.freshness;
|
|
646
|
+
if (!freshness?.repoLastCommit) return null;
|
|
647
|
+
const stateIds = new Set(
|
|
648
|
+
facts.artifacts.filter((a) => a.kind === "state" && a.exists).map((a) => a.id)
|
|
649
|
+
);
|
|
650
|
+
const dated = freshness.artifacts.filter((f) => stateIds.has(f.artifactId));
|
|
651
|
+
if (!dated.length) return null;
|
|
652
|
+
const repoAt = Date.parse(freshness.repoLastCommit);
|
|
653
|
+
return Math.max(
|
|
654
|
+
0,
|
|
655
|
+
...dated.map(
|
|
656
|
+
(f) => (
|
|
657
|
+
// A dirty artifact is treated fresh-now (the refresh is on disk, uncommitted) — the row
|
|
658
|
+
// must agree with the lens, which discloses instead of flagging it.
|
|
659
|
+
f.commitsSince && !f.dirty ? Math.floor((repoAt - Date.parse(f.lastCommit)) / 864e5) : 0
|
|
660
|
+
)
|
|
661
|
+
)
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
async function sweepEntry(manifest, entry, opts) {
|
|
665
|
+
const project = {
|
|
666
|
+
name: entry.name,
|
|
667
|
+
profile: entry.profile,
|
|
668
|
+
kind: entry.kind,
|
|
669
|
+
resolvedRoot: entry.resolvedRoot,
|
|
670
|
+
unresolved: entry.unresolved,
|
|
671
|
+
staleAfterDays: entry.staleAfterDays ?? DEFAULT_CONFIG.state.staleAfterDays,
|
|
672
|
+
stateAgeDays: null,
|
|
673
|
+
counts: { risk: 0, gap: 0, polish: 0 },
|
|
674
|
+
findings: [],
|
|
675
|
+
disclosures: []
|
|
676
|
+
};
|
|
677
|
+
const root = entry.resolvedRoot;
|
|
678
|
+
if (!root) {
|
|
679
|
+
project.disclosures.push(`not audited: ${entry.unresolved ?? "unresolved"}`);
|
|
680
|
+
return project;
|
|
681
|
+
}
|
|
682
|
+
if (!await isDirectory(root)) {
|
|
683
|
+
project.unresolved = `resolved directory does not exist: ${root}`;
|
|
684
|
+
project.disclosures.push(`not audited: ${project.unresolved} \u2014 \`etymd fleet check\` names it`);
|
|
685
|
+
return project;
|
|
686
|
+
}
|
|
687
|
+
let upstreamRemote;
|
|
688
|
+
if (entry.upstream) {
|
|
689
|
+
const url = await git(root, ["remote", "get-url", entry.upstream]);
|
|
690
|
+
const refs = url !== null ? await git(root, ["for-each-ref", "--count=1", `refs/remotes/${entry.upstream}`]) : null;
|
|
691
|
+
if (url !== null && refs) {
|
|
692
|
+
upstreamRemote = entry.upstream;
|
|
693
|
+
project.disclosures.push(
|
|
694
|
+
`freshness measured on fork-authored commits only (\`--not --remotes=${entry.upstream}\`)`
|
|
695
|
+
);
|
|
696
|
+
} else if (url !== null) {
|
|
697
|
+
project.disclosures.push(
|
|
698
|
+
`upstream remote \`${entry.upstream}\` has no fetched refs \u2014 freshness measured on all commits`
|
|
699
|
+
);
|
|
700
|
+
} else {
|
|
701
|
+
project.disclosures.push(
|
|
702
|
+
`upstream remote \`${entry.upstream}\` not found \u2014 freshness measured on all commits`
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
const corp = entry.profile === "corp";
|
|
707
|
+
const optedIn = !corp && await pathExists(path.join(root, ETYMD_DIR));
|
|
708
|
+
const result = await runAudit(root, {
|
|
709
|
+
kind: opts.kind,
|
|
710
|
+
persistLedger: !corp && opts.persistLedgers === true && optedIn,
|
|
711
|
+
readOnlyRoot: corp,
|
|
712
|
+
ledgerRoot: corp ? corpPersistenceRoot(manifest, entry.name) : void 0,
|
|
713
|
+
stateBudgets: stateBudgetsFor(entry),
|
|
714
|
+
upstreamRemote
|
|
715
|
+
});
|
|
716
|
+
project.findings = result.findings;
|
|
717
|
+
if (entry.contract.placement === "none") {
|
|
718
|
+
project.findings = project.findings.filter((f) => !f.id.endsWith("/no-contract"));
|
|
719
|
+
}
|
|
720
|
+
for (const f of project.findings) project.counts[f.tier] += 1;
|
|
721
|
+
project.stateAgeDays = stateAgeDays(result.facts);
|
|
722
|
+
project.staleAfterDays = result.config.config.state.staleAfterDays;
|
|
723
|
+
for (const [key, value] of Object.entries(entry.contract)) {
|
|
724
|
+
if (key === "placement" || !value) continue;
|
|
725
|
+
const exists = await pathExists(path.join(root, value.replace(/#.*$/, "")));
|
|
726
|
+
project.disclosures.push(
|
|
727
|
+
`contract override \`${key}: ${value}\` \u2014 registered, existence-checked only (${exists ? "present" : "MISSING"})`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
if (entry.contract.placement === "none") {
|
|
731
|
+
project.disclosures.push("contract placement `none` \u2014 legitimately absent, by declaration");
|
|
732
|
+
}
|
|
733
|
+
return project;
|
|
734
|
+
}
|
|
735
|
+
async function sweepFleet(manifest, opts = {}) {
|
|
736
|
+
let entries = manifest.entries;
|
|
737
|
+
if (opts.only?.length) {
|
|
738
|
+
const known = new Set(entries.map((e) => e.name));
|
|
739
|
+
const unknown = opts.only.filter((n) => !known.has(n));
|
|
740
|
+
if (unknown.length) {
|
|
741
|
+
throw new Error(
|
|
742
|
+
`--only names not in the manifest: ${unknown.join(", ")} \u2014 run \`etymd fleet check\``
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
const wanted = new Set(opts.only);
|
|
746
|
+
entries = entries.filter((e) => wanted.has(e.name));
|
|
747
|
+
}
|
|
748
|
+
if (opts.profile) entries = entries.filter((e) => e.profile === opts.profile);
|
|
749
|
+
const projects = [];
|
|
750
|
+
for (const entry of entries) {
|
|
751
|
+
projects.push(await sweepEntry(manifest, entry, opts));
|
|
752
|
+
}
|
|
753
|
+
const wall = await collectWallFindings(manifest);
|
|
754
|
+
return {
|
|
755
|
+
schema: FLEET_JSON_SCHEMA,
|
|
756
|
+
manifest: manifest.manifestPath,
|
|
757
|
+
sweptAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
758
|
+
projects,
|
|
759
|
+
wall: wall.findings,
|
|
760
|
+
wallDisclosures: wall.disclosures,
|
|
761
|
+
outOfScope: projects.filter((p) => !p.resolvedRoot || p.unresolved).map((p) => p.name),
|
|
762
|
+
problems: manifest.problems.map((p) => `${p.file}: ${p.detail}`),
|
|
763
|
+
recurringClasses: recurringClasses(projects)
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
async function fleetLedgerTarget(manifest, name) {
|
|
767
|
+
const entry = manifest.entries.find((e) => e.name === name);
|
|
768
|
+
if (!entry) {
|
|
769
|
+
throw new Error(
|
|
770
|
+
`no entry \`${name}\` in ${path.basename(manifest.manifestPath)} \u2014 registered names: ${manifest.entries.map((e) => e.name).join(", ")}`
|
|
771
|
+
);
|
|
772
|
+
}
|
|
773
|
+
if (!entry.resolvedRoot) {
|
|
774
|
+
throw new Error(`\`${name}\` does not resolve on this machine: ${entry.unresolved}`);
|
|
775
|
+
}
|
|
776
|
+
const corp = entry.profile === "corp";
|
|
777
|
+
return {
|
|
778
|
+
entry,
|
|
779
|
+
auditRoot: entry.resolvedRoot,
|
|
780
|
+
ledgerRoot: corp ? corpPersistenceRoot(manifest, name) : entry.resolvedRoot,
|
|
781
|
+
corp
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
async function ensureFindingRecorded(manifest, name, id) {
|
|
785
|
+
const target = await fleetLedgerTarget(manifest, name);
|
|
786
|
+
const has = async () => (await readLedger(target.ledgerRoot)).entries.some((e) => e.id === id);
|
|
787
|
+
if (await has()) return { ledgerRoot: target.ledgerRoot, recorded: true };
|
|
788
|
+
await runAudit(target.auditRoot, {
|
|
789
|
+
persistLedger: true,
|
|
790
|
+
readOnlyRoot: target.corp,
|
|
791
|
+
ledgerRoot: target.corp ? target.ledgerRoot : void 0,
|
|
792
|
+
stateBudgets: stateBudgetsFor(target.entry)
|
|
793
|
+
});
|
|
794
|
+
return { ledgerRoot: target.ledgerRoot, recorded: await has() };
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// src/commands/fleet.ts
|
|
798
|
+
var LAST_SWEEP_FILE = "last.fleet.json";
|
|
799
|
+
async function loadManifest(cwd, manifestOpt) {
|
|
800
|
+
if (manifestOpt) {
|
|
801
|
+
return { manifest: await loadFleetManifest(path.resolve(cwd, manifestOpt)) };
|
|
802
|
+
}
|
|
803
|
+
const candidate = path.join(cwd, "registry.json");
|
|
804
|
+
if (await pathExists(candidate)) {
|
|
805
|
+
return {
|
|
806
|
+
manifest: await loadFleetManifest(candidate),
|
|
807
|
+
autoNote: `manifest auto-selected from the cwd: ${candidate} (no --manifest given)`
|
|
808
|
+
};
|
|
809
|
+
}
|
|
810
|
+
throw new Error(
|
|
811
|
+
"no --manifest given and no registry.json in the current directory \u2014 pass --manifest <file> (there is deliberately no env var and no global pointer)"
|
|
812
|
+
);
|
|
813
|
+
}
|
|
814
|
+
async function readLastSweep(lastPath) {
|
|
815
|
+
const raw = await readText(lastPath);
|
|
816
|
+
if (raw === null) return { record: null };
|
|
817
|
+
let parsed;
|
|
818
|
+
try {
|
|
819
|
+
parsed = JSON.parse(raw);
|
|
820
|
+
} catch {
|
|
821
|
+
return {
|
|
822
|
+
record: null,
|
|
823
|
+
note: `${LAST_SWEEP_FILE} exists but is not valid JSON \u2014 delta baseline reset (every finding reads as new this sweep).`
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const rec = parsed;
|
|
827
|
+
if (typeof rec !== "object" || rec === null || rec.schema !== FLEET_JSON_SCHEMA || typeof rec.projects !== "object" || rec.projects === null || Array.isArray(rec.projects) || !Array.isArray(rec.wall)) {
|
|
828
|
+
return {
|
|
829
|
+
record: null,
|
|
830
|
+
note: `${LAST_SWEEP_FILE} carries a foreign schema \u2014 delta baseline reset (every finding reads as new this sweep).`
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
return { record: rec };
|
|
834
|
+
}
|
|
835
|
+
function failOnExit(failOn, findings) {
|
|
836
|
+
if (failOn === void 0) return;
|
|
837
|
+
if (meetsFailOn(findings, parseFailOnTier(failOn))) process.exitCode = 1;
|
|
838
|
+
}
|
|
839
|
+
async function sweep(opts) {
|
|
840
|
+
if (opts.profile && opts.profile !== "personal" && opts.profile !== "corp") {
|
|
841
|
+
throw new Error(`--profile must be personal or corp, got \`${opts.profile}\``);
|
|
842
|
+
}
|
|
843
|
+
if (opts.failOn !== void 0) parseFailOnTier(opts.failOn);
|
|
844
|
+
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
845
|
+
const result = await sweepFleet(manifest, {
|
|
846
|
+
only: opts.only,
|
|
847
|
+
profile: opts.profile,
|
|
848
|
+
kind: opts.truth ? "truth" : void 0,
|
|
849
|
+
persistLedgers: opts.persistLedgers
|
|
850
|
+
});
|
|
851
|
+
const lastPath = path.join(manifest.dir, LAST_SWEEP_FILE);
|
|
852
|
+
const { record: previous, note: baselineNote } = await readLastSweep(lastPath);
|
|
853
|
+
const previousIds = (name) => new Set((previous?.projects[name] ?? []).map((e) => e.id));
|
|
854
|
+
const previousWallIds = new Set((previous?.wall ?? []).map((e) => e.id));
|
|
855
|
+
const allFindings = [...result.projects.flatMap((p) => p.findings), ...result.wall];
|
|
856
|
+
failOnExit(opts.failOn, allFindings);
|
|
857
|
+
const fullSweep = !opts.only && !opts.profile && !opts.truth;
|
|
858
|
+
const delta = {};
|
|
859
|
+
for (const p of result.projects) {
|
|
860
|
+
const prev = previousIds(p.name);
|
|
861
|
+
const current = new Set(p.findings.map((f) => f.id));
|
|
862
|
+
delta[p.name] = {
|
|
863
|
+
new: [...current].filter((id) => !prev.has(id)),
|
|
864
|
+
resolved: [...prev].filter((id) => !current.has(id))
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
const wallNew = result.wall.filter((f) => !previousWallIds.has(f.id)).map((f) => f.id);
|
|
868
|
+
if (opts.json) {
|
|
869
|
+
print(
|
|
870
|
+
JSON.stringify(
|
|
871
|
+
{
|
|
872
|
+
...result,
|
|
873
|
+
delta: previous ? delta : null,
|
|
874
|
+
...baselineNote ? { deltaBaselineNote: baselineNote } : {}
|
|
875
|
+
},
|
|
876
|
+
null,
|
|
877
|
+
2
|
|
878
|
+
)
|
|
879
|
+
);
|
|
880
|
+
} else {
|
|
881
|
+
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
882
|
+
if (baselineNote) print(` ${theme.dim(`\u25E6 ${baselineNote}`)}`);
|
|
883
|
+
renderSweep(result, previous, delta, wallNew);
|
|
884
|
+
}
|
|
885
|
+
if (fullSweep) {
|
|
886
|
+
const record = {
|
|
887
|
+
schema: FLEET_JSON_SCHEMA,
|
|
888
|
+
sweptAt: result.sweptAt,
|
|
889
|
+
projects: Object.fromEntries(
|
|
890
|
+
result.projects.map((p) => [p.name, p.findings.map((f) => ({ id: f.id, tier: f.tier }))])
|
|
891
|
+
),
|
|
892
|
+
wall: result.wall.map((f) => ({ id: f.id, tier: f.tier }))
|
|
893
|
+
};
|
|
894
|
+
const tmpPath = `${lastPath}.tmp`;
|
|
895
|
+
await promises.writeFile(tmpPath, JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
896
|
+
await promises.rename(tmpPath, lastPath);
|
|
897
|
+
} else if (!opts.json) {
|
|
898
|
+
print();
|
|
899
|
+
print(` ${theme.dim("partial sweep \u2014 the delta baseline was not updated")}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
function renderSweep(result, previous, delta, wallNew) {
|
|
903
|
+
section(
|
|
904
|
+
`Fleet sweep ${theme.dim(`\xB7 ${path.basename(result.manifest)} \xB7 ${result.projects.length} project(s) \xB7 schema ${result.schema}`)}`
|
|
905
|
+
);
|
|
906
|
+
const rows = result.projects.map((p) => {
|
|
907
|
+
const d = delta[p.name] ?? { new: [], resolved: [] };
|
|
908
|
+
const deltaStr = !previous ? "first sweep" : d.new.length || d.resolved.length ? `\u0394 ${[d.new.length ? `+${d.new.length}` : "", d.resolved.length ? `\u2212${d.resolved.length}` : ""].filter(Boolean).join(" ")}` : "\u0394 \xB10";
|
|
909
|
+
return {
|
|
910
|
+
name: p.name,
|
|
911
|
+
age: p.stateAgeDays === null ? "\u2014" : `${p.stateAgeDays}/${p.staleAfterDays}d`,
|
|
912
|
+
counts: p.counts,
|
|
913
|
+
delta: deltaStr,
|
|
914
|
+
note: p.unresolved
|
|
915
|
+
};
|
|
916
|
+
});
|
|
917
|
+
renderFleetRows(rows);
|
|
918
|
+
renderFleetNotes(
|
|
919
|
+
result.problems,
|
|
920
|
+
result.projects.flatMap((p) => p.disclosures.map((d) => `${p.name}: ${d}`))
|
|
921
|
+
);
|
|
922
|
+
const detail = [
|
|
923
|
+
...result.projects.flatMap(
|
|
924
|
+
(p) => p.findings.filter((f) => f.tier === "risk" || (delta[p.name]?.new ?? []).includes(f.id))
|
|
925
|
+
),
|
|
926
|
+
...result.wall.filter((f) => f.tier === "risk" || wallNew.includes(f.id))
|
|
927
|
+
];
|
|
928
|
+
if (detail.length) {
|
|
929
|
+
section(`New or risk findings ${theme.dim(`(${detail.length})`)}`);
|
|
930
|
+
renderFindings(detail);
|
|
931
|
+
}
|
|
932
|
+
if (result.recurringClasses.length) {
|
|
933
|
+
section(`Recurring classes ${theme.dim("(open in \u22652 projects \u2014 class-fix candidates)")}`);
|
|
934
|
+
for (const rc of result.recurringClasses) {
|
|
935
|
+
print(
|
|
936
|
+
` ${TIER_BADGE[rc.tier]} ${rc.classId} ${theme.dim(`\u2014 ${rc.projects.length}\xD7: ${rc.projects.join(", ")}`)}`
|
|
937
|
+
);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
section("Fleet wall");
|
|
941
|
+
if (!result.wall.length) print(` ${glyph.ok} ${theme.dim("clean")}`);
|
|
942
|
+
else
|
|
943
|
+
print(
|
|
944
|
+
` ${theme.warn(`${result.wall.length} finding(s)`)} ${theme.dim("(detailed above if new or risk)")}`
|
|
945
|
+
);
|
|
946
|
+
renderFleetNotes([], result.wallDisclosures);
|
|
947
|
+
if (result.outOfScope.length) {
|
|
948
|
+
print(
|
|
949
|
+
` ${theme.dim(`out of scope (not audited, named above): ${result.outOfScope.join(", ")}`)}`
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
async function check(opts) {
|
|
954
|
+
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
955
|
+
const { findings, disclosures } = await checkManifest(manifest);
|
|
956
|
+
if (findings.length) process.exitCode = 1;
|
|
957
|
+
if (opts.json) {
|
|
958
|
+
print(
|
|
959
|
+
JSON.stringify(
|
|
960
|
+
{ schema: FLEET_JSON_SCHEMA, manifest: manifest.manifestPath, findings, disclosures },
|
|
961
|
+
null,
|
|
962
|
+
2
|
|
963
|
+
)
|
|
964
|
+
);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
968
|
+
section(
|
|
969
|
+
`Fleet check ${theme.dim(`\xB7 ${path.basename(manifest.manifestPath)} \xB7 ${manifest.entries.length} entr${manifest.entries.length === 1 ? "y" : "ies"} \xB7 manifest truth only, no lenses`)}`
|
|
970
|
+
);
|
|
971
|
+
renderFindings(findings);
|
|
972
|
+
renderFleetNotes([], disclosures);
|
|
973
|
+
}
|
|
974
|
+
async function resolveFleetFinding(opts, status) {
|
|
975
|
+
if (status === "dismissed" && !opts.reason?.trim()) {
|
|
976
|
+
throw new Error('dismiss needs a reason: etymd fleet dismiss <name> <id> --reason "\u2026"');
|
|
977
|
+
}
|
|
978
|
+
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
979
|
+
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
980
|
+
const target = await fleetLedgerTarget(manifest, opts.name);
|
|
981
|
+
const hadLedger = await pathExists(path.join(target.ledgerRoot, ".etymd"));
|
|
982
|
+
const { ledgerRoot, recorded } = await ensureFindingRecorded(manifest, opts.name, opts.id);
|
|
983
|
+
if (!recorded) {
|
|
984
|
+
throw new Error(
|
|
985
|
+
`no finding \`${opts.id}\` recorded for \`${opts.name}\` even after a fresh audit \u2014 check the id against \`etymd fleet\` output (fleet-manifest wall findings are not ledger-quietable)`
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
if (!hadLedger) {
|
|
989
|
+
print(
|
|
990
|
+
` ${theme.dim(`first ledger for \`${opts.name}\`: created ${path.join(ledgerRoot, ".etymd")} (a persisting audit ran to record the finding)`)}`
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
const args = { cwd: ledgerRoot, id: opts.id, reason: opts.reason };
|
|
994
|
+
await (status === "dismissed" ? dismiss(args) : accept(args));
|
|
995
|
+
print(` ${theme.dim(`ledger: ${path.join(ledgerRoot, ".etymd", "ledger.json")}`)}`);
|
|
996
|
+
}
|
|
997
|
+
async function dismiss2(opts) {
|
|
998
|
+
await resolveFleetFinding(opts, "dismissed");
|
|
999
|
+
}
|
|
1000
|
+
async function accept2(opts) {
|
|
1001
|
+
await resolveFleetFinding(opts, "accepted");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
export { accept2 as accept, check, dismiss2 as dismiss, sweep };
|