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