@sleepy_panda_srl/portulan 0.1.0 → 0.1.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/README.md +137 -50
- package/cli/README.md +91 -120
- package/cli/compile.mjs +242 -3
- package/cli/doctor.mjs +54 -1
- package/cli/index.mjs +92 -42
- package/cli/init.mjs +3 -2
- package/cli/inside.mjs +38 -0
- package/cli/pack-identity.mjs +180 -0
- package/cli/recipe-set.mjs +42 -1
- package/cli/skills-set.mjs +166 -4
- package/cli/stop-gate.mjs +401 -16
- package/cli/upgrade.mjs +2 -3
- package/cli/version-carriers.mjs +211 -0
- package/core/operating/memory.md +7 -8
- package/core/operating/verification.md +4 -12
- package/package.json +1 -1
- package/packs/README.md +11 -21
- package/packs/tools/README.md +1 -4
- package/spec/README.md +19 -22
- package/spec/migrations/README.md +2 -3
- package/spec/slots.md +4 -9
package/cli/inside.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Is one path inside another? One predicate, one file, no dependencies.
|
|
2
|
+
//
|
|
3
|
+
// **It lives alone because two large modules need it and must not import each other.** It began in
|
|
4
|
+
// `./index.mjs`, which `./doctor.mjs` imports; when `./compile.mjs` needed it too, importing it from
|
|
5
|
+
// there would have closed a cycle — `index.mjs` already imports `resolvePack`/`rootPlan` from
|
|
6
|
+
// `compile.mjs`. This repository has paid for a cycle once already: `../docs/plan.md`'s session log
|
|
7
|
+
// records **an import cycle exiting 13 in silence**, and three modules (`./plugin-lint.mjs:1369`,
|
|
8
|
+
// `./skills-set.mjs:48` and `:114`) carry comments explaining that they chose their import direction
|
|
9
|
+
// to avoid making one. Copying the predicate instead was the other option and is the one its own
|
|
10
|
+
// docblock forbids: two copies of this rule drifted into the identical defect before either shipped.
|
|
11
|
+
//
|
|
12
|
+
// So the rule keeps one implementation and gains no edges. `./index.mjs` re-exports it, so every
|
|
13
|
+
// existing importer is untouched.
|
|
14
|
+
//
|
|
15
|
+
// Found by Copilot on the change that closed #264, which had reached for the `index.mjs` export.
|
|
16
|
+
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Is `child` inside `parent`?
|
|
21
|
+
*
|
|
22
|
+
* **The obvious spelling — `!path.relative(parent, child).startsWith("..")` — is wrong, and it fails
|
|
23
|
+
* OPEN.** A name beginning with `..` is an ordinary filename, not a traversal: `path.relative` of
|
|
24
|
+
* `memory/..index.md` against `memory/` is `..index.md`, which `startsWith("..")` calls *outside*.
|
|
25
|
+
* Measured end to end before this was written — an index declared at `memory/..index.md` was written
|
|
26
|
+
* into the store, reported `ok`, and `doctor` then counted it as a second record. That is the ninth
|
|
27
|
+
* fail-open found in this repository's scaffolding, and the first one written by the change that
|
|
28
|
+
* cites the class; `recordedOrigin` in `./compile.mjs` was the second, which is why this moved here.
|
|
29
|
+
*
|
|
30
|
+
* Only `..` exactly, or `..` followed by a separator, means outside. An absolute result means the two
|
|
31
|
+
* paths share no root and cannot contain one another. A path is inside itself.
|
|
32
|
+
*/
|
|
33
|
+
export function isInside(parent, child) {
|
|
34
|
+
const rel = path.relative(parent, child);
|
|
35
|
+
if (rel === "") return true;
|
|
36
|
+
if (path.isAbsolute(rel)) return false;
|
|
37
|
+
return rel !== ".." && !rel.startsWith(`..${path.sep}`);
|
|
38
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Every file `npm pack` emits is byte-identical to the tracked blob it came from.
|
|
3
|
+
//
|
|
4
|
+
// The rail [#149](https://github.com/sleepy-panda-srl/portulan/issues/149) asked for, in the form that
|
|
5
|
+
// can actually run on every pull request. `.portulan/identity.md` states the property — **the `npx`
|
|
6
|
+
// path IS an install, and it installs the SAME BYTES** — and until 2026-08-18 nothing checked it: the
|
|
7
|
+
// figure was re-measured by hand three times (72 files, then 114, then 73) and was wrong in the tree
|
|
8
|
+
// twice between those measurements.
|
|
9
|
+
//
|
|
10
|
+
// ## Why this compares against the TREE and not against the registry
|
|
11
|
+
//
|
|
12
|
+
// The measurement taken at the first publish compared the published tarball to a fresh `npm pack`, and
|
|
13
|
+
// they hashed the same. That is the stronger claim and it is recorded on #149 — but it is the wrong
|
|
14
|
+
// shape for a recipe: it needs the network, it needs a published version to exist, and it cannot run
|
|
15
|
+
// before the first publish or on a machine offline. A recipe that cannot run is a recipe that stops
|
|
16
|
+
// being run.
|
|
17
|
+
//
|
|
18
|
+
// What this checks instead needs neither: `npm pack --dry-run --json` lists what the package would
|
|
19
|
+
// contain, and every one of those paths must be byte-identical to its STAGED blob, `git show :<path>`
|
|
20
|
+
// — the index rather than HEAD, for the reason the comment on `blobAt` gives. That is the
|
|
21
|
+
// property the registry comparison DEPENDS on — if the packed bytes match the tree, and the published
|
|
22
|
+
// tarball matches a fresh pack, then the published bytes are the tree's. This rail owns the first half,
|
|
23
|
+
// which is the half that can drift silently on any commit; the second half is a hand measurement taken
|
|
24
|
+
// at each publish, which is what #149 asked for and what its comment records.
|
|
25
|
+
//
|
|
26
|
+
// ## Exit codes, per ../.portulan/memory/verify-preconditions-fail-closed.md
|
|
27
|
+
//
|
|
28
|
+
// 0 every packed file matches its staged blob
|
|
29
|
+
// 1 a finding: at least one packed file differs from what is staged, or is not tracked at all
|
|
30
|
+
// 2 could not run: `npm pack` unusable, not a git repository, or HEAD unreadable
|
|
31
|
+
//
|
|
32
|
+
// The 2 matters more here than in most recipes. `npm pack` shells out to npm, which can be absent,
|
|
33
|
+
// offline-hostile, or a different major version — and a rail that reports "the bytes differ" when what
|
|
34
|
+
// happened is "npm did not run" sends someone hunting a drift that does not exist.
|
|
35
|
+
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
37
|
+
import fs from "node:fs";
|
|
38
|
+
import path from "node:path";
|
|
39
|
+
import { pathToFileURL } from "node:url";
|
|
40
|
+
import process from "node:process";
|
|
41
|
+
|
|
42
|
+
class CannotRun extends Error {}
|
|
43
|
+
|
|
44
|
+
const run = (cmd, args, opts = {}) => execFileSync(cmd, args, { encoding: "buffer", maxBuffer: 64 * 1024 * 1024, ...opts });
|
|
45
|
+
|
|
46
|
+
/** What the package would contain, as repo-relative paths. */
|
|
47
|
+
function packedPaths(root) {
|
|
48
|
+
let out;
|
|
49
|
+
try {
|
|
50
|
+
out = run("npm", ["pack", "--dry-run", "--json"], { cwd: root, stdio: ["ignore", "pipe", "pipe"] }).toString("utf8");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
throw new CannotRun(`\`npm pack --dry-run --json\` did not run: ${error.message.split("\n")[0]}`);
|
|
53
|
+
}
|
|
54
|
+
let parsed;
|
|
55
|
+
try {
|
|
56
|
+
parsed = JSON.parse(out);
|
|
57
|
+
} catch {
|
|
58
|
+
throw new CannotRun("`npm pack --dry-run --json` printed something that is not JSON");
|
|
59
|
+
}
|
|
60
|
+
const files = parsed?.[0]?.files;
|
|
61
|
+
if (!Array.isArray(files) || files.length === 0) throw new CannotRun("`npm pack` reported no files — refusing to report a green over an empty roster");
|
|
62
|
+
// The SHAPE is checked, not assumed. `files` is npm's JSON, and a changed schema or an odd entry
|
|
63
|
+
// would otherwise yield `undefined` paths that crash further down — surfacing as an uncaught
|
|
64
|
+
// exception the runner reads as a red FINDING about the package. npm's output is an input like any
|
|
65
|
+
// other; a roster this tool cannot read is could-not-run.
|
|
66
|
+
const paths = [];
|
|
67
|
+
for (const [i, entry] of files.entries()) {
|
|
68
|
+
const rel = entry?.path;
|
|
69
|
+
if (typeof rel !== "string" || rel.length === 0) {
|
|
70
|
+
throw new CannotRun(`\`npm pack\` reported an entry at index ${i} with no usable \`path\` — its JSON shape is not what this rail reads`);
|
|
71
|
+
}
|
|
72
|
+
paths.push(rel);
|
|
73
|
+
}
|
|
74
|
+
return paths;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The blob is read from the INDEX (`git show :<path>`), not from HEAD, and the difference is the whole
|
|
78
|
+
// usability of this rail. `npm pack` reads the WORKING TREE, so comparing it to HEAD makes the recipe red
|
|
79
|
+
// for any uncommitted edit — and `.portulan/dod.md` requires every recipe green BEFORE a commit, which a
|
|
80
|
+
// check that can only pass after one contradicts. The index is what is about to become the commit, so
|
|
81
|
+
// staged work compares clean and unstaged work is correctly still a difference. In CI the distinction
|
|
82
|
+
// vanishes: `actions/checkout` leaves the index matching HEAD, so this reads exactly as HEAD there.
|
|
83
|
+
/** The staged blob for a path, or null when the path is not tracked. */
|
|
84
|
+
function blobAt(root, rel) {
|
|
85
|
+
try {
|
|
86
|
+
return run("git", ["show", `:${rel}`], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
|
|
87
|
+
} catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Every path npm reports is checked for CONTAINMENT before it is used, by RESOLUTION rather than by
|
|
93
|
+
// pattern — the shape `cli/eval-bundle.mjs` was hardened to on #280, for the same reason: a pattern
|
|
94
|
+
// test answers a question about a string, and what matters is where the filesystem actually goes.
|
|
95
|
+
// `rel` arrives from `npm pack`'s JSON, so it is not this tool's to trust: an absolute path, a `..`
|
|
96
|
+
// escape or an embedded NUL would send both `git show` and the read outside the repository, and the
|
|
97
|
+
// rail would then compare the wrong bytes and report GREEN. Refused as could-not-run, never as a
|
|
98
|
+
// finding — a path this tool will not resolve supports no verdict about the package.
|
|
99
|
+
//
|
|
100
|
+
// Symlinks are refused for the same reason and separately from containment: `readFileSync` follows
|
|
101
|
+
// them, so a link whose target sits outside the tree reads bytes git never carried while `git show`
|
|
102
|
+
// reads the link itself. `lstat` is what distinguishes them, and `realpath` alone would not.
|
|
103
|
+
function containedPath(root, rel) {
|
|
104
|
+
if (rel.includes("\0")) throw new CannotRun(`npm reported a path containing NUL — refusing to resolve it`);
|
|
105
|
+
if (path.isAbsolute(rel)) throw new CannotRun(`npm reported an absolute path (${rel}); packed paths are repo-relative`);
|
|
106
|
+
const resolved = path.resolve(root, rel);
|
|
107
|
+
const base = path.resolve(root);
|
|
108
|
+
if (resolved !== base && !resolved.startsWith(base + path.sep)) {
|
|
109
|
+
throw new CannotRun(`npm reported a path that resolves outside the repository: ${rel}`);
|
|
110
|
+
}
|
|
111
|
+
let stat;
|
|
112
|
+
try {
|
|
113
|
+
stat = fs.lstatSync(resolved);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
throw new CannotRun(`packed path ${rel} could not be stat'd: ${error.message.split("\n")[0]}`);
|
|
116
|
+
}
|
|
117
|
+
if (stat.isSymbolicLink()) throw new CannotRun(`packed path ${rel} is a symlink; its target is not what git carries`);
|
|
118
|
+
if (!stat.isFile()) throw new CannotRun(`packed path ${rel} is not a regular file`);
|
|
119
|
+
return resolved;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function compare(root) {
|
|
123
|
+
try {
|
|
124
|
+
run("git", ["rev-parse", "--verify", "HEAD"], { cwd: root, stdio: ["ignore", "pipe", "ignore"] });
|
|
125
|
+
} catch {
|
|
126
|
+
throw new CannotRun("not a git repository, or HEAD does not resolve");
|
|
127
|
+
}
|
|
128
|
+
const packed = packedPaths(root);
|
|
129
|
+
const untracked = [];
|
|
130
|
+
const differing = [];
|
|
131
|
+
for (const rel of packed) {
|
|
132
|
+
// `package.json` is the one path npm may rewrite on pack — it normalises fields. Compared like
|
|
133
|
+
// every other file rather than exempted: the 2026-08-13 measurement asserted all 114 identical
|
|
134
|
+
// "package.json included, with no exemption", and an exemption added here would quietly retire
|
|
135
|
+
// that claim.
|
|
136
|
+
const abs = containedPath(root, rel);
|
|
137
|
+
const blob = blobAt(root, rel);
|
|
138
|
+
if (blob === null) untracked.push(rel);
|
|
139
|
+
// Read with `fs`, not by shelling out. `cat` would be an undeclared dependency this recipe's
|
|
140
|
+
// `requires` does not list, and it takes its argument as an OPTION when a filename begins with
|
|
141
|
+
// `-` — so a file named `-n` would silently be read as a flag rather than compared.
|
|
142
|
+
else if (!blob.equals(fs.readFileSync(abs))) differing.push(rel);
|
|
143
|
+
}
|
|
144
|
+
return { packed, untracked, differing };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function main(argv, stdout, stderr) {
|
|
148
|
+
const root = argv[2] ?? process.cwd();
|
|
149
|
+
let result;
|
|
150
|
+
try {
|
|
151
|
+
result = compare(root);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof CannotRun) {
|
|
154
|
+
stderr.write(`pack-identity: could not run — ${error.message}\n`);
|
|
155
|
+
return 2;
|
|
156
|
+
}
|
|
157
|
+
// An unexpected throw is COULD-NOT-RUN, never a finding. Rethrowing would exit non-contractually
|
|
158
|
+
// — node's own code, typically 1 — and the runner would read a crash in this rail as a verdict
|
|
159
|
+
// about the package's bytes. `1` is reserved for a finding; this repository has a commit by that
|
|
160
|
+
// name. The diagnostic names the rail so the exit is traceable to the thing that broke.
|
|
161
|
+
stderr.write(`pack-identity: could not run — unexpected ${error?.name ?? "error"}: ${String(error?.message ?? error).split("\n")[0]}\n`);
|
|
162
|
+
return 2;
|
|
163
|
+
}
|
|
164
|
+
const { packed, untracked, differing } = result;
|
|
165
|
+
if (untracked.length === 0 && differing.length === 0) {
|
|
166
|
+
stdout.write(`ok pack-identity — all ${packed.length} packed file(s) are byte-identical to their staged blob\n`);
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
for (const rel of untracked) stderr.write(`pack-identity: ${rel} would ship but is NOT TRACKED — the package would carry a byte nobody reviewed\n`);
|
|
170
|
+
for (const rel of differing) stderr.write(`pack-identity: ${rel} differs from \`git show :${rel}\` — the package would not install the tree's bytes\n`);
|
|
171
|
+
stderr.write(`pack-identity: ${untracked.length + differing.length} of ${packed.length} packed file(s) failed; .portulan/identity.md's same-bytes claim does not hold\n`);
|
|
172
|
+
return 1;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The same guard `pack-version.mjs` uses. A bare path comparison is FALSE when npm installs a bin as a
|
|
176
|
+
// symlink — node realpaths the main module for `import.meta.url` while `process.argv[1]` keeps the link.
|
|
177
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
178
|
+
process.exitCode = main(process.argv, process.stdout, process.stderr);
|
|
179
|
+
}
|
|
180
|
+
export { main };
|
package/cli/recipe-set.mjs
CHANGED
|
@@ -61,7 +61,8 @@ import fs from "node:fs";
|
|
|
61
61
|
import path from "node:path";
|
|
62
62
|
import { pathToFileURL } from "node:url";
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
// `shadowedCopy` rides along: one carrier for *is this pack shadowed*, per #316.
|
|
65
|
+
import { resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
|
|
65
66
|
import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
|
|
66
67
|
|
|
67
68
|
/**
|
|
@@ -288,6 +289,46 @@ export function resolverFor({ workspaceDir, manifest, repoRoot = ".", named = []
|
|
|
288
289
|
if (plan.refusal) throw new Error(`recipe-set: ${plan.refusal}`);
|
|
289
290
|
if (plan.couldNotRun) throw new Error(`recipe-set: ${plan.couldNotRun}`);
|
|
290
291
|
const roots = plan.roots ?? [];
|
|
292
|
+
const originAt = (r) => (plan.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin;
|
|
293
|
+
|
|
294
|
+
// **A shadowed pack is refused here too** (#318), and this tool's ground is the strongest of the
|
|
295
|
+
// three. `compile` refuses even an agreeing shadow because its artifact records which root
|
|
296
|
+
// answered; `index` refuses one although its artifact deliberately does not. Here the answering
|
|
297
|
+
// root is not merely recorded — **it is spliced into the output**: `${PACK_ROOT}` expands to the
|
|
298
|
+
// root that answered, so two copies whose manifests are byte-identical still yield different `run`
|
|
299
|
+
// lines pointing at different files. What CI runs and what a bare local run runs would diverge,
|
|
300
|
+
// with nothing in either output to say so.
|
|
301
|
+
//
|
|
302
|
+
// So the agreeing case is not a carve-out anyone could argue for here, and the honest predicate was
|
|
303
|
+
// never manifest comparison: it is output equality, computable only by resolving both worlds.
|
|
304
|
+
// Refusing the ambiguity is what remains.
|
|
305
|
+
//
|
|
306
|
+
// **Checked HERE rather than inside the returned closure**, which is where a first cut put it. The
|
|
307
|
+
// closure runs during `recipeSet`, outside the `try` that wraps this constructor — so the refusal
|
|
308
|
+
// escaped as an uncaught throw, exit 1 with a stack trace, in a tool whose whole contract is exit 2
|
|
309
|
+
// for could-not-run. Measured, not reasoned about. Throwing from the constructor puts it on the
|
|
310
|
+
// path `plan.refusal` already uses and the caller already catches.
|
|
311
|
+
//
|
|
312
|
+
// **Only where the caller did not choose** — a named root replaces the derived one, `auto` is
|
|
313
|
+
// discovery elected — which is `compile`'s guard shape, bound to it deliberately.
|
|
314
|
+
if (!forced && plan.source !== "named") {
|
|
315
|
+
const declared = Array.isArray(manifest?.packs) ? manifest.packs : [];
|
|
316
|
+
for (const ref of declared) {
|
|
317
|
+
const found = resolvePack(String(ref), roots);
|
|
318
|
+
if (!found?.dir) continue;
|
|
319
|
+
// Path-resolved, matching `compile` — see the sibling note in `index.mjs`.
|
|
320
|
+
const behind = shadowedCopy(String(ref), originAt(found.root), roots, originAt);
|
|
321
|
+
if (!behind) continue;
|
|
322
|
+
throw new Error(
|
|
323
|
+
`recipe-set: \`${ref}\` is SHADOWED — it resolved under ${found.root}, a root discovered on ` +
|
|
324
|
+
`this host, while the root ${path.relative(path.resolve(repoRoot), behind.root)} also carries it. ` +
|
|
325
|
+
"A composed recipe's ${PACK_ROOT} expands to whichever root answered, so the two compose to " +
|
|
326
|
+
"run lines pointing at different files. Refusing to pick: name the root — `--pack-root packs` " +
|
|
327
|
+
"for the tree, which is what CI runs, or `--pack-root auto` for the installed copy.",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
291
332
|
return (ref) => {
|
|
292
333
|
const found = resolvePack(ref, roots);
|
|
293
334
|
// `resolvePack` returns `manifest` as the PATH to `pack.json`, not as the parsed object —
|
package/cli/skills-set.mjs
CHANGED
|
@@ -75,6 +75,33 @@
|
|
|
75
75
|
* every manifest in this repository already has. A manifest formatted otherwise is reformatted, and
|
|
76
76
|
* that is a real cost rather than a hidden one.
|
|
77
77
|
*
|
|
78
|
+
* ## A pack two roots answer for is refused, not picked
|
|
79
|
+
*
|
|
80
|
+
* On a host carrying an installed copy of a composed pack **and** the tree's own, resolution is
|
|
81
|
+
* discovered-first and first-match-wins, so the installed copy answers. That copy lives outside the
|
|
82
|
+
* plugin root, containment fails, and this tool concluded the tree's declared skills path belonged to
|
|
83
|
+
* no composed pack — printing `--write` as the remedy, where `--write` is the mutation that strips a
|
|
84
|
+
* correct declaration out of a tracked manifest
|
|
85
|
+
* ([#317](https://github.com/sleepy-panda-srl/portulan/issues/317)).
|
|
86
|
+
*
|
|
87
|
+
* So this refuses at RESOLUTION — exit 2 — where `compile` refuses for the same family
|
|
88
|
+
* ([#316](https://github.com/sleepy-panda-srl/portulan/issues/316)), and for the same reason: the
|
|
89
|
+
* printer, `--check` and `--write` inherit ONE rule rather than three. Refusing on `--write` alone
|
|
90
|
+
* would leave `--check` exiting **1**, asserting the *repository* had drifted when it had not — and an
|
|
91
|
+
* exit code is the machine-read API, so a false 1 is a false verdict about the tree.
|
|
92
|
+
*
|
|
93
|
+
* **The predicate is imported, not re-spelled.** `shadowedCopy` and `packDifferences` live in
|
|
94
|
+
* `./compile.mjs`. That comparison had already been wrong twice in `doctor` before it became one
|
|
95
|
+
* carrier, and a third copy written inside a fix for its own family is precisely the defect
|
|
96
|
+
* `../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md` names.
|
|
97
|
+
*
|
|
98
|
+
* **Where this tool's reason is SHARPER than `compile`'s, and the message says so.** `compile` refuses
|
|
99
|
+
* a shadow whose manifests agree because `recordedOrigin` writes the answering root into the artifact,
|
|
100
|
+
* so agreement in the manifests is not agreement in the bytes. Here agreement is beside the point in a
|
|
101
|
+
* more direct way: the two copies sit on opposite sides of the plugin root, so which one answers
|
|
102
|
+
* decides whether this pack contributes a `skills` path **at all**. Version parity does not save it,
|
|
103
|
+
* which is why this fires on hosts where `compile`'s difference-driven half would have nothing to say.
|
|
104
|
+
*
|
|
78
105
|
* Zero dependencies, no network, no install step — the same constraints as every tool here.
|
|
79
106
|
*/
|
|
80
107
|
|
|
@@ -93,7 +120,7 @@ import { pathToFileURL } from "node:url";
|
|
|
93
120
|
// root wins outright; otherwise discovery is unioned with the derived root, discovered first** — asking
|
|
94
121
|
// only selects the strict degrade. This tool exited **2** on the ordinary drafted workspace until then,
|
|
95
122
|
// which is what the row's "optional where discovery finds a root" was not.)_
|
|
96
|
-
import { resolvePack, rootPlan } from "./compile.mjs";
|
|
123
|
+
import { packDifferences, recordedOrigin, resolvePack, rootPlan, shadowedCopy } from "./compile.mjs";
|
|
97
124
|
import { AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
|
|
98
125
|
|
|
99
126
|
/**
|
|
@@ -425,6 +452,114 @@ export function declaredFor(set, pluginManifest, pluginRoot) {
|
|
|
425
452
|
return [...portion.outside, ...set.paths.map((p) => p.path)];
|
|
426
453
|
}
|
|
427
454
|
|
|
455
|
+
/**
|
|
456
|
+
* The refusal a shadowed pack earns, or `null` when no declared pack is shadowed.
|
|
457
|
+
*
|
|
458
|
+
* Separated from `run` so the sentence a reader gets is testable without a command line, and kept in
|
|
459
|
+
* this file rather than pushed into `compile.mjs` because the CONSEQUENCE clause is this tool's own:
|
|
460
|
+
* `compile` talks about emitted policy, and this one talks about whether a `skills` path is derived.
|
|
461
|
+
* What is shared — *is there a second copy behind the one that answered, and do the two differ* — is
|
|
462
|
+
* imported, and that split is the point.
|
|
463
|
+
*
|
|
464
|
+
* Only the **unasked** path reaches here. `--pack-root <dir>` names a root, which replaces the derived
|
|
465
|
+
* one and leaves nothing behind it to shadow; `--pack-root auto` is discovery ELECTED, and refusing
|
|
466
|
+
* somebody for answering the question the refusal asks would be incoherent. Both spellings are offered
|
|
467
|
+
* in the message for that reason — naming only the tree would make the refusal decide the question it
|
|
468
|
+
* claims to hand back.
|
|
469
|
+
*/
|
|
470
|
+
export function shadowRefusal({ packs, roots, plan, repoRoot, pluginRoot }) {
|
|
471
|
+
const originOf = (r) => (plan?.origins ?? []).find((o) => path.resolve(o.root) === path.resolve(r))?.origin;
|
|
472
|
+
for (const ref of Array.isArray(packs) ? packs : []) {
|
|
473
|
+
const found = resolvePack(ref, roots);
|
|
474
|
+
if (!found?.dir || !found.manifest) continue;
|
|
475
|
+
const behind = shadowedCopy(found.name, recordedOrigin(found.root, plan, repoRoot), roots, originOf);
|
|
476
|
+
if (!behind) continue;
|
|
477
|
+
|
|
478
|
+
const there = path.relative(repoRoot, behind.root) || behind.root;
|
|
479
|
+
// **The tree spelling is the root we just named, not the conventional guess.** Both arms below
|
|
480
|
+
// hard-coded `--pack-root packs`, and the derived root is `path.resolve(workspaceDir, tree,
|
|
481
|
+
// "packs")` — so a workspace whose `tree` is `../nested/` derives `nested/packs` and was being
|
|
482
|
+
// told to pass a directory that does not carry the pack. That is this branch's own defect class
|
|
483
|
+
// one more time: a message prescribing a remedy it had not checked, in the sentence whose whole
|
|
484
|
+
// job is to hand back an accurate choice. `there` is already the relative spelling of that root,
|
|
485
|
+
// and for the conventional layout it is exactly `packs`, so the common output is unchanged.
|
|
486
|
+
//
|
|
487
|
+
// The `verify/plugin.sh` clause is gated with it, because that rail pins the literal
|
|
488
|
+
// `--pack-root packs`: claiming the equivalence for `nested/packs` would replace one false
|
|
489
|
+
// sentence with another. Raised through the promoted suppressed-note channel, which carries
|
|
490
|
+
// findings the inline round does not.
|
|
491
|
+
const rail = there === "packs" ? ", which is what `verify/plugin.sh` checks" : "";
|
|
492
|
+
|
|
493
|
+
// **Read and parse are two failures with two repairs**, and one sentence for both misdiagnoses
|
|
494
|
+
// the commoner one — the defect Copilot raised against `run`'s workspace arm on #229, whose
|
|
495
|
+
// comment sits one screen below this and which the first cut of this block reintroduced
|
|
496
|
+
// anyway. A `SyntaxError` from `JSON.parse` carries no `.code`, so a collapsed arm reporting
|
|
497
|
+
// `error.code ?? error.message` under *could not be read* sends somebody with a malformed
|
|
498
|
+
// manifest to look at permissions. `readManifest` and `resolverFor` both keep the two apart;
|
|
499
|
+
// so does this. It also names WHICH of the two copies is the broken one, which the collapsed
|
|
500
|
+
// form could not — with two manifests in play, "one of the two" is half a diagnosis.
|
|
501
|
+
const load = (file) => {
|
|
502
|
+
let text;
|
|
503
|
+
try {
|
|
504
|
+
text = fs.readFileSync(file, "utf8");
|
|
505
|
+
} catch (error) {
|
|
506
|
+
return { why: `could not be read — ${error.code ?? error.message}` };
|
|
507
|
+
}
|
|
508
|
+
try {
|
|
509
|
+
return { value: JSON.parse(text) };
|
|
510
|
+
} catch (error) {
|
|
511
|
+
return { why: `does not parse as JSON — ${error.message}` };
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const here = load(found.manifest);
|
|
515
|
+
const beneath = load(behind.manifest);
|
|
516
|
+
const broken = here.why ? { at: found.root, why: here.why } : beneath.why ? { at: there, why: beneath.why } : null;
|
|
517
|
+
if (broken) {
|
|
518
|
+
// Could-not-run wearing its own sentence rather than the SHADOWED one: with a manifest
|
|
519
|
+
// unreadable, *what differs* is unanswerable, and a refusal that guessed would be claiming
|
|
520
|
+
// to have compared two things it could not read. The remedy is the same either way.
|
|
521
|
+
return (
|
|
522
|
+
`the pack \`${found.name}\` resolved under the root ${found.root} while the root ${there} also ` +
|
|
523
|
+
`carries it, and the copy under ${broken.at} ${broken.why} — so which one this would derive ` +
|
|
524
|
+
`from could not be established. Name the root: \`--pack-root ${there}\` derives from the tree${rail}, ` +
|
|
525
|
+
"`--pack-root auto` from the installed copy"
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const mine = here.value;
|
|
529
|
+
const other = beneath.value;
|
|
530
|
+
|
|
531
|
+
// **Containment, computed rather than assumed.** The sharp case — one copy inside the plugin
|
|
532
|
+
// root and one outside — is what makes `--write` destructive, but it is not a law: a
|
|
533
|
+
// `--plugin-root` pointing elsewhere puts both outside, and the refusal still stands because
|
|
534
|
+
// the derived path is relative to whichever root answered. Asserting the sharp case
|
|
535
|
+
// unconditionally would be this message's first lie, in the sentence that exists to be exact.
|
|
536
|
+
//
|
|
537
|
+
// Tested against the ROOTS, which is also what `packPortion` partitions on — `resolvePack`
|
|
538
|
+
// builds `dir` as `<root>/<category>/<pack>`, so the two can never disagree here, and using the
|
|
539
|
+
// root keeps this predicate spelled the same way as the derivation it is predicting.
|
|
540
|
+
const differs = packDifferences(mine, other);
|
|
541
|
+
const split = escapes(pluginRoot, path.resolve(found.root)) !== escapes(pluginRoot, path.resolve(behind.root));
|
|
542
|
+
// **Both ROOTS by path — the roots, not the pack directories.** The sibling refusal in
|
|
543
|
+
// `compile` shipped `dir` while calling it a root, which mislabels the thing it names in a
|
|
544
|
+
// message whose own claim is to name both; the root is also the half a reader types back into
|
|
545
|
+
// `--pack-root`. Corrected here in the same stroke rather than left to be found twice.
|
|
546
|
+
return (
|
|
547
|
+
`the pack \`${found.name}\` is SHADOWED — it resolved under ${found.root}, a root discovered on this ` +
|
|
548
|
+
`host, while the root ${there} also carries it. ` +
|
|
549
|
+
(differs.length
|
|
550
|
+
? `They differ by ${differs.join(" and ")}, and which root answers decides the derived set anyway: `
|
|
551
|
+
: "Their manifests agree, and here that changes nothing: ") +
|
|
552
|
+
(split
|
|
553
|
+
? "the two sit on opposite sides of this plugin root, so one of them derives a `skills` path for " +
|
|
554
|
+
"this pack and the other derives none — `--write` would act on whichever answered. "
|
|
555
|
+
: "the two roots derive different `skills` paths, and `--write` would act on whichever answered. ") +
|
|
556
|
+
`Refusing to pick: name the root instead — \`--pack-root ${there}\` derives from the tree${rail}, ` +
|
|
557
|
+
"and `--pack-root auto` from the installed copy"
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
return null;
|
|
561
|
+
}
|
|
562
|
+
|
|
428
563
|
/**
|
|
429
564
|
* A resolver over a workspace's declared pack roots.
|
|
430
565
|
*
|
|
@@ -545,8 +680,11 @@ function readManifest(file, pluginRoot) {
|
|
|
545
680
|
* The runnable surface: the printer CI and an adopter's own pipeline call, plus `--check` and
|
|
546
681
|
* `--write`.
|
|
547
682
|
*
|
|
548
|
-
* `options.manifest` and `options.
|
|
549
|
-
* its reader. No production caller passes
|
|
683
|
+
* `options.manifest`, `options.resolve` and `options.discovery` are injection points for the suite, the
|
|
684
|
+
* way `init` injects its reader. No production caller passes any of them; every flag below is the real
|
|
685
|
+
* surface. `discovery` is injected as a THUNK like the production one, because the guarantee that a
|
|
686
|
+
* branch which cannot use the answer never touches the host is a property of the ARGUMENT — a suite
|
|
687
|
+
* handed a pre-computed value would be testing a shape production never takes.
|
|
550
688
|
*/
|
|
551
689
|
export function run(argv = [], options = {}) {
|
|
552
690
|
const { stdout = process.stdout, stderr = process.stderr } = options;
|
|
@@ -623,7 +761,7 @@ export function run(argv = [], options = {}) {
|
|
|
623
761
|
// caller wiring no thunk stays hermetic on every arm.)_
|
|
624
762
|
const plan = rootPlan(workspaceDir, manifest, {
|
|
625
763
|
named: namedRoots,
|
|
626
|
-
discovery: () => discoverPackRoots(),
|
|
764
|
+
discovery: options.discovery ?? (() => discoverPackRoots()),
|
|
627
765
|
forced: wantsDiscovery,
|
|
628
766
|
});
|
|
629
767
|
if (plan.refusal || plan.couldNotRun) {
|
|
@@ -631,6 +769,30 @@ export function run(argv = [], options = {}) {
|
|
|
631
769
|
return 2;
|
|
632
770
|
}
|
|
633
771
|
const roots = plan.roots ?? [];
|
|
772
|
+
// **Refused here, above the mode branches, so the printer, `--check` and `--write` inherit one
|
|
773
|
+
// rule** (#317). The damage this prevents is specific to the write path — the tool printed
|
|
774
|
+
// `run with --write` while `--write` would have stripped a correct, tracked `skills` entry on
|
|
775
|
+
// the strength of what happens to be installed on the machine — but guarding only `--write`
|
|
776
|
+
// would leave `--check` exiting 1 about a repository that had not drifted, which is a false
|
|
777
|
+
// verdict handed to a machine. `compile` settled the same question the same way for #316.
|
|
778
|
+
//
|
|
779
|
+
// A caller that injected its own `resolve` never reaches this, and that is deliberate rather
|
|
780
|
+
// than a hole: it chose its own roots, which is the same election `--pack-root` makes.
|
|
781
|
+
//
|
|
782
|
+
// **The `named` half of this condition is defence in depth, and it was measured HERE rather
|
|
783
|
+
// than inherited from `compile`'s note.** Forcing the whole condition to `true` reds exactly
|
|
784
|
+
// one case — the `--pack-root auto` exemption — while the named-root case goes on passing: a
|
|
785
|
+
// named root REPLACES the derived one, so the plan carries no discovered root, `shadowedCopy`
|
|
786
|
+
// finds nothing behind it, and there is no shadow to refuse. The protection is structural, and
|
|
787
|
+
// no test in this suite binds the clause. It stays because it states the intent at the site
|
|
788
|
+
// where a future change to `resolutionRoots` could quietly make it load-bearing.
|
|
789
|
+
if (!wantsDiscovery && plan.source !== "named") {
|
|
790
|
+
const shadowed = shadowRefusal({ packs: manifest?.packs, roots, plan, repoRoot, pluginRoot });
|
|
791
|
+
if (shadowed) {
|
|
792
|
+
stderr.write(`skills-set: ${shadowed}\n`);
|
|
793
|
+
return 2;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
634
796
|
resolve = resolverFor({ workspaceDir, manifest, roots });
|
|
635
797
|
// The same roots stand in as the owned set when nothing resolves — so a workspace that has
|
|
636
798
|
// stopped composing gets its stale entries cleaned wherever its packs WOULD have been, not
|