etymd 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -16
- package/README.md +114 -47
- package/dist/{approve-QDOH2E7V.js → approve-K5FFTPA4.js} +2 -2
- package/dist/{audit-42OHF7ON.js → audit-HBNXNGD6.js} +5 -4
- package/dist/{brief-ZUQK3CFL.js → brief-DOQ2DQEG.js} +2 -2
- package/dist/{fleet-F3XDEDCV.js → chunk-ANXGT7V3.js} +431 -466
- package/dist/{chunk-HWC6PKJM.js → chunk-EJM47PIF.js} +1 -1
- package/dist/{chunk-PAMWS665.js → chunk-FKDT4RBP.js} +2 -1
- package/dist/{chunk-UFNETE6P.js → chunk-OKJ3RGJB.js} +1 -1
- package/dist/{chunk-LT7JMVV3.js → chunk-UV3YIBDW.js} +43 -730
- package/dist/{chunk-623W6KVC.js → chunk-UYCGP5BS.js} +1 -1
- package/dist/chunk-Y4E6ERCM.js +783 -0
- package/dist/cli.js +78 -22
- package/dist/{doctor-CE5SD4JW.js → doctor-X6XAGKV7.js} +5 -4
- package/dist/fleet-UEFLKIRI.js +489 -0
- package/dist/{gates-F4YPKWJR.js → gates-NVWWKMT2.js} +3 -3
- package/dist/{generate-KEX75XNG.js → generate-YEARCSHW.js} +1 -1
- package/dist/index.d.ts +301 -20
- package/dist/index.js +958 -233
- package/dist/{init-AHA7VRRI.js → init-Z5O2WVRJ.js} +3 -3
- package/dist/premise-4QRU7LRI.js +368 -0
- package/dist/propose-IKE4CUIM.js +252 -0
- package/dist/scan-64Z6SNYF.js +5 -0
- package/dist/{scan-TVX3FKK4.js → scan-H3NF5IV5.js} +2 -2
- package/package.json +5 -5
- package/dist/scan-QRTH6HAP.js +0 -5
|
@@ -1,18 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { parseFailOnTier, meetsFailOn, runAudit } from './chunk-LT7JMVV3.js';
|
|
2
|
+
import { runAudit } from './chunk-UV3YIBDW.js';
|
|
4
3
|
import { readLedger } from './chunk-3E2IPCRY.js';
|
|
5
|
-
import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-HI7NWPRA.js';
|
|
6
|
-
import { scanProject } from './chunk-HWC6PKJM.js';
|
|
7
|
-
import './chunk-623W6KVC.js';
|
|
8
4
|
import { DEFAULT_CONFIG, ETYMD_DIR } from './chunk-P6ATKV2R.js';
|
|
9
|
-
import './chunk-
|
|
10
|
-
import { readText, pathExists, git, isDirectory } from './chunk-4VPBP6K6.js';
|
|
11
|
-
import { promises } from 'fs';
|
|
5
|
+
import { readText, isDirectory, git, pathExists } from './chunk-4VPBP6K6.js';
|
|
12
6
|
import os from 'os';
|
|
13
7
|
import path from 'path';
|
|
14
|
-
import { select, isCancel, cancel, text, confirm } from '@clack/prompts';
|
|
15
8
|
import { execFile } from 'child_process';
|
|
9
|
+
import { promises } from 'fs';
|
|
16
10
|
import { promisify } from 'util';
|
|
17
11
|
|
|
18
12
|
var FLEET_TRUST_VALUES = ["public-repo", "public-bound", "private"];
|
|
@@ -24,14 +18,14 @@ function expandTilde(p) {
|
|
|
24
18
|
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
25
19
|
return p;
|
|
26
20
|
}
|
|
27
|
-
var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"
|
|
21
|
+
var REBUILD_RECIPE = 'rebuild it beside the manifest: {"machineProfile":"guarded","dirs":{"<name>":"~/path/to/worktree"}}';
|
|
28
22
|
var SAFE_NAME = /^[^\s/\\:.][^\s/\\:]*$/;
|
|
29
23
|
function rejectUnsafeName(name, where, manifest, manifestFile) {
|
|
30
24
|
if (SAFE_NAME.test(name)) return false;
|
|
31
25
|
manifest.problems.push({
|
|
32
26
|
kind: "bad-shape",
|
|
33
27
|
file: manifestFile,
|
|
34
|
-
detail: `${where} name ${JSON.stringify(name)} is not a single path-safe segment \u2014 entry skipped (names build finding ids and
|
|
28
|
+
detail: `${where} name ${JSON.stringify(name)} is not a single path-safe segment \u2014 entry skipped (names build finding ids and guarded/<name>/ persistence paths)`
|
|
35
29
|
});
|
|
36
30
|
return true;
|
|
37
31
|
}
|
|
@@ -72,7 +66,7 @@ async function readLocal(localPath, problems) {
|
|
|
72
66
|
}
|
|
73
67
|
}
|
|
74
68
|
function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
75
|
-
const
|
|
69
|
+
const guardedNames = [];
|
|
76
70
|
for (const [i, rawEntry] of projects.entries()) {
|
|
77
71
|
const rec = asRecord(rawEntry);
|
|
78
72
|
const name = rec ? asString(rec.name) : void 0;
|
|
@@ -85,8 +79,8 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
85
79
|
continue;
|
|
86
80
|
}
|
|
87
81
|
if (rejectUnsafeName(name, `projects[${i}]`, manifest, manifestFile)) continue;
|
|
88
|
-
const profile = rec.profile === "
|
|
89
|
-
if (rec.profile !== void 0 && rec.profile !== "
|
|
82
|
+
const profile = rec.profile === "guarded" ? "guarded" : "personal";
|
|
83
|
+
if (rec.profile !== void 0 && rec.profile !== "guarded" && rec.profile !== "personal") {
|
|
90
84
|
manifest.problems.push({
|
|
91
85
|
kind: "bad-shape",
|
|
92
86
|
file: manifestFile,
|
|
@@ -124,12 +118,12 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
124
118
|
contract,
|
|
125
119
|
links: asStringMap(rec.links)
|
|
126
120
|
};
|
|
127
|
-
if (entry.profile === "
|
|
128
|
-
|
|
121
|
+
if (entry.profile === "guarded" || entry.private) {
|
|
122
|
+
guardedNames.push(name);
|
|
129
123
|
if (!manifest.localPresent) {
|
|
130
124
|
entry.unresolved = `registry.local.json is absent \u2014 ${REBUILD_RECIPE}`;
|
|
131
125
|
} else if (manifest.machineProfile === "personal") {
|
|
132
|
-
entry.unresolved = '
|
|
126
|
+
entry.unresolved = 'guarded entries do not resolve under machineProfile "personal" \u2014 absent on this machine by design';
|
|
133
127
|
} else {
|
|
134
128
|
const dir = manifest.localDirs[name];
|
|
135
129
|
if (!dir) {
|
|
@@ -145,11 +139,11 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
145
139
|
}
|
|
146
140
|
manifest.entries.push(entry);
|
|
147
141
|
}
|
|
148
|
-
if (
|
|
142
|
+
if (guardedNames.length && !manifest.localPresent) {
|
|
149
143
|
manifest.problems.push({
|
|
150
144
|
kind: "local-missing",
|
|
151
145
|
file: path.basename(manifest.localPath),
|
|
152
|
-
detail: `${
|
|
146
|
+
detail: `${guardedNames.length} guarded/private entr${guardedNames.length === 1 ? "y" : "ies"} (${guardedNames.join(", ")}) cannot resolve without it \u2014 ${REBUILD_RECIPE}`
|
|
153
147
|
});
|
|
154
148
|
}
|
|
155
149
|
}
|
|
@@ -198,7 +192,7 @@ async function loadFleetManifest(manifestPath) {
|
|
|
198
192
|
shape: "registry",
|
|
199
193
|
manifestPath: abs,
|
|
200
194
|
dir,
|
|
201
|
-
|
|
195
|
+
guardedHosts: [],
|
|
202
196
|
labels: {},
|
|
203
197
|
localDirs: {},
|
|
204
198
|
localPath: path.join(dir, "registry.local.json"),
|
|
@@ -231,8 +225,8 @@ async function loadFleetManifest(manifestPath) {
|
|
|
231
225
|
manifest.localPresent = local.present;
|
|
232
226
|
manifest.localDirs = asStringMap(local.data.dirs);
|
|
233
227
|
manifest.labels = asStringMap(local.data.labels);
|
|
234
|
-
manifest.
|
|
235
|
-
manifest.machineProfile = local.data.machineProfile === "
|
|
228
|
+
manifest.guardedHosts = Array.isArray(local.data.guardedHosts) ? local.data.guardedHosts.filter((h) => typeof h === "string") : [];
|
|
229
|
+
manifest.machineProfile = local.data.machineProfile === "guarded" || local.data.machineProfile === "personal" ? local.data.machineProfile : void 0;
|
|
236
230
|
const rootRaw = asString(local.data.root) ?? asString(parsed.root);
|
|
237
231
|
if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
|
|
238
232
|
const orientationRec = asRecord(parsed.orientation);
|
|
@@ -270,10 +264,334 @@ async function loadFleetManifest(manifestPath) {
|
|
|
270
264
|
}
|
|
271
265
|
return manifest;
|
|
272
266
|
}
|
|
267
|
+
|
|
268
|
+
// src/engine/milestones.ts
|
|
269
|
+
var MILESTONES_FILE = "MILESTONES.md";
|
|
270
|
+
var BOARD_JSON_SCHEMA = "fleet-board-experimental-0.1";
|
|
271
|
+
var MILESTONE_COLUMNS = [
|
|
272
|
+
"id",
|
|
273
|
+
"milestone",
|
|
274
|
+
"goal",
|
|
275
|
+
"status",
|
|
276
|
+
"next",
|
|
277
|
+
"effort",
|
|
278
|
+
"depends-on"
|
|
279
|
+
];
|
|
280
|
+
var INITIATIVE_COLUMNS = [
|
|
281
|
+
"rank",
|
|
282
|
+
"id",
|
|
283
|
+
"initiative",
|
|
284
|
+
"goal",
|
|
285
|
+
"status",
|
|
286
|
+
"next",
|
|
287
|
+
"effort",
|
|
288
|
+
"projects",
|
|
289
|
+
"depends-on"
|
|
290
|
+
];
|
|
291
|
+
var MILESTONE_GOALS = ["1", "2", "3"];
|
|
292
|
+
var MILESTONE_STATUSES = ["planned", "active", "blocked", "done"];
|
|
293
|
+
var MILESTONE_EFFORTS = ["S", "M", "L"];
|
|
294
|
+
var NONE_MARKERS = /* @__PURE__ */ new Set(["", "\u2014", "-", "\u2013", "none"]);
|
|
295
|
+
function splitRow(line) {
|
|
296
|
+
const trimmed = line.trim().replace(/^\|/, "").replace(/\|$/, "");
|
|
297
|
+
return trimmed.split("|").map((c) => c.trim());
|
|
298
|
+
}
|
|
299
|
+
function isSeparatorRow(line) {
|
|
300
|
+
const cells = splitRow(line);
|
|
301
|
+
return cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(c));
|
|
302
|
+
}
|
|
303
|
+
function firstTable(lines, startLine) {
|
|
304
|
+
for (let i = startLine; i < lines.length - 1; i++) {
|
|
305
|
+
const line = lines[i] ?? "";
|
|
306
|
+
const next = lines[i + 1] ?? "";
|
|
307
|
+
if (!line.trim().startsWith("|") || !isSeparatorRow(next)) continue;
|
|
308
|
+
const header = splitRow(line).map((h) => h.toLowerCase());
|
|
309
|
+
const rows = [];
|
|
310
|
+
for (let j = i + 2; j < lines.length; j++) {
|
|
311
|
+
const body = lines[j] ?? "";
|
|
312
|
+
if (!body.trim().startsWith("|")) break;
|
|
313
|
+
rows.push({ line: j + 1, cells: splitRow(body) });
|
|
314
|
+
}
|
|
315
|
+
return { header, rows, headerLine: i + 1 };
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
function parseList(cell2) {
|
|
320
|
+
if (NONE_MARKERS.has(cell2.trim().toLowerCase())) return [];
|
|
321
|
+
return cell2.split(",").map((s) => s.trim()).filter(Boolean);
|
|
322
|
+
}
|
|
323
|
+
function checkColumns(table, expected, problems) {
|
|
324
|
+
const got = table.header.join(" | ");
|
|
325
|
+
const want = expected.join(" | ");
|
|
326
|
+
if (got !== want) {
|
|
327
|
+
problems.push(
|
|
328
|
+
`line ${table.headerLine}: table columns are \`${got}\`; the standard is \`${want}\``
|
|
329
|
+
);
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
function checkVocabulary(line, field, value, allowed, problems) {
|
|
335
|
+
if (allowed.includes(value)) return true;
|
|
336
|
+
problems.push(
|
|
337
|
+
`line ${line}: ${field} is \`${value}\`; allowed: ${allowed.map((a) => `\`${a}\``).join(", ")}`
|
|
338
|
+
);
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
function parseMilestones(text) {
|
|
342
|
+
const problems = [];
|
|
343
|
+
const lines = text.split(/\r?\n/);
|
|
344
|
+
const headingIdx = lines.findIndex((l) => /^#\s/.test(l));
|
|
345
|
+
if (headingIdx === -1 || lines[headingIdx]?.trim() !== "# Milestones") {
|
|
346
|
+
problems.push(
|
|
347
|
+
headingIdx === -1 ? "no heading: the file must start with `# Milestones`" : `line ${headingIdx + 1}: first heading is \`${lines[headingIdx]?.trim()}\`; it must be \`# Milestones\``
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
const table = firstTable(lines, headingIdx === -1 ? 0 : headingIdx + 1);
|
|
351
|
+
if (!table) {
|
|
352
|
+
problems.push(
|
|
353
|
+
`no milestones table: expected \`| ${MILESTONE_COLUMNS.join(" | ")} |\` after the heading`
|
|
354
|
+
);
|
|
355
|
+
return { rows: [], problems };
|
|
356
|
+
}
|
|
357
|
+
if (!checkColumns(table, MILESTONE_COLUMNS, problems)) return { rows: [], problems };
|
|
358
|
+
const rows = [];
|
|
359
|
+
const ids = /* @__PURE__ */ new Set();
|
|
360
|
+
for (const { line, cells } of table.rows) {
|
|
361
|
+
if (cells.length !== MILESTONE_COLUMNS.length) {
|
|
362
|
+
problems.push(`line ${line}: ${cells.length} cells, expected ${MILESTONE_COLUMNS.length}`);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const [id, milestone, goal, status, next, effort, deps] = cells;
|
|
366
|
+
let ok = true;
|
|
367
|
+
if (!/^M\d+$/.test(id)) {
|
|
368
|
+
problems.push(`line ${line}: id \`${id}\` is not \`M<number>\``);
|
|
369
|
+
ok = false;
|
|
370
|
+
} else if (ids.has(id)) {
|
|
371
|
+
problems.push(`line ${line}: id \`${id}\` is used twice`);
|
|
372
|
+
ok = false;
|
|
373
|
+
}
|
|
374
|
+
if (!milestone) {
|
|
375
|
+
problems.push(`line ${line}: milestone title is empty`);
|
|
376
|
+
ok = false;
|
|
377
|
+
}
|
|
378
|
+
if (!next) {
|
|
379
|
+
problems.push(`line ${line}: next step is empty \u2014 a milestone without a next step is a wish`);
|
|
380
|
+
ok = false;
|
|
381
|
+
}
|
|
382
|
+
ok = checkVocabulary(line, "goal", goal, MILESTONE_GOALS, problems) && ok;
|
|
383
|
+
ok = checkVocabulary(line, "status", status, MILESTONE_STATUSES, problems) && ok;
|
|
384
|
+
ok = checkVocabulary(line, "effort", effort, MILESTONE_EFFORTS, problems) && ok;
|
|
385
|
+
if (!ok) continue;
|
|
386
|
+
ids.add(id);
|
|
387
|
+
rows.push({
|
|
388
|
+
id,
|
|
389
|
+
milestone,
|
|
390
|
+
goal,
|
|
391
|
+
status,
|
|
392
|
+
next,
|
|
393
|
+
effort,
|
|
394
|
+
dependsOn: parseList(deps)
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
for (const r of rows) {
|
|
398
|
+
for (const d of r.dependsOn) {
|
|
399
|
+
if (!ids.has(d)) problems.push(`${r.id} depends on \`${d}\`, which is not in the table`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return { rows, problems };
|
|
403
|
+
}
|
|
404
|
+
function parseInitiatives(text) {
|
|
405
|
+
const problems = [];
|
|
406
|
+
const lines = text.split(/\r?\n/);
|
|
407
|
+
const table = firstTable(lines, 0);
|
|
408
|
+
if (!table) {
|
|
409
|
+
problems.push(`no initiatives table: expected \`| ${INITIATIVE_COLUMNS.join(" | ")} |\``);
|
|
410
|
+
return { rows: [], problems };
|
|
411
|
+
}
|
|
412
|
+
if (!checkColumns(table, INITIATIVE_COLUMNS, problems)) return { rows: [], problems };
|
|
413
|
+
const rows = [];
|
|
414
|
+
const ids = /* @__PURE__ */ new Set();
|
|
415
|
+
const ranks = /* @__PURE__ */ new Set();
|
|
416
|
+
for (const { line, cells } of table.rows) {
|
|
417
|
+
if (cells.length !== INITIATIVE_COLUMNS.length) {
|
|
418
|
+
problems.push(`line ${line}: ${cells.length} cells, expected ${INITIATIVE_COLUMNS.length}`);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
const [rankRaw, id, initiative, goal, status, next, effort, projects, deps] = cells;
|
|
422
|
+
let ok = true;
|
|
423
|
+
const rank = Number(rankRaw);
|
|
424
|
+
if (!/^\d+$/.test(rankRaw) || rank < 1) {
|
|
425
|
+
problems.push(`line ${line}: rank \`${rankRaw}\` is not a positive integer`);
|
|
426
|
+
ok = false;
|
|
427
|
+
} else if (ranks.has(rank)) {
|
|
428
|
+
problems.push(`line ${line}: rank ${rank} is used twice \u2014 a rank is an order, not a score`);
|
|
429
|
+
ok = false;
|
|
430
|
+
}
|
|
431
|
+
if (!/^I\d+$/.test(id)) {
|
|
432
|
+
problems.push(`line ${line}: id \`${id}\` is not \`I<number>\``);
|
|
433
|
+
ok = false;
|
|
434
|
+
} else if (ids.has(id)) {
|
|
435
|
+
problems.push(`line ${line}: id \`${id}\` is used twice`);
|
|
436
|
+
ok = false;
|
|
437
|
+
}
|
|
438
|
+
if (!initiative) {
|
|
439
|
+
problems.push(`line ${line}: initiative title is empty`);
|
|
440
|
+
ok = false;
|
|
441
|
+
}
|
|
442
|
+
if (!next) {
|
|
443
|
+
problems.push(`line ${line}: next step is empty`);
|
|
444
|
+
ok = false;
|
|
445
|
+
}
|
|
446
|
+
ok = checkVocabulary(line, "goal", goal, MILESTONE_GOALS, problems) && ok;
|
|
447
|
+
ok = checkVocabulary(line, "status", status, MILESTONE_STATUSES, problems) && ok;
|
|
448
|
+
ok = checkVocabulary(line, "effort", effort, MILESTONE_EFFORTS, problems) && ok;
|
|
449
|
+
if (!ok) continue;
|
|
450
|
+
ids.add(id);
|
|
451
|
+
ranks.add(rank);
|
|
452
|
+
rows.push({
|
|
453
|
+
rank,
|
|
454
|
+
id,
|
|
455
|
+
milestone: initiative,
|
|
456
|
+
goal,
|
|
457
|
+
status,
|
|
458
|
+
next,
|
|
459
|
+
effort,
|
|
460
|
+
projects: parseList(projects),
|
|
461
|
+
dependsOn: parseList(deps)
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
for (const r of rows) {
|
|
465
|
+
for (const d of r.dependsOn) {
|
|
466
|
+
if (!ids.has(d)) problems.push(`${r.id} depends on \`${d}\`, which is not in the table`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
rows.sort((a, b) => a.rank - b.rank);
|
|
470
|
+
return { rows, problems };
|
|
471
|
+
}
|
|
472
|
+
function cell(s) {
|
|
473
|
+
return s.replace(/\|/g, "\\|");
|
|
474
|
+
}
|
|
475
|
+
function list(xs) {
|
|
476
|
+
return xs.length ? xs.join(", ") : "\u2014";
|
|
477
|
+
}
|
|
478
|
+
function countBy(rows) {
|
|
479
|
+
const c = { planned: 0, active: 0, blocked: 0, done: 0 };
|
|
480
|
+
for (const r of rows) c[r.status] += 1;
|
|
481
|
+
return c;
|
|
482
|
+
}
|
|
483
|
+
function statusLine(rows) {
|
|
484
|
+
const c = countBy(rows);
|
|
485
|
+
return MILESTONE_STATUSES.map((s) => `${s} ${c[s]}`).join(" \xB7 ");
|
|
486
|
+
}
|
|
487
|
+
function renderBoard(input) {
|
|
488
|
+
const out = [];
|
|
489
|
+
out.push("# Fleet board");
|
|
490
|
+
out.push("");
|
|
491
|
+
out.push(
|
|
492
|
+
`Generated ${input.generatedOn} by \`etymd fleet board\` from \`${input.manifest}\`. Do not edit: change a project's milestones file or the initiatives table and re-render.`
|
|
493
|
+
);
|
|
494
|
+
out.push("");
|
|
495
|
+
out.push("## Initiatives, ranked");
|
|
496
|
+
out.push("");
|
|
497
|
+
if (input.initiatives === null) {
|
|
498
|
+
out.push("No initiatives file given (`--initiatives <file>`).");
|
|
499
|
+
} else {
|
|
500
|
+
if (input.initiativesProblems.length) {
|
|
501
|
+
out.push("Problems in the initiatives table:");
|
|
502
|
+
out.push("");
|
|
503
|
+
for (const p of input.initiativesProblems) out.push(`- ${p}`);
|
|
504
|
+
out.push("");
|
|
505
|
+
}
|
|
506
|
+
if (input.initiatives.length === 0) {
|
|
507
|
+
out.push("No initiatives ranked yet.");
|
|
508
|
+
} else {
|
|
509
|
+
out.push(`| ${INITIATIVE_COLUMNS.join(" | ")} |`);
|
|
510
|
+
out.push(`|${INITIATIVE_COLUMNS.map(() => "---").join("|")}|`);
|
|
511
|
+
for (const i of input.initiatives) {
|
|
512
|
+
out.push(
|
|
513
|
+
`| ${i.rank} | ${i.id} | ${cell(i.milestone)} | ${i.goal} | ${i.status} | ${cell(i.next)} | ${i.effort} | ${cell(list(i.projects))} | ${list(i.dependsOn)} |`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
out.push("");
|
|
519
|
+
out.push("## Milestones by project");
|
|
520
|
+
out.push("");
|
|
521
|
+
const all = [];
|
|
522
|
+
for (const p of input.projects) {
|
|
523
|
+
const kind = p.kind ? ` (${p.kind})` : "";
|
|
524
|
+
switch (p.state) {
|
|
525
|
+
case "ok":
|
|
526
|
+
case "declared-empty":
|
|
527
|
+
case "invalid": {
|
|
528
|
+
const head = p.state === "declared-empty" ? "declared, empty" : p.state === "invalid" ? `INVALID \u2014 ${p.problems.length} problem${p.problems.length === 1 ? "" : "s"}` : statusLine(p.rows);
|
|
529
|
+
out.push(`### ${p.name}${kind} \u2014 ${head}`);
|
|
530
|
+
out.push("");
|
|
531
|
+
if (p.problems.length) {
|
|
532
|
+
for (const pr of p.problems) out.push(`- ${pr}`);
|
|
533
|
+
out.push("");
|
|
534
|
+
}
|
|
535
|
+
if (p.rows.length) {
|
|
536
|
+
out.push(`| ${MILESTONE_COLUMNS.join(" | ")} |`);
|
|
537
|
+
out.push(`|${MILESTONE_COLUMNS.map(() => "---").join("|")}|`);
|
|
538
|
+
for (const r of p.rows) {
|
|
539
|
+
out.push(
|
|
540
|
+
`| ${r.id} | ${cell(r.milestone)} | ${r.goal} | ${r.status} | ${cell(r.next)} | ${r.effort} | ${list(r.dependsOn)} |`
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
out.push("");
|
|
544
|
+
all.push(...p.rows);
|
|
545
|
+
}
|
|
546
|
+
break;
|
|
547
|
+
}
|
|
548
|
+
case "not-declared":
|
|
549
|
+
out.push(`### ${p.name}${kind} \u2014 not declared (no \`milestones\` contract key)`);
|
|
550
|
+
out.push("");
|
|
551
|
+
break;
|
|
552
|
+
case "none":
|
|
553
|
+
out.push(`### ${p.name}${kind} \u2014 none, by declaration`);
|
|
554
|
+
out.push("");
|
|
555
|
+
break;
|
|
556
|
+
case "missing":
|
|
557
|
+
out.push(
|
|
558
|
+
`### ${p.name}${kind} \u2014 MISSING: \`${p.file ?? MILESTONES_FILE}\` is declared and absent`
|
|
559
|
+
);
|
|
560
|
+
out.push("");
|
|
561
|
+
break;
|
|
562
|
+
case "unresolved":
|
|
563
|
+
out.push(`### ${p.name}${kind} \u2014 not read: ${p.problems[0] ?? "unresolved"}`);
|
|
564
|
+
out.push("");
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
out.push("## Totals");
|
|
569
|
+
out.push("");
|
|
570
|
+
const c = countBy(all);
|
|
571
|
+
out.push("| status | count |");
|
|
572
|
+
out.push("|---|---|");
|
|
573
|
+
for (const s of MILESTONE_STATUSES) out.push(`| ${s} | ${c[s]} |`);
|
|
574
|
+
out.push("");
|
|
575
|
+
const byGoal = { "1": 0, "2": 0, "3": 0 };
|
|
576
|
+
for (const r of all) if (r.status !== "done") byGoal[r.goal] = (byGoal[r.goal] ?? 0) + 1;
|
|
577
|
+
out.push("| goal | open milestones |");
|
|
578
|
+
out.push("|---|---|");
|
|
579
|
+
for (const g of MILESTONE_GOALS) out.push(`| ${g} | ${byGoal[g] ?? 0} |`);
|
|
580
|
+
out.push("");
|
|
581
|
+
const states = input.projects.reduce((acc, p) => {
|
|
582
|
+
acc[p.state] = (acc[p.state] ?? 0) + 1;
|
|
583
|
+
return acc;
|
|
584
|
+
}, {});
|
|
585
|
+
out.push(
|
|
586
|
+
`Projects: ${input.projects.length} \u2014 ` + Object.entries(states).map(([k, v]) => `${k} ${v}`).join(" \xB7 ")
|
|
587
|
+
);
|
|
588
|
+
out.push("");
|
|
589
|
+
return out.join("\n");
|
|
590
|
+
}
|
|
273
591
|
var pExecFile = promisify(execFile);
|
|
274
592
|
var FLEET_LENS = "fleet-manifest";
|
|
275
593
|
var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
|
|
276
|
-
var
|
|
594
|
+
var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
|
|
277
595
|
function recurringClasses(projects) {
|
|
278
596
|
const byClass = /* @__PURE__ */ new Map();
|
|
279
597
|
for (const p of projects) {
|
|
@@ -306,12 +624,12 @@ function finding(id, tier, claim, evidence, why, action) {
|
|
|
306
624
|
confidence: "high"
|
|
307
625
|
};
|
|
308
626
|
}
|
|
309
|
-
function
|
|
310
|
-
const
|
|
311
|
-
const root = path.resolve(
|
|
312
|
-
if (path.dirname(root) !==
|
|
627
|
+
function guardedPersistenceRoot(manifest, name) {
|
|
628
|
+
const guardedDir = path.join(manifest.dir, "guarded");
|
|
629
|
+
const root = path.resolve(guardedDir, name);
|
|
630
|
+
if (path.dirname(root) !== guardedDir || root === guardedDir) {
|
|
313
631
|
throw new Error(
|
|
314
|
-
`entry name ${JSON.stringify(name)} escapes the
|
|
632
|
+
`entry name ${JSON.stringify(name)} escapes the guarded persistence zone \u2014 refusing to resolve a ledger root for it`
|
|
315
633
|
);
|
|
316
634
|
}
|
|
317
635
|
return root;
|
|
@@ -344,7 +662,7 @@ async function realpathOr(p) {
|
|
|
344
662
|
return path.resolve(p);
|
|
345
663
|
}
|
|
346
664
|
}
|
|
347
|
-
function
|
|
665
|
+
function emailMatchesGuardedHosts(email, hosts) {
|
|
348
666
|
const domain = email.split("@")[1]?.toLowerCase();
|
|
349
667
|
if (!domain) return false;
|
|
350
668
|
return hosts.some((h) => {
|
|
@@ -395,7 +713,7 @@ async function checkManifest(manifest) {
|
|
|
395
713
|
);
|
|
396
714
|
}
|
|
397
715
|
seen.add(entry.name);
|
|
398
|
-
if (entry.profile !== "
|
|
716
|
+
if (entry.profile !== "guarded" && !entry.private && !entry.trust) {
|
|
399
717
|
const bad = entry.trustRaw;
|
|
400
718
|
findings.push(
|
|
401
719
|
finding(
|
|
@@ -433,14 +751,14 @@ async function checkManifest(manifest) {
|
|
|
433
751
|
)
|
|
434
752
|
);
|
|
435
753
|
}
|
|
436
|
-
if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "
|
|
754
|
+
if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "guarded" || entry.private)) {
|
|
437
755
|
findings.push(
|
|
438
756
|
finding(
|
|
439
757
|
`${FLEET_LENS}/dangling-dir:${entry.name}`,
|
|
440
758
|
"gap",
|
|
441
759
|
`\`${entry.name}\` has no usable directory mapping`,
|
|
442
760
|
[`${localFile}: ${entry.unresolved}`],
|
|
443
|
-
"An unmapped
|
|
761
|
+
"An unmapped guarded entry silently drops out of every sweep on the machine that most needs it.",
|
|
444
762
|
`Add a \`dirs\` mapping for \`${entry.name}\` in ${localFile}.`
|
|
445
763
|
)
|
|
446
764
|
);
|
|
@@ -504,28 +822,28 @@ async function checkManifest(manifest) {
|
|
|
504
822
|
);
|
|
505
823
|
}
|
|
506
824
|
if (manifest.machineProfile === "personal") {
|
|
507
|
-
const
|
|
508
|
-
if (
|
|
825
|
+
const guardedCount = manifest.entries.filter((e) => e.profile === "guarded" || e.private).length;
|
|
826
|
+
if (guardedCount) {
|
|
509
827
|
disclosures.push(
|
|
510
|
-
`machineProfile "personal": ${
|
|
828
|
+
`machineProfile "personal": ${guardedCount} guarded entr${guardedCount === 1 ? "y" : "ies"} deliberately absent on this machine \u2014 disclosed, not dangling.`
|
|
511
829
|
);
|
|
512
830
|
}
|
|
513
831
|
}
|
|
514
832
|
return { findings, disclosures };
|
|
515
833
|
}
|
|
516
|
-
async function
|
|
834
|
+
async function checkGuardedWallArtifacts(entries, findings) {
|
|
517
835
|
for (const entry of entries) {
|
|
518
|
-
if (entry.profile !== "
|
|
519
|
-
for (const artifact of
|
|
836
|
+
if (entry.profile !== "guarded" || !entry.resolvedRoot) continue;
|
|
837
|
+
for (const artifact of GUARDED_WALL_ARTIFACTS) {
|
|
520
838
|
if (await pathExists(path.join(entry.resolvedRoot, artifact))) {
|
|
521
839
|
findings.push(
|
|
522
840
|
finding(
|
|
523
|
-
`${FLEET_LENS}/
|
|
841
|
+
`${FLEET_LENS}/guarded-artifact-in-repo:${entry.name}:${artifact}`,
|
|
524
842
|
"risk",
|
|
525
|
-
`
|
|
843
|
+
`guarded entry \`${entry.name}\` carries ${artifact} inside its worktree`,
|
|
526
844
|
[`${entry.resolvedRoot}/${artifact}`],
|
|
527
|
-
"Agent artifacts for
|
|
528
|
-
`Move it to the fleet's
|
|
845
|
+
"Agent artifacts for guarded 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.",
|
|
846
|
+
`Move it to the fleet's guarded/${entry.name}/ zone and leave a pointer if the harness needs one.`
|
|
529
847
|
)
|
|
530
848
|
);
|
|
531
849
|
}
|
|
@@ -533,9 +851,9 @@ async function checkCorpWallArtifacts(entries, findings) {
|
|
|
533
851
|
}
|
|
534
852
|
}
|
|
535
853
|
async function checkCoverage(manifest, findings, disclosures) {
|
|
536
|
-
if (!manifest.
|
|
854
|
+
if (!manifest.guardedHosts.length) {
|
|
537
855
|
disclosures.push(
|
|
538
|
-
"Coverage check skipped \u2014 no
|
|
856
|
+
"Coverage check skipped \u2014 no guardedHosts in the local manifest, so a guarded remote cannot be recognized."
|
|
539
857
|
);
|
|
540
858
|
return;
|
|
541
859
|
}
|
|
@@ -556,16 +874,16 @@ async function checkCoverage(manifest, findings, disclosures) {
|
|
|
556
874
|
if (!await pathExists(path.join(dir, ".git"))) continue;
|
|
557
875
|
const remotes = await git(dir, ["config", "--get-regexp", "^remote\\..*\\.url"]);
|
|
558
876
|
if (!remotes) continue;
|
|
559
|
-
const
|
|
560
|
-
if (
|
|
877
|
+
const guardedRemote = manifest.guardedHosts.find((h) => remotes.includes(h));
|
|
878
|
+
if (guardedRemote) {
|
|
561
879
|
findings.push(
|
|
562
880
|
finding(
|
|
563
|
-
`${FLEET_LENS}/unregistered-
|
|
881
|
+
`${FLEET_LENS}/unregistered-guarded-remote:${d.name}`,
|
|
564
882
|
"risk",
|
|
565
|
-
`\`${d.name}\` under the fleet root has a
|
|
566
|
-
[`${dir}: remote matches ${
|
|
567
|
-
"The wall is only as good as its census \u2014 an unregistered
|
|
568
|
-
"Register it as a
|
|
883
|
+
`\`${d.name}\` under the fleet root has a guarded remote but is not registered`,
|
|
884
|
+
[`${dir}: remote matches ${guardedRemote}`],
|
|
885
|
+
"The wall is only as good as its census \u2014 an unregistered guarded checkout is guarded material no sweep, wall check, or transfer plan knows exists.",
|
|
886
|
+
"Register it as a guarded entry (opaque alias + local dirs mapping) or move it out of the fleet root."
|
|
569
887
|
)
|
|
570
888
|
);
|
|
571
889
|
}
|
|
@@ -613,7 +931,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
613
931
|
[
|
|
614
932
|
...Object.values(manifest.labels),
|
|
615
933
|
...Object.values(manifest.localDirs).map((d) => path.basename(expandNeedleDir(d))),
|
|
616
|
-
...manifest.
|
|
934
|
+
...manifest.guardedHosts
|
|
617
935
|
].filter((n) => n.length >= 3)
|
|
618
936
|
)
|
|
619
937
|
];
|
|
@@ -640,7 +958,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
640
958
|
"risk",
|
|
641
959
|
`${file} in public-repo \`${entry.name}\` carries a needle from the fleet's private local manifest`,
|
|
642
960
|
[`${file}: contains "${needle}"`],
|
|
643
|
-
"A public repo that names a private label,
|
|
961
|
+
"A public repo that names a private label, guarded directory, or guarded host joins the public and guarded identities for anyone who greps.",
|
|
644
962
|
"Scrub the needle from the tracked file (rewrite history if it already shipped)."
|
|
645
963
|
)
|
|
646
964
|
);
|
|
@@ -651,10 +969,10 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
651
969
|
function expandNeedleDir(dir) {
|
|
652
970
|
return dir.replace(/\/+$/, "");
|
|
653
971
|
}
|
|
654
|
-
async function
|
|
655
|
-
if (!manifest.
|
|
972
|
+
async function checkGuardedEmails(manifest, findings, disclosures) {
|
|
973
|
+
if (!manifest.guardedHosts.length) {
|
|
656
974
|
disclosures.push(
|
|
657
|
-
"
|
|
975
|
+
"Guarded-email check skipped \u2014 no guardedHosts in the local manifest, so a guarded domain cannot be recognized."
|
|
658
976
|
);
|
|
659
977
|
return;
|
|
660
978
|
}
|
|
@@ -663,21 +981,23 @@ async function checkCorpEmails(manifest, findings, disclosures) {
|
|
|
663
981
|
const emails = await git(entry.resolvedRoot, ["log", "-30", "--format=%ae"]);
|
|
664
982
|
if (emails === null) {
|
|
665
983
|
disclosures.push(
|
|
666
|
-
`
|
|
984
|
+
`Guarded-email check could not run in \`${entry.name}\` (no readable commit history) \u2014 undetermined, not clean.`
|
|
667
985
|
);
|
|
668
986
|
continue;
|
|
669
987
|
}
|
|
670
988
|
const lines = emails.split("\n").filter(Boolean);
|
|
671
|
-
const matched = [
|
|
989
|
+
const matched = [
|
|
990
|
+
...new Set(lines.filter((e) => emailMatchesGuardedHosts(e, manifest.guardedHosts)))
|
|
991
|
+
];
|
|
672
992
|
if (matched.length) {
|
|
673
993
|
const count = lines.filter((e) => matched.includes(e)).length;
|
|
674
994
|
findings.push(
|
|
675
995
|
finding(
|
|
676
|
-
`${FLEET_LENS}/
|
|
996
|
+
`${FLEET_LENS}/guarded-email:${entry.name}`,
|
|
677
997
|
"risk",
|
|
678
|
-
`recent commits on personal entry \`${entry.name}\` are authored with a
|
|
998
|
+
`recent commits on personal entry \`${entry.name}\` are authored with a guarded-host email`,
|
|
679
999
|
[`${count} of the last ${lines.length} commits: ${matched.join(", ")}`],
|
|
680
|
-
"A
|
|
1000
|
+
"A guarded 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 guarded one.",
|
|
681
1001
|
"Set the repo-local identity to the personal one and amend the unpushed commits."
|
|
682
1002
|
)
|
|
683
1003
|
);
|
|
@@ -685,18 +1005,18 @@ async function checkCorpEmails(manifest, findings, disclosures) {
|
|
|
685
1005
|
}
|
|
686
1006
|
}
|
|
687
1007
|
async function checkGateDrift(manifest, findings, disclosures) {
|
|
688
|
-
const { planWorkflow } = await import('./generate-
|
|
689
|
-
const { scanProject
|
|
1008
|
+
const { planWorkflow } = await import('./generate-YEARCSHW.js');
|
|
1009
|
+
const { scanProject } = await import('./scan-64Z6SNYF.js');
|
|
690
1010
|
const { readConfig } = await import('./config-724Y3IOB.js');
|
|
691
1011
|
for (const entry of manifest.entries) {
|
|
692
1012
|
const root = entry.resolvedRoot;
|
|
693
1013
|
if (!root || !await isDirectory(root)) continue;
|
|
694
|
-
if (entry.profile === "
|
|
1014
|
+
if (entry.profile === "guarded" || entry.private) continue;
|
|
695
1015
|
if (entry.gates === "none") {
|
|
696
1016
|
disclosures.push(`${entry.name}: gates declared \`none\` \u2014 deliberately absent, not missing`);
|
|
697
1017
|
continue;
|
|
698
1018
|
}
|
|
699
|
-
const facts = await
|
|
1019
|
+
const facts = await scanProject(root);
|
|
700
1020
|
if (!facts.git.isRepo) continue;
|
|
701
1021
|
if (facts.hooks.source === "husky" || facts.hooks.source === "husky-legacy") {
|
|
702
1022
|
disclosures.push(
|
|
@@ -744,11 +1064,11 @@ async function checkGateDrift(manifest, findings, disclosures) {
|
|
|
744
1064
|
async function collectWallFindings(manifest) {
|
|
745
1065
|
const findings = [];
|
|
746
1066
|
const disclosures = [];
|
|
747
|
-
await
|
|
1067
|
+
await checkGuardedWallArtifacts(manifest.entries, findings);
|
|
748
1068
|
await checkCoverage(manifest, findings, disclosures);
|
|
749
1069
|
await checkManifestRepoMachinePaths(manifest, findings, disclosures);
|
|
750
1070
|
await checkHygieneNeedles(manifest, findings, disclosures);
|
|
751
|
-
await
|
|
1071
|
+
await checkGuardedEmails(manifest, findings, disclosures);
|
|
752
1072
|
await checkGateDrift(manifest, findings, disclosures);
|
|
753
1073
|
return { findings, disclosures };
|
|
754
1074
|
}
|
|
@@ -814,13 +1134,13 @@ async function sweepEntry(manifest, entry, opts) {
|
|
|
814
1134
|
);
|
|
815
1135
|
}
|
|
816
1136
|
}
|
|
817
|
-
const
|
|
818
|
-
const optedIn = !
|
|
1137
|
+
const guarded = entry.profile === "guarded";
|
|
1138
|
+
const optedIn = !guarded && await pathExists(path.join(root, ETYMD_DIR));
|
|
819
1139
|
const result = await runAudit(root, {
|
|
820
1140
|
kind: opts.kind,
|
|
821
|
-
persistLedger: !
|
|
822
|
-
readOnlyRoot:
|
|
823
|
-
ledgerRoot:
|
|
1141
|
+
persistLedger: !guarded && opts.persistLedgers === true && optedIn,
|
|
1142
|
+
readOnlyRoot: guarded,
|
|
1143
|
+
ledgerRoot: guarded ? guardedPersistenceRoot(manifest, entry.name) : void 0,
|
|
824
1144
|
stateBudgets: stateBudgetsFor(entry),
|
|
825
1145
|
upstreamRemote
|
|
826
1146
|
});
|
|
@@ -828,11 +1148,43 @@ async function sweepEntry(manifest, entry, opts) {
|
|
|
828
1148
|
if (entry.contract.placement === "none") {
|
|
829
1149
|
project.findings = project.findings.filter((f) => !f.id.endsWith("/no-contract"));
|
|
830
1150
|
}
|
|
1151
|
+
const milestonesFile = entry.contract.milestones;
|
|
1152
|
+
if (milestonesFile && milestonesFile !== "none") {
|
|
1153
|
+
const text = await readText(path.join(root, milestonesFile));
|
|
1154
|
+
if (text === null) {
|
|
1155
|
+
project.findings.push(
|
|
1156
|
+
finding(
|
|
1157
|
+
`${FLEET_LENS}/milestones-missing:${entry.name}`,
|
|
1158
|
+
"gap",
|
|
1159
|
+
`\`${entry.name}\` declares milestones at \`${milestonesFile}\` and the file is absent`,
|
|
1160
|
+
[
|
|
1161
|
+
`${path.basename(manifest.manifestPath)}: ${entry.name}.contract.milestones = ${milestonesFile}`
|
|
1162
|
+
],
|
|
1163
|
+
"The fleet board rolls every project's plan up from this file; a declared file that is absent is a row the board cannot fill.",
|
|
1164
|
+
'Create it (heading `# Milestones`, then the standard table), or declare `milestones: "none"` if this project deliberately carries no plan.'
|
|
1165
|
+
)
|
|
1166
|
+
);
|
|
1167
|
+
} else {
|
|
1168
|
+
const doc = parseMilestones(text);
|
|
1169
|
+
doc.problems.forEach((problem, i) => {
|
|
1170
|
+
project.findings.push(
|
|
1171
|
+
finding(
|
|
1172
|
+
`${FLEET_LENS}/milestones-shape:${entry.name}:${i + 1}`,
|
|
1173
|
+
"gap",
|
|
1174
|
+
`\`${entry.name}\` ${milestonesFile}: ${problem}`,
|
|
1175
|
+
[`${milestonesFile}`],
|
|
1176
|
+
"A milestones file the board cannot parse is a plan the fleet cannot see.",
|
|
1177
|
+
`Fix the table to the standard shape: \`| ${MILESTONE_COLUMNS.join(" | ")} |\`.`
|
|
1178
|
+
)
|
|
1179
|
+
);
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
831
1183
|
for (const f of project.findings) project.counts[f.tier] += 1;
|
|
832
1184
|
project.stateAgeDays = stateAgeDays(result.facts);
|
|
833
1185
|
project.staleAfterDays = result.config.config.state.staleAfterDays;
|
|
834
1186
|
for (const [key, value] of Object.entries(entry.contract)) {
|
|
835
|
-
if (key === "placement" || !value) continue;
|
|
1187
|
+
if (key === "placement" || key === "milestones" || !value) continue;
|
|
836
1188
|
const exists = await pathExists(path.join(root, value.replace(/#.*$/, "")));
|
|
837
1189
|
project.disclosures.push(
|
|
838
1190
|
`contract override \`${key}: ${value}\` \u2014 registered, existence-checked only (${exists ? "present" : "MISSING"})`
|
|
@@ -884,12 +1236,12 @@ async function fleetLedgerTarget(manifest, name) {
|
|
|
884
1236
|
if (!entry.resolvedRoot) {
|
|
885
1237
|
throw new Error(`\`${name}\` does not resolve on this machine: ${entry.unresolved}`);
|
|
886
1238
|
}
|
|
887
|
-
const
|
|
1239
|
+
const guarded = entry.profile === "guarded";
|
|
888
1240
|
return {
|
|
889
1241
|
entry,
|
|
890
1242
|
auditRoot: entry.resolvedRoot,
|
|
891
|
-
ledgerRoot:
|
|
892
|
-
|
|
1243
|
+
ledgerRoot: guarded ? guardedPersistenceRoot(manifest, name) : entry.resolvedRoot,
|
|
1244
|
+
guarded
|
|
893
1245
|
};
|
|
894
1246
|
}
|
|
895
1247
|
async function ensureFindingRecorded(manifest, name, id) {
|
|
@@ -898,398 +1250,11 @@ async function ensureFindingRecorded(manifest, name, id) {
|
|
|
898
1250
|
if (await has()) return { ledgerRoot: target.ledgerRoot, recorded: true };
|
|
899
1251
|
await runAudit(target.auditRoot, {
|
|
900
1252
|
persistLedger: true,
|
|
901
|
-
readOnlyRoot: target.
|
|
902
|
-
ledgerRoot: target.
|
|
1253
|
+
readOnlyRoot: target.guarded,
|
|
1254
|
+
ledgerRoot: target.guarded ? target.ledgerRoot : void 0,
|
|
903
1255
|
stateBudgets: stateBudgetsFor(target.entry)
|
|
904
1256
|
});
|
|
905
1257
|
return { ledgerRoot: target.ledgerRoot, recorded: await has() };
|
|
906
1258
|
}
|
|
907
1259
|
|
|
908
|
-
|
|
909
|
-
var LAST_SWEEP_FILE = "last.fleet.json";
|
|
910
|
-
async function loadManifest(cwd, manifestOpt) {
|
|
911
|
-
if (manifestOpt) {
|
|
912
|
-
return { manifest: await loadFleetManifest(path.resolve(cwd, manifestOpt)) };
|
|
913
|
-
}
|
|
914
|
-
const candidate = path.join(cwd, "registry.json");
|
|
915
|
-
if (await pathExists(candidate)) {
|
|
916
|
-
return {
|
|
917
|
-
manifest: await loadFleetManifest(candidate),
|
|
918
|
-
autoNote: `manifest auto-selected from the cwd: ${candidate} (no --manifest given)`
|
|
919
|
-
};
|
|
920
|
-
}
|
|
921
|
-
throw new Error(
|
|
922
|
-
"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)"
|
|
923
|
-
);
|
|
924
|
-
}
|
|
925
|
-
async function readLastSweep(lastPath) {
|
|
926
|
-
const raw = await readText(lastPath);
|
|
927
|
-
if (raw === null) return { record: null };
|
|
928
|
-
let parsed;
|
|
929
|
-
try {
|
|
930
|
-
parsed = JSON.parse(raw);
|
|
931
|
-
} catch {
|
|
932
|
-
return {
|
|
933
|
-
record: null,
|
|
934
|
-
note: `${LAST_SWEEP_FILE} exists but is not valid JSON \u2014 delta baseline reset (every finding reads as new this sweep).`
|
|
935
|
-
};
|
|
936
|
-
}
|
|
937
|
-
const rec = parsed;
|
|
938
|
-
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)) {
|
|
939
|
-
return {
|
|
940
|
-
record: null,
|
|
941
|
-
note: `${LAST_SWEEP_FILE} carries a foreign schema \u2014 delta baseline reset (every finding reads as new this sweep).`
|
|
942
|
-
};
|
|
943
|
-
}
|
|
944
|
-
return { record: rec };
|
|
945
|
-
}
|
|
946
|
-
function failOnExit(failOn, findings) {
|
|
947
|
-
if (failOn === void 0) return;
|
|
948
|
-
if (meetsFailOn(findings, parseFailOnTier(failOn))) process.exitCode = 1;
|
|
949
|
-
}
|
|
950
|
-
async function sweep(opts) {
|
|
951
|
-
if (opts.profile && opts.profile !== "personal" && opts.profile !== "corp") {
|
|
952
|
-
throw new Error(`--profile must be personal or corp, got \`${opts.profile}\``);
|
|
953
|
-
}
|
|
954
|
-
if (opts.failOn !== void 0) parseFailOnTier(opts.failOn);
|
|
955
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
956
|
-
const result = await sweepFleet(manifest, {
|
|
957
|
-
only: opts.only,
|
|
958
|
-
profile: opts.profile,
|
|
959
|
-
kind: opts.truth ? "truth" : void 0,
|
|
960
|
-
persistLedgers: opts.persistLedgers
|
|
961
|
-
});
|
|
962
|
-
const lastPath = path.join(manifest.dir, LAST_SWEEP_FILE);
|
|
963
|
-
const { record: previous, note: baselineNote } = await readLastSweep(lastPath);
|
|
964
|
-
const previousIds = (name) => new Set((previous?.projects[name] ?? []).map((e) => e.id));
|
|
965
|
-
const previousWallIds = new Set((previous?.wall ?? []).map((e) => e.id));
|
|
966
|
-
const allFindings = [...result.projects.flatMap((p) => p.findings), ...result.wall];
|
|
967
|
-
failOnExit(opts.failOn, allFindings);
|
|
968
|
-
const fullSweep = !opts.only && !opts.profile && !opts.truth;
|
|
969
|
-
const delta = {};
|
|
970
|
-
for (const p of result.projects) {
|
|
971
|
-
const prev = previousIds(p.name);
|
|
972
|
-
const current = new Set(p.findings.map((f) => f.id));
|
|
973
|
-
delta[p.name] = {
|
|
974
|
-
new: [...current].filter((id) => !prev.has(id)),
|
|
975
|
-
resolved: [...prev].filter((id) => !current.has(id))
|
|
976
|
-
};
|
|
977
|
-
}
|
|
978
|
-
const wallNew = result.wall.filter((f) => !previousWallIds.has(f.id)).map((f) => f.id);
|
|
979
|
-
if (opts.json) {
|
|
980
|
-
print(
|
|
981
|
-
JSON.stringify(
|
|
982
|
-
{
|
|
983
|
-
...result,
|
|
984
|
-
delta: previous ? delta : null,
|
|
985
|
-
...baselineNote ? { deltaBaselineNote: baselineNote } : {}
|
|
986
|
-
},
|
|
987
|
-
null,
|
|
988
|
-
2
|
|
989
|
-
)
|
|
990
|
-
);
|
|
991
|
-
} else {
|
|
992
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
993
|
-
if (baselineNote) print(` ${theme.dim(`\u25E6 ${baselineNote}`)}`);
|
|
994
|
-
renderSweep(result, previous, delta, wallNew);
|
|
995
|
-
}
|
|
996
|
-
if (fullSweep) {
|
|
997
|
-
const record = {
|
|
998
|
-
schema: FLEET_JSON_SCHEMA,
|
|
999
|
-
sweptAt: result.sweptAt,
|
|
1000
|
-
projects: Object.fromEntries(
|
|
1001
|
-
result.projects.map((p) => [p.name, p.findings.map((f) => ({ id: f.id, tier: f.tier }))])
|
|
1002
|
-
),
|
|
1003
|
-
wall: result.wall.map((f) => ({ id: f.id, tier: f.tier }))
|
|
1004
|
-
};
|
|
1005
|
-
const tmpPath = `${lastPath}.tmp`;
|
|
1006
|
-
await promises.writeFile(tmpPath, JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
1007
|
-
await promises.rename(tmpPath, lastPath);
|
|
1008
|
-
} else if (!opts.json) {
|
|
1009
|
-
print();
|
|
1010
|
-
print(` ${theme.dim("partial sweep \u2014 the delta baseline was not updated")}`);
|
|
1011
|
-
}
|
|
1012
|
-
}
|
|
1013
|
-
function renderSweep(result, previous, delta, wallNew) {
|
|
1014
|
-
section(
|
|
1015
|
-
`Fleet sweep ${theme.dim(`\xB7 ${path.basename(result.manifest)} \xB7 ${result.projects.length} project(s) \xB7 schema ${result.schema}`)}`
|
|
1016
|
-
);
|
|
1017
|
-
const rows = result.projects.map((p) => {
|
|
1018
|
-
const d = delta[p.name] ?? { new: [], resolved: [] };
|
|
1019
|
-
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";
|
|
1020
|
-
return {
|
|
1021
|
-
name: p.name,
|
|
1022
|
-
age: p.stateAgeDays === null ? "\u2014" : `${p.stateAgeDays}/${p.staleAfterDays}d`,
|
|
1023
|
-
counts: p.counts,
|
|
1024
|
-
delta: deltaStr,
|
|
1025
|
-
note: p.unresolved
|
|
1026
|
-
};
|
|
1027
|
-
});
|
|
1028
|
-
renderFleetRows(rows);
|
|
1029
|
-
renderFleetNotes(
|
|
1030
|
-
result.problems,
|
|
1031
|
-
result.projects.flatMap((p) => p.disclosures.map((d) => `${p.name}: ${d}`))
|
|
1032
|
-
);
|
|
1033
|
-
const detail = [
|
|
1034
|
-
...result.projects.flatMap(
|
|
1035
|
-
(p) => p.findings.filter((f) => f.tier === "risk" || (delta[p.name]?.new ?? []).includes(f.id))
|
|
1036
|
-
),
|
|
1037
|
-
...result.wall.filter((f) => f.tier === "risk" || wallNew.includes(f.id))
|
|
1038
|
-
];
|
|
1039
|
-
if (detail.length) {
|
|
1040
|
-
section(`New or risk findings ${theme.dim(`(${detail.length})`)}`);
|
|
1041
|
-
renderFindings(detail);
|
|
1042
|
-
}
|
|
1043
|
-
if (result.recurringClasses.length) {
|
|
1044
|
-
section(`Recurring classes ${theme.dim("(open in \u22652 projects \u2014 class-fix candidates)")}`);
|
|
1045
|
-
for (const rc of result.recurringClasses) {
|
|
1046
|
-
print(
|
|
1047
|
-
` ${TIER_BADGE[rc.tier]} ${rc.classId} ${theme.dim(`\u2014 ${rc.projects.length}\xD7: ${rc.projects.join(", ")}`)}`
|
|
1048
|
-
);
|
|
1049
|
-
}
|
|
1050
|
-
}
|
|
1051
|
-
section("Fleet wall");
|
|
1052
|
-
if (!result.wall.length) print(` ${glyph.ok} ${theme.dim("clean")}`);
|
|
1053
|
-
else
|
|
1054
|
-
print(
|
|
1055
|
-
` ${theme.warn(`${result.wall.length} finding(s)`)} ${theme.dim("(detailed above if new or risk)")}`
|
|
1056
|
-
);
|
|
1057
|
-
renderFleetNotes([], result.wallDisclosures);
|
|
1058
|
-
if (result.outOfScope.length) {
|
|
1059
|
-
print(
|
|
1060
|
-
` ${theme.dim(`out of scope (not audited, named above): ${result.outOfScope.join(", ")}`)}`
|
|
1061
|
-
);
|
|
1062
|
-
}
|
|
1063
|
-
}
|
|
1064
|
-
async function check(opts) {
|
|
1065
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
1066
|
-
const { findings, disclosures } = await checkManifest(manifest);
|
|
1067
|
-
if (findings.length) process.exitCode = 1;
|
|
1068
|
-
if (opts.json) {
|
|
1069
|
-
print(
|
|
1070
|
-
JSON.stringify(
|
|
1071
|
-
{ schema: FLEET_JSON_SCHEMA, manifest: manifest.manifestPath, findings, disclosures },
|
|
1072
|
-
null,
|
|
1073
|
-
2
|
|
1074
|
-
)
|
|
1075
|
-
);
|
|
1076
|
-
return;
|
|
1077
|
-
}
|
|
1078
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
1079
|
-
section(
|
|
1080
|
-
`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`)}`
|
|
1081
|
-
);
|
|
1082
|
-
renderFindings(findings);
|
|
1083
|
-
renderFleetNotes([], disclosures);
|
|
1084
|
-
}
|
|
1085
|
-
async function recordCorpMapping(manifest, name, absTarget) {
|
|
1086
|
-
const home = os.homedir();
|
|
1087
|
-
const value = absTarget === home || absTarget.startsWith(home + path.sep) ? `~${absTarget.slice(home.length)}` : absTarget;
|
|
1088
|
-
const insideRepo = await git(manifest.dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
1089
|
-
if (insideRepo) {
|
|
1090
|
-
const ignored = await git(manifest.dir, ["check-ignore", "-q", manifest.localPath]) !== null;
|
|
1091
|
-
if (!ignored) {
|
|
1092
|
-
throw new Error(
|
|
1093
|
-
`${path.basename(manifest.localPath)} is not gitignored \u2014 refusing to write employer directory names into a file git would track. Add it to .gitignore, then re-run.`
|
|
1094
|
-
);
|
|
1095
|
-
}
|
|
1096
|
-
}
|
|
1097
|
-
const existing = await readText(manifest.localPath);
|
|
1098
|
-
let doc = {};
|
|
1099
|
-
if (existing) {
|
|
1100
|
-
try {
|
|
1101
|
-
doc = JSON.parse(existing);
|
|
1102
|
-
} catch {
|
|
1103
|
-
throw new Error(
|
|
1104
|
-
`${path.basename(manifest.localPath)} is not valid JSON \u2014 fix it before registering, so the mapping is not written over hand-maintained entries that cannot be re-derived.`
|
|
1105
|
-
);
|
|
1106
|
-
}
|
|
1107
|
-
}
|
|
1108
|
-
const dirs = doc.dirs ?? {};
|
|
1109
|
-
dirs[name] = value;
|
|
1110
|
-
doc.dirs = dirs;
|
|
1111
|
-
await promises.writeFile(manifest.localPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
1112
|
-
return value;
|
|
1113
|
-
}
|
|
1114
|
-
async function add(opts) {
|
|
1115
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
1116
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
1117
|
-
if (manifest.shape !== "registry") {
|
|
1118
|
-
throw new Error("`fleet add` targets the registry shape \u2014 this manifest is a legacy corpus");
|
|
1119
|
-
}
|
|
1120
|
-
const raw = await readText(manifest.manifestPath);
|
|
1121
|
-
if (raw === null) throw new Error(`cannot read ${manifest.manifestPath}`);
|
|
1122
|
-
const absTarget = path.resolve(opts.cwd, opts.target);
|
|
1123
|
-
if (!await pathExists(absTarget)) throw new Error(`no such directory: ${absTarget}`);
|
|
1124
|
-
const name = opts.name ?? path.basename(absTarget);
|
|
1125
|
-
if (manifest.entries.some((e) => e.name === name)) {
|
|
1126
|
-
throw new Error(`\`${name}\` is already registered \u2014 resolution is keyed by name`);
|
|
1127
|
-
}
|
|
1128
|
-
const facts = await scanProject(absTarget);
|
|
1129
|
-
const remote = facts.git.isRepo ? await git(absTarget, ["remote", "get-url", "origin"]) : null;
|
|
1130
|
-
section(`Fleet add ${theme.dim(`\xB7 ${name} \xB7 ${absTarget}`)}`);
|
|
1131
|
-
let profile = opts.profile;
|
|
1132
|
-
if (!profile && !opts.yes) {
|
|
1133
|
-
const picked = await select({
|
|
1134
|
-
message: "Which side of the wall does this belong to?",
|
|
1135
|
-
initialValue: "personal",
|
|
1136
|
-
options: [
|
|
1137
|
-
{ value: "personal", label: "personal \u2014 your own work" },
|
|
1138
|
-
{ value: "corp", label: "corp \u2014 employer work (alias-only, machine-pinned)" }
|
|
1139
|
-
]
|
|
1140
|
-
});
|
|
1141
|
-
if (isCancel(picked)) {
|
|
1142
|
-
cancel("No entry written.");
|
|
1143
|
-
return;
|
|
1144
|
-
}
|
|
1145
|
-
profile = picked;
|
|
1146
|
-
}
|
|
1147
|
-
profile = profile ?? "personal";
|
|
1148
|
-
if (profile !== "personal" && profile !== "corp") {
|
|
1149
|
-
throw new Error(`--profile must be personal or corp, got \`${profile}\``);
|
|
1150
|
-
}
|
|
1151
|
-
const derivedKind = facts.commands.build || facts.commands.test ? "tool" : "repo";
|
|
1152
|
-
let kind = opts.kind;
|
|
1153
|
-
if (!kind && !opts.yes) {
|
|
1154
|
-
const known = [...new Set(manifest.entries.map((e) => e.kind).filter(Boolean))];
|
|
1155
|
-
const choices = [.../* @__PURE__ */ new Set([derivedKind, ...known])];
|
|
1156
|
-
const picked = await select({
|
|
1157
|
-
message: "Kind?",
|
|
1158
|
-
initialValue: derivedKind,
|
|
1159
|
-
options: [
|
|
1160
|
-
...choices.map((k) => ({
|
|
1161
|
-
value: k,
|
|
1162
|
-
label: k === derivedKind ? `${k} \u2014 matches this repo's shape` : k
|
|
1163
|
-
})),
|
|
1164
|
-
{ value: "", label: "something else\u2026" }
|
|
1165
|
-
]
|
|
1166
|
-
});
|
|
1167
|
-
if (isCancel(picked)) {
|
|
1168
|
-
cancel("No entry written.");
|
|
1169
|
-
return;
|
|
1170
|
-
}
|
|
1171
|
-
kind = picked;
|
|
1172
|
-
if (!kind) {
|
|
1173
|
-
const typed = await text({
|
|
1174
|
-
message: "Kind (free text):",
|
|
1175
|
-
validate: (v) => v.trim() ? void 0 : "a kind is required"
|
|
1176
|
-
});
|
|
1177
|
-
if (isCancel(typed)) {
|
|
1178
|
-
cancel("No entry written.");
|
|
1179
|
-
return;
|
|
1180
|
-
}
|
|
1181
|
-
kind = typed.trim();
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
kind = kind ?? derivedKind;
|
|
1185
|
-
let trust;
|
|
1186
|
-
if (profile === "personal") {
|
|
1187
|
-
trust = opts.trust;
|
|
1188
|
-
if (!trust && !opts.yes) {
|
|
1189
|
-
const picked = await select({
|
|
1190
|
-
message: "How exposed is this repo's history? (gates content screening)",
|
|
1191
|
-
options: [
|
|
1192
|
-
{ value: "private", label: "private \u2014 not destined to be published" },
|
|
1193
|
-
{ value: "public-bound", label: "public-bound \u2014 private now, plausibly public later" },
|
|
1194
|
-
{ value: "public-repo", label: "public-repo \u2014 already public" }
|
|
1195
|
-
]
|
|
1196
|
-
});
|
|
1197
|
-
if (isCancel(picked)) {
|
|
1198
|
-
cancel("No entry written.");
|
|
1199
|
-
return;
|
|
1200
|
-
}
|
|
1201
|
-
trust = picked;
|
|
1202
|
-
}
|
|
1203
|
-
if (!trust) {
|
|
1204
|
-
throw new Error(
|
|
1205
|
-
`\`${name}\` needs a trust level: pass --trust <${FLEET_TRUST_VALUES.join("|")}> (non-interactive runs cannot be prompted, and there is deliberately no default \u2014 trust gates content screening)`
|
|
1206
|
-
);
|
|
1207
|
-
}
|
|
1208
|
-
if (!isFleetTrust(trust)) {
|
|
1209
|
-
throw new Error(`--trust must be one of ${FLEET_TRUST_VALUES.join(", ")}, got \`${trust}\``);
|
|
1210
|
-
}
|
|
1211
|
-
} else if (opts.trust) {
|
|
1212
|
-
throw new Error("corp entries take no `trust` \u2014 `profile: corp` already implies it");
|
|
1213
|
-
}
|
|
1214
|
-
const corpHost = remote ? manifest.corpHosts.find((h) => remote.includes(h)) : void 0;
|
|
1215
|
-
if (corpHost && profile !== "corp") {
|
|
1216
|
-
throw new Error(
|
|
1217
|
-
`\`${name}\` has a remote on the corp host \`${corpHost}\`, but --profile is \`${profile}\`. A personal entry records its path and raw remote in the TRACKED manifest \u2014 register it with \`--profile corp\` (alias-only, machine-pinned), or move it out of the fleet root.`
|
|
1218
|
-
);
|
|
1219
|
-
}
|
|
1220
|
-
const entry = profile === "corp" ? { name, kind, profile, private: true } : {
|
|
1221
|
-
name,
|
|
1222
|
-
kind,
|
|
1223
|
-
profile,
|
|
1224
|
-
path: path.relative(manifest.root ?? manifest.dir, absTarget),
|
|
1225
|
-
trust,
|
|
1226
|
-
contract: {},
|
|
1227
|
-
links: {}
|
|
1228
|
-
// The remote is deliberately NOT recorded. Nothing reads it — it was persisted only
|
|
1229
|
-
// because it happened to be derivable — and it is the field that turns a mis-profiled
|
|
1230
|
-
// entry into a disclosure: a raw URL carries the host AND the internal group path,
|
|
1231
|
-
// while `path` carries a bare directory name. Removing a field no consumer reads
|
|
1232
|
-
// retires that whole class without any host-matching guess, and keeps working on a
|
|
1233
|
-
// machine that has no local manifest for the corp-host guard above to read.
|
|
1234
|
-
// It stays derivable at any time from the checkout itself, which is where it came from.
|
|
1235
|
-
};
|
|
1236
|
-
print(` ${glyph.bullet} ${theme.dim("entry")}`);
|
|
1237
|
-
print(
|
|
1238
|
-
JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${theme.dim(l)}`).join("\n")
|
|
1239
|
-
);
|
|
1240
|
-
if (!opts.yes) {
|
|
1241
|
-
const ok = await confirm({
|
|
1242
|
-
message: `Write this entry to ${path.basename(manifest.manifestPath)}?`
|
|
1243
|
-
});
|
|
1244
|
-
if (isCancel(ok) || !ok) {
|
|
1245
|
-
cancel("No entry written.");
|
|
1246
|
-
return;
|
|
1247
|
-
}
|
|
1248
|
-
}
|
|
1249
|
-
let mappedTo;
|
|
1250
|
-
if (profile === "corp") {
|
|
1251
|
-
mappedTo = await recordCorpMapping(manifest, name, absTarget);
|
|
1252
|
-
}
|
|
1253
|
-
const doc = JSON.parse(raw);
|
|
1254
|
-
if (!Array.isArray(doc.projects)) throw new Error("manifest has no `projects` array to append to");
|
|
1255
|
-
doc.projects.push(entry);
|
|
1256
|
-
await promises.writeFile(manifest.manifestPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
1257
|
-
print(` ${glyph.ok} ${theme.dim("registered")} ${theme.info(name)}`);
|
|
1258
|
-
if (mappedTo) {
|
|
1259
|
-
print(
|
|
1260
|
-
` ${glyph.ok} ${theme.dim("mapped")} ${theme.info(`${name} \u2192 ${mappedTo}`)} ${theme.dim(`in ${path.basename(manifest.localPath)} (gitignored)`)}`
|
|
1261
|
-
);
|
|
1262
|
-
}
|
|
1263
|
-
print(` ${theme.dim(`verify with \`etymd fleet check --manifest ${manifest.manifestPath}\``)}`);
|
|
1264
|
-
}
|
|
1265
|
-
async function resolveFleetFinding(opts, status) {
|
|
1266
|
-
if (status === "dismissed" && !opts.reason?.trim()) {
|
|
1267
|
-
throw new Error('dismiss needs a reason: etymd fleet dismiss <name> <id> --reason "\u2026"');
|
|
1268
|
-
}
|
|
1269
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
1270
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
1271
|
-
const target = await fleetLedgerTarget(manifest, opts.name);
|
|
1272
|
-
const hadLedger = await pathExists(path.join(target.ledgerRoot, ".etymd"));
|
|
1273
|
-
const { ledgerRoot, recorded } = await ensureFindingRecorded(manifest, opts.name, opts.id);
|
|
1274
|
-
if (!recorded) {
|
|
1275
|
-
throw new Error(
|
|
1276
|
-
`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)`
|
|
1277
|
-
);
|
|
1278
|
-
}
|
|
1279
|
-
if (!hadLedger) {
|
|
1280
|
-
print(
|
|
1281
|
-
` ${theme.dim(`first ledger for \`${opts.name}\`: created ${path.join(ledgerRoot, ".etymd")} (a persisting audit ran to record the finding)`)}`
|
|
1282
|
-
);
|
|
1283
|
-
}
|
|
1284
|
-
const args = { cwd: ledgerRoot, id: opts.id, reason: opts.reason };
|
|
1285
|
-
await (status === "dismissed" ? dismiss(args) : accept(args));
|
|
1286
|
-
print(` ${theme.dim(`ledger: ${path.join(ledgerRoot, ".etymd", "ledger.json")}`)}`);
|
|
1287
|
-
}
|
|
1288
|
-
async function dismiss2(opts) {
|
|
1289
|
-
await resolveFleetFinding(opts, "dismissed");
|
|
1290
|
-
}
|
|
1291
|
-
async function accept2(opts) {
|
|
1292
|
-
await resolveFleetFinding(opts, "accepted");
|
|
1293
|
-
}
|
|
1294
|
-
|
|
1295
|
-
export { accept2 as accept, add, check, dismiss2 as dismiss, sweep };
|
|
1260
|
+
export { BOARD_JSON_SCHEMA, FLEET_JSON_SCHEMA, FLEET_TRUST_VALUES, MILESTONES_FILE, checkManifest, ensureFindingRecorded, fleetLedgerTarget, isFleetTrust, loadFleetManifest, parseInitiatives, parseMilestones, renderBoard, sweepFleet };
|