@sleepy_panda_srl/portulan 0.1.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/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// A change to a pack's `contributes` must move that pack's `portulan.version`.
|
|
3
|
+
//
|
|
4
|
+
// Ruled by the maintainer on `#265`, 2026-08-14, in three parts: **arm 3** — a rail rather than a
|
|
5
|
+
// convention; **a prose-only edit counts**, so a fragment whose `reason` alone changes owes the bump;
|
|
6
|
+
// and the field is **`pack.json`'s `portulan.version`**, not the packs plugin's bundle version.
|
|
7
|
+
//
|
|
8
|
+
// ## Why the field is the pack's own
|
|
9
|
+
//
|
|
10
|
+
// `packs/.claude-plugin/plugin.json` versions the *bundle*. Measured on `c93a819`: it reads `0.2.1`,
|
|
11
|
+
// `rituals/checkpoints` reads `0.2.1` and `tools/github` reads `0.1.0` — so the bundle agrees with one
|
|
12
|
+
// pack by coincidence and cannot track both, and two packs changing in one pull request would contend
|
|
13
|
+
// for a single field. `portulan.version` is per-pack, so it cannot contend. Tying the bundle to the pack
|
|
14
|
+
// manifests is arm 4 of the issue and is deliberately NOT here.
|
|
15
|
+
//
|
|
16
|
+
// ## Why the whole `contributes` block, not only `gates`
|
|
17
|
+
//
|
|
18
|
+
// The issue's title asks about gate fragments; arm 3's own text says *"touching a pack's `contributes`
|
|
19
|
+
// block"*, and arm 3 is what was ruled — confirmed by the maintainer when the two readings were put to
|
|
20
|
+
// him. Skills, personas and verify recipes reach every composing workspace by the same route a gate
|
|
21
|
+
// fragment does.
|
|
22
|
+
//
|
|
23
|
+
// ## THREE-DOT, and it is not a detail
|
|
24
|
+
//
|
|
25
|
+
// The comparison is `base...head` — from the **merge-base** to the head — never `base..head`.
|
|
26
|
+
// Demonstrated rather than asserted, and pinned in `./pack-version.test.mjs`: with a branch that changes
|
|
27
|
+
// `pack.json` while the base branch independently changes another file, two-dot reports BOTH files and
|
|
28
|
+
// three-dot reports only the branch's. A rail on two-dot would red a pull request because somebody else
|
|
29
|
+
// bumped a version upstream, and would read a `contributes` change the pull request never made.
|
|
30
|
+
//
|
|
31
|
+
// `git diff A...B` is exactly `git diff $(git merge-base A B) B`, so this resolves the merge-base once
|
|
32
|
+
// and reads each base manifest at that commit. No diff is parsed: the manifests are compared as VALUES,
|
|
33
|
+
// which is what makes a reformatting-only edit — whitespace, key order — correctly *not* a change.
|
|
34
|
+
//
|
|
35
|
+
// ## Exit codes, and the middle one is the point
|
|
36
|
+
//
|
|
37
|
+
// 0 every changed pack moved its version
|
|
38
|
+
// 1 a pack changed `contributes` and did not move `portulan.version`
|
|
39
|
+
// 2 could not run — no repository, no base ref, no merge-base, or a manifest that will not parse
|
|
40
|
+
//
|
|
41
|
+
// **2 is never 1.** A rail that cannot read its input must not report green, and must not report red
|
|
42
|
+
// either: a red verdict is a claim about the work, and "I could not look" is a claim about the check.
|
|
43
|
+
// That is `../.portulan/memory/verify-preconditions-fail-closed.md`'s rule and the doctrine `#208` is
|
|
44
|
+
// open about for the sibling scanner. It matters most here because `actions/checkout` is shallow by
|
|
45
|
+
// default — `../cli/librarian.mjs` calls that "not a theoretical clone; it is the *normal* one" — so a
|
|
46
|
+
// missing merge-base is the ordinary case on an unprepared runner, not an exotic one.
|
|
47
|
+
//
|
|
48
|
+
// ## What "moved" means, stated because the ruling's word was "moves"
|
|
49
|
+
//
|
|
50
|
+
// **Any change to the string counts, including absent → present.** It is deliberately NOT "the SemVer
|
|
51
|
+
// increased": this rail enforces the maintainer's word, and reading an ordering into it would be the
|
|
52
|
+
// rail deciding a policy it was not given — `0.2.0` → `0.1.0` is a different question, and a bad answer
|
|
53
|
+
// to it, but it is not this one. Absent → present has to count, or a pack red for having no version
|
|
54
|
+
// could never discharge that red. Present → absent is the `unversioned` red, from the head side.
|
|
55
|
+
//
|
|
56
|
+
// ## What this rail does NOT see, named rather than left to be discovered
|
|
57
|
+
//
|
|
58
|
+
// It compares the `contributes` **block**. Content the block merely *points at* — a skill's `SKILL.md`
|
|
59
|
+
// body, a persona file, the shell script a contributed verify recipe names — reaches every adopting
|
|
60
|
+
// workspace and can change without touching `pack.json` at all. Inline material is covered: gate
|
|
61
|
+
// fragments live in the manifest, and so do recipe objects. Pointed-at files are not. That is `#259`'s
|
|
62
|
+
// class — a rail narrower than the sentence around it — and it is stated here rather than closed,
|
|
63
|
+
// because widening it is a different change with a different argument. A rename, or a delete-plus-add
|
|
64
|
+
// under a new name, likewise passes as `deleted` + `added`.
|
|
65
|
+
|
|
66
|
+
import fs from "node:fs";
|
|
67
|
+
import path from "node:path";
|
|
68
|
+
import { execFileSync } from "node:child_process";
|
|
69
|
+
import { pathToFileURL } from "node:url";
|
|
70
|
+
|
|
71
|
+
/** Raised when the check cannot run, or cannot judge honestly. Always exit 2, never 1. */
|
|
72
|
+
export class CannotRun extends Error {}
|
|
73
|
+
|
|
74
|
+
const DEFAULT_BASE = "origin/main";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Run git, or say why it could not.
|
|
78
|
+
*
|
|
79
|
+
* `stdio` pins stderr to a pipe so a failure's own message rides into the exception rather than
|
|
80
|
+
* leaking to the terminal and leaving the thrown error to say only "command failed".
|
|
81
|
+
*/
|
|
82
|
+
function git(root, args, what) {
|
|
83
|
+
try {
|
|
84
|
+
return execFileSync("git", ["-C", root, ...args], {
|
|
85
|
+
encoding: "utf8",
|
|
86
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
87
|
+
});
|
|
88
|
+
} catch (cause) {
|
|
89
|
+
throw new CannotRun(`git could not ${what} — ${cause.stderr?.toString().trim() || cause.message}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The merge-base of `base` and `HEAD`, which IS the three-dot comparison point.
|
|
95
|
+
*
|
|
96
|
+
* **A shallow clone reaches this function by TWO different routes, and an earlier draft of these
|
|
97
|
+
* messages knew about only one.** Measured on git, both shapes built as fixtures:
|
|
98
|
+
*
|
|
99
|
+
* - `--depth 1` alone is also `--single-branch`, so `origin/main` is **not fetched at all** and the
|
|
100
|
+
* failure is an unknown ref. This is the common CI shape.
|
|
101
|
+
* - `--depth 1 --no-single-branch` fetches the ref and truncates its history, so the ref resolves and
|
|
102
|
+
* **no merge-base exists**.
|
|
103
|
+
*
|
|
104
|
+
* So both messages name `fetch-depth: 0` — a reader who lands on either one is most likely looking at a
|
|
105
|
+
* shallow checkout — while staying distinguishable, because the *other* causes differ: an unknown ref
|
|
106
|
+
* can also be a typo or an unfetched remote, and a missing merge-base can also be genuinely unrelated
|
|
107
|
+
* histories. Naming only one of the two would send half of the readers to the wrong repair.
|
|
108
|
+
*/
|
|
109
|
+
// **`--end-of-options` on every call that takes the caller's ref**, because `base` is user-supplied —
|
|
110
|
+
// `PORTULAN_BASE_REF`, or `--base` — and git will otherwise read a value that starts with `-` as a FLAG.
|
|
111
|
+
// Measured on git 2.50.1: `git merge-base --is-ancestor HEAD` is parsed as the `--is-ancestor` option and
|
|
112
|
+
// exits 129, so the command silently stops being a merge-base lookup and becomes an ancestry test. With
|
|
113
|
+
// the guard it exits 128, refused as a ref, which is what a bad ref should do.
|
|
114
|
+
//
|
|
115
|
+
// `rev-parse` was already shielded by accident rather than by design: the `^{commit}` suffix makes
|
|
116
|
+
// `--help^{commit}` an unparseable revision rather than a flag. Guarded anyway, so the protection is
|
|
117
|
+
// stated instead of resting on a suffix somebody could reasonably remove.
|
|
118
|
+
//
|
|
119
|
+
// _(Copilot proposed `--` for both, and it is the wrong separator here: measured,
|
|
120
|
+
// `git rev-parse --verify -- HEAD^{commit}` exits **128** — `--` marks the start of PATHS for rev-parse,
|
|
121
|
+
// so the fix would have broken every call. Raised on #274, round 2; the concern was right and the
|
|
122
|
+
// mechanism was not.)_
|
|
123
|
+
const END = "--end-of-options";
|
|
124
|
+
|
|
125
|
+
export function mergeBase(root, base = DEFAULT_BASE) {
|
|
126
|
+
const shallowHint =
|
|
127
|
+
"`actions/checkout` is shallow by default — `../cli/librarian.mjs` calls that the normal clone rather than a " +
|
|
128
|
+
"theoretical one — so the usual repair is `fetch-depth: 0` on the job running this check; see .github/workflows/verify.yml.";
|
|
129
|
+
try {
|
|
130
|
+
git(root, ["rev-parse", "--verify", END, `${base}^{commit}`], `resolve ${base}`);
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
// The cause rides along. A bare `catch {}` here reported EVERY failure of this command as an
|
|
133
|
+
// unknown ref — including a corrupt object store, a permissions failure, or a git that would not
|
|
134
|
+
// start — so the message named the one cause it could think of and discarded the one git gave.
|
|
135
|
+
// `git()` has already captured that stderr; throwing it away and substituting a guess is the
|
|
136
|
+
// wrong-mechanism-in-a-user-facing-string class this repository keeps finding. Raised by Copilot
|
|
137
|
+
// on #274, round 4 — and its round 6 caught that the FIX was half-done: the cause rode along
|
|
138
|
+
// while the leading clause still asserted `is not in this repository`, so the sentence stated one
|
|
139
|
+
// cause as fact and listed the alternatives underneath it. It now says what HAPPENED (could not
|
|
140
|
+
// resolve) and ranks the causes as causes. The defect class named in this very comment, surviving
|
|
141
|
+
// one round inside the repair for it.
|
|
142
|
+
throw new CannotRun(
|
|
143
|
+
`could not resolve base ref \`${base}\` — refusing to report a verdict against a ref nothing could read. ` +
|
|
144
|
+
`The usual cause is a SHALLOW single-branch clone, which does not fetch it at all; a typo or an ` +
|
|
145
|
+
`unfetched remote look the same from here, and so does a repository that cannot be read at all. ` +
|
|
146
|
+
`${shallowHint} — git said: ${cause.message}`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return git(root, ["merge-base", END, base, "HEAD"], `find the merge-base of ${base} and HEAD`).trim();
|
|
151
|
+
} catch (cause) {
|
|
152
|
+
throw new CannotRun(
|
|
153
|
+
`no merge-base between \`${base}\` and HEAD, though the ref itself resolved — a SHALLOW clone that fetched ` +
|
|
154
|
+
`the ref and truncated its history looks exactly like this, as do genuinely unrelated histories. ` +
|
|
155
|
+
`${shallowHint} — git said: ${cause.message}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Is this path THERE — as a directory entry, not as something openable?
|
|
162
|
+
*
|
|
163
|
+
* `fs.existsSync` follows symlinks, so it answers **false** for a dangling link and the entry disappears
|
|
164
|
+
* from every listing built on it. That is the same absent-versus-unreadable confusion this file has now
|
|
165
|
+
* repaired at three sites, and this helper is the sweep `0020` asks for *at the first fix* rather than
|
|
166
|
+
* after a third round found the third one: existence is `lstat`, readability is whoever opens the file.
|
|
167
|
+
*/
|
|
168
|
+
function presentOnDisk(file) {
|
|
169
|
+
try {
|
|
170
|
+
fs.lstatSync(file);
|
|
171
|
+
return true;
|
|
172
|
+
} catch {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* `--packs` as a repository-relative path, or a refusal.
|
|
179
|
+
*
|
|
180
|
+
* The flag is documented as *relative to the repository root* and, until #274's round 2, the parser took
|
|
181
|
+
* that on trust. Measured: `path.join("/repo", "../../etc")` is `/etc`, so `--packs ../..` sent the
|
|
182
|
+
* filesystem scan **outside the repository** — and the escape was invisible in the result, because
|
|
183
|
+
* `git ls-tree` then refused the same path and the run ended at exit 2 for a reason that named the wrong
|
|
184
|
+
* thing. Containment is checked after resolution rather than by pattern, since a `..` chain satisfies any
|
|
185
|
+
* regex and still escapes; the same shape `../compile.mjs`'s `policyPath` uses for the same reason.
|
|
186
|
+
*
|
|
187
|
+
* An absolute path is refused outright rather than silently re-rooted: `path.join` turns `/etc` into
|
|
188
|
+
* `<root>/etc`, which is *contained* and is not what the caller asked for, and a flag that quietly means
|
|
189
|
+
* something else is worse than one that refuses.
|
|
190
|
+
*/
|
|
191
|
+
// **It returns the CANONICAL path, and the first draft returned the caller's spelling.** That was a
|
|
192
|
+
// correctness defect rather than untidiness, and it produced a false green. `--packs packs/../packs`
|
|
193
|
+
// validated fine — it resolves inside — and then the two halves of `compare` disagreed about what a pack
|
|
194
|
+
// is called: the filesystem scan joined the raw value (`packs/../packs/tools/github/pack.json`) while
|
|
195
|
+
// `git ls-tree` canonicalised its pathspec (`packs/tools/github/pack.json`). Measured: every pack was
|
|
196
|
+
// reported TWICE, once `added` from the uncanonical spelling and once `unchanged` from git's — and
|
|
197
|
+
// `added` is exempt, so a real unbumped change would have passed. Raised by Copilot on #274, round 3.
|
|
198
|
+
export function insideRepo(value) {
|
|
199
|
+
if (path.isAbsolute(value)) {
|
|
200
|
+
throw new CannotRun(
|
|
201
|
+
`--packs ${value} is an absolute path — this flag names a directory RELATIVE to the repository root, and ` +
|
|
202
|
+
`joining an absolute path would silently re-root it under the repository instead of refusing.`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
// Normalised, then checked — a `..` chain satisfies any pattern and still escapes, so containment is
|
|
206
|
+
// decided on the resolved form. Separators are forced to `/` because every downstream consumer is a
|
|
207
|
+
// git pathspec or a repository-relative id, neither of which is a platform path.
|
|
208
|
+
const canonical = path.normalize(value).split(path.sep).join("/").replace(/\/+$/, "");
|
|
209
|
+
if (canonical === "" || canonical === "." || canonical === ".." || canonical.startsWith("../")) {
|
|
210
|
+
throw new CannotRun(
|
|
211
|
+
`--packs ${value} resolves to ${JSON.stringify(canonical)}, which is not a directory inside the repository — ` +
|
|
212
|
+
`refusing to scan somewhere this check has no business reading.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return canonical;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Every pack manifest in the tree, as repository-relative paths, sorted so output is stable.
|
|
220
|
+
*
|
|
221
|
+
* **ENOENT is the only benign failure, and an earlier draft of this function treated every failure as
|
|
222
|
+
* benign.** A bare `catch { return [] }` turns an unreadable `packs/` — wrong permissions, a broken
|
|
223
|
+
* symlink, a mount that went away — into *the empty set*, and an empty set is a **green**. That is the
|
|
224
|
+
* shape `../.portulan/memory/verify-preconditions-fail-closed.md` exists about: a recipe that enumerates
|
|
225
|
+
* nothing and reports success has described the machine rather than the work. So the absent directory is
|
|
226
|
+
* an answer (a workspace that composes nothing has no manifests to check) and everything else is a
|
|
227
|
+
* refusal. Found by the session-open checkpoint, which asked what guards the enumeration.
|
|
228
|
+
*/
|
|
229
|
+
export function packManifests(root, packsDir = "packs") {
|
|
230
|
+
const base = path.join(root, packsDir);
|
|
231
|
+
const found = [];
|
|
232
|
+
const read = (dir, what) => {
|
|
233
|
+
try {
|
|
234
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
235
|
+
} catch (cause) {
|
|
236
|
+
if (cause.code === "ENOENT") return null;
|
|
237
|
+
throw new CannotRun(
|
|
238
|
+
`cannot read ${what} at ${dir} — ${cause.code ?? cause.message}. Refusing to report an empty set of packs ` +
|
|
239
|
+
`as a clean one: an enumeration that could not run is not a finding that nothing changed.`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
const categories = read(base, `the packs directory`);
|
|
244
|
+
if (categories === null) return [];
|
|
245
|
+
for (const category of categories) {
|
|
246
|
+
if (!category.isDirectory()) continue;
|
|
247
|
+
const packs = read(path.join(base, category.name), `the pack category \`${category.name}\``);
|
|
248
|
+
if (packs === null) continue;
|
|
249
|
+
for (const pack of packs) {
|
|
250
|
+
if (!pack.isDirectory()) continue;
|
|
251
|
+
const rel = [packsDir, category.name, pack.name, "pack.json"].join("/");
|
|
252
|
+
if (presentOnDisk(path.join(root, rel))) found.push(rel);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return found.sort();
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// **ABSENCE IS A FACT FROM THE LISTING, NEVER AN INFERENCE FROM A FAILED READ.** Both readers below got
|
|
259
|
+
// this wrong in the same way, one function apart from the `readdir` guard that got it right — a fix that
|
|
260
|
+
// missed its own siblings inside a single change, which is `0020`'s shape at its smallest. Demonstrated:
|
|
261
|
+
// `chmod 000` on a tracked `pack.json` made this report the pack **deleted** and exit **0**, so a
|
|
262
|
+
// permissions accident read as an intentional removal and the whole rail went quiet. Found by the
|
|
263
|
+
// pre-commit checkpoint, which attacked the readers after the enumeration guard was added rather than
|
|
264
|
+
// assuming the class had been swept.
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A manifest as it stood at `commit`, or `null` where it genuinely did not exist there.
|
|
268
|
+
*
|
|
269
|
+
* `present` is the caller's listing from `ls-tree` at that same commit, and it is what decides absence —
|
|
270
|
+
* `git show` failing is then always a refusal, never a shrug. Matching on git's stderr would be the
|
|
271
|
+
* other way to tell the two apart, and it would be a string comparison against another tool's prose.
|
|
272
|
+
*/
|
|
273
|
+
export function manifestAt(root, commit, rel, present) {
|
|
274
|
+
if (!present) return null;
|
|
275
|
+
const raw = git(root, ["show", `${commit}:${rel}`], `read ${rel} at ${commit.slice(0, 7)}`);
|
|
276
|
+
try {
|
|
277
|
+
return JSON.parse(raw);
|
|
278
|
+
} catch (cause) {
|
|
279
|
+
throw new CannotRun(
|
|
280
|
+
`${rel} at ${commit.slice(0, 7)} is not valid JSON — ${cause.message}. Refusing a verdict on a manifest ` +
|
|
281
|
+
`this check cannot read; \`json\` and \`doctor\` are the tools that judge manifest validity.`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The manifest in the working tree, or `null` where the pack has genuinely been deleted.
|
|
288
|
+
*
|
|
289
|
+
* **`lstat` decides whether the file is there; `readFileSync`'s ENOENT does not.** Third instance of one
|
|
290
|
+
* class in this file, and the sharpest: a **dangling symlink** at `pack.json` makes `readFileSync` throw
|
|
291
|
+
* ENOENT while the path itself plainly exists — measured, `lstat` succeeds and reports
|
|
292
|
+
* `isSymbolicLink: true` — so an ENOENT-only carve-out reported a present-but-unreadable manifest as a
|
|
293
|
+
* pack somebody deleted, at exit 0. The same shape as the `readdir` guard and the absent-vs-unreadable
|
|
294
|
+
* repair before it, at a site both of those left alone.
|
|
295
|
+
*
|
|
296
|
+
* The lstat-first pattern is not invented here: `./control-chars.mjs`'s `bytesOf` already does exactly
|
|
297
|
+
* this, for exactly this reason, down to the sentence — *"Refusing to report it clean: this is a fact
|
|
298
|
+
* about the filesystem, not about the file."* Raised by Copilot on #274, round 7, which also supplied the
|
|
299
|
+
* precedent.
|
|
300
|
+
*/
|
|
301
|
+
export function manifestHere(root, rel) {
|
|
302
|
+
const file = path.join(root, rel);
|
|
303
|
+
try {
|
|
304
|
+
// `lstat`, not `stat`: `stat` follows the link and would throw ENOENT for the dangling case,
|
|
305
|
+
// which is the very confusion this guard exists to end.
|
|
306
|
+
fs.lstatSync(file);
|
|
307
|
+
} catch (cause) {
|
|
308
|
+
if (cause.code === "ENOENT") return null;
|
|
309
|
+
throw new CannotRun(
|
|
310
|
+
`cannot stat ${rel} — ${cause.code ?? cause.message}. Refusing to report it as removed: this is a fact ` +
|
|
311
|
+
`about the filesystem, not about the pack.`,
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
let raw;
|
|
315
|
+
try {
|
|
316
|
+
raw = fs.readFileSync(file, "utf8");
|
|
317
|
+
} catch (cause) {
|
|
318
|
+
// The path EXISTS — `lstat` just said so — so every failure here is the check unable to look:
|
|
319
|
+
// a dangling symlink, EACCES, EISDIR, a symlink loop. None of them is a deletion.
|
|
320
|
+
throw new CannotRun(
|
|
321
|
+
`cannot read ${rel} — ${cause.code ?? cause.message}. The path exists, so this is not a removal: a ` +
|
|
322
|
+
`dangling symlink, an unreadable mode or a directory in its place all land here.`,
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
return JSON.parse(raw);
|
|
327
|
+
} catch (cause) {
|
|
328
|
+
throw new CannotRun(`${rel} is not valid JSON — ${cause.message}. Refusing a verdict on a manifest this check cannot read.`);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Deep value equality over parsed JSON, which is deliberately NOT a text comparison.
|
|
334
|
+
*
|
|
335
|
+
* Reformatting a manifest — reindenting, reordering keys — changes its bytes and changes nothing a
|
|
336
|
+
* composing workspace yields, so it must not owe a version bump. Comparing values is what makes that
|
|
337
|
+
* true, and it is why this reads the manifests rather than parsing `git diff`.
|
|
338
|
+
*/
|
|
339
|
+
export function sameValue(a, b) {
|
|
340
|
+
if (a === b) return true;
|
|
341
|
+
if (a === null || b === null || typeof a !== "object" || typeof b !== "object") return false;
|
|
342
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
343
|
+
if (Array.isArray(a)) {
|
|
344
|
+
// Order IS significant in an array: `contributes.gates` is an ordered list, and `composeFragments`
|
|
345
|
+
// applies fragments in the order it finds them, so a reordering can change what a workspace yields.
|
|
346
|
+
return a.length === b.length && a.every((item, i) => sameValue(item, b[i]));
|
|
347
|
+
}
|
|
348
|
+
const ka = Object.keys(a).sort();
|
|
349
|
+
const kb = Object.keys(b).sort();
|
|
350
|
+
return ka.length === kb.length && ka.every((k, i) => k === kb[i]) && ka.every((k) => sameValue(a[k], b[k]));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Judge one pack. Returns `{ rel, verdict, why }` with verdict `ok` | `unchanged` | `added` | `deleted`
|
|
355
|
+
* | `stale` | `unversioned`.
|
|
356
|
+
*
|
|
357
|
+
* The two reds are told apart because they need different sentences: `stale` has a version and did not
|
|
358
|
+
* move it, `unversioned` has no version to move.
|
|
359
|
+
*/
|
|
360
|
+
export function judge(rel, before, after) {
|
|
361
|
+
if (after === null) return { rel, verdict: "deleted", why: "the pack was removed" };
|
|
362
|
+
if (before === null) {
|
|
363
|
+
// An added pack has no prior state to bump FROM, so no bump is owed. Whether a new pack must
|
|
364
|
+
// declare a version at all is a Pack Definition question — `spec/pack.schema.json` currently
|
|
365
|
+
// makes `portulan.version` optional — and annexing it here would be this rail deciding a schema
|
|
366
|
+
// rule it was not given. Named in `#265` as a hole with a shape, left to the schema.
|
|
367
|
+
return { rel, verdict: "added", why: "the pack is new — nothing to bump from" };
|
|
368
|
+
}
|
|
369
|
+
if (sameValue(before?.contributes, after?.contributes)) {
|
|
370
|
+
return { rel, verdict: "unchanged", why: "`contributes` is unchanged" };
|
|
371
|
+
}
|
|
372
|
+
const was = before?.portulan?.version;
|
|
373
|
+
const now = after?.portulan?.version;
|
|
374
|
+
if (typeof now !== "string" || now === "") {
|
|
375
|
+
return {
|
|
376
|
+
rel,
|
|
377
|
+
verdict: "unversioned",
|
|
378
|
+
why:
|
|
379
|
+
"`contributes` changed and this pack declares no `portulan.version` to move. The field is optional in " +
|
|
380
|
+
"`spec/pack.schema.json`, and its own description calls it how independent versioning is expressed — " +
|
|
381
|
+
"declare one, because a consumer pinning this pack has nothing else to pin on",
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (now === was) {
|
|
385
|
+
return {
|
|
386
|
+
rel,
|
|
387
|
+
verdict: "stale",
|
|
388
|
+
why: `\`contributes\` changed and \`portulan.version\` stayed at \`${now}\`. A prose-only edit to a fragment's \`reason\` counts — it is the sentence the gate runner shows a human`,
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
return { rel, verdict: "ok", why: `\`contributes\` changed and \`portulan.version\` moved \`${was ?? "(absent)"}\` → \`${now}\`` };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Compare every pack in the tree against the merge-base.
|
|
396
|
+
*
|
|
397
|
+
* `packsNamed` says whether the caller *chose* `packsDir`, and it decides what an empty result means.
|
|
398
|
+
* **A directory that is absent is an answer only when nobody named it.** The default `packs/` missing is
|
|
399
|
+
* a workspace that composes nothing — green, correctly. But `--packs paks`, a typo, found nothing at head
|
|
400
|
+
* and nothing at the merge-base and reported *"nothing to check"* at exit **0**: a false green, and a
|
|
401
|
+
* bypass anyone experimenting locally could reach by accident. Measured. So a named directory that exists
|
|
402
|
+
* in neither tree is a refusal, and the ENOENT carve-out in `packManifests` keeps its narrower job.
|
|
403
|
+
* Raised by Copilot on #274, round 5 — on the `Copilot` login of the comments endpoint rather than the
|
|
404
|
+
* `copilot-pull-request-reviewer[bot]` login of `/reviews`, which is the two-logins gotcha this
|
|
405
|
+
* repository already records.
|
|
406
|
+
*/
|
|
407
|
+
export function compare(root, { base = DEFAULT_BASE, packsDir = "packs", packsNamed = false } = {}) {
|
|
408
|
+
const at = mergeBase(root, base);
|
|
409
|
+
// The union of what exists NOW and what existed at the merge-base — a pack deleted in this change
|
|
410
|
+
// has no working-tree manifest and would otherwise never be looked at. It is green either way, but
|
|
411
|
+
// being green by having been considered is not the same as being green by being invisible.
|
|
412
|
+
const here = packManifests(root, packsDir);
|
|
413
|
+
const there = git(root, ["ls-tree", "-r", "--name-only", at, "--", `${packsDir}/`], `list packs at ${at.slice(0, 7)}`)
|
|
414
|
+
.split("\n")
|
|
415
|
+
.filter((line) => line.endsWith("/pack.json"));
|
|
416
|
+
if (packsNamed && here.length === 0 && there.length === 0 && !presentOnDisk(path.join(root, packsDir))) {
|
|
417
|
+
throw new CannotRun(
|
|
418
|
+
`--packs ${packsDir} names a directory that exists neither in the working tree nor at the merge-base. ` +
|
|
419
|
+
`Refusing to report green having examined nothing: an empty set is a verdict about packs, and this is a ` +
|
|
420
|
+
`verdict about the path you typed. (The DEFAULT \`packs/\` being absent is different — that is a ` +
|
|
421
|
+
`workspace which composes nothing, and it passes.)`,
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const atBase = new Set(there);
|
|
425
|
+
const all = [...new Set([...here, ...there])].sort();
|
|
426
|
+
return {
|
|
427
|
+
at,
|
|
428
|
+
results: all.map((rel) => judge(rel, manifestAt(root, at, rel, atBase.has(rel)), manifestHere(root, rel))),
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function usage() {
|
|
433
|
+
return [
|
|
434
|
+
// NOT `portulan pack-version` — `./portulan.mjs` has eight subcommands and this is not one of
|
|
435
|
+
// them (measured: it answers "unknown subcommand"), and its own header forbids minting a ninth.
|
|
436
|
+
// The sibling checkers carry no `portulan <name>` banner either. This is the form the verify
|
|
437
|
+
// recipe actually runs, which is the only invocation that exists.
|
|
438
|
+
"pack-version — a change to a pack's `contributes` must move its `portulan.version`",
|
|
439
|
+
"",
|
|
440
|
+
" node cli/pack-version.mjs [--base <ref>] [--packs <dir>] [<repository-root>]",
|
|
441
|
+
"",
|
|
442
|
+
" --base what to compare against; default `origin/main`. Compared THREE-DOT: the merge-base",
|
|
443
|
+
" of this ref and HEAD, which is what a pull request shows",
|
|
444
|
+
" --packs the packs directory, relative to the repository root; default `packs`",
|
|
445
|
+
"",
|
|
446
|
+
"Ruled on #265: the whole `contributes` block, and a prose-only edit to a `reason` counts.",
|
|
447
|
+
"",
|
|
448
|
+
"Exit codes: 0 green · 1 a red verdict · 2 could not run.",
|
|
449
|
+
].join("\n");
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function run(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd() } = {}) {
|
|
453
|
+
// Before every other argument decision, so asking for help cannot be outranked by a complaint about
|
|
454
|
+
// the rest of the command line — the contract `./portulan.mjs` states.
|
|
455
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
456
|
+
stdout.write(`${usage()}\n`);
|
|
457
|
+
return 0;
|
|
458
|
+
}
|
|
459
|
+
let base = DEFAULT_BASE;
|
|
460
|
+
let packsDir = "packs";
|
|
461
|
+
let packsNamed = false;
|
|
462
|
+
let root = null;
|
|
463
|
+
try {
|
|
464
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
465
|
+
if (argv[i] === "--base" || argv[i] === "--packs") {
|
|
466
|
+
const which = argv[i];
|
|
467
|
+
const value = argv[i + 1];
|
|
468
|
+
i += 1;
|
|
469
|
+
// A single leading `-` is refused too, not just `--`: `--base --packs` would otherwise
|
|
470
|
+
// consume the next flag as a ref and fail later as an unreadable one. The three tools in
|
|
471
|
+
// `cli/` that take a directory already read it this way; this aligns with them rather
|
|
472
|
+
// than inventing. _(A draft used `--base -h` as the example, which never reaches here:
|
|
473
|
+
// `--help` is answered before any other argument decision, so that spelling exits 0 on
|
|
474
|
+
// the help path. Measured.)_
|
|
475
|
+
if (value === undefined || value.startsWith("-")) throw new CannotRun(`${which} needs a value`);
|
|
476
|
+
if (which === "--base") base = value;
|
|
477
|
+
else {
|
|
478
|
+
packsDir = insideRepo(value);
|
|
479
|
+
packsNamed = true;
|
|
480
|
+
}
|
|
481
|
+
} else if (argv[i].startsWith("-")) {
|
|
482
|
+
throw new CannotRun(`unknown argument ${JSON.stringify(argv[i])}`);
|
|
483
|
+
} else if (root === null) {
|
|
484
|
+
root = argv[i];
|
|
485
|
+
} else {
|
|
486
|
+
throw new CannotRun(`unexpected second repository root ${JSON.stringify(argv[i])}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const where = path.resolve(root ?? cwd);
|
|
490
|
+
// Resolved to the repository top level rather than trusting the argument: a caller invoking this
|
|
491
|
+
// from a subdirectory would otherwise have every `packs/…` path resolved against the wrong root,
|
|
492
|
+
// which is #131's class — paths against the author's layout.
|
|
493
|
+
const top = git(where, ["rev-parse", "--show-toplevel"], `find a git repository at ${where}`).trim();
|
|
494
|
+
|
|
495
|
+
const { at, results } = compare(top, { base, packsDir, packsNamed });
|
|
496
|
+
const red = results.filter((r) => r.verdict === "stale" || r.verdict === "unversioned");
|
|
497
|
+
const moved = results.filter((r) => r.verdict === "ok");
|
|
498
|
+
|
|
499
|
+
stdout.write(`pack-version: comparing against \`${base}\` at merge-base ${at.slice(0, 7)} (three-dot)\n`);
|
|
500
|
+
// Every pack is printed, not only the failures. A rail that prints only reds cannot be told from
|
|
501
|
+
// one that examined nothing — the same reason `compile` prints its refusals on every run. The
|
|
502
|
+
// `ok` rows carry their `why` too, so a green states WHICH version moved rather than leaving a
|
|
503
|
+
// reader to diff the manifest to find out what the rail approved.
|
|
504
|
+
for (const r of results) {
|
|
505
|
+
stdout.write(` ${r.verdict.padEnd(12)} ${r.rel}${r.verdict === "ok" ? ` — ${r.why}` : ""}\n`);
|
|
506
|
+
}
|
|
507
|
+
if (results.length === 0) stdout.write(" (no pack manifests here or at the merge-base — nothing to check)\n");
|
|
508
|
+
|
|
509
|
+
if (red.length === 0) {
|
|
510
|
+
stdout.write(`\nok ${moved.length} pack(s) changed \`contributes\` and moved their version; ${results.length} examined\n`);
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
stderr.write("\n");
|
|
514
|
+
for (const r of red) stderr.write(`RED ${r.rel} — ${r.why}\n`);
|
|
515
|
+
stderr.write(`\n${red.length} pack(s) changed \`contributes\` without moving \`portulan.version\` (#265).\n`);
|
|
516
|
+
return 1;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (error instanceof CannotRun) {
|
|
519
|
+
stderr.write(`pack-version: ${error.message}\n`);
|
|
520
|
+
return 2;
|
|
521
|
+
}
|
|
522
|
+
// **A CRASH IS A COULD-NOT-RUN, NOT A RED**, and this file's own header says so two screens up
|
|
523
|
+
// while an earlier draft did the opposite: an uncaught throw left node exiting **1**, which the
|
|
524
|
+
// recipe faithfully printed as *"RED — verify recipe failed"*. So a TypeError in this checker
|
|
525
|
+
// accused the pull request of a policy breach it had not committed — the exact defect `#208` is
|
|
526
|
+
// open about in the sibling scanner, shipped new in the change that cites it. Demonstrated by
|
|
527
|
+
// the pre-commit checkpoint with an injected throw.
|
|
528
|
+
//
|
|
529
|
+
// The stack is printed rather than swallowed: a crash must stay loud, it just must not wear a
|
|
530
|
+
// verdict it did not earn.
|
|
531
|
+
stderr.write(`pack-version: CRASHED — ${error?.stack ?? error}\n`);
|
|
532
|
+
stderr.write("pack-version: reporting could-not-run (2) rather than a red verdict — a defect in this checker is not a finding about the work.\n");
|
|
533
|
+
return 2;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function isMain() {
|
|
538
|
+
return import.meta.url === pathToFileURL(process.argv[1] ?? "").href;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
if (isMain()) {
|
|
542
|
+
process.exitCode = run(process.argv.slice(2));
|
|
543
|
+
}
|