geneseed 4.9.0 → 4.9.2
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 +40 -0
- package/SHIPPED.md +1 -1
- package/adapters/opencode/plugins/geneseed-context.js +7 -1
- package/adapters/opencode/plugins/geneseed-guard.js +139 -27
- package/adapters/opencode/plugins/geneseed-learn.js +7 -1
- package/docs/concepts/hooks.md +1 -1
- package/docs/web-ui.md +3 -2
- package/harness.config.json +1 -1
- package/js/README.md +30 -9
- package/js/build/args.mjs +9 -8
- package/js/build/driver.mjs +21 -21
- package/js/build/emit-claude.mjs +3 -3
- package/js/build/generate.mjs +4 -5
- package/js/build/source.mjs +18 -22
- package/js/hosts/gitref.mjs +5 -3
- package/js/hosts/hooks-done.mjs +21 -9
- package/js/hosts/hooks-learn.mjs +23 -10
- package/js/hosts/hooks-prims.mjs +25 -0
- package/js/hosts/hooks.mjs +17 -8
- package/js/hosts/installs.mjs +5 -4
- package/js/hosts/loss.mjs +124 -18
- package/js/hosts/native.mjs +3 -1
- package/js/hosts/owned.mjs +34 -6
- package/js/hosts/settings.mjs +5 -4
- package/js/inspect/cost.mjs +37 -5
- package/js/inspect/diff.mjs +132 -61
- package/js/inspect/doctor.mjs +4 -3
- package/js/inspect/validate.mjs +2 -2
- package/js/lib/fs.mjs +73 -2
- package/js/maintain/setup.mjs +31 -7
- package/js/maintain/uninstall.mjs +1 -1
- package/js/maintain/update.mjs +1 -1
- package/js/web/actions.mjs +38 -4
- package/js/web/api.mjs +21 -13
- package/js/web/handler.mjs +5 -17
- package/js/web/jobs.mjs +5 -3
- package/js/web/routes.mjs +3 -1
- package/js/web/server.mjs +3 -1
- package/js/web/state.mjs +35 -8
- package/package.json +1 -1
- package/web/dist/assets/{Activity-DZ3O_DsJ.js → Activity-D7Igwuoo.js} +1 -1
- package/web/dist/assets/{ActivityDetail-BQSWwoJ-.js → ActivityDetail-53rp05hx.js} +1 -1
- package/web/dist/assets/Dashboard-BoEH5qyY.js +2 -0
- package/web/dist/assets/{Docs-DUxizutI.js → Docs-BM56RCT8.js} +3 -3
- package/web/dist/assets/GreenhouseView-BTMvGVco.js +2 -0
- package/web/dist/assets/Installs-Dh1tB1NK.js +2 -0
- package/web/dist/assets/JournalView-F4hlWTVB.js +1 -0
- package/web/dist/assets/Laws-iEheocNI.js +3 -0
- package/web/dist/assets/Library-d4_uOGmC.js +1 -0
- package/web/dist/assets/{Loops-Bzb2Y96W.js → Loops-BbYeWl70.js} +2 -2
- package/web/dist/assets/{OperatorHudView-D-nzB6_r.js → OperatorHudView-Dki9Q6W3.js} +1 -1
- package/web/dist/assets/Personal-B_YQJjA_.js +1 -0
- package/web/dist/assets/{Tabs-DkPfjG3H.js → Tabs-CAl6oFwG.js} +1 -1
- package/web/dist/assets/api-CiXlkbe0.js +1 -0
- package/web/dist/assets/drawable-BdTDG5oI.js +1 -0
- package/web/dist/assets/index-BZgEGRbL.js +2 -0
- package/web/dist/index.html +2 -2
- package/web/src/pages/Laws.jsx +60 -353
- package/web/dist/assets/Dashboard-COLskkOJ.js +0 -2
- package/web/dist/assets/GreenhouseView-BSzVX_l7.js +0 -2
- package/web/dist/assets/Installs-Ck4YyEzs.js +0 -2
- package/web/dist/assets/JournalView-BbbACsYK.js +0 -1
- package/web/dist/assets/Laws-BZ_M86r3.js +0 -3
- package/web/dist/assets/Library-D_4LbUg2.js +0 -1
- package/web/dist/assets/Personal-C66284wd.js +0 -1
- package/web/dist/assets/api-Bp5eW_6m.js +0 -1
- package/web/dist/assets/drawable-DlrXDOJ7.js +0 -1
- package/web/dist/assets/index-DJorAjeH.js +0 -2
package/js/hosts/loss.mjs
CHANGED
|
@@ -18,14 +18,18 @@
|
|
|
18
18
|
* `branch -f`/`checkout -B`/`switch -C` moving a protected branch off commits nothing else
|
|
19
19
|
* holds — the same forms on a feature branch pass;
|
|
20
20
|
* - `stash drop`/`stash clear` while a stash exists; `reflog expire` and `gc --prune` always;
|
|
21
|
-
* - a file removal (`rm`, `rmdir`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of
|
|
22
|
-
* cannot bring back: untracked, ignored or modified files, a repository's own
|
|
23
|
-
* any path outside a git repository.
|
|
21
|
+
* - a file removal (`rm`, `rmdir`, `unlink`, `del`, `erase`, `Remove-Item`/`ri`/`rd`) of
|
|
22
|
+
* anything git cannot bring back: untracked, ignored or modified files, a repository's own
|
|
23
|
+
* `.git`, or any path outside a git repository.
|
|
24
24
|
*
|
|
25
25
|
* FAIL CLOSED. Anything this reader cannot settle — a glob or a `$variable` in a path it must
|
|
26
|
-
* name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`)
|
|
27
|
-
* a
|
|
28
|
-
*
|
|
26
|
+
* name, a `cd` it cannot follow, an interpreter (`sh -c`, `xargs`, `eval`, `find -delete`)
|
|
27
|
+
* carrying a removal, a removal that names no path (`… | Remove-Item` takes its paths from the
|
|
28
|
+
* pipe), a wrapper flag whose arity it does not know, ANY program it does not read (`setsid`,
|
|
29
|
+
* `ForEach-Object`, `case`) with a removal named unquoted after it, a git that fails, times out
|
|
30
|
+
* or is missing — asks, and the reason says it could not verify. A command substitution
|
|
31
|
+
* (`$(…)`, backticks) is judged as a command of its own. "Not proven safe" is an ask; only
|
|
32
|
+
* "proven safe" passes.
|
|
29
33
|
*
|
|
30
34
|
* THE ONE SPAWN IN THE GATE PATH, and why it is allowed. Whether a work tree is dirty, which
|
|
31
35
|
* files are untracked, and whether a branch's commits are reachable elsewhere are not readable
|
|
@@ -60,16 +64,31 @@ import { currentBranch, gitRootOf, protectedBranches, stashExists } from './gitr
|
|
|
60
64
|
* a superset of what the parse acts on.
|
|
61
65
|
*/
|
|
62
66
|
export const LOSS_CANDIDATE_RE =
|
|
63
|
-
/\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'])(?:rm|rmdir|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"']|$)/im;
|
|
67
|
+
/\bgit\b[^\n]*\b(?:reset|clean|branch|checkout|switch|restore|stash|reflog|gc|push)\b|(?:^|[\s;&|("'`])(?:rm|rmdir|unlink|del|erase|remove-item|ri|rd)(?:\.exe)?(?=[\s;&|)"'`]|$)|(?:^|\s)-delete(?=\s|$)/im;
|
|
64
68
|
|
|
65
69
|
class Unverified extends Error {}
|
|
66
70
|
const unverified = (why) => { throw new Unverified(why); };
|
|
67
71
|
|
|
68
|
-
const RM_PROGS = new Set(['rm', 'rmdir', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
72
|
+
const RM_PROGS = new Set(['rm', 'rmdir', 'unlink', 'del', 'erase', 'remove-item', 'ri', 'rd']);
|
|
69
73
|
// cmd.exe's own removal verbs take `/s` `/q` switches, which are not paths.
|
|
70
74
|
const CMD_SWITCH_PROGS = new Set(['rmdir', 'del', 'erase', 'rd']);
|
|
71
75
|
const CD_PROGS = new Set(['cd', 'pushd', 'chdir', 'set-location', 'sl']);
|
|
72
|
-
|
|
76
|
+
// A wrapper runs the command after its own flags: `<short flags taking a value>|<short flags taking
|
|
77
|
+
// none>`. A flag in neither list (`env -S` runs a string, `env -C`/`sudo -D` change directory,
|
|
78
|
+
// `command -v` only looks up) is not guessed at — the segment asks if it names a removal.
|
|
79
|
+
const WRAPPERS = new Map([['sudo', 'ughpCUrtT|AbEHiknPSs'], ['doas', 'uC|ns'], ['command', '|p'],
|
|
80
|
+
['builtin', '|'], ['\\builtin', '|'], ['time', 'fo|pav'], ['nice', 'n|'], ['nohup', '|'],
|
|
81
|
+
['exec', 'a|cl'], ['env', 'u|i0v'], ['timeout', 'sk|v'], ['ionice', 'cn|t'], ['stdbuf', 'ioe|']]);
|
|
82
|
+
const WRAPPER_LONG = new Set(['--preserve-status', '--foreground', '--verbose', '--login', '--preserve-env',
|
|
83
|
+
'--non-interactive', '--ignore-environment', '--null']);
|
|
84
|
+
// Shell grammar that may stand before the program word (`(cd x && rm y)`, `if …; then rm y; fi`).
|
|
85
|
+
const STRUCTURE = new Set(['(', '((', '{', '!', 'if', 'then', 'elif', 'else', 'do', 'while', 'until']);
|
|
86
|
+
// The only programs this reader does not parse that may still name a removal word and pass: their
|
|
87
|
+
// `rm`/`del` is their own subcommand (a package, a container), or they never run their arguments
|
|
88
|
+
// (a substitution in one is judged on its own). Any other program over a removal word asks.
|
|
89
|
+
const INERT = new Set(['npm', 'pnpm', 'yarn', 'bun', 'cargo', 'brew', 'docker', 'podman', 'echo', 'printf',
|
|
90
|
+
'grep', 'egrep', 'fgrep', 'rg', 'findstr', 'select-string', 'which', 'where', 'type', 'man', 'get-command',
|
|
91
|
+
'get-help']);
|
|
73
92
|
// Programs that run ANOTHER command from their arguments or stdin: what they would remove is
|
|
74
93
|
// not on this command line in a form the parse can read, so a removal word inside one asks.
|
|
75
94
|
const INDIRECT = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish', 'pwsh', 'powershell', 'cmd',
|
|
@@ -434,6 +453,8 @@ function rmAct(prog, args, dir) {
|
|
|
434
453
|
if (opts && CMD_SWITCH_PROGS.has(prog) && /^\/[a-zA-Z?]$/.test(a)) continue;
|
|
435
454
|
targets.push(a);
|
|
436
455
|
}
|
|
456
|
+
// `… | Remove-Item`, `ri -Force`: the paths come from the pipe, which this reader cannot see.
|
|
457
|
+
if (!targets.length) unverified(`\`${prog}\` names no path, so what it removes comes from elsewhere`);
|
|
437
458
|
const groups = new Map();
|
|
438
459
|
for (const t of targets) {
|
|
439
460
|
const shown = t.replace(OPAQUE, '');
|
|
@@ -475,6 +496,69 @@ function rmAct(prog, args, dir) {
|
|
|
475
496
|
/** The program word, stripped of its path and `.exe`, lowercased (PowerShell and cmd are case-blind). */
|
|
476
497
|
const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '');
|
|
477
498
|
|
|
499
|
+
/**
|
|
500
|
+
* A segment's RAW tokens with the shell grammar around its command dropped: leading `(`, `{`,
|
|
501
|
+
* `!`, `then`, `do`… (a `(` glued to the first word too) and a closing `)`/`}`. Without it
|
|
502
|
+
* `(git reset --hard)` has `(git` for its program and `--hard)` for its flag. `rootScan`
|
|
503
|
+
* shares it, so `(find / …)` is a scan.
|
|
504
|
+
*/
|
|
505
|
+
export function stripStructure(raw) {
|
|
506
|
+
const t = raw.slice();
|
|
507
|
+
while (t.length) {
|
|
508
|
+
if (STRUCTURE.has(t[0])) t.shift();
|
|
509
|
+
else if (/^\(+./.test(t[0])) t[0] = t[0].replace(/^\(+/, '');
|
|
510
|
+
else break;
|
|
511
|
+
}
|
|
512
|
+
while (t.length && (t[t.length - 1] === '}' || /^\)+$/.test(t[t.length - 1]))) t.pop();
|
|
513
|
+
if (t.length) t[t.length - 1] = t[t.length - 1].replace(/\)+$/, '');
|
|
514
|
+
return t;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* The bodies of the command substitutions in `text` (`$(…)`, backticks, `<(…)`, `>(…)`) —
|
|
519
|
+
* single quotes hide them, double quotes do not. An unclosed one runs to the end of the text:
|
|
520
|
+
* the splitter already cut an unquoted `$(a; b)` at its `;`.
|
|
521
|
+
*/
|
|
522
|
+
function substitutions(text) {
|
|
523
|
+
const out = [];
|
|
524
|
+
let sq = false; let dq = false;
|
|
525
|
+
for (let i = 0; i < text.length; i++) {
|
|
526
|
+
const c = text[i];
|
|
527
|
+
if (sq) { if (c === "'") sq = false; continue; }
|
|
528
|
+
if (c === "'" && !dq) { sq = true; continue; }
|
|
529
|
+
if (c === '"') { dq = !dq; continue; }
|
|
530
|
+
if (c === '`') {
|
|
531
|
+
const end = text.indexOf('`', i + 1);
|
|
532
|
+
out.push(text.slice(i + 1, end < 0 ? text.length : end));
|
|
533
|
+
i = end < 0 ? text.length : end;
|
|
534
|
+
} else if ('$<>'.includes(c) && text[i + 1] === '(') {
|
|
535
|
+
let depth = 1; let j = i + 2;
|
|
536
|
+
for (; j < text.length && depth; j++) depth += text[j] === '(' ? 1 : text[j] === ')' ? -1 : 0;
|
|
537
|
+
out.push(text.slice(i + 2, depth ? text.length : j - 1));
|
|
538
|
+
i = j - 1;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
return out;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** `w` past wrapper `name`'s own flags (and `timeout`'s duration), or null when a flag is unknown. */
|
|
545
|
+
function pastWrapper(name, w) {
|
|
546
|
+
const [takes, plain] = WRAPPERS.get(name).split('|');
|
|
547
|
+
let i = 0;
|
|
548
|
+
for (; i < w.length && w[i].length > 1 && w[i].startsWith('-'); i++) {
|
|
549
|
+
const a = w[i];
|
|
550
|
+
if (a === '--') { i += 1; break; }
|
|
551
|
+
if (a.startsWith('--')) { if (a.includes('=') || WRAPPER_LONG.has(a)) continue; return null; }
|
|
552
|
+
if (name === 'nice' && /^-\d+$/.test(a)) continue;
|
|
553
|
+
for (let j = 1; j < a.length; j++) {
|
|
554
|
+
if (takes.includes(a[j])) { if (j === a.length - 1) i += 1; break; } // the value: the rest, or the next word
|
|
555
|
+
if (!plain.includes(a[j])) return null;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (name === 'timeout' && /^\d/.test(w[i] || '')) i += 1;
|
|
559
|
+
return w.slice(i);
|
|
560
|
+
}
|
|
561
|
+
|
|
478
562
|
/**
|
|
479
563
|
* The verdict for a whole command, given its segments as RAW token arrays (quotes still on —
|
|
480
564
|
* `js/hosts/hooks.mjs` cuts and tokenises with the same lexer `rootScan` uses, so a quoted
|
|
@@ -485,19 +569,33 @@ const progOf = (w) => w.split(/[\\/]/).pop().toLowerCase().replace(/\.exe$/, '')
|
|
|
485
569
|
* each host's ceiling for a crashed gate (docs/adr/0007-gates-fail-closed-per-host-ceiling.md).
|
|
486
570
|
* `always: false` skips the two acts that lose data in every state (`reflog expire`, `gc --prune`)
|
|
487
571
|
* — the OpenCode guard passes it, because a static `permission.bash` glob already asks for those.
|
|
572
|
+
* `split` cuts a command substitution's body into segments the same way (both callers pass their
|
|
573
|
+
* splitter); without it, a substitution that names a removal asks.
|
|
574
|
+
* @param {string[][]} segments
|
|
575
|
+
* @param {string} cwd
|
|
576
|
+
* @param {{ always?: boolean, split?: (text: string) => string[][] }} [options]
|
|
488
577
|
*/
|
|
489
|
-
export function lossReason(segments, cwd, { always = true } = {}) {
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
578
|
+
export function lossReason(segments, cwd, { always = true, split } = {}) {
|
|
579
|
+
/** The first loss among `segs`, run from `start`; a substitution body is judged as a command. */
|
|
580
|
+
const judge = (segs, start, depth = 0) => {
|
|
581
|
+
let dir = start;
|
|
582
|
+
for (const raw of segs.map(stripStructure)) {
|
|
583
|
+
const text = raw.join(' ');
|
|
584
|
+
const named = LOSS_CANDIDATE_RE.test(text);
|
|
585
|
+
for (const body of named ? substitutions(text) : []) {
|
|
586
|
+
if (!LOSS_CANDIDATE_RE.test(body)) continue;
|
|
587
|
+
// Bounded: `$($($(…` nested past any real command would rescan the text once per level.
|
|
588
|
+
if (!split || depth >= 4) unverified('a command substitution names a removal');
|
|
589
|
+
const v = judge(split(body), dir, depth + 1);
|
|
590
|
+
if (v) return v;
|
|
591
|
+
}
|
|
493
592
|
let w = words(raw);
|
|
494
|
-
while (w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
593
|
+
while (w && w.length && (/^\w+=/.test(w[0]) || WRAPPERS.has(progOf(w[0])))) {
|
|
495
594
|
const wrapper = progOf(w[0]);
|
|
496
|
-
w = w.slice(1);
|
|
497
|
-
|
|
498
|
-
if (wrapper !== 'command' && wrapper !== 'exec') while (w.length && w[0].startsWith('-')) w = w.slice(1);
|
|
595
|
+
w = /^\w+=/.test(w[0]) ? w.slice(1) : pastWrapper(wrapper, w.slice(1));
|
|
596
|
+
if (!w && named) unverified(`\`${wrapper}\` takes a flag the gate does not know`);
|
|
499
597
|
}
|
|
500
|
-
if (!w.length) continue;
|
|
598
|
+
if (!w || !w.length) continue;
|
|
501
599
|
const prog = progOf(w[0]);
|
|
502
600
|
const args = w.slice(1);
|
|
503
601
|
if (CD_PROGS.has(prog)) {
|
|
@@ -510,10 +608,18 @@ export function lossReason(segments, cwd, { always = true } = {}) {
|
|
|
510
608
|
else if (RM_PROGS.has(prog)) verdict = rmAct(prog, args, dir);
|
|
511
609
|
else if (INDIRECT.has(prog) && LOSS_CANDIDATE_RE.test(args.join(' '))) {
|
|
512
610
|
unverified(`\`${prog}\` runs a command the gate cannot read`);
|
|
611
|
+
} else if (named && !INERT.has(prog)
|
|
612
|
+
&& LOSS_CANDIDATE_RE.test(raw.map((t) => t.replace(/"[^"]*"?|'[^']*'?/g, ' ')).join(' '))) {
|
|
613
|
+
// A program this reader does not know (a wrapper, a loop, shell syntax) over an unquoted
|
|
614
|
+
// removal word: it may run it, so it is not proven safe. A quoted word is data.
|
|
615
|
+
unverified(`\`${w[0].replace(OPAQUE, '')}\` is not a program the gate reads, and a removal follows it`);
|
|
513
616
|
}
|
|
514
617
|
if (verdict) return verdict;
|
|
515
618
|
}
|
|
516
619
|
return null;
|
|
620
|
+
};
|
|
621
|
+
try {
|
|
622
|
+
return judge(segments, cwd);
|
|
517
623
|
} catch (e) {
|
|
518
624
|
if (!(e instanceof Unverified)) throw e;
|
|
519
625
|
return `the gate could not verify what this command would lose (${e.message})`;
|
package/js/hosts/native.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { writeText, copyFile, readText } from '../lib/fs.mjs';
|
|
|
33
33
|
import { jsonDumps, parseJson, formatValue, formatRepr, isTruthy, isDict } from '../lib/json.mjs';
|
|
34
34
|
import { relPosix } from '../lib/text.mjs';
|
|
35
35
|
import { aliasesOf } from '../build/source.mjs';
|
|
36
|
+
import { resolvePath } from './hosts.mjs';
|
|
36
37
|
import { claimLeaf, ownedState } from './owned.mjs';
|
|
37
38
|
// `lstripNewlines` on spec bodies is a no-op on every input the generator produces today
|
|
38
39
|
// (measured: no file under `src/` and no rendered spec in any theme begins with a newline), so
|
|
@@ -497,8 +498,9 @@ export function claimer(oldOwned, cfg, manifestExisted = true, oldHashes = {}) {
|
|
|
497
498
|
const oldSet = oldOwned !== null && oldOwned !== undefined ? new Set(oldOwned) : null;
|
|
498
499
|
let headerPrinted = false;
|
|
499
500
|
const editRefused = new Set();
|
|
501
|
+
const rroot = cfg != null ? resolvePath(cfg) : undefined; // once per emit, not per leaf
|
|
500
502
|
const claim = (dest) => {
|
|
501
|
-
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest))) return false;
|
|
503
|
+
if (cfg != null && !claimLeaf(cfg, dest, relPosix(cfg, dest), rroot)) return false;
|
|
502
504
|
if (oldSet === null || cfg == null || !existsSync(dest)) return true;
|
|
503
505
|
const rel = relPosix(cfg, dest);
|
|
504
506
|
if (oldSet.has(rel)) {
|
package/js/hosts/owned.mjs
CHANGED
|
@@ -26,7 +26,7 @@ import path from 'node:path';
|
|
|
26
26
|
|
|
27
27
|
import { resolvePath } from './hosts.mjs';
|
|
28
28
|
import { within } from '../lib/paths.mjs';
|
|
29
|
-
import { isFile } from '../lib/fs.mjs';
|
|
29
|
+
import { isFile, statOrNull, textWrites } from '../lib/fs.mjs';
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
32
|
* The manifest rels that resolve INSIDE `base` — the one security boundary every walk over
|
|
@@ -68,9 +68,13 @@ export function ownedWithin(base, rels) {
|
|
|
68
68
|
* `resolvePath` follows the root link on both `root` and `dest`, so a leaf reached the
|
|
69
69
|
* ordinary way through a linked root still resolves inside it and stays allowed — only the
|
|
70
70
|
* LEAF itself being a link is refused.
|
|
71
|
+
*
|
|
72
|
+
* `rroot` is `resolvePath(root)`, passed in by a caller that claims many leaves under one root
|
|
73
|
+
* (`claimer`, once per emit) — re-resolving it per leaf was ~2.6 s of `doctor --all`. Only the
|
|
74
|
+
* ROOT is hoisted: the lstat and the leaf's own `resolvePath` below stay per call, since they
|
|
75
|
+
* are the boundary.
|
|
71
76
|
*/
|
|
72
|
-
export function safeLeaf(root, dest) {
|
|
73
|
-
const rroot = resolvePath(root);
|
|
77
|
+
export function safeLeaf(root, dest, rroot = resolvePath(root)) {
|
|
74
78
|
const st = lstatSync(dest, { throwIfNoEntry: false });
|
|
75
79
|
if (st) {
|
|
76
80
|
if (st.isSymbolicLink()) return false;
|
|
@@ -86,8 +90,8 @@ export function safeLeaf(root, dest) {
|
|
|
86
90
|
* callers that already have it (every one of them, since it is what they are about to push
|
|
87
91
|
* into `owned`) pass it straight through instead of re-deriving it from `dest`.
|
|
88
92
|
*/
|
|
89
|
-
export function claimLeaf(root, dest, rel) {
|
|
90
|
-
if (safeLeaf(root, dest)) return true;
|
|
93
|
+
export function claimLeaf(root, dest, rel, rroot = undefined) {
|
|
94
|
+
if (safeLeaf(root, dest, rroot)) return true;
|
|
91
95
|
process.stderr.write(`[geneseed] refused ${rel}: it is a link — Geneseed writes only `
|
|
92
96
|
+ 'regular files it owns\n');
|
|
93
97
|
return false;
|
|
@@ -107,6 +111,19 @@ export function fileHash(p) {
|
|
|
107
111
|
return createHash('sha256').update(folded, 'latin1').digest('hex');
|
|
108
112
|
}
|
|
109
113
|
|
|
114
|
+
/**
|
|
115
|
+
* `fileHash` of a file whose whole content is `written`, computed from the string instead of
|
|
116
|
+
* read back — `written` must be EXACTLY what went to disk as utf8 (`writeText`'s translated
|
|
117
|
+
* text, never the text before its `\n` -> `os.EOL`: an embedded `\r\n` becomes `\r\r\n` on
|
|
118
|
+
* Windows, which folds to `\r\n`, not `\n`). CR and LF are single bytes that never occur
|
|
119
|
+
* inside a multi-byte utf8 sequence, so folding the string then encoding equals encoding then
|
|
120
|
+
* folding.
|
|
121
|
+
*/
|
|
122
|
+
export function textHash(written) {
|
|
123
|
+
return createHash('sha256').update(Buffer.from(written.replaceAll('\r\n', '\n'), 'utf8'))
|
|
124
|
+
.digest('hex');
|
|
125
|
+
}
|
|
126
|
+
|
|
110
127
|
/**
|
|
111
128
|
* One sha256 per entry of `owned` that is both inside `root` (through `ownedWithin`, the same
|
|
112
129
|
* containment boundary every other owned-rel walk goes through) and a file that still exists —
|
|
@@ -114,12 +131,23 @@ export function fileHash(p) {
|
|
|
114
131
|
* rather than hashed. Keys land in the same sorted order the manifest writers already give
|
|
115
132
|
* `owned` itself (`[...owned].sort()`), so the hash map a re-emit produces for an unchanged
|
|
116
133
|
* tree is byte-identical to the one before it — `golden.mjs --idempotent`'s whole point.
|
|
134
|
+
*
|
|
135
|
+
* A file this emit wrote through `writeText` is hashed from the text it recorded
|
|
136
|
+
* (`textWrites`, `js/lib/fs.mjs`) rather than read back, while its size and mtime still match
|
|
137
|
+
* the stat taken at write time; anything else — a copied binary, an unrecorded rewrite — is read.
|
|
138
|
+
* shortcut: a same-size rewrite by a non-`writeText` writer inside one mtime tick would keep the
|
|
139
|
+
* stale record; no emit does that today (measured across golden's cells, 0 mismatches) — route
|
|
140
|
+
* such a writer through `writeText` or drop its entry if one ever appears.
|
|
117
141
|
*/
|
|
118
142
|
export function ownedHashes(root, owned) {
|
|
119
143
|
const hashes = {};
|
|
120
144
|
for (const rel of ownedWithin(root, owned).sort()) {
|
|
121
145
|
const p = path.join(root, rel);
|
|
122
|
-
|
|
146
|
+
const st = statOrNull(p);
|
|
147
|
+
if (!st?.isFile()) continue;
|
|
148
|
+
const w = textWrites.map?.get(path.resolve(p));
|
|
149
|
+
hashes[rel] = w && w.size === st.size && w.mtimeMs === st.mtimeMs ? textHash(w.text)
|
|
150
|
+
: fileHash(p);
|
|
123
151
|
}
|
|
124
152
|
return hashes;
|
|
125
153
|
}
|
package/js/hosts/settings.mjs
CHANGED
|
@@ -726,7 +726,9 @@ export function mergeOpencodeJson(p, agentPath, doctrines = null, excluded = [],
|
|
|
726
726
|
* remove. `default` here means unknown, and unknown keeps the gate (see `processPackOn`).
|
|
727
727
|
*/
|
|
728
728
|
export function claudeHookGroups(cfg, hookOpts, doctrines = null, excluded = [], host = 'claude',
|
|
729
|
-
|
|
729
|
+
gates = {}) {
|
|
730
|
+
// One object keyed as `GATE_SWITCHES` keys it; only an explicit `false` disarms a gate.
|
|
731
|
+
const { secretsGate = true, doneGate = true, deletionGate = true } = gates ?? {};
|
|
730
732
|
const run = hookPrefix(hookOpts);
|
|
731
733
|
const mem = `--memory "${path.join(cfg, 'memory')}"`;
|
|
732
734
|
// --root carries the install's own dir so a GLOBAL hook can stand down when a live, wired
|
|
@@ -932,15 +934,14 @@ function readSettingsForMerge(p, consequence) {
|
|
|
932
934
|
* global that never stood down for a project install.
|
|
933
935
|
*/
|
|
934
936
|
export function mergeClaudeSettings(p, priorHooks = null, hookOpts = {},
|
|
935
|
-
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p),
|
|
936
|
-
gates = {}) {
|
|
937
|
+
doctrines = null, excluded = [], host = 'claude', cfgDir = path.dirname(p), gates = {}) {
|
|
937
938
|
const prior = (priorHooks || []).filter(isDict);
|
|
938
939
|
const loaded = readSettingsForMerge(p, 'Hooks were not wired.');
|
|
939
940
|
if (!loaded) return [p, prior];
|
|
940
941
|
const [config, hadComments] = loaded;
|
|
941
942
|
let hooks = get(config, 'hooks');
|
|
942
943
|
if (!isDict(hooks)) hooks = {};
|
|
943
|
-
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host,
|
|
944
|
+
const canonical = claudeHookGroups(cfgDir, hookOpts, doctrines, excluded, host, gates);
|
|
944
945
|
const canonFlat = [];
|
|
945
946
|
for (const [event, groups] of Object.entries(canonical)) {
|
|
946
947
|
for (const g of groups) canonFlat.push({ event, group: g });
|
package/js/inspect/cost.mjs
CHANGED
|
@@ -9,8 +9,12 @@
|
|
|
9
9
|
* `AGENTS.md`), CRLF folded to LF as the smoke test folds it, so the figure and
|
|
10
10
|
* `CARRIER_CEILING` are the same measurement. `readText` also folds a lone CR,
|
|
11
11
|
* which the smoke test does not, so the two agree on any carrier without one.
|
|
12
|
-
* - `skills` the name + description every native skill lists
|
|
13
|
-
*
|
|
12
|
+
* - `skills` the name + description of every native skill the MODEL lists. A user-only skill
|
|
13
|
+
* (and every alias, which native.mjs writes user-only) stays on disk but out of
|
|
14
|
+
* the catalogue, by each host's own mechanism (`userOnly`): the frontmatter's
|
|
15
|
+
* `disable-model-invocation` on Claude Code and OpenClaude; on OpenCode, which
|
|
16
|
+
* ignores that key, the `permission.skill` deny in `opencode.json`; on Bob no
|
|
17
|
+
* SKILL.md exists for one, so there is nothing to skip.
|
|
14
18
|
* - `agents` the same for native agents; Bob has none, its agents table is in the carrier.
|
|
15
19
|
* - `context` the session files the context hook injects (untouched seeds already skipped).
|
|
16
20
|
* The per-repo project context is NOT counted: it changes with the working dir.
|
|
@@ -21,6 +25,8 @@ import { sessionFiles } from '../hosts/hooks-context.mjs';
|
|
|
21
25
|
import { carriersFor } from '../hosts/installs.mjs';
|
|
22
26
|
import { frontmatter } from '../hosts/memory-files.mjs';
|
|
23
27
|
import { listDir } from '../hosts/hooks-prims.mjs';
|
|
28
|
+
import { loadJsonObject, opencodeTarget } from '../hosts/settings.mjs';
|
|
29
|
+
import { isDict } from '../lib/json.mjs';
|
|
24
30
|
import { isFile, readText } from '../lib/fs.mjs';
|
|
25
31
|
|
|
26
32
|
/**
|
|
@@ -44,14 +50,39 @@ function fmValue(raw) {
|
|
|
44
50
|
return raw;
|
|
45
51
|
}
|
|
46
52
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
53
|
+
/**
|
|
54
|
+
* name + description of one spec file; `fallbackName` for agents, whose name is the file stem.
|
|
55
|
+
* `hidden(fm)` answers true for a file the host keeps out of the model's catalogue: it counts 0.
|
|
56
|
+
* @param {string} file
|
|
57
|
+
* @param {string} fallbackName
|
|
58
|
+
* @param {(fm: Map<string, string>) => boolean} [hidden]
|
|
59
|
+
*/
|
|
60
|
+
function listedChars(file, fallbackName, hidden = () => false) {
|
|
49
61
|
let fm;
|
|
50
62
|
try { [fm] = frontmatter(readText(file)); } catch { return 0; }
|
|
63
|
+
if (hidden(fm)) return 0;
|
|
51
64
|
const name = fmValue(fm.get('name')) || fallbackName;
|
|
52
65
|
return name.length + fmValue(fm.get('description')).length;
|
|
53
66
|
}
|
|
54
67
|
|
|
68
|
+
/**
|
|
69
|
+
* How `host` hides a skill from the model, mirroring `writeNativeLayer` (js/hosts/native.mjs).
|
|
70
|
+
* OpenCode reads the deny where `mcpConfigFor` finds its config (`opencode.json(c)` at the
|
|
71
|
+
* root); a blanket non-object `permission.skill` is the user's own policy and hides nothing
|
|
72
|
+
* by name, so nothing is skipped. Bob documents no such key: nothing is skipped either.
|
|
73
|
+
*/
|
|
74
|
+
function userOnlyTest(host, root) {
|
|
75
|
+
if (host === 'claude' || host === 'openclaude') {
|
|
76
|
+
return (_name, fm) => fm.get('disable-model-invocation') === 'true';
|
|
77
|
+
}
|
|
78
|
+
if (host === 'opencode') {
|
|
79
|
+
const { data } = loadJsonObject(opencodeTarget(path.join(root, 'opencode.json')));
|
|
80
|
+
const skill = isDict(data) && isDict(data.permission) ? data.permission.skill : null;
|
|
81
|
+
return (name) => isDict(skill) && skill[name] === 'deny';
|
|
82
|
+
}
|
|
83
|
+
return () => false;
|
|
84
|
+
}
|
|
85
|
+
|
|
55
86
|
/**
|
|
56
87
|
* `root` is the install's root (`installTargets`), `dir` its data dir (`viewCfg`), which holds
|
|
57
88
|
* `skills/`, `agents/` and the session files. Every part that cannot be read counts 0; a missing
|
|
@@ -64,9 +95,10 @@ export function alwaysOnCost({ host, root, dir, footprint }) {
|
|
|
64
95
|
|
|
65
96
|
let skills = 0;
|
|
66
97
|
const skillsDir = path.join(dir, 'skills');
|
|
98
|
+
const userOnly = userOnlyTest(host, root);
|
|
67
99
|
for (const name of listDir(skillsDir)) {
|
|
68
100
|
const f = path.join(skillsDir, name, 'SKILL.md');
|
|
69
|
-
if (isFile(f)) skills += listedChars(f, name);
|
|
101
|
+
if (isFile(f)) skills += listedChars(f, name, (fm) => userOnly(name, fm));
|
|
70
102
|
}
|
|
71
103
|
let agents = 0;
|
|
72
104
|
if (host !== 'bob') {
|
package/js/inspect/diff.mjs
CHANGED
|
@@ -35,11 +35,15 @@
|
|
|
35
35
|
* directly rather than by comparison, because a check made tolerant of a value owes one
|
|
36
36
|
* somewhere else.
|
|
37
37
|
*/
|
|
38
|
+
import { createHash } from 'node:crypto';
|
|
38
39
|
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, statSync } from 'node:fs';
|
|
39
40
|
import os from 'node:os';
|
|
40
41
|
import path from 'node:path';
|
|
41
42
|
|
|
42
|
-
import { emitGlobalInto } from '../build/driver.mjs';
|
|
43
|
+
import { PRIMARY_AGENT_SRC, emitGlobalInto } from '../build/driver.mjs';
|
|
44
|
+
import { rglobFiles } from '../build/emit-common.mjs';
|
|
45
|
+
import { CONFIG, makeCfg } from '../build/source.mjs';
|
|
46
|
+
import { sourceFingerprint } from '../build/version.mjs';
|
|
43
47
|
import {
|
|
44
48
|
GLOBAL_MANIFEST, VERSION_MARKER, expanduser, opencodeConfigDir, resolvePath,
|
|
45
49
|
} from '../hosts/hosts.mjs';
|
|
@@ -53,6 +57,7 @@ import {
|
|
|
53
57
|
import { unifiedDiff, splitLines } from '../lib/udiff.mjs';
|
|
54
58
|
import { printOut, printErr, readText, writeText, isFile } from '../lib/fs.mjs';
|
|
55
59
|
import { toPlatformPath } from '../lib/paths.mjs';
|
|
60
|
+
import { relPosix } from '../lib/text.mjs';
|
|
56
61
|
|
|
57
62
|
/**
|
|
58
63
|
* `_harness_core._T0` — this process's start, in epoch seconds.
|
|
@@ -121,6 +126,81 @@ export function withStdoutSwallowed(fn) {
|
|
|
121
126
|
}
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/** A manifest's `rel`, spelled the way the expected snapshot keys it. */
|
|
130
|
+
const posixRel = (rel) => rel.split('\\').join('/');
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Render the `expected` tree for `host` at `axes` into a temp dir and SNAPSHOT it — every file,
|
|
134
|
+
* not only the owned ones, because a rel the DEPLOYED manifest claims is looked up here too and
|
|
135
|
+
* its presence decides "edited" against "added". The temp dir is gone before this returns.
|
|
136
|
+
*
|
|
137
|
+
* The one place a render depends on WHERE it is installed: a global Bob emit writes each
|
|
138
|
+
* folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
|
|
139
|
+
* reference render names the temp tree. Mapped back to the deployed dir here, or every such
|
|
140
|
+
* skill reads as edited. A deployment that has since MOVED still differs, and that is real
|
|
141
|
+
* drift — its skills point at a directory that is no longer theirs. A side-file pointer (and a
|
|
142
|
+
* global OpenCode command's) spells the same directory `/`-separated, so that spelling is
|
|
143
|
+
* mapped too.
|
|
144
|
+
*/
|
|
145
|
+
function renderExpected(host, axes, deployedDir) {
|
|
146
|
+
const tmp = mkdtempSync(path.join(os.tmpdir(), 'geneseed-diff-'));
|
|
147
|
+
try {
|
|
148
|
+
const expected = path.join(tmp, 'expected');
|
|
149
|
+
withStdoutSwallowed(() => emitGlobalInto(host, {
|
|
150
|
+
...axes, out: path.join(tmp, 'bundle'), cfgDir: expected,
|
|
151
|
+
}));
|
|
152
|
+
const posix = (p) => p.split(path.sep).join('/');
|
|
153
|
+
const texts = new Map();
|
|
154
|
+
for (const p of rglobFiles(expected)) {
|
|
155
|
+
texts.set(relPosix(expected, p), readText(p).split(expected).join(deployedDir)
|
|
156
|
+
.split(posix(expected)).join(posix(deployedDir)));
|
|
157
|
+
}
|
|
158
|
+
return { owned: ownedSet(expected), texts };
|
|
159
|
+
} finally {
|
|
160
|
+
// `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
|
|
161
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* A memo for `diffCollect`'s `expected` render — opt-in, so `geneseed diff` and every other
|
|
167
|
+
* caller still renders afresh. The web console passes one: `/api/overview` and `/api/fleet`
|
|
168
|
+
* both diff the selected install on every refresh, and the render is ~0.6 s of each.
|
|
169
|
+
*
|
|
170
|
+
* ONLY THE RENDER IS MEMOISED, NEVER THE RESULT: the compare against the deployed tree runs on
|
|
171
|
+
* every call, because catching a hand edit there is the whole point of the drift figure.
|
|
172
|
+
* One entry per deployed dir, at most `cap` of them (least recently used goes first); `renders`
|
|
173
|
+
* counts real renders, for the tests.
|
|
174
|
+
*/
|
|
175
|
+
export function renderMemo(cap = 4) {
|
|
176
|
+
return { cap, renders: 0, entries: new Map() };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The memoised `renderExpected`. The key is everything the render reads: the source
|
|
181
|
+
* fingerprint, the two render inputs it does not hash (`harness.config.json` and the
|
|
182
|
+
* orchestrator agent), the host and every axis. A deployed dir whose key changed is
|
|
183
|
+
* re-rendered in place, and a fingerprint change drops every entry from the old source.
|
|
184
|
+
*/
|
|
185
|
+
function memoExpected(memo, host, axes, deployedDir, sourceFp) {
|
|
186
|
+
const fp = sourceFp ?? sourceFingerprint(makeCfg());
|
|
187
|
+
const extra = createHash('sha256');
|
|
188
|
+
for (const p of [CONFIG, PRIMARY_AGENT_SRC]) extra.update(`${readMaybe(p) ?? ''}\0`);
|
|
189
|
+
const key = JSON.stringify([fp, extra.digest('hex'), host, axes]);
|
|
190
|
+
const hit = memo.entries.get(deployedDir);
|
|
191
|
+
memo.entries.delete(deployedDir);
|
|
192
|
+
if (hit && hit.key === key) {
|
|
193
|
+
memo.entries.set(deployedDir, hit);
|
|
194
|
+
return hit.exp;
|
|
195
|
+
}
|
|
196
|
+
for (const [d, e] of memo.entries) if (e.fp !== fp) memo.entries.delete(d);
|
|
197
|
+
const exp = renderExpected(host, axes, deployedDir);
|
|
198
|
+
memo.renders += 1;
|
|
199
|
+
memo.entries.set(deployedDir, { key, fp, exp });
|
|
200
|
+
while (memo.entries.size > memo.cap) memo.entries.delete(memo.entries.keys().next().value);
|
|
201
|
+
return exp;
|
|
202
|
+
}
|
|
203
|
+
|
|
124
204
|
/**
|
|
125
205
|
* `_harness_diff._diff_collect` — returns `{target, theme, files}`, with `files === null` for
|
|
126
206
|
* "nothing to compare here".
|
|
@@ -140,7 +220,9 @@ export function withStdoutSwallowed(fn) {
|
|
|
140
220
|
* choice, which is the same defect with teeth. `rebuild-all` never had it: it reads
|
|
141
221
|
* `postureOfDir`/`modeOfDir` per install root, and this now asks the same two questions.
|
|
142
222
|
*/
|
|
143
|
-
export function diffCollect({
|
|
223
|
+
export function diffCollect({
|
|
224
|
+
target = null, theme = null, emit = null, footprint = null, memo = null, sourceFp = null,
|
|
225
|
+
} = {}) {
|
|
144
226
|
// `Path(target).expanduser()` — expanded and NOT resolved, which is the opposite of
|
|
145
227
|
// `cmd_version`'s `.expanduser().resolve()` two files over. It is observable: `target` is
|
|
146
228
|
// printed on the summary line, so `--target .` reports `.` here and an absolute path
|
|
@@ -165,69 +247,58 @@ export function diffCollect({ target = null, theme = null, emit = null, footprin
|
|
|
165
247
|
|
|
166
248
|
const fp = footprint || footprintOfDir(dir);
|
|
167
249
|
|
|
250
|
+
// Every input the render reads off the deployment, in ONE object: it is what the render is
|
|
251
|
+
// called with AND what the memo keys on, so the two cannot drift apart.
|
|
252
|
+
const axes = {
|
|
253
|
+
theme: themeName, footprint: fp,
|
|
254
|
+
// The third register read off the deployment, for the same reason as the other two: an
|
|
255
|
+
// install built with `--doctrines craft` compared against an `expected` rendered at all
|
|
256
|
+
// four reports its entire doctrines section — and its `Active packs:` line — as drift.
|
|
257
|
+
// `null` (no marker) means unknown and renders at all four, which `doctrinesForBuild`
|
|
258
|
+
// makes explicit rather than leaving to `makeCfg`'s parameter default two layers down.
|
|
259
|
+
posture: postureOfDir(dir), mode: modeOfDir(dir), doctrines: doctrinesForBuild(dir),
|
|
260
|
+
// The loop skill's preset, or its `Default trust preset:` line reads as edited.
|
|
261
|
+
trust: trustOfDir(dir, host),
|
|
262
|
+
// The excluded rules, or an install built `--exclude-rules "process 5"` reads its
|
|
263
|
+
// carrier as drift: `expected` would re-state the rule the deployment took away.
|
|
264
|
+
excludeRules: excludedRulesOfDir(dir),
|
|
265
|
+
// The excluded skills, or every skill the install left out reads as deleted.
|
|
266
|
+
excludeSkills: excludedSkillsOfDir(dir),
|
|
267
|
+
// The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
|
|
268
|
+
...gatesOfDir(dir),
|
|
269
|
+
};
|
|
270
|
+
const deployedDir = resolvePath(dir);
|
|
271
|
+
const exp = memo
|
|
272
|
+
? memoExpected(memo, host, axes, deployedDir, sourceFp)
|
|
273
|
+
: renderExpected(host, axes, deployedDir);
|
|
274
|
+
|
|
168
275
|
const files = [];
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
// The loop skill's preset, or its `Default trust preset:` line reads as edited.
|
|
181
|
-
trust: trustOfDir(dir, host),
|
|
182
|
-
// The excluded rules, or an install built `--exclude-rules "process 5"` reads its
|
|
183
|
-
// carrier as drift: `expected` would re-state the rule the deployment took away.
|
|
184
|
-
excludeRules: excludedRulesOfDir(dir),
|
|
185
|
-
// The excluded skills, or every skill the install left out reads as deleted.
|
|
186
|
-
excludeSkills: excludedSkillsOfDir(dir),
|
|
187
|
-
// The gate switches, or a disarmed install reads its `<gate>: off` line as drift.
|
|
188
|
-
...gatesOfDir(dir),
|
|
189
|
-
}));
|
|
190
|
-
// The one place a render depends on WHERE it is installed: a global Bob emit writes each
|
|
191
|
-
// folder skill's absolute directory into its SKILL.md (see `writeNativeLayer`), so the
|
|
192
|
-
// reference render names the temp tree. Mapped back to the deployed dir before comparing,
|
|
193
|
-
// or every such skill reads as edited. A deployment that has since MOVED still differs,
|
|
194
|
-
// and that is real drift — its skills point at a directory that is no longer theirs.
|
|
195
|
-
// A side-file pointer (and a global OpenCode command's) spells the same directory
|
|
196
|
-
// `/`-separated, so that spelling is mapped too.
|
|
197
|
-
const deployedDir = resolvePath(dir);
|
|
198
|
-
const posix = (p) => p.split(path.sep).join('/');
|
|
199
|
-
const rels = [...new Set([...ownedSet(dir), ...ownedSet(expected)])].sort();
|
|
200
|
-
for (const rel of rels) {
|
|
201
|
-
const a = path.join(dir, rel);
|
|
202
|
-
const b = path.join(expected, rel);
|
|
203
|
-
if (isFile(a) && isFile(b)) {
|
|
204
|
-
// `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
|
|
205
|
-
// same bytes, and `readText` folds the line endings the way `Path.read_text` does.
|
|
206
|
-
const ta = readText(a);
|
|
207
|
-
const tb = readText(b).split(expected).join(deployedDir)
|
|
208
|
-
.split(posix(expected)).join(posix(deployedDir));
|
|
209
|
-
if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
|
|
210
|
-
const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
|
|
211
|
-
fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
|
|
212
|
-
});
|
|
213
|
-
files.push({ rel, status: 'edited', diff });
|
|
214
|
-
}
|
|
215
|
-
} else if (isFile(a)) {
|
|
216
|
-
const body = splitLines(readText(a));
|
|
217
|
-
files.push({
|
|
218
|
-
rel,
|
|
219
|
-
status: 'added',
|
|
220
|
-
diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
|
|
221
|
-
});
|
|
222
|
-
} else {
|
|
223
|
-
files.push({
|
|
224
|
-
rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'],
|
|
276
|
+
const rels = [...new Set([...ownedSet(dir), ...exp.owned])].sort();
|
|
277
|
+
for (const rel of rels) {
|
|
278
|
+
const a = path.join(dir, rel);
|
|
279
|
+
const tb = exp.texts.get(posixRel(rel));
|
|
280
|
+
if (isFile(a) && tb !== undefined) {
|
|
281
|
+
// `read_text(errors="replace")` — Node's utf8 decoder substitutes U+FFFD for the
|
|
282
|
+
// same bytes, and `readText` folds the line endings the way `Path.read_text` does.
|
|
283
|
+
const ta = readText(a);
|
|
284
|
+
if (cmpKey(rel, ta) !== cmpKey(rel, tb)) {
|
|
285
|
+
const diff = unifiedDiff(splitLines(tb), splitLines(ta), {
|
|
286
|
+
fromfile: `source/${rel}`, tofile: `deployed/${rel}`, lineterm: '',
|
|
225
287
|
});
|
|
288
|
+
files.push({ rel, status: 'edited', diff });
|
|
226
289
|
}
|
|
290
|
+
} else if (isFile(a)) {
|
|
291
|
+
const body = splitLines(readText(a));
|
|
292
|
+
files.push({
|
|
293
|
+
rel,
|
|
294
|
+
status: 'added',
|
|
295
|
+
diff: ['(only in deployed — your addition)', '', ...body.map((ln) => `+${ln}`)],
|
|
296
|
+
});
|
|
297
|
+
} else {
|
|
298
|
+
files.push({
|
|
299
|
+
rel, status: 'missing', diff: ['(in source, not deployed — re-emit to add)'],
|
|
300
|
+
});
|
|
227
301
|
}
|
|
228
|
-
} finally {
|
|
229
|
-
// `tempfile.TemporaryDirectory()`'s cleanup, which runs on the exception path too.
|
|
230
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
231
302
|
}
|
|
232
303
|
// Already sorted by `rel` above; the Python sorts again after the loop and so does this,
|
|
233
304
|
// because the two sorts are over different things — that one is over the RECORDS, and a
|