@sleepy_panda_srl/portulan 0.1.3 → 0.3.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 +7 -6
- package/cli/README.md +145 -358
- package/cli/advisory.mjs +354 -0
- package/cli/comments.mjs +932 -0
- package/cli/compile.mjs +1719 -1699
- package/cli/context.mjs +883 -0
- package/cli/control-chars.mjs +12 -301
- package/cli/discover.mjs +17 -574
- package/cli/doctor.mjs +269 -1080
- package/cli/drills.mjs +92 -504
- 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 +882 -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 +6 -125
- package/cli/recipe-set.mjs +15 -245
- package/cli/release-eval.mjs +28 -348
- package/cli/review-meter.mjs +29 -265
- 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 +82 -505
- 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 +258 -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/README.md +33 -0
- package/core/personas/librarian.md +2 -2
- package/core/skills/clarify/SKILL.md +1 -1
- package/core/skills/consolidate/SKILL.md +5 -3
- package/core/templates/handoff.md +28 -15
- package/core/templates/memory-entry.md +1 -0
- package/evals/README.md +271 -845
- package/evals/releases/0.2.0.json +146 -0
- package/evals/releases/0.2.0.md +67 -0
- package/evals/releases/0.3.0.json +150 -0
- package/evals/releases/0.3.0.md +68 -0
- package/evals/releases/README.md +3 -3
- package/package.json +5 -2
- package/packs/.claude-plugin/plugin.json +2 -1
- package/packs/README.md +7 -4
- package/packs/rituals/checkpoints/README.md +1 -1
- package/packs/tools/README.md +8 -8
- package/packs/tools/github/verify/actions-pinned.sh +10 -55
- package/packs/tools/local-llm/README.md +136 -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 +1665 -0
- package/packs/tools/local-llm/skills/delegate-local/SKILL.md +64 -0
- package/packs/tools/local-llm/verify/README.md +18 -0
- package/spec/README.md +113 -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 +343 -27
- package/spec/workspace.schema.json +145 -4
package/cli/doctor.mjs
CHANGED
|
@@ -1,48 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// `doctor` — the Workspace Definition validator.
|
|
3
3
|
//
|
|
4
|
-
// node cli/doctor.mjs <
|
|
4
|
+
// node cli/doctor.mjs [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir>...
|
|
5
5
|
//
|
|
6
6
|
// Exit 0 every workspace validates · 1 at least one does not · 2 could not run.
|
|
7
|
-
//
|
|
8
|
-
// The third code is the one that carries weight here. A verdict ABOUT a workspace — a missing
|
|
9
|
-
// manifest, a slot pointing nowhere, a rule with no provenance — is exit 1: the tool ran and
|
|
10
|
-
// judged. Exit 2 means the tool could not judge at all: no arguments, an unreadable schema, an
|
|
11
|
-
// unanticipated throw. Borrowing 1 for the last of those would claim a judgement nobody made,
|
|
12
|
-
// which is the laundering already fixed once in ../.portulan/tools/gh-bot-token.mjs.
|
|
13
|
-
//
|
|
14
|
-
// Zero dependencies and no install step, deliberately — ../.portulan/identity.md places doctor at
|
|
15
|
-
// milestone 2 as "zero-dependency JavaScript on Node, run from the repository", absorbed by the
|
|
16
|
-
// TypeScript CLI at milestone 7. Nothing here touches the network: a check that fails for reasons
|
|
17
|
-
// unrelated to the change under test is worse than no check (../.portulan/verify/README.md).
|
|
18
|
-
//
|
|
19
|
-
// What it does NOT do is written next to what it does, in ../spec/slots.md and in ../cli/README.md.
|
|
20
|
-
// The short list: it never runs a verify recipe, never dereferences a link, and never judges whether a
|
|
21
|
-
// sealed stamp is true.
|
|
22
|
-
//
|
|
23
|
-
// **It DOES score agent legibility, since milestone 7 session 7** — this line read "and never scores
|
|
24
|
-
// agent-legibility" until then, which was the honest state from milestone 2 until row 7's 2026-07-28
|
|
25
|
-
// amendment was built. The score reads the `affordances` slot and six manifest keys beside it, prints
|
|
26
|
-
// one line, and **moves no exit code**: a measurement is not a verdict, and one that could fail a
|
|
27
|
-
// workspace would make this tool's judgement a function of how much prose somebody wrote. See section
|
|
28
|
-
// 3b for the dimensions and for why each of them can vary.
|
|
29
|
-
//
|
|
30
|
-
// **One name IS dereferenced, as of milestone 7: a pointer's `governed_by`.** It is resolved against
|
|
31
|
-
// the host's installed-plugin record by ./discover.mjs — on disk, never over the network, and the
|
|
32
|
-
// line above stays literally true because that is not a link. The result is REPORTED and never
|
|
33
|
-
// graded: a pointer whose governor is not installed is a correct pointer, and this tool says where
|
|
34
|
-
// the workspace is rather than passing judgement on it.
|
|
35
7
|
|
|
36
8
|
import fs from "node:fs";
|
|
9
|
+
import { execFileSync } from "node:child_process";
|
|
37
10
|
import path from "node:path";
|
|
38
11
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
39
12
|
|
|
40
|
-
// The enforcement compiler, imported rather than reimplemented. `doctor` reports what each backend
|
|
41
|
-
// covers, and the only way that report can be true is for it to ask the backends themselves — a
|
|
42
|
-
// second copy of the accounting would drift, and a *coverage claim* that drifts does not look wrong,
|
|
43
|
-
// it looks like enforcement that quietly stopped covering something. Same reasoning that has
|
|
44
|
-
// ./gate.mjs import the matcher instead of writing a second one. Zero
|
|
45
|
-
// dependencies on both sides, so nothing is added to what this tool needs to run.
|
|
46
13
|
import {
|
|
47
14
|
parse,
|
|
48
15
|
backends,
|
|
@@ -52,45 +19,22 @@ import {
|
|
|
52
19
|
composeFragments,
|
|
53
20
|
packDifferences,
|
|
54
21
|
shadowedCopy,
|
|
22
|
+
personaDeclarations,
|
|
55
23
|
} from "./compile.mjs";
|
|
56
|
-
// The containment test the memory-index siting rule turns on, imported for the reason directly
|
|
57
|
-
// above: the copy that used to live here drifted into the identical fail-open as the original.
|
|
58
24
|
import { isInside, recordType } from "./index.mjs";
|
|
59
|
-
// One frontmatter parser for this repository, not two. `plugin-lint` minted it and has the tests that
|
|
60
|
-
// pin its edges — an unterminated block, a block that is not first, a value with a colon in it — and a
|
|
61
|
-
// second implementation here would be a second carrier of one contract — the class that proposal
|
|
62
|
-
// `0020` names (../.portulan/proposals/0020-a-fix-is-not-done-at-the-site-it-was-found.md), and what
|
|
63
|
-
// ../core/operating/evolution.md prescribes leaving one site for.
|
|
64
|
-
// `AGENT_DIR` for the same reason, and it is the stronger case of the two: it is not a convention this
|
|
65
|
-
// tool is free to choose but a MEASUREMENT of where one host looks — with a positive control, recorded
|
|
66
|
-
// at its declaration. A copy here would be this tool's opinion about somebody else's loader.
|
|
67
25
|
import { parseFrontmatter, AGENT_DIR } from "./plugin-lint.mjs";
|
|
68
|
-
// Host plugin-cache discovery, which is where a pointer's `governed_by` stops being a name and
|
|
69
|
-
// becomes a directory. Imported rather than reimplemented for the reason above, and kept in its own
|
|
70
|
-
// file for a second one: the boot skill's whole instruction is to report what THIS resolver said, so
|
|
71
|
-
// there must be exactly one thing that says it.
|
|
72
26
|
import { resolveGovernor, AUTO, discoverPackRoots, namedWithAuto } from "./discover.mjs";
|
|
73
27
|
import { composedId } from "./recipe-set.mjs";
|
|
28
|
+
import { alwaysLine } from "./context.mjs";
|
|
29
|
+
import { formLine } from "./form.mjs";
|
|
30
|
+
import { sessionsLine } from "./sessions.mjs";
|
|
31
|
+
import { hostPaths, ledger, print, readSpend, worktrees, linesChanged } from "./ledger.mjs";
|
|
74
32
|
|
|
75
33
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
76
34
|
const DEFAULT_SCHEMA = path.resolve(HERE, "..", "spec", "workspace.schema.json");
|
|
77
|
-
// The Pack Definition is a SEPARATE contract on its own version train — see spec/README.md. A pack
|
|
78
|
-
// and a workspace are different artifacts, and one number governing both would make a bump in
|
|
79
|
-
// either mean a change in the other.
|
|
80
35
|
const DEFAULT_PACK_SCHEMA = path.resolve(HERE, "..", "spec", "pack.schema.json");
|
|
81
36
|
|
|
82
|
-
/**
|
|
83
|
-
* The tail of `doctor`'s binding-success line, exported so the suite keys on ONE carrier.
|
|
84
|
-
*
|
|
85
|
-
* Two tests guard this sentence: one asserts it IS printed for an ordinary binding, the other that it is
|
|
86
|
-
* NOT printed for a name that traverses out of the tree — a path-traversal case where a green would mean
|
|
87
|
-
* `doctor` had opened a file the pack chose. Held as two literals, a reword satisfies the negative
|
|
88
|
-
* assertion **for the wrong reason**: the string is gone, nothing prints it, the test passes, and it
|
|
89
|
-
* would go on passing with the traversal guard deleted. Measured at milestone 7's close, where exactly
|
|
90
|
-
* that happened. Kept here beside the other module constants rather than beside its use, because the
|
|
91
|
-
* first attempt put it between `validatePersona`'s docblock and `validatePersona` — a doc comment
|
|
92
|
-
* attaches to the next symbol, so that silently reassigned it. (Copilot, round 1 on #240.)
|
|
93
|
-
*/
|
|
37
|
+
/** The binding-success line's tail, one constant so a test asserting its absence cannot pass on a reword. */
|
|
94
38
|
export const BINDING_OK = "names match and a tool grant is declared";
|
|
95
39
|
|
|
96
40
|
/** Raised when `doctor` cannot run at all. Always exit 2, never 1. */
|
|
@@ -101,26 +45,8 @@ export class DoctorError extends Error {
|
|
|
101
45
|
}
|
|
102
46
|
}
|
|
103
47
|
|
|
104
|
-
//
|
|
105
|
-
|
|
106
|
-
// ===========================================================================================
|
|
107
|
-
//
|
|
108
|
-
// spec/README.md names the exact JSON Schema subset this validator implements, on the reasoning
|
|
109
|
-
// that a validator carried rather than depended on must be small enough to implement completely
|
|
110
|
-
// and honestly. The list below IS that sentence, in a form that fails.
|
|
111
|
-
//
|
|
112
|
-
// Everything outside it is refused rather than ignored — which is the point. A validator that
|
|
113
|
-
// skips the keywords it does not know reports conformance it never checked: the author wrote a
|
|
114
|
-
// constraint, the machine agreed, and nothing enforced it. That is the same fail-open shape this
|
|
115
|
-
// repository has now minted three rules about (../.portulan/memory/verify-preconditions-fail-closed.md),
|
|
116
|
-
// and a validator is exactly where a fourth would hide.
|
|
117
|
-
|
|
118
|
-
// Each supported keyword, with what a well-formed VALUE for it looks like. Knowing the name is not
|
|
119
|
-
// enough: `pattern: "["` and `enum: "repository"` are both inside the subset by name and neither can
|
|
120
|
-
// be honoured, and without this table they surface at instance-validation time as a raw SyntaxError
|
|
121
|
-
// or TypeError — an "unanticipated failure" (exit 2) naming no keyword and no location, from a defect
|
|
122
|
-
// that is squarely in the schema. Refusing at compile time is the same rule one level finer: the unit
|
|
123
|
-
// this validator must refuse is a constraint it cannot apply, not merely a word it does not know.
|
|
48
|
+
// ---------------------------------------------------------------- the schema subset
|
|
49
|
+
|
|
124
50
|
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
125
51
|
const isCount = (v) => typeof v === "number" && Number.isInteger(v) && v >= 0;
|
|
126
52
|
const regexCompiles = (v) => {
|
|
@@ -135,8 +61,7 @@ const SUPPORTED = {
|
|
|
135
61
|
$ref: (v) => typeof v === "string",
|
|
136
62
|
title: (v) => typeof v === "string",
|
|
137
63
|
description: (v) => typeof v === "string",
|
|
138
|
-
//
|
|
139
|
-
// accepted here and then match nothing, failing every instance — loud, but for the wrong reason.
|
|
64
|
+
// No "integer": `check` cannot test it, so it would fail every instance.
|
|
140
65
|
type: (v) => ["object", "array", "string", "number", "boolean", "null"].includes(v),
|
|
141
66
|
properties: isObject,
|
|
142
67
|
required: (v) => Array.isArray(v) && v.every((n) => typeof n === "string"),
|
|
@@ -163,15 +88,9 @@ const SHAPE = {
|
|
|
163
88
|
"keys through a check this validator does not implement",
|
|
164
89
|
};
|
|
165
90
|
|
|
166
|
-
// Siblings a `$ref` may carry. Annotations only: they describe, they never constrain, so they
|
|
167
|
-
// cannot silently narrow what the ref resolves to. Used where a field reuses a shared definition
|
|
168
|
-
// and still deserves its own prose — `name` and `verify.default` both do with `$defs/slug`.
|
|
169
91
|
const REF_SIBLINGS = new Set(["$ref", "title", "description"]);
|
|
170
92
|
|
|
171
|
-
/**
|
|
172
|
-
* Walk a schema and refuse anything outside the subset. Returns the schema unchanged; the value
|
|
173
|
-
* is the walk, not the result.
|
|
174
|
-
*/
|
|
93
|
+
/** Throws a DoctorError for any keyword or value outside the subset; returns the schema unchanged. */
|
|
175
94
|
export function compileSchema(schema, where = "#") {
|
|
176
95
|
if (schema === null || typeof schema !== "object" || Array.isArray(schema)) {
|
|
177
96
|
throw new DoctorError(`schema at ${where} is not an object`);
|
|
@@ -230,10 +149,7 @@ export function compileSchema(schema, where = "#") {
|
|
|
230
149
|
const typeOf = (v) =>
|
|
231
150
|
v === null ? "null" : Array.isArray(v) ? "array" : typeof v === "number" ? "number" : typeof v;
|
|
232
151
|
|
|
233
|
-
/**
|
|
234
|
-
* Validate an instance against a schema written in the subset.
|
|
235
|
-
* Returns `[{ pointer, message }]` — empty when the instance conforms.
|
|
236
|
-
*/
|
|
152
|
+
/** The instance's faults as `[{ pointer, message }]`, empty where it conforms. */
|
|
237
153
|
export function validate(schema, instance) {
|
|
238
154
|
compileSchema(schema);
|
|
239
155
|
const errors = [];
|
|
@@ -261,7 +177,7 @@ function check(schema, value, pointer, root, errors) {
|
|
|
261
177
|
const ok = schema.type === "number" ? actual === "number" : actual === schema.type;
|
|
262
178
|
if (!ok) {
|
|
263
179
|
add(`expected type \`${schema.type}\`, found \`${actual}\``);
|
|
264
|
-
return;
|
|
180
|
+
return;
|
|
265
181
|
}
|
|
266
182
|
}
|
|
267
183
|
|
|
@@ -300,10 +216,7 @@ function check(schema, value, pointer, root, errors) {
|
|
|
300
216
|
if (!(name in value)) add(`required property \`${name}\` is missing`);
|
|
301
217
|
}
|
|
302
218
|
if (schema.additionalProperties === false) {
|
|
303
|
-
// `properties`
|
|
304
|
-
// 2020-12 semantics. Treating a missing `properties` as "nothing to check" would make a
|
|
305
|
-
// supported spelling silently mean the opposite of what it says, which is the failure
|
|
306
|
-
// ../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md is about.
|
|
219
|
+
// With no `properties` this forbids every key, as JSON Schema 2020-12 does.
|
|
307
220
|
const declared = schema.properties ?? {};
|
|
308
221
|
for (const name of Object.keys(value)) {
|
|
309
222
|
if (!(name in declared)) {
|
|
@@ -338,46 +251,22 @@ function check(schema, value, pointer, root, errors) {
|
|
|
338
251
|
}
|
|
339
252
|
}
|
|
340
253
|
|
|
341
|
-
//
|
|
342
|
-
// 2. Provenance, parsed out of a Markdown record
|
|
343
|
-
// ===========================================================================================
|
|
344
|
-
//
|
|
345
|
-
// The Workspace Definition puts provenance on a rule rather than in the manifest: a rule lives in
|
|
346
|
-
// Markdown, so a manifest key would describe a workspace's POLICY about provenance while leaving
|
|
347
|
-
// every actual rule unchecked (spec/slots.md). So the schema defines the shape and the records
|
|
348
|
-
// carry instances — one definition, two carriers — and this is the parser between them.
|
|
349
|
-
//
|
|
350
|
-
// The accepted syntax is deliberately narrow: backticked `key=value` tokens on the `**provenance:**`
|
|
351
|
-
// paragraph, keys drawn from the five the schema names. Narrow because the alternative is false
|
|
352
|
-
// reds — the live records carry annotation prose after the stamp ("— the milestone-2 pull request,
|
|
353
|
-
// where…"), and a parser that treated every backtick in that prose as a field would fail records
|
|
354
|
-
// that are perfectly correct.
|
|
254
|
+
// ---------------------------------------------------------------- provenance
|
|
355
255
|
|
|
356
256
|
const PROVENANCE_KEYS = ["form", "href", "owner", "date", "shape"];
|
|
357
257
|
const PROVENANCE_TOKEN = new RegExp("`(" + PROVENANCE_KEYS.join("|") + ")=([^`]*)`", "g");
|
|
358
258
|
|
|
359
|
-
/**
|
|
360
|
-
* Pull the provenance stamp out of a record.
|
|
361
|
-
* Returns `{ present, fields, raw }`; `fields` is null when the record carries prose provenance
|
|
362
|
-
* — which is a finding for a rule and a note for anything else, not a parse failure.
|
|
363
|
-
*/
|
|
259
|
+
/** A record's provenance stamp as `{ present, fields, raw }`, `fields` null where the provenance is prose. */
|
|
364
260
|
export function parseProvenance(source) {
|
|
365
261
|
const lines = source.split("\n");
|
|
366
262
|
const start = lines.findIndex((l) => /^\s*\*\*provenance:\*\*/i.test(l));
|
|
367
263
|
if (start === -1) return { present: false, fields: null, raw: "" };
|
|
368
264
|
|
|
369
|
-
// The stamp may wrap, so the unit is the paragraph, not the line.
|
|
370
265
|
let end = start;
|
|
371
266
|
while (end + 1 < lines.length && lines[end + 1].trim() !== "") end += 1;
|
|
372
267
|
const raw = lines.slice(start, end + 1).join(" ");
|
|
373
268
|
|
|
374
|
-
// First occurrence wins
|
|
375
|
-
// later `form=…` inside that prose is somebody writing *about* provenance, not declaring it —
|
|
376
|
-
// and last-wins would let a sentence mentioning the other form turn a correct record red. A
|
|
377
|
-
// false red is the failure that gets a whole check switched off, so the tie breaks toward the
|
|
378
|
-
// stamp. The residual limit is stated in ../core/templates/memory-entry.md rather than left to
|
|
379
|
-
// be discovered: a backticked `key=value` using one of these five keys is reserved syntax
|
|
380
|
-
// wherever it appears in the paragraph.
|
|
269
|
+
// First occurrence wins: prose after the stamp may mention another `key=value`.
|
|
381
270
|
const fields = {};
|
|
382
271
|
for (const [, key, value] of raw.matchAll(PROVENANCE_TOKEN)) {
|
|
383
272
|
if (!(key in fields)) fields[key] = value.trim();
|
|
@@ -385,46 +274,10 @@ export function parseProvenance(source) {
|
|
|
385
274
|
return { present: true, fields: Object.keys(fields).length ? fields : null, raw };
|
|
386
275
|
}
|
|
387
276
|
|
|
388
|
-
|
|
389
|
-
// tools ask this of the same records and all three carried their own regex, which is half of what #74
|
|
390
|
-
// is about; the direction of the import is set by ./index.mjs already being this module's dependency
|
|
391
|
-
// for `isInside`, so putting the one carrier there keeps the graph acyclic.
|
|
392
|
-
|
|
393
|
-
// The retirement condition is a template field, not a schema one (core/templates/memory-entry.md):
|
|
394
|
-
// the line a demotion pass retires by. Anchored to the bolded field at line start, so prose that
|
|
395
|
-
// merely discusses retiring never matches — the same false-red caution parseProvenance takes with
|
|
396
|
-
// backticked tokens, for the same reason: a false red is what gets a whole check switched off.
|
|
397
|
-
// **Exported for `./skill-goldens.mjs`**, which binds `core/skills/consolidate/SKILL.md` step 1 —
|
|
398
|
-
// *"Every record carries a `Retire when:` line"* — to the live store. Exported rather than re-spelled:
|
|
399
|
-
// a second regex there would be a second carrier of this field's spelling, and the anchoring caution
|
|
400
|
-
// above is the part a re-speller gets wrong first.
|
|
277
|
+
/** A record's `**Retire when:**` line, anchored at a line's start so prose about retiring never matches. */
|
|
401
278
|
export const RETIRE_WHEN = /^\s*\*\*retire when:\*\*/im;
|
|
402
279
|
|
|
403
|
-
//
|
|
404
|
-
// 3. Claims against the tree
|
|
405
|
-
// ===========================================================================================
|
|
406
|
-
//
|
|
407
|
-
// The milestone-2 criterion asks for this "the way the `map` check already holds the root README
|
|
408
|
-
// to the repo's shape" — so a claim that is verifiable and wrong FAILS, exactly as `map` does.
|
|
409
|
-
//
|
|
410
|
-
// Three things make that safe to do. First, the lint reads only what parses confidently as a path:
|
|
411
|
-
// a code span or link target containing `/`, never absolute. `build: none — no build step yet`
|
|
412
|
-
// claims nothing and is treated as claiming nothing. Prose outside the two parsed sections is
|
|
413
|
-
// **left alone** — not failed and not reported — because reporting every ordinary sentence in a
|
|
414
|
-
// repo card would bury the findings that matter.
|
|
415
|
-
//
|
|
416
|
-
// Second, FAIL is reserved for a claim that is unambiguous. A build/test/run candidate that is a
|
|
417
|
-
// single path-shaped token is a claim a file exists; a candidate that is a command merely contains
|
|
418
|
-
// tokens that might be paths, output paths, flag values or globs, and those are reported rather
|
|
419
|
-
// than failed. A false red is the outcome that gets a whole recipe switched off
|
|
420
|
-
// (../.portulan/verify/README.md), and an ambitious parser is the shortest route to one — which
|
|
421
|
-
// this check learned by producing them.
|
|
422
|
-
//
|
|
423
|
-
// Second, the tree is DECLARED rather than guessed. A workspace that makes claims about the
|
|
424
|
-
// repository containing it says so with the `tree` slot; one that describes repositories not
|
|
425
|
-
// present beside it — a demo, or a portfolio spanning many — omits it, and its claims are reported
|
|
426
|
-
// **unverifiable**. Never silently skipped: a check class that disappears without saying so is the
|
|
427
|
-
// fail-open this repository keeps re-finding.
|
|
280
|
+
// ---------------------------------------------------------------- claims against the tree
|
|
428
281
|
|
|
429
282
|
const CODE_SPAN = /`([^`]+)`/g;
|
|
430
283
|
const LINK_TARGET = /\]\(([^)]+)\)/g;
|
|
@@ -442,9 +295,7 @@ function repoCardClaims(source) {
|
|
|
442
295
|
return lines.slice(start, end + 1);
|
|
443
296
|
};
|
|
444
297
|
|
|
445
|
-
//
|
|
446
|
-
// `/usr/bin/env` inside a command would otherwise be checked, found, and counted as a passing
|
|
447
|
-
// claim about a repository it has nothing to do with.
|
|
298
|
+
// Not absolute: `/usr/bin/env` resolves on the host, not in the tree.
|
|
448
299
|
const isPathish = (token) =>
|
|
449
300
|
token.includes("/") &&
|
|
450
301
|
!token.startsWith("/") &&
|
|
@@ -460,28 +311,9 @@ function repoCardClaims(source) {
|
|
|
460
311
|
const spans = [...rest.matchAll(CODE_SPAN)].map((m) => m[1].trim());
|
|
461
312
|
const candidate = spans[0] ?? rest.trim().split(/\s+/)[0];
|
|
462
313
|
if (!candidate) continue;
|
|
463
|
-
if (/^none\b/i.test(candidate)) continue;
|
|
464
|
-
|
|
465
|
-
//
|
|
466
|
-
// split is the whole design of this branch.
|
|
467
|
-
//
|
|
468
|
-
// A candidate that is a single path-shaped token — `./verify.sh` — is unambiguously a
|
|
469
|
-
// claim that a file exists, and absent it is a **failure**. That is the shape customer
|
|
470
|
-
// zero's card uses and the shape the milestone-2 close demonstrated red.
|
|
471
|
-
//
|
|
472
|
-
// A candidate that is a command — `dotnet run --project src/App` — contains tokens that
|
|
473
|
-
// may be an input path, an output path not built yet, a flag value, a `sed` expression, a
|
|
474
|
-
// glob, an npm script name, or an SSH remote. Nothing here can tell those apart, so every
|
|
475
|
-
// extracted token is **reported, never failed**. An earlier version failed them and bought
|
|
476
|
-
// false reds on `go test ./...`, `cc -o bin/app src/main.c`, and `--project=src/App` with
|
|
477
|
-
// the directory present — on exactly the input `../core/templates/repo-card.md` tells
|
|
478
|
-
// adopters to write. Tightening the pattern cannot fix it: `bin/app` and `test/unit` are
|
|
479
|
-
// clean-looking paths. A false red gets a whole recipe switched off; a missed check does
|
|
480
|
-
// not, and this repository has ranked those two twice.
|
|
481
|
-
//
|
|
482
|
-
// What both branches guarantee is that nothing is **silent**. Before this, a
|
|
483
|
-
// command-shaped line was tested whole, rejected for containing a space, and dropped:
|
|
484
|
-
// not checked, not counted, not reported.
|
|
314
|
+
if (/^none\b/i.test(candidate)) continue;
|
|
315
|
+
|
|
316
|
+
// A lone path is a claim; a command's tokens may be outputs, flags or globs, so they are only reported.
|
|
485
317
|
const tokens = candidate.split(/\s+/);
|
|
486
318
|
const targets = tokens.filter(isPathish);
|
|
487
319
|
if (tokens.length === 1 && targets.length === 1) {
|
|
@@ -515,11 +347,6 @@ function repoCardClaims(source) {
|
|
|
515
347
|
return claims;
|
|
516
348
|
}
|
|
517
349
|
|
|
518
|
-
/**
|
|
519
|
-
* The status-check contexts a gate map claims `main` requires — every one named in the row, not just
|
|
520
|
-
* the first. A repository may require several, and reading only the first silently exempts the rest:
|
|
521
|
-
* the third real workspace requires two, and the one that was ignored was the one that was wrong.
|
|
522
|
-
*/
|
|
523
350
|
function requiredCheckClaims(source) {
|
|
524
351
|
for (const line of source.split("\n")) {
|
|
525
352
|
if (!/^\s*\|/.test(line)) continue;
|
|
@@ -532,18 +359,8 @@ function requiredCheckClaims(source) {
|
|
|
532
359
|
}
|
|
533
360
|
|
|
534
361
|
/**
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
* The context is the job's `name:` when it sets one, and the job id otherwise — that is what branch
|
|
538
|
-
* protection pins, and the distinction is invisible until a job has both. It surfaced on the third
|
|
539
|
-
* real workspace: a job id `example-job` carrying `name: Example Job`, where the ruleset requires
|
|
540
|
-
* `Example Job` and this function previously returned only `example-job`, so a gate map claiming the
|
|
541
|
-
* id passed a check that should have failed. Customer zero could never have shown it, because its
|
|
542
|
-
* workflow deliberately sets no `name:` precisely so the two coincide.
|
|
543
|
-
*
|
|
544
|
-
* A regex rather than a YAML parse, because a parser is a dependency and this reads one shape from one
|
|
545
|
-
* well-known file. Stated as a limit rather than left to be discovered: unusual-but-valid YAML — flow
|
|
546
|
-
* mappings, quoted or multi-line keys, `name` given as an expression — is not recognised.
|
|
362
|
+
* Each workflow job as `{ id, context }`, the context being its `name:` where set, as branch protection pins it.
|
|
363
|
+
* A regex, not a YAML parser: flow mappings, quoted or multi-line keys and `name` expressions go unrecognised.
|
|
547
364
|
*/
|
|
548
365
|
function workflowJobs(source) {
|
|
549
366
|
const lines = source.split("\n");
|
|
@@ -551,7 +368,7 @@ function workflowJobs(source) {
|
|
|
551
368
|
if (start === -1) return [];
|
|
552
369
|
const jobs = [];
|
|
553
370
|
for (const line of lines.slice(start + 1)) {
|
|
554
|
-
if (/^\S/.test(line) && line.trim() !== "") break;
|
|
371
|
+
if (/^\S/.test(line) && line.trim() !== "") break;
|
|
555
372
|
const id = line.match(/^ {2}([A-Za-z0-9_-]+):\s*$/);
|
|
556
373
|
if (id) {
|
|
557
374
|
jobs.push({ id: id[1], context: id[1] });
|
|
@@ -567,24 +384,14 @@ function workflowJobs(source) {
|
|
|
567
384
|
return jobs;
|
|
568
385
|
}
|
|
569
386
|
|
|
570
|
-
//
|
|
571
|
-
// 4. Inspecting one workspace
|
|
572
|
-
// ===========================================================================================
|
|
387
|
+
// ---------------------------------------------------------------- inspecting one workspace
|
|
573
388
|
|
|
574
389
|
const PATH_SLOTS = {
|
|
575
390
|
identity: "file", principles: "file", gates: "file", dod: "file",
|
|
576
391
|
constitution: "either",
|
|
577
|
-
memory: "dir", repos: "dir", tasks: "dir", handoffs: "dir", proposals: "dir",
|
|
392
|
+
memory: "dir", repos: "dir", tasks: "dir", handoffs: "dir", proposals: "dir", context: "dir",
|
|
578
393
|
};
|
|
579
394
|
|
|
580
|
-
/**
|
|
581
|
-
* The five parts `core/personas/README.md` fixes, each with the pattern that finds it and the word a
|
|
582
|
-
* failure must use so a reader learns **which** part is missing rather than that "the contract" is unmet.
|
|
583
|
-
*
|
|
584
|
-
* The `tools:` allow-list is frontmatter and the other four are sections, which is why the first entry
|
|
585
|
-
* is matched differently — it is a field, not a heading, and a persona that writes it as a heading has
|
|
586
|
-
* not declared a tool grant a host can read.
|
|
587
|
-
*/
|
|
588
395
|
const PERSONA_PARTS = [
|
|
589
396
|
{ part: "a `tools:` allow-list", find: (fields) => typeof fields?.tools === "string" && fields.tools.trim().length > 0 },
|
|
590
397
|
{ part: "a Charter section", find: (_f, body) => /^##\s+Charter\b/im.test(body) },
|
|
@@ -593,53 +400,20 @@ const PERSONA_PARTS = [
|
|
|
593
400
|
{ part: "a Read / write posture section", find: (_f, body) => /^##\s+Read\s*\/\s*write posture\b/im.test(body) },
|
|
594
401
|
];
|
|
595
402
|
|
|
596
|
-
/**
|
|
597
|
-
* How deep a declared skills root is walked, and it is a bound rather than a recursion.
|
|
598
|
-
*
|
|
599
|
-
* **Three, matching `cli/plugin-lint.mjs`'s `MAX_DECLARED_SKILL_DEPTH`.** The first cut here was 1 and
|
|
600
|
-
* cited `.portulan/tasks/0008-a-declared-skills-path-sees-one-level-down.md` as fixing it — which that
|
|
601
|
-
* task does not do: it fixes plugin-lint's bound, in a different tool, at 3. Two walkers over the same
|
|
602
|
-
* declared key disagreeing about how deep a skill may sit is the sibling-divergence class this
|
|
603
|
-
* repository already has a rule for, so the number is shared rather than independently chosen, and the
|
|
604
|
-
* task is cited for the *shape* it fixes rather than for a number it never gave this tool.
|
|
605
|
-
*
|
|
606
|
-
* **The bound reports when it stops.** That sentence was in this comment before it was true — the walk
|
|
607
|
-
* returned silently at the limit, so a pack whose only skill sat below it was reported as a root with
|
|
608
|
-
* nothing in it, and a skill with a bad name and an empty description passed. Found by the pre-commit
|
|
609
|
-
* checkpoint. Task 0008's own acceptance criterion is that a validator reaching the limit says so
|
|
610
|
-
* rather than reporting an absence it did not establish.
|
|
611
|
-
*/
|
|
403
|
+
/** How deep a declared skills root is walked; kept equal to `MAX_DECLARED_SKILL_DEPTH` in ./plugin-lint.mjs. */
|
|
612
404
|
const SKILL_DEPTH = 3;
|
|
613
405
|
|
|
614
|
-
/**
|
|
615
|
-
* Open and validate what a pack contributes: its skills' frontmatter and its personas' five-part
|
|
616
|
-
* contract. Returns what was actually opened, so the caller's report line can say *opened* rather than
|
|
617
|
-
* *declared* — the distinction the whole check exists to make. `bindable` carries the key each opened
|
|
618
|
-
* persona would be bound by on a host; the caller owns that check, because it needs the tree.
|
|
619
|
-
*
|
|
620
|
-
* **Containment is checked after resolution, never by pattern.** `spec/pack.schema.json` bars only the
|
|
621
|
-
* leading `../` form and says so in its own text; `a/../../x` matches it and still escapes, and a symlink
|
|
622
|
-
* matches nothing until it is followed. `cli/index.mjs` already models the answer for personas —
|
|
623
|
-
* `realpathSync` on both the pack directory and the file, compared after resolution — and this is that
|
|
624
|
-
* rule applied to the half of the manifest nothing had opened.
|
|
625
|
-
*/
|
|
406
|
+
/** Opens and validates a pack's skills and personas, counting what was opened; `bindable` holds each persona's host key. */
|
|
626
407
|
export function validateContributions(packDir, contributes, { fail, report, pack }) {
|
|
627
408
|
let realPack;
|
|
628
409
|
try {
|
|
629
410
|
realPack = fs.realpathSync(packDir);
|
|
630
411
|
} catch (cause) {
|
|
631
412
|
fail("packs", `\`${pack}\` resolved to a directory that cannot be realpathed — ${cause.code ?? cause.message}`);
|
|
632
|
-
// The SAME shape as the success path, `unreadableRoots` included. A caller reading a property
|
|
633
|
-
// this branch omits gets `undefined`, which is falsy — so the report line silently prints the
|
|
634
|
-
// clean wording over a pack nothing could open. Raised by Copilot, round 1 on #227.
|
|
635
413
|
return { skills: 0, personas: 0, unreadableRoots: 0, bindable: [] };
|
|
636
414
|
}
|
|
637
415
|
|
|
638
|
-
//
|
|
639
|
-
// there" — which is only true for ENOENT. An EACCES is a question that could not be ANSWERED, and
|
|
640
|
-
// answering it "absent" is the fail-open — "nothing looked" reported as "nothing wrong" — arriving
|
|
641
|
-
// inside the function added to prevent one. Copilot, round 1 on #156; the same class as session 1's
|
|
642
|
-
// round 7, one tool over.
|
|
416
|
+
// Contained after realpath, never by pattern: `a/../../x` and a symlink both pass one. Only ENOENT is absent.
|
|
643
417
|
const inside = (target) => {
|
|
644
418
|
let real;
|
|
645
419
|
try {
|
|
@@ -679,19 +453,11 @@ export function validateContributions(packDir, contributes, { fail, report, pack
|
|
|
679
453
|
continue;
|
|
680
454
|
}
|
|
681
455
|
const found = walkSkills(contained.real, { fail, report, pack, rel });
|
|
682
|
-
// `null` means the walk could not look, which is NOT zero. Summing it as zero would put "0
|
|
683
|
-
// skill(s)" in the report line over a root nothing opened — the same "nothing looked recorded as
|
|
684
|
-
// nothing wrong" the walker itself refuses, arriving one layer out in the sentence a reader
|
|
685
|
-
// actually reads. Found by the test written for the walker, which is the argument for testing the
|
|
686
|
-
// report line and not only the finding.
|
|
687
456
|
if (found === null) unreadableRoots += 1;
|
|
688
457
|
else skills += found;
|
|
689
458
|
}
|
|
690
459
|
|
|
691
460
|
let personas = 0;
|
|
692
|
-
// What each opened persona would be BOUND by on a host, collected here because this is the one
|
|
693
|
-
// place the file is read. The caller matches them against `agents/` once the tree is known — the
|
|
694
|
-
// check needs a repository, and this function is given a pack.
|
|
695
461
|
const bindable = [];
|
|
696
462
|
for (const rel of contributes.personas ?? []) {
|
|
697
463
|
const file = path.join(packDir, rel);
|
|
@@ -717,11 +483,6 @@ export function validateContributions(packDir, contributes, { fail, report, pack
|
|
|
717
483
|
}
|
|
718
484
|
const declared = validatePersona(text, { fail, pack, rel });
|
|
719
485
|
personas += 1;
|
|
720
|
-
// The key is the declared `name`, and the file's basename where it declares none. Two sources
|
|
721
|
-
// rather than one because the contract's five parts do not include a name — a persona missing
|
|
722
|
-
// it is not thereby unbindable, and `new persona` writes `personas/<name>.md`, so the basename
|
|
723
|
-
// is the author's own answer to the same question. Which one answered is printed, since a
|
|
724
|
-
// binding resolved by filename is a weaker fact than one resolved by declaration.
|
|
725
486
|
const basename = path.basename(rel, ".md");
|
|
726
487
|
bindable.push({
|
|
727
488
|
rel,
|
|
@@ -734,20 +495,8 @@ export function validateContributions(packDir, contributes, { fail, report, pack
|
|
|
734
495
|
}
|
|
735
496
|
|
|
736
497
|
/**
|
|
737
|
-
* One persona against the five-part contract,
|
|
738
|
-
*
|
|
739
|
-
* **What this checks is PRESENCE, and that is narrower than "validated".** Four of the five parts are
|
|
740
|
-
* matched as headings, so a persona whose Charter, Autonomy reach, Memory scope and Read/write posture
|
|
741
|
-
* sections are all empty passes; and `tools:` is satisfied by any non-empty value, so `tools: []` passes.
|
|
742
|
-
* Both measured at the pre-commit checkpoint. The distinction matters because `core/operating/memory.md`
|
|
743
|
-
* frames this milestone as where a declared memory scope stops being prose a checker can read but not
|
|
744
|
-
* honour — a heading check reads it and does not honour it, and the sentence saying so belongs beside the
|
|
745
|
-
* check rather than only in the milestone's record.
|
|
746
|
-
*
|
|
747
|
-
* **Returns the name the persona declares, or `null` where it declares none** — the key the binding
|
|
748
|
-
* check below resolves a host agent file by. It is returned rather than re-parsed at the call site
|
|
749
|
-
* because the frontmatter has already been read here, and two parses of one block are two chances to
|
|
750
|
-
* disagree about what it said.
|
|
498
|
+
* One persona against the five-part contract, by presence only: an empty section or `tools: []` passes.
|
|
499
|
+
* Returns `{ name }`, `name` null where none is declared, or null where there is no frontmatter.
|
|
751
500
|
*/
|
|
752
501
|
export function validatePersona(text, { fail, pack, rel }) {
|
|
753
502
|
const { fields, error } = parseFrontmatter(text);
|
|
@@ -760,30 +509,9 @@ export function validatePersona(text, { fail, pack, rel }) {
|
|
|
760
509
|
fail("packs", `\`${pack}\`'s persona \`${rel}\` is missing ${part} — the five-part contract core/personas/README.md fixes`);
|
|
761
510
|
}
|
|
762
511
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
// **The first cut of this check flagged the word anywhere in the reach section, and its first run
|
|
767
|
-
// false-redded this repository's own `checkpoints` supervisor** — whose reach section says
|
|
768
|
-
// *"**Prohibited is not a reach** and does not appear here"*. That file is obeying the rule by
|
|
769
|
-
// explaining it, and the checker could not tell disclaiming from claiming. A false red is the failure
|
|
770
|
-
// that gets a whole recipe switched off (`.portulan/memory/` records `json.sh` costing exactly that),
|
|
771
|
-
// so the matcher is narrowed to an affirmative mention.
|
|
772
|
-
//
|
|
773
|
-
// **The limit, restated after the pre-commit checkpoint measured it WIDER than the first wording
|
|
774
|
-
// admitted.** This is a prose heuristic and prose defeats it. The splitter breaks on sentence
|
|
775
|
-
// punctuation and blank lines, so a *block* — a bullet list, which is the ordinary way a reach
|
|
776
|
-
// section is written — counts as one unit: a negation **anywhere in the same block** suppresses the
|
|
777
|
-
// finding, not merely one in the same sentence. Both of these pass:
|
|
778
|
-
//
|
|
779
|
-
// - Acts in Prohibited
|
|
780
|
-
// - This role does not merge
|
|
781
|
-
//
|
|
782
|
-
// Reach is Prohibited, and there is no ceiling above it
|
|
783
|
-
//
|
|
784
|
-
// The structural fix is a declared reach *field* the way `tools:` is a field — a persona-contract
|
|
785
|
-
// change, and not an implementer's to make. Named at this width so the gap is visible instead of
|
|
786
|
-
// assumed covered, which is the rule `core/operating/verification.md` legislates.
|
|
512
|
+
for (const problem of personaDeclarations(text).problems) {
|
|
513
|
+
fail("packs", `\`${pack}\`'s persona \`${rel}\` ${problem} — core/personas/README.md gives the fields a persona may declare, and none is defaulted`);
|
|
514
|
+
}
|
|
787
515
|
const reach = text.split(/^##\s+Autonomy reach\b.*$/im)[1]?.split(/^##\s/m)[0];
|
|
788
516
|
if (reach && claimsProhibited(reach)) {
|
|
789
517
|
fail(
|
|
@@ -795,14 +523,11 @@ export function validatePersona(text, { fail, pack, rel }) {
|
|
|
795
523
|
return { name: typeof fields.name === "string" && fields.name.trim() ? fields.name.trim() : null };
|
|
796
524
|
}
|
|
797
525
|
|
|
798
|
-
/**
|
|
526
|
+
/** Walks a declared skills root to `SKILL_DEPTH`, validating each `SKILL.md`: the count, or null where a directory went unread. */
|
|
799
527
|
function walkSkills(root, { fail, report, pack, rel }, depth = 0) {
|
|
800
528
|
let found = 0;
|
|
801
529
|
const own = path.join(root, "SKILL.md");
|
|
802
|
-
// `lstat`,
|
|
803
|
-
// under a contained root was opened, validated and counted as the pack's own. Containment covered the
|
|
804
|
-
// declared root and not the files found beneath it, which is the half-guarantee shape session 1 paid
|
|
805
|
-
// for twice. Found by the pre-commit checkpoint.
|
|
530
|
+
// `lstat`, since `existsSync` follows a link and would count a `SKILL.md` linked in from outside the pack.
|
|
806
531
|
let ownStat = null;
|
|
807
532
|
try {
|
|
808
533
|
ownStat = fs.lstatSync(own);
|
|
@@ -822,9 +547,6 @@ function walkSkills(root, { fail, report, pack, rel }, depth = 0) {
|
|
|
822
547
|
found += 1;
|
|
823
548
|
}
|
|
824
549
|
if (depth >= SKILL_DEPTH) {
|
|
825
|
-
// Reported, never silent. Only when there is something below to miss: announcing a stop over an
|
|
826
|
-
// empty directory would be noise, and a note nobody can act on trains a reader to skip the ones
|
|
827
|
-
// they can.
|
|
828
550
|
let below = [];
|
|
829
551
|
try {
|
|
830
552
|
below = fs.readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
@@ -845,8 +567,6 @@ function walkSkills(root, { fail, report, pack, rel }, depth = 0) {
|
|
|
845
567
|
try {
|
|
846
568
|
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
847
569
|
} catch (cause) {
|
|
848
|
-
// Only ENOENT means absent. Anything else and the walk could not look, which must never be
|
|
849
|
-
// reported as a root with no skills in it — #108's shape, arriving in a new walker.
|
|
850
570
|
if (cause.code === "ENOENT") {
|
|
851
571
|
fail("packs", `\`${pack}\` declares the skills root \`${rel}\`, which is not there`);
|
|
852
572
|
return found;
|
|
@@ -859,11 +579,7 @@ function walkSkills(root, { fail, report, pack, rel }, depth = 0) {
|
|
|
859
579
|
return null;
|
|
860
580
|
}
|
|
861
581
|
for (const entry of entries) {
|
|
862
|
-
// A symlinked directory
|
|
863
|
-
// obvious `if (!isDirectory()) continue` SKIPS it — silently, with no finding, hiding whatever
|
|
864
|
-
// skills sit behind it. That is a false green of the walk's own kind, and it is reported rather
|
|
865
|
-
// than followed for the same reason the `SKILL.md` link is: following it is how a pack ships
|
|
866
|
-
// content it does not contain. Copilot, round 3 on #156.
|
|
582
|
+
// A symlinked directory's Dirent is not `isDirectory()`, so the check below would skip it in silence.
|
|
867
583
|
if (entry.isSymbolicLink()) {
|
|
868
584
|
fail(
|
|
869
585
|
"packs",
|
|
@@ -874,20 +590,13 @@ function walkSkills(root, { fail, report, pack, rel }, depth = 0) {
|
|
|
874
590
|
}
|
|
875
591
|
if (!entry.isDirectory()) continue;
|
|
876
592
|
const child = walkSkills(path.join(root, entry.name), { fail, report, pack, rel: path.join(rel, entry.name) }, depth + 1);
|
|
877
|
-
//
|
|
878
|
-
// which is what the review that found this proposed; `null` coerces to 0 in numeric addition,
|
|
879
|
-
// measured. The conclusion was right and the mechanism was not, and the real defect is the worse
|
|
880
|
-
// of the two: an unreadable subdirectory would be silently counted as *no skills here*, which is
|
|
881
|
-
// exactly the "nothing looked recorded as nothing wrong" this walk was changed to stop — surviving
|
|
882
|
-
// one level down from the fix. Propagated instead, so the root reports UNREAD however deep the
|
|
883
|
-
// unreadable directory sits.
|
|
593
|
+
// Propagated: `found += null` adds 0, which would count an unread directory as empty.
|
|
884
594
|
if (child === null) return null;
|
|
885
595
|
found += child;
|
|
886
596
|
}
|
|
887
597
|
return found;
|
|
888
598
|
}
|
|
889
599
|
|
|
890
|
-
/** One skill's frontmatter: the `name` and `description` that ARE the contract a host reads. */
|
|
891
600
|
export function validateSkill(file, { fail, pack, rel }) {
|
|
892
601
|
let text;
|
|
893
602
|
try {
|
|
@@ -920,12 +629,8 @@ export function validateSkill(file, { fail, pack, rel }) {
|
|
|
920
629
|
const SLUG_PATTERN = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
921
630
|
|
|
922
631
|
/**
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
* Sentence-scoped, because the two readings sit in the same section and differ only by a negation. A
|
|
926
|
-
* sentence mentioning the tier alongside `not` / `never` / `no role` / `nobody` / `cannot` is the
|
|
927
|
-
* disclaimer every well-written persona contains — this repository's own supervisor is one — and
|
|
928
|
-
* flagging it made a correct file red on the check's first run.
|
|
632
|
+
* Whether a reach section claims Prohibited: a sentence naming it with no negation, so a disclaimer passes.
|
|
633
|
+
* A prose heuristic: a bullet block splits as one sentence, so a negation anywhere in it hides a claim.
|
|
929
634
|
*/
|
|
930
635
|
function claimsProhibited(reach) {
|
|
931
636
|
for (const sentence of reach.split(/(?<=[.!?])\s+|\n{2,}/)) {
|
|
@@ -936,12 +641,7 @@ function claimsProhibited(reach) {
|
|
|
936
641
|
return false;
|
|
937
642
|
}
|
|
938
643
|
|
|
939
|
-
/**
|
|
940
|
-
* The Pack Definition version a schema implements, read from its `$id` — the same argument as
|
|
941
|
-
* `schemaVersion` below, on the other version train. Separate rather than shared because the two
|
|
942
|
-
* `$id` shapes differ deliberately (`/spec/2.5/` and `/spec/pack/1.0/`), and a single regex loose
|
|
943
|
-
* enough to read both would read `/spec/pack/1.0/` as Workspace Definition version 1.0.
|
|
944
|
-
*/
|
|
644
|
+
/** The Pack Definition version in a pack schema's `$id` (`/spec/pack/1.0/`) as `{ major, minor }`; a DoctorError without one. */
|
|
945
645
|
export function packSchemaVersion(schema) {
|
|
946
646
|
const match = /\/spec\/pack\/([0-9]+)\.([0-9]+)\//.exec(schema.$id ?? "");
|
|
947
647
|
if (!match) {
|
|
@@ -953,14 +653,7 @@ export function packSchemaVersion(schema) {
|
|
|
953
653
|
return { major: Number(match[1]), minor: Number(match[2]) };
|
|
954
654
|
}
|
|
955
655
|
|
|
956
|
-
/**
|
|
957
|
-
* The Workspace Definition version a schema implements, read from its `$id`.
|
|
958
|
-
*
|
|
959
|
-
* Carried in the identifier rather than parsed out of `title`, because a machine fact read from a
|
|
960
|
-
* human sentence drifts the first time somebody rewords the sentence. Absent is a hard stop: a
|
|
961
|
-
* validator that cannot tell which version of the contract it implements cannot honestly say a
|
|
962
|
-
* manifest conforms to it.
|
|
963
|
-
*/
|
|
656
|
+
/** The Workspace Definition version in a schema's `$id` (`/spec/2.5/`) as `{ major, minor }`; a DoctorError without one. */
|
|
964
657
|
export function schemaVersion(schema) {
|
|
965
658
|
const match = /\/spec\/([0-9]+)\.([0-9]+)\//.exec(schema.$id ?? "");
|
|
966
659
|
if (!match) {
|
|
@@ -993,48 +686,9 @@ function loadSchema({ schema, schemaPath }) {
|
|
|
993
686
|
}
|
|
994
687
|
}
|
|
995
688
|
|
|
996
|
-
//
|
|
997
|
-
// 3b. Agent legibility
|
|
998
|
-
// ===========================================================================================
|
|
999
|
-
//
|
|
1000
|
-
// Row 7's 2026-07-28 amendment: `doctor` scores agent legibility — the audit `docs/vision.md`'s
|
|
1001
|
-
// influence map calls the **unclaimed niche**, "repo affordances scored by doctor".
|
|
1002
|
-
//
|
|
1003
|
-
// **It moves no exit code, and that is the design rather than a shortfall.** A score is a
|
|
1004
|
-
// measurement; `doctor`'s exit codes are verdicts about conformance. A score that could fail a
|
|
1005
|
-
// workspace would make the verdict a function of how much affordance prose somebody wrote, and
|
|
1006
|
-
// every incentive it created would point at writing more of it.
|
|
1007
|
-
//
|
|
1008
|
-
// **Every carrier of the amendment says the `affordances` slot "is its input", and this reads six
|
|
1009
|
-
// keys besides.** That is a reading, stated rather than taken in silence: the constitution's own
|
|
1010
|
-
// gloss is *"repo affordances scored by doctor"* — the repository's affordances, of which the slot
|
|
1011
|
-
// is the written half — and a score confined to the slot could not tell two workspaces apart at
|
|
1012
|
-
// all. **The slot is the named input, not the only one.**
|
|
1013
|
-
//
|
|
1014
|
-
// What each dimension has in common is the property that makes a score honest: every one is
|
|
1015
|
-
// **optional in the Workspace Definition**, so it can genuinely be absent, and none of them
|
|
1016
|
-
// duplicates a check that already fails. A dimension that cannot vary measures nothing, and a
|
|
1017
|
-
// dimension that restates a hard failure is a second carrier of one fact.
|
|
1018
|
-
//
|
|
1019
|
-
// **Three candidates were dropped by that rule, and they are named because two of them were in this
|
|
1020
|
-
// session's own plan.** *A declared `tree`* and *a repo card for every repository the products name*
|
|
1021
|
-
// were both listed in task 0013's acceptance list; each is already a hard failure — `tree` for any
|
|
1022
|
-
// `kind: repository`, the cards through the `cross` check — so scoring them would have been a
|
|
1023
|
-
// guaranteed point for every workspace that got far enough to be scored, and a second carrier of a
|
|
1024
|
-
// verdict the tool already renders. *Executable verification* was the third, and it survived one
|
|
1025
|
-
// checkpoint before being measured: see `legibility` itself. **Seven dimensions is what the rule
|
|
1026
|
-
// leaves**, and the number is derived by the suite rather than trusted here.
|
|
689
|
+
// ---------------------------------------------------------------- agent legibility
|
|
1027
690
|
|
|
1028
|
-
/**
|
|
1029
|
-
* The headings that state limits, and it is a FORM check.
|
|
1030
|
-
*
|
|
1031
|
-
* A named table rather than a matcher clever enough to be wrong quietly — the same shape and the
|
|
1032
|
-
* same reason as `PERSONA_PARTS` above and as the writer table in `cli/compile.mjs`. What it
|
|
1033
|
-
* establishes is that a limits section is **present**, never that what it says is true or complete:
|
|
1034
|
-
* a heading with an empty body passes. `.portulan/products/portulan/affordances.md` states the rule
|
|
1035
|
-
* this scores in its own words — *"a legibility report that lists only strengths is marketing"* — and
|
|
1036
|
-
* a form check is the most a reader of the tree can hold anybody to.
|
|
1037
|
-
*/
|
|
691
|
+
/** Headings that mark a limits section: a form check, so an empty section passes. */
|
|
1038
692
|
const LIMIT_HEADINGS = [
|
|
1039
693
|
/^#{2,3}\s+What an agent must not assume\b/im,
|
|
1040
694
|
/^#{2,3}\s+.*\bmust not\b/im,
|
|
@@ -1044,27 +698,13 @@ const LIMIT_HEADINGS = [
|
|
|
1044
698
|
];
|
|
1045
699
|
|
|
1046
700
|
/**
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
1049
|
-
* Returns `{ met, applicable, dimensions }`, where a dimension is
|
|
1050
|
-
* `{ id, title, met, applicable, why }`. **Inapplicable dimensions leave the denominator** rather
|
|
1051
|
-
* than counting as failures: a workspace with no products has nothing to declare affordances for,
|
|
1052
|
-
* and scoring it down for that would be grading it against a standard it is not under.
|
|
1053
|
-
*
|
|
1054
|
-
* Reads the tree only through paths the manifest declares, so it fetches nothing and infers nothing.
|
|
701
|
+
* Scores agent legibility as `{ met, applicable, dimensions }`; an inapplicable dimension leaves the denominator.
|
|
702
|
+
* A dimension must be optional in the Workspace Definition and must not restate a check that already fails.
|
|
1055
703
|
*/
|
|
1056
704
|
export function legibility(workspace, dir) {
|
|
1057
705
|
const dimensions = [];
|
|
1058
706
|
const add = (id, title, met, why, applicable = true) => dimensions.push({ id, title, met, applicable, why });
|
|
1059
707
|
|
|
1060
|
-
// **`verify` itself is deliberately NOT a dimension**, and the reason is the rule this list is
|
|
1061
|
-
// built on. The schema's first `oneOf` form requires `slots` and `verify` of every workspace that
|
|
1062
|
-
// is not a pointer, a pointer returns long before this line, and a manifest that fails the schema
|
|
1063
|
-
// returns earlier still — so *does this workspace declare executable verification* is a question
|
|
1064
|
-
// whose answer is always yes by the time anything here could ask it. It was scored for exactly one
|
|
1065
|
-
// pre-commit checkpoint, which measured it and found a dimension that could not vary: a constant
|
|
1066
|
-
// +1 dressed as a measurement, in a list whose whole claim is that every entry can genuinely be
|
|
1067
|
-
// absent. What a recipe DECLARES about itself still varies, which is the dimension below.
|
|
1068
708
|
const recipes = workspace.verify?.recipes ?? [];
|
|
1069
709
|
add(
|
|
1070
710
|
"requires",
|
|
@@ -1093,21 +733,6 @@ export function legibility(workspace, dir) {
|
|
|
1093
733
|
"why a decision was taken survives the session that took it, and is reachable without reading the series",
|
|
1094
734
|
);
|
|
1095
735
|
|
|
1096
|
-
// The slot the amendment names, read for both of its dimensions in one pass.
|
|
1097
|
-
//
|
|
1098
|
-
// **Per DOCUMENT, not per product**, and the distinction is the whole of #228 item 1. `products`
|
|
1099
|
-
// resolve `product.affordances ?? workspace.affordances`, so N products inheriting one
|
|
1100
|
-
// workspace-level default all name the same `rel` — which was read N times, and, when it could not
|
|
1101
|
-
// be read, counted N times. `unreadable` was therefore a count of **products**, under a name that
|
|
1102
|
-
// says documents, and both dimensions below are about documents.
|
|
1103
|
-
//
|
|
1104
|
-
// Nothing read the magnitude — `limits` asks only `unreadable === 0`, so no verdict moved — which
|
|
1105
|
-
// is exactly why it is worth fixing now rather than after something starts reading it. A count
|
|
1106
|
-
// wrong about its own unit, in the tool whose subject is claims being checkable.
|
|
1107
|
-
//
|
|
1108
|
-
// Triaged out of [#227](https://github.com/sleepy-panda-srl/portulan/pull/227) at the two-fix-round
|
|
1109
|
-
// bound and **dropped on 2026-08-12 for scope, not on merits** — "nothing here opens
|
|
1110
|
-
// cli/doctor.mjs" — which is the disposition this change reverses now that something does.
|
|
1111
736
|
const products = workspace.products ?? [];
|
|
1112
737
|
const docs = [];
|
|
1113
738
|
const seen = new Set();
|
|
@@ -1119,8 +744,6 @@ export function legibility(workspace, dir) {
|
|
|
1119
744
|
try {
|
|
1120
745
|
docs.push({ rel, text: fs.readFileSync(path.resolve(dir, rel), "utf8") });
|
|
1121
746
|
} catch {
|
|
1122
|
-
// Counted, never skipped: a document that could not be read has not been found to state
|
|
1123
|
-
// its limits, and treating it as absent and as present are both claims nothing established.
|
|
1124
747
|
unreadable += 1;
|
|
1125
748
|
}
|
|
1126
749
|
}
|
|
@@ -1144,22 +767,19 @@ export function legibility(workspace, dir) {
|
|
|
1144
767
|
}
|
|
1145
768
|
|
|
1146
769
|
/**
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
* where severity is `fail` (exit 1) or `report` (worth reading, not a verdict).
|
|
770
|
+
* Inspects one workspace: `{ dir, workspace, findings, stats }`, with `governor` for a pointer. A finding is
|
|
771
|
+
* `{ severity, check, message }`, and only a `fail` is a verdict; a DoctorError means it could not judge.
|
|
1150
772
|
*/
|
|
1151
773
|
export async function inspect(workspaceDir, options = {}) {
|
|
1152
774
|
const schema = loadSchema(options);
|
|
1153
775
|
const packSchema = loadPackSchema(options);
|
|
1154
776
|
const dir = path.resolve(workspaceDir);
|
|
1155
777
|
const findings = [];
|
|
1156
|
-
// `unretirable` counts records stating no `Retire when:` condition — retirable by a human
|
|
1157
|
-
// re-reading them, never by the condition-driven pass, which is the sense that matters here.
|
|
1158
778
|
const stats = { records: 0, rules: 0, sealed: 0, linked: 0, claims: 0, unverifiable: 0, bytes: 0, unretirable: 0, unassessed: 0, packs: 0 };
|
|
1159
779
|
const fail = (check, message) => findings.push({ severity: "fail", check, message });
|
|
1160
780
|
const report = (check, message) => findings.push({ severity: "report", check, message });
|
|
1161
781
|
|
|
1162
|
-
//
|
|
782
|
+
// ---------------------------------------------------------------- the manifest itself
|
|
1163
783
|
const manifestPath = path.join(dir, "workspace.json");
|
|
1164
784
|
let workspace;
|
|
1165
785
|
try {
|
|
@@ -1170,14 +790,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1170
790
|
return { dir, workspace: null, findings, stats };
|
|
1171
791
|
}
|
|
1172
792
|
|
|
1173
|
-
//
|
|
1174
|
-
// a manifest against a definition it was not written for produces confident nonsense: an
|
|
1175
|
-
// optional slot added in a later MINOR would come back as an unexpected property, and the
|
|
1176
|
-
// report would blame the author for using the spec correctly.
|
|
1177
|
-
//
|
|
1178
|
-
// Both mismatches are exit 2 rather than 1. "This workspace is wrong" and "I do not implement
|
|
1179
|
-
// the contract it names" are different statements, and only the first is a verdict `doctor` is
|
|
1180
|
-
// entitled to make — see ../.portulan/memory/a-checker-must-refuse-what-it-cannot-check.md.
|
|
793
|
+
// Before conformance: graded against a contract it was not written for, a correct manifest would read as wrong.
|
|
1181
794
|
const here = schemaVersion(schema);
|
|
1182
795
|
const declared = /^([0-9]+)\.([0-9]+)$/.exec(workspace?.portulan?.spec ?? "");
|
|
1183
796
|
if (declared) {
|
|
@@ -1187,10 +800,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1187
800
|
`${path.relative(process.cwd(), dir)} declares Workspace Definition ${major}.${minor}; ` +
|
|
1188
801
|
`this schema is ${here.major}.${here.minor}. A MAJOR difference means a migration ` +
|
|
1189
802
|
`exists and this validator is not the one to run — ` +
|
|
1190
|
-
// Named since milestone 7 session 9. This sentence said a migration exists and
|
|
1191
|
-
// stopped there, which is a refusal that describes a remedy nobody could reach:
|
|
1192
|
-
// the tool did not exist when it was written. It reads the direction too, since
|
|
1193
|
-
// a workspace AHEAD of this bundle is not one `upgrade` can help with.
|
|
1194
803
|
(major > here.major
|
|
1195
804
|
? "and this validator is OLDER than that workspace, so upgrade the CLI rather than the workspace."
|
|
1196
805
|
: "run `portulan upgrade <workspace-dir>` to see what it owes, and `--write` to apply it."),
|
|
@@ -1201,9 +810,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1201
810
|
`${path.relative(process.cwd(), dir)} declares Workspace Definition ${major}.${minor}, ` +
|
|
1202
811
|
`which is ahead of the ${here.major}.${here.minor} this schema implements. Refusing ` +
|
|
1203
812
|
`rather than grading it against an older contract and reporting the difference as errors — ` +
|
|
1204
|
-
// The MAJOR arm above names its remedy and this one stopped at "Refusing", which is
|
|
1205
|
-
// one rule enforced at two sites and repaired at one — `0020`, ten lines apart, in
|
|
1206
|
-
// the change that repaired the other site. Caught at the pre-commit checkpoint.
|
|
1207
813
|
"and this validator is OLDER than that workspace, so upgrade the CLI rather than the workspace.",
|
|
1208
814
|
);
|
|
1209
815
|
}
|
|
@@ -1215,6 +821,24 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1215
821
|
`${major}.${minor} will simply be absent`,
|
|
1216
822
|
);
|
|
1217
823
|
}
|
|
824
|
+
// A key newer than the declared MINOR is refused only for keys born gated, from 2.9: refusing an older
|
|
825
|
+
// key now would fail a manifest that passes today.
|
|
826
|
+
for (const [key, since, value] of [
|
|
827
|
+
["context", 9, workspace.context],
|
|
828
|
+
["slots.context", 10, workspace.slots?.context],
|
|
829
|
+
["memory.store.budget.cutoff", 11, workspace.memory?.store?.budget?.cutoff],
|
|
830
|
+
["sessions", 11, workspace.sessions],
|
|
831
|
+
["spend", 12, workspace.spend],
|
|
832
|
+
["spend.restart", 13, workspace.spend?.restart],
|
|
833
|
+
]) {
|
|
834
|
+
if (major === 2 && minor < since && value !== undefined) {
|
|
835
|
+
fail(
|
|
836
|
+
"schema",
|
|
837
|
+
`\`${key}\` is Workspace Definition 2.${since}'s, and this manifest declares ${major}.${minor}, ` +
|
|
838
|
+
`whose validator refuses it as an unknown key. Declare 2.${since}, or remove the key`,
|
|
839
|
+
);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
1218
842
|
}
|
|
1219
843
|
|
|
1220
844
|
const errors = validate(schema, workspace);
|
|
@@ -1222,34 +846,15 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1222
846
|
fail("schema", `${e.pointer || "/"} — ${e.message}`);
|
|
1223
847
|
}
|
|
1224
848
|
if (errors.length) {
|
|
1225
|
-
// The later checks read the manifest's shape. Running them on a manifest that failed the
|
|
1226
|
-
// schema would produce noise at best and a crash at worst — so they are skipped, and the
|
|
1227
|
-
// skip is said out loud rather than left to look like a pass.
|
|
1228
849
|
report("schema", "path, cross-field, claims and provenance checks were skipped: the manifest must conform first");
|
|
1229
850
|
return { dir, workspace, findings, stats };
|
|
1230
851
|
}
|
|
1231
852
|
|
|
1232
|
-
//
|
|
1233
|
-
//
|
|
1234
|
-
// The maintainer's ruling of 2026-07-30, recorded in ../.portulan/proposals/0017. Two of its three
|
|
1235
|
-
// refusals are here, where a single manifest is enough to see the fault; the third needs to look
|
|
1236
|
-
// outside this directory and is below, with the repo cards.
|
|
1237
|
-
//
|
|
1238
|
-
// These are `doctor`'s and not the schema's ON PURPOSE, and the reason is mechanical rather than
|
|
1239
|
-
// stylistic: the schema's own failures return above at "the manifest must conform first", so a
|
|
1240
|
-
// shape refused by the schema is a shape whose refusal never prints the sentence it exists to
|
|
1241
|
-
// print. The rule is a sentence a human has to read — the whole point is that the reader learns
|
|
1242
|
-
// *why* two workspaces are one too many — so the schema permits the shape and this refuses it.
|
|
853
|
+
// ---------------------------------------------------------------- residence
|
|
1243
854
|
const GOVERNS =
|
|
1244
855
|
"a repository is governed by exactly one workspace — it carries its own full workspace, or a " +
|
|
1245
856
|
"pointer to the workspace that names it, never both";
|
|
1246
|
-
// Everything a pointer may carry. `summary` is here because one line an agent reads before loading
|
|
1247
|
-
// anything else costs nothing and is the difference between "governed elsewhere" and a bare name.
|
|
1248
857
|
const POINTER_KEYS = new Set(["portulan", "name", "summary", "kind", "governed_by"]);
|
|
1249
|
-
// Read off the schema rather than written out again. The cross-repository check below needs to tell a
|
|
1250
|
-
// kind it UNDERSTANDS from one it does not, and a second hand-maintained copy of that list is the
|
|
1251
|
-
// defect class this whole change is partly about — one fact, two carriers, only one of them checked.
|
|
1252
|
-
// Derived, so a fifth kind joins both at once or neither.
|
|
1253
858
|
const KNOWN_KINDS = new Set(schema.properties?.kind?.enum ?? []);
|
|
1254
859
|
|
|
1255
860
|
if (workspace.kind === "pointer") {
|
|
@@ -1263,18 +868,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1263
868
|
"two policy layers for one repository and nothing holding them in agreement",
|
|
1264
869
|
);
|
|
1265
870
|
}
|
|
1266
|
-
// A
|
|
1267
|
-
// recipes, no products, no packs, no store, no cards. Every check below reads one of those, so
|
|
1268
|
-
// running them here would grade the absence of things a pointer correctly does not have —
|
|
1269
|
-
// `verify.default` alone would fail every compliant pointer, naming a recipe list that is empty
|
|
1270
|
-
// because the manifest is right. Skipped, and SAID, on the standing rule that a check which
|
|
1271
|
-
// disappears without a word is worse than one that admits what it could not reach.
|
|
1272
|
-
// Blank reads as absent HERE too, because the resolver one file away now treats it that way and
|
|
1273
|
-
// two carriers of one fact are this repository's signature defect. Reporting *"delivered through
|
|
1274
|
-
// ` `"* while `resolveGovernor` ignores that value is a report describing a constraint the tool
|
|
1275
|
-
// does not apply — worse than either behaviour alone, since a reader would go looking for the
|
|
1276
|
-
// marketplace it names. Found by Copilot on the round that reviewed the fix, and by the
|
|
1277
|
-
// pre-commit checkpoint independently, both of them at the site the fix did not reach.
|
|
871
|
+
// A blank `feed` reads as absent, as `resolveGovernor` reads it.
|
|
1278
872
|
const rawFeed = workspace.governed_by?.feed;
|
|
1279
873
|
const feed = typeof rawFeed === "string" && rawFeed.trim() !== "" ? rawFeed : undefined;
|
|
1280
874
|
report(
|
|
@@ -1283,51 +877,11 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1283
877
|
(feed ? `, delivered through \`${feed}\`` : "") +
|
|
1284
878
|
" — no workspace resides here, and this manifest says so rather than improvising one",
|
|
1285
879
|
);
|
|
1286
|
-
//
|
|
1287
|
-
//
|
|
1288
|
-
//
|
|
1289
|
-
// discovery, and the roots are named rather than found (milestone 7)"*. Discovery has landed
|
|
1290
|
-
// (./discover.mjs), so the sentence became a document denying a capability that exists —
|
|
1291
|
-
// condition 4 of ../.portulan/dod.md pointing the other way, which this repository has now
|
|
1292
|
-
// corrected often enough to expect.
|
|
1293
|
-
//
|
|
1294
|
-
// **Nothing is fetched is still true and is still said**, by the resolver's own sentence: the
|
|
1295
|
-
// host's installed-plugin record is read from disk and no network call is made anywhere in
|
|
1296
|
-
// this path. What changed is that a name on disk is now looked up, not that anything is
|
|
1297
|
-
// downloaded.
|
|
1298
|
-
//
|
|
1299
|
-
// The verdict is REPORTED and never graded, and that boundary is load-bearing rather than
|
|
1300
|
-
// cautious. `not-installed` is not a defect in this manifest — a pointer is correct while its
|
|
1301
|
-
// governor is uninstalled, which is the ordinary state of a fresh clone — so failing here
|
|
1302
|
-
// would red every honest pointer on every machine that had not run an install yet, including
|
|
1303
|
-
// CI, where nothing is ever installed. What the reader gets instead is the root, and what to
|
|
1304
|
-
// run against it.
|
|
1305
|
-
// `await`ed, though today's resolver is synchronous and `await` on a plain value is a no-op.
|
|
1306
|
-
// `options.discover` is an injection point, `inspect` is already `async`, and a hook that ever
|
|
1307
|
-
// returns a promise would otherwise yield `governor.state === undefined` — a report about a
|
|
1308
|
-
// Promise rather than about a host. One keyword now against a class of silent wrong answer
|
|
1309
|
-
// later (Copilot, round 2).
|
|
880
|
+
// ---------------------------------------------------------------- the pointer's governor
|
|
881
|
+
// Reported, never failed: an uninstalled governor is the ordinary state of a fresh clone and of CI.
|
|
882
|
+
// Awaited although `resolveGovernor` is synchronous, since an injected `discover` may return a promise.
|
|
1310
883
|
const resolve = options.discover ?? ((governedBy) => resolveGovernor(governedBy, options));
|
|
1311
884
|
const governor = await resolve(workspace.governed_by);
|
|
1312
|
-
// The resolver owns the sentence — one carrier, so every surface reporting this prints the same
|
|
1313
|
-
// words rather than four paraphrases. The fallback names what the resolver answered rather than
|
|
1314
|
-
// inventing prose: a resolver returning no sentence is a defect in the resolver, and a report
|
|
1315
|
-
// that hid it behind something still looking like a report would be the worse failure.
|
|
1316
|
-
//
|
|
1317
|
-
// **The fallback had that defect itself, in the line that claimed to prevent it** (#182 item 2).
|
|
1318
|
-
// It interpolated `governor.state` directly, so a verdict object carrying no `state` printed the
|
|
1319
|
-
// literal word `undefined` inside the one sentence whose whole job is to say a resolver
|
|
1320
|
-
// misbehaved. It now distinguishes three shapes — a state, a state that is not a string, and no
|
|
1321
|
-
// state at all — because "no `state` at all" said of `{state: 123}` is the same class of wrong
|
|
1322
|
-
// one revision later, which is what the pre-commit checkpoint found by feeding it that value.
|
|
1323
|
-
//
|
|
1324
|
-
// Deliberately NOT changed: a resolver that throws, or returns `null` or `undefined`, still
|
|
1325
|
-
// propagates to this tool's top-level catch and exits **2** — *could not run*, which is what that
|
|
1326
|
-
// catch's own header says of anything reaching it, a defect in `doctor` included. Measured four
|
|
1327
|
-
// ways through `run()` before any of this was written, and three of the four were already right.
|
|
1328
|
-
// And the reachability is worth recording so nobody prices this above what it is: `options.discover`
|
|
1329
|
-
// is a test seam, every `resolveGovernor` return path sets `sentence`, so no shipped path reaches
|
|
1330
|
-
// the fallback at all. It is the most marginal of #182's three items.
|
|
1331
885
|
const answered = () => {
|
|
1332
886
|
if (typeof governor.state === "string") return `\`${governor.state}\``;
|
|
1333
887
|
if ("state" in governor) return `a \`state\` that is not a string (\`${typeof governor.state}\`)`;
|
|
@@ -1345,7 +899,8 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1345
899
|
report(
|
|
1346
900
|
"residence",
|
|
1347
901
|
"the governing-workspace checks did not run here — path slots, cross-field, packs, claims, " +
|
|
1348
|
-
"enforcement, provenance
|
|
902
|
+
"enforcement, provenance, the store reports, the legibility score and the always tier's " +
|
|
903
|
+
"report all read a policy layer this manifest " +
|
|
1349
904
|
"correctly does not carry. They run where the workspace resides, and a green pointer is " +
|
|
1350
905
|
"not a statement that the workspace it names is green",
|
|
1351
906
|
);
|
|
@@ -1362,14 +917,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1362
917
|
);
|
|
1363
918
|
}
|
|
1364
919
|
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
// report a false escape wherever the workspace's own path runs through a symlink — on macOS
|
|
1368
|
-
// `/tmp` is one, which is where the tests build their scratch workspaces.
|
|
920
|
+
// ---------------------------------------------------------------- path slots
|
|
921
|
+
// Both sides realpathed, or a workspace reached through a symlink (macOS `/tmp`) would seem to escape itself.
|
|
1369
922
|
let realDir = dir;
|
|
1370
923
|
try {
|
|
1371
924
|
realDir = fs.realpathSync(dir);
|
|
1372
|
-
} catch { /* the manifest read
|
|
925
|
+
} catch { /* the manifest read above reports a workspace that is not there */ }
|
|
1373
926
|
const inside = (target) => {
|
|
1374
927
|
const rel = path.relative(realDir, target);
|
|
1375
928
|
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
@@ -1386,19 +939,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1386
939
|
}
|
|
1387
940
|
if (kind === "file" && !stat.isFile()) fail("paths", `${label} points at \`${value}\`, which is not a file`);
|
|
1388
941
|
if (kind === "dir" && !stat.isDirectory()) fail("paths", `${label} points at \`${value}\`, which is not a directory`);
|
|
1389
|
-
// Containment is tested on the REAL path, so a symlink out of the workspace is seen for
|
|
1390
|
-
// what it is. `realpathSync` is only safe after the stat above proved the target exists.
|
|
1391
942
|
let real = target;
|
|
1392
943
|
try {
|
|
1393
944
|
real = fs.realpathSync(target);
|
|
1394
945
|
} catch { /* keep the lexical path; the stat already succeeded, so this is unusual */ }
|
|
1395
946
|
if (!inside(real)) {
|
|
1396
|
-
// Reported, never failed
|
|
1397
|
-
// reach for a shared document, and failing would make the tool wrong about a case it
|
|
1398
|
-
// cannot see. Two slots are *expected* to escape and are noted as unremarkable rather
|
|
1399
|
-
// than flagged: `constitution`, because a product's constitution normally lives with
|
|
1400
|
-
// the product, and `tree`, which names the repository the workspace sits inside and
|
|
1401
|
-
// would be pointing at the wrong thing if it did not escape.
|
|
947
|
+
// Reported, never failed: a workspace inside a larger repository may share a document outside it.
|
|
1402
948
|
const ordinary = {
|
|
1403
949
|
"slots.constitution": "a constitution usually lives with the product",
|
|
1404
950
|
tree: "a workspace usually sits inside the tree it describes",
|
|
@@ -1418,48 +964,24 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1418
964
|
}
|
|
1419
965
|
if (workspace.affordances) resolvePath(workspace.affordances, "file", "affordances");
|
|
1420
966
|
if (workspace.tree) resolvePath(workspace.tree, "dir", "tree");
|
|
1421
|
-
// Added with the `gates` key in Workspace Definition 2.1. It shipped for one checkpoint without
|
|
1422
|
-
// this line while ../spec/slots.md already promised "What `doctor` checks: that the path
|
|
1423
|
-
// resolves" — a manifest naming a policy file that did not exist validated GREEN. Caught at the
|
|
1424
|
-
// pre-commit checkpoint, and it is exactly the shape of
|
|
1425
|
-
// ../.portulan/memory/a-mandate-nothing-checks-is-already-broken.md: the mandate was written in
|
|
1426
|
-
// the same change as the key, and nothing looked.
|
|
1427
967
|
if (workspace.gates) resolvePath(workspace.gates, "file", "gates");
|
|
1428
|
-
// Added with the `memory` object in Workspace Definition 2.3, in the same change as the key —
|
|
1429
|
-
// because the `gates` line above records what happens when it is not: a manifest naming a file
|
|
1430
|
-
// that does not exist, validating GREEN, for one whole checkpoint.
|
|
1431
968
|
if (workspace.memory?.index?.path) resolvePath(workspace.memory.index.path, "file", "memory.index.path");
|
|
1432
969
|
(workspace.products ?? []).forEach((product, i) => {
|
|
1433
970
|
resolvePath(product.product, "file", `products[${i}].product`);
|
|
1434
971
|
if (product.affordances) resolvePath(product.affordances, "file", `products[${i}].affordances`);
|
|
1435
972
|
});
|
|
1436
|
-
// Reads `verify.recipes` directly, and that is deliberate now that `cli/recipe-set.mjs` is the one
|
|
1437
|
-
// carrier of the RUNNABLE set. This is not a reader of that set: it validates the workspace's own
|
|
1438
|
-
// DECLARATION — that each declared recipe's `doc` resolves — which is a claim about this manifest,
|
|
1439
|
-
// not about what CI will run. A composed recipe's `doc` resolves from its own pack root and is that
|
|
1440
|
-
// pack's to validate. The same distinction governs the `verify.default` cross-check below. Both are
|
|
1441
|
-
// exempt from the undeclared-reader sweep in `cli/recipe-set.live.test.mjs` for this reason, and
|
|
1442
|
-
// the exemption is named there too so the two cannot drift apart.
|
|
1443
973
|
(workspace.verify?.recipes ?? []).forEach((recipe, i) => {
|
|
1444
974
|
if (recipe.doc) resolvePath(recipe.doc, "file", `verify.recipes[${i}].doc`);
|
|
1445
975
|
});
|
|
1446
976
|
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
// Scoped to the workspace's OWN recipes on purpose, and it stays that way now that a pack's
|
|
1450
|
-
// recipes compose into the runnable set. `verify.default` is a bare slug (`$defs/slug`) and a
|
|
1451
|
-
// composed id is `<category>/<name>:<id>`, so a composed recipe can never be named here — widening
|
|
1452
|
-
// this check to the composed union would accept nothing new and would quietly suggest that naming
|
|
1453
|
-
// a pack's recipe as the default is a thing a manifest may do. Row 7 forbids exactly that: a
|
|
1454
|
-
// composed recipe may never become the workspace's `verify.default`.
|
|
977
|
+
// ---------------------------------------------------------------- cross-field
|
|
978
|
+
// Own recipes only: a composed recipe may never be the workspace's `verify.default`.
|
|
1455
979
|
const ids = (workspace.verify?.recipes ?? []).map((r) => r.id);
|
|
1456
980
|
if (!ids.includes(workspace.verify?.default)) {
|
|
1457
981
|
fail("cross", `verify.default names \`${workspace.verify?.default}\`, which is not among the declared recipes (${ids.join(", ") || "none"})`);
|
|
1458
982
|
}
|
|
1459
983
|
|
|
1460
|
-
//
|
|
1461
|
-
// objects, so two recipes sharing an id and differing anywhere else pass it — and then naming
|
|
1462
|
-
// that id resolves to whichever the reader happens to look at first.
|
|
984
|
+
// `uniqueItems` compares whole objects, so two entries sharing only an id pass the schema.
|
|
1463
985
|
const duplicates = (list, what) => {
|
|
1464
986
|
const seen = new Set();
|
|
1465
987
|
for (const id of list) {
|
|
@@ -1482,8 +1004,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1482
1004
|
|
|
1483
1005
|
(workspace.products ?? []).forEach((product, i) => {
|
|
1484
1006
|
for (const name of product.repos ?? []) {
|
|
1485
|
-
// Exact basename, never a substring: `portulan` must not be satisfied by
|
|
1486
|
-
// `portulan-internal.md`.
|
|
1487
1007
|
if (!cardNames.has(name)) {
|
|
1488
1008
|
fail("cross", `products[${i}].repos names \`${name}\`, for which there is no card in the repos slot`);
|
|
1489
1009
|
}
|
|
@@ -1493,14 +1013,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1493
1013
|
}
|
|
1494
1014
|
});
|
|
1495
1015
|
|
|
1496
|
-
//
|
|
1497
|
-
// of a repository that is present, so it always has an answer; `demo` and `portfolio` describe
|
|
1498
|
-
// repositories that are not beside them and genuinely do not.
|
|
1499
|
-
//
|
|
1500
|
-
// This constraint cannot live in the schema: a conditional dependency between two keys needs `if`/
|
|
1501
|
-
// `then` or `dependentRequired`, and neither is in the subset spec/README.md declares. So it is here,
|
|
1502
|
-
// and spec/slots.md states it as a rule the schema does not carry. That asymmetry is a real cost —
|
|
1503
|
-
// a constraint invisible to anyone reading the schema alone — and it is stated rather than hidden.
|
|
1016
|
+
// Constraints between keys live here: the subset has no `if`/`then` or `dependentRequired`.
|
|
1504
1017
|
if (workspace.kind === "repository" && !workspace.tree) {
|
|
1505
1018
|
fail(
|
|
1506
1019
|
"cross",
|
|
@@ -1510,15 +1023,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1510
1023
|
);
|
|
1511
1024
|
}
|
|
1512
1025
|
|
|
1513
|
-
//
|
|
1514
|
-
// a repository this workspace NAMES must not carry a governing workspace of its own. Visibility is
|
|
1515
|
-
// the whole difficulty — a portfolio workspace declares no `tree`, so it has no path to the
|
|
1516
|
-
// repositories it covers — and it is bought the way the packs resolver buys its own, with named
|
|
1517
|
-
// roots rather than discovery. `--repo-root` is to repo cards what `--pack-root` is to packs.
|
|
1518
|
-
//
|
|
1519
|
-
// What this does NOT do, stated because the asymmetry is real: the refusal runs from the naming
|
|
1520
|
-
// workspace outward. A repository carrying a full workspace cannot see a portfolio that claims it,
|
|
1521
|
-
// and nothing here changes that.
|
|
1026
|
+
// Checked from the naming side only: a repository carrying a full workspace cannot see a portfolio naming it.
|
|
1522
1027
|
const repoRoots = options.repoRoots ?? [];
|
|
1523
1028
|
if (cardNames.size) {
|
|
1524
1029
|
if (repoRoots.length === 0) {
|
|
@@ -1549,12 +1054,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1549
1054
|
);
|
|
1550
1055
|
continue;
|
|
1551
1056
|
}
|
|
1552
|
-
// A workspace
|
|
1553
|
-
// twice, not two managers. Customer zero is exactly this shape: `.portulan/` names the
|
|
1554
|
-
// card `portulan`, so the first run of this check against a root holding the Portulan
|
|
1555
|
-
// checkout would have refused the very arrangement the ruling permits. Compared on the
|
|
1556
|
-
// REAL path, because the root is commonly reached through a symlink and a lexical
|
|
1557
|
-
// comparison would miss the identity and print a false red.
|
|
1057
|
+
// A workspace naming its own repository finds itself here, compared on real paths: roots are often links.
|
|
1558
1058
|
let identical = false;
|
|
1559
1059
|
try {
|
|
1560
1060
|
identical = fs.realpathSync(found) === fs.realpathSync(manifestPath);
|
|
@@ -1581,20 +1081,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1581
1081
|
);
|
|
1582
1082
|
continue;
|
|
1583
1083
|
}
|
|
1584
|
-
//
|
|
1585
|
-
// run and this is somebody else's. Everything below follows from that one fact, and it
|
|
1586
|
-
// took two rounds to apply it consistently: this check refuses only what a manifest
|
|
1587
|
-
// clearly DECLARES, and reports every shape it merely fails to understand. A refusal
|
|
1588
|
-
// built on a field this run never validated would be a verdict about a workspace nobody
|
|
1589
|
-
// graded.
|
|
1590
|
-
//
|
|
1591
|
-
// The first miss was a pointer with no `governed_by.workspace`, refused for "naming
|
|
1592
|
-
// `undefined`" — a conflicting-governor verdict about a manifest naming no governor at
|
|
1593
|
-
// all. The second was its sibling one field over, and it survived the fix to the first:
|
|
1594
|
-
// a manifest with a MISSING or unrecognised `kind` fell into the governing branch and was
|
|
1595
|
-
// refused as `kind: "undefined"`. Both found by Copilot on #135, rounds 1 and 4, which is
|
|
1596
|
-
// the sibling class the 2026-07-27 ruling names — found here in the very change that
|
|
1597
|
-
// quotes that ruling twice.
|
|
1084
|
+
// Somebody else's manifest, read and never validated: only what it clearly declares is refused.
|
|
1598
1085
|
if (!KNOWN_KINDS.has(other.kind)) {
|
|
1599
1086
|
report(
|
|
1600
1087
|
"residence",
|
|
@@ -1612,22 +1099,8 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1612
1099
|
"residences carry full functionality, so nothing is lost either way",
|
|
1613
1100
|
);
|
|
1614
1101
|
} else if (typeof other.governed_by?.workspace !== "string" || other.governed_by.workspace.trim() === "") {
|
|
1615
|
-
//
|
|
1616
|
-
//
|
|
1617
|
-
// left. `=== undefined` catches ABSENT and not INVALID, so `""`, `null` and a
|
|
1618
|
-
// non-string fell through to the conflicting-governor branch below and were refused
|
|
1619
|
-
// for naming a governor they do not name: a **false red** about somebody else's
|
|
1620
|
-
// manifest, in the block whose own rule is *read, never validated*.
|
|
1621
|
-
//
|
|
1622
|
-
// Blank counts as no name, which is the rule `cli/discover.mjs` now enforces at the
|
|
1623
|
-
// other site of this same operation. **Non-blank string**, deliberately, not *usable
|
|
1624
|
-
// slug*: a padded or otherwise illegal name is still a name the manifest DECLARES, and
|
|
1625
|
-
// judging its legality would be validating somebody else's workspace — the one thing
|
|
1626
|
-
// this block forbids. So `" sleepy-panda "` stays a conflict rather than becoming
|
|
1627
|
-
// "no name", and a test pins that boundary. The schema would refuse `""` through `$defs/slug`, but the schema
|
|
1628
|
-
// never runs here, and that is the whole point of read-not-validated.
|
|
1629
|
-
// ([#141](https://github.com/sleepy-panda-srl/portulan/issues/141), found by Copilot
|
|
1630
|
-
// on #135 and filed rather than fixed there.)
|
|
1102
|
+
// Blank is no name, as in `resolveGovernor`; a non-blank name counts even if illegal, since
|
|
1103
|
+
// judging it would be validating somebody else's manifest.
|
|
1631
1104
|
const named = other.governed_by?.workspace;
|
|
1632
1105
|
report(
|
|
1633
1106
|
"residence",
|
|
@@ -1654,89 +1127,32 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1654
1127
|
}
|
|
1655
1128
|
}
|
|
1656
1129
|
|
|
1657
|
-
// Every persona a composed pack contributes, with the key a host would bind it by. Declared out
|
|
1658
|
-
// here rather than inside the branch below so the binding check has one shape to read: a workspace
|
|
1659
|
-
// composing no packs contributes no personas, which is an empty list and not a missing one.
|
|
1660
1130
|
const composedPersonas = [];
|
|
1661
1131
|
|
|
1662
|
-
// The roots the packs section resolved, carried to the enforcement section so the two read ONE
|
|
1663
|
-
// resolution rather than each running `rootPlan`. Two calls would be two answers whenever
|
|
1664
|
-
// discovery is in play — the host's plugin record can change between them, and more to the point a
|
|
1665
|
-
// second call is a second carrier of the precedence rule this file already imports rather than
|
|
1666
|
-
// respells. `null` means the packs section never ran, which is not the same as "it ran and found
|
|
1667
|
-
// nothing": the enforcement section keys on that difference below.
|
|
1668
1132
|
let resolvedPackRoots = null;
|
|
1669
1133
|
let packsUnresolved = 0;
|
|
1670
1134
|
|
|
1671
1135
|
if (workspace.packs?.length) {
|
|
1672
|
-
//
|
|
1673
|
-
// said so — "a declaration only" — because there was no format to validate one against and
|
|
1674
|
-
// nowhere to resolve a name. Both now exist. What is still NOT demonstrated here is resolution
|
|
1675
|
-
// from a FEED: the roots below are the tree this workspace already declares, and an adopter
|
|
1676
|
-
// installing a pack from a private marketplace resolves inside the installed plugin instead.
|
|
1677
|
-
// The resolver takes its roots as an argument for exactly that reason — the second case is the
|
|
1678
|
-
// same code path, not a parallel one — but this repository has not yet run it.
|
|
1679
|
-
// Through the SHARED precedence rule rather than a third spelling of it — see `rootPlan`.
|
|
1680
|
-
// `options.packRoots` keeps the meaning `compile`'s `packContributions` gives it — the FINAL
|
|
1681
|
-
// root set, replacing the derived one, so an explicitly empty array means search nowhere. The
|
|
1682
|
-
// three tools disagreed about this once and `../cli/compile.mjs`'s `namedRootsOption` records
|
|
1683
|
-
// what that cost; aligned here before a caller finds it the same way.
|
|
1684
|
-
// Built through `rootPlan` on EVERY path, including the named one. It used to short-circuit
|
|
1685
|
-
// here with a plan literal, which is how `options.packRoots` came to silently ignore
|
|
1686
|
-
// `discoverPacks`: a caller asking for both got one of them and no word about the other.
|
|
1687
|
-
// `namedGiven` is what lets the shared rule keep this option's meaning — the FINAL root set,
|
|
1688
|
-
// so an explicitly empty array still means search nowhere.
|
|
1136
|
+
// `packRoots`, when given, is the final root set, as `compile` reads it: an empty array searches nowhere.
|
|
1689
1137
|
const plan = rootPlan(dir, workspace, {
|
|
1690
1138
|
named: options.packRoots ?? [],
|
|
1691
1139
|
namedGiven: options.packRoots !== undefined && options.packRoots !== null,
|
|
1692
|
-
// `options` carries the injected env — the same one the pointer resolver above is given.
|
|
1693
|
-
// Without it this reader consulted the MACHINE the suite runs on, so the end-to-end pack
|
|
1694
|
-
// cases could not build a host and the file header's "every environment input injectable"
|
|
1695
|
-
// was true of one of its two readers.
|
|
1696
1140
|
discovery: () => discoverPackRoots(options),
|
|
1697
1141
|
forced: options.discoverPacks === true,
|
|
1698
1142
|
});
|
|
1699
1143
|
if (plan.refusal) throw new DoctorError(plan.refusal);
|
|
1700
|
-
// Asked-for discovery that could not look is could-not-run, never a green over an unread host.
|
|
1701
1144
|
if (plan.couldNotRun) throw new DoctorError(plan.couldNotRun);
|
|
1702
1145
|
const roots = plan.roots;
|
|
1703
1146
|
resolvedPackRoots = roots;
|
|
1704
|
-
// Which root answered, for each root that could answer. A union's whole contract is that a
|
|
1705
|
-
// tree-derived resolution is visible rather than silent, and a lookup is how a per-pack line
|
|
1706
|
-
// says so without re-deriving the first-match rule.
|
|
1707
1147
|
const originOf = new Map((plan.origins ?? []).map((o) => [o.root, o.origin]));
|
|
1708
|
-
//
|
|
1709
|
-
// question about origin rather than about count. A named root is the caller's claim; a root
|
|
1710
|
-
// derived from `tree` is the workspace's own claim; a **discovered** root is neither — it is a
|
|
1711
|
-
// fact about the machine, and this tool must not turn a fact about the machine into a verdict
|
|
1712
|
-
// about the repository. Keyed on `roots.length` alone, it did exactly that: measured
|
|
1713
|
-
// 2026-08-13, `doctor examples` exited **0** and `doctor --pack-root auto examples` exited
|
|
1714
|
-
// **1**, because `examples` derives no root and `tools/github` is not in this host's cache. The
|
|
1715
|
-
// count key let host state flip a workspace's verdict, and making discovery the unasked default
|
|
1716
|
-
// would have moved that flip onto the bare invocation — red on a laptop, green in CI.
|
|
1717
|
-
//
|
|
1718
|
-
// So: **discovery may flip a note to resolved, and may never turn a MISS into a FAIL.** A hit is
|
|
1719
|
-
// a different matter and is graded whatever its origin — a resolved-but-invalid copy fails with
|
|
1720
|
-
// its origin named, below, because that is a claim the pack's own files make about themselves.
|
|
1148
|
+
// A miss fails only under a root somebody claimed: discovery may resolve a pack, never fail one.
|
|
1721
1149
|
const claimed = roots.filter((root) => originOf.get(root) !== "discovered");
|
|
1722
1150
|
report("packs", `resolution root ${plan.source} — ${plan.why}`);
|
|
1723
1151
|
for (const name of workspace.packs) {
|
|
1724
1152
|
const found = resolvePack(name, roots);
|
|
1725
1153
|
if (!found.dir) {
|
|
1726
|
-
// A workspace with no `tree` has no root of its own to search, so its declared packs are
|
|
1727
|
-
// *unverifiable* rather than *wrong* — the same answer `tree`'s absence already gives
|
|
1728
|
-
// every other claim needing a tree. Where a claimed root exists, resolution was claimed
|
|
1729
|
-
// and a miss is a failure.
|
|
1730
1154
|
if (claimed.length === 0) {
|
|
1731
|
-
// Counted, never skipped: the pack was not graded, and `stats.unverifiable`'s
|
|
1732
|
-
// contract at the head of this file is that a check class which disappears without
|
|
1733
|
-
// saying so is the defect. A discovered-only miss is exactly that class.
|
|
1734
1155
|
stats.unverifiable += 1;
|
|
1735
|
-
// Two causes, two sentences. The claimed set is empty either because there was no
|
|
1736
|
-
// root at all — no `tree`, or discovery finding nothing — or because every root
|
|
1737
|
-
// there IS was discovered, which is a real search that nobody asked for and whose
|
|
1738
|
-
// failure is therefore nobody's claim. Saying "there is no packs root to search"
|
|
1739
|
-
// about the second is false: there was one, and it was looked in.
|
|
1740
1156
|
const why = roots.length
|
|
1741
1157
|
? `\`${name}\` was looked for under ${roots.length} discovered root(s) and is not there — reported rather than failed, because a root discovered on this host is not a claim this workspace made: ${plan.why}`
|
|
1742
1158
|
: `\`${name}\` cannot be resolved — there is no packs root to search: ${plan.why}`;
|
|
@@ -1744,10 +1160,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1744
1160
|
} else {
|
|
1745
1161
|
fail("packs", `\`${name}\` does not resolve — ${found.why}`);
|
|
1746
1162
|
}
|
|
1747
|
-
// Counted, not inspected: an unresolved pack's manifest is unreadable, so this tool
|
|
1748
|
-
// cannot know whether it would have contributed gates. The enforcement section says
|
|
1749
|
-
// only that — never that gates were missed, which would claim knowledge of a file it
|
|
1750
|
-
// could not open.
|
|
1751
1163
|
packsUnresolved += 1;
|
|
1752
1164
|
continue;
|
|
1753
1165
|
}
|
|
@@ -1758,13 +1170,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1758
1170
|
fail("packs", `\`${name}\` resolves to ${path.relative(dir, found.manifest)} but its manifest is unreadable — ${cause.message}`);
|
|
1759
1171
|
continue;
|
|
1760
1172
|
}
|
|
1761
|
-
// Refuse before grading, exactly as the workspace train does: a manifest written against a
|
|
1762
|
-
// contract this validator does not implement produces confident nonsense — a key added in a
|
|
1763
|
-
// later MINOR comes back as an unexpected property and the report blames the author for
|
|
1764
|
-
// using the spec correctly. This sentence is normative in spec/pack.schema.json, and for one
|
|
1765
|
-
// pre-commit checkpoint it was normative there and implemented nowhere, while `doctor`
|
|
1766
|
-
// printed "validates against Pack Definition 99.0" — a conformance claim about a contract it
|
|
1767
|
-
// had never seen.
|
|
1768
1173
|
const declaredPack = /^([0-9]+)\.([0-9]+)$/.exec(manifest?.portulan?.pack ?? "");
|
|
1769
1174
|
if (declaredPack) {
|
|
1770
1175
|
const [major, minor] = [Number(declaredPack[1]), Number(declaredPack[2])];
|
|
@@ -1790,11 +1195,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1790
1195
|
stats.packs += 1;
|
|
1791
1196
|
const c = manifest.contributes ?? {};
|
|
1792
1197
|
|
|
1793
|
-
// Milestone 7, the maintainer's ruling of 2026-08-03 on #150: the artifacts a pack ships are
|
|
1794
|
-
// OPENED and validated, not counted. Until this landed, `doctor` counted these paths into the
|
|
1795
|
-
// line below and read none of them — which is why spec/pack.schema.json could say an escaping
|
|
1796
|
-
// `contributes.skills` value was "still inert". It is not inert once something opens it, so
|
|
1797
|
-
// the containment check arrives in the same change as the opening.
|
|
1798
1198
|
const opened = validateContributions(found.dir, c, { fail, report, pack: name });
|
|
1799
1199
|
for (const one of opened.bindable) composedPersonas.push({ pack: name, ...one });
|
|
1800
1200
|
|
|
@@ -1804,19 +1204,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1804
1204
|
(opened.unreadableRoots ? `, ${opened.unreadableRoots} root(s) UNREAD — that count is over what was opened, not over what was declared` : "")
|
|
1805
1205
|
: null,
|
|
1806
1206
|
c.personas?.length ? `${opened.personas} of ${c.personas.length} persona(s) opened` : null,
|
|
1807
|
-
// Merged as of milestone 7's composition amendment. This said "declared and not
|
|
1808
|
-
// merged" while that was true, and became false the hour `cli/recipe-set.mjs` landed
|
|
1809
|
-
// — a code carrier of the same fact the schema note carries, which is why both moved
|
|
1810
|
-
// in one change. The ids are printed rather than counted: the namespace is what makes
|
|
1811
|
-
// a collision impossible, so a reader should be able to see it.
|
|
1812
1207
|
c.verify?.length
|
|
1813
1208
|
? `${c.verify.length} recipe(s) composed as ${c.verify.map((r) => `\`${composedId(name, r.id)}\``).join(", ")}`
|
|
1814
1209
|
: null,
|
|
1815
1210
|
c.gates?.length ? `${c.gates.length} gate fragment(s)` : null,
|
|
1816
1211
|
].filter(Boolean);
|
|
1817
|
-
//
|
|
1818
|
-
// reader cannot tell the origin from the invocation they typed. Adding it everywhere would
|
|
1819
|
-
// put a sentence on every line that says nothing the command did not already say.
|
|
1212
|
+
// Named only for a union, the one plan whose origin the invocation does not show.
|
|
1820
1213
|
const origin = plan.source === "union" ? originOf.get(found.root) ?? null : null;
|
|
1821
1214
|
const from =
|
|
1822
1215
|
origin === "discovered"
|
|
@@ -1829,15 +1222,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1829
1222
|
`\`${name}\` resolves${from} to ${path.relative(dir, found.dir)} and validates against Pack Definition ${manifest.portulan.pack} — contributes ${parts.length ? parts.join(", ") : "nothing"}`,
|
|
1830
1223
|
);
|
|
1831
1224
|
|
|
1832
|
-
//
|
|
1833
|
-
// first-match-wins and discovered roots come first, so where the host carries a copy of a
|
|
1834
|
-
// pack the tree also carries, the installed one answers and nothing said so. Silence about
|
|
1835
|
-
// the shadowed copy is the part that makes the failure unreproducible: the compiled artifact
|
|
1836
|
-
// moves, the rail reds, and the deciding input is a directory outside the repository.
|
|
1837
|
-
//
|
|
1838
|
-
// A REPORT, never a verdict — an installed pack shadowing a tree copy is a fact about the
|
|
1839
|
-
// machine, and this tool must not turn a fact about the machine into a verdict about the
|
|
1840
|
-
// repository (the boundary this file already states for discovered roots).
|
|
1225
|
+
// First match wins and discovered roots come first, so an installed copy can shadow the tree's.
|
|
1841
1226
|
{
|
|
1842
1227
|
const shadowed = shadowedCopy(name, origin, roots, (r) => originOf.get(r)) ?? { dir: null };
|
|
1843
1228
|
if (shadowed.dir) {
|
|
@@ -1845,23 +1230,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1845
1230
|
try {
|
|
1846
1231
|
other = JSON.parse(fs.readFileSync(shadowed.manifest, "utf8"));
|
|
1847
1232
|
} catch (cause) {
|
|
1848
|
-
// Could-not-compare, in so many words. A shadow we cannot read is not a shadow
|
|
1849
|
-
// we can call harmless, and saying nothing here would be the silence this whole
|
|
1850
|
-
// report exists to end.
|
|
1851
1233
|
report(
|
|
1852
1234
|
"packs",
|
|
1853
1235
|
`\`${name}\` is SHADOWED — the tree-derived root also carries it at ${path.relative(dir, shadowed.dir)}, and that copy could not be read (${cause.message}), so what differs could not be compared`,
|
|
1854
1236
|
);
|
|
1855
1237
|
}
|
|
1856
1238
|
if (other) {
|
|
1857
|
-
// **The comparison lives in `compile.mjs`, and this calls it.** It grew here,
|
|
1858
|
-
// and its two earlier spellings each claimed more than they compared — a
|
|
1859
|
-
// projection over `[id, tier, action]` that read a copy differing in `reason`
|
|
1860
|
-
// as agreeing, and a *byte-identical* claim `JSON.stringify` of a parsed value
|
|
1861
|
-
// cannot make. `compile` now has to answer the same question in order to refuse
|
|
1862
|
-
// a shadow it cannot honestly pick between (#316), and a second copy of a
|
|
1863
|
-
// comparison already corrected twice is exactly proposal `0020`'s defect. The
|
|
1864
|
-
// rationale for parsed-and-key-sorted moved with the code.
|
|
1865
1239
|
const differs = packDifferences(manifest, other);
|
|
1866
1240
|
report(
|
|
1867
1241
|
"packs",
|
|
@@ -1875,15 +1249,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1875
1249
|
}
|
|
1876
1250
|
}
|
|
1877
1251
|
|
|
1878
|
-
// Workspace Definition 2.3's and 2.4's conditional constraints, here for the same reason the
|
|
1879
|
-
// `repository`/`tree` pair above is: `dependentRequired` is not in the subset spec/README.md
|
|
1880
|
-
// declares, so the schema cannot carry any of them and ../spec/slots.md says so.
|
|
1881
|
-
//
|
|
1882
|
-
// The count went one → three in a single MINOR at 2.3 and three → five at 2.4. That is the
|
|
1883
|
-
// thing to watch rather than absorb quietly: every one of them is a rule the schema states
|
|
1884
|
-
// nowhere, so a workspace validated by some other tool against the published schema alone would
|
|
1885
|
-
// pass shapes this validator refuses. `../spec/README.md` carries the running count for exactly
|
|
1886
|
-
// that reason.
|
|
1887
1252
|
if (workspace.librarian && !workspace.slots?.memory) {
|
|
1888
1253
|
fail(
|
|
1889
1254
|
"cross",
|
|
@@ -1893,23 +1258,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1893
1258
|
);
|
|
1894
1259
|
}
|
|
1895
1260
|
|
|
1896
|
-
// Every number the schema can only type as `number`, checked here for the constraint the declared
|
|
1897
|
-
// subset cannot express. Without this, `record_days: 0` — or `-1`, or `"90"` — passes CI green and
|
|
1898
|
-
// fails at 06:00 on a Monday, when `cli/librarian.mjs` refuses it with exit 2 and nobody is
|
|
1899
|
-
// watching. A policy defect that only surfaces in an unattended run is the worst place for one, and
|
|
1900
|
-
// this is a `doctor` failure rather than a `librarian` one because it is a fact about the manifest.
|
|
1901
|
-
// Raised by Copilot on #81, suppressed half. The memory budgets are checked the same way, and were
|
|
1902
|
-
// missing it too — a sibling of the same class, fixed in the same stroke rather than left for the
|
|
1903
|
-
// next round to find.
|
|
1904
|
-
//
|
|
1905
|
-
// **The message stated a downstream behaviour this tree does not have**, and that is issue #84,
|
|
1906
|
-
// fixed here. It said a zero or negative value *reads as undeclared to the tool that consumes it* —
|
|
1907
|
-
// which was true of nothing: both consumers, `cli/index.mjs`'s `budgetNumber` and
|
|
1908
|
-
// `cli/librarian.mjs`'s `threshold`, refuse such a value outright with exit 2. What the sentence
|
|
1909
|
-
// described was the hazard those refusals exist to prevent, written in the present tense as though
|
|
1910
|
-
// it survived them. ../.portulan/dod.md condition 4 refuses a claim of a capability that does not
|
|
1911
|
-
// exist; this was the same error pointed the other way, a claim of a *defect* that does not exist,
|
|
1912
|
-
// inside a failure message — where a reader has the least room to check it.
|
|
1913
1261
|
const positive = (v) => typeof v === "number" && Number.isInteger(v) && v > 0;
|
|
1914
1262
|
for (const [where, value] of [
|
|
1915
1263
|
["memory.index.budget.lines", workspace.memory?.index?.budget?.lines],
|
|
@@ -1931,6 +1279,90 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1931
1279
|
}
|
|
1932
1280
|
}
|
|
1933
1281
|
|
|
1282
|
+
const alwaysTokens = workspace.context?.always?.budget?.tokens;
|
|
1283
|
+
if (alwaysTokens !== undefined && !positive(alwaysTokens)) {
|
|
1284
|
+
fail(
|
|
1285
|
+
"schema",
|
|
1286
|
+
`context.always.budget.tokens is ${JSON.stringify(alwaysTokens)}, which is not a positive integer. ` +
|
|
1287
|
+
"The declared keyword subset has no `minimum` and cannot say `integer`, so this is checked here",
|
|
1288
|
+
);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// Not an integer: 2.99 bytes per token is a real figure.
|
|
1292
|
+
const bytesPerToken = workspace.context?.ratio?.bytes_per_token;
|
|
1293
|
+
if (bytesPerToken !== undefined && !(Number.isFinite(bytesPerToken) && bytesPerToken >= 1)) {
|
|
1294
|
+
fail(
|
|
1295
|
+
"schema",
|
|
1296
|
+
`context.ratio.bytes_per_token is ${String(bytesPerToken)}, and it must be a finite number of at least 1. ` +
|
|
1297
|
+
"A token covers at least one byte, so a smaller figure is tokens per byte entered inverted. The " +
|
|
1298
|
+
"declared keyword subset has no `minimum`, so this is checked here",
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const cutoff = workspace.memory?.store?.budget?.cutoff;
|
|
1303
|
+
if (cutoff !== undefined) {
|
|
1304
|
+
const m = typeof cutoff === "string" ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(cutoff) : null;
|
|
1305
|
+
const at = new Date(0);
|
|
1306
|
+
if (m) at.setUTCFullYear(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
1307
|
+
if (!m || at.toISOString().slice(0, 10) !== cutoff) {
|
|
1308
|
+
fail(
|
|
1309
|
+
"schema",
|
|
1310
|
+
`memory.store.budget.cutoff is ${JSON.stringify(cutoff)}, which is not a real day written YYYY-MM-DD. ` +
|
|
1311
|
+
"The declared keyword subset's pattern admits a day that does not exist, so this is checked here",
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
if (workspace.memory?.store?.budget?.record_kilobytes === undefined) {
|
|
1315
|
+
fail(
|
|
1316
|
+
"cross",
|
|
1317
|
+
"`memory.store.budget.cutoff` is declared with no `record_kilobytes` beside it. A cutoff says which " +
|
|
1318
|
+
"records the per-record cap binds, so alone it configures nothing while reading as configured",
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// Held to the bounds of `readSpend` and `overflowingWrite` in ./ledger.mjs, so what passes here, the ledger takes.
|
|
1324
|
+
const readMultiplier = workspace.spend?.multipliers?.read;
|
|
1325
|
+
if (readMultiplier !== undefined && !(Number.isFinite(readMultiplier) && readMultiplier > 0 && readMultiplier <= 1)) {
|
|
1326
|
+
fail(
|
|
1327
|
+
"schema",
|
|
1328
|
+
`spend.multipliers.read is ${String(readMultiplier)}, and it must be a finite number above 0 and at most 1. A token ` +
|
|
1329
|
+
"read from cache costs something, and never more than the same token sent uncached. The declared keyword subset " +
|
|
1330
|
+
"has neither `minimum` nor `maximum`, so this is checked here",
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
for (const lifetime of ["5m", "1h"]) {
|
|
1334
|
+
const writeMultiplier = workspace.spend?.multipliers?.write?.[lifetime];
|
|
1335
|
+
if (writeMultiplier !== undefined && !(Number.isFinite(writeMultiplier) && writeMultiplier >= 1)) {
|
|
1336
|
+
fail(
|
|
1337
|
+
"schema",
|
|
1338
|
+
`spend.multipliers.write["${lifetime}"] is ${String(writeMultiplier)}, and it must be a finite number of at least 1. ` +
|
|
1339
|
+
"Writing a token to the cache costs at least what sending it uncached does. The declared keyword subset " +
|
|
1340
|
+
"has no `minimum`, so this is checked here",
|
|
1341
|
+
);
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
if (Number.isFinite(readMultiplier) && readMultiplier > 0) {
|
|
1345
|
+
for (const lifetime of ["5m", "1h"]) {
|
|
1346
|
+
const writeMultiplier = workspace.spend?.multipliers?.write?.[lifetime];
|
|
1347
|
+
if (Number.isFinite(writeMultiplier) && !Number.isFinite(writeMultiplier / readMultiplier)) {
|
|
1348
|
+
fail(
|
|
1349
|
+
"schema",
|
|
1350
|
+
`spend.multipliers.write["${lifetime}"] divided by spend.multipliers.read overflows, so no finite restart ` +
|
|
1351
|
+
"threshold can be computed from them. No keyword in the declared subset relates two figures, so this is " +
|
|
1352
|
+
"checked here",
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
const horizonRequests = workspace.spend?.horizon?.requests;
|
|
1358
|
+
if (horizonRequests !== undefined && !positive(horizonRequests)) {
|
|
1359
|
+
fail(
|
|
1360
|
+
"schema",
|
|
1361
|
+
`spend.horizon.requests is ${JSON.stringify(horizonRequests)}, which is not a positive integer. ` +
|
|
1362
|
+
"The declared keyword subset has no `minimum` and cannot say `integer`, so this is checked here",
|
|
1363
|
+
);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1934
1366
|
if (workspace.librarian?.staleness?.proposal_days !== undefined && !workspace.slots?.proposals) {
|
|
1935
1367
|
fail(
|
|
1936
1368
|
"cross",
|
|
@@ -1950,21 +1382,9 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1950
1382
|
);
|
|
1951
1383
|
}
|
|
1952
1384
|
|
|
1953
|
-
// An index inside the store it indexes is counted as a record by the retirement and provenance
|
|
1954
|
-
// walks below — sized into the KB figure and reported for stating no retirement condition. The
|
|
1955
|
-
// repair is a siting rule rather than an exemption by filename: teaching one name to hide from a
|
|
1956
|
-
// walk is a door any record could use, which is the fail-open class this repository has now found
|
|
1957
|
-
// eight of in its own scaffolding — and then this check turned out to be the ninth, in the very
|
|
1958
|
-
// containment test that was supposed to be the door-less alternative. See `isInside`.
|
|
1959
1385
|
if (workspace.memory?.index?.path && workspace.slots?.memory) {
|
|
1960
1386
|
const storeDir = path.resolve(dir, workspace.slots.memory);
|
|
1961
1387
|
const indexPath = path.resolve(dir, workspace.memory.index.path);
|
|
1962
|
-
// Imported rather than restated. This check and `index`'s had two copies of one containment
|
|
1963
|
-
// rule and both carried the same fail-open: `!path.relative(…).startsWith("..")` calls a file
|
|
1964
|
-
// named `..index.md` *outside* the directory it sits in, because a leading `..` in a filename
|
|
1965
|
-
// is not a traversal. Measured — such an index was written into the store, and this walk then
|
|
1966
|
-
// counted it as a record. Same reasoning as `parse`/`backends` from ./compile.mjs above: a
|
|
1967
|
-
// rule with two copies drifts, and these two drifted identically before either shipped.
|
|
1968
1388
|
if (isInside(storeDir, indexPath)) {
|
|
1969
1389
|
fail(
|
|
1970
1390
|
"cross",
|
|
@@ -1975,10 +1395,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
1975
1395
|
}
|
|
1976
1396
|
}
|
|
1977
1397
|
|
|
1978
|
-
// The handoff series gets both of the memory index's conditional requirements, added at 2.5, and
|
|
1979
|
-
// gets them here rather than in a second copy of the reasoning: they are the same two rules about
|
|
1980
|
-
// the same kind of object. What differs is only which walk would swallow a badly-sited index —
|
|
1981
|
-
// `doctor`'s store report there, `docs.sh`'s date correspondence here.
|
|
1982
1398
|
if (workspace.handoffs && !workspace.slots?.handoffs) {
|
|
1983
1399
|
fail(
|
|
1984
1400
|
"cross",
|
|
@@ -2002,10 +1418,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2002
1418
|
}
|
|
2003
1419
|
}
|
|
2004
1420
|
|
|
2005
|
-
// The scopes layer, at 2.6, gets the same pair for the third time. What differs is only which walk
|
|
2006
|
-
// would swallow a badly-sited index: the store report there, `docs.sh`'s date correspondence for the
|
|
2007
|
-
// handoffs, and here `index`'s orphan sweep — a generated file inside the layer would be read by the
|
|
2008
|
-
// one check whose whole job is telling an arrived location from a directory somebody made.
|
|
2009
1421
|
if (workspace.personas && !workspace.slots?.personas) {
|
|
2010
1422
|
fail(
|
|
2011
1423
|
"cross",
|
|
@@ -2028,33 +1440,13 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2028
1440
|
}
|
|
2029
1441
|
}
|
|
2030
1442
|
|
|
2031
|
-
//
|
|
1443
|
+
// ---------------------------------------------------------------- claims against the tree
|
|
2032
1444
|
const treeRoot = workspace.tree ? path.resolve(dir, workspace.tree) : null;
|
|
2033
1445
|
const claimTargets = [];
|
|
2034
1446
|
|
|
2035
|
-
//
|
|
2036
|
-
//
|
|
2037
|
-
// Row 7's fourth validation, and the one that was still owed after milestone 7 session 2 built the
|
|
2038
|
-
// other three. A persona is host-agnostic — its `tools:` are capability classes — and a **binding**
|
|
2039
|
-
// is the file that translates one into a host's own vocabulary. `core/personas/README.md` records
|
|
2040
|
-
// that the translation is **lossy**: of three charters exactly one survives it. So this check grades
|
|
2041
|
-
// agreement and never equivalence, and there are exactly two things it can honestly hold.
|
|
2042
|
-
//
|
|
2043
|
-
// **Absence is reported, never failed**, for the same reason a pointer's `governed_by` is reported
|
|
2044
|
-
// and never graded: whether a binding file exists is deployment state, and a persona with no binding
|
|
2045
|
-
// is a legitimate choice — an adopter may be on a host with no agent layer at all. This repository is
|
|
2046
|
-
// the exhibit rather than the embarrassment: the `checkpoints` supervisor is deliberately unbound,
|
|
2047
|
-
// because that pack's own `self-certify-a-checkpoint` gate makes a **fresh context** the mechanism,
|
|
2048
|
-
// and a subagent binding inside the implementer's session is precisely what it refuses.
|
|
2049
|
-
//
|
|
2050
|
-
// **A binding that contradicts its persona is failed**, because that one is wrong on every host: the
|
|
2051
|
-
// loader keys on frontmatter `name`, so a mismatch binds a persona nobody named, and a binding with
|
|
2052
|
-
// no `tools:` hands the role the host's whole toolbox — the context firewall's first part, gone.
|
|
1447
|
+
// ---------------------------------------------------------------- persona ↔ agent bindings
|
|
2053
1448
|
if (composedPersonas.length) {
|
|
2054
1449
|
if (!treeRoot) {
|
|
2055
|
-
// The same answer every other claim gets without a tree, and it is counted with them: a
|
|
2056
|
-
// workspace that declares no repository has nowhere for `agents/` to be, which is not the
|
|
2057
|
-
// same as having looked and found nothing.
|
|
2058
1450
|
stats.unverifiable += composedPersonas.length;
|
|
2059
1451
|
report(
|
|
2060
1452
|
"bindings",
|
|
@@ -2062,17 +1454,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2062
1454
|
`so there is no repository to look in. Unverifiable, not unbound`,
|
|
2063
1455
|
);
|
|
2064
1456
|
}
|
|
2065
|
-
//
|
|
2066
|
-
// Comparing a realpathed file against an unresolved root fails wherever the root itself sits
|
|
2067
|
-
// under a link — every macOS temporary directory does, `/var` being a link to `/private/var` —
|
|
2068
|
-
// so the guard below refused perfectly ordinary bindings until this line existed. That is the
|
|
2069
|
-
// rule `validateContributions` already applies to a pack directory, applied to a tree.
|
|
1457
|
+
// Both sides realpathed: a tree under a link (macOS `/var`) would otherwise refuse ordinary bindings.
|
|
2070
1458
|
let realTree = treeRoot;
|
|
2071
1459
|
try {
|
|
2072
1460
|
if (treeRoot) realTree = fs.realpathSync(treeRoot);
|
|
2073
1461
|
} catch {
|
|
2074
|
-
//
|
|
2075
|
-
// here means the containment test still runs, on the stricter of the two spellings.
|
|
1462
|
+
// Already reported by the claims checks; the declared path keeps the containment test running.
|
|
2076
1463
|
}
|
|
2077
1464
|
for (const persona of treeRoot ? composedPersonas : []) {
|
|
2078
1465
|
const rel = path.join(AGENT_DIR, `${persona.key}.md`);
|
|
@@ -2080,23 +1467,8 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2080
1467
|
const keyed = persona.keyedBy === "declaration" ? "" : " (keyed by filename — the persona declares no `name`)";
|
|
2081
1468
|
const file = path.join(treeRoot, rel);
|
|
2082
1469
|
|
|
2083
|
-
//
|
|
2084
|
-
//
|
|
2085
|
-
// Definition does not reach persona markdown — so a pack declaring `name: ../../poison`
|
|
2086
|
-
// aims this read anywhere on the machine. Before this guard it read the file, validated
|
|
2087
|
-
// it, and printed *names and tool grant agree*: a green over something no host would ever
|
|
2088
|
-
// load as an agent, with the outside file's own `name:` echoed into the report.
|
|
2089
|
-
//
|
|
2090
|
-
// Resolved and then compared, never matched as a pattern — the rule `validateContributions`
|
|
2091
|
-
// already applies to a pack's own paths, and the same one `plugin-lint` applies to a
|
|
2092
|
-
// symlinked `agents/` entry. A symlink is why the test is on the REAL path: a link inside
|
|
2093
|
-
// the tree pointing out of it passes any check on the spelling.
|
|
2094
|
-
// The LEXICAL test comes first, and it is not redundant with the resolved one below.
|
|
2095
|
-
// `path.join("agents", "../../poison.md")` is `../poison.md`, so a traversing name aims
|
|
2096
|
-
// outside the tree whether or not anything is there — and where nothing is, the resolved
|
|
2097
|
-
// test never runs and this check fell through to its *unbound* sentence, which named the
|
|
2098
|
-
// escaping path as "the one location a host loads agents from". A refusal that depends on
|
|
2099
|
-
// whether the attacker's file happens to exist is not a refusal.
|
|
1470
|
+
// The key is a pack's free text, so it is contained before it is opened: lexically here, since the
|
|
1471
|
+
// realpath test below is skipped where nothing exists.
|
|
2100
1472
|
if (!isInside(treeRoot, path.resolve(treeRoot, rel))) {
|
|
2101
1473
|
fail(
|
|
2102
1474
|
"bindings",
|
|
@@ -2114,8 +1486,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2114
1486
|
report("bindings", `${where}'s binding at \`${rel}\` could not be resolved — ${cause.code ?? cause.message}. Unread, not absent`);
|
|
2115
1487
|
continue;
|
|
2116
1488
|
}
|
|
2117
|
-
// ENOENT
|
|
2118
|
-
// renders the one sentence this check has for it.
|
|
1489
|
+
// ENOENT falls through to the read below, which reports the persona unbound.
|
|
2119
1490
|
}
|
|
2120
1491
|
if (real !== null && !isInside(realTree, real)) {
|
|
2121
1492
|
fail(
|
|
@@ -2134,12 +1505,9 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2134
1505
|
report(
|
|
2135
1506
|
"bindings",
|
|
2136
1507
|
`${where} has no host binding at \`${rel}\`${keyed} — reported, not failed: a persona without one is unbound rather than wrong, ` +
|
|
2137
|
-
"and this is
|
|
1508
|
+
"and this is where a Claude Code plugin's agents load from — a repository that is not a plugin loads its agents from `.claude/agents/`",
|
|
2138
1509
|
);
|
|
2139
1510
|
} else {
|
|
2140
|
-
// `existsSync` would answer *false* here and this would print as an absence. The
|
|
2141
|
-
// distinction is the same one `validateContributions` carries: only a missing file
|
|
2142
|
-
// means absent, and a question that could not be answered is not an answer.
|
|
2143
1511
|
report("bindings", `${where}'s binding at \`${rel}\` could not be read — ${cause.code ?? cause.message}. Unread, not absent`);
|
|
2144
1512
|
}
|
|
2145
1513
|
continue;
|
|
@@ -2170,13 +1538,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2170
1538
|
);
|
|
2171
1539
|
continue;
|
|
2172
1540
|
}
|
|
2173
|
-
//
|
|
2174
|
-
// string. Nothing here compares it against the persona's own contract — the persona's
|
|
2175
|
-
// `tools:` never reaches `bindable` — and by design, since `../docs/milestones/m07.md`
|
|
2176
|
-
// holds that a binding may licitly grant less, or other, than the persona describes. The
|
|
2177
|
-
// previous wording said the two *agree*, which reported a grant of `Read, Write, Bash` to a
|
|
2178
|
-
// persona whose contract is *does not write* as though it had been checked. Found by
|
|
2179
|
-
// milestone 7's close pass, forcing exactly that case green.
|
|
1541
|
+
// Declared, not compared: a binding may grant less, or other, than its persona describes.
|
|
2180
1542
|
report("bindings", `${where} is bound by \`${rel}\`${keyed} — ${BINDING_OK}`);
|
|
2181
1543
|
}
|
|
2182
1544
|
}
|
|
@@ -2189,8 +1551,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2189
1551
|
try {
|
|
2190
1552
|
source = fs.readFileSync(card, "utf8");
|
|
2191
1553
|
} catch (cause) {
|
|
2192
|
-
// Was a bare `continue`: an unreadable card dropped every claim it makes, silently,
|
|
2193
|
-
// and the run stayed green. A card that cannot be read is not a card with no claims.
|
|
2194
1554
|
fail("claims", `repos/${name}.md could not be read, so its claims went unchecked — ${cause.message}`);
|
|
2195
1555
|
continue;
|
|
2196
1556
|
}
|
|
@@ -2200,28 +1560,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2200
1560
|
}
|
|
2201
1561
|
}
|
|
2202
1562
|
|
|
2203
|
-
// The gate map's claim is extracted OUTSIDE the tree branch on purpose. Reading it only when a
|
|
2204
|
-
// tree exists is how a check class disappears without saying so — a no-`tree` workspace whose
|
|
2205
|
-
// gate map named a status check nothing reports used to produce no mention of it at all, while
|
|
2206
|
-
// spec/slots.md promised those claims were "counted and reported unverifiable, never skipped
|
|
2207
|
-
// silently". Found at the pre-commit checkpoint, in the paragraph that made the promise.
|
|
2208
1563
|
let claimedChecks = [];
|
|
2209
|
-
//
|
|
2210
|
-
// when there are no workflows to compare against, and the floor cross-check read it afterwards —
|
|
2211
|
-
// reporting that the gate map named nothing, about a gate map that named it plainly. That is the
|
|
2212
|
-
// SECOND consumer caught reading this array after the mutation; the first fix added a separate
|
|
2213
|
-
// flag for one consumer instead of making the array safe to read, so the next consumer inherited
|
|
2214
|
-
// the trap. Found by review on the pull request. Snapshot rather than flag this time, so a third
|
|
2215
|
-
// consumer inherits something true instead of something to remember.
|
|
1564
|
+
// A snapshot: `claimedChecks` is emptied below where there are no workflows to compare it with.
|
|
2216
1565
|
let declaredChecksInProse = [];
|
|
2217
1566
|
let gatesRead = false;
|
|
2218
|
-
//
|
|
2219
|
-
// no tree was declared, which is a different thing from a tree with no workflows in it.
|
|
1567
|
+
// Null without a tree, which differs from a tree with no workflows.
|
|
2220
1568
|
let workflowContexts = null;
|
|
2221
|
-
// Captured immediately after parsing and never reassigned. `claimedChecks` is emptied later when
|
|
2222
|
-
// there are no workflows to compare against, and keying the "names no check" report off the
|
|
2223
|
-
// mutated array made it fire for a gate map that had named one — two contradictory findings from
|
|
2224
|
-
// the same run.
|
|
2225
1569
|
let namedAnyCheck = false;
|
|
2226
1570
|
if (workspace.slots?.gates) {
|
|
2227
1571
|
try {
|
|
@@ -2230,8 +1574,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2230
1574
|
gatesRead = true;
|
|
2231
1575
|
namedAnyCheck = claimedChecks.length > 0;
|
|
2232
1576
|
} catch {
|
|
2233
|
-
//
|
|
2234
|
-
// which would trade a verdict this run had already reached for "could not run".
|
|
1577
|
+
// Already a `paths` failure; a throw here would trade the verdicts reached for exit 2.
|
|
2235
1578
|
}
|
|
2236
1579
|
}
|
|
2237
1580
|
|
|
@@ -2242,18 +1585,11 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2242
1585
|
report("claims", `${claim.where} states ${claim.what}, which contains nothing path-shaped to check — counted as unverifiable rather than passed over`);
|
|
2243
1586
|
continue;
|
|
2244
1587
|
}
|
|
2245
|
-
// A token pulled out of a command is never a *checked* claim, whether or not it happens to
|
|
2246
|
-
// resolve. Counting it in `stats.claims` when it resolved and in `unverifiable` when it did
|
|
2247
|
-
// not would make the accounting depend on incidental filesystem state and would overstate
|
|
2248
|
-
// what was verified — `docker run -v $(pwd)/state:/state` is not a claim about the tree
|
|
2249
|
-
// because `state` exists. It is unverifiable either way; only the finding differs.
|
|
2250
1588
|
const checkable = claim.severity !== "report";
|
|
2251
1589
|
if (checkable) stats.claims += 1;
|
|
2252
1590
|
else stats.unverifiable += 1;
|
|
2253
1591
|
|
|
2254
|
-
// Two bases
|
|
2255
|
-
// repository root while `../../core/` is written from the card. A claim that resolves
|
|
2256
|
-
// under either is a claim that points at something real, which is what the lint is for.
|
|
1592
|
+
// Two bases: a card writes some paths from the repository root and some from itself.
|
|
2257
1593
|
const resolved = [path.resolve(treeRoot, claim.target), path.resolve(claim.base, claim.target)];
|
|
2258
1594
|
if (!resolved.some((p) => fs.existsSync(p))) {
|
|
2259
1595
|
const message = `${claim.where} claims ${claim.what}, which exists nowhere in the tree`;
|
|
@@ -2265,10 +1601,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2265
1601
|
}
|
|
2266
1602
|
}
|
|
2267
1603
|
|
|
2268
|
-
// Read once and shared by two consumers: the gate map's prose claim about required checks,
|
|
2269
|
-
// and — since the floor backend arrived — the gate policy's `floor.checks`. It was nested
|
|
2270
|
-
// inside the first consumer's `if`, which would have left the second silently unable to
|
|
2271
|
-
// check anything whenever the prose named nothing.
|
|
2272
1604
|
const workflows = path.join(treeRoot, ".github", "workflows");
|
|
2273
1605
|
let jobs = [];
|
|
2274
1606
|
let found = false;
|
|
@@ -2283,9 +1615,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2283
1615
|
workflowContexts = { contexts, found };
|
|
2284
1616
|
|
|
2285
1617
|
if (claimedChecks.length) {
|
|
2286
|
-
// A job whose id is claimed but whose reported context differs is the interesting failure,
|
|
2287
|
-
// and it deserves its own message: "no job declares that" would send the reader hunting for
|
|
2288
|
-
// a missing job when the job is right there under a display name.
|
|
2289
1618
|
if (!found) {
|
|
2290
1619
|
stats.unverifiable += claimedChecks.length;
|
|
2291
1620
|
report("claims", `the gate map requires ${claimedChecks.length} status check(s) — ${claimedChecks.map((c) => `\`${c}\``).join(", ")} — and there are no workflows in the tree to report them — unverifiable`);
|
|
@@ -2321,12 +1650,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2321
1650
|
}
|
|
2322
1651
|
}
|
|
2323
1652
|
|
|
2324
|
-
// Finding no claim is a result, not an absence, and it has to say so. `doctor` recognises the
|
|
2325
|
-
// required-check claim by one convention — a table row whose first cell reads "required status
|
|
2326
|
-
// check" — and no template defines that label (see spec/slots.md). A gate map wording the row
|
|
2327
|
-
// differently produced **nothing at all**: no finding, no count, and a reader with a GREEN in
|
|
2328
|
-
// front of them had no way to tell "this workspace requires no check" from "I did not recognise
|
|
2329
|
-
// your table". Reported since the review that measured it.
|
|
2330
1653
|
if (gatesRead && !namedAnyCheck) {
|
|
2331
1654
|
report(
|
|
2332
1655
|
"claims",
|
|
@@ -2334,50 +1657,21 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2334
1657
|
);
|
|
2335
1658
|
}
|
|
2336
1659
|
|
|
2337
|
-
//
|
|
2338
|
-
//
|
|
2339
|
-
// `../docs/vision.md`: "the enforcement backends are per-host with an honest degradation
|
|
2340
|
-
// report." The compiler's accounting IS that report's data — every rule ends as compiled or
|
|
2341
|
-
// refused-with-a-reason, per backend — so this reads the backends rather than re-deriving what
|
|
2342
|
-
// they cover. Two implementations of one accounting is the drift this repository keeps finding,
|
|
2343
|
-
// and it is the reason `./gate.mjs` imports the matcher instead of copying it.
|
|
2344
|
-
//
|
|
2345
|
-
// Reported, never failed, with one exception below: nothing legislates a coverage floor, and
|
|
2346
|
-
// `doctor` does not enforce what nobody legislated — the same reasoning that made `retirement`
|
|
2347
|
-
// report-only. What it must not do is print a green that reads as "everything is enforced".
|
|
1660
|
+
// ---------------------------------------------------------------- the per-host degradation report
|
|
1661
|
+
// Coverage is reported, never failed: nothing legislates a minimum.
|
|
2348
1662
|
if (workspace.gates) {
|
|
2349
1663
|
const policyFile = path.resolve(dir, workspace.gates);
|
|
2350
1664
|
let parsed = null;
|
|
2351
1665
|
let columns = null;
|
|
2352
1666
|
let composed = null;
|
|
2353
1667
|
try {
|
|
2354
|
-
//
|
|
2355
|
-
// reason: a pack's fragment is then validated by exactly the code that validates a
|
|
2356
|
-
// hand-written rule, rather than by a second, laxer path. Through `compile.mjs`'s own
|
|
2357
|
-
// `packContributions` + `composeFragments` — this file already imports four functions from
|
|
2358
|
-
// there and mints none of them, because the last thing a policy needs is a second opinion
|
|
2359
|
-
// about what it contains.
|
|
2360
|
-
//
|
|
2361
|
-
// Why this reads the composed set at all: until 2026-08-13 it read `gates.json` alone, so
|
|
2362
|
-
// its report described a policy the workspace does not have. Measured on `d5a5eb7`, this
|
|
2363
|
-
// section printed `Claude Code: 10 of 23 rule(s) compiled → .claude/settings.json` while
|
|
2364
|
-
// that file carried **eleven** rules' compilation, the eleventh being a composed one — an
|
|
2365
|
-
// arrow naming an artifact beside a number that is not that artifact's. `../.portulan/dod.md`
|
|
2366
|
-
// condition 1 had already been rewritten from *declares* to **yields** for the recipe set,
|
|
2367
|
-
// for the identical reason; this is that word applied to the gate policy.
|
|
1668
|
+
// Composed before parsing, as `compile` does, so a pack's fragment is validated as a hand-written rule is.
|
|
2368
1669
|
const composition = packContributions(dir, ".", { packRoots: resolvedPackRoots ?? [] });
|
|
2369
1670
|
composed = composeFragments(JSON.parse(fs.readFileSync(policyFile, "utf8")), composition.contributions);
|
|
2370
1671
|
parsed = parse(composed.policy);
|
|
2371
|
-
//
|
|
2372
|
-
// parse cleanly and still be refused by a backend — a declared floor no rule reaches, or
|
|
2373
|
-
// gate rules that all compile to nothing — and with `backends()` outside the try, that
|
|
2374
|
-
// threw out of `inspect`, exited 2, and discarded every verdict this run had already
|
|
2375
|
-
// reached. Trading a verdict for "could not run" is the milestone-2 gates-file defect,
|
|
2376
|
-
// and it was sitting three lines under a comment naming it. Found at the pre-commit
|
|
2377
|
-
// checkpoint.
|
|
1672
|
+
// In the guard: a backend may refuse a policy that parses, and an unguarded throw discards every verdict.
|
|
2378
1673
|
columns = backends(parsed, { source: workspace.gates });
|
|
2379
1674
|
} catch (cause) {
|
|
2380
|
-
// A defect in the WORKSPACE's own policy, reported where every other finding is.
|
|
2381
1675
|
fail("enforcement", `${workspace.gates} — ${cause.message}`);
|
|
2382
1676
|
parsed = null;
|
|
2383
1677
|
}
|
|
@@ -2391,16 +1685,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2391
1685
|
);
|
|
2392
1686
|
}
|
|
2393
1687
|
|
|
2394
|
-
//
|
|
2395
|
-
// rule compiled by nothing is the system working; a `gated` or `prohibited` one is a
|
|
2396
|
-
// gate that exists only as a sentence, which is what this product is against.
|
|
1688
|
+
// Gated and prohibited rules only: an `auto` rule compiled by nothing is the system working.
|
|
2397
1689
|
const uncovered = parsed.rules.filter(
|
|
2398
1690
|
(rule) =>
|
|
2399
1691
|
(rule.tier === "gated" || rule.tier === "prohibited") &&
|
|
2400
1692
|
columns.every((c) => !c.compiled.some((x) => x.id === rule.id)),
|
|
2401
1693
|
);
|
|
2402
|
-
// "policy this workspace yields" rather than "declared policy": the count now includes
|
|
2403
|
-
// composed rows, so *declared* would be the word that made this sentence wrong.
|
|
2404
1694
|
report(
|
|
2405
1695
|
"enforcement",
|
|
2406
1696
|
uncovered.length
|
|
@@ -2408,15 +1698,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2408
1698
|
: "every gate this workspace yields is compiled by at least one backend — which says the policy is reachable, never that a host honours what was emitted",
|
|
2409
1699
|
);
|
|
2410
1700
|
|
|
2411
|
-
// Declared from composed, kept legible. A reader who cannot tell which rows came from a
|
|
2412
|
-
// pack cannot tell which half to go and change, and the composed half lives in a file this
|
|
2413
|
-
// workspace does not own.
|
|
2414
1701
|
if (composed.added?.length) {
|
|
2415
|
-
// Its own subject and its own members. "N of those" read as the uncovered gates named on
|
|
2416
|
-
// the line above — of which only one is composed — while the real antecedent was the
|
|
2417
|
-
// whole yield two lines up. And a count alone does not tell a reader which file to
|
|
2418
|
-
// change: with two gate-contributing packs, "composed from this workspace's packs" names
|
|
2419
|
-
// neither. `composed.added` already carries `{ pack, id }`, so the members are printed.
|
|
2420
1702
|
const members = composed.added.map((a) => `\`${a.id}\` (${a.pack})`).join(", ");
|
|
2421
1703
|
report(
|
|
2422
1704
|
"enforcement",
|
|
@@ -2424,10 +1706,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2424
1706
|
);
|
|
2425
1707
|
}
|
|
2426
1708
|
|
|
2427
|
-
// What this tool can KNOW, and no more. An unresolved pack's manifest is unreadable, so
|
|
2428
|
-
// whether it would have contributed gates is not a fact in reach — the sentence says the
|
|
2429
|
-
// totals may be incomplete, never that gates were missed. Severity is the packs section's
|
|
2430
|
-
// to set and it has already set it above, origin-keyed; this line does not re-litigate it.
|
|
2431
1709
|
if (packsUnresolved) {
|
|
2432
1710
|
report(
|
|
2433
1711
|
"enforcement",
|
|
@@ -2435,7 +1713,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2435
1713
|
);
|
|
2436
1714
|
}
|
|
2437
1715
|
|
|
2438
|
-
//
|
|
1716
|
+
// ---------------------------------------------------------------- the floor's declared contexts
|
|
2439
1717
|
for (const check of parsed.floor?.checks ?? []) {
|
|
2440
1718
|
if (check.integration_id === undefined) {
|
|
2441
1719
|
report(
|
|
@@ -2454,10 +1732,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2454
1732
|
continue;
|
|
2455
1733
|
}
|
|
2456
1734
|
stats.claims += 1;
|
|
2457
|
-
//
|
|
2458
|
-
// never reports blocks every pull request, and `enforce_admins` leaves nobody able
|
|
2459
|
-
// to force past it. That is proposal 0004's lesson, which cost a three-step rename
|
|
2460
|
-
// to work around after the fact — the highest-priced typo this tree can catch.
|
|
1735
|
+
// Failed, not reported: a required context that never reports blocks every pull request.
|
|
2461
1736
|
if (!workflowContexts.contexts.includes(check.context)) {
|
|
2462
1737
|
fail(
|
|
2463
1738
|
"enforcement",
|
|
@@ -2466,18 +1741,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2466
1741
|
}
|
|
2467
1742
|
}
|
|
2468
1743
|
|
|
2469
|
-
//
|
|
2470
|
-
// `floor`. Reported rather than failed — the prose row is a human summary and may
|
|
2471
|
-
// legitimately be shaped differently — but never left silent, because two files stating
|
|
2472
|
-
// one policy is this repository's signature defect, and the prose half is the half no
|
|
2473
|
-
// other check here reads for content.
|
|
2474
|
-
// NOT gated on the prose having named a check. It was for one round, which exempted the
|
|
2475
|
-
// worst divergence there is: a policy declaring required checks beside a gate-map row that
|
|
2476
|
-
// is missing or written in a shape `requiredCheckClaims` does not recognise. The generic
|
|
2477
|
-
// "names no required status check" note fires there, but it reports that nothing was
|
|
2478
|
-
// compared against the *tree* and says nothing about the policy carrying checks the prose
|
|
2479
|
-
// does not. A check that quietly skips its own extreme case is
|
|
2480
|
-
// `a-checker-must-refuse-what-it-cannot-check.md` again. Found by review, round 2.
|
|
1744
|
+
// Not gated on the prose naming a check: a missing or unrecognised row is the widest divergence.
|
|
2481
1745
|
if (parsed.floor && gatesRead) {
|
|
2482
1746
|
const declared = parsed.floor.checks.map((c) => c.context);
|
|
2483
1747
|
const missingFromProse = declared.filter((c) => !declaredChecksInProse.includes(c));
|
|
@@ -2494,33 +1758,11 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2494
1758
|
}
|
|
2495
1759
|
}
|
|
2496
1760
|
} else if (workspace.packs?.length) {
|
|
2497
|
-
//
|
|
2498
|
-
// the workspace declaring a `gates` slot, and composition does not care whether it does: a
|
|
2499
|
-
// workspace can compose a pack that contributes gates while declaring no policy of its own,
|
|
2500
|
-
// and then the guard above skips — silence, over a policy that has gate rules in it. That is
|
|
2501
|
-
// the same shape as the divergence this whole change repairs, displaced by one condition.
|
|
2502
|
-
//
|
|
2503
|
-
// Measured on `d5a5eb7`: `examples/` is exactly this workspace — it composes
|
|
2504
|
-
// `rituals/checkpoints`, which contributes two gate fragments, and declares no `gates` and
|
|
2505
|
-
// carries no `gates.json`. `compile --workspace examples --check` exits **2** there
|
|
2506
|
-
// (could-not-run: nothing to compose *into*), while `doctor` said nothing at all.
|
|
2507
|
-
//
|
|
2508
|
-
// **Report, never fail**, and the severity is not a preference: `../.portulan/verify/doctor.sh`
|
|
2509
|
-
// grades `examples` on every run as part of a required check, so a failure here would turn the
|
|
2510
|
-
// repository's own verify red over a workspace that is behaving as designed. It is also keyed
|
|
2511
|
-
// on contributions being non-empty rather than on `packs` being non-empty, so a workspace
|
|
2512
|
-
// composing packs that contribute no gates keeps producing zero enforcement findings — which
|
|
2513
|
-
// is what `./doctor.test.mjs`'s no-gates case pins.
|
|
1761
|
+
// Reported, never failed: gate fragments composed with no `gates` policy are a valid shape, only uncompiled.
|
|
2514
1762
|
try {
|
|
2515
1763
|
const { contributions } = packContributions(dir, ".", { packRoots: resolvedPackRoots ?? [] });
|
|
2516
1764
|
const fragments = contributions.reduce((n, c) => n + (c.fragments?.length ?? 0), 0);
|
|
2517
1765
|
if (fragments) {
|
|
2518
|
-
// `fragments` counts RESOLVED packs only — `packContributions` cannot read a manifest it
|
|
2519
|
-
// could not find — so where a declared pack did not resolve this number is a floor and
|
|
2520
|
-
// not a total. Saying "composes N from its packs" without that caveat implies the count
|
|
2521
|
-
// covers every declared pack, which is the same overstatement this whole change repairs
|
|
2522
|
-
// one branch up: a number claiming more than it measured. The gates-slot branch already
|
|
2523
|
-
// carried the caveat and this one did not. (Copilot, round 1 on #241.)
|
|
2524
1766
|
const incomplete = packsUnresolved
|
|
2525
1767
|
? `, and ${packsUnresolved} declared pack(s) did not resolve, so this count is a floor rather than a total`
|
|
2526
1768
|
: "";
|
|
@@ -2530,13 +1772,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2530
1772
|
);
|
|
2531
1773
|
}
|
|
2532
1774
|
} catch (cause) {
|
|
2533
|
-
//
|
|
2534
|
-
// saying it twice would be one defect wearing two severities.
|
|
1775
|
+
// The packs section owns that failure; failing it here too would count one defect twice.
|
|
2535
1776
|
report("enforcement", `the composed gate contributions could not be read — ${cause.message}`);
|
|
2536
1777
|
}
|
|
2537
1778
|
}
|
|
2538
1779
|
|
|
2539
|
-
//
|
|
1780
|
+
// ---------------------------------------------------------------- provenance and the store's growth
|
|
2540
1781
|
if (workspace.slots?.memory) {
|
|
2541
1782
|
const memoryDir = path.resolve(dir, workspace.slots.memory);
|
|
2542
1783
|
let entries = [];
|
|
@@ -2545,19 +1786,12 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2545
1786
|
} catch { /* the missing directory is already a `paths` failure */ }
|
|
2546
1787
|
|
|
2547
1788
|
for (const entry of entries) {
|
|
2548
|
-
// A record that is present and unreadable is a defect in the WORKSPACE — exit 1, reported
|
|
2549
|
-
// alongside everything else already found. Letting the read throw made it exit 2 and
|
|
2550
|
-
// discarded every finding the run had reached, which is the gates-file defect over again
|
|
2551
|
-
// one function down: the same shape, missed in the same change that fixed it.
|
|
2552
1789
|
let source;
|
|
2553
1790
|
try {
|
|
2554
1791
|
source = fs.readFileSync(path.join(memoryDir, entry), "utf8");
|
|
2555
1792
|
} catch (cause) {
|
|
2556
1793
|
stats.records += 1;
|
|
2557
|
-
//
|
|
2558
|
-
// `records` — so it is sized from stat, or `record(s)` and `KB` would disagree
|
|
2559
|
-
// about what a record is. Its content is still never assessed: this run is
|
|
2560
|
-
// already red on the read failure, and only the accounting stays consistent.
|
|
1794
|
+
// Sized from stat, so the record and KB counts agree; its content is never assessed.
|
|
2561
1795
|
try { stats.bytes += fs.statSync(path.join(memoryDir, entry)).size; } catch { /* unstattable: its size stays unknown */ }
|
|
2562
1796
|
stats.unassessed += 1;
|
|
2563
1797
|
fail("provenance", `${entry} could not be read — ${cause.message}`);
|
|
@@ -2570,11 +1804,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2570
1804
|
const isRule = type === "rule";
|
|
2571
1805
|
if (isRule) stats.rules += 1;
|
|
2572
1806
|
|
|
2573
|
-
//
|
|
2574
|
-
// condition its own `Retire when:` line states, so a record stating none is one no
|
|
2575
|
-
// condition will ever demote. Reported, never failed — no rule mandates the field, and
|
|
2576
|
-
// doctor does not enforce what nobody legislated. Checked ahead of the provenance
|
|
2577
|
-
// branches below, so a record with a provenance defect is still assessed for this.
|
|
1807
|
+
// Ahead of the provenance branches below, which `continue`, so every readable record is assessed.
|
|
2578
1808
|
if (!RETIRE_WHEN.test(source)) {
|
|
2579
1809
|
stats.unretirable += 1;
|
|
2580
1810
|
report(
|
|
@@ -2589,10 +1819,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2589
1819
|
continue;
|
|
2590
1820
|
}
|
|
2591
1821
|
if (!fields) {
|
|
2592
|
-
// Prose provenance. Mandatory only on a rule: thesis 4, proposal 0002 as adopted,
|
|
2593
|
-
// dod.md condition 3 and the task's own criterion are all rule-scoped, and having
|
|
2594
|
-
// doctor bind types nobody legislated for would be tooling enforcing a rule the
|
|
2595
|
-
// constitution does not state — backwards for this product.
|
|
2596
1822
|
const message = `${entry} (${type || "untyped"}) carries prose provenance rather than a link or a sealed stamp`;
|
|
2597
1823
|
isRule ? fail("provenance", message) : report("provenance", message);
|
|
2598
1824
|
continue;
|
|
@@ -2607,9 +1833,6 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2607
1833
|
}
|
|
2608
1834
|
}
|
|
2609
1835
|
|
|
2610
|
-
// Always emitted, including for zero records. A workspace with no memory yet is valid — the
|
|
2611
|
-
// minimality rule is explicit that a day-one workspace must pass — but "checked nothing" and
|
|
2612
|
-
// "checked everything and found nothing wrong" print identically unless one of them says so.
|
|
2613
1836
|
const proportion = stats.rules ? `${stats.sealed} of ${stats.rules}` : "0 of 0";
|
|
2614
1837
|
report(
|
|
2615
1838
|
"provenance",
|
|
@@ -2620,12 +1843,7 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2620
1843
|
". The form is checked, never the truth: a fabricated stamp passes exactly as a real one does",
|
|
2621
1844
|
);
|
|
2622
1845
|
|
|
2623
|
-
//
|
|
2624
|
-
// growing and a store quietly fine print identically unless something says which. Size and
|
|
2625
|
-
// count are what doctor can honestly see — it reads the tree and never git, and in a fresh
|
|
2626
|
-
// clone every file's mtime is checkout time, so an age report from here would be fabrication.
|
|
2627
|
-
// Staleness is ./librarian.mjs's, the scheduled pass, which may legitimately ask git — and which
|
|
2628
|
-
// is not a verify recipe precisely so that asking is safe. Built at milestone 5.
|
|
1846
|
+
// Size and count only: in a fresh clone every file's mtime is the checkout's, so no age is read here.
|
|
2629
1847
|
report(
|
|
2630
1848
|
"retirement",
|
|
2631
1849
|
stats.records
|
|
@@ -2638,12 +1856,10 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2638
1856
|
(stats.unassessed
|
|
2639
1857
|
? `; ${stats.unassessed} unreadable and never assessed`
|
|
2640
1858
|
: "") +
|
|
2641
|
-
". Size and count only: ages
|
|
1859
|
+
". Size and count only: memory ages are not measured here, so staleness belongs to `cli/librarian.mjs` — the scheduled pass, which may ask git and does"
|
|
2642
1860
|
: "no memory records — nothing measured, nothing awaiting retirement",
|
|
2643
1861
|
);
|
|
2644
1862
|
|
|
2645
|
-
// Always emitted, for the third time on this page and for the same reason: a workspace nobody
|
|
2646
|
-
// scored and a workspace that scored well print identically unless one of them says which.
|
|
2647
1863
|
const score = legibility(workspace, dir);
|
|
2648
1864
|
const missed = score.dimensions.filter((d) => d.applicable && !d.met);
|
|
2649
1865
|
const skipped = score.dimensions.filter((d) => !d.applicable);
|
|
@@ -2656,38 +1872,72 @@ export async function inspect(workspaceDir, options = {}) {
|
|
|
2656
1872
|
"because a score that could fail a workspace would make the verdict a function of how much prose somebody wrote",
|
|
2657
1873
|
);
|
|
2658
1874
|
|
|
1875
|
+
const always = alwaysLine(dir, workspace);
|
|
1876
|
+
(always.verdict === "over" || always.verdict === "unjudged" ? fail : report)("context", always.line);
|
|
1877
|
+
|
|
1878
|
+
// Never a verdict: failing it would turn red every workspace drafted before the new form.
|
|
1879
|
+
let form;
|
|
1880
|
+
try {
|
|
1881
|
+
form = formLine(dir, workspace, { over: always.verdict === "over" });
|
|
1882
|
+
} catch (error) {
|
|
1883
|
+
form = `not read — ${error.message}`;
|
|
1884
|
+
}
|
|
1885
|
+
report("form", form);
|
|
1886
|
+
|
|
1887
|
+
let sessions;
|
|
1888
|
+
try {
|
|
1889
|
+
sessions = sessionsLine(workspace);
|
|
1890
|
+
} catch (error) {
|
|
1891
|
+
sessions = `not read — ${error.message}`;
|
|
1892
|
+
}
|
|
1893
|
+
report("sessions", sessions);
|
|
1894
|
+
|
|
2659
1895
|
return { dir, workspace, findings, stats };
|
|
2660
1896
|
}
|
|
2661
1897
|
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
1898
|
+
/** Optional usage accounting: a data failure never changes the workspace-validation verdict. */
|
|
1899
|
+
export async function workspaceUsageReport(workspaceDir, workspace, options = {}) {
|
|
1900
|
+
const unmeasured = (why) => [` note spend unmeasured — ${why}`];
|
|
1901
|
+
if (typeof workspace?.tree !== "string" || workspace.tree.trim() === "") return unmeasured("the workspace declares no tree to attribute usage to");
|
|
1902
|
+
try {
|
|
1903
|
+
const repo = path.resolve(workspaceDir, workspace.tree);
|
|
1904
|
+
const roots = worktrees(repo);
|
|
1905
|
+
if (roots === null || roots.length === 0) return unmeasured("the declared tree has no Git worktree ownership");
|
|
1906
|
+
const branch = execFileSync("git", ["-C", repo, "rev-parse", "--abbrev-ref", "HEAD"], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 10_000 }).trim();
|
|
1907
|
+
if (!branch || branch === "HEAD") return unmeasured("the declared tree has no current named Git branch");
|
|
1908
|
+
const host = hostPaths(options.env ?? process.env, options.home);
|
|
1909
|
+
if (host.why !== undefined) return unmeasured("the configured usage-record location is ambiguous");
|
|
1910
|
+
const report = ledger({ ...host, roots, branch, lines: linesChanged(repo, branch), spend: readSpend(workspace.spend, "this workspace") });
|
|
1911
|
+
const lines = [];
|
|
1912
|
+
if (report.collected.malformed) lines.push(` note spend partial data — ${report.collected.malformed} malformed usage line(s) were ignored`);
|
|
1913
|
+
if (report.figures.main.requests + report.figures.subagents.requests === 0 && report.local.receipts === 0) {
|
|
1914
|
+
return [...lines, ...unmeasured("no host requests or local receipts on this branch are recorded here")];
|
|
1915
|
+
}
|
|
1916
|
+
print(report, (line) => lines.push(line));
|
|
1917
|
+
return lines;
|
|
1918
|
+
} catch {
|
|
1919
|
+
// Parser messages can quote a private record. Never print them or a raw usage line.
|
|
1920
|
+
return unmeasured("optional local usage data could not be read or parsed; no totals were reported");
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
// ---------------------------------------------------------------- the command
|
|
2665
1925
|
|
|
2666
1926
|
const ICON = { fail: "FAIL ", report: "note " };
|
|
2667
1927
|
|
|
2668
|
-
/** Relative when that is shorter to read, absolute when the relative form is a ladder of `../`. */
|
|
2669
1928
|
function display(target) {
|
|
2670
1929
|
const rel = path.relative(process.cwd(), path.resolve(target));
|
|
2671
1930
|
return rel === "" ? "." : rel.startsWith("..") ? path.resolve(target) : rel;
|
|
2672
1931
|
}
|
|
2673
1932
|
|
|
2674
|
-
/**
|
|
2675
|
-
/**
|
|
2676
|
-
* The help screen. An explicit `--help` exits **0** and prints to stdout — `./portulan.mjs` states that
|
|
2677
|
-
* contract ("asking for help is a request, and it succeeded") and five sibling tools already keep it;
|
|
2678
|
-
* this tool did not, and what a user saw instead was the no-arguments usage line on **stderr** at exit
|
|
2679
|
-
* 2, which is the could-not-run fallback rather than an answered question. Added at milestone 7's
|
|
2680
|
-
* follow-up, with `compile` and `index`, after the close pass found three nonconformers where its own
|
|
2681
|
-
* handoff had said two.
|
|
2682
|
-
*
|
|
2683
|
-
* `../.portulan/dod.md` condition 4 binds this text: every flag below exists in the parser above it.
|
|
2684
|
-
*/
|
|
1933
|
+
/** The help screen: every flag it names must be one `run` parses. */
|
|
2685
1934
|
function usage() {
|
|
2686
1935
|
return [
|
|
2687
1936
|
"portulan doctor — validate a workspace against the Workspace Definition",
|
|
2688
1937
|
"",
|
|
2689
|
-
" portulan doctor [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
|
|
1938
|
+
" portulan doctor [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]",
|
|
2690
1939
|
"",
|
|
1940
|
+
" --static skip local usage records; validation and plugin discovery remain unchanged.",
|
|
2691
1941
|
" --pack-root where declared packs are resolved from; `auto` discovers the host's plugin cache.",
|
|
2692
1942
|
" A named root REPLACES every other source. A directory actually named `auto` is `./auto`",
|
|
2693
1943
|
" --repo-root where the repositories this workspace's cards NAME are checked out, so their",
|
|
@@ -2701,70 +1951,20 @@ function usage() {
|
|
|
2701
1951
|
].join("\n");
|
|
2702
1952
|
}
|
|
2703
1953
|
|
|
1954
|
+
/** Runs against each workspace directory and returns the exit code; never throws. */
|
|
2704
1955
|
export async function run(argv, options = {}) {
|
|
2705
1956
|
const say = options.quiet ? () => {} : (line = "") => process.stdout.write(`${line}\n`);
|
|
2706
|
-
//
|
|
2707
|
-
// complaint about the rest of the command line. `./portulan.mjs` states the contract: an
|
|
2708
|
-
// explicit `--help` exits 0, because asking for help is a request and it succeeded.
|
|
1957
|
+
// Before any other argument is judged, so help is never outranked by a complaint about the rest.
|
|
2709
1958
|
if (argv.includes("--help") || argv.includes("-h")) {
|
|
2710
1959
|
say(usage());
|
|
2711
1960
|
return 0;
|
|
2712
1961
|
}
|
|
2713
1962
|
try {
|
|
2714
|
-
|
|
2715
|
-
// rather than being searched ahead of it. `inspect` has read `options.packRoots` since session 0
|
|
2716
|
-
// and no caller set it, so the path shaped for an adopter resolving from an installed feed had no
|
|
2717
|
-
// way in from a command line. Replacement rather than precedence so that "it resolved from the
|
|
2718
|
-
// feed" cannot be satisfied by a copy in the local tree at all — the three tools disagreed about
|
|
2719
|
-
// this once, and `../cli/compile.mjs`'s `namedRootsOption` carries what that cost.
|
|
2720
|
-
//
|
|
2721
|
-
// **A PACK root IS discovered by default as of 2026-08-13, and the boundary moved rather than
|
|
2722
|
-
// being dropped.** `./discover.mjs` reads the host's installed-plugin record, and this tool uses
|
|
2723
|
-
// it for two things: dereferencing a POINTER's `governed_by`, and resolving pack roots (#123).
|
|
2724
|
-
//
|
|
2725
|
-
// This passage read *"discovered only when asked … what is deliberately not taken is a DEFAULT"*,
|
|
2726
|
-
// and defended it as a boundary. Row 7's clause is that discovery makes `--pack-root` **optional
|
|
2727
|
-
// where discovery finds a root**, and it was not: measured on the workspace `init` drafts by
|
|
2728
|
-
// default plus one pack of the adopter's own, `doctor` exited **1** with no flag and **0** under
|
|
2729
|
-
// `auto`. The maintainer ruled a behaviour change rather than a row amendment; a fresh supervisor
|
|
2730
|
-
// graded that sound from `../docs/vision.md`, which defines this tool as a **per-host capability
|
|
2731
|
-
// report** — answering about the host is what it is for.
|
|
2732
|
-
//
|
|
2733
|
-
// **The boundary it replaces, sharpened, is: a verdict about the *repository* must not depend on
|
|
2734
|
-
// the machine.** Three things hold it, and none of them is the absence of a default:
|
|
2735
|
-
//
|
|
2736
|
-
// 1. **The pins.** Six required invocations name their root, a named root REPLACES every other
|
|
2737
|
-
// source, and `./pinned-roots.live.test.mjs` reds if one drops it or a seventh joins unpinned.
|
|
2738
|
-
// 2. **The note-vs-fail keying below is on ORIGIN**, so a root nobody claimed can turn an
|
|
2739
|
-
// unresolved pack from a note into a resolution, and never into a failure.
|
|
2740
|
-
// 3. **Never silently.** The `resolution root` note names the source, and each pack's line names
|
|
2741
|
-
// which root answered and whether it was discovered or derived.
|
|
2742
|
-
//
|
|
2743
|
-
// The row's own direction is unchanged and is what the arms above implement: **add a root where
|
|
2744
|
-
// none was named; never replace one that was.**
|
|
2745
|
-
//
|
|
2746
|
-
// `--repo-root <dir>`, repeatable, is its sibling and is deliberately shaped the same way: the
|
|
2747
|
-
// directories under which the repositories a workspace's cards NAME are checked out, so the
|
|
2748
|
-
// residence ruling's third refusal has somewhere to look. Named rather than discovered for the
|
|
2749
|
-
// same reason the pack roots are — a host's plugin cache holds installed plugins, and a
|
|
2750
|
-
// developer's checkouts are not in it, so there is nothing there to find. The two lists are
|
|
2751
|
-
// separate because they answer different questions and a single `--root` would make a packs
|
|
2752
|
-
// root and a checkout root interchangeable, which they are not: one holds `category/name` pack
|
|
2753
|
-
// directories, the other holds repositories.
|
|
1963
|
+
let staticMode = false;
|
|
2754
1964
|
const namedRoots = [];
|
|
2755
1965
|
let discoverPacks = false;
|
|
2756
1966
|
const repoRoots = [];
|
|
2757
1967
|
const dirs = [];
|
|
2758
|
-
// ONE validator for both flags, and it is one because a reviewer caught the comment claiming so
|
|
2759
|
-
// while `--pack-root` still carried its own copy (Copilot, round 1 on #135). Two inputs have to
|
|
2760
|
-
// fail closed here and each has a real incident behind it: a root that is **not there** is a
|
|
2761
|
-
// filesystem fact rather than a pack that failed to resolve, and reporting it as the latter sends
|
|
2762
|
-
// a reader to the one file that is not at fault; and existence is not enough, because
|
|
2763
|
-
// `existsSync` is true for a FILE, so one was accepted and every later resolution failure was
|
|
2764
|
-
// misattributed to the workspace. Three tools take `--pack-root` and only `index` had the
|
|
2765
|
-
// directory check — the sibling class the 2026-07-27 ruling names, found by Copilot on #117 round
|
|
2766
|
-
// 7, one round after the same defect was fixed in `index` alone. `what` keeps each flag's message
|
|
2767
|
-
// specific: sharing the check must not cost the reader the sentence that says what a root is FOR.
|
|
2768
1968
|
const directoryRoot = (flag, value, what) => {
|
|
2769
1969
|
if (value === undefined || value.startsWith("-")) throw new DoctorError(`${flag} needs a directory${what.keyword ?? ""}`);
|
|
2770
1970
|
let stat = null;
|
|
@@ -2780,8 +1980,6 @@ export async function run(argv, options = {}) {
|
|
|
2780
1980
|
}
|
|
2781
1981
|
return path.resolve(value);
|
|
2782
1982
|
};
|
|
2783
|
-
// `keyword` is PACK_ROOT's alone: `auto` discovers a plugin cache, and there is no such thing
|
|
2784
|
-
// for a repository checkout, so offering it on `--repo-root` would advertise a refused value.
|
|
2785
1983
|
const PACK_ROOT = {
|
|
2786
1984
|
unresolvable: "a pack unresolvable",
|
|
2787
1985
|
holds: "packs",
|
|
@@ -2789,37 +1987,27 @@ export async function run(argv, options = {}) {
|
|
|
2789
1987
|
};
|
|
2790
1988
|
const REPO_ROOT = { unresolvable: "a repository ungoverned", holds: "repositories" };
|
|
2791
1989
|
for (let i = 0; i < argv.length; i += 1) {
|
|
2792
|
-
if (argv[i] === "--
|
|
1990
|
+
if (argv[i] === "--static") {
|
|
1991
|
+
staticMode = true;
|
|
1992
|
+
} else if (argv[i] === "--repo-root") {
|
|
2793
1993
|
repoRoots.push(directoryRoot("--repo-root", argv[i + 1], REPO_ROOT));
|
|
2794
1994
|
i += 1;
|
|
2795
1995
|
} else if (argv[i] === "--pack-root") {
|
|
2796
|
-
// The keyword, on the raw argument and before `directoryRoot` resolves anything.
|
|
2797
1996
|
if (argv[i + 1] === AUTO) discoverPacks = true;
|
|
2798
1997
|
else namedRoots.push(directoryRoot("--pack-root", argv[i + 1], PACK_ROOT));
|
|
2799
1998
|
i += 1;
|
|
2800
1999
|
} else if (!argv[i].startsWith("-")) dirs.push(argv[i]);
|
|
2801
2000
|
else {
|
|
2802
|
-
// **Refused loudly, where it used to be swallowed.** This arm read `else if
|
|
2803
|
-
// (!startsWith("-")) dirs.push(...)` with no `else` at all, so any `-`-prefixed
|
|
2804
|
-
// argument this parser did not recognise was silently discarded. Measured on
|
|
2805
|
-
// `d5a5eb7`: `doctor --repo-rot /nonexistent` dropped the misspelled flag and graded
|
|
2806
|
-
// `/nonexistent` **as a workspace**, red for a reason that had nothing to do with what
|
|
2807
|
-
// was asked. A typo in a flag is could-not-run, never a verdict — and #155's lesson is
|
|
2808
|
-
// that the refusal names both real invocations, because a user who typed one of them
|
|
2809
|
-
// cannot act on advice about the other.
|
|
2810
2001
|
throw new DoctorError(
|
|
2811
2002
|
`unknown argument \`${argv[i]}\` — run \`portulan doctor --help\` or \`node cli/doctor.mjs --help\` for the flags this tool takes`,
|
|
2812
2003
|
);
|
|
2813
2004
|
}
|
|
2814
2005
|
}
|
|
2815
|
-
// Refused at PARSE time, which is where an exit 2 belongs: before a workspace is read, so the
|
|
2816
|
-
// refusal cannot be mistaken for a verdict about one. `resolutionRoots` refuses the same
|
|
2817
|
-
// combination for callers that never parsed anything; both ask the same exported predicate.
|
|
2818
2006
|
const bothAsked = namedWithAuto(namedRoots, discoverPacks);
|
|
2819
2007
|
if (bothAsked) throw new DoctorError(bothAsked);
|
|
2820
2008
|
if (dirs.length === 0) {
|
|
2821
2009
|
if (!options.quiet) {
|
|
2822
|
-
process.stderr.write("usage: node cli/doctor.mjs [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
|
|
2010
|
+
process.stderr.write("usage: node cli/doctor.mjs [--static] [--pack-root <dir>|auto]... [--repo-root <dir>]... <workspace-dir> [<workspace-dir> ...]\n");
|
|
2823
2011
|
}
|
|
2824
2012
|
return 2;
|
|
2825
2013
|
}
|
|
@@ -2831,7 +2019,7 @@ export async function run(argv, options = {}) {
|
|
|
2831
2019
|
...(discoverPacks ? { discoverPacks } : {}),
|
|
2832
2020
|
...(repoRoots.length ? { repoRoots } : {}),
|
|
2833
2021
|
};
|
|
2834
|
-
const { findings, stats } = await inspect(dir, { ...options, ...roots });
|
|
2022
|
+
const { findings, stats, workspace } = await inspect(dir, { ...options, ...roots });
|
|
2835
2023
|
const bad = findings.filter((f) => f.severity === "fail");
|
|
2836
2024
|
say(display(dir));
|
|
2837
2025
|
for (const f of findings) say(` ${ICON[f.severity]} ${f.check.padEnd(10)} ${f.message}`);
|
|
@@ -2840,14 +2028,15 @@ export async function run(argv, options = {}) {
|
|
|
2840
2028
|
`${findings.length - bad.length} note(s), ${stats.claims} claim(s) checked` +
|
|
2841
2029
|
(stats.unverifiable ? `, ${stats.unverifiable} unverifiable` : ""),
|
|
2842
2030
|
);
|
|
2031
|
+
if (!staticMode && !options.quiet && !bad.length && workspace) {
|
|
2032
|
+
for (const line of await workspaceUsageReport(path.resolve(dir), workspace, options)) say(line);
|
|
2033
|
+
}
|
|
2843
2034
|
say();
|
|
2844
2035
|
if (bad.length) failed += 1;
|
|
2845
2036
|
}
|
|
2846
2037
|
return failed ? 1 : 0;
|
|
2847
2038
|
} catch (error) {
|
|
2848
|
-
// Anything reaching here
|
|
2849
|
-
// itself. Exit 2. Reporting 1 would assert a verdict about the workspace that was
|
|
2850
|
-
// never reached, and reporting 0 would be the fail-open this whole tool exists against.
|
|
2039
|
+
// Anything reaching here could not be judged, a defect in doctor included: 2, never a verdict.
|
|
2851
2040
|
if (!options.quiet) {
|
|
2852
2041
|
process.stderr.write(`doctor: ${error instanceof DoctorError ? error.message : `unanticipated failure — ${error.stack ?? error}`}\n`);
|
|
2853
2042
|
}
|