@rightkit/release 0.6.6 → 0.6.8

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.
Files changed (46) hide show
  1. package/README.md +132 -10
  2. package/admission-files.mjs +3 -2
  3. package/advisory-db.json +4 -0
  4. package/advisory-db.mjs +124 -0
  5. package/advisory-exceptions.mjs +13 -10
  6. package/app-check.mjs +257 -38
  7. package/bin/darwin/rightkit-swap +0 -0
  8. package/bin/rightkit-agent-hook.mjs +9 -0
  9. package/bin/rightkit-gate.mjs +524 -345
  10. package/build-release.mjs +13 -6
  11. package/cli/right-release.mjs +29 -6
  12. package/compile-inputs.mjs +66 -0
  13. package/consumer-contracts.json +1044 -0
  14. package/consumer-contracts.mjs +627 -0
  15. package/consumer-input-contracts.mjs +422 -0
  16. package/consumer-policy.mjs +588 -0
  17. package/consumer-source-contracts.mjs +469 -0
  18. package/deny-default.toml +42 -0
  19. package/deterministic-scans.mjs +240 -29
  20. package/disk-command.mjs +270 -30
  21. package/disk-policy.json +50 -10
  22. package/disk-policy.mjs +482 -35
  23. package/disk-report.mjs +129 -0
  24. package/door-policy.mjs +228 -0
  25. package/door.mjs +23 -0
  26. package/gate-executor.mjs +30 -0
  27. package/gate-stages.mjs +110 -0
  28. package/github-updater-release.mjs +48 -8
  29. package/init-command.mjs +41 -0
  30. package/legion-semantic-contracts.mjs +428 -0
  31. package/ownership-class-rules.mjs +277 -0
  32. package/ownership-key-authority.mjs +3 -1
  33. package/ownership-mandatory-set.mjs +3 -2
  34. package/package.json +11 -3
  35. package/publish-cargo.mjs +5 -2
  36. package/publish-update.mjs +14 -11
  37. package/release-config.mjs +51 -6
  38. package/rightkit-versions.json +172 -167
  39. package/shell-contract-bridge.json +251 -0
  40. package/shell-contract.mjs +1069 -0
  41. package/sign-updater.mjs +24 -4
  42. package/source-closure-rules.mjs +204 -0
  43. package/stage-sidecar-command.mjs +2 -1
  44. package/stage-sidecar.mjs +437 -114
  45. package/suite-api.generated.mjs +96 -6
  46. package/suite-trust-roots.mjs +198 -0
package/init-command.mjs CHANGED
@@ -7,6 +7,7 @@ import { createHash } from "node:crypto";
7
7
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
+ import { isJudgedNotice } from "./consumer-contracts.mjs";
10
11
 
11
12
  export const SKIP_DIRS = new Set(["node_modules", "target", ".git", "dist", "gen"]);
12
13
  export const TEXT_EXT = new Set(["json", "toml", "mjs", "ts", "tsx", "rs", "md", "html", "css", "yaml", "yml", "sh"]);
@@ -171,11 +172,22 @@ export function rehashLegalManifest(text, read) {
171
172
  * The rename changes the bytes of text the legal manifest hashes (the privacy notice and the npm appendix carry the product name), so the
172
173
  * manifest's digests are recomputed from the renamed bytes in the same pass.
173
174
  */
175
+ /** The lock a notice field names (`prefix` holds the name or path), resolved from `dir` upward to `root` as CA-14 resolves it. */
176
+ function resolveLockUpward(root, dir, prefix) {
177
+ const ref = /^`?([^`\s(]+)/.exec(prefix)[1];
178
+ for (let at = path.resolve(dir); ; at = path.dirname(at)) {
179
+ const candidate = path.resolve(at, ...ref.split("/"));
180
+ if (existsSync(candidate)) return candidate;
181
+ if (at === path.resolve(root) || at === path.dirname(at)) return null;
182
+ }
183
+ }
184
+
174
185
  export function initTemplate(root, options) {
175
186
  const identity = resolveIdentity(options);
176
187
  const changed = [];
177
188
  const warnings = [...identity.warnings];
178
189
  const renamed = new Map();
190
+ const lockDigestsBefore = new Map();
179
191
  for (const file of walkTextFiles(root)) {
180
192
  if (path.basename(file) === SELF) continue;
181
193
  let before;
@@ -186,10 +198,39 @@ export function initTemplate(root, options) {
186
198
  }
187
199
  const after = path.basename(file) === LOCK_FILE ? reidentifyCargoLock(before, identity) : renameText(before, identity);
188
200
  if (after === before) continue;
201
+ if (path.basename(file) === LOCK_FILE) lockDigestsBefore.set(path.resolve(file), createHash("sha256").update(before).digest("hex"));
189
202
  renamed.set(path.resolve(file), after);
190
203
  if (!options.dryRun) writeFileSync(file, after);
191
204
  changed.push(path.relative(root, file).split(path.sep).join("/"));
192
205
  }
206
+ // The rename changes Cargo.lock's bytes (the app's own crates), so every notice that records the lock's digest
207
+ // (`Cargo.lock sha256 <hex>` / `sha256=<hex>`, checked by notice-lock-digest CA-14) would go stale: carry the new digest.
208
+ // Only the provenance field moves: `<lock> sha256 <hex>` or `<lock> sha256=<hex>` (optionally backticked, optionally with a
209
+ // parenthesised label) in a notice CA-14 judges (isJudgedNotice), where <lock> resolves from the notice's directory upward to
210
+ // this lock, as CA-14 resolves it, and <hex> is its old digest. The same digest, or the same formatted text, anywhere else
211
+ // (another input's field, archived reports and evidence, a source literal, a notice in a history tree) is left alone.
212
+ const escapeRe = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
213
+ const pairs = [...lockDigestsBefore].map(([file, old]) => {
214
+ const names = [...new Set([path.basename(file), path.relative(root, file).split(path.sep).join("/")])].map(escapeRe).join("|");
215
+ const field = new RegExp(`(\`?(?:${names})\`?(?:[ \\t]*\\([^)\\n]*\\))?[ \\t]+sha256(?:=|[ \\t]+)\`?)${old}(?![0-9a-f])`, "g");
216
+ return { lock: file, field, now: createHash("sha256").update(renamed.get(file)).digest("hex") };
217
+ });
218
+ if (pairs.length) {
219
+ for (const file of walkTextFiles(root)) {
220
+ if (path.basename(file) === SELF || path.basename(file) === LOCK_FILE) continue;
221
+ if (!isJudgedNotice(path.relative(root, file).split(path.sep).join("/"))) continue;
222
+ const before = renamed.get(path.resolve(file)) ?? readFileSync(file, "utf8");
223
+ let after = before;
224
+ for (const { lock, field, now } of pairs) {
225
+ after = after.replace(field, (whole, prefix) => (resolveLockUpward(root, path.dirname(file), prefix) === lock ? `${prefix}${now}` : whole));
226
+ }
227
+ if (after === before) continue;
228
+ renamed.set(path.resolve(file), after);
229
+ if (!options.dryRun) writeFileSync(file, after);
230
+ const relative = path.relative(root, file).split(path.sep).join("/");
231
+ if (!changed.includes(relative)) changed.push(relative);
232
+ }
233
+ }
193
234
  const manifestFile = path.join(root, ...LEGAL_MANIFEST.split("/"));
194
235
  if (existsSync(manifestFile)) {
195
236
  const legalDir = path.dirname(manifestFile);
@@ -0,0 +1,428 @@
1
+ // Legion's semantic catalog and provenance contracts, as RightKit-owned class A rules for the app check. Legion (Orthic-Labs/legion)
2
+ // enforces each of these with its own code only after its CI has built `legion-dev` (minutes into a Windows or macOS job); the
3
+ // relations themselves are pure data over committed files and git history, so they are read here before any build, by trusted
4
+ // adapters: no Legion code runs, no network, no clock. Each relation is copied from the producer that owns its meaning, cited by
5
+ // path:line at Orthic-Labs/legion 26459d5 (unchanged at the historical commits below unless stated), and every exemption it honours
6
+ // is that producer's, fixed here, never one a repository chooses.
7
+ //
8
+ // legion-schema-projection LG-02 a JSON schema that Legion's schema generator byte-compares (`generate-schemas --check`) is not
9
+ // pinned against line-ending conversion in .gitattributes (a Windows checkout rewrites it to
10
+ // CRLF and the check reports SCHEMA DRIFT: run 31302567212, jobs 93217599020 and 93217599034),
11
+ // or one of its `enum` arrays is not the code-owned string list it is generated from
12
+ // legion-legacy-tokens LG-03 a legacy token (seer, nemesis, forge, sentinel, sorcerer) in a tracked path or file that
13
+ // src/config/naming-legacy-allowlist.json does not classify, or classifies with a different exact
14
+ // occurrence count (jobs 120707319434/491, 118763807969/988)
15
+ // legion-retirement-ledger LG-04 a skill, script, hook, doctrine, agent-rule or native command file deleted after c1d80c9d with no
16
+ // row in docs/provenance/retirements.md (job 119412709257/288)
17
+ // legion-authority-body LG-05 an agent card that does not carry its doctrine body verbatim (job 117161756781/757149), a
18
+ // variant card without its roster bodies, or a council-seat card whose body differs from doctrine
19
+ //
20
+ // A contract applies when its producer or its data file is in the tree (ANCHORS); once it applies, a missing input is a finding.
21
+ // What stays with Legion's own gate: anything its generators compute (the rest of a schema's shape, the plugin surface, skill
22
+ // catalogs and manifests: canonical regeneration runs `legion-dev`, class B), and the naming check's semantic half (authority sets
23
+ // read from Rust and README), which is not this ledger.
24
+ import fs from "node:fs";
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+ import { spawnSync } from "./background-process.mjs";
28
+
29
+ // git's repository-location variables are dropped, as app-check.mjs does: a hook runs with some of them set.
30
+ const GIT_LOCATION = /^GIT_(?:DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|PREFIX|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|NAMESPACE)$/i;
31
+ const gitEnv = () => Object.fromEntries(Object.entries(process.env).filter(([key]) => !GIT_LOCATION.test(key)));
32
+ function git(repo, args, { input } = {}) {
33
+ const r = spawnSync("git", ["-c", "core.quotepath=off", ...args], { cwd: repo, env: gitEnv(), encoding: "utf8", input, maxBuffer: 256 * 1024 * 1024 });
34
+ return { ok: !r.error && r.status === 0, out: String(r.stdout ?? ""), err: r.error?.message ?? String(r.stderr ?? "").trim() };
35
+ }
36
+
37
+ const readBytes = (root, rel) => { try { return fs.readFileSync(path.join(root, rel)); } catch { return null; } };
38
+ const isFile = (root, rel) => { try { return fs.statSync(path.join(root, rel)).isFile(); } catch { return false; } };
39
+ /** Legion's read_text (checks/mod.rs:117-123): null for a NUL byte or invalid UTF-8; a BOM is kept, as Rust keeps it. */
40
+ export function decodeText(bytes) {
41
+ if (bytes === null || bytes.includes(0)) return null;
42
+ try { return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes); } catch { return null; }
43
+ }
44
+ const readText = (root, rel) => decodeText(readBytes(root, rel));
45
+
46
+ /** The files and paths each contract is anchored to: any of them in the tree makes the contract apply. */
47
+ export const ANCHORS = Object.freeze({
48
+ "legion-schema-projection": ["engine/bins/legion-dev/src/generators/schemas.rs", "scripts/generate-schemas.mjs"],
49
+ "legion-legacy-tokens": ["engine/bins/legion-dev/src/checks/canonical_names.rs", "src/config/naming-legacy-allowlist.json"],
50
+ "legion-retirement-ledger": ["engine/bins/legion-dev/src/checks/retirements.rs", "docs/provenance/retirements.md"],
51
+ "legion-authority-body": ["engine/bins/legion-dev/src/checks/authority_parity.rs", "src/roster/alchemist.md"],
52
+ });
53
+ const applies = (id, files) => ANCHORS[id].some((anchor) => files.has(anchor));
54
+
55
+ // ---- LG-02 schema projection ------------------------------------------------------------------------------------------------------
56
+
57
+ /**
58
+ * The schemas each generator writes and compares byte for byte, and the enum arrays it projects from code. Current producer:
59
+ * engine/bins/legion-dev/src/generators/schemas.rs: build_schemas() :202-216 names the targets, run() :224-238 compares the committed
60
+ * text with the rendering, :31 projects PROVIDER_STATUS, :81 EVIDENCE_STRENGTH and :82 SECURITY_VERDICTS, all from
61
+ * engine/crates/legion-contracts/src/enums.rs (:15, :83, :160). Historical producer (Orthic-Labs/legion 8c275f0c, the failing
62
+ * run): scripts/generate-schemas.mjs buildSchemas() :157-163 and main() :189-216 (`committed !== generated`, :195); its enums are
63
+ * JavaScript modules, which a class A rule does not evaluate, so only its line-ending pin is checked here.
64
+ */
65
+ export const SCHEMA_PROJECTIONS = Object.freeze([
66
+ Object.freeze({
67
+ producer: "engine/bins/legion-dev/src/generators/schemas.rs",
68
+ targets: ["src/schemas/provider-result-v1.schema.json", "src/schemas/security-verdict-v1.schema.json", "src/schemas/platform/web-actor-fixture-v1.schema.json"],
69
+ enumSource: "engine/crates/legion-contracts/src/enums.rs",
70
+ enums: [
71
+ { target: "src/schemas/provider-result-v1.schema.json", pointer: "/properties/status/enum", constant: "PROVIDER_STATUS" },
72
+ { target: "src/schemas/security-verdict-v1.schema.json", pointer: "/properties/evidenceStrength/enum", constant: "EVIDENCE_STRENGTH" },
73
+ { target: "src/schemas/security-verdict-v1.schema.json", pointer: "/properties/verdict/enum", constant: "SECURITY_VERDICTS" },
74
+ ],
75
+ }),
76
+ Object.freeze({
77
+ producer: "scripts/generate-schemas.mjs",
78
+ targets: ["schemas/provider-result-v1.schema.json", "schemas/security-verdict-v1.schema.json", "schemas/platform/web-actor-fixture-v1.schema.json"],
79
+ enumSource: null,
80
+ enums: [],
81
+ }),
82
+ ]);
83
+
84
+ /**
85
+ * The string list of `pub const NAME: &[&str] = &[ "a", "b", ... ];` in Rust source, read as data: string literals with the simple
86
+ * escapes, commas, whitespace and line comments only. Returns { values } or { problem }; anything else is refused, never evaluated.
87
+ */
88
+ export function rustStrList(source, name) {
89
+ const head = new RegExp(`(?:^|\\n)[ \\t]*pub(?:\\([^)\\n]*\\))?[ \\t]+const[ \\t]+${name}[ \\t]*:[ \\t]*&(?:'static[ \\t]+)?\\[[ \\t]*&(?:'static[ \\t]+)?str[ \\t]*\\][ \\t]*=[ \\t\\n]*&\\[`, "g");
90
+ const starts = [...source.matchAll(head)].map((m) => m.index + m[0].length);
91
+ if (starts.length !== 1) return { problem: starts.length ? `declares ${name} ${starts.length} times` : `declares no \`pub const ${name}: &[&str] = &[...]\`` };
92
+ const token = /\s+|\/\/[^\n]*|"((?:[^"\\\n]|\\["\\nt])*)"|,|\]/y;
93
+ token.lastIndex = starts[0];
94
+ const values = [];
95
+ for (;;) {
96
+ const at = token.lastIndex;
97
+ const m = token.exec(source);
98
+ if (!m || m.index !== at) return { problem: `${name} is not a plain list of string literals (at offset ${at})` };
99
+ if (m[0] === "]") return { values };
100
+ if (m[1] !== undefined) values.push(m[1].replace(/\\(["\\nt])/g, (_, c) => ({ n: "\n", t: "\t" })[c] ?? c));
101
+ }
102
+ }
103
+
104
+ const pointerGet = (value, pointer) => pointer.split("/").slice(1).map((part) => part.replace(/~1/g, "/").replace(/~0/g, "~"))
105
+ .reduce((node, key) => (node !== null && typeof node === "object" && Object.hasOwn(node, key) ? node[key] : undefined), value);
106
+
107
+ /**
108
+ * git's text and eol attributes of `rels` as the checked commit alone sets them: `check-attr --source <commit>` reads only the
109
+ * .gitattributes files of that tree, run from an empty scratch repository that borrows the checked repository's objects (so its
110
+ * $GIT_DIR/info/attributes and index are the scratch's, empty), with HOME and XDG_CONFIG_HOME isolated, no global or system
111
+ * config and no system attributes. Nothing outside the committed tree can pin, or unpin, a path.
112
+ */
113
+ function attributesOf(repo, commit, rels) {
114
+ const objects = git(repo, ["rev-parse", "--path-format=absolute", "--git-path", "objects"]);
115
+ if (!objects.ok) throw new Error(`cannot locate the object store: ${objects.err}`);
116
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "rk-attr-"));
117
+ try {
118
+ const env = { ...gitEnv(), HOME: scratch, XDG_CONFIG_HOME: scratch, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: path.join(scratch, "no-global-config"), GIT_ATTR_NOSYSTEM: "1", GIT_ALTERNATE_OBJECT_DIRECTORIES: objects.out.trim() };
119
+ const run = (args, input) => {
120
+ const r = spawnSync("git", args, { cwd: scratch, env, encoding: "utf8", input, maxBuffer: 64 * 1024 * 1024 });
121
+ if (r.error || r.status !== 0) throw new Error(`git ${args[0]} failed: ${r.error?.message ?? String(r.stderr).trim()}`);
122
+ return String(r.stdout);
123
+ };
124
+ run(["init", "-q"]);
125
+ const fields = run(["check-attr", "--source", commit, "-z", "--stdin", "text", "eol"], rels.join("\0")).split("\0");
126
+ const seen = new Map();
127
+ for (let i = 0; i + 2 < fields.length; i += 3) seen.set(fields[i], { ...seen.get(fields[i]), [fields[i + 1]]: fields[i + 2] });
128
+ return seen;
129
+ } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
130
+ }
131
+
132
+ export function legionSchemaProjection({ root, commit, files, repo = process.cwd() }) {
133
+ const tracked = new Set(files ?? []);
134
+ if (!applies("legion-schema-projection", tracked)) return [];
135
+ const out = [];
136
+ for (const projection of SCHEMA_PROJECTIONS) {
137
+ if (!tracked.has(projection.producer)) continue;
138
+ const present = projection.targets.filter((t) => tracked.has(t));
139
+ for (const target of projection.targets.filter((t) => !tracked.has(t))) {
140
+ out.push({ file: target, subject: "missing", problem: `${target} is missing: ${projection.producer} generates it and its --check reads it`, fix: `regenerate it with Legion's schema generator and commit it` });
141
+ }
142
+ if (present.length && !commit) throw new Error("no checked commit or tree to read .gitattributes from");
143
+ const attrs = present.length ? attributesOf(repo, commit, present) : new Map();
144
+ for (const target of present) {
145
+ const { text = "unspecified", eol = "unspecified" } = attrs.get(target) ?? {};
146
+ if (text === "unset" || eol === "lf") continue;
147
+ out.push({
148
+ file: target, subject: "eol",
149
+ problem: `${target} is byte-compared by ${projection.producer} --check but not pinned against line-ending conversion (text: ${text}, eol: ${eol}): a Windows checkout with core.autocrlf writes it as CRLF and the check reports "SCHEMA DRIFT: ${target} is not up to date with the code-owned enums" though nothing drifted`,
150
+ fix: `pin it in .gitattributes: \`* -text\` (Legion's choice) or \`${target} -text\` / \`eol=lf\``,
151
+ });
152
+ }
153
+ if (!projection.enumSource || !projection.enums.length) continue;
154
+ const source = tracked.has(projection.enumSource) ? readText(root, projection.enumSource) : null;
155
+ if (source === null) {
156
+ out.push({ file: projection.enumSource, subject: "missing", problem: `${projection.enumSource} is missing or not UTF-8 text: the code-owned enums ${projection.enums.map((e) => e.constant).join(", ")} of ${projection.producer} cannot be read`, fix: "restore it, or retire the projection in Legion and in RightKit's contract together" });
157
+ continue;
158
+ }
159
+ for (const { target, pointer, constant } of projection.enums) {
160
+ if (!present.includes(target)) continue;
161
+ const list = rustStrList(source, constant);
162
+ if (list.problem) { out.push({ file: projection.enumSource, subject: `enum:${constant}`, problem: `${projection.enumSource}: ${list.problem}; the projection into ${target}${pointer} cannot be checked`, fix: `keep ${constant} a literal \`&[&str]\` list` }); continue; }
163
+ let schema;
164
+ try { schema = JSON.parse(readText(root, target) ?? ""); } catch (error) { out.push({ file: target, subject: "json", problem: `${target} is not JSON (${error.message})`, fix: "regenerate it" }); continue; }
165
+ const actual = pointerGet(schema, pointer);
166
+ if (JSON.stringify(actual) === JSON.stringify(list.values)) continue;
167
+ out.push({
168
+ file: target, subject: `enum:${pointer}`,
169
+ problem: `${target}${pointer} is ${JSON.stringify(actual)} but ${projection.enumSource} ${constant} is ${JSON.stringify(list.values)}: the schema is not the projection of its code-owned enum`,
170
+ fix: `regenerate the schemas with Legion's generator (generate-schemas) and commit them with the enum change`,
171
+ });
172
+ }
173
+ }
174
+ return out;
175
+ }
176
+
177
+ // ---- LG-03 legacy occurrence ledger -----------------------------------------------------------------------------------------------
178
+
179
+ // engine/bins/legion-dev/src/checks/canonical_names.rs: TOKENS :17, SKIP_PREFIXES :18-28 (the `records/*/evidence/` and
180
+ // `evidence/` rows arrived after 118763807969 failed on immutable evidence at b797eec5), is_active_source :46-52, occurrences :56-82,
181
+ // matching_rule :84-99, allowlist_issues :101-171, the scan :388-458, skipped_naming_path :494-510.
182
+ export const LEGACY_TOKENS = Object.freeze(["seer", "nemesis", "forge", "sentinel", "sorcerer"]);
183
+ export const LEGACY_SKIP_PREFIXES = Object.freeze([
184
+ ".git/", ".agent/", ".audit/", ".cache/", "docs/foundation/", "node_modules/",
185
+ "qualification/provider-accuracy/logs/", "qualification/provider-accuracy/evidence/", "qualification/provider-accuracy/records/*/evidence/",
186
+ ]);
187
+ export const LEGACY_ALLOWLIST = "src/config/naming-legacy-allowlist.json";
188
+ const ACTIVE_SOURCE = /\.(?:cjs|js|json|jsx|md|mjs|py|sh|toml|ts|tsx|ya?ml)$/i;
189
+
190
+ /** skipped_naming_path: a `*` covers exactly one non-empty directory, never its siblings. */
191
+ export function legacySkipped(rel) {
192
+ const p = `${rel}/`;
193
+ return LEGACY_SKIP_PREFIXES.some((prefix) => {
194
+ const star = prefix.indexOf("*/");
195
+ if (star < 0) return p.startsWith(prefix);
196
+ const parent = prefix.slice(0, star), child = prefix.slice(star + 2);
197
+ if (!p.startsWith(parent)) return false;
198
+ const rest = p.slice(parent.length), slash = rest.indexOf("/");
199
+ return slash > 0 && rest.slice(slash + 1).startsWith(child);
200
+ });
201
+ }
202
+
203
+ const isAlnum = (b) => (b >= 48 && b <= 57) || (b >= 65 && b <= 90) || (b >= 97 && b <= 122);
204
+ /**
205
+ * The line of every whole-word, ASCII case-insensitive occurrence of each token (canonical_names.rs occurrences, byte for byte):
206
+ * { token: [line, ...] } for every token in `tokens`.
207
+ */
208
+ export function tokenLinesAll(text, tokens = LEGACY_TOKENS) {
209
+ const found = Object.fromEntries(tokens.map((t) => [t, []]));
210
+ const folded = text.toLowerCase(); // a superset filter only: ASCII matches survive Unicode lowercasing
211
+ const wanted = tokens.filter((t) => folded.includes(t.toLowerCase()));
212
+ if (!wanted.length) return found;
213
+ const bytes = Buffer.from(text, "utf8");
214
+ const lower = new Uint8Array(bytes.length);
215
+ for (let i = 0; i < bytes.length; i++) { const b = bytes[i]; lower[i] = b >= 65 && b <= 90 ? b + 32 : b; }
216
+ const low = Buffer.from(lower.buffer, lower.byteOffset, lower.length);
217
+ for (const token of wanted) {
218
+ const needle = Buffer.from(token.toLowerCase(), "utf8");
219
+ let line = 1, counted = 0;
220
+ for (let at = low.indexOf(needle, 0); at >= 0; at = low.indexOf(needle, at + 1)) {
221
+ const end = at + needle.length;
222
+ if ((at === 0 || !isAlnum(bytes[at - 1])) && (end >= bytes.length || !isAlnum(bytes[end]))) {
223
+ for (; counted < at; counted++) if (bytes[counted] === 10) line++;
224
+ found[token].push(line);
225
+ }
226
+ }
227
+ }
228
+ return found;
229
+ }
230
+ export const tokenLines = (text, token) => tokenLinesAll(text, [token])[token];
231
+
232
+ const ruleTargets = (rule, rel) => (typeof rule.path === "string" && rule.path === rel) || (typeof rule.pathPrefix === "string" && rel.startsWith(rule.pathPrefix));
233
+ const matchingRule = (rules, rel, token) => rules.find((rule) => rule && typeof rule === "object" && ruleTargets(rule, rel) && Array.isArray(rule.tokens) && rule.tokens.includes(token));
234
+ const legacyFinding = (rel, line, token, reason, key) => ({
235
+ file: rel, subject: `${token ?? "-"}:${key}`,
236
+ problem: `${rel}${line ? `:${line}` : ""}: ${reason}${token ? ` (${token})` : ""}`,
237
+ fix: `classify it in ${LEGACY_ALLOWLIST} (path, tokens, exact occurrences, class, reason) or rename the occurrence`,
238
+ });
239
+
240
+ export function legionLegacyTokens({ root, files }) {
241
+ const tracked = new Set(files ?? []);
242
+ if (!applies("legion-legacy-tokens", tracked)) return [];
243
+ let allowlist;
244
+ try { allowlist = JSON.parse(readText(root, LEGACY_ALLOWLIST) ?? ""); }
245
+ catch (error) { return [{ file: LEGACY_ALLOWLIST, subject: "ledger", problem: `${LEGACY_ALLOWLIST} is missing or not JSON (${error.message}): the legacy-token ledger cannot be read`, fix: "restore the ledger; Legion's canonical-names check fails without it too" }]; }
246
+ const rules = Array.isArray(allowlist?.rules) ? allowlist.rules : [];
247
+ const scanned = [...tracked].filter((rel) => !legacySkipped(rel) && isFile(root, rel)).sort();
248
+ // One read per file: the occurrences of every token in its path and its text (null text: binary or not UTF-8).
249
+ const seen = new Map(scanned.map((rel) => {
250
+ const text = readText(root, rel);
251
+ return [rel, { name: tokenLinesAll(rel), text: text === null ? null : tokenLinesAll(text) }];
252
+ }));
253
+ const ledger = [];
254
+ // The ledger's own shape: every rule has a target, tokens, class and reason; names known tokens; matches files; is used.
255
+ rules.forEach((rule, index) => {
256
+ const at = `${LEGACY_ALLOWLIST}#rules[${index}]`;
257
+ const target = typeof rule?.path === "string" ? rule.path : typeof rule?.pathPrefix === "string" ? rule.pathPrefix : null;
258
+ const shaped = target !== null && Array.isArray(rule.tokens) && rule.tokens.length && typeof rule.class === "string" && typeof rule.reason === "string";
259
+ if (!shaped) { ledger.push({ ...legacyFinding(at, null, null, "legacy allowlist rule lacks target, tokens, class, or reason", "shape"), file: LEGACY_ALLOWLIST, subject: `rules[${index}]:shape` }); return; }
260
+ const candidates = scanned.filter((rel) => ruleTargets(rule, rel));
261
+ if (!candidates.length) { ledger.push({ ...legacyFinding(target, null, null, "legacy allowlist target does not exist or matches no files", "no-target"), file: LEGACY_ALLOWLIST, subject: `${target}:no-target` }); return; }
262
+ for (const token of rule.tokens) {
263
+ if (typeof token !== "string") continue;
264
+ if (!LEGACY_TOKENS.includes(token)) { ledger.push({ ...legacyFinding(at, null, token, "legacy allowlist names unknown token", "unknown-token"), file: LEGACY_ALLOWLIST, subject: `rules[${index}]:${token}:unknown-token` }); continue; }
265
+ const observed = candidates.reduce((sum, rel) => sum + seen.get(rel).name[token].length + (seen.get(rel).text?.[token].length ?? 0), 0);
266
+ if (!observed) ledger.push({ ...legacyFinding(target, null, token, "legacy allowlist token exemption is unused", "unused"), file: LEGACY_ALLOWLIST, subject: `${target}:${token}:unused` });
267
+ }
268
+ });
269
+ const out = [];
270
+ for (const rel of scanned) {
271
+ const { name, text } = seen.get(rel);
272
+ for (const token of LEGACY_TOKENS) if (name[token].length && !matchingRule(rules, rel, token)) out.push(legacyFinding(rel, null, token, "unclassified legacy filename", "filename"));
273
+ if (text === null) { if (ACTIVE_SOURCE.test(rel)) out.push(legacyFinding(rel, null, null, "active source cannot be decoded for naming scan", "decode")); continue; }
274
+ for (const token of LEGACY_TOKENS) {
275
+ const lines = text[token];
276
+ const rule = matchingRule(rules, rel, token);
277
+ if (!lines.length && !rule) continue;
278
+ const exact = rule?.occurrences && Number.isInteger(rule.occurrences[token]) ? rule.occurrences[token] : null;
279
+ if (lines.length && !rule) out.push(legacyFinding(rel, lines[0], token, "unclassified legacy token", "unclassified"));
280
+ else if (lines.length && typeof rule.path === "string" && rule.class !== "R5" && exact === null) out.push(legacyFinding(rel, lines[0], token, "active exact-path allowlist lacks occurrence count", "no-count"));
281
+ else if (exact !== null && lines.length !== exact) out.push(legacyFinding(rel, lines[0] ?? null, token, `legacy token occurrence count differs: expected ${exact}, found ${lines.length}`, "count"));
282
+ }
283
+ }
284
+ return [...ledger, ...out];
285
+ }
286
+
287
+ // ---- LG-04 retirement ledger ------------------------------------------------------------------------------------------------------
288
+
289
+ // engine/bins/legion-dev/src/checks/retirements.rs: BASELINE :20 (Legion writes "c1d80c9d"; RightKit binds the full id
290
+ // c1d80c9d190e0f45dfd630d4d65e8bed748381ce, Orthic-Labs/legion 2026-10-08), LEDGER :21,
291
+ // EXEMPT_PREFIXES :22, is_ledger_surface :25-40, deleted_paths :44-54 (still absent at HEAD), ledger_entries :58-77, glob_matches
292
+ // :80-100, is_covered :102-111, the history read :160-178 (`git log -M --diff-filter=D --name-only --format= BASELINE..HEAD`).
293
+ // Legion skips locally when history is shallow and fails in CI; a class A rule cannot tell, so it always fails: unverified is not clean.
294
+ export const RETIREMENT_BASELINE = "c1d80c9d190e0f45dfd630d4d65e8bed748381ce";
295
+ export const RETIREMENT_LEDGER = "docs/provenance/retirements.md";
296
+ const RETIREMENT_EXEMPT = Object.freeze(["skills/manifests/"]);
297
+
298
+ export function onLedgerSurface(rel) {
299
+ if (RETIREMENT_EXEMPT.some((p) => rel.startsWith(p))) return false;
300
+ return /^(?:skills\/|scripts\/|hooks\/|doctrine\/|docs\/agent-rules)/.test(rel) || /^engine\/bins\/[^/]+\/src\/commands\//.test(rel);
301
+ }
302
+
303
+ /** Backtick spans in the first column of every table row. */
304
+ export function ledgerEntries(text) {
305
+ const entries = [];
306
+ for (const raw of text.split("\n")) {
307
+ const line = raw.trim();
308
+ if (!line.startsWith("|")) continue;
309
+ const cell = line.split("|")[1];
310
+ if (cell === undefined) continue;
311
+ for (const m of cell.matchAll(/`([^`]+)`/g)) {
312
+ const entry = m[1].trim().replace(/^(?:\.\/)+/, "");
313
+ if (entry) entries.push(entry);
314
+ }
315
+ }
316
+ return entries;
317
+ }
318
+
319
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
320
+ /** `*` within a segment, `**` across segments, then the path or anything below it. */
321
+ export function ledgerGlob(pattern, rel) {
322
+ let re = "^";
323
+ for (let i = 0; i < pattern.length;) {
324
+ if (pattern[i] === "*") { if (pattern[i + 1] === "*") { re += ".*"; i += 2; } else { re += "[^/]*"; i += 1; } }
325
+ else re += escapeRe(pattern[i++]);
326
+ }
327
+ return new RegExp(`${re}(?:/.*)?$`, "s").test(rel);
328
+ }
329
+ export const ledgerCovers = (rel, entries) => entries.some((entry) => {
330
+ if (entry.includes("*")) return ledgerGlob(entry, rel);
331
+ const e = entry.replace(/\/+$/, "");
332
+ return rel === e || rel.startsWith(`${e}/`);
333
+ });
334
+
335
+ /** The LG-04 rule bound to a full baseline object id: RETIREMENT_BASELINE in the app check; a fixture's own commit in tests only. */
336
+ export const retirementLedgerFor = (baseline) => ({ root, commit, files, repo }) => legionRetirementLedger({ root, commit, files, repo, baseline });
337
+
338
+ export function legionRetirementLedger({ root, commit, files, repo = process.cwd(), baseline = RETIREMENT_BASELINE }) {
339
+ const tracked = new Set(files ?? []);
340
+ if (!applies("legion-retirement-ledger", tracked)) return [];
341
+ const shown = String(baseline).slice(0, 8); // how Legion names it in its messages
342
+ const unverified = (why) => [{ file: RETIREMENT_LEDGER, subject: "history", problem: `cannot enumerate files deleted since ${shown}: ${why}; a ledger that is not checked is not clean`, fix: `check with full history (actions/checkout fetch-depth: 0, or git fetch --unshallow)` }];
343
+ const shallow = git(repo, ["rev-parse", "--is-shallow-repository"]);
344
+ if (!shallow.ok) return unverified(`not a git repository (${shallow.err})`);
345
+ if (shallow.out.trim() !== "false") return unverified("the clone is shallow");
346
+ // The baseline is exactly one object id, never a prefix and never a ref: that object must be a commit here and an ancestor of what
347
+ // is checked. A different commit that merely shares its first digits is not the baseline.
348
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(String(baseline))) return unverified(`the baseline ${baseline} is not a full object id`);
349
+ if (git(repo, ["cat-file", "-t", baseline]).out.trim() !== "commit") return unverified(`${baseline} is not a commit here`);
350
+ const base = { out: baseline };
351
+ const isCommit = commit && git(repo, ["cat-file", "-t", commit]).out.trim() === "commit";
352
+ const end = isCommit ? commit : "HEAD";
353
+ if (!git(repo, ["merge-base", "--is-ancestor", base.out.trim(), end]).ok) return unverified(`${baseline} is not an ancestor of ${end === "HEAD" ? "HEAD" : end.slice(0, 12)}`);
354
+ const log = git(repo, ["log", "-M", "--diff-filter=D", "--name-only", "--format=", `${base.out.trim()}..${end}`]);
355
+ if (!log.ok) throw new Error(`git log failed: ${log.err}`);
356
+ let names = log.out;
357
+ // --worktree checks a tree: what HEAD has and the tree lacks is deleted too.
358
+ if (!isCommit && commit) {
359
+ const diff = git(repo, ["diff", "-M", "--diff-filter=D", "--name-only", "HEAD", commit]);
360
+ if (!diff.ok) throw new Error(`git diff failed: ${diff.err}`);
361
+ names += `\n${diff.out}`;
362
+ }
363
+ const dirs = new Set();
364
+ for (const rel of tracked) for (let i = rel.indexOf("/"); i > 0; i = rel.indexOf("/", i + 1)) dirs.add(rel.slice(0, i));
365
+ const exists = (rel) => tracked.has(rel) || dirs.has(rel);
366
+ const deleted = [...new Set(names.split("\n").map((l) => l.trim()).filter((l) => l && onLedgerSurface(l) && !exists(l)))].sort();
367
+ if (!deleted.length) return [];
368
+ const ledger = tracked.has(RETIREMENT_LEDGER) ? readText(root, RETIREMENT_LEDGER) : null;
369
+ if (ledger === null) return [{ file: RETIREMENT_LEDGER, subject: "ledger", problem: `${RETIREMENT_LEDGER} is missing or not UTF-8 text, and ${deleted.length} path(s) were deleted since ${shown}`, fix: "restore the ledger" }];
370
+ const entries = ledgerEntries(ledger);
371
+ return deleted.filter((rel) => !ledgerCovers(rel, entries)).map((rel) => ({
372
+ file: rel, subject: "row",
373
+ problem: `${rel}: deleted since ${shown} with no row in ${RETIREMENT_LEDGER}`,
374
+ fix: `add a row whose first column names the path, a parent directory or a glob that matches it, with its successor or why it was dropped`,
375
+ }));
376
+ }
377
+
378
+ // ---- LG-05 authority body parity --------------------------------------------------------------------------------------------------
379
+
380
+ // engine/bins/legion-dev/src/checks/authority_parity.rs: ROLES :14, VARIANTS :18-19 (card, base role), cards() :23-29,
381
+ // body_after_frontmatter :262-267, the council seat's body equality :289-301, check_method_embedded :336-379 (the doctrine body
382
+ // verbatim in the card, a variant's two roster bodies too, and no `doctrine/` or `src/roster/` outside the embedded method).
383
+ export const AUTHORITY_CARDS = Object.freeze([["sage", "sage"], ["alchemist", "alchemist"], ["oracle", "oracle"], ["sentry", "sentry"], ["alchemist-specialist", "alchemist"]]);
384
+ const AUTHORITY_VARIANTS = Object.freeze({ "alchemist-specialist": "alchemist" });
385
+
386
+ /** The text after a leading `---` frontmatter block, CRLF as LF, trimmed. */
387
+ export const bodyAfterFrontmatter = (text) => text.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").replace(/\r\n/g, "\n").trim();
388
+
389
+ export function legionAuthorityBody({ root, files }) {
390
+ const tracked = new Set(files ?? []);
391
+ if (!applies("legion-authority-body", tracked)) return [];
392
+ const out = [];
393
+ const need = (rel) => {
394
+ const text = tracked.has(rel) ? readText(root, rel) : null;
395
+ if (text === null) out.push({ file: rel, subject: "missing", problem: `${rel} is missing or not UTF-8 text; the authority cards cannot be checked against it`, fix: "restore it, or retire the role in Legion and in RightKit's contract together" });
396
+ return text;
397
+ };
398
+ for (const [card, doctrineId] of AUTHORITY_CARDS) {
399
+ const doctrine = need(`doctrine/${doctrineId}.md`);
400
+ const raw = need(`agents/${card}.md`);
401
+ if (doctrine === null || raw === null) continue;
402
+ const body = bodyAfterFrontmatter(doctrine);
403
+ const text = raw.replace(/\r\n/g, "\n");
404
+ if (!text.includes(body)) out.push({ file: `agents/${card}.md`, subject: `method:${doctrineId}`, problem: `${card}: agents/${card}.md does not contain the doctrine/${doctrineId}.md body verbatim under ## Method`, fix: `copy the doctrine/${doctrineId}.md body (after its frontmatter) into agents/${card}.md under ## Method` });
405
+ const base = AUTHORITY_VARIANTS[card];
406
+ if (base) {
407
+ for (const rosterId of [base, card]) {
408
+ const roster = need(`src/roster/${rosterId}.md`);
409
+ if (roster !== null && !text.includes(bodyAfterFrontmatter(roster))) out.push({ file: `agents/${card}.md`, subject: `roster:${rosterId}`, problem: `${card}: agents/${card}.md does not contain the src/roster/${rosterId}.md body verbatim under ## Roster`, fix: `copy the src/roster/${rosterId}.md body into agents/${card}.md under ## Roster` });
410
+ }
411
+ }
412
+ const outside = text.replace(body, () => "");
413
+ for (const forbidden of ["doctrine/", "src/roster/"]) if (outside.includes(forbidden)) out.push({ file: `agents/${card}.md`, subject: `outside:${forbidden}`, problem: `${card}: agents/${card}.md refers to \`${forbidden}\` outside its embedded method; the installed card cannot read that path`, fix: "name the rule itself, not the repository path" });
414
+ }
415
+ const seatDoctrine = need("doctrine/council-seat.md");
416
+ const seatCard = need("agents/council-seat.md");
417
+ if (seatDoctrine !== null && seatCard !== null && bodyAfterFrontmatter(seatDoctrine) !== bodyAfterFrontmatter(seatCard)) {
418
+ out.push({ file: "agents/council-seat.md", subject: "body", problem: "council-seat: agents/council-seat.md body differs from doctrine/council-seat.md body; the card must carry the doctrine body verbatim", fix: "copy the doctrine/council-seat.md body into the card" });
419
+ }
420
+ return out;
421
+ }
422
+
423
+ export const LEGION_SEMANTIC_RULES = Object.freeze([
424
+ { id: "legion-schema-projection", gap: "LG-02", run: legionSchemaProjection },
425
+ { id: "legion-legacy-tokens", gap: "LG-03", run: legionLegacyTokens },
426
+ { id: "legion-retirement-ledger", gap: "LG-04", run: retirementLedgerFor(RETIREMENT_BASELINE) },
427
+ { id: "legion-authority-body", gap: "LG-05", run: legionAuthorityBody },
428
+ ]);