@sleepy_panda_srl/portulan 0.2.0 → 0.4.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 +15 -4
- package/cli/README.md +61 -6
- package/cli/advisory.mjs +167 -53
- package/cli/cache-risk.mjs +69 -0
- package/cli/comments.mjs +8 -5
- package/cli/compile.mjs +781 -130
- package/cli/context.mjs +204 -43
- package/cli/doctor.mjs +248 -31
- package/cli/drills.mjs +56 -9
- package/cli/finish.md +34 -0
- package/cli/finish.mjs +8 -67
- package/cli/form.mjs +34 -18
- package/cli/frontmatter.mjs +39 -0
- package/cli/gate.mjs +14 -12
- package/cli/guidance-files.mjs +335 -0
- package/cli/handoff-policy.mjs +76 -0
- package/cli/html-entities.mjs +2130 -0
- package/cli/import-files.mjs +15 -0
- package/cli/index.mjs +77 -24
- package/cli/init-preview.mjs +553 -0
- package/cli/init.mjs +261 -46
- package/cli/instructions-journal.mjs +1185 -0
- package/cli/instructions.mjs +332 -57
- package/cli/ledger.mjs +230 -40
- package/cli/librarian.mjs +103 -19
- package/cli/persona-routing.mjs +209 -0
- package/cli/plugin-lint.mjs +77 -56
- package/cli/portulan.mjs +1 -1
- package/cli/recipe-output.mjs +91 -0
- package/cli/recipe-runner.md +68 -0
- package/cli/recipe-runner.mjs +131 -0
- package/cli/recipe-set.mjs +12 -3
- package/cli/review-meter.mjs +14 -9
- package/cli/ritual-contexts.mjs +103 -0
- package/cli/sessions.mjs +2 -1
- package/cli/spend-analysis.mjs +111 -0
- package/cli/spend-offer.md +53 -0
- package/cli/spend-offer.mjs +93 -0
- package/cli/stop-diagnostics.mjs +114 -0
- package/cli/stop-gate.mjs +133 -90
- package/cli/stop-policy.mjs +14 -0
- package/cli/symbols.mjs +55 -8
- package/cli/telemetry.mjs +66 -17
- package/cli/upgrade.mjs +62 -44
- package/cli/vendor.mjs +207 -125
- package/core/operating/context.md +401 -18
- package/core/operating/evolution.md +3 -2
- package/core/operating/loop.md +9 -4
- package/core/operating/sessions.md +6 -15
- package/core/personas/README.md +75 -0
- package/core/personas/implementer.md +3 -0
- package/core/personas/librarian.md +3 -0
- package/core/personas/reviewer.md +3 -0
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +1 -1
- package/core/templates/handoff.md +6 -2
- package/evals/README.md +23 -7
- package/evals/ab/personas/README.md +111 -0
- package/evals/ab/personas/acquisition.json +330 -0
- package/evals/ab/personas/bindings/implementer.txt +43 -0
- package/evals/ab/personas/bindings/librarian.txt +40 -0
- package/evals/ab/personas/bindings/reviewer.txt +41 -0
- package/evals/ab/personas/corpus.json +69 -0
- package/evals/ab/personas/grader.mjs +171 -0
- package/evals/ab/personas/implementer.json +774 -0
- package/evals/ab/personas/implementer.md +22 -0
- package/evals/ab/personas/librarian.json +758 -0
- package/evals/ab/personas/librarian.md +22 -0
- package/evals/ab/personas/protocol/continuation.mjs.txt +284 -0
- package/evals/ab/personas/protocol/first-outcome.mjs.txt +227 -0
- package/evals/ab/personas/protocol/first-probe.mjs.txt +172 -0
- package/evals/ab/personas/protocol/publisher.mjs.txt +126 -0
- package/evals/ab/personas/protocol/second-probe.mjs.txt +193 -0
- package/evals/ab/personas/repairs/first-control-answer.txt +23 -0
- package/evals/ab/personas/repairs/grader-before-decode-repair.mjs.txt +143 -0
- package/evals/ab/personas/reviewer.json +758 -0
- package/evals/ab/personas/reviewer.md +22 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/0.4.0.json +154 -0
- package/evals/releases/0.4.0.md +69 -0
- package/package.json +7 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +35 -0
- package/packs/rituals/checkpoints/pack.json +3 -2
- package/packs/tools/README.md +8 -8
- package/packs/tools/local-llm/README.md +176 -0
- package/packs/tools/local-llm/pack.json +32 -0
- package/packs/tools/local-llm/personas/local-worker.md +44 -0
- package/packs/tools/local-llm/runner/local-llm.mjs +1779 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +88 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +37 -3
- package/spec/migrations/0009-instruction-sections-on-read.mjs +60 -11
- package/spec/migrations/0011-persona-bindings-compiled.mjs +23 -0
- package/spec/migrations/README.md +6 -0
- package/spec/pack.schema.json +12 -3
- package/spec/slots.md +272 -123
- package/spec/workspace.schema.json +67 -11
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Stop recipe deadline policy.
|
|
2
|
+
export const DEFAULT_STOP_TIMEOUT = 90;
|
|
3
|
+
export const STOP_HOOK_MARGIN = 60;
|
|
4
|
+
export const MAX_STOP_TIMEOUT = Math.floor((2 ** 31 - 1) / 1000) - STOP_HOOK_MARGIN;
|
|
5
|
+
|
|
6
|
+
export function readStopPolicy(manifest) {
|
|
7
|
+
const seconds = manifest?.verify?.stopTimeout;
|
|
8
|
+
if (seconds === undefined) return { seconds: DEFAULT_STOP_TIMEOUT, declared: false };
|
|
9
|
+
const spec = manifest?.portulan?.spec;
|
|
10
|
+
const version = typeof spec === "string" ? /^(\d+)\.(\d+)$/.exec(spec) : null;
|
|
11
|
+
if (!version || Number(version[1]) !== 2 || Number(version[2]) < 14) throw new Error("verify.stopTimeout requires portulan.spec 2.14 or a later minor on major 2");
|
|
12
|
+
if (!Number.isSafeInteger(seconds) || seconds < 1 || seconds > MAX_STOP_TIMEOUT) throw new Error(`verify.stopTimeout must be an integer from 1 to ${MAX_STOP_TIMEOUT} seconds`);
|
|
13
|
+
return { seconds, declared: true };
|
|
14
|
+
}
|
package/cli/symbols.mjs
CHANGED
|
@@ -8,6 +8,7 @@ import fs from "node:fs";
|
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import process from "node:process";
|
|
10
10
|
import { pathToFileURL } from "node:url";
|
|
11
|
+
import { HTML_ENTITIES } from "./html-entities.mjs";
|
|
11
12
|
|
|
12
13
|
const USAGE = "usage: node cli/symbols.mjs <file>... | <file.md>#<heading>... | --find <name>...";
|
|
13
14
|
|
|
@@ -764,7 +765,14 @@ function order(entries) {
|
|
|
764
765
|
// 5. Markdown: headings, and the section under each
|
|
765
766
|
// ===========================================================================================
|
|
766
767
|
|
|
767
|
-
//
|
|
768
|
+
// Top-level headings; list-container headings and full CommonMark block parsing are outside this reader.
|
|
769
|
+
|
|
770
|
+
function characterReference(reference) {
|
|
771
|
+
if (!reference.startsWith("#")) return Object.hasOwn(HTML_ENTITIES, reference) ? HTML_ENTITIES[reference] : `&${reference};`;
|
|
772
|
+
const hex = /^#x/i.test(reference);
|
|
773
|
+
const point = Number.parseInt(reference.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
774
|
+
return String.fromCodePoint(point === 0 || point > 0x10ffff || (point >= 0xd800 && point <= 0xdfff) ? 0xfffd : point);
|
|
775
|
+
}
|
|
768
776
|
|
|
769
777
|
/** A heading's text as it reads rendered: a code span's content kept, links and images their text, and emphasis, tags and escapes gone. */
|
|
770
778
|
function plainText(title) {
|
|
@@ -772,9 +780,8 @@ function plainText(title) {
|
|
|
772
780
|
text
|
|
773
781
|
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
774
782
|
.replace(/!?\[([^\]]*)\]\[[^\]]*\]/g, "$1")
|
|
775
|
-
.replace(
|
|
776
|
-
|
|
777
|
-
.replace(/(^|[^\p{L}\p{N}*_])[*_]+|[*_]+(?=[^\p{L}\p{N}*_]|$)/gu, "$1");
|
|
783
|
+
.replace(/\\([!-/:-@[-`{-~])|<([A-Za-z][A-Za-z0-9+.-]{1,31}:[^<>\x00-\x20]*|[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?)>|<[^>]*>|&(#\d{1,7}|#[xX][\da-fA-F]{1,6}|[A-Za-z][A-Za-z0-9]*);|(^|[^\p{L}\p{N}*_])[*_]+|[*_]+(?=[^\p{L}\p{N}*_]|$)/gu,
|
|
784
|
+
(match, escaped, autolink, reference, lead) => escaped ?? autolink ?? (reference ? characterReference(reference) : lead ?? ""));
|
|
778
785
|
let out = "";
|
|
779
786
|
let at = 0;
|
|
780
787
|
for (const span of title.matchAll(/(`+)(.+?)\1(?!`)/g)) {
|
|
@@ -794,6 +801,15 @@ const loose = (text) => plainText(text).replace(/\s+/g, " ").trim().toLowerCase(
|
|
|
794
801
|
// A line that opens a block other than a paragraph, so no `=` or `-` line under it makes it a heading.
|
|
795
802
|
const NOT_PARAGRAPH = /^(?: {4}|\t| {0,3}(?:>|[-*+](?:[ \t]|$)|\d{1,9}[.)](?:[ \t]|$)|<[A-Za-z/!?]|\|))/;
|
|
796
803
|
const RULE = /^ {0,3}(?:(?:-[ \t]*){3,}|(?:\*[ \t]*){3,}|(?:_[ \t]*){3,})$/;
|
|
804
|
+
const HEADING_OR_FENCE = /^(?:#{1,6}(?:[ \t]|$)|`{3,}|~{3,})/;
|
|
805
|
+
|
|
806
|
+
const startsListParagraph = (text) => text.trim() !== "" && !NOT_PARAGRAPH.test(text) && !RULE.test(text) && !HEADING_OR_FENCE.test(text);
|
|
807
|
+
|
|
808
|
+
function columns(text, start = 0) {
|
|
809
|
+
let col = start;
|
|
810
|
+
for (const char of text) col += char === "\t" ? 4 - col % 4 : 1;
|
|
811
|
+
return col;
|
|
812
|
+
}
|
|
797
813
|
|
|
798
814
|
/** The outline of a Markdown file: `{lines, bytes, entries}`, an entry per heading, nested by level. */
|
|
799
815
|
export function outlineMd(src) {
|
|
@@ -811,8 +827,10 @@ export function outlineMd(src) {
|
|
|
811
827
|
}
|
|
812
828
|
let fence = null;
|
|
813
829
|
let comment = false;
|
|
814
|
-
let para = null; // the line
|
|
830
|
+
let para = null; // the line the current paragraph opened at
|
|
815
831
|
let above = "start"; // what the line above was: start, blank, heading, rule, para or other
|
|
832
|
+
const listIndents = [];
|
|
833
|
+
let listParagraph = false;
|
|
816
834
|
for (; i < lines; i++) {
|
|
817
835
|
const line = at(i);
|
|
818
836
|
if (fence) {
|
|
@@ -829,6 +847,36 @@ export function outlineMd(src) {
|
|
|
829
847
|
}
|
|
830
848
|
continue;
|
|
831
849
|
}
|
|
850
|
+
const indent = columns(/^[ \t]*/.exec(line)[0]);
|
|
851
|
+
const marker = !RULE.test(line) && /^([ \t]*)([-*+]|\d{1,9}[.)])([ \t]+|$)/.exec(line);
|
|
852
|
+
if (marker && (indent <= 3 || (listIndents.length && indent < listIndents.at(-1) + 4))) {
|
|
853
|
+
const end = indent + marker[2].length;
|
|
854
|
+
const padding = columns(marker[3], end) - end;
|
|
855
|
+
while (listIndents.length && listIndents.at(-1) > indent) listIndents.pop();
|
|
856
|
+
listIndents.push(end + (padding >= 1 && padding <= 4 ? padding : 1));
|
|
857
|
+
listParagraph = padding <= 4 && startsListParagraph(line.slice(marker[0].length));
|
|
858
|
+
para = null;
|
|
859
|
+
above = "other";
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
if (listIndents.length) {
|
|
863
|
+
if (line.trim() === "") {
|
|
864
|
+
listParagraph = false;
|
|
865
|
+
} else if (indent >= listIndents[0] || (listParagraph && !RULE.test(line) && !/^ {0,3}(?:#{1,6}(?:[ \t]|$)|>|`{3,}|~{3,}|<)/.test(line))) {
|
|
866
|
+
if (indent >= listIndents[0]) {
|
|
867
|
+
while (listIndents.at(-1) > indent) listIndents.pop();
|
|
868
|
+
listParagraph = (listParagraph || indent < listIndents.at(-1) + 4) && startsListParagraph(line.trimStart());
|
|
869
|
+
}
|
|
870
|
+
para = null;
|
|
871
|
+
above = "other";
|
|
872
|
+
continue;
|
|
873
|
+
} else {
|
|
874
|
+
listIndents.length = 0;
|
|
875
|
+
listParagraph = false;
|
|
876
|
+
para = null;
|
|
877
|
+
above = "blank";
|
|
878
|
+
}
|
|
879
|
+
}
|
|
832
880
|
const open = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
833
881
|
if (open && !(open[1][0] === "`" && open[2].includes("`"))) {
|
|
834
882
|
fence = new RegExp(`^ {0,3}${open[1][0]}{${open[1].length},}[ \\t]*$`);
|
|
@@ -843,7 +891,7 @@ export function outlineMd(src) {
|
|
|
843
891
|
}
|
|
844
892
|
const atx = /^ {0,3}(#{1,6})(?:[ \t]+|$)(.*)$/.exec(line);
|
|
845
893
|
if (atx) {
|
|
846
|
-
found.push({ start: i + 1, level: atx[1].length, title: atx[2].replace(/(?:^|[ \t]+)#+[ \t]*$/, "").
|
|
894
|
+
found.push({ start: i + 1, level: atx[1].length, title: atx[2].replace(/(?:^|[ \t]+)#+[ \t]*$/, "").replace(/^[ \t]+|[ \t]+$/g, "") });
|
|
847
895
|
para = null;
|
|
848
896
|
above = "heading";
|
|
849
897
|
continue;
|
|
@@ -866,8 +914,7 @@ export function outlineMd(src) {
|
|
|
866
914
|
para = null;
|
|
867
915
|
above = "other";
|
|
868
916
|
} else if (above !== "para" && above !== "other") {
|
|
869
|
-
|
|
870
|
-
para = /^\S/.test(line) ? i + 1 : null;
|
|
917
|
+
para = i + 1;
|
|
871
918
|
above = para === null ? "other" : "para";
|
|
872
919
|
}
|
|
873
920
|
}
|
package/cli/telemetry.mjs
CHANGED
|
@@ -17,16 +17,35 @@ import { recipeSet, resolverFor } from "./recipe-set.mjs";
|
|
|
17
17
|
import { isInside } from "./inside.mjs";
|
|
18
18
|
|
|
19
19
|
// ---------------------------------------------------------------------------------------------
|
|
20
|
-
// The offline audit:
|
|
20
|
+
// The offline audit: recipes reach neither the network nor local usage records
|
|
21
21
|
// ---------------------------------------------------------------------------------------------
|
|
22
22
|
|
|
23
|
-
/** Every mode in cli/
|
|
23
|
+
/** Every network mode in cli/ or a pack (named from `<category>/<name>/`); a new one is unrailed until it has a row here. */
|
|
24
24
|
export const NETWORK_MODES = Object.freeze([
|
|
25
|
+
{ module: "cli/fixtures/on-path-host/capture.mjs", flag: "--run-host", what: "runs the manual real-host on-path demonstration" },
|
|
26
|
+
{ module: "cli/fixtures/nested-on-path-host/capture.mjs", flag: "--run-host", what: "runs the manual real-host nested instruction demonstration" },
|
|
27
|
+
{ module: "cli/context.mjs", flag: "--count-tokens", what: "sends measured project source to Anthropic's token counter" },
|
|
28
|
+
{ module: "cli/doctor.mjs", flag: "--count-tokens", aliases: [{ module: "cli/portulan.mjs", subcommand: "doctor" }, { module: "portulan", subcommand: "doctor" }, { module: "@sleepy_panda_srl/portulan", subcommand: "doctor" }], what: "requests a provider token estimate" },
|
|
25
29
|
{ module: "cli/review-meter.mjs", flag: "--fetch", what: "fetches review data from GitHub" },
|
|
26
30
|
{ module: "cli/telemetry.mjs", flag: "--export", what: "posts an OTLP payload to a collector" },
|
|
27
31
|
{ module: "cli/feedback.mjs", flag: "--approve", what: "files a GitHub issue with `gh issue create`" },
|
|
32
|
+
{
|
|
33
|
+
module: "tools/local-llm/runner/local-llm.mjs",
|
|
34
|
+
unless: "--verify-offline",
|
|
35
|
+
alsoUnless: ["eval", "--verify"],
|
|
36
|
+
what: "calls the model endpoint except offline rails and captured-record verification",
|
|
37
|
+
},
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
/** Local usage is machine-specific too; static mode must be explicit on each recipe invocation. */
|
|
41
|
+
export const LOCAL_RECORD_MODES = Object.freeze([
|
|
42
|
+
{ module: "cli/doctor.mjs", requiresStatic: "--static", what: "reads local usage records by default" },
|
|
43
|
+
{ module: "cli/portulan.mjs", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
|
|
44
|
+
{ module: "portulan", subcommand: "doctor", requiresStatic: "--static", what: "dispatches to the local usage-record reader" },
|
|
28
45
|
]);
|
|
29
46
|
|
|
47
|
+
export const modeSpelling = (m) => m.requiresStatic ? `${m.module}${m.subcommand ? ` ${m.subcommand}` : ""} without an immediate ${m.requiresStatic}` : (m.flag ? [m, ...(m.aliases ?? [])].map((s) => `${s.module}${s.subcommand ? ` ${s.subcommand}` : ""} ${m.flag}`).join(", ") : `${m.module} with anything but ${m.unless} alone${m.alsoUnless ? ` or ${m.alsoUnless.join(" ")} alone` : ""}`);
|
|
48
|
+
|
|
30
49
|
/** Whole-line comments only: a trailing `#` may sit inside a quote. */
|
|
31
50
|
export const stripShellComments = (source) =>
|
|
32
51
|
source
|
|
@@ -36,13 +55,38 @@ export const stripShellComments = (source) =>
|
|
|
36
55
|
|
|
37
56
|
const shellTokens = (body) => body.split(/\s+/).map((t) => t.replace(/^[("'`]+/, "").replace(/[)"'`;]+$/, "")).filter(Boolean);
|
|
38
57
|
|
|
39
|
-
const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`);
|
|
58
|
+
const tokenNamesModule = (token, module) => token === module || token.endsWith(`/${module}`) || (module.startsWith("@") && token.startsWith(`${module}@`));
|
|
40
59
|
|
|
41
60
|
const tokenIsFlag = (token, flag) => token === flag || token.startsWith(`${flag}=`);
|
|
61
|
+
const shellSource = (source) => stripShellComments(source).replace(/\\\r?\n/g, "");
|
|
62
|
+
|
|
63
|
+
/** An `unless` row passes only where every invocation is the module and its one safe flag, alone on its command. */
|
|
64
|
+
const onlySafeInvocations = (source, m) =>
|
|
65
|
+
shellSource(source)
|
|
66
|
+
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
67
|
+
.every((segment) => {
|
|
68
|
+
const tokens = shellTokens(segment);
|
|
69
|
+
if (tokens[0] === "for" || ["[", "test"].includes(tokens[0]) || (tokens[0] === "if" && ["[", "test"].includes(tokens[1]))) return true;
|
|
70
|
+
return tokens.every((t, i) => !tokenNamesModule(t, m.module) || ((tokens.length === i + 2 && tokens[i + 1] === m.unless) || (m.alsoUnless && JSON.stringify(tokens.slice(i + 1)) === JSON.stringify(m.alsoUnless))));
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const onlyStaticInvocations = (source, mode) => shellSource(source)
|
|
74
|
+
.split(/\n|;|&&?|\|\|?|\$\(|<\(|>\(|`/)
|
|
75
|
+
.every((segment) => {
|
|
76
|
+
const words = shellTokens(segment);
|
|
77
|
+
const first = words[0];
|
|
78
|
+
// A path in a file test or dependency list is data, not a CLI invocation.
|
|
79
|
+
if (["for", "printf", "echo", "[", "test"].includes(first) || (first === "if" && words.slice(1, 3).some((w) => ["[", "test"].includes(w)))) return true;
|
|
80
|
+
return words.every((word, i) => !tokenNamesModule(word, mode.module) || (mode.subcommand && words[i + 1] !== mode.subcommand) || words[i + (mode.subcommand ? 2 : 1)] === mode.requiresStatic);
|
|
81
|
+
});
|
|
42
82
|
|
|
43
83
|
export function auditRecipeSource(source) {
|
|
44
|
-
const tokens = shellTokens(
|
|
45
|
-
|
|
84
|
+
const tokens = shellTokens(shellSource(source));
|
|
85
|
+
const network = NETWORK_MODES.filter((m) =>
|
|
86
|
+
m.flag ? [m, ...(m.aliases ?? [])].some((s) => tokens.some((t, i) => tokenNamesModule(t, s.module) && (!s.subcommand || tokens[i + 1] === s.subcommand))) && tokens.some((t) => tokenIsFlag(t, m.flag)) : !onlySafeInvocations(source, m),
|
|
87
|
+
);
|
|
88
|
+
const local = LOCAL_RECORD_MODES.filter((m) => !onlyStaticInvocations(source, m));
|
|
89
|
+
return [...network, ...local];
|
|
46
90
|
}
|
|
47
91
|
|
|
48
92
|
export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
@@ -72,9 +116,11 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
72
116
|
const findings = [];
|
|
73
117
|
const read = [];
|
|
74
118
|
for (const r of recipes) {
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
119
|
+
const words = String(r.run ?? "").split(/\s+/).filter(Boolean);
|
|
120
|
+
// Direct CLI commands are graded as shell text; referenced shell scripts are read too.
|
|
121
|
+
const bare = words[0] === "portulan";
|
|
122
|
+
const direct = bare || (words[0] === "node" && !words.some((t) => t.endsWith(".sh")));
|
|
123
|
+
const script = bare ? "cli/portulan.mjs" : direct ? words.find((t) => /\.[cm]?js$/.test(t)) : words.find((t) => t.endsWith(".sh"));
|
|
78
124
|
if (!script) {
|
|
79
125
|
return { ok: false, why: `recipe ${JSON.stringify(r.id)} has no readable script in its run line (${JSON.stringify(r.run ?? null)}); this audit cannot grade what it cannot read` };
|
|
80
126
|
}
|
|
@@ -89,7 +135,10 @@ export function auditRecipes({ workspaceDir, repoRoot, packRoots = [] }) {
|
|
|
89
135
|
return { ok: false, why: `recipe ${JSON.stringify(r.id)} names ${script}, which could not be read — ${cause.message}` };
|
|
90
136
|
}
|
|
91
137
|
read.push(r.id);
|
|
92
|
-
|
|
138
|
+
const hits = [...auditRecipeSource(r.run), ...(direct ? [] : auditRecipeSource(source))];
|
|
139
|
+
for (const [i, hit] of hits.entries()) {
|
|
140
|
+
if (!hits.slice(0, i).some((previous) => previous.module === hit.module && previous.flag === hit.flag && previous.requiresStatic === hit.requiresStatic)) findings.push({ recipe: r.id, script, ...hit });
|
|
141
|
+
}
|
|
93
142
|
}
|
|
94
143
|
return { ok: true, examined: read, findings };
|
|
95
144
|
}
|
|
@@ -157,7 +206,7 @@ export const PRODUCERS = {
|
|
|
157
206
|
return [
|
|
158
207
|
{ name: "portulan.review.pull_requests", unit: "{pull_request}", description: "Merged pull requests in the metered window.", value: m.pullRequests },
|
|
159
208
|
{ name: "portulan.review.submissions", unit: "{submission}", description: "Reviews the reviewer submitted across the window. Submission units, never fix-rounds.", value: m.submissions },
|
|
160
|
-
{ name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure a-review-loop-needs-a-bound.md
|
|
209
|
+
{ name: "portulan.review.submissions_per_pull_request", unit: "1", description: "The figure the retired a-review-loop-needs-a-bound.md bounded the loop on.", value: m.submissionsPerPullRequest },
|
|
161
210
|
{ name: "portulan.review.submissions_no_inline", unit: "{submission}", description: "Submissions raising no inline comment.", value: m.noInline },
|
|
162
211
|
{ name: "portulan.review.no_inline_rate", unit: "1", description: "An UPPER BOUND on the found-nothing rate; a submission carrying only suppressed notes counts in it.", value: m.noInlineRate },
|
|
163
212
|
{ name: "portulan.review.pushes", unit: "{push}", description: "Distinct heads the reviewer saw — a floor on pushes, not a count of them.", value: m.pushes },
|
|
@@ -336,7 +385,7 @@ const USAGE = [
|
|
|
336
385
|
" first: OTEL_EXPORTER_OTLP_METRICS_ENDPOINT (used as given) else",
|
|
337
386
|
" OTEL_EXPORTER_OTLP_ENDPOINT (a base, +/v1/metrics); and",
|
|
338
387
|
" OTEL_EXPORTER_OTLP_METRICS_HEADERS replacing OTEL_EXPORTER_OTLP_HEADERS",
|
|
339
|
-
" --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode;",
|
|
388
|
+
" --audit-recipes assert that no recipe the workspace YIELDS can reach a network mode or read local usage records;",
|
|
340
389
|
" takes --workspace and --pack-root, and needs no --config",
|
|
341
390
|
"",
|
|
342
391
|
"exit 0 the thing asked for happened · 1 a verdict (golden drift, or export while opted out)",
|
|
@@ -403,16 +452,16 @@ export async function run(argv = process.argv.slice(2), io = console, { env = pr
|
|
|
403
452
|
return 2;
|
|
404
453
|
}
|
|
405
454
|
if (res.findings.length > 0) {
|
|
406
|
-
io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network:`);
|
|
407
|
-
for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${f
|
|
408
|
-
io.error("A verify recipe may not make a network call: spec/slots.md and .portulan/verify/README.md");
|
|
455
|
+
io.error(`telemetry: ${res.findings.length} verify recipe(s) can reach the network or local usage records:`);
|
|
456
|
+
for (const f of res.findings) io.error(` - ${f.recipe} (${f.script}) invokes ${modeSpelling(f)}, which ${f.what}`);
|
|
457
|
+
io.error("A verify recipe may not make a network call or read local usage records: spec/slots.md and .portulan/verify/README.md");
|
|
409
458
|
io.error("both prohibit it, because a rail that moves with the network goes red about the world");
|
|
410
459
|
io.error("rather than about the tree.");
|
|
411
460
|
return 1;
|
|
412
461
|
}
|
|
413
|
-
io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode`);
|
|
414
|
-
io.log(` Modes railed: ${NETWORK_MODES.map(
|
|
415
|
-
io.log(" A network mode with no row here is UNRAILED, and nothing can audit that a row was added.");
|
|
462
|
+
io.log(`telemetry: ${res.examined.length} yielded recipe(s) examined; none reaches a network mode or reads local usage records`);
|
|
463
|
+
io.log(` Modes railed: ${[...NETWORK_MODES, ...LOCAL_RECORD_MODES].map(modeSpelling).join(", ")}.`);
|
|
464
|
+
io.log(" A network or local-record mode with no row here is UNRAILED, and nothing can audit that a row was added.");
|
|
416
465
|
return 0;
|
|
417
466
|
}
|
|
418
467
|
|
package/cli/upgrade.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Exit 0 succeeded · 1 a step owed under `--check` or left by hand, a red workspace, an unresolved pointer · 2 could not run.
|
|
5
5
|
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import { assertNoSourceJournal } from "./instructions-journal.mjs";
|
|
7
8
|
import path from "node:path";
|
|
8
9
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
9
10
|
|
|
@@ -12,6 +13,7 @@ import { resolveGovernor } from "./discover.mjs";
|
|
|
12
13
|
import { gitIn } from "./form.mjs";
|
|
13
14
|
import { offerLines } from "./sessions.mjs";
|
|
14
15
|
import { walk } from "./vendor.mjs";
|
|
16
|
+
import { atomicWrite,guardedRemove,readOutput,sameContent } from "./guidance-files.mjs";
|
|
15
17
|
|
|
16
18
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
17
19
|
const BUNDLE = path.resolve(HERE, "..");
|
|
@@ -19,10 +21,10 @@ const MIGRATIONS = path.join(BUNDLE, "spec", "migrations");
|
|
|
19
21
|
|
|
20
22
|
let stagingSeq = 0;
|
|
21
23
|
|
|
22
|
-
/**
|
|
24
|
+
/** Unavailable, without a verdict about the workspace. */
|
|
23
25
|
export class UpgradeError extends Error {}
|
|
24
26
|
|
|
25
|
-
/**
|
|
27
|
+
/** This bundle's Workspace Definition version from the schema `$id`. */
|
|
26
28
|
export function bundleSpec({ schemaPath } = {}) {
|
|
27
29
|
const file = schemaPath ?? path.join(BUNDLE, "spec", "workspace.schema.json");
|
|
28
30
|
let text;
|
|
@@ -68,7 +70,7 @@ export async function loadSteps({ dir = MIGRATIONS } = {}) {
|
|
|
68
70
|
return steps;
|
|
69
71
|
}
|
|
70
72
|
|
|
71
|
-
/**
|
|
73
|
+
/** The step's workspace view; contract: `spec/migrations/README.md`. */
|
|
72
74
|
export function readWorkspace(dir) {
|
|
73
75
|
const root = path.resolve(dir);
|
|
74
76
|
const file = path.join(root, "workspace.json");
|
|
@@ -120,7 +122,7 @@ export function readWorkspace(dir) {
|
|
|
120
122
|
};
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
/** The
|
|
125
|
+
/** The step's declared repository view, or null without a tree. */
|
|
124
126
|
export function repositoryView(root, manifest) {
|
|
125
127
|
if (typeof manifest?.tree !== "string") return null;
|
|
126
128
|
const dir = path.resolve(root, manifest.tree);
|
|
@@ -223,7 +225,7 @@ export async function planFor(ws, ctx, steps) {
|
|
|
223
225
|
} catch (error) {
|
|
224
226
|
answer = { owed: null, because: `${step.id} threw while deciding whether it is owed — ${error.message}` };
|
|
225
227
|
}
|
|
226
|
-
// `hand
|
|
228
|
+
// `hand` requires a person; report it and continue.
|
|
227
229
|
entries.push({ step, owed: answer.owed, because: answer.because, hand: answer.owed === true && answer.hand === true });
|
|
228
230
|
if (answer.owed === null) unknown += 1;
|
|
229
231
|
else if (answer.owed === true) owed += 1;
|
|
@@ -231,14 +233,14 @@ export async function planFor(ws, ctx, steps) {
|
|
|
231
233
|
return { entries, owed, unknown };
|
|
232
234
|
}
|
|
233
235
|
|
|
234
|
-
/**
|
|
236
|
+
/** Resolve inside root, rejecting escapes. */
|
|
235
237
|
export function inside(root, rel) {
|
|
236
238
|
const resolved = path.resolve(root, rel);
|
|
237
239
|
if (resolved !== root && !resolved.startsWith(`${root}${path.sep}`)) return null;
|
|
238
240
|
return resolved;
|
|
239
241
|
}
|
|
240
242
|
|
|
241
|
-
/**
|
|
243
|
+
/** Refusal or null; root may itself follow macOS's `/var` alias. */
|
|
242
244
|
function linkOnPath(root, resolved) {
|
|
243
245
|
const rel = path.relative(root, resolved);
|
|
244
246
|
if (rel === "") return null;
|
|
@@ -257,7 +259,7 @@ function linkOnPath(root, resolved) {
|
|
|
257
259
|
return null;
|
|
258
260
|
}
|
|
259
261
|
|
|
260
|
-
/**
|
|
262
|
+
/** Remove created directories deepest first, stopping at a nonempty one. */
|
|
261
263
|
function unwindDirs(dirs) {
|
|
262
264
|
for (const made of [...dirs].reverse()) {
|
|
263
265
|
try {
|
|
@@ -275,6 +277,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
275
277
|
const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
|
|
276
278
|
const snapshots = [];
|
|
277
279
|
for (const edit of edits) {
|
|
280
|
+
try{options.beforeMutation?.(edit);}catch(error){return {ok:false,snapshots,reason:error.message};}
|
|
278
281
|
if (edit.root !== undefined && edit.root !== "workspace" && edit.root !== "tree") {
|
|
279
282
|
return { ok: false, snapshots, reason: `an edit names root \`${edit.root}\`, which is neither the workspace nor the tree` };
|
|
280
283
|
}
|
|
@@ -294,29 +297,35 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
294
297
|
if (linked !== null) return { ok: false, snapshots, reason: linked };
|
|
295
298
|
|
|
296
299
|
let previous = null;
|
|
297
|
-
let mode = null;
|
|
300
|
+
let mode = null, observedIdentity = null;
|
|
298
301
|
try {
|
|
299
|
-
|
|
300
|
-
|
|
302
|
+
const at=fs.lstatSync(file);
|
|
303
|
+
if(!at.isFile()) return {ok:false,snapshots,reason:`${file} is not a regular file`};
|
|
304
|
+
if(edit.nested && edit.next!==null && at.nlink!==1) return {ok:false,snapshots,reason:`${file} is a multiply linked guidance output`};
|
|
305
|
+
previous = readOutput(file,root);
|
|
306
|
+
const after=fs.lstatSync(file);
|
|
307
|
+
if(after.dev!==at.dev||after.ino!==at.ino||after.mode!==at.mode)return {ok:false,snapshots,reason:`${file} changed while its original was read`};
|
|
308
|
+
mode=at.mode;observedIdentity={dev:at.dev,ino:at.ino,mode:at.mode};
|
|
301
309
|
} catch (error) {
|
|
302
310
|
if (error.code !== "ENOENT") {
|
|
303
311
|
return { ok: false, snapshots, reason: `${file} could not be read before writing — ${error.code ?? error.message}` };
|
|
304
312
|
}
|
|
305
313
|
}
|
|
306
314
|
|
|
315
|
+
if (Object.hasOwn(edit,"before") && !sameContent(previous,edit.before)) return {ok:false,snapshots,reason:`${file} changed after guidance planning`};
|
|
316
|
+
if(edit.beforeIdentity){const at=fs.lstatSync(file,{throwIfNoEntry:false});if(!at||at.dev!==edit.beforeIdentity.dev||at.ino!==edit.beforeIdentity.ino||at.mode!==edit.beforeIdentity.mode)return {ok:false,snapshots,reason:`${file} changed identity after source planning`};}
|
|
307
317
|
if (edit.next === null) {
|
|
308
318
|
if (previous === null) continue;
|
|
309
319
|
if (!fs.lstatSync(file).isFile()) return { ok: false, snapshots, reason: `${file} is not a regular file — refusing to delete it` };
|
|
310
320
|
try {
|
|
311
|
-
|
|
321
|
+
guardedRemove(file,{before:previous,identity:edit.beforeIdentity??observedIdentity,allowLinks:Boolean(edit.claimBase),claimBase:edit.claimBase,onClaim:data=>options.journal?.(edit,"claim",data),onRemove:claim=>{snapshots.push({file:edit.file,root:edit.root,previous,mode,created:[],deleted:true,after:null,identity:null,cleanup:[claim]});options.journal?.(edit,"retired",claim);}});
|
|
312
322
|
} catch (error) {
|
|
313
323
|
return { ok: false, snapshots, reason: `${file} could not be deleted — ${error.code ?? error.message}` };
|
|
314
324
|
}
|
|
315
|
-
snapshots.push({ file: edit.file, root: edit.root, previous, mode, created: [], deleted: true });
|
|
316
325
|
continue;
|
|
317
326
|
}
|
|
318
327
|
|
|
319
|
-
//
|
|
328
|
+
// lstat distinguishes links and unreadable paths from absence.
|
|
320
329
|
const wanted = [];
|
|
321
330
|
for (let at = path.dirname(file); at.startsWith(root) && at !== root; at = path.dirname(at)) {
|
|
322
331
|
try {
|
|
@@ -330,7 +339,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
330
339
|
}
|
|
331
340
|
}
|
|
332
341
|
|
|
333
|
-
//
|
|
342
|
+
// Unwind this edit's created directories if publication fails.
|
|
334
343
|
const created = [];
|
|
335
344
|
try {
|
|
336
345
|
for (const dir of wanted) {
|
|
@@ -338,7 +347,7 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
338
347
|
fs.mkdirSync(dir);
|
|
339
348
|
created.push(dir);
|
|
340
349
|
} catch (error) {
|
|
341
|
-
//
|
|
350
|
+
// A concurrently created directory is not ours to remove.
|
|
342
351
|
if (error.code !== "EEXIST" || !fs.lstatSync(dir).isDirectory()) throw error;
|
|
343
352
|
}
|
|
344
353
|
}
|
|
@@ -347,24 +356,17 @@ export function applyEdits(dir, edits, options = {}) {
|
|
|
347
356
|
return { ok: false, snapshots, reason: `${path.dirname(file)} could not be created — ${error.code ?? error.message}` };
|
|
348
357
|
}
|
|
349
358
|
|
|
350
|
-
//
|
|
359
|
+
// Stage each edit beside its target for exclusive publication.
|
|
351
360
|
const staging = `${file}.portulan-upgrade.${process.pid}.${stagingSeq++}`;
|
|
361
|
+
let published;
|
|
352
362
|
try {
|
|
353
|
-
//
|
|
354
|
-
write(staging, edit.next, { flag: "wx" });
|
|
363
|
+
// Exclusive creation refuses existing files and symlinks.
|
|
355
364
|
const keep = mode ?? edit.mode ?? null;
|
|
356
|
-
|
|
357
|
-
fs.renameSync(staging, file);
|
|
365
|
+
published=atomicWrite(file,edit.next,{exclusive:edit.exclusive??previous===null,before:previous,identity:edit.beforeIdentity??observedIdentity??undefined,mode:keep,write,stagingPath:edit.nested?undefined:staging,onPrepare:data=>options.journal?.(edit,"stage",data),onClaim:data=>options.journal?.(edit,"claim",data),onCleanupClaim:data=>options.journal?.(edit,"undo-claim",data),onPublish:(id,cleanup)=>snapshots.push({file:edit.file,root:edit.root,previous,mode,created,after:edit.next,identity:{dev:id.dev,ino:id.ino,mode:id.mode},cleanup})});
|
|
358
366
|
} catch (error) {
|
|
359
|
-
try {
|
|
360
|
-
fs.rmSync(staging, { force: true });
|
|
361
|
-
} catch {
|
|
362
|
-
/* the staging file is the lesser problem; the write failure is what gets reported */
|
|
363
|
-
}
|
|
364
367
|
unwindDirs(created);
|
|
365
368
|
return { ok: false, snapshots, reason: `${file} could not be written — ${error.code ?? error.message}` };
|
|
366
369
|
}
|
|
367
|
-
snapshots.push({ file: edit.file, root: edit.root, previous, mode, created });
|
|
368
370
|
}
|
|
369
371
|
return { ok: true, snapshots };
|
|
370
372
|
}
|
|
@@ -374,9 +376,12 @@ export function restore(dir, snapshots, options = {}) {
|
|
|
374
376
|
const treeRoot = typeof options.treeDir === "string" ? path.resolve(options.treeDir) : null;
|
|
375
377
|
const restored = [];
|
|
376
378
|
const failed = [];
|
|
377
|
-
|
|
379
|
+
const restoredIdentities=new Map();
|
|
380
|
+
// Reverse order empties shared directories before their owner removes them.
|
|
378
381
|
for (const snapshot of [...snapshots].reverse()) {
|
|
379
|
-
|
|
382
|
+
if(options.stopOnFailure&&failed.length){failed.push(snapshot.file);continue;}
|
|
383
|
+
try{options.beforeMutation?.(snapshot);}catch{failed.push(snapshot.file);continue;}
|
|
384
|
+
// Exported restore must recheck containment of caller-supplied snapshots.
|
|
380
385
|
const root = snapshot.root === "tree" ? treeRoot : workspaceRoot;
|
|
381
386
|
const file = root === null ? null : inside(root, snapshot.file);
|
|
382
387
|
if (file === null || linkOnPath(root, file) !== null) {
|
|
@@ -384,15 +389,27 @@ export function restore(dir, snapshots, options = {}) {
|
|
|
384
389
|
continue;
|
|
385
390
|
}
|
|
386
391
|
try {
|
|
392
|
+
for(const item of snapshot.cleanup??[])if(typeof item.file!=="string"||inside(root,path.relative(root,item.file))!==item.file||linkOnPath(root,item.file)!==null)throw new UpgradeError("cleanup journal is outside its transaction root or aliases another path");
|
|
393
|
+
const at=fs.lstatSync(file,{throwIfNoEntry:false});
|
|
394
|
+
const aliases=(snapshot.cleanup??[]).filter(item=>{try{const linked=fs.lstatSync(item.file);return linked.isFile()&&linked.dev===at?.dev&&linked.ino===at?.ino&&sameContent(readOutput(item.file,root),item.before);}catch{return false;}});
|
|
395
|
+
const allowLinks=Boolean(at&&at.nlink>1&&at.nlink===aliases.length+1);
|
|
396
|
+
if(at && (!at.isFile() || at.isSymbolicLink() || (at.nlink!==1&&!allowLinks))) throw new UpgradeError(`${file} is not an ordinary singly linked restore target`);
|
|
397
|
+
const current=at ? readOutput(file,root) : null;
|
|
398
|
+
if(Object.hasOwn(snapshot,"after")) {
|
|
399
|
+
const expected=restoredIdentities.has(file) ? restoredIdentities.get(file) : snapshot.identity;
|
|
400
|
+
if(!sameContent(current,snapshot.after) || (expected && (!at || at.dev!==expected.dev || at.ino!==expected.ino || (expected.mode!==undefined&&at.mode!==expected.mode)))) throw new UpgradeError(`${file} changed since this transaction wrote it`);
|
|
401
|
+
}
|
|
402
|
+
let replacedIdentity=null;
|
|
387
403
|
if (snapshot.previous === null) {
|
|
388
|
-
|
|
389
|
-
unwindDirs(snapshot.created ?? []);
|
|
404
|
+
if(at)guardedRemove(file,{before:current,identity:{dev:at.dev,ino:at.ino,mode:at.mode},allowLinks,onClaim:data=>options.journal?.(snapshot,"undo-claim",data),onRemove:claim=>options.journal?.(snapshot,"retired",claim)});
|
|
390
405
|
} else {
|
|
391
|
-
|
|
392
|
-
if (at?.isSymbolicLink()) throw new UpgradeError(`${file} is a symlink — refusing to restore through it`);
|
|
393
|
-
fs.writeFileSync(file, snapshot.previous);
|
|
394
|
-
if (snapshot.mode !== null && snapshot.mode !== undefined) fs.chmodSync(file, snapshot.mode);
|
|
406
|
+
replacedIdentity=atomicWrite(file,snapshot.previous,{exclusive:!at,before:current,identity:at ? {dev:at.dev,ino:at.ino,mode:at.mode} : undefined,allowLinks,mode:snapshot.mode,onPrepare:data=>options.journal?.(snapshot,"undo-stage",data),onClaim:data=>options.journal?.(snapshot,"undo-claim",data),onCleanupClaim:data=>options.journal?.(snapshot,"claim",data)});
|
|
395
407
|
}
|
|
408
|
+
for(const item of snapshot.cleanup??[])if(fs.lstatSync(item.file,{throwIfNoEntry:false}))guardedRemove(item.file,{before:item.before,identity:item.identity,allowLinks:true,claimBase:path.basename(file)});
|
|
409
|
+
if(snapshot.previous===null)unwindDirs(snapshot.created??[]);
|
|
410
|
+
const replaced=fs.lstatSync(file,{throwIfNoEntry:false});
|
|
411
|
+
if(replacedIdentity?(!replaced||replaced.dev!==replacedIdentity.dev||replaced.ino!==replacedIdentity.ino||replaced.mode!==replacedIdentity.mode):Boolean(replaced))throw new UpgradeError(`${file} changed during rollback publication`);
|
|
412
|
+
restoredIdentities.set(file,replacedIdentity);
|
|
396
413
|
restored.push(snapshot.file);
|
|
397
414
|
} catch {
|
|
398
415
|
failed.push(snapshot.file);
|
|
@@ -497,7 +514,7 @@ export async function run(argv = [], options = {}) {
|
|
|
497
514
|
return 2;
|
|
498
515
|
}
|
|
499
516
|
|
|
500
|
-
//
|
|
517
|
+
// Resolve target and residence.
|
|
501
518
|
const target = await resolveTarget(positional[0], { env });
|
|
502
519
|
if (target.state === "could-not-look") {
|
|
503
520
|
warn(`upgrade: ${target.sentence}`);
|
|
@@ -524,6 +541,7 @@ export async function run(argv = [], options = {}) {
|
|
|
524
541
|
return read.code;
|
|
525
542
|
}
|
|
526
543
|
const ws = read.ws;
|
|
544
|
+
try{assertNoSourceJournal(ws.dir);if(ws.repository)assertNoSourceJournal(ws.repository.dir);}catch(error){warn(`upgrade: ${error.message}`);return 2;}
|
|
527
545
|
const relative = path.relative(process.cwd(), ws.dir);
|
|
528
546
|
const shown = relative && relative.length < ws.dir.length ? relative : ws.dir;
|
|
529
547
|
|
|
@@ -533,7 +551,7 @@ export async function run(argv = [], options = {}) {
|
|
|
533
551
|
for (const line of offerLines()) say(`upgrade: ${line}`);
|
|
534
552
|
};
|
|
535
553
|
|
|
536
|
-
//
|
|
554
|
+
// Classify version drift.
|
|
537
555
|
const declared = /^([0-9]+)\.([0-9]+)$/.exec(ws.manifest?.portulan?.spec ?? "");
|
|
538
556
|
if (!declared) {
|
|
539
557
|
warn(`upgrade: ${shown} declares no readable \`portulan.spec\` — refusing to guess which contract it was written against`);
|
|
@@ -551,7 +569,7 @@ export async function run(argv = [], options = {}) {
|
|
|
551
569
|
return 2;
|
|
552
570
|
}
|
|
553
571
|
|
|
554
|
-
//
|
|
572
|
+
// Pre-state gate requires a gradeable workspace.
|
|
555
573
|
if (!behindByMajor) {
|
|
556
574
|
let pre;
|
|
557
575
|
try {
|
|
@@ -572,7 +590,7 @@ export async function run(argv = [], options = {}) {
|
|
|
572
590
|
}
|
|
573
591
|
}
|
|
574
592
|
|
|
575
|
-
//
|
|
593
|
+
// Plan.
|
|
576
594
|
const ctx = { bundle: BUNDLE, spec, tree, today: options.today ?? new Date().toISOString().slice(0, 10) };
|
|
577
595
|
const plan = await planFor(ws, ctx, steps);
|
|
578
596
|
|
|
@@ -622,10 +640,10 @@ export async function run(argv = [], options = {}) {
|
|
|
622
640
|
return 0;
|
|
623
641
|
}
|
|
624
642
|
|
|
625
|
-
//
|
|
643
|
+
// Apply and reread after each step.
|
|
626
644
|
let snapshots = [];
|
|
627
645
|
let current = ws;
|
|
628
|
-
//
|
|
646
|
+
// Steps can declare a tree, but never move it.
|
|
629
647
|
let treeDir = ws.repository?.dir ?? null;
|
|
630
648
|
const undo = () => {
|
|
631
649
|
const result = restore(current.dir, snapshots, { treeDir });
|
|
@@ -640,7 +658,7 @@ export async function run(argv = [], options = {}) {
|
|
|
640
658
|
const byHand = new Map();
|
|
641
659
|
const apply = async (entry) => {
|
|
642
660
|
applied.add(entry.step.id);
|
|
643
|
-
//
|
|
661
|
+
// Foreign plan failures must reach rollback.
|
|
644
662
|
let planned;
|
|
645
663
|
try {
|
|
646
664
|
planned = await entry.step.plan(current, ctx);
|
|
@@ -677,7 +695,7 @@ export async function run(argv = [], options = {}) {
|
|
|
677
695
|
treeDir = current.repository?.dir ?? treeDir;
|
|
678
696
|
return null;
|
|
679
697
|
};
|
|
680
|
-
//
|
|
698
|
+
// Revisit earlier steps until a pass applies nothing.
|
|
681
699
|
for (let pass = 0; ; pass++) {
|
|
682
700
|
let appliedNow = 0;
|
|
683
701
|
for (const entry of plan.entries) {
|
|
@@ -712,7 +730,7 @@ export async function run(argv = [], options = {}) {
|
|
|
712
730
|
}
|
|
713
731
|
}
|
|
714
732
|
|
|
715
|
-
//
|
|
733
|
+
// Grade with the real validator.
|
|
716
734
|
let post;
|
|
717
735
|
try {
|
|
718
736
|
post = await inspect(current.dir);
|