@sleepy_panda_srl/portulan 0.1.3 → 0.2.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/README.md +6 -5
- package/cli/README.md +142 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1322 -1607
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +224 -1074
- package/cli/drills.mjs +68 -502
- package/cli/feedback.mjs +16 -237
- package/cli/finish.mjs +437 -0
- package/cli/form.mjs +644 -0
- package/cli/fuzz-shell.mjs +20 -305
- package/cli/gate.mjs +7 -199
- package/cli/goldens.mjs +26 -184
- package/cli/index.mjs +305 -655
- package/cli/init.mjs +243 -566
- package/cli/inside.mjs +3 -28
- package/cli/instructions.mjs +829 -0
- package/cli/ledger.mjs +778 -0
- package/cli/librarian.mjs +135 -599
- package/cli/manifest.mjs +2 -39
- package/cli/mutants.mjs +9 -170
- package/cli/new.mjs +12 -243
- package/cli/pack-identity.mjs +5 -74
- package/cli/pack-version.mjs +13 -255
- package/cli/plugin-lint.mjs +42 -658
- package/cli/portulan.mjs +5 -124
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +17 -258
- package/cli/rule-carriers.mjs +12 -175
- package/cli/sessions.mjs +82 -0
- package/cli/skill-goldens.mjs +10 -176
- package/cli/skills-set.mjs +12 -414
- package/cli/stop-gate.mjs +83 -514
- package/cli/symbols.mjs +1132 -0
- package/cli/telemetry.mjs +27 -492
- package/cli/upgrade.mjs +171 -314
- package/cli/vendor.mjs +146 -476
- package/cli/version-carriers.mjs +8 -86
- package/core/README.md +1 -1
- package/core/engine.md +3 -9
- package/core/operating/autonomy.md +1 -1
- package/core/operating/context.md +183 -0
- package/core/operating/evolution.md +3 -3
- package/core/operating/loop.md +13 -20
- package/core/operating/memory.md +12 -3
- package/core/operating/sessions.md +100 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/consolidate/SKILL.md +4 -2
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +255 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/README.md +3 -3
- package/package.json +4 -2
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/spec/README.md +112 -6
- package/spec/migrations/0001-repository-declares-its-tree.mjs +1 -26
- package/spec/migrations/0002-bundle-fallback-path.mjs +13 -74
- package/spec/migrations/0003-handoff-index-not-kept.mjs +66 -0
- package/spec/migrations/0004-changelog-fragments.mjs +53 -0
- package/spec/migrations/0005-session-log-retired.mjs +69 -0
- package/spec/migrations/0006-boot-card.mjs +77 -0
- package/spec/migrations/0007-guidance-compiled.mjs +31 -0
- package/spec/migrations/0008-card-reading.mjs +55 -0
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -0
- package/spec/migrations/0010-comment-rail.mjs +57 -0
- package/spec/migrations/README.md +45 -10
- package/spec/pack.schema.json +1 -1
- package/spec/slots.md +333 -21
- package/spec/workspace.schema.json +145 -4
package/cli/upgrade.mjs
CHANGED
|
@@ -1,48 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `upgrade` — migrate a workspace, in either residence.
|
|
3
3
|
//
|
|
4
|
-
//
|
|
5
|
-
// `docs/plan.md`: *"`upgrade` migrates a workspace in **either** residence."*
|
|
6
|
-
//
|
|
7
|
-
// ## What a migration is, and where it lives
|
|
8
|
-
//
|
|
9
|
-
// Not here. `spec/migrations/` is the contract — a directory of steps, each a module — because a
|
|
10
|
-
// migration is part of the **Workspace Definition** rather than part of a tool, and `spec/` ships in
|
|
11
|
-
// `package.json`'s `files` so it reaches an `npx` user — the package was published 2026-08-18 at
|
|
12
|
-
// `0.1.0`, closing #242, so that user now exists. See `../.portulan/identity.md`. This file is the runner: it decides which
|
|
13
|
-
// steps a workspace owes, applies them, and grades the result with the real validator.
|
|
14
|
-
//
|
|
15
|
-
// **Two kinds of step**, by the maintainer's ruling of 2026-08-12: a `version` step migrates a
|
|
16
|
-
// Workspace Definition MAJOR, and a `repair` fixes something a rewriter owes a workspace it touched.
|
|
17
|
-
// Without the second kind this tool would be machinery with no subject — the train's only MAJOR
|
|
18
|
-
// migration is `1.0 → 2.0` and **nothing in this repository declares 1.0**.
|
|
19
|
-
//
|
|
20
|
-
// ## The three states a workspace can be in relative to this bundle, and why two of them are refusals
|
|
21
|
-
//
|
|
22
|
-
// `doctor` refuses a manifest whose MAJOR differs from the schema's — in **either direction** — and
|
|
23
|
-
// refuses a MINOR ahead of it. The direction matters here in a way it does not there:
|
|
24
|
-
//
|
|
25
|
-
// - **behind by a MAJOR** — the case this tool exists for. `doctor` cannot grade it, so the pre-state
|
|
26
|
-
// gate below is skipped and the result is graded post-state only.
|
|
27
|
-
// - **ahead** (MAJOR or MINOR) — **exit 2**. This bundle is older than the workspace; nothing here
|
|
28
|
-
// knows the contract it was written against, and the remedy is to upgrade the CLI. Branching on the
|
|
29
|
-
// *fact* of `doctor`'s refusal rather than on the direction sent such a workspace into the plan,
|
|
30
|
-
// where every step answered *not owed* and the run exited **0 — nothing owed**: a green rendered by
|
|
31
|
-
// a tool that could not read the workspace. Caught at the session-open checkpoint.
|
|
32
|
-
// - **level, or behind by a MINOR** — gradeable, and graded before anything is planned.
|
|
33
|
-
//
|
|
34
|
-
// The same hole has a second mouth: a workspace behind by a MAJOR that **no step reaches** would also
|
|
35
|
-
// plan to nothing and exit 0. That is refused too, further down, for the same reason.
|
|
36
|
-
//
|
|
37
|
-
// ## Exit codes
|
|
38
|
-
//
|
|
39
|
-
// `0` succeeded — nothing owed, or applied and green · `1` a verdict — steps owed under `--check`, a
|
|
40
|
-
// red workspace before or after, **and a pointer that does not resolve** (`not-installed` or
|
|
41
|
-
// `ambiguous`, which is `discover.mjs`'s own mapping rather than a second opinion about it) · `2`
|
|
42
|
-
// could not run, including a step that **could not tell**, either direction this bundle cannot help
|
|
43
|
-
// with, and a `--write` aimed at an installed workspace.
|
|
44
|
-
//
|
|
45
|
-
// Zero dependencies, ESM, no build step — the session-0 ruling.
|
|
4
|
+
// Exit 0 succeeded · 1 a step owed under `--check` or left by hand, a red workspace, an unresolved pointer · 2 could not run.
|
|
46
5
|
|
|
47
6
|
import fs from "node:fs";
|
|
48
7
|
import path from "node:path";
|
|
@@ -50,35 +9,22 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
50
9
|
|
|
51
10
|
import { inspect, schemaVersion } from "./doctor.mjs";
|
|
52
11
|
import { resolveGovernor } from "./discover.mjs";
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
// three `collisions()` implementing one rule is already an open complaint against this repository
|
|
56
|
-
// (#169). Its errors are re-worded below so a reader is never told `vendor` failed.
|
|
12
|
+
import { gitIn } from "./form.mjs";
|
|
13
|
+
import { offerLines } from "./sessions.mjs";
|
|
57
14
|
import { walk } from "./vendor.mjs";
|
|
58
15
|
|
|
59
16
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
60
17
|
const BUNDLE = path.resolve(HERE, "..");
|
|
61
18
|
const MIGRATIONS = path.join(BUNDLE, "spec", "migrations");
|
|
62
19
|
|
|
63
|
-
/** Distinguishes one run's staging files from another's, alongside the pid. */
|
|
64
20
|
let stagingSeq = 0;
|
|
65
21
|
|
|
66
22
|
/** Everything that means `upgrade` could not run. Carries no verdict about a workspace. */
|
|
67
23
|
export class UpgradeError extends Error {}
|
|
68
24
|
|
|
69
|
-
/**
|
|
70
|
-
* The Workspace Definition version **this bundle implements**, derived from the schema's `$id`.
|
|
71
|
-
*
|
|
72
|
-
* Derived rather than written down, and through `doctor`'s own exported reader, so this file is not a
|
|
73
|
-
* second carrier of the current version. A literal here would be wrong the hour the schema moves —
|
|
74
|
-
* which is the defect class this repository has repaired more often than any other.
|
|
75
|
-
*/
|
|
25
|
+
/** The Workspace Definition version this bundle implements, read from its schema's `$id`. */
|
|
76
26
|
export function bundleSpec({ schemaPath } = {}) {
|
|
77
27
|
const file = schemaPath ?? path.join(BUNDLE, "spec", "workspace.schema.json");
|
|
78
|
-
// The THIRD site of the read-versus-parse rule, found by sweeping after Copilot named the second.
|
|
79
|
-
// This one reads a file the bundle ships rather than one a user wrote, so a malformed schema is a
|
|
80
|
-
// broken install rather than somebody's typo — which changes who the sentence is for, not whether
|
|
81
|
-
// it should be accurate. Two carriers here were already one too many.
|
|
82
28
|
let text;
|
|
83
29
|
try {
|
|
84
30
|
text = fs.readFileSync(file, "utf8");
|
|
@@ -94,13 +40,6 @@ export function bundleSpec({ schemaPath } = {}) {
|
|
|
94
40
|
return schemaVersion(schema);
|
|
95
41
|
}
|
|
96
42
|
|
|
97
|
-
/**
|
|
98
|
-
* The chain, in id order, read from the directory rather than from a list.
|
|
99
|
-
*
|
|
100
|
-
* There is no registry to keep in sync with the tree: a step is a file, and the filename is the
|
|
101
|
-
* order. A hand-maintained list beside a directory is the second carrier this repository keeps
|
|
102
|
-
* repairing out of its own design.
|
|
103
|
-
*/
|
|
104
43
|
export async function loadSteps({ dir = MIGRATIONS } = {}) {
|
|
105
44
|
let names;
|
|
106
45
|
try {
|
|
@@ -122,8 +61,6 @@ export async function loadSteps({ dir = MIGRATIONS } = {}) {
|
|
|
122
61
|
throw new UpgradeError(`${file} exports no \`step\` object — every module in ${dir} is a migration step`);
|
|
123
62
|
}
|
|
124
63
|
if (step.id !== path.basename(name, ".mjs")) {
|
|
125
|
-
// The id IS the order, and the order is the filename. A step whose id disagrees with its
|
|
126
|
-
// file would sort by one and report as the other.
|
|
127
64
|
throw new UpgradeError(`${file} declares id \`${step.id}\`, which is not its filename — the id is the chain's order`);
|
|
128
65
|
}
|
|
129
66
|
steps.push(step);
|
|
@@ -131,13 +68,7 @@ export async function loadSteps({ dir = MIGRATIONS } = {}) {
|
|
|
131
68
|
return steps;
|
|
132
69
|
}
|
|
133
70
|
|
|
134
|
-
/**
|
|
135
|
-
* Read a workspace directory into the view `spec/migrations/README.md` promises a step.
|
|
136
|
-
*
|
|
137
|
-
* Read and parse stay two failures with two repairs: a `SyntaxError` carries no `.code`, and
|
|
138
|
-
* reporting it under *could not be read* sends somebody with a malformed manifest to look at
|
|
139
|
-
* permissions.
|
|
140
|
-
*/
|
|
71
|
+
/** Read a workspace into the view a step is given, as `spec/migrations/README.md` defines it. */
|
|
141
72
|
export function readWorkspace(dir) {
|
|
142
73
|
const root = path.resolve(dir);
|
|
143
74
|
const file = path.join(root, "workspace.json");
|
|
@@ -146,8 +77,6 @@ export function readWorkspace(dir) {
|
|
|
146
77
|
try {
|
|
147
78
|
text = fs.readFileSync(file, "utf8");
|
|
148
79
|
} catch (error) {
|
|
149
|
-
// Only `ENOENT` means absent. An `EACCES` is a question that could not be answered, and
|
|
150
|
-
// answering it *nothing there* is the fail-open.
|
|
151
80
|
return {
|
|
152
81
|
ok: false,
|
|
153
82
|
code: 2,
|
|
@@ -169,17 +98,6 @@ export function readWorkspace(dir) {
|
|
|
169
98
|
try {
|
|
170
99
|
files = walk(root).map((entry) => entry.rel);
|
|
171
100
|
} catch (error) {
|
|
172
|
-
// **Framed, never rewritten.** `walk` is `vendor`'s and its refusals speak in `vendor`'s voice
|
|
173
|
-
// — *"this refuses to copy through one"* — which is the wrong verb for a tool that is
|
|
174
|
-
// migrating. The first attempt at this substituted the wording, and it was **inert**: the
|
|
175
|
-
// pattern said `refusing to copy through` and the message says `refuses`. A fix that could not
|
|
176
|
-
// fire, under a comment asserting it did — this repository's dominant defect class, committed
|
|
177
|
-
// here in the change that cites it. Copilot's promoted note, round 2 on #231.
|
|
178
|
-
//
|
|
179
|
-
// Rewriting was the wrong shape anyway: matching another module's sentence makes this file a
|
|
180
|
-
// second carrier of `vendor`'s wording, free to rot the next time that sentence is edited. So
|
|
181
|
-
// the prefix says who declined and why, and the borrowed sentence is left exactly as its owner
|
|
182
|
-
// wrote it.
|
|
183
101
|
return { ok: false, code: 2, reason: `${root} could not be read for migration — ${error.message}` };
|
|
184
102
|
}
|
|
185
103
|
|
|
@@ -190,12 +108,8 @@ export function readWorkspace(dir) {
|
|
|
190
108
|
dir: root,
|
|
191
109
|
manifest,
|
|
192
110
|
manifestText: text,
|
|
111
|
+
repository: repositoryView(root, manifest),
|
|
193
112
|
list: () => files,
|
|
194
|
-
// The READ sibling of the write guard. `list()` only ever yields paths `walk` enumerated
|
|
195
|
-
// inside the workspace, so a step iterating it is safe — but `read` takes whatever a step
|
|
196
|
-
// hands it, and the step contract does not constrain that. A tool that refused to WRITE
|
|
197
|
-
// outside the workspace while reading anything on the disk would be guarding one half of
|
|
198
|
-
// the same rule. Found by sweeping after Copilot caught the `restore` sibling.
|
|
199
113
|
read: (rel) => {
|
|
200
114
|
const at = inside(root, rel);
|
|
201
115
|
if (at === null) throw new UpgradeError(`\`${rel}\` resolves outside ${root} — refusing to read there`);
|
|
@@ -206,22 +120,56 @@ export function readWorkspace(dir) {
|
|
|
206
120
|
};
|
|
207
121
|
}
|
|
208
122
|
|
|
209
|
-
/**
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
123
|
+
/** The repository the manifest's `tree` names, as a step reads it; `null` where it names no directory. */
|
|
124
|
+
export function repositoryView(root, manifest) {
|
|
125
|
+
if (typeof manifest?.tree !== "string") return null;
|
|
126
|
+
const dir = path.resolve(root, manifest.tree);
|
|
127
|
+
try {
|
|
128
|
+
if (!fs.statSync(dir).isDirectory()) return null;
|
|
129
|
+
} catch {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
const cache = new Map();
|
|
133
|
+
let git;
|
|
134
|
+
return {
|
|
135
|
+
dir,
|
|
136
|
+
read: (rel) => {
|
|
137
|
+
const at = inside(dir, rel);
|
|
138
|
+
if (at === null) throw new UpgradeError(`\`${rel}\` resolves outside ${dir} — refusing to read there`);
|
|
139
|
+
const linked = linkOnPath(dir, at);
|
|
140
|
+
if (linked !== null) throw new UpgradeError(linked.replace("write through", "read through"));
|
|
141
|
+
if (!cache.has(rel)) {
|
|
142
|
+
try {
|
|
143
|
+
cache.set(rel, fs.readFileSync(at, "utf8"));
|
|
144
|
+
} catch (error) {
|
|
145
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw new UpgradeError(`${at} could not be read — ${error.code ?? error.message}`);
|
|
146
|
+
cache.set(rel, null);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return cache.get(rel);
|
|
150
|
+
},
|
|
151
|
+
names: (rel) => {
|
|
152
|
+
const at = inside(dir, rel);
|
|
153
|
+
if (at === null) throw new UpgradeError(`\`${rel}\` resolves outside ${dir} — refusing to list there`);
|
|
154
|
+
const linked = linkOnPath(dir, at);
|
|
155
|
+
if (linked !== null) throw new UpgradeError(linked.replace("write through", "list through"));
|
|
156
|
+
try {
|
|
157
|
+
return fs.readdirSync(at);
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") return [];
|
|
160
|
+
throw new UpgradeError(`${at} could not be listed — ${error.code ?? error.message}`);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
get git() {
|
|
164
|
+
if (git === undefined) git = gitIn(dir);
|
|
165
|
+
return git;
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
216
170
|
export async function resolveTarget(dir, options = {}) {
|
|
217
171
|
const root = path.resolve(dir);
|
|
218
172
|
const file = path.join(root, "workspace.json");
|
|
219
|
-
// **Read and parse are two failures with two repairs, and this had them as one.** `readWorkspace`
|
|
220
|
-
// twelve lines up already keeps them apart — a `SyntaxError` carries no `.code`, so a malformed
|
|
221
|
-
// manifest fell through to the *could not be read* sentence and sent the reader to look at
|
|
222
|
-
// permissions and paths instead of at their JSON. One rule, two sites, enforced at one: `0020`,
|
|
223
|
-
// and the second site is inside the file whose own contract states the distinction.
|
|
224
|
-
// Copilot's suppressed note, round 7 on #231.
|
|
225
173
|
const fail = (sentence) => ({ state: "could-not-look", dir: null, resolution: null, sentence });
|
|
226
174
|
|
|
227
175
|
let text;
|
|
@@ -242,12 +190,6 @@ export async function resolveTarget(dir, options = {}) {
|
|
|
242
190
|
return fail(`${file} does not parse as JSON — ${error.message}`);
|
|
243
191
|
}
|
|
244
192
|
if (manifest?.kind !== "pointer") {
|
|
245
|
-
// **Not `discover`'s wording, deliberately, and the difference is the argument.** `discover`
|
|
246
|
-
// answers *this repository's workspace resides here* because it is asked about a repository's
|
|
247
|
-
// own `.portulan` — that is its question. This tool takes ANY workspace directory: `examples/`,
|
|
248
|
-
// a feed-side portfolio, a path a user typed. Borrowing the sentence would have made a claim
|
|
249
|
-
// about ownership from the mere absence of `kind: pointer`, which is a different fact from the
|
|
250
|
-
// one that was checked. So it says what it actually established. Copilot, round 1 on #231.
|
|
251
193
|
const kind = typeof manifest?.kind === "string" ? `\`${manifest.kind}\`` : "a governing";
|
|
252
194
|
return {
|
|
253
195
|
state: "resides-here",
|
|
@@ -265,16 +207,6 @@ export async function resolveTarget(dir, options = {}) {
|
|
|
265
207
|
};
|
|
266
208
|
}
|
|
267
209
|
|
|
268
|
-
/**
|
|
269
|
-
* Ask every step whether it is owed. Three answers, and the third is not a shorter plan.
|
|
270
|
-
*
|
|
271
|
-
* **A step that THROWS is `could not tell`, not a crash.** The three-valued answer exists precisely so
|
|
272
|
-
* that "this step could not work out whether it applies" has somewhere to go other than a green — and
|
|
273
|
-
* an unhandled exception routed around it entirely, taking the whole tool down instead of producing
|
|
274
|
-
* the designed exit 2. A step is a module from `spec/migrations/`, so a bug in one, or an unexpected
|
|
275
|
-
* read error inside it, must degrade to the answer the contract already has a word for.
|
|
276
|
-
* Copilot's promoted note, round 6 on #231.
|
|
277
|
-
*/
|
|
278
210
|
export async function planFor(ws, ctx, steps) {
|
|
279
211
|
const entries = [];
|
|
280
212
|
let owed = 0;
|
|
@@ -283,69 +215,30 @@ export async function planFor(ws, ctx, steps) {
|
|
|
283
215
|
let answer;
|
|
284
216
|
try {
|
|
285
217
|
answer = await step.owed(ws, ctx);
|
|
286
|
-
// **The VALUE, not the presence of the key.** A first cut accepted any object carrying an
|
|
287
|
-
// `owed` property, which let `{ owed: "yes" }` or `{ owed: undefined }` through: neither is
|
|
288
|
-
// `true` and neither is `null`, so the arithmetic below counted them as **not owed** — a
|
|
289
|
-
// false green produced by the very guard added to prevent one. Checking that a field exists
|
|
290
|
-
// is not checking that its value is one this contract defines.
|
|
291
|
-
// Copilot, round 8 on #231.
|
|
292
218
|
if (answer === null || typeof answer !== "object" || ![true, false, null].includes(answer.owed)) {
|
|
293
219
|
answer = { owed: null, because: `${step.id} returned no usable verdict from \`owed\` (${JSON.stringify(answer?.owed)})` };
|
|
294
220
|
} else if (typeof answer.because !== "string" || answer.because === "") {
|
|
295
|
-
|
|
296
|
-
// grounds to discard a good verdict, so the verdict stands and the sentence is named
|
|
297
|
-
// as absent rather than printed as `undefined`.
|
|
298
|
-
answer = { owed: answer.owed, because: `${step.id} gave no reason` };
|
|
221
|
+
answer = { owed: answer.owed, hand: answer.hand, because: `${step.id} gave no reason` };
|
|
299
222
|
}
|
|
300
223
|
} catch (error) {
|
|
301
224
|
answer = { owed: null, because: `${step.id} threw while deciding whether it is owed — ${error.message}` };
|
|
302
225
|
}
|
|
303
|
-
|
|
226
|
+
// `hand`: owed, and placed by a person rather than by the step, so `--write` reports it and runs on.
|
|
227
|
+
entries.push({ step, owed: answer.owed, because: answer.because, hand: answer.owed === true && answer.hand === true });
|
|
304
228
|
if (answer.owed === null) unknown += 1;
|
|
305
229
|
else if (answer.owed === true) owed += 1;
|
|
306
230
|
}
|
|
307
231
|
return { entries, owed, unknown };
|
|
308
232
|
}
|
|
309
233
|
|
|
310
|
-
/**
|
|
311
|
-
* Resolve a step-supplied relative path inside `root`, or `null` if it escapes.
|
|
312
|
-
*
|
|
313
|
-
* **One carrier, and it has two callers because it has to.** `applyEdits` writes and `restore`
|
|
314
|
-
* writes back, and a guard on the first alone leaves the second reachable with the same value — one
|
|
315
|
-
* rule enforced at one of its two sites, which is `0020` exactly. That is not hypothetical here:
|
|
316
|
-
* `applyEdits` got the guard first and `restore` did not, and Copilot found the sibling before this
|
|
317
|
-
* session's own sweep did.
|
|
318
|
-
*
|
|
319
|
-
* `resolve`, never `join`: `path.join(root, "/etc/passwd")` CONCATENATES to `<root>/etc/passwd`,
|
|
320
|
-
* which hides an absolute path from the check instead of exposing it. And resolution rather than
|
|
321
|
-
* pattern-matching, because every interesting escape parses fine and only fails once resolved.
|
|
322
|
-
*/
|
|
234
|
+
/** `rel` resolved inside `root`, or `null` if it escapes: `path.join` would pass an absolute `rel` as inside. */
|
|
323
235
|
export function inside(root, rel) {
|
|
324
236
|
const resolved = path.resolve(root, rel);
|
|
325
237
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return null;
|
|
326
238
|
return resolved;
|
|
327
239
|
}
|
|
328
240
|
|
|
329
|
-
/**
|
|
330
|
-
* The other half of containment: the path on DISK, not the path as a string.
|
|
331
|
-
*
|
|
332
|
-
* `inside()` is lexical. A symlink at any **parent component** makes the write escape while the
|
|
333
|
-
* string still resolves inside the root — `<root>/link/a.md` passes every character-level test and
|
|
334
|
-
* lands wherever `link` points. `{ flag: "wx" }` guards only the final component. This is the rule
|
|
335
|
-
* `cli/vendor.mjs` states as rule 2 and enforces by walking the chain, and `cli/skills-set.mjs`
|
|
336
|
-
* spells the same walk; this file had inherited the rule and implemented only its lexical half.
|
|
337
|
-
* Copilot, round 11 on #231.
|
|
338
|
-
*
|
|
339
|
-
* Components that do not exist are fine — they are about to be created, and nothing can be followed
|
|
340
|
-
* through a path that is not there — so the walk stops at the first `ENOENT`. Anything else is a
|
|
341
|
-
* question that could not be answered, and is refused rather than assumed absent: rule 3.
|
|
342
|
-
*
|
|
343
|
-
* The root itself is not checked, deliberately, and that is `vendor`'s boundary: **above the named
|
|
344
|
-
* path, resolve — the caller named it, and on macOS `os.tmpdir()` runs through `/var`. At it and
|
|
345
|
-
* below it, refuse.**
|
|
346
|
-
*
|
|
347
|
-
* Returns a reason to refuse, or `null` when the chain is clean.
|
|
348
|
-
*/
|
|
241
|
+
/** A reason to refuse, or `null`; `root` itself is not checked, since macOS's `os.tmpdir()` runs through `/var`. */
|
|
349
242
|
function linkOnPath(root, resolved) {
|
|
350
243
|
const rel = path.relative(root, resolved);
|
|
351
244
|
if (rel === "") return null;
|
|
@@ -364,22 +257,7 @@ function linkOnPath(root, resolved) {
|
|
|
364
257
|
return null;
|
|
365
258
|
}
|
|
366
259
|
|
|
367
|
-
/**
|
|
368
|
-
* Remove directories this run created, deepest first, stopping at the first that will not go.
|
|
369
|
-
*
|
|
370
|
-
* **One carrier, two callers.** `applyEdits` needs it on every failure path between creating a
|
|
371
|
-
* directory and recording the snapshot that would have owned it, and `restore` needs it when undoing
|
|
372
|
-
* a file that did not exist. Two copies of this loop would be one rule at two sites — and the second
|
|
373
|
-
* site is precisely where the first copy was missing.
|
|
374
|
-
*
|
|
375
|
-
* Stops rather than forces: a directory that will not `rmdir` is one that is not empty, which means
|
|
376
|
-
* something else is in it and it was not ours to remove after all.
|
|
377
|
-
*
|
|
378
|
-
* **`ENOENT` is not that case, and stopping on it was a bug.** A directory already gone — removed out
|
|
379
|
-
* of band, or by a `rm -rf` of a subtree — satisfies the goal rather than blocking it, and treating it
|
|
380
|
-
* as an obstacle abandoned every parent above it while they may well have been empty. Absence means
|
|
381
|
-
* *done*, not *stop*. Copilot's promoted note, round 6 on #231.
|
|
382
|
-
*/
|
|
260
|
+
/** `dirs` as created, shallowest first; one that will not go is not empty, and neither are its parents. */
|
|
383
261
|
function unwindDirs(dirs) {
|
|
384
262
|
for (const made of [...dirs].reverse()) {
|
|
385
263
|
try {
|
|
@@ -391,37 +269,19 @@ function unwindDirs(dirs) {
|
|
|
391
269
|
}
|
|
392
270
|
}
|
|
393
271
|
|
|
394
|
-
/**
|
|
395
|
-
* Write the edits, keeping the previous contents so a red verdict can be undone.
|
|
396
|
-
*
|
|
397
|
-
* Each file lands by write-temp-then-rename, so no file is ever half-written. **The mode is carried
|
|
398
|
-
* across**: `rename(2)` replaces the inode, and `verify/index.sh` is executable — a repair that left
|
|
399
|
-
* the rail unexecutable would have broken the thing it exists to fix.
|
|
400
|
-
*
|
|
401
|
-
* **A parent directory is created where an edit names one that does not exist**, and the directories
|
|
402
|
-
* created are recorded so a rollback can remove them again. Neither step shipped today writes a new
|
|
403
|
-
* file, so nothing exercised this in production — which is exactly why it was worth fixing rather than
|
|
404
|
-
* leaving: a later step adding a nested file would have failed `ENOENT` on a perfectly valid edit.
|
|
405
|
-
* Copilot's promoted note, round 2 on #231.
|
|
406
|
-
*/
|
|
407
272
|
export function applyEdits(dir, edits, options = {}) {
|
|
408
|
-
// `write` is an injection point for the suite, the way `init` injects its reader and `skills-set`
|
|
409
|
-
// injects its manifest. No production caller passes it. It exists because the window this guards —
|
|
410
|
-
// `mkdir` succeeds, the write then fails — is a disk-full or permissions race that cannot be
|
|
411
|
-
// staged honestly on a real filesystem, and a failure path with no test is how the orphan it
|
|
412
|
-
// guards against got here in the first place.
|
|
413
273
|
const write = options.write ?? fs.writeFileSync;
|
|
414
|
-
const
|
|
274
|
+
const workspaceRoot = path.resolve(dir);
|
|
275
|
+
const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
|
|
415
276
|
const snapshots = [];
|
|
416
277
|
for (const edit of edits) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
// guards the same way. Copilot, round 4 on #231.
|
|
278
|
+
if (edit.root !== undefined && edit.root !== "workspace" && edit.root !== "tree") {
|
|
279
|
+
return { ok: false, snapshots, reason: `an edit names root \`${edit.root}\`, which is neither the workspace nor the tree` };
|
|
280
|
+
}
|
|
281
|
+
if (edit.root === "tree" && treeRoot === null) {
|
|
282
|
+
return { ok: false, snapshots, reason: `an edit to \`${edit.file}\` names the tree, and this workspace declares none` };
|
|
283
|
+
}
|
|
284
|
+
const root = edit.root === "tree" ? treeRoot : workspaceRoot;
|
|
425
285
|
const file = inside(root, edit.file);
|
|
426
286
|
if (file === null) {
|
|
427
287
|
return {
|
|
@@ -444,9 +304,19 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
444
304
|
}
|
|
445
305
|
}
|
|
446
306
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
307
|
+
if (edit.next === null) {
|
|
308
|
+
if (previous === null) continue;
|
|
309
|
+
if (!fs.lstatSync(file).isFile()) return { ok: false, snapshots, reason: `${file} is not a regular file — refusing to delete it` };
|
|
310
|
+
try {
|
|
311
|
+
fs.rmSync(file);
|
|
312
|
+
} catch (error) {
|
|
313
|
+
return { ok: false, snapshots, reason: `${file} could not be deleted — ${error.code ?? error.message}` };
|
|
314
|
+
}
|
|
315
|
+
snapshots.push({ file: edit.file, root: edit.root, previous, mode, created: [], deleted: true });
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// `lstat`, not `existsSync`, which follows links and answers `false` for a path it could not read.
|
|
450
320
|
const wanted = [];
|
|
451
321
|
for (let at = path.dirname(file); at.startsWith(root) && at !== root; at = path.dirname(at)) {
|
|
452
322
|
try {
|
|
@@ -460,10 +330,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
460
330
|
}
|
|
461
331
|
}
|
|
462
332
|
|
|
463
|
-
//
|
|
464
|
-
// successful rename, so every failure between here and there has to clean up its own
|
|
465
|
-
// directories or they are orphaned — created, unrecorded, and invisible to `restore`. That
|
|
466
|
-
// is the leak Copilot's promoted note found in the fix that added them, one round earlier.
|
|
333
|
+
// No snapshot owns these until the rename lands, so every failure before it unwinds them.
|
|
467
334
|
const created = [];
|
|
468
335
|
try {
|
|
469
336
|
for (const dir of wanted) {
|
|
@@ -471,11 +338,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
471
338
|
fs.mkdirSync(dir);
|
|
472
339
|
created.push(dir);
|
|
473
340
|
} catch (error) {
|
|
474
|
-
//
|
|
475
|
-
// goal rather than defeating it — but only if what appeared is a DIRECTORY. An
|
|
476
|
-
// `EEXIST` from a file of that name is a real failure, and swallowing both would
|
|
477
|
-
// be the fail-open. Not recorded in `created`: this run did not make it, so this
|
|
478
|
-
// run does not get to remove it. Copilot's promoted note, round 6.
|
|
341
|
+
// Made by another since the probe: fine if a directory, and not this run's to remove.
|
|
479
342
|
if (error.code !== "EEXIST" || !fs.lstatSync(dir).isDirectory()) throw error;
|
|
480
343
|
}
|
|
481
344
|
}
|
|
@@ -484,21 +347,13 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
484
347
|
return { ok: false, snapshots, reason: `${path.dirname(file)} could not be created — ${error.code ?? error.message}` };
|
|
485
348
|
}
|
|
486
349
|
|
|
487
|
-
//
|
|
488
|
-
// concurrent CI invocations, or a human beside a pipeline — would otherwise race on the same
|
|
489
|
-
// staging path and lose or corrupt an edit. Still in the same directory, so the `rename` stays
|
|
490
|
-
// atomic. Copilot, round 6 on #231.
|
|
350
|
+
// Unique per process and edit, and beside its file so the rename stays atomic.
|
|
491
351
|
const staging = `${file}.portulan-upgrade.${process.pid}.${stagingSeq++}`;
|
|
492
352
|
try {
|
|
493
|
-
//
|
|
494
|
-
// when the workspace is read — so the containment guard above says nothing about it. With
|
|
495
|
-
// default flags a symlink planted there would be **followed**, writing outside the
|
|
496
|
-
// workspace and defeating that guard entirely; a leftover file would be clobbered. `wx`
|
|
497
|
-
// fails with `EEXIST` on anything already at the path, symlink included, which is the
|
|
498
|
-
// fail-closed direction and rule 2 of the three a tool writing into somebody's tree owes.
|
|
499
|
-
// Copilot, round 10 on #231.
|
|
353
|
+
// `wx`: anything already at the staging path, a planted symlink included, fails rather than being followed.
|
|
500
354
|
write(staging, edit.next, { flag: "wx" });
|
|
501
|
-
|
|
355
|
+
const keep = mode ?? edit.mode ?? null;
|
|
356
|
+
if (keep !== null) fs.chmodSync(staging, keep);
|
|
502
357
|
fs.renameSync(staging, file);
|
|
503
358
|
} catch (error) {
|
|
504
359
|
try {
|
|
@@ -509,35 +364,21 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
509
364
|
unwindDirs(created);
|
|
510
365
|
return { ok: false, snapshots, reason: `${file} could not be written — ${error.code ?? error.message}` };
|
|
511
366
|
}
|
|
512
|
-
snapshots.push({ file: edit.file, previous, mode, created });
|
|
367
|
+
snapshots.push({ file: edit.file, root: edit.root, previous, mode, created });
|
|
513
368
|
}
|
|
514
369
|
return { ok: true, snapshots };
|
|
515
370
|
}
|
|
516
371
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
* **It reports what it did not restore rather than claiming the tree is clean.** "The workspace is
|
|
521
|
-
* exactly as it was" is a promise a mid-restore `EACCES` breaks, and a rollback that lies about
|
|
522
|
-
* having succeeded is worse than one that failed loudly.
|
|
523
|
-
*/
|
|
524
|
-
export function restore(dir, snapshots) {
|
|
525
|
-
const root = path.resolve(dir);
|
|
372
|
+
export function restore(dir, snapshots, options = {}) {
|
|
373
|
+
const workspaceRoot = path.resolve(dir);
|
|
374
|
+
const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
|
|
526
375
|
const restored = [];
|
|
527
376
|
const failed = [];
|
|
528
|
-
//
|
|
529
|
-
// newly-created directory record it as `created` on the FIRST of them only; forwards, that
|
|
530
|
-
// snapshot's `rmdir` fails because the second file is still there, and the directory is left
|
|
531
|
-
// behind. Backwards, the second file goes first and the directory is empty when its turn comes.
|
|
532
|
-
// Found by sweeping this file for the sibling of the note that added the directories, rather than
|
|
533
|
-
// by a round that raised it.
|
|
377
|
+
// Last edit first: a directory two edits share is recorded on the first, and empties only after the second.
|
|
534
378
|
for (const snapshot of [...snapshots].reverse()) {
|
|
535
|
-
//
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
// Copilot found it; the sweep did not. A snapshot normally comes from `applyEdits` and is
|
|
539
|
-
// therefore already contained, but this function is exported and takes them from a caller.
|
|
540
|
-
const file = inside(root, snapshot.file);
|
|
379
|
+
// Contained again, as `restore` is exported and a snapshot may come from any caller.
|
|
380
|
+
const root = snapshot.root === "tree" ? treeRoot : workspaceRoot;
|
|
381
|
+
const file = root === null ? null : inside(root, snapshot.file);
|
|
541
382
|
if (file === null || linkOnPath(root, file) !== null) {
|
|
542
383
|
failed.push(snapshot.file);
|
|
543
384
|
continue;
|
|
@@ -545,14 +386,8 @@ export function restore(dir, snapshots) {
|
|
|
545
386
|
try {
|
|
546
387
|
if (snapshot.previous === null) {
|
|
547
388
|
fs.rmSync(file, { force: true });
|
|
548
|
-
// And the directories this run had to create for it. Leaving them behind would make
|
|
549
|
-
// "the workspace is exactly as it was" false in a second way — quieter than a stray
|
|
550
|
-
// file, and still not true.
|
|
551
389
|
unwindDirs(snapshot.created ?? []);
|
|
552
390
|
} else {
|
|
553
|
-
// The sibling: a rollback writes back to a path that existed when the run began, but
|
|
554
|
-
// "existed then" is not "is a regular file now". Refusing to write through a link
|
|
555
|
-
// here costs nothing and keeps the rule whole at both write sites.
|
|
556
391
|
const at = fs.lstatSync(file, { throwIfNoEntry: false });
|
|
557
392
|
if (at?.isSymbolicLink()) throw new UpgradeError(`${file} is a symlink — refusing to restore through it`);
|
|
558
393
|
fs.writeFileSync(file, snapshot.previous);
|
|
@@ -585,13 +420,10 @@ export function usage() {
|
|
|
585
420
|
" --write apply the chain, then grade the result with doctor",
|
|
586
421
|
" --tree the value a 1.0 `repository` workspace needs and this will not guess",
|
|
587
422
|
"",
|
|
588
|
-
// Precisely which half is refused. The older wording — \"never inside the install\" — read as
|
|
589
|
-
// *this tool does not touch an installed workspace*, and a bare or `--check` run DOES read one
|
|
590
|
-
// to work out what it owes. Only writing is refused. Copilot's promoted note, round 3.
|
|
591
423
|
"A pointer is resolved through the host's installed-plugin record; the installed workspace is",
|
|
592
424
|
"read and reported on, and `--write` is refused there — migrate it at its own directory.",
|
|
593
425
|
"",
|
|
594
|
-
"Exit codes: 0 succeeded · 1 a red verdict · 2 could not run.",
|
|
426
|
+
"Exit codes: 0 succeeded · 1 a red verdict, or a step owed by hand under --check or --write · 2 could not run.",
|
|
595
427
|
].join("\n");
|
|
596
428
|
}
|
|
597
429
|
|
|
@@ -610,13 +442,6 @@ export async function run(argv = [], options = {}) {
|
|
|
610
442
|
for (let i = 0; i < argv.length; i += 1) {
|
|
611
443
|
const arg = argv[i];
|
|
612
444
|
if (arg === "--tree") {
|
|
613
|
-
// **Three refusals, matching the three parsers that already own this rule** —
|
|
614
|
-
// `cli/init.mjs`, `cli/new.mjs` and `cli/vendor.mjs` each refuse a missing value, a value
|
|
615
|
-
// that reads as another flag, and an empty one. This took only the first, so
|
|
616
|
-
// `--tree --write <dir>` swallowed `--write` as the tree value and **silently dropped the
|
|
617
|
-
// write**: a report where the user asked for a migration. A fourth parser inheriting half
|
|
618
|
-
// a rule is the shape this pull request has now hit three times.
|
|
619
|
-
// Copilot, round 12 on #231.
|
|
620
445
|
const value = argv[i + 1];
|
|
621
446
|
if (value === undefined) {
|
|
622
447
|
warn("upgrade: `--tree` needs a value — it is the one thing this tool will not guess");
|
|
@@ -641,8 +466,6 @@ export async function run(argv = [], options = {}) {
|
|
|
641
466
|
positional.push(arg);
|
|
642
467
|
}
|
|
643
468
|
|
|
644
|
-
// Refused rather than dropped. A silently-ignored `--wrote` is a user who believes they asked for
|
|
645
|
-
// something and got a report instead — `discover`'s rule, for `discover`'s reason.
|
|
646
469
|
const unknown = flags.filter((flag) => !KNOWN_FLAGS.has(flag));
|
|
647
470
|
if (unknown.length) {
|
|
648
471
|
warn(`upgrade: unknown flag \`${unknown[0]}\` — run \`portulan upgrade --help\``);
|
|
@@ -668,10 +491,6 @@ export async function run(argv = [], options = {}) {
|
|
|
668
491
|
let steps;
|
|
669
492
|
try {
|
|
670
493
|
spec = bundleSpec();
|
|
671
|
-
// Injected only by the suite, alongside `write` and `env`. The apply loop's guards against a
|
|
672
|
-
// step returning an unusable plan cannot be reached from the two steps this bundle ships —
|
|
673
|
-
// both are well-behaved — and a failure path with no test is how the defect those guards
|
|
674
|
-
// exist for arrived in the first place.
|
|
675
494
|
steps = options.steps ?? (await loadSteps());
|
|
676
495
|
} catch (error) {
|
|
677
496
|
warn(`upgrade: ${error.message}`);
|
|
@@ -691,11 +510,6 @@ export async function run(argv = [], options = {}) {
|
|
|
691
510
|
if (target.state === "resolved") {
|
|
692
511
|
say(`upgrade: ${target.sentence}`);
|
|
693
512
|
if (write) {
|
|
694
|
-
// A cache install is a **materialisation** whose identity is a version claim, not one of
|
|
695
|
-
// proposal 0017's two residences. Writing here would leave a directory that no longer
|
|
696
|
-
// matches the version the host's record names for it, and a reinstall would silently
|
|
697
|
-
// revert it. The workspace itself is migrated at its own directory — the only act that
|
|
698
|
-
// re-pins a feed.
|
|
699
513
|
warn(
|
|
700
514
|
`upgrade: \`${target.dir}\` is an installed workspace, pinned at the version the host's record names for it. ` +
|
|
701
515
|
"Refusing to migrate a copy: run this against the workspace's own directory and republish, then reinstall here",
|
|
@@ -710,12 +524,15 @@ export async function run(argv = [], options = {}) {
|
|
|
710
524
|
return read.code;
|
|
711
525
|
}
|
|
712
526
|
const ws = read.ws;
|
|
713
|
-
// The SHORTER of the two spellings. A relative path is what a reader wants for a workspace inside
|
|
714
|
-
// the tree they are standing in, and turns into `../../../../…` for a target that is not — a
|
|
715
|
-
// temp directory, or an install in the plugin cache. Neither is wrong; one is unreadable.
|
|
716
527
|
const relative = path.relative(process.cwd(), ws.dir);
|
|
717
528
|
const shown = relative && relative.length < ws.dir.length ? relative : ws.dir;
|
|
718
529
|
|
|
530
|
+
const offerCacheLifetime = (manifest) => {
|
|
531
|
+
if (check || target.state === "resolved") return;
|
|
532
|
+
if (manifest?.kind !== "repository" || manifest?.sessions?.cache_lifetime !== undefined) return;
|
|
533
|
+
for (const line of offerLines()) say(`upgrade: ${line}`);
|
|
534
|
+
};
|
|
535
|
+
|
|
719
536
|
// ---- which direction is this workspace off in
|
|
720
537
|
const declared = /^([0-9]+)\.([0-9]+)$/.exec(ws.manifest?.portulan?.spec ?? "");
|
|
721
538
|
if (!declared) {
|
|
@@ -756,7 +573,7 @@ export async function run(argv = [], options = {}) {
|
|
|
756
573
|
}
|
|
757
574
|
|
|
758
575
|
// ---- the plan
|
|
759
|
-
const ctx = { bundle: BUNDLE, spec, tree };
|
|
576
|
+
const ctx = { bundle: BUNDLE, spec, tree, today: options.today ?? new Date().toISOString().slice(0, 10) };
|
|
760
577
|
const plan = await planFor(ws, ctx, steps);
|
|
761
578
|
|
|
762
579
|
if (plan.unknown > 0) {
|
|
@@ -767,8 +584,6 @@ export async function run(argv = [], options = {}) {
|
|
|
767
584
|
return 2;
|
|
768
585
|
}
|
|
769
586
|
|
|
770
|
-
// The second mouth of the direction hole: a workspace behind by a MAJOR that no step reaches
|
|
771
|
-
// would plan to nothing and exit 0, which is the same false green from the other end.
|
|
772
587
|
if (behindByMajor && plan.owed === 0) {
|
|
773
588
|
warn(
|
|
774
589
|
`upgrade: ${shown} declares ${major}.${minor} and no migration in this chain reaches it — refusing to ` +
|
|
@@ -779,37 +594,41 @@ export async function run(argv = [], options = {}) {
|
|
|
779
594
|
|
|
780
595
|
if (plan.owed === 0) {
|
|
781
596
|
say(`upgrade: ${shown} owes nothing — it is current at ${major}.${minor}`);
|
|
597
|
+
offerCacheLifetime(ws.manifest);
|
|
782
598
|
return 0;
|
|
783
599
|
}
|
|
784
600
|
|
|
785
601
|
for (const entry of plan.entries.filter((e) => e.owed === true)) {
|
|
786
|
-
say(`upgrade: ${entry.step.id} (${entry.step.kind}) — ${entry.step.title}`);
|
|
602
|
+
say(`upgrade: ${entry.step.id} (${entry.step.kind}${entry.hand ? ", by hand" : ""}) — ${entry.step.title}`);
|
|
787
603
|
say(`upgrade: ${entry.because}`);
|
|
788
604
|
}
|
|
789
605
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
// 2 — advice and refusal disagreeing about the same act, which is the sibling of the refusal
|
|
793
|
-
// itself. Caught at the pre-commit checkpoint, reproduced end to end on a real install.
|
|
606
|
+
const byHandOwed = plan.entries.filter((entry) => entry.hand).length;
|
|
607
|
+
const byHandNote = byHandOwed > 0 ? `, ${byHandOwed} of them by hand` : "";
|
|
794
608
|
const advice =
|
|
795
609
|
target.state === "resolved"
|
|
796
610
|
? "run this against the workspace's own directory and republish — an installed copy is not migrated in place"
|
|
797
|
-
:
|
|
611
|
+
: byHandOwed === plan.owed
|
|
612
|
+
? "add what each names by hand, then upgrade again"
|
|
613
|
+
: `run with --write to apply ${byHandOwed > 0 ? "the rest" : "them"}`;
|
|
798
614
|
|
|
799
615
|
if (check) {
|
|
800
|
-
warn(`upgrade: ${shown} owes ${plan.owed} step(s) — ${advice}`);
|
|
616
|
+
warn(`upgrade: ${shown} owes ${plan.owed} step(s)${byHandNote} — ${advice}`);
|
|
801
617
|
return 1;
|
|
802
618
|
}
|
|
803
619
|
if (!write) {
|
|
804
|
-
say(`upgrade: ${plan.owed} step(s) owed. Nothing was written — ${advice}`);
|
|
620
|
+
say(`upgrade: ${plan.owed} step(s) owed${byHandNote}. Nothing was written — ${advice}`);
|
|
621
|
+
offerCacheLifetime(ws.manifest);
|
|
805
622
|
return 0;
|
|
806
623
|
}
|
|
807
624
|
|
|
808
625
|
// ---- apply, one step at a time, re-reading between them
|
|
809
626
|
let snapshots = [];
|
|
810
627
|
let current = ws;
|
|
628
|
+
// A step may declare the tree but none moves it, so every snapshot was made in the latest one.
|
|
629
|
+
let treeDir = ws.repository?.dir ?? null;
|
|
811
630
|
const undo = () => {
|
|
812
|
-
const result = restore(current.dir, snapshots);
|
|
631
|
+
const result = restore(current.dir, snapshots, { treeDir });
|
|
813
632
|
if (!result.ok) {
|
|
814
633
|
warn(`upgrade: the rollback was INCOMPLETE — put back ${result.restored.join(", ") || "nothing"}; NOT put back ${result.failed.join(", ")}`);
|
|
815
634
|
return false;
|
|
@@ -817,19 +636,14 @@ export async function run(argv = [], options = {}) {
|
|
|
817
636
|
return true;
|
|
818
637
|
};
|
|
819
638
|
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
//
|
|
639
|
+
const applied = new Set();
|
|
640
|
+
const byHand = new Map();
|
|
641
|
+
const apply = async (entry) => {
|
|
642
|
+
applied.add(entry.step.id);
|
|
643
|
+
// A step's `plan` is foreign code: a throw becomes a refusal, so the rollback still runs.
|
|
825
644
|
let planned;
|
|
826
645
|
try {
|
|
827
646
|
planned = await entry.step.plan(current, ctx);
|
|
828
|
-
// Same lesson as `planFor` above, at the second site: presence of `ok` is not a usable
|
|
829
|
-
// plan. `{ ok: true }` with no `edits` array made `applyEdits` throw on `undefined` —
|
|
830
|
-
// **bypassing the rollback entirely** and leaving a partially migrated workspace, which is
|
|
831
|
-
// the exact outcome the try/catch around this call exists to prevent. `{ ok: false }` with
|
|
832
|
-
// no `reason` printed `undefined` at the user. Copilot, round 8 on #231.
|
|
833
647
|
if (planned === null || typeof planned !== "object" || typeof planned.ok !== "boolean") {
|
|
834
648
|
planned = { ok: false, reason: `${entry.step.id} returned no plan` };
|
|
835
649
|
} else if (planned.ok && !Array.isArray(planned.edits)) {
|
|
@@ -846,11 +660,11 @@ export async function run(argv = [], options = {}) {
|
|
|
846
660
|
if (snapshots.length) warn("upgrade: nothing was left behind — the steps that had already run were rolled back");
|
|
847
661
|
return 2;
|
|
848
662
|
}
|
|
849
|
-
const
|
|
850
|
-
snapshots = [...snapshots, ...
|
|
851
|
-
if (!
|
|
663
|
+
const edited = applyEdits(current.dir, planned.edits, { treeDir });
|
|
664
|
+
snapshots = [...snapshots, ...edited.snapshots];
|
|
665
|
+
if (!edited.ok) {
|
|
852
666
|
if (!undo()) return 2;
|
|
853
|
-
warn(`upgrade: ${entry.step.id} — ${
|
|
667
|
+
warn(`upgrade: ${entry.step.id} — ${edited.reason}`);
|
|
854
668
|
return 2;
|
|
855
669
|
}
|
|
856
670
|
const again = readWorkspace(current.dir);
|
|
@@ -860,6 +674,42 @@ export async function run(argv = [], options = {}) {
|
|
|
860
674
|
return 2;
|
|
861
675
|
}
|
|
862
676
|
current = again.ws;
|
|
677
|
+
treeDir = current.repository?.dir ?? treeDir;
|
|
678
|
+
return null;
|
|
679
|
+
};
|
|
680
|
+
// A step can make an earlier one owed, so the chain is asked again until a pass applies nothing.
|
|
681
|
+
for (let pass = 0; ; pass++) {
|
|
682
|
+
let appliedNow = 0;
|
|
683
|
+
for (const entry of plan.entries) {
|
|
684
|
+
let asked = entry;
|
|
685
|
+
if (applied.size > 0 || entry.owed !== true) {
|
|
686
|
+
[asked] = (await planFor(current, ctx, [entry.step])).entries;
|
|
687
|
+
if (asked.owed === null) {
|
|
688
|
+
if (!undo()) return 2;
|
|
689
|
+
warn(`upgrade: ${entry.step.id} could not tell, after the steps before it — ${asked.because}. Rolled back`);
|
|
690
|
+
return 2;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
byHand.delete(entry.step.id);
|
|
694
|
+
if (asked.owed !== true) continue;
|
|
695
|
+
if (asked.hand) {
|
|
696
|
+
byHand.set(entry.step.id, asked.because);
|
|
697
|
+
continue;
|
|
698
|
+
}
|
|
699
|
+
if (pass > 0 || entry.owed !== true) {
|
|
700
|
+
say(`upgrade: ${entry.step.id} (${entry.step.kind}) — ${entry.step.title}`);
|
|
701
|
+
say(`upgrade: ${asked.because}`);
|
|
702
|
+
}
|
|
703
|
+
const refused = await apply(entry);
|
|
704
|
+
if (refused !== null) return refused;
|
|
705
|
+
appliedNow += 1;
|
|
706
|
+
}
|
|
707
|
+
if (appliedNow === 0) break;
|
|
708
|
+
if (pass === plan.entries.length) {
|
|
709
|
+
if (!undo()) return 2;
|
|
710
|
+
warn(`upgrade: the steps did not settle — pass ${pass + 1} still applied ${appliedNow}, and a chain of ${plan.entries.length} settles in fewer. Rolled back`);
|
|
711
|
+
return 2;
|
|
712
|
+
}
|
|
863
713
|
}
|
|
864
714
|
|
|
865
715
|
// ---- and grade what it produced, with the real validator
|
|
@@ -886,9 +736,16 @@ export async function run(argv = [], options = {}) {
|
|
|
886
736
|
return 1;
|
|
887
737
|
}
|
|
888
738
|
|
|
889
|
-
const
|
|
890
|
-
|
|
891
|
-
|
|
739
|
+
const named = (s) => {
|
|
740
|
+
const at = s.root === "tree" ? path.relative(current.dir, path.resolve(treeDir, s.file)).split(path.sep).join("/") : s.file;
|
|
741
|
+
return s.deleted ? `${at} (deleted)` : at;
|
|
742
|
+
};
|
|
743
|
+
const written = [...new Set(snapshots.map(named))];
|
|
744
|
+
if (applied.size > 0) say(`upgrade: applied ${applied.size} step(s) to ${shown} — ${written.join(", ")}. doctor is green`);
|
|
745
|
+
const left = plan.entries.filter((entry) => byHand.has(entry.step.id));
|
|
746
|
+
for (const entry of left) warn(`upgrade: ${entry.step.id} is owed and not placed — ${byHand.get(entry.step.id)}`);
|
|
747
|
+
offerCacheLifetime(current.manifest);
|
|
748
|
+
return left.length > 0 ? 1 : 0;
|
|
892
749
|
}
|
|
893
750
|
|
|
894
751
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|