@sleepy_panda_srl/portulan 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +5 -0
- package/README.md +87 -0
- package/cli/README.md +337 -0
- package/cli/compile.mjs +2209 -0
- package/cli/control-chars.mjs +570 -0
- package/cli/discover.mjs +985 -0
- package/cli/doctor.mjs +2803 -0
- package/cli/feedback.mjs +970 -0
- package/cli/gate.mjs +271 -0
- package/cli/index.mjs +1390 -0
- package/cli/init.mjs +1729 -0
- package/cli/librarian.mjs +1384 -0
- package/cli/manifest.mjs +59 -0
- package/cli/new.mjs +803 -0
- package/cli/pack-version.mjs +543 -0
- package/cli/plugin-lint.mjs +1566 -0
- package/cli/portulan.mjs +266 -0
- package/cli/recipe-set.mjs +425 -0
- package/cli/rule-carriers.mjs +489 -0
- package/cli/skills-set.mjs +715 -0
- package/cli/stop-gate.mjs +434 -0
- package/cli/upgrade.mjs +897 -0
- package/cli/vendor.mjs +1504 -0
- package/core/README.md +46 -0
- package/core/engine.md +47 -0
- package/core/operating/autonomy.md +100 -0
- package/core/operating/evolution.md +84 -0
- package/core/operating/loop.md +83 -0
- package/core/operating/memory.md +161 -0
- package/core/operating/safety.md +42 -0
- package/core/operating/verification.md +76 -0
- package/core/personas/README.md +40 -0
- package/core/personas/implementer.md +51 -0
- package/core/personas/librarian.md +73 -0
- package/core/personas/reviewer.md +52 -0
- package/core/skills/README.md +52 -0
- package/core/skills/clarify/SKILL.md +42 -0
- package/core/skills/codify/SKILL.md +65 -0
- package/core/skills/consolidate/SKILL.md +121 -0
- package/core/templates/gate-policy.md +76 -0
- package/core/templates/handoff.md +29 -0
- package/core/templates/memory-entry.md +44 -0
- package/core/templates/pack.md +94 -0
- package/core/templates/persona.md +69 -0
- package/core/templates/proposal.md +27 -0
- package/core/templates/repo-card.md +28 -0
- package/core/templates/skill.md +57 -0
- package/core/templates/task.md +26 -0
- package/core/templates/workspace.md +81 -0
- package/package.json +41 -0
- package/packs/.claude-plugin/plugin.json +22 -0
- package/packs/README.md +61 -0
- package/packs/rituals/README.md +13 -0
- package/packs/rituals/checkpoints/README.md +105 -0
- package/packs/rituals/checkpoints/pack.json +36 -0
- package/packs/rituals/checkpoints/personas/supervisor.md +64 -0
- package/packs/rituals/checkpoints/skills/milestone-close/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/pre-commit/SKILL.md +75 -0
- package/packs/rituals/checkpoints/skills/session-open/SKILL.md +57 -0
- package/packs/stacks/README.md +8 -0
- package/packs/tools/README.md +17 -0
- package/packs/tools/github/README.md +65 -0
- package/packs/tools/github/pack.json +29 -0
- package/packs/tools/github/verify/README.md +51 -0
- package/packs/tools/github/verify/actions-pinned.sh +132 -0
- package/spec/README.md +399 -0
- package/spec/migrations/0001-repository-declares-its-tree.mjs +100 -0
- package/spec/migrations/0002-bundle-fallback-path.mjs +179 -0
- package/spec/migrations/README.md +95 -0
- package/spec/pack.schema.json +201 -0
- package/spec/slots.md +796 -0
- package/spec/workspace.schema.json +438 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `rule-carriers` — the rail that keeps a reduced rule reduced.
|
|
3
|
+
*
|
|
4
|
+
* Proposal `.portulan/proposals/0027-a-reduced-rule-stays-reduced.md`. A rule an incident has reduced
|
|
5
|
+
* to ONE carrier is registered with the spellings its other carriers used; those spellings may then
|
|
6
|
+
* appear only in the carrier, or beside a citation of it.
|
|
7
|
+
*
|
|
8
|
+
* ## What this is NOT
|
|
9
|
+
*
|
|
10
|
+
* It is not a solution to `0020`'s class, and describing it as one would be the capability claim
|
|
11
|
+
* `.portulan/memory/a-stated-enforcer-must-be-the-real-one.md` forbids. `0020` proved no rail can find
|
|
12
|
+
* *this patch's rule's other carriers*, because a rule has no token and the sibling set is exactly what
|
|
13
|
+
* nobody enumerated. That holds. **This operates only after an incident has enumerated a set** — at
|
|
14
|
+
* which point the rule does have a token, namely the spellings actually found — and its job is to stop
|
|
15
|
+
* the reduction being undone. Measured need: on 2026-08-10 one branch removed a hand-maintained count
|
|
16
|
+
* and roster while another re-armed them, and a handoff on `main` is titled
|
|
17
|
+
* *the-correction-merged-and-the-next-pull-request-put-it-back*.
|
|
18
|
+
*
|
|
19
|
+
* A rule nobody registered is covered by nothing, and nothing says so. That is stated in the proposal
|
|
20
|
+
* and repeated here because this file is where a reader checks whether the claim matches the code.
|
|
21
|
+
*
|
|
22
|
+
* ## Scope is a PREFIX, deliberately, and the record layer is out by construction
|
|
23
|
+
*
|
|
24
|
+
* `scope` entries are path prefixes, not globs. A glob language would be a second thing to get wrong,
|
|
25
|
+
* and prefixes are what the domain actually needs: doctrine lives in directories. `exclude` entries are
|
|
26
|
+
* prefixes too and win over `scope`.
|
|
27
|
+
*
|
|
28
|
+
* The record layer — handoffs, proposals, milestone files, the Session log, the changelog — is excluded
|
|
29
|
+
* because it legitimately quotes retired sentences forever: `0004` keeps its own minting words under a
|
|
30
|
+
* dated supersession note, and records here are forward-only. A rail over them would be red on arrival
|
|
31
|
+
* and permanently, which is what `a-superlative-is-a-count-nobody-ran.md` refused a grep over.
|
|
32
|
+
*
|
|
33
|
+
* ## Exit codes — the three-code discipline every recipe here holds
|
|
34
|
+
*
|
|
35
|
+
* 0 no registered spelling appears outside its carrier without a citation
|
|
36
|
+
* 1 at least one does
|
|
37
|
+
* 2 could not run — the registry is unusable, or its own audit fails
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import fs from "node:fs";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { pathToFileURL } from "node:url";
|
|
43
|
+
|
|
44
|
+
/** Thrown for every exit-2 condition. Carries the sentence the wrapper prints. */
|
|
45
|
+
export class RegistryError extends Error {}
|
|
46
|
+
|
|
47
|
+
const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate the registry BEFORE it is used, so an unusable rule is could-not-run rather than a rule
|
|
51
|
+
* that quietly covers nothing. The same direction `recipe-set.mjs` takes: the emitter validates
|
|
52
|
+
* before it emits, because "declared" is what a gate treats as "enforced".
|
|
53
|
+
*/
|
|
54
|
+
export function parseRegistry(source, { where = "registry" } = {}) {
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(source);
|
|
58
|
+
} catch (cause) {
|
|
59
|
+
throw new RegistryError(`${where} does not parse as JSON — ${cause.message}`);
|
|
60
|
+
}
|
|
61
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
62
|
+
throw new RegistryError(`${where} must be a JSON object`);
|
|
63
|
+
}
|
|
64
|
+
if (!Array.isArray(raw.rules)) {
|
|
65
|
+
throw new RegistryError(`${where} declares no \`rules\` array — could-not-run rather than a set covering nothing`);
|
|
66
|
+
}
|
|
67
|
+
if (raw.rules.length === 0) {
|
|
68
|
+
throw new RegistryError(`${where} declares zero rules — refusing to report green over an empty registry`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const seen = new Set();
|
|
72
|
+
const rules = raw.rules.map((rule, i) => {
|
|
73
|
+
const at = `${where} rule ${i}`;
|
|
74
|
+
if (!rule || typeof rule !== "object" || Array.isArray(rule)) throw new RegistryError(`${at} is not an object`);
|
|
75
|
+
const { id, carrier, summary, incident, tells, cites, scope, exclude } = rule;
|
|
76
|
+
|
|
77
|
+
if (typeof id !== "string" || !SLUG.test(id)) {
|
|
78
|
+
throw new RegistryError(`${at} has an \`id\` that is not a slug: ${JSON.stringify(id)}`);
|
|
79
|
+
}
|
|
80
|
+
if (seen.has(id)) throw new RegistryError(`${where} declares the rule \`${id}\` more than once`);
|
|
81
|
+
seen.add(id);
|
|
82
|
+
|
|
83
|
+
for (const [key, value] of [["carrier", carrier], ["summary", summary], ["incident", incident]]) {
|
|
84
|
+
if (typeof value !== "string" || value.trim() === "") {
|
|
85
|
+
throw new RegistryError(`${at} (\`${id}\`) has no usable \`${key}\``);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const [key, value] of [["tells", tells], ["cites", cites], ["scope", scope]]) {
|
|
89
|
+
if (!Array.isArray(value) || value.length === 0) {
|
|
90
|
+
throw new RegistryError(`${at} (\`${id}\`) has no usable \`${key}\` — a non-empty array is required`);
|
|
91
|
+
}
|
|
92
|
+
for (const entry of value) {
|
|
93
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
94
|
+
throw new RegistryError(`${at} (\`${id}\`) has an empty entry in \`${key}\``);
|
|
95
|
+
}
|
|
96
|
+
// Untrimmed is refused rather than silently trimmed, matching `cli/compile.mjs`, which
|
|
97
|
+
// does the same for a rule's action and a required context. It matters most for `scope`
|
|
98
|
+
// and `exclude`, which are compared with `startsWith`: a trailing space makes a prefix
|
|
99
|
+
// that can never match any path, so the entry silently covers nothing and the registry
|
|
100
|
+
// still reports green. Refusing is predictable; repairing quietly is not.
|
|
101
|
+
if (entry !== entry.trim()) {
|
|
102
|
+
throw new RegistryError(
|
|
103
|
+
`${at} (\`${id}\`) has an untrimmed entry in \`${key}\`: ${JSON.stringify(entry)} — ` +
|
|
104
|
+
"leading or trailing whitespace silently changes what it matches",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (exclude !== undefined) {
|
|
110
|
+
if (!Array.isArray(exclude) || exclude.some((e) => typeof e !== "string" || e.trim() === "")) {
|
|
111
|
+
throw new RegistryError(`${at} (\`${id}\`) has an unusable \`exclude\``);
|
|
112
|
+
}
|
|
113
|
+
// The rule-level `exclude` needs the untrimmed check too. It did not have one: the check was
|
|
114
|
+
// added to `tells`, `cites`, `scope` and the top-level `exclude` and missed this fourth site
|
|
115
|
+
// — one rule, four enforcement points, repaired at three. The suite caught it, which is the
|
|
116
|
+
// only reason it is not shipping as the defect this whole change is about.
|
|
117
|
+
for (const entry of exclude) {
|
|
118
|
+
if (entry !== entry.trim()) {
|
|
119
|
+
throw new RegistryError(
|
|
120
|
+
`${at} (\`${id}\`) has an untrimmed entry in \`exclude\`: ${JSON.stringify(entry)} — ` +
|
|
121
|
+
"leading or trailing whitespace silently changes what it matches",
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
id,
|
|
128
|
+
carrier,
|
|
129
|
+
summary,
|
|
130
|
+
incident,
|
|
131
|
+
tells: [...tells],
|
|
132
|
+
cites: [...cites],
|
|
133
|
+
scope: [...scope],
|
|
134
|
+
exclude: exclude ? [...exclude] : [],
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// The top-level `exclude` is validated exactly as a rule's is. It was not, and the gap was not
|
|
139
|
+
// cosmetic — but the reason is NOT a crash, and this comment said it was until it was corrected.
|
|
140
|
+
// `inDomain` calls `file.startsWith(p)`, and `String.prototype.startsWith` COERCES its argument:
|
|
141
|
+
// 1, null, true, {} and ["x"] all return false quietly, so nothing throws for anything JSON can
|
|
142
|
+
// carry. The real risk is worse than a stack trace because it is silent — `[]` and `""` coerce to
|
|
143
|
+
// the empty string, EVERY path starts with the empty string, so one such entry would exclude the
|
|
144
|
+
// whole tree and this rail would report green having examined nothing. A fail-open in an
|
|
145
|
+
// allow-list. The suite pins that coercion; this comment stated the opposite for one round, which
|
|
146
|
+
// is the class this file exists to catch, in the explanation of the fix for it.
|
|
147
|
+
if (raw.exclude !== undefined) {
|
|
148
|
+
if (!Array.isArray(raw.exclude)) {
|
|
149
|
+
throw new RegistryError(`${where} has an \`exclude\` that is not an array`);
|
|
150
|
+
}
|
|
151
|
+
for (const entry of raw.exclude) {
|
|
152
|
+
if (typeof entry !== "string" || entry.trim() === "") {
|
|
153
|
+
throw new RegistryError(`${where} has an unusable entry in \`exclude\`: ${JSON.stringify(entry)}`);
|
|
154
|
+
}
|
|
155
|
+
if (entry !== entry.trim()) {
|
|
156
|
+
throw new RegistryError(
|
|
157
|
+
`${where} has an untrimmed entry in \`exclude\`: ${JSON.stringify(entry)} — ` +
|
|
158
|
+
"leading or trailing whitespace silently changes what it matches",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { rules, exclude: Array.isArray(raw.exclude) ? [...raw.exclude] : [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A file is in a rule's domain when a scope prefix matches and no exclude prefix does. */
|
|
168
|
+
export function inDomain(file, rule, globalExclude = []) {
|
|
169
|
+
// `rule.exclude` is normalised to an array by `parseRegistry`, but this function is exported and a
|
|
170
|
+
// caller can hand it a rule object built by hand — which the suite does, and which threw. An
|
|
171
|
+
// exported predicate that only works on one caller's normalisation is a trap for the next reader.
|
|
172
|
+
const ruleExclude = Array.isArray(rule.exclude) ? rule.exclude : [];
|
|
173
|
+
const scope = Array.isArray(rule.scope) ? rule.scope : [];
|
|
174
|
+
const excluded = [...globalExclude, ...ruleExclude].some((p) => file === p || file.startsWith(p));
|
|
175
|
+
if (excluded) return false;
|
|
176
|
+
return scope.some((p) => file === p || file.startsWith(p));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Normalise before matching, and this is the part the first run taught rather than the design.
|
|
181
|
+
*
|
|
182
|
+
* Four of the first registry's seven tells matched NOTHING at the commit whose carriers they were
|
|
183
|
+
* copied from, and the dead-tell audit caught all four. Two causes, both already written down in this
|
|
184
|
+
* repository as traps and both walked into anyway:
|
|
185
|
+
*
|
|
186
|
+
* 1. **A markdown link's URL sits inside the phrase.** `.portulan/dod.md` reads
|
|
187
|
+
* "run each recipe [`workspace.json`](workspace.json) declares" — the literal text carries the
|
|
188
|
+
* whole link, so a tell spelled the way a human reads the sentence matches nothing. Links are
|
|
189
|
+
* collapsed to their label first, which is the repair `#211` recorded and this file now applies.
|
|
190
|
+
* 2. **Prose wraps.** `spec/slots.md` breaks "This repository's CI reads / `verify.recipes` from the
|
|
191
|
+
* manifest" across a newline, so any tell spanning that break fails on a raw `includes`.
|
|
192
|
+
*
|
|
193
|
+
* 3. **Emphasis and code spans sit INSIDE the phrase.** The record documenting the sweep wrote
|
|
194
|
+
* "every verify recipe the workspace **declares**" — the bold markers fall between the words, so
|
|
195
|
+
* the tell went dead the moment the live carrier was fixed and only the record still quoted it.
|
|
196
|
+
*
|
|
197
|
+
* Three variants of ONE trap — markup between the words of a sentence — each found by running this
|
|
198
|
+
* instrument rather than reading it, and the third found by forcing the audit red. So both haystack
|
|
199
|
+
* and needle are flattened: links to labels, emphasis and code markers dropped, every whitespace run
|
|
200
|
+
* to one space, lowercased. A tell is then written the way a human READS the sentence, which is the
|
|
201
|
+
* only way anyone will ever write one.
|
|
202
|
+
*
|
|
203
|
+
* Liveness is measured over the whole tracked set INCLUDING the record layer, deliberately. After a
|
|
204
|
+
* successful sweep a retired spelling survives only in the handoff and proposal that recorded it —
|
|
205
|
+
* that is the steady state, not a defect — so records are what keep a correct tell alive. A tell that
|
|
206
|
+
* matches nothing even there was never a real spelling.
|
|
207
|
+
*/
|
|
208
|
+
export function normalise(text) {
|
|
209
|
+
return text
|
|
210
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
211
|
+
.replace(/[*_`]/g, "")
|
|
212
|
+
.replace(/\s+/g, " ")
|
|
213
|
+
.toLowerCase();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const lower = normalise;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The scan. `read(file)` returns the file's text; a file that cannot be read is reported rather than
|
|
220
|
+
* skipped, because a check class that disappears quietly is worse than one that says it could not run.
|
|
221
|
+
*
|
|
222
|
+
* `resolve` turns a listed path and a registered carrier into the same kind of thing before they are
|
|
223
|
+
* compared, and it defaults to resolving against the process's own directory rather than to identity.
|
|
224
|
+
* A default of identity would be the string comparison this parameter exists to remove, spelled as a
|
|
225
|
+
* default — correct for the one caller that already resolves and silently wrong for every other.
|
|
226
|
+
*/
|
|
227
|
+
export function scan({ registry, files, read, resolve = (p) => path.resolve(p) }) {
|
|
228
|
+
const findings = [];
|
|
229
|
+
const unreadable = [];
|
|
230
|
+
// Nested maps, NOT a joined string key. The first version joined the rule id and the tell with a
|
|
231
|
+
// separator and split on it to report — which a tell CONTAINING that separator corrupts, and a JSON
|
|
232
|
+
// registry can carry one as a legal escape that `control-chars` never sees, because it is an escape
|
|
233
|
+
// in the file rather than a raw byte. The repair is not to validate the separator out; it is to stop
|
|
234
|
+
// having one. `evolution.md` ranks removing what would otherwise need enforcing above enforcing it.
|
|
235
|
+
const tellSeen = new Map(); // rule id -> Map(tell -> seen)
|
|
236
|
+
|
|
237
|
+
// Resolved once per rule rather than once per file × rule, and kept beside the rule id so the
|
|
238
|
+
// comparison below has an identity on both sides instead of two spellings.
|
|
239
|
+
const carrierId = new Map();
|
|
240
|
+
|
|
241
|
+
for (const rule of registry.rules) {
|
|
242
|
+
const seen = new Map();
|
|
243
|
+
for (const tell of rule.tells) seen.set(tell, false);
|
|
244
|
+
tellSeen.set(rule.id, seen);
|
|
245
|
+
carrierId.set(rule.id, resolve(rule.carrier));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
for (const file of files) {
|
|
249
|
+
let text;
|
|
250
|
+
try {
|
|
251
|
+
text = read(file);
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
unreadable.push({ file, message: cause.message });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const hay = lower(text);
|
|
257
|
+
const fileId = resolve(file);
|
|
258
|
+
|
|
259
|
+
for (const rule of registry.rules) {
|
|
260
|
+
// The carrier is where the rule LIVES; its own spellings are the point of it.
|
|
261
|
+
//
|
|
262
|
+
// Compared as RESOLVED paths, not as strings — the same repair `run()` already applies to
|
|
263
|
+
// the registry's own exclusion one screen below, and for the same reason. `git ls-files -z`
|
|
264
|
+
// emits `/` while a registry author writes whatever resolves on disk, so `./cli/x.mjs` names
|
|
265
|
+
// the carrier perfectly well, passes the carrier audit, and then fails to equal the string
|
|
266
|
+
// `cli/x.mjs`. The rule would flag its own carrier as a restatement of itself: the one file
|
|
267
|
+
// allowed to spell the rule, reported for spelling it.
|
|
268
|
+
const isCarrier = fileId === carrierId.get(rule.id);
|
|
269
|
+
|
|
270
|
+
const hits = rule.tells.filter((t) => hay.includes(lower(t)));
|
|
271
|
+
for (const t of hits) tellSeen.get(rule.id).set(t, true);
|
|
272
|
+
|
|
273
|
+
if (isCarrier || hits.length === 0) continue;
|
|
274
|
+
if (!inDomain(file, rule, registry.exclude)) continue;
|
|
275
|
+
|
|
276
|
+
const cited = rule.cites.some((c) => hay.includes(lower(c)));
|
|
277
|
+
if (cited) continue;
|
|
278
|
+
|
|
279
|
+
findings.push({ rule: rule.id, file, tells: hits, carrier: rule.carrier });
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const deadTells = [];
|
|
284
|
+
for (const [ruleId, tells] of tellSeen) {
|
|
285
|
+
for (const [tell, found] of tells) {
|
|
286
|
+
if (!found) deadTells.push({ rule: ruleId, tell });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return { findings, deadTells, unreadable };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* The carrier must exist **and be a file**. A rule pointing at a file that is gone is could-not-run,
|
|
295
|
+
* never green — and so is one pointing at a directory.
|
|
296
|
+
*
|
|
297
|
+
* `existsSync` was the first spelling and it answers `true` for a directory. A directory carrier then
|
|
298
|
+
* passed the audit, matched no file in the scan, and left the rule covering **nothing** while the
|
|
299
|
+
* recipe printed green: the quiet-coverage-loss shape the three audits exist to prevent, arriving
|
|
300
|
+
* through the audit itself.
|
|
301
|
+
*
|
|
302
|
+
* `state(carrier)` answers `file`, `absent`, `not-a-file`, or **`unreadable:<errno>`**, and they are
|
|
303
|
+
* kept apart rather than collapsed to a boolean because the caller prints them. Telling a maintainer a
|
|
304
|
+
* directory *"does not resolve"* sends them looking for a missing file that is sitting right there —
|
|
305
|
+
* the same defect `control-chars`'s exemption audit was corrected for, where *dead* and *never read*
|
|
306
|
+
* had to be split.
|
|
307
|
+
*
|
|
308
|
+
* **This function does not enumerate the states; it forwards whatever `state` returns.** Anything but
|
|
309
|
+
* `file` is unusable and is carried through with its own label, so a caller may add a state without
|
|
310
|
+
* touching this code — which is exactly how `unreadable:<errno>` arrived, and exactly how this
|
|
311
|
+
* docstring came to describe three states while the caller returned four. A doc that lists a set it
|
|
312
|
+
* does not enforce goes stale silently, so it now says both the set it knows and the rule it actually
|
|
313
|
+
* applies. Copilot, #249 round 1, suppressed and promoted.
|
|
314
|
+
*/
|
|
315
|
+
export function auditCarriers(registry, { state }) {
|
|
316
|
+
const unusable = [];
|
|
317
|
+
for (const rule of registry.rules) {
|
|
318
|
+
const found = state(rule.carrier);
|
|
319
|
+
if (found !== "file") unusable.push({ rule: rule.id, carrier: rule.carrier, state: found });
|
|
320
|
+
}
|
|
321
|
+
return unusable;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function readList(stdin) {
|
|
325
|
+
const parts = stdin.split("\0").filter((p) => p !== "");
|
|
326
|
+
return parts;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function run(argv = [], { stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), stdin } = {}) {
|
|
330
|
+
let registryPath = ".portulan/rule-carriers.json";
|
|
331
|
+
for (let i = 0; i < argv.length; i++) {
|
|
332
|
+
if (argv[i] === "--registry") {
|
|
333
|
+
registryPath = argv[++i];
|
|
334
|
+
if (registryPath === undefined) {
|
|
335
|
+
stderr.write("rule-carriers: --registry needs a path\n");
|
|
336
|
+
return 2;
|
|
337
|
+
}
|
|
338
|
+
} else {
|
|
339
|
+
stderr.write(`rule-carriers: unknown argument \`${argv[i]}\`\n`);
|
|
340
|
+
return 2;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let registry;
|
|
345
|
+
try {
|
|
346
|
+
const abs = path.resolve(cwd, registryPath);
|
|
347
|
+
registry = parseRegistry(fs.readFileSync(abs, "utf8"), { where: registryPath });
|
|
348
|
+
} catch (cause) {
|
|
349
|
+
stderr.write(`rule-carriers: ${cause instanceof RegistryError ? cause.message : `cannot read ${registryPath} — ${cause.message}`}\n`);
|
|
350
|
+
return 2;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// `statSync` rather than `lstatSync`: a symlink pointing at a real file IS a usable carrier, and
|
|
354
|
+
// the scan reads through it exactly the same way. What is refused is a target that is not a file
|
|
355
|
+
// once followed.
|
|
356
|
+
const unusable = auditCarriers(registry, {
|
|
357
|
+
state: (p) => {
|
|
358
|
+
let st;
|
|
359
|
+
try {
|
|
360
|
+
st = fs.statSync(path.resolve(cwd, p));
|
|
361
|
+
} catch (cause) {
|
|
362
|
+
// ONLY `ENOENT` IS ABSENT. A bare `catch` here turned every errno into "does not
|
|
363
|
+
// resolve" — so a carrier the process could not LOOK at (EACCES on a parent directory,
|
|
364
|
+
// ELOOP on a symlink cycle, ENAMETOOLONG) was reported as one it had looked at and not
|
|
365
|
+
// found. The exit code was right either way; the sentence was not, and an accurate
|
|
366
|
+
// error sentence is this change's whole subject.
|
|
367
|
+
//
|
|
368
|
+
// The rule already existed on a sibling noun and was not swept to this one:
|
|
369
|
+
// `./control-chars.mjs`'s `bytesOf` returns `null` for `ENOENT` alone and refuses every
|
|
370
|
+
// other errno, citing `../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md`
|
|
371
|
+
// — *could not look* reported as *looked and found nothing*. Same rule, same words, now
|
|
372
|
+
// on this noun too. Copilot, #249 round 1.
|
|
373
|
+
if (cause.code === "ENOENT") return "absent";
|
|
374
|
+
return `unreadable:${cause.code ?? cause.message}`;
|
|
375
|
+
}
|
|
376
|
+
return st.isFile() ? "file" : "not-a-file";
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
if (unusable.length > 0) {
|
|
380
|
+
for (const a of unusable) {
|
|
381
|
+
if (a.state === "absent") {
|
|
382
|
+
stderr.write(`rule-carriers: rule \`${a.rule}\` names a carrier that does not resolve: ${a.carrier}\n`);
|
|
383
|
+
} else if (a.state.startsWith("unreadable:")) {
|
|
384
|
+
stderr.write(
|
|
385
|
+
`rule-carriers: rule \`${a.rule}\` names a carrier this run could not examine: ${a.carrier} — ` +
|
|
386
|
+
`${a.state.slice("unreadable:".length)}. Refusing to call it missing: that is a fact about the ` +
|
|
387
|
+
"filesystem, not about the carrier, and the two want different repairs\n",
|
|
388
|
+
);
|
|
389
|
+
} else {
|
|
390
|
+
stderr.write(
|
|
391
|
+
`rule-carriers: rule \`${a.rule}\` names a carrier that is not a file: ${a.carrier} — ` +
|
|
392
|
+
"it resolves, so this is not a typo, and it matches no file in the scan, which would leave the rule covering nothing\n",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return 2;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const all = readList(stdin ?? "");
|
|
400
|
+
if (all.length === 0) {
|
|
401
|
+
stderr.write("rule-carriers: the file list on stdin is empty — refusing to report green over nothing\n");
|
|
402
|
+
return 2;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// THE REGISTRY IS NOT A CARRIER, and leaving it in the scanned set defeated the dead-tell audit
|
|
406
|
+
// outright: every tell is spelled in the registry, so every tell found ITSELF and read as alive.
|
|
407
|
+
// The audit reported green over a tell that matches nothing else in the tree — found by forcing
|
|
408
|
+
// it red, not by reading it, and it passed the first demonstration only because the registry was
|
|
409
|
+
// untracked in that scratch worktree.
|
|
410
|
+
// Compared as RESOLVED ABSOLUTE paths, not as strings. `git ls-files -z` always emits `/`, while
|
|
411
|
+
// `path.relative` emits the platform separator — so on Windows the two spellings never matched, the
|
|
412
|
+
// registry was scanned after all, and the self-satisfied dead-tell audit came straight back. A
|
|
413
|
+
// string comparison between a git path and a platform path is a defect wherever it appears.
|
|
414
|
+
const registryAbs = path.resolve(cwd, registryPath);
|
|
415
|
+
const files = all.filter((f) => path.resolve(cwd, f) !== registryAbs);
|
|
416
|
+
|
|
417
|
+
const { findings, deadTells, unreadable } = scan({
|
|
418
|
+
registry,
|
|
419
|
+
files,
|
|
420
|
+
read: (f) => fs.readFileSync(path.resolve(cwd, f), "utf8"),
|
|
421
|
+
resolve: (p) => path.resolve(cwd, p),
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
if (unreadable.length > 0) {
|
|
425
|
+
for (const u of unreadable) stderr.write(`rule-carriers: cannot read ${u.file} — ${u.message}\n`);
|
|
426
|
+
return 2;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
if (deadTells.length > 0) {
|
|
430
|
+
for (const d of deadTells) {
|
|
431
|
+
stderr.write(
|
|
432
|
+
`rule-carriers: rule \`${d.rule}\` registers a tell that matches nothing in the tree: ${JSON.stringify(d.tell)}\n` +
|
|
433
|
+
" Either the rule was rewritten and the registry was not, or the tell was wrong when it was written.\n",
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
return 2;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
if (findings.length > 0) {
|
|
440
|
+
for (const f of findings) {
|
|
441
|
+
stdout.write(
|
|
442
|
+
`${f.file}: carries rule \`${f.rule}\` (${f.tells.map((t) => JSON.stringify(t)).join(", ")}) ` +
|
|
443
|
+
`without citing ${f.carrier}\n`,
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
stdout.write(
|
|
447
|
+
`\n${findings.length} carrier(s) restate a registered rule instead of citing it. ` +
|
|
448
|
+
"Point the sentence at the carrier, or add the citation.\n",
|
|
449
|
+
);
|
|
450
|
+
return 1;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
stdout.write(`rule-carriers: ${registry.rules.length} registered rule(s), ${files.length} file(s) examined, no restatement\n`);
|
|
454
|
+
return 0;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The main-module guard, and it is `cli/portulan.mjs`'s `isMain()` rather than a third spelling of the
|
|
459
|
+
* same question — one carrier, and the others reach it, which is the rule this file exists to enforce.
|
|
460
|
+
*
|
|
461
|
+
* Two ways to get this wrong, and this file has now had both:
|
|
462
|
+
*
|
|
463
|
+
* 1. Comparing `process.argv[1]` against `new URL(import.meta.url).pathname`. This working copy lives
|
|
464
|
+
* under "Sleepy Panda SRL Projects", a URL pathname percent-encodes the spaces, the comparison failed,
|
|
465
|
+
* and the tool **exited 0 having run nothing**. Comparing URLs on both sides removes that entirely.
|
|
466
|
+
* 2. Comparing resolved paths when the script is reached through a **symlink** — an npm `bin`, most
|
|
467
|
+
* obviously. `path.resolve` does not follow links, so the same silent skip returns. Hence the
|
|
468
|
+
* `realpathSync` fallback, in a `try` because a missing path must answer *no* rather than throw.
|
|
469
|
+
*
|
|
470
|
+
* Both failures look identical from outside: a green that is the tool never starting.
|
|
471
|
+
*/
|
|
472
|
+
function isMain() {
|
|
473
|
+
const invoked = process.argv[1];
|
|
474
|
+
if (!invoked) return false;
|
|
475
|
+
if (import.meta.url === pathToFileURL(invoked).href) return true;
|
|
476
|
+
try {
|
|
477
|
+
return import.meta.url === pathToFileURL(fs.realpathSync(invoked)).href;
|
|
478
|
+
} catch {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (isMain()) {
|
|
484
|
+
const chunks = [];
|
|
485
|
+
process.stdin.on("data", (c) => chunks.push(c));
|
|
486
|
+
process.stdin.on("end", () => {
|
|
487
|
+
process.exitCode = run(process.argv.slice(2), { stdin: Buffer.concat(chunks).toString("utf8") });
|
|
488
|
+
});
|
|
489
|
+
}
|