etymd 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -16
- package/README.md +121 -47
- package/dist/{approve-MA4Z3TBT.js → approve-VN4EDBR5.js} +2 -2
- package/dist/{audit-YSALDC2L.js → audit-PHAK5HEW.js} +5 -5
- package/dist/{brief-DPZYSAMC.js → brief-7MWP35UI.js} +2 -2
- package/dist/{chunk-Y6RZRED3.js → chunk-446EZNAQ.js} +1 -1
- package/dist/{chunk-CEO3BXQB.js → chunk-ANFNXFRI.js} +2 -2
- package/dist/{chunk-DBWDMIYO.js → chunk-BUMOGHQA.js} +1 -1
- package/dist/{chunk-IWG77WV3.js → chunk-FNA4R5KT.js} +31 -6
- package/dist/{fleet-4FYZ3GBK.js → chunk-LVR2DPR7.js} +438 -469
- package/dist/{chunk-UFNETE6P.js → chunk-PIJZUDDQ.js} +6 -2
- package/dist/{chunk-2RNQ6OLV.js → chunk-WKP7M2B3.js} +2 -2
- package/dist/cli.js +58 -23
- package/dist/{doctor-Y3DWDEBT.js → doctor-R4QDCAN5.js} +5 -5
- package/dist/fleet-WPYEVHVL.js +489 -0
- package/dist/{gates-IBQ7HCAN.js → gates-3HRGYCYY.js} +7 -4
- package/dist/{generate-KEX75XNG.js → generate-FSJQFQPV.js} +1 -1
- package/dist/index.d.ts +146 -20
- package/dist/index.js +497 -95
- package/dist/{init-H57H2JBE.js → init-6RULX5JZ.js} +3 -3
- package/dist/{premise-VMC3UUIB.js → premise-GFWHSR3K.js} +59 -24
- package/dist/propose-IYNOIWAO.js +252 -0
- package/dist/scan-5SC47FSM.js +5 -0
- package/dist/{scan-XBOHDXAH.js → scan-LLWW4YOU.js} +2 -2
- package/dist/{screen-E4FC7W5N.js → screen-S736DXQ5.js} +56 -24
- package/package.json +4 -4
- package/dist/scan-W4US23MU.js +0 -5
|
@@ -1,19 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { runAudit } from './chunk-2RNQ6OLV.js';
|
|
4
|
-
import { parseFailOnTier, meetsFailOn } from './chunk-IWG77WV3.js';
|
|
2
|
+
import { runAudit } from './chunk-WKP7M2B3.js';
|
|
5
3
|
import { readLedger } from './chunk-3E2IPCRY.js';
|
|
6
|
-
import { print, theme, section, renderFleetRows, renderFleetNotes, renderFindings, TIER_BADGE, glyph } from './chunk-HI7NWPRA.js';
|
|
7
|
-
import { scanProject } from './chunk-Y6RZRED3.js';
|
|
8
|
-
import './chunk-DBWDMIYO.js';
|
|
9
4
|
import { DEFAULT_CONFIG, ETYMD_DIR } from './chunk-P6ATKV2R.js';
|
|
10
|
-
import './chunk-
|
|
11
|
-
import { readText, pathExists, git, isDirectory } from './chunk-4VPBP6K6.js';
|
|
12
|
-
import { promises } from 'fs';
|
|
5
|
+
import { readText, isDirectory, git, pathExists } from './chunk-4VPBP6K6.js';
|
|
13
6
|
import os from 'os';
|
|
14
7
|
import path from 'path';
|
|
15
|
-
import { select, isCancel, cancel, text, confirm } from '@clack/prompts';
|
|
16
8
|
import { execFile } from 'child_process';
|
|
9
|
+
import { promises } from 'fs';
|
|
17
10
|
import { promisify } from 'util';
|
|
18
11
|
|
|
19
12
|
var FLEET_TRUST_VALUES = ["public-repo", "public-bound", "private"];
|
|
@@ -25,14 +18,14 @@ function expandTilde(p) {
|
|
|
25
18
|
if (p.startsWith("~/")) return path.join(os.homedir(), p.slice(2));
|
|
26
19
|
return p;
|
|
27
20
|
}
|
|
28
|
-
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"}}';
|
|
29
22
|
var SAFE_NAME = /^[^\s/\\:.][^\s/\\:]*$/;
|
|
30
23
|
function rejectUnsafeName(name, where, manifest, manifestFile) {
|
|
31
24
|
if (SAFE_NAME.test(name)) return false;
|
|
32
25
|
manifest.problems.push({
|
|
33
26
|
kind: "bad-shape",
|
|
34
27
|
file: manifestFile,
|
|
35
|
-
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)`
|
|
36
29
|
});
|
|
37
30
|
return true;
|
|
38
31
|
}
|
|
@@ -73,7 +66,7 @@ async function readLocal(localPath, problems) {
|
|
|
73
66
|
}
|
|
74
67
|
}
|
|
75
68
|
function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
76
|
-
const
|
|
69
|
+
const guardedNames = [];
|
|
77
70
|
for (const [i, rawEntry] of projects.entries()) {
|
|
78
71
|
const rec = asRecord(rawEntry);
|
|
79
72
|
const name = rec ? asString(rec.name) : void 0;
|
|
@@ -86,8 +79,8 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
86
79
|
continue;
|
|
87
80
|
}
|
|
88
81
|
if (rejectUnsafeName(name, `projects[${i}]`, manifest, manifestFile)) continue;
|
|
89
|
-
const profile = rec.profile === "
|
|
90
|
-
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") {
|
|
91
84
|
manifest.problems.push({
|
|
92
85
|
kind: "bad-shape",
|
|
93
86
|
file: manifestFile,
|
|
@@ -125,12 +118,12 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
125
118
|
contract,
|
|
126
119
|
links: asStringMap(rec.links)
|
|
127
120
|
};
|
|
128
|
-
if (entry.profile === "
|
|
129
|
-
|
|
121
|
+
if (entry.profile === "guarded" || entry.private) {
|
|
122
|
+
guardedNames.push(name);
|
|
130
123
|
if (!manifest.localPresent) {
|
|
131
124
|
entry.unresolved = `registry.local.json is absent \u2014 ${REBUILD_RECIPE}`;
|
|
132
125
|
} else if (manifest.machineProfile === "personal") {
|
|
133
|
-
entry.unresolved = '
|
|
126
|
+
entry.unresolved = 'guarded entries do not resolve under machineProfile "personal" \u2014 absent on this machine by design';
|
|
134
127
|
} else {
|
|
135
128
|
const dir = manifest.localDirs[name];
|
|
136
129
|
if (!dir) {
|
|
@@ -146,11 +139,11 @@ function loadRegistryEntries(projects, manifest, manifestFile) {
|
|
|
146
139
|
}
|
|
147
140
|
manifest.entries.push(entry);
|
|
148
141
|
}
|
|
149
|
-
if (
|
|
142
|
+
if (guardedNames.length && !manifest.localPresent) {
|
|
150
143
|
manifest.problems.push({
|
|
151
144
|
kind: "local-missing",
|
|
152
145
|
file: path.basename(manifest.localPath),
|
|
153
|
-
detail: `${
|
|
146
|
+
detail: `${guardedNames.length} guarded/private entr${guardedNames.length === 1 ? "y" : "ies"} (${guardedNames.join(", ")}) cannot resolve without it \u2014 ${REBUILD_RECIPE}`
|
|
154
147
|
});
|
|
155
148
|
}
|
|
156
149
|
}
|
|
@@ -199,7 +192,7 @@ async function loadFleetManifest(manifestPath) {
|
|
|
199
192
|
shape: "registry",
|
|
200
193
|
manifestPath: abs,
|
|
201
194
|
dir,
|
|
202
|
-
|
|
195
|
+
guardedHosts: [],
|
|
203
196
|
labels: {},
|
|
204
197
|
localDirs: {},
|
|
205
198
|
localPath: path.join(dir, "registry.local.json"),
|
|
@@ -232,8 +225,8 @@ async function loadFleetManifest(manifestPath) {
|
|
|
232
225
|
manifest.localPresent = local.present;
|
|
233
226
|
manifest.localDirs = asStringMap(local.data.dirs);
|
|
234
227
|
manifest.labels = asStringMap(local.data.labels);
|
|
235
|
-
manifest.
|
|
236
|
-
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;
|
|
237
230
|
const rootRaw = asString(local.data.root) ?? asString(parsed.root);
|
|
238
231
|
if (rootRaw) manifest.root = path.resolve(dir, expandTilde(rootRaw));
|
|
239
232
|
const orientationRec = asRecord(parsed.orientation);
|
|
@@ -271,10 +264,334 @@ async function loadFleetManifest(manifestPath) {
|
|
|
271
264
|
}
|
|
272
265
|
return manifest;
|
|
273
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
|
+
}
|
|
274
591
|
var pExecFile = promisify(execFile);
|
|
275
592
|
var FLEET_LENS = "fleet-manifest";
|
|
276
593
|
var FLEET_JSON_SCHEMA = "fleet-experimental-0.2";
|
|
277
|
-
var
|
|
594
|
+
var GUARDED_WALL_ARTIFACTS = ["PROJECT_CONTEXT.md", "DECISIONS.md"];
|
|
278
595
|
function recurringClasses(projects) {
|
|
279
596
|
const byClass = /* @__PURE__ */ new Map();
|
|
280
597
|
for (const p of projects) {
|
|
@@ -307,12 +624,12 @@ function finding(id, tier, claim, evidence, why, action) {
|
|
|
307
624
|
confidence: "high"
|
|
308
625
|
};
|
|
309
626
|
}
|
|
310
|
-
function
|
|
311
|
-
const
|
|
312
|
-
const root = path.resolve(
|
|
313
|
-
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) {
|
|
314
631
|
throw new Error(
|
|
315
|
-
`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`
|
|
316
633
|
);
|
|
317
634
|
}
|
|
318
635
|
return root;
|
|
@@ -345,7 +662,7 @@ async function realpathOr(p) {
|
|
|
345
662
|
return path.resolve(p);
|
|
346
663
|
}
|
|
347
664
|
}
|
|
348
|
-
function
|
|
665
|
+
function emailMatchesGuardedHosts(email, hosts) {
|
|
349
666
|
const domain = email.split("@")[1]?.toLowerCase();
|
|
350
667
|
if (!domain) return false;
|
|
351
668
|
return hosts.some((h) => {
|
|
@@ -396,7 +713,7 @@ async function checkManifest(manifest) {
|
|
|
396
713
|
);
|
|
397
714
|
}
|
|
398
715
|
seen.add(entry.name);
|
|
399
|
-
if (entry.profile !== "
|
|
716
|
+
if (entry.profile !== "guarded" && !entry.private && !entry.trust) {
|
|
400
717
|
const bad = entry.trustRaw;
|
|
401
718
|
findings.push(
|
|
402
719
|
finding(
|
|
@@ -434,14 +751,14 @@ async function checkManifest(manifest) {
|
|
|
434
751
|
)
|
|
435
752
|
);
|
|
436
753
|
}
|
|
437
|
-
if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "
|
|
754
|
+
if (entry.unresolved && manifest.localPresent && manifest.machineProfile !== "personal" && (entry.profile === "guarded" || entry.private)) {
|
|
438
755
|
findings.push(
|
|
439
756
|
finding(
|
|
440
757
|
`${FLEET_LENS}/dangling-dir:${entry.name}`,
|
|
441
758
|
"gap",
|
|
442
759
|
`\`${entry.name}\` has no usable directory mapping`,
|
|
443
760
|
[`${localFile}: ${entry.unresolved}`],
|
|
444
|
-
"An unmapped
|
|
761
|
+
"An unmapped guarded entry silently drops out of every sweep on the machine that most needs it.",
|
|
445
762
|
`Add a \`dirs\` mapping for \`${entry.name}\` in ${localFile}.`
|
|
446
763
|
)
|
|
447
764
|
);
|
|
@@ -505,28 +822,28 @@ async function checkManifest(manifest) {
|
|
|
505
822
|
);
|
|
506
823
|
}
|
|
507
824
|
if (manifest.machineProfile === "personal") {
|
|
508
|
-
const
|
|
509
|
-
if (
|
|
825
|
+
const guardedCount = manifest.entries.filter((e) => e.profile === "guarded" || e.private).length;
|
|
826
|
+
if (guardedCount) {
|
|
510
827
|
disclosures.push(
|
|
511
|
-
`machineProfile "personal": ${
|
|
828
|
+
`machineProfile "personal": ${guardedCount} guarded entr${guardedCount === 1 ? "y" : "ies"} deliberately absent on this machine \u2014 disclosed, not dangling.`
|
|
512
829
|
);
|
|
513
830
|
}
|
|
514
831
|
}
|
|
515
832
|
return { findings, disclosures };
|
|
516
833
|
}
|
|
517
|
-
async function
|
|
834
|
+
async function checkGuardedWallArtifacts(entries, findings) {
|
|
518
835
|
for (const entry of entries) {
|
|
519
|
-
if (entry.profile !== "
|
|
520
|
-
for (const artifact of
|
|
836
|
+
if (entry.profile !== "guarded" || !entry.resolvedRoot) continue;
|
|
837
|
+
for (const artifact of GUARDED_WALL_ARTIFACTS) {
|
|
521
838
|
if (await pathExists(path.join(entry.resolvedRoot, artifact))) {
|
|
522
839
|
findings.push(
|
|
523
840
|
finding(
|
|
524
|
-
`${FLEET_LENS}/
|
|
841
|
+
`${FLEET_LENS}/guarded-artifact-in-repo:${entry.name}:${artifact}`,
|
|
525
842
|
"risk",
|
|
526
|
-
`
|
|
843
|
+
`guarded entry \`${entry.name}\` carries ${artifact} inside its worktree`,
|
|
527
844
|
[`${entry.resolvedRoot}/${artifact}`],
|
|
528
|
-
"Agent artifacts for
|
|
529
|
-
`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.`
|
|
530
847
|
)
|
|
531
848
|
);
|
|
532
849
|
}
|
|
@@ -534,9 +851,9 @@ async function checkCorpWallArtifacts(entries, findings) {
|
|
|
534
851
|
}
|
|
535
852
|
}
|
|
536
853
|
async function checkCoverage(manifest, findings, disclosures) {
|
|
537
|
-
if (!manifest.
|
|
854
|
+
if (!manifest.guardedHosts.length) {
|
|
538
855
|
disclosures.push(
|
|
539
|
-
"Coverage check skipped \u2014 no
|
|
856
|
+
"Coverage check skipped \u2014 no guardedHosts in the local manifest, so a guarded remote cannot be recognized."
|
|
540
857
|
);
|
|
541
858
|
return;
|
|
542
859
|
}
|
|
@@ -557,16 +874,16 @@ async function checkCoverage(manifest, findings, disclosures) {
|
|
|
557
874
|
if (!await pathExists(path.join(dir, ".git"))) continue;
|
|
558
875
|
const remotes = await git(dir, ["config", "--get-regexp", "^remote\\..*\\.url"]);
|
|
559
876
|
if (!remotes) continue;
|
|
560
|
-
const
|
|
561
|
-
if (
|
|
877
|
+
const guardedRemote = manifest.guardedHosts.find((h) => remotes.includes(h));
|
|
878
|
+
if (guardedRemote) {
|
|
562
879
|
findings.push(
|
|
563
880
|
finding(
|
|
564
|
-
`${FLEET_LENS}/unregistered-
|
|
881
|
+
`${FLEET_LENS}/unregistered-guarded-remote:${d.name}`,
|
|
565
882
|
"risk",
|
|
566
|
-
`\`${d.name}\` under the fleet root has a
|
|
567
|
-
[`${dir}: remote matches ${
|
|
568
|
-
"The wall is only as good as its census \u2014 an unregistered
|
|
569
|
-
"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."
|
|
570
887
|
)
|
|
571
888
|
);
|
|
572
889
|
}
|
|
@@ -614,7 +931,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
614
931
|
[
|
|
615
932
|
...Object.values(manifest.labels),
|
|
616
933
|
...Object.values(manifest.localDirs).map((d) => path.basename(expandNeedleDir(d))),
|
|
617
|
-
...manifest.
|
|
934
|
+
...manifest.guardedHosts
|
|
618
935
|
].filter((n) => n.length >= 3)
|
|
619
936
|
)
|
|
620
937
|
];
|
|
@@ -641,7 +958,7 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
641
958
|
"risk",
|
|
642
959
|
`${file} in public-repo \`${entry.name}\` carries a needle from the fleet's private local manifest`,
|
|
643
960
|
[`${file}: contains "${needle}"`],
|
|
644
|
-
"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.",
|
|
645
962
|
"Scrub the needle from the tracked file (rewrite history if it already shipped)."
|
|
646
963
|
)
|
|
647
964
|
);
|
|
@@ -652,10 +969,10 @@ async function checkHygieneNeedles(manifest, findings, disclosures) {
|
|
|
652
969
|
function expandNeedleDir(dir) {
|
|
653
970
|
return dir.replace(/\/+$/, "");
|
|
654
971
|
}
|
|
655
|
-
async function
|
|
656
|
-
if (!manifest.
|
|
972
|
+
async function checkGuardedEmails(manifest, findings, disclosures) {
|
|
973
|
+
if (!manifest.guardedHosts.length) {
|
|
657
974
|
disclosures.push(
|
|
658
|
-
"
|
|
975
|
+
"Guarded-email check skipped \u2014 no guardedHosts in the local manifest, so a guarded domain cannot be recognized."
|
|
659
976
|
);
|
|
660
977
|
return;
|
|
661
978
|
}
|
|
@@ -664,21 +981,23 @@ async function checkCorpEmails(manifest, findings, disclosures) {
|
|
|
664
981
|
const emails = await git(entry.resolvedRoot, ["log", "-30", "--format=%ae"]);
|
|
665
982
|
if (emails === null) {
|
|
666
983
|
disclosures.push(
|
|
667
|
-
`
|
|
984
|
+
`Guarded-email check could not run in \`${entry.name}\` (no readable commit history) \u2014 undetermined, not clean.`
|
|
668
985
|
);
|
|
669
986
|
continue;
|
|
670
987
|
}
|
|
671
988
|
const lines = emails.split("\n").filter(Boolean);
|
|
672
|
-
const matched = [
|
|
989
|
+
const matched = [
|
|
990
|
+
...new Set(lines.filter((e) => emailMatchesGuardedHosts(e, manifest.guardedHosts)))
|
|
991
|
+
];
|
|
673
992
|
if (matched.length) {
|
|
674
993
|
const count = lines.filter((e) => matched.includes(e)).length;
|
|
675
994
|
findings.push(
|
|
676
995
|
finding(
|
|
677
|
-
`${FLEET_LENS}/
|
|
996
|
+
`${FLEET_LENS}/guarded-email:${entry.name}`,
|
|
678
997
|
"risk",
|
|
679
|
-
`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`,
|
|
680
999
|
[`${count} of the last ${lines.length} commits: ${matched.join(", ")}`],
|
|
681
|
-
"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.",
|
|
682
1001
|
"Set the repo-local identity to the personal one and amend the unpushed commits."
|
|
683
1002
|
)
|
|
684
1003
|
);
|
|
@@ -686,18 +1005,18 @@ async function checkCorpEmails(manifest, findings, disclosures) {
|
|
|
686
1005
|
}
|
|
687
1006
|
}
|
|
688
1007
|
async function checkGateDrift(manifest, findings, disclosures) {
|
|
689
|
-
const { planWorkflow } = await import('./generate-
|
|
690
|
-
const { scanProject
|
|
1008
|
+
const { planWorkflow } = await import('./generate-FSJQFQPV.js');
|
|
1009
|
+
const { scanProject } = await import('./scan-5SC47FSM.js');
|
|
691
1010
|
const { readConfig } = await import('./config-724Y3IOB.js');
|
|
692
1011
|
for (const entry of manifest.entries) {
|
|
693
1012
|
const root = entry.resolvedRoot;
|
|
694
1013
|
if (!root || !await isDirectory(root)) continue;
|
|
695
|
-
if (entry.profile === "
|
|
1014
|
+
if (entry.profile === "guarded" || entry.private) continue;
|
|
696
1015
|
if (entry.gates === "none") {
|
|
697
1016
|
disclosures.push(`${entry.name}: gates declared \`none\` \u2014 deliberately absent, not missing`);
|
|
698
1017
|
continue;
|
|
699
1018
|
}
|
|
700
|
-
const facts = await
|
|
1019
|
+
const facts = await scanProject(root);
|
|
701
1020
|
if (!facts.git.isRepo) continue;
|
|
702
1021
|
if (facts.hooks.source === "husky" || facts.hooks.source === "husky-legacy") {
|
|
703
1022
|
disclosures.push(
|
|
@@ -705,8 +1024,13 @@ async function checkGateDrift(manifest, findings, disclosures) {
|
|
|
705
1024
|
);
|
|
706
1025
|
continue;
|
|
707
1026
|
}
|
|
708
|
-
const { config } = await readConfig(root);
|
|
709
|
-
const planned = (await planWorkflow(root, facts, {
|
|
1027
|
+
const { config, explicit } = await readConfig(root);
|
|
1028
|
+
const planned = (await planWorkflow(root, facts, {
|
|
1029
|
+
agents: false,
|
|
1030
|
+
gates: true,
|
|
1031
|
+
gateConfig: config.gates,
|
|
1032
|
+
gateFailOnPinned: explicit.gatesFailOn
|
|
1033
|
+
})).filter((f) => f.executable);
|
|
710
1034
|
const missing = planned.filter((f) => !f.exists).map((f) => f.path);
|
|
711
1035
|
const differing = planned.filter((f) => f.exists && f.differs);
|
|
712
1036
|
const stale = differing.filter((f) => f.drift !== "edited").map((f) => f.path);
|
|
@@ -745,11 +1069,11 @@ async function checkGateDrift(manifest, findings, disclosures) {
|
|
|
745
1069
|
async function collectWallFindings(manifest) {
|
|
746
1070
|
const findings = [];
|
|
747
1071
|
const disclosures = [];
|
|
748
|
-
await
|
|
1072
|
+
await checkGuardedWallArtifacts(manifest.entries, findings);
|
|
749
1073
|
await checkCoverage(manifest, findings, disclosures);
|
|
750
1074
|
await checkManifestRepoMachinePaths(manifest, findings, disclosures);
|
|
751
1075
|
await checkHygieneNeedles(manifest, findings, disclosures);
|
|
752
|
-
await
|
|
1076
|
+
await checkGuardedEmails(manifest, findings, disclosures);
|
|
753
1077
|
await checkGateDrift(manifest, findings, disclosures);
|
|
754
1078
|
return { findings, disclosures };
|
|
755
1079
|
}
|
|
@@ -815,13 +1139,13 @@ async function sweepEntry(manifest, entry, opts) {
|
|
|
815
1139
|
);
|
|
816
1140
|
}
|
|
817
1141
|
}
|
|
818
|
-
const
|
|
819
|
-
const optedIn = !
|
|
1142
|
+
const guarded = entry.profile === "guarded";
|
|
1143
|
+
const optedIn = !guarded && await pathExists(path.join(root, ETYMD_DIR));
|
|
820
1144
|
const result = await runAudit(root, {
|
|
821
1145
|
kind: opts.kind,
|
|
822
|
-
persistLedger: !
|
|
823
|
-
readOnlyRoot:
|
|
824
|
-
ledgerRoot:
|
|
1146
|
+
persistLedger: !guarded && opts.persistLedgers === true && optedIn,
|
|
1147
|
+
readOnlyRoot: guarded,
|
|
1148
|
+
ledgerRoot: guarded ? guardedPersistenceRoot(manifest, entry.name) : void 0,
|
|
825
1149
|
stateBudgets: stateBudgetsFor(entry),
|
|
826
1150
|
upstreamRemote
|
|
827
1151
|
});
|
|
@@ -829,11 +1153,43 @@ async function sweepEntry(manifest, entry, opts) {
|
|
|
829
1153
|
if (entry.contract.placement === "none") {
|
|
830
1154
|
project.findings = project.findings.filter((f) => !f.id.endsWith("/no-contract"));
|
|
831
1155
|
}
|
|
1156
|
+
const milestonesFile = entry.contract.milestones;
|
|
1157
|
+
if (milestonesFile && milestonesFile !== "none") {
|
|
1158
|
+
const text = await readText(path.join(root, milestonesFile));
|
|
1159
|
+
if (text === null) {
|
|
1160
|
+
project.findings.push(
|
|
1161
|
+
finding(
|
|
1162
|
+
`${FLEET_LENS}/milestones-missing:${entry.name}`,
|
|
1163
|
+
"gap",
|
|
1164
|
+
`\`${entry.name}\` declares milestones at \`${milestonesFile}\` and the file is absent`,
|
|
1165
|
+
[
|
|
1166
|
+
`${path.basename(manifest.manifestPath)}: ${entry.name}.contract.milestones = ${milestonesFile}`
|
|
1167
|
+
],
|
|
1168
|
+
"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.",
|
|
1169
|
+
'Create it (heading `# Milestones`, then the standard table), or declare `milestones: "none"` if this project deliberately carries no plan.'
|
|
1170
|
+
)
|
|
1171
|
+
);
|
|
1172
|
+
} else {
|
|
1173
|
+
const doc = parseMilestones(text);
|
|
1174
|
+
doc.problems.forEach((problem, i) => {
|
|
1175
|
+
project.findings.push(
|
|
1176
|
+
finding(
|
|
1177
|
+
`${FLEET_LENS}/milestones-shape:${entry.name}:${i + 1}`,
|
|
1178
|
+
"gap",
|
|
1179
|
+
`\`${entry.name}\` ${milestonesFile}: ${problem}`,
|
|
1180
|
+
[`${milestonesFile}`],
|
|
1181
|
+
"A milestones file the board cannot parse is a plan the fleet cannot see.",
|
|
1182
|
+
`Fix the table to the standard shape: \`| ${MILESTONE_COLUMNS.join(" | ")} |\`.`
|
|
1183
|
+
)
|
|
1184
|
+
);
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
832
1188
|
for (const f of project.findings) project.counts[f.tier] += 1;
|
|
833
1189
|
project.stateAgeDays = stateAgeDays(result.facts);
|
|
834
1190
|
project.staleAfterDays = result.config.config.state.staleAfterDays;
|
|
835
1191
|
for (const [key, value] of Object.entries(entry.contract)) {
|
|
836
|
-
if (key === "placement" || !value) continue;
|
|
1192
|
+
if (key === "placement" || key === "milestones" || !value) continue;
|
|
837
1193
|
const exists = await pathExists(path.join(root, value.replace(/#.*$/, "")));
|
|
838
1194
|
project.disclosures.push(
|
|
839
1195
|
`contract override \`${key}: ${value}\` \u2014 registered, existence-checked only (${exists ? "present" : "MISSING"})`
|
|
@@ -885,12 +1241,12 @@ async function fleetLedgerTarget(manifest, name) {
|
|
|
885
1241
|
if (!entry.resolvedRoot) {
|
|
886
1242
|
throw new Error(`\`${name}\` does not resolve on this machine: ${entry.unresolved}`);
|
|
887
1243
|
}
|
|
888
|
-
const
|
|
1244
|
+
const guarded = entry.profile === "guarded";
|
|
889
1245
|
return {
|
|
890
1246
|
entry,
|
|
891
1247
|
auditRoot: entry.resolvedRoot,
|
|
892
|
-
ledgerRoot:
|
|
893
|
-
|
|
1248
|
+
ledgerRoot: guarded ? guardedPersistenceRoot(manifest, name) : entry.resolvedRoot,
|
|
1249
|
+
guarded
|
|
894
1250
|
};
|
|
895
1251
|
}
|
|
896
1252
|
async function ensureFindingRecorded(manifest, name, id) {
|
|
@@ -899,398 +1255,11 @@ async function ensureFindingRecorded(manifest, name, id) {
|
|
|
899
1255
|
if (await has()) return { ledgerRoot: target.ledgerRoot, recorded: true };
|
|
900
1256
|
await runAudit(target.auditRoot, {
|
|
901
1257
|
persistLedger: true,
|
|
902
|
-
readOnlyRoot: target.
|
|
903
|
-
ledgerRoot: target.
|
|
1258
|
+
readOnlyRoot: target.guarded,
|
|
1259
|
+
ledgerRoot: target.guarded ? target.ledgerRoot : void 0,
|
|
904
1260
|
stateBudgets: stateBudgetsFor(target.entry)
|
|
905
1261
|
});
|
|
906
1262
|
return { ledgerRoot: target.ledgerRoot, recorded: await has() };
|
|
907
1263
|
}
|
|
908
1264
|
|
|
909
|
-
|
|
910
|
-
var LAST_SWEEP_FILE = "last.fleet.json";
|
|
911
|
-
async function loadManifest(cwd, manifestOpt) {
|
|
912
|
-
if (manifestOpt) {
|
|
913
|
-
return { manifest: await loadFleetManifest(path.resolve(cwd, manifestOpt)) };
|
|
914
|
-
}
|
|
915
|
-
const candidate = path.join(cwd, "registry.json");
|
|
916
|
-
if (await pathExists(candidate)) {
|
|
917
|
-
return {
|
|
918
|
-
manifest: await loadFleetManifest(candidate),
|
|
919
|
-
autoNote: `manifest auto-selected from the cwd: ${candidate} (no --manifest given)`
|
|
920
|
-
};
|
|
921
|
-
}
|
|
922
|
-
throw new Error(
|
|
923
|
-
"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)"
|
|
924
|
-
);
|
|
925
|
-
}
|
|
926
|
-
async function readLastSweep(lastPath) {
|
|
927
|
-
const raw = await readText(lastPath);
|
|
928
|
-
if (raw === null) return { record: null };
|
|
929
|
-
let parsed;
|
|
930
|
-
try {
|
|
931
|
-
parsed = JSON.parse(raw);
|
|
932
|
-
} catch {
|
|
933
|
-
return {
|
|
934
|
-
record: null,
|
|
935
|
-
note: `${LAST_SWEEP_FILE} exists but is not valid JSON \u2014 delta baseline reset (every finding reads as new this sweep).`
|
|
936
|
-
};
|
|
937
|
-
}
|
|
938
|
-
const rec = parsed;
|
|
939
|
-
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)) {
|
|
940
|
-
return {
|
|
941
|
-
record: null,
|
|
942
|
-
note: `${LAST_SWEEP_FILE} carries a foreign schema \u2014 delta baseline reset (every finding reads as new this sweep).`
|
|
943
|
-
};
|
|
944
|
-
}
|
|
945
|
-
return { record: rec };
|
|
946
|
-
}
|
|
947
|
-
function failOnExit(failOn, findings) {
|
|
948
|
-
if (failOn === void 0) return;
|
|
949
|
-
if (meetsFailOn(findings, parseFailOnTier(failOn))) process.exitCode = 1;
|
|
950
|
-
}
|
|
951
|
-
async function sweep(opts) {
|
|
952
|
-
if (opts.profile && opts.profile !== "personal" && opts.profile !== "corp") {
|
|
953
|
-
throw new Error(`--profile must be personal or corp, got \`${opts.profile}\``);
|
|
954
|
-
}
|
|
955
|
-
if (opts.failOn !== void 0) parseFailOnTier(opts.failOn);
|
|
956
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
957
|
-
const result = await sweepFleet(manifest, {
|
|
958
|
-
only: opts.only,
|
|
959
|
-
profile: opts.profile,
|
|
960
|
-
kind: opts.truth ? "truth" : void 0,
|
|
961
|
-
persistLedgers: opts.persistLedgers
|
|
962
|
-
});
|
|
963
|
-
const lastPath = path.join(manifest.dir, LAST_SWEEP_FILE);
|
|
964
|
-
const { record: previous, note: baselineNote } = await readLastSweep(lastPath);
|
|
965
|
-
const previousIds = (name) => new Set((previous?.projects[name] ?? []).map((e) => e.id));
|
|
966
|
-
const previousWallIds = new Set((previous?.wall ?? []).map((e) => e.id));
|
|
967
|
-
const allFindings = [...result.projects.flatMap((p) => p.findings), ...result.wall];
|
|
968
|
-
failOnExit(opts.failOn, allFindings);
|
|
969
|
-
const fullSweep = !opts.only && !opts.profile && !opts.truth;
|
|
970
|
-
const delta = {};
|
|
971
|
-
for (const p of result.projects) {
|
|
972
|
-
const prev = previousIds(p.name);
|
|
973
|
-
const current = new Set(p.findings.map((f) => f.id));
|
|
974
|
-
delta[p.name] = {
|
|
975
|
-
new: [...current].filter((id) => !prev.has(id)),
|
|
976
|
-
resolved: [...prev].filter((id) => !current.has(id))
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
const wallNew = result.wall.filter((f) => !previousWallIds.has(f.id)).map((f) => f.id);
|
|
980
|
-
if (opts.json) {
|
|
981
|
-
print(
|
|
982
|
-
JSON.stringify(
|
|
983
|
-
{
|
|
984
|
-
...result,
|
|
985
|
-
delta: previous ? delta : null,
|
|
986
|
-
...baselineNote ? { deltaBaselineNote: baselineNote } : {}
|
|
987
|
-
},
|
|
988
|
-
null,
|
|
989
|
-
2
|
|
990
|
-
)
|
|
991
|
-
);
|
|
992
|
-
} else {
|
|
993
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
994
|
-
if (baselineNote) print(` ${theme.dim(`\u25E6 ${baselineNote}`)}`);
|
|
995
|
-
renderSweep(result, previous, delta, wallNew);
|
|
996
|
-
}
|
|
997
|
-
if (fullSweep) {
|
|
998
|
-
const record = {
|
|
999
|
-
schema: FLEET_JSON_SCHEMA,
|
|
1000
|
-
sweptAt: result.sweptAt,
|
|
1001
|
-
projects: Object.fromEntries(
|
|
1002
|
-
result.projects.map((p) => [p.name, p.findings.map((f) => ({ id: f.id, tier: f.tier }))])
|
|
1003
|
-
),
|
|
1004
|
-
wall: result.wall.map((f) => ({ id: f.id, tier: f.tier }))
|
|
1005
|
-
};
|
|
1006
|
-
const tmpPath = `${lastPath}.tmp`;
|
|
1007
|
-
await promises.writeFile(tmpPath, JSON.stringify(record, null, 2) + "\n", "utf8");
|
|
1008
|
-
await promises.rename(tmpPath, lastPath);
|
|
1009
|
-
} else if (!opts.json) {
|
|
1010
|
-
print();
|
|
1011
|
-
print(` ${theme.dim("partial sweep \u2014 the delta baseline was not updated")}`);
|
|
1012
|
-
}
|
|
1013
|
-
}
|
|
1014
|
-
function renderSweep(result, previous, delta, wallNew) {
|
|
1015
|
-
section(
|
|
1016
|
-
`Fleet sweep ${theme.dim(`\xB7 ${path.basename(result.manifest)} \xB7 ${result.projects.length} project(s) \xB7 schema ${result.schema}`)}`
|
|
1017
|
-
);
|
|
1018
|
-
const rows = result.projects.map((p) => {
|
|
1019
|
-
const d = delta[p.name] ?? { new: [], resolved: [] };
|
|
1020
|
-
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";
|
|
1021
|
-
return {
|
|
1022
|
-
name: p.name,
|
|
1023
|
-
age: p.stateAgeDays === null ? "\u2014" : `${p.stateAgeDays}/${p.staleAfterDays}d`,
|
|
1024
|
-
counts: p.counts,
|
|
1025
|
-
delta: deltaStr,
|
|
1026
|
-
note: p.unresolved
|
|
1027
|
-
};
|
|
1028
|
-
});
|
|
1029
|
-
renderFleetRows(rows);
|
|
1030
|
-
renderFleetNotes(
|
|
1031
|
-
result.problems,
|
|
1032
|
-
result.projects.flatMap((p) => p.disclosures.map((d) => `${p.name}: ${d}`))
|
|
1033
|
-
);
|
|
1034
|
-
const detail = [
|
|
1035
|
-
...result.projects.flatMap(
|
|
1036
|
-
(p) => p.findings.filter((f) => f.tier === "risk" || (delta[p.name]?.new ?? []).includes(f.id))
|
|
1037
|
-
),
|
|
1038
|
-
...result.wall.filter((f) => f.tier === "risk" || wallNew.includes(f.id))
|
|
1039
|
-
];
|
|
1040
|
-
if (detail.length) {
|
|
1041
|
-
section(`New or risk findings ${theme.dim(`(${detail.length})`)}`);
|
|
1042
|
-
renderFindings(detail);
|
|
1043
|
-
}
|
|
1044
|
-
if (result.recurringClasses.length) {
|
|
1045
|
-
section(`Recurring classes ${theme.dim("(open in \u22652 projects \u2014 class-fix candidates)")}`);
|
|
1046
|
-
for (const rc of result.recurringClasses) {
|
|
1047
|
-
print(
|
|
1048
|
-
` ${TIER_BADGE[rc.tier]} ${rc.classId} ${theme.dim(`\u2014 ${rc.projects.length}\xD7: ${rc.projects.join(", ")}`)}`
|
|
1049
|
-
);
|
|
1050
|
-
}
|
|
1051
|
-
}
|
|
1052
|
-
section("Fleet wall");
|
|
1053
|
-
if (!result.wall.length) print(` ${glyph.ok} ${theme.dim("clean")}`);
|
|
1054
|
-
else
|
|
1055
|
-
print(
|
|
1056
|
-
` ${theme.warn(`${result.wall.length} finding(s)`)} ${theme.dim("(detailed above if new or risk)")}`
|
|
1057
|
-
);
|
|
1058
|
-
renderFleetNotes([], result.wallDisclosures);
|
|
1059
|
-
if (result.outOfScope.length) {
|
|
1060
|
-
print(
|
|
1061
|
-
` ${theme.dim(`out of scope (not audited, named above): ${result.outOfScope.join(", ")}`)}`
|
|
1062
|
-
);
|
|
1063
|
-
}
|
|
1064
|
-
}
|
|
1065
|
-
async function check(opts) {
|
|
1066
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
1067
|
-
const { findings, disclosures } = await checkManifest(manifest);
|
|
1068
|
-
if (findings.length) process.exitCode = 1;
|
|
1069
|
-
if (opts.json) {
|
|
1070
|
-
print(
|
|
1071
|
-
JSON.stringify(
|
|
1072
|
-
{ schema: FLEET_JSON_SCHEMA, manifest: manifest.manifestPath, findings, disclosures },
|
|
1073
|
-
null,
|
|
1074
|
-
2
|
|
1075
|
-
)
|
|
1076
|
-
);
|
|
1077
|
-
return;
|
|
1078
|
-
}
|
|
1079
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
1080
|
-
section(
|
|
1081
|
-
`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`)}`
|
|
1082
|
-
);
|
|
1083
|
-
renderFindings(findings);
|
|
1084
|
-
renderFleetNotes([], disclosures);
|
|
1085
|
-
}
|
|
1086
|
-
async function recordCorpMapping(manifest, name, absTarget) {
|
|
1087
|
-
const home = os.homedir();
|
|
1088
|
-
const value = absTarget === home || absTarget.startsWith(home + path.sep) ? `~${absTarget.slice(home.length)}` : absTarget;
|
|
1089
|
-
const insideRepo = await git(manifest.dir, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
1090
|
-
if (insideRepo) {
|
|
1091
|
-
const ignored = await git(manifest.dir, ["check-ignore", "-q", manifest.localPath]) !== null;
|
|
1092
|
-
if (!ignored) {
|
|
1093
|
-
throw new Error(
|
|
1094
|
-
`${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.`
|
|
1095
|
-
);
|
|
1096
|
-
}
|
|
1097
|
-
}
|
|
1098
|
-
const existing = await readText(manifest.localPath);
|
|
1099
|
-
let doc = {};
|
|
1100
|
-
if (existing) {
|
|
1101
|
-
try {
|
|
1102
|
-
doc = JSON.parse(existing);
|
|
1103
|
-
} catch {
|
|
1104
|
-
throw new Error(
|
|
1105
|
-
`${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.`
|
|
1106
|
-
);
|
|
1107
|
-
}
|
|
1108
|
-
}
|
|
1109
|
-
const dirs = doc.dirs ?? {};
|
|
1110
|
-
dirs[name] = value;
|
|
1111
|
-
doc.dirs = dirs;
|
|
1112
|
-
await promises.writeFile(manifest.localPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
1113
|
-
return value;
|
|
1114
|
-
}
|
|
1115
|
-
async function add(opts) {
|
|
1116
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
1117
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
1118
|
-
if (manifest.shape !== "registry") {
|
|
1119
|
-
throw new Error("`fleet add` targets the registry shape \u2014 this manifest is a legacy corpus");
|
|
1120
|
-
}
|
|
1121
|
-
const raw = await readText(manifest.manifestPath);
|
|
1122
|
-
if (raw === null) throw new Error(`cannot read ${manifest.manifestPath}`);
|
|
1123
|
-
const absTarget = path.resolve(opts.cwd, opts.target);
|
|
1124
|
-
if (!await pathExists(absTarget)) throw new Error(`no such directory: ${absTarget}`);
|
|
1125
|
-
const name = opts.name ?? path.basename(absTarget);
|
|
1126
|
-
if (manifest.entries.some((e) => e.name === name)) {
|
|
1127
|
-
throw new Error(`\`${name}\` is already registered \u2014 resolution is keyed by name`);
|
|
1128
|
-
}
|
|
1129
|
-
const facts = await scanProject(absTarget);
|
|
1130
|
-
const remote = facts.git.isRepo ? await git(absTarget, ["remote", "get-url", "origin"]) : null;
|
|
1131
|
-
section(`Fleet add ${theme.dim(`\xB7 ${name} \xB7 ${absTarget}`)}`);
|
|
1132
|
-
let profile = opts.profile;
|
|
1133
|
-
if (!profile && !opts.yes) {
|
|
1134
|
-
const picked = await select({
|
|
1135
|
-
message: "Which side of the wall does this belong to?",
|
|
1136
|
-
initialValue: "personal",
|
|
1137
|
-
options: [
|
|
1138
|
-
{ value: "personal", label: "personal \u2014 your own work" },
|
|
1139
|
-
{ value: "corp", label: "corp \u2014 employer work (alias-only, machine-pinned)" }
|
|
1140
|
-
]
|
|
1141
|
-
});
|
|
1142
|
-
if (isCancel(picked)) {
|
|
1143
|
-
cancel("No entry written.");
|
|
1144
|
-
return;
|
|
1145
|
-
}
|
|
1146
|
-
profile = picked;
|
|
1147
|
-
}
|
|
1148
|
-
profile = profile ?? "personal";
|
|
1149
|
-
if (profile !== "personal" && profile !== "corp") {
|
|
1150
|
-
throw new Error(`--profile must be personal or corp, got \`${profile}\``);
|
|
1151
|
-
}
|
|
1152
|
-
const derivedKind = facts.commands.build || facts.commands.test ? "tool" : "repo";
|
|
1153
|
-
let kind = opts.kind;
|
|
1154
|
-
if (!kind && !opts.yes) {
|
|
1155
|
-
const known = [...new Set(manifest.entries.map((e) => e.kind).filter(Boolean))];
|
|
1156
|
-
const choices = [.../* @__PURE__ */ new Set([derivedKind, ...known])];
|
|
1157
|
-
const picked = await select({
|
|
1158
|
-
message: "Kind?",
|
|
1159
|
-
initialValue: derivedKind,
|
|
1160
|
-
options: [
|
|
1161
|
-
...choices.map((k) => ({
|
|
1162
|
-
value: k,
|
|
1163
|
-
label: k === derivedKind ? `${k} \u2014 matches this repo's shape` : k
|
|
1164
|
-
})),
|
|
1165
|
-
{ value: "", label: "something else\u2026" }
|
|
1166
|
-
]
|
|
1167
|
-
});
|
|
1168
|
-
if (isCancel(picked)) {
|
|
1169
|
-
cancel("No entry written.");
|
|
1170
|
-
return;
|
|
1171
|
-
}
|
|
1172
|
-
kind = picked;
|
|
1173
|
-
if (!kind) {
|
|
1174
|
-
const typed = await text({
|
|
1175
|
-
message: "Kind (free text):",
|
|
1176
|
-
validate: (v) => v.trim() ? void 0 : "a kind is required"
|
|
1177
|
-
});
|
|
1178
|
-
if (isCancel(typed)) {
|
|
1179
|
-
cancel("No entry written.");
|
|
1180
|
-
return;
|
|
1181
|
-
}
|
|
1182
|
-
kind = typed.trim();
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
kind = kind ?? derivedKind;
|
|
1186
|
-
let trust;
|
|
1187
|
-
if (profile === "personal") {
|
|
1188
|
-
trust = opts.trust;
|
|
1189
|
-
if (!trust && !opts.yes) {
|
|
1190
|
-
const picked = await select({
|
|
1191
|
-
message: "How exposed is this repo's history? (gates content screening)",
|
|
1192
|
-
options: [
|
|
1193
|
-
{ value: "private", label: "private \u2014 not destined to be published" },
|
|
1194
|
-
{ value: "public-bound", label: "public-bound \u2014 private now, plausibly public later" },
|
|
1195
|
-
{ value: "public-repo", label: "public-repo \u2014 already public" }
|
|
1196
|
-
]
|
|
1197
|
-
});
|
|
1198
|
-
if (isCancel(picked)) {
|
|
1199
|
-
cancel("No entry written.");
|
|
1200
|
-
return;
|
|
1201
|
-
}
|
|
1202
|
-
trust = picked;
|
|
1203
|
-
}
|
|
1204
|
-
if (!trust) {
|
|
1205
|
-
throw new Error(
|
|
1206
|
-
`\`${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)`
|
|
1207
|
-
);
|
|
1208
|
-
}
|
|
1209
|
-
if (!isFleetTrust(trust)) {
|
|
1210
|
-
throw new Error(`--trust must be one of ${FLEET_TRUST_VALUES.join(", ")}, got \`${trust}\``);
|
|
1211
|
-
}
|
|
1212
|
-
} else if (opts.trust) {
|
|
1213
|
-
throw new Error("corp entries take no `trust` \u2014 `profile: corp` already implies it");
|
|
1214
|
-
}
|
|
1215
|
-
const corpHost = remote ? manifest.corpHosts.find((h) => remote.includes(h)) : void 0;
|
|
1216
|
-
if (corpHost && profile !== "corp") {
|
|
1217
|
-
throw new Error(
|
|
1218
|
-
`\`${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.`
|
|
1219
|
-
);
|
|
1220
|
-
}
|
|
1221
|
-
const entry = profile === "corp" ? { name, kind, profile, private: true } : {
|
|
1222
|
-
name,
|
|
1223
|
-
kind,
|
|
1224
|
-
profile,
|
|
1225
|
-
path: path.relative(manifest.root ?? manifest.dir, absTarget),
|
|
1226
|
-
trust,
|
|
1227
|
-
contract: {},
|
|
1228
|
-
links: {}
|
|
1229
|
-
// The remote is deliberately NOT recorded. Nothing reads it — it was persisted only
|
|
1230
|
-
// because it happened to be derivable — and it is the field that turns a mis-profiled
|
|
1231
|
-
// entry into a disclosure: a raw URL carries the host AND the internal group path,
|
|
1232
|
-
// while `path` carries a bare directory name. Removing a field no consumer reads
|
|
1233
|
-
// retires that whole class without any host-matching guess, and keeps working on a
|
|
1234
|
-
// machine that has no local manifest for the corp-host guard above to read.
|
|
1235
|
-
// It stays derivable at any time from the checkout itself, which is where it came from.
|
|
1236
|
-
};
|
|
1237
|
-
print(` ${glyph.bullet} ${theme.dim("entry")}`);
|
|
1238
|
-
print(
|
|
1239
|
-
JSON.stringify(entry, null, 2).split("\n").map((l) => ` ${theme.dim(l)}`).join("\n")
|
|
1240
|
-
);
|
|
1241
|
-
if (!opts.yes) {
|
|
1242
|
-
const ok = await confirm({
|
|
1243
|
-
message: `Write this entry to ${path.basename(manifest.manifestPath)}?`
|
|
1244
|
-
});
|
|
1245
|
-
if (isCancel(ok) || !ok) {
|
|
1246
|
-
cancel("No entry written.");
|
|
1247
|
-
return;
|
|
1248
|
-
}
|
|
1249
|
-
}
|
|
1250
|
-
let mappedTo;
|
|
1251
|
-
if (profile === "corp") {
|
|
1252
|
-
mappedTo = await recordCorpMapping(manifest, name, absTarget);
|
|
1253
|
-
}
|
|
1254
|
-
const doc = JSON.parse(raw);
|
|
1255
|
-
if (!Array.isArray(doc.projects)) throw new Error("manifest has no `projects` array to append to");
|
|
1256
|
-
doc.projects.push(entry);
|
|
1257
|
-
await promises.writeFile(manifest.manifestPath, JSON.stringify(doc, null, 2) + "\n", "utf8");
|
|
1258
|
-
print(` ${glyph.ok} ${theme.dim("registered")} ${theme.info(name)}`);
|
|
1259
|
-
if (mappedTo) {
|
|
1260
|
-
print(
|
|
1261
|
-
` ${glyph.ok} ${theme.dim("mapped")} ${theme.info(`${name} \u2192 ${mappedTo}`)} ${theme.dim(`in ${path.basename(manifest.localPath)} (gitignored)`)}`
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
print(` ${theme.dim(`verify with \`etymd fleet check --manifest ${manifest.manifestPath}\``)}`);
|
|
1265
|
-
}
|
|
1266
|
-
async function resolveFleetFinding(opts, status) {
|
|
1267
|
-
if (status === "dismissed" && !opts.reason?.trim()) {
|
|
1268
|
-
throw new Error('dismiss needs a reason: etymd fleet dismiss <name> <id> --reason "\u2026"');
|
|
1269
|
-
}
|
|
1270
|
-
const { manifest, autoNote } = await loadManifest(opts.cwd, opts.manifest);
|
|
1271
|
-
if (autoNote) print(` ${theme.dim(`\u25E6 ${autoNote}`)}`);
|
|
1272
|
-
const target = await fleetLedgerTarget(manifest, opts.name);
|
|
1273
|
-
const hadLedger = await pathExists(path.join(target.ledgerRoot, ".etymd"));
|
|
1274
|
-
const { ledgerRoot, recorded } = await ensureFindingRecorded(manifest, opts.name, opts.id);
|
|
1275
|
-
if (!recorded) {
|
|
1276
|
-
throw new Error(
|
|
1277
|
-
`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)`
|
|
1278
|
-
);
|
|
1279
|
-
}
|
|
1280
|
-
if (!hadLedger) {
|
|
1281
|
-
print(
|
|
1282
|
-
` ${theme.dim(`first ledger for \`${opts.name}\`: created ${path.join(ledgerRoot, ".etymd")} (a persisting audit ran to record the finding)`)}`
|
|
1283
|
-
);
|
|
1284
|
-
}
|
|
1285
|
-
const args = { cwd: ledgerRoot, id: opts.id, reason: opts.reason };
|
|
1286
|
-
await (status === "dismissed" ? dismiss(args) : accept(args));
|
|
1287
|
-
print(` ${theme.dim(`ledger: ${path.join(ledgerRoot, ".etymd", "ledger.json")}`)}`);
|
|
1288
|
-
}
|
|
1289
|
-
async function dismiss2(opts) {
|
|
1290
|
-
await resolveFleetFinding(opts, "dismissed");
|
|
1291
|
-
}
|
|
1292
|
-
async function accept2(opts) {
|
|
1293
|
-
await resolveFleetFinding(opts, "accepted");
|
|
1294
|
-
}
|
|
1295
|
-
|
|
1296
|
-
export { accept2 as accept, add, check, dismiss2 as dismiss, sweep };
|
|
1265
|
+
export { BOARD_JSON_SCHEMA, FLEET_JSON_SCHEMA, FLEET_TRUST_VALUES, MILESTONES_FILE, checkManifest, ensureFindingRecorded, fleetLedgerTarget, isFleetTrust, loadFleetManifest, parseInitiatives, parseMilestones, renderBoard, sweepFleet };
|