@nimiplatform/nimi-coding 0.3.1 → 0.5.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.
Files changed (115) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/README.md +297 -41
  4. package/README.zh-CN.md +306 -31
  5. package/bin/nimicoding.mjs +1 -1
  6. package/cli/commands/authority.mjs +804 -0
  7. package/cli/commands/clear.mjs +27 -7
  8. package/cli/commands/doctor.mjs +10 -1
  9. package/cli/commands/start.mjs +23 -27
  10. package/cli/commands/sync.mjs +21 -101
  11. package/cli/constants.mjs +7 -31
  12. package/cli/dev/check-authority-surface.mjs +182 -0
  13. package/cli/dev/check-product-conformance.mjs +889 -0
  14. package/cli/help.mjs +10 -17
  15. package/cli/index.mjs +6 -47
  16. package/cli/lib/authority/anchors.mjs +491 -0
  17. package/cli/lib/authority/audit.mjs +567 -0
  18. package/cli/lib/authority/change-candidates.mjs +807 -0
  19. package/cli/lib/authority/closed-sets.mjs +645 -0
  20. package/cli/lib/authority/compile.mjs +92 -0
  21. package/cli/lib/authority/compiled-snapshot.mjs +143 -0
  22. package/cli/lib/authority/consumers.mjs +520 -0
  23. package/cli/lib/authority/diagnostics.mjs +93 -0
  24. package/cli/lib/authority/diff.mjs +310 -0
  25. package/cli/lib/authority/discover.mjs +447 -0
  26. package/cli/lib/authority/format.mjs +169 -0
  27. package/cli/lib/authority/git-snapshot.mjs +720 -0
  28. package/cli/lib/authority/graph.mjs +351 -0
  29. package/cli/lib/authority/impact.mjs +125 -0
  30. package/cli/lib/authority/lexicon.mjs +55 -0
  31. package/cli/lib/authority/query.mjs +162 -0
  32. package/cli/lib/authority/repository-corpus.mjs +37 -0
  33. package/cli/lib/authority/repository-inventory.mjs +34 -0
  34. package/cli/lib/authority/review.mjs +212 -0
  35. package/cli/lib/authority/sarif.mjs +174 -0
  36. package/cli/lib/authority/scope-bindings.mjs +225 -0
  37. package/cli/lib/authority/source-map.mjs +105 -0
  38. package/cli/lib/authority/source-markdown.mjs +167 -0
  39. package/cli/lib/authority/source-yaml.mjs +308 -0
  40. package/cli/lib/authority/surface.mjs +97 -0
  41. package/cli/lib/authority/terms.mjs +422 -0
  42. package/cli/lib/authority/tracked-text.mjs +70 -0
  43. package/cli/lib/authority/validate.mjs +300 -0
  44. package/cli/lib/bootstrap.mjs +55 -71
  45. package/cli/lib/doctor.mjs +40 -123
  46. package/cli/lib/entrypoints.mjs +104 -105
  47. package/cli/lib/fs-helpers.mjs +139 -15
  48. package/cli/lib/internal/governance/config.mjs +0 -36
  49. package/cli/lib/shared.mjs +8 -21
  50. package/cli/lib/sync.mjs +81 -92
  51. package/cli/lib/value-helpers.mjs +0 -26
  52. package/cli/lib/yaml-helpers.mjs +1 -124
  53. package/cli/seeds/bootstrap.mjs +31 -87
  54. package/cli/seeds/seed-policy.yaml +9 -63
  55. package/contracts/authority-scope-bindings.schema.yaml +15 -0
  56. package/contracts/authority-source.schema.yaml +58 -0
  57. package/contracts/authority-verifier-bindings.schema.yaml +31 -0
  58. package/methodology/authority-authoring.yaml +127 -0
  59. package/methodology/core.yaml +34 -27
  60. package/package.json +10 -6
  61. package/spec/authority-anchors.yaml +99 -0
  62. package/spec/authority-audit.yaml +89 -0
  63. package/spec/authority-authoring.yaml +137 -0
  64. package/spec/authority-change-candidates.yaml +146 -0
  65. package/spec/authority-closed-sets.yaml +65 -0
  66. package/spec/authority-consumers.yaml +77 -0
  67. package/spec/authority-context.yaml +50 -0
  68. package/spec/authority-discovery.yaml +119 -0
  69. package/spec/authority-graph.yaml +59 -0
  70. package/spec/authority-impact.yaml +61 -0
  71. package/spec/authority-review.yaml +71 -0
  72. package/spec/authority-terms.yaml +63 -0
  73. package/spec/product-scope.yaml +37 -23
  74. package/cli/commands/blueprint-audit.mjs +0 -91
  75. package/cli/commands/classify-spec-tree.mjs +0 -5
  76. package/cli/commands/generate-spec-derived-docs.mjs +0 -151
  77. package/cli/commands/generate-spec-migration-plan.mjs +0 -30
  78. package/cli/commands/surface-validator-command.mjs +0 -49
  79. package/cli/commands/validate-domain-admission.mjs +0 -5
  80. package/cli/commands/validate-guidance-bodies.mjs +0 -5
  81. package/cli/commands/validate-placement.mjs +0 -5
  82. package/cli/commands/validate-projection-edges.mjs +0 -5
  83. package/cli/commands/validate-spec-audit.mjs +0 -27
  84. package/cli/commands/validate-spec-governance.mjs +0 -144
  85. package/cli/commands/validate-spec-tree.mjs +0 -27
  86. package/cli/commands/validate-table-family.mjs +0 -5
  87. package/cli/commands/validate-tracked-output-admission.mjs +0 -5
  88. package/cli/lib/blueprint-audit.mjs +0 -363
  89. package/cli/lib/contracts.mjs +0 -21
  90. package/cli/lib/internal/contracts-loaders.mjs +0 -47
  91. package/cli/lib/internal/contracts-parse.mjs +0 -192
  92. package/cli/lib/internal/governance/runner.mjs +0 -35
  93. package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
  94. package/cli/lib/internal/validators-shared.mjs +0 -12
  95. package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
  96. package/cli/lib/internal/validators-spec.mjs +0 -380
  97. package/cli/lib/validators.mjs +0 -30
  98. package/config/bootstrap.yaml +0 -6
  99. package/config/spec-generation-inputs.yaml +0 -41
  100. package/contracts/domain-admission.schema.yaml +0 -56
  101. package/contracts/migration-inventory.schema.yaml +0 -15
  102. package/contracts/negative-fixtures.yaml +0 -26
  103. package/contracts/placement-contract.schema.yaml +0 -127
  104. package/contracts/projection-edge.schema.yaml +0 -46
  105. package/contracts/shared-enums.yaml +0 -63
  106. package/contracts/spec-generation-audit.schema.yaml +0 -46
  107. package/contracts/spec-generation-inputs.schema.yaml +0 -43
  108. package/contracts/spec-layout.schema.yaml +0 -32
  109. package/contracts/surface-taxonomy.schema.yaml +0 -139
  110. package/contracts/table-family.schema.yaml +0 -133
  111. package/contracts/tracked-output-admission.schema.yaml +0 -17
  112. package/methodology/four-closure-policy.yaml +0 -20
  113. package/methodology/role-separation-policy.yaml +0 -22
  114. package/methodology/spec-reconstruction.yaml +0 -69
  115. package/spec/_meta/spec-tree-model.yaml +0 -44
@@ -0,0 +1,720 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { constants as fsConstants } from "node:fs";
5
+ import { lstat, mkdir, mkdtemp, open, readdir, realpath, rm, writeFile } from "node:fs/promises";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ import { compareText, isPathWithin, makeDiagnostic } from "./diagnostics.mjs";
10
+
11
+ const SNAPSHOT_IDENTITY_FORMAT = "nimicoding.authority-snapshot-content/v1";
12
+ const SPEC_ROOT = ".nimi/spec";
13
+ const UTF8 = new TextDecoder("utf-8", { fatal: true });
14
+
15
+ export class AuthorityReviewRefusal extends Error {
16
+ constructor(code, reason, file = ".") {
17
+ super(reason);
18
+ this.name = "AuthorityReviewRefusal";
19
+ this.code = code;
20
+ this.file = file;
21
+ }
22
+ }
23
+
24
+ export function authorityReviewRefusalDiagnostic(error) {
25
+ return makeDiagnostic({
26
+ code: error.code,
27
+ file: error.file,
28
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
29
+ pointer: "",
30
+ reason: error.message,
31
+ repair: "provide one complete stable Git repository snapshot; no partial authority review is admitted",
32
+ });
33
+ }
34
+
35
+ function refusal(code, reason, file = ".") {
36
+ return new AuthorityReviewRefusal(code, reason, file);
37
+ }
38
+
39
+ function decodeUtf8(bytes, description, code) {
40
+ try {
41
+ return UTF8.decode(bytes);
42
+ } catch {
43
+ throw refusal(code, `${description} is not valid UTF-8`);
44
+ }
45
+ }
46
+
47
+ function childEnvironment() {
48
+ const env = { ...process.env };
49
+ const exact = new Set([
50
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
51
+ "GIT_COMMON_DIR",
52
+ "GIT_CONFIG",
53
+ "GIT_CONFIG_COUNT",
54
+ "GIT_CONFIG_PARAMETERS",
55
+ "GIT_DIR",
56
+ "GIT_EXEC_PATH",
57
+ "GIT_INDEX_FILE",
58
+ "GIT_NAMESPACE",
59
+ "GIT_OBJECT_DIRECTORY",
60
+ "GIT_PREFIX",
61
+ "GIT_QUARANTINE_PATH",
62
+ "GIT_REPLACE_REF_BASE",
63
+ "GIT_SHALLOW_FILE",
64
+ "GIT_WORK_TREE",
65
+ ]);
66
+ for (const key of Object.keys(env)) {
67
+ if (exact.has(key) || key.startsWith("GIT_CONFIG_KEY_") || key.startsWith("GIT_CONFIG_VALUE_") || key.startsWith("GIT_TRACE")) delete env[key];
68
+ }
69
+ return {
70
+ ...env,
71
+ GIT_CONFIG_NOSYSTEM: "1",
72
+ GIT_LITERAL_PATHSPECS: "1",
73
+ GIT_NO_LAZY_FETCH: "1",
74
+ GIT_NO_REPLACE_OBJECTS: "1",
75
+ GIT_OPTIONAL_LOCKS: "0",
76
+ GIT_TERMINAL_PROMPT: "0",
77
+ LC_ALL: "C",
78
+ };
79
+ }
80
+
81
+ async function runGit(repository, args) {
82
+ return new Promise((resolve, reject) => {
83
+ const child = spawn("git", ["--no-replace-objects", ...args], {
84
+ cwd: repository,
85
+ env: childEnvironment(),
86
+ shell: false,
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ });
89
+ const stdout = [];
90
+ const stderr = [];
91
+ child.stdout.on("data", (chunk) => stdout.push(chunk));
92
+ child.stderr.on("data", (chunk) => stderr.push(chunk));
93
+ child.once("error", reject);
94
+ child.once("close", (status, signal) => resolve({
95
+ status,
96
+ signal,
97
+ stdout: Buffer.concat(stdout),
98
+ stderr: Buffer.concat(stderr),
99
+ }));
100
+ });
101
+ }
102
+
103
+ async function checkedGit(repository, args, code, reason) {
104
+ const result = await runGit(repository, args);
105
+ if (result.signal !== null) throw new Error(`Git process terminated by signal ${result.signal}`);
106
+ if (result.status !== 0) throw refusal(code, reason);
107
+ return result.stdout;
108
+ }
109
+
110
+ function oneLine(bytes, description, code) {
111
+ const text = decodeUtf8(bytes, description, code);
112
+ const value = text.endsWith("\n") ? text.slice(0, -1) : text;
113
+ if (value.length === 0 || value.includes("\n") || value.includes("\r")) throw refusal(code, `${description} is not one exact line`);
114
+ return value;
115
+ }
116
+
117
+ async function resolveRepository(repositoryPath) {
118
+ const requested = path.resolve(process.cwd(), repositoryPath);
119
+ let info;
120
+ try {
121
+ info = await lstat(requested);
122
+ } catch {
123
+ throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path is not a readable Git worktree root");
124
+ }
125
+ if (info.isSymbolicLink() || !info.isDirectory()) {
126
+ throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path must be one regular non-symlink directory");
127
+ }
128
+ const repository = await realpath(requested);
129
+ const inside = await checkedGit(
130
+ repository,
131
+ ["rev-parse", "--is-inside-work-tree"],
132
+ "AUTH_REVIEW_GIT_REPOSITORY_INVALID",
133
+ "repository path is not a Git worktree",
134
+ );
135
+ if (oneLine(inside, "Git worktree response", "AUTH_REVIEW_GIT_REPOSITORY_INVALID") !== "true") {
136
+ throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path is not a non-bare Git worktree");
137
+ }
138
+ const top = oneLine(await checkedGit(
139
+ repository,
140
+ ["rev-parse", "--path-format=absolute", "--show-toplevel"],
141
+ "AUTH_REVIEW_GIT_REPOSITORY_INVALID",
142
+ "repository worktree root cannot be resolved",
143
+ ), "Git worktree root", "AUTH_REVIEW_GIT_REPOSITORY_INVALID");
144
+ let resolvedTop;
145
+ try {
146
+ resolvedTop = await realpath(top);
147
+ } catch {
148
+ throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "resolved Git worktree root is not readable");
149
+ }
150
+ if (resolvedTop !== repository) {
151
+ throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", "repository path must name the exact Git worktree root, not a subdirectory");
152
+ }
153
+ const protectedRoots = [repository];
154
+ for (const [argument, description] of [
155
+ ["--git-dir", "Git worktree administration directory"],
156
+ ["--git-common-dir", "Git common administration directory"],
157
+ ]) {
158
+ const resolved = oneLine(await checkedGit(
159
+ repository,
160
+ ["rev-parse", "--path-format=absolute", argument],
161
+ "AUTH_REVIEW_GIT_REPOSITORY_INVALID",
162
+ `${description} cannot be resolved`,
163
+ ), description, "AUTH_REVIEW_GIT_REPOSITORY_INVALID");
164
+ try {
165
+ protectedRoots.push(await realpath(resolved));
166
+ } catch {
167
+ throw refusal("AUTH_REVIEW_GIT_REPOSITORY_INVALID", `${description} is not readable`);
168
+ }
169
+ }
170
+ return { repository, protectedRoots: [...new Set(protectedRoots)] };
171
+ }
172
+
173
+ function validBaseRef(baseRef) {
174
+ return typeof baseRef === "string"
175
+ && baseRef.length > 0
176
+ && baseRef.trim() === baseRef
177
+ && !baseRef.startsWith("-")
178
+ && !baseRef.includes("\0")
179
+ && !baseRef.includes("\n")
180
+ && !baseRef.includes("\r");
181
+ }
182
+
183
+ async function resolveBaseOid(repository, baseRef) {
184
+ if (!validBaseRef(baseRef)) throw refusal("AUTH_REVIEW_BASE_INVALID", "base ref must be one non-option Git commit expression");
185
+ const bytes = await checkedGit(
186
+ repository,
187
+ ["rev-parse", "--verify", "--end-of-options", `${baseRef}^{commit}`],
188
+ "AUTH_REVIEW_BASE_INVALID",
189
+ "base ref does not resolve to one commit",
190
+ );
191
+ const oid = oneLine(bytes, "resolved base commit OID", "AUTH_REVIEW_BASE_INVALID");
192
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) throw refusal("AUTH_REVIEW_BASE_INVALID", "base ref did not resolve to one immutable full commit OID");
193
+ return oid;
194
+ }
195
+
196
+ function splitNullRecords(bytes) {
197
+ const records = [];
198
+ let offset = 0;
199
+ while (offset < bytes.length) {
200
+ const end = bytes.indexOf(0, offset);
201
+ if (end < 0) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree output is not NUL-terminated");
202
+ if (end === offset) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree output contains an empty entry");
203
+ records.push(bytes.subarray(offset, end));
204
+ offset = end + 1;
205
+ }
206
+ return records;
207
+ }
208
+
209
+ function parseTreeRecord(record) {
210
+ const tab = record.indexOf(9);
211
+ if (tab <= 0 || tab === record.length - 1) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree entry has an invalid binary record shape");
212
+ const header = decodeUtf8(record.subarray(0, tab), "Git tree entry metadata", "AUTH_REVIEW_BASE_ENTRY_INVALID");
213
+ const match = /^(\d{6}) ([a-z]+) ([0-9a-f]{40}|[0-9a-f]{64})$/.exec(header);
214
+ if (!match) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "Git tree entry metadata is not exact mode, type, and full OID");
215
+ return {
216
+ mode: match[1],
217
+ objectType: match[2],
218
+ oid: match[3],
219
+ rawPath: record.subarray(tab + 1),
220
+ };
221
+ }
222
+
223
+ function validateRelativePath(value) {
224
+ if (value.length === 0 || path.posix.isAbsolute(value) || value.includes("\\")) return false;
225
+ const parts = value.split("/");
226
+ return parts.every((part) => part.length > 0 && part !== "." && part !== "..");
227
+ }
228
+
229
+ async function baseTreeEntries(repository, baseOid, hooks) {
230
+ const rootRecords = splitNullRecords(await checkedGit(
231
+ repository,
232
+ ["ls-tree", "-z", baseOid, "--", SPEC_ROOT],
233
+ "AUTH_REVIEW_GIT_OBJECT_MISSING",
234
+ "base commit or authority tree object is missing",
235
+ ));
236
+ if (rootRecords.length !== 1) throw refusal("AUTH_REVIEW_BASE_SPEC_MISSING", "base commit must contain one complete .nimi/spec tree");
237
+ const root = parseTreeRecord(rootRecords[0]);
238
+ const rootPath = decodeUtf8(root.rawPath, "base authority root path", "AUTH_REVIEW_BASE_ENTRY_INVALID");
239
+ if (root.mode !== "040000" || root.objectType !== "tree" || rootPath !== SPEC_ROOT) {
240
+ throw refusal("AUTH_REVIEW_BASE_SPEC_MISSING", "base .nimi/spec must be one Git tree");
241
+ }
242
+ const records = splitNullRecords(await checkedGit(
243
+ repository,
244
+ ["ls-tree", "-r", "-t", "-z", "--full-tree", root.oid],
245
+ "AUTH_REVIEW_GIT_OBJECT_MISSING",
246
+ "base authority tree object is missing or unreadable",
247
+ ));
248
+ const entries = [{ path: SPEC_ROOT, type: "directory", bytes: null }];
249
+ const seen = new Set([SPEC_ROOT]);
250
+ for (const record of records) {
251
+ const parsed = parseTreeRecord(record);
252
+ const relative = decodeUtf8(parsed.rawPath, "base authority entry path", "AUTH_REVIEW_BASE_ENTRY_INVALID");
253
+ if (!validateRelativePath(relative)) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", "base authority entry path is not one safe relative portable path");
254
+ const entryPath = `${SPEC_ROOT}/${relative}`;
255
+ if (seen.has(entryPath)) throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", `base authority tree contains a duplicate path: ${entryPath}`);
256
+ seen.add(entryPath);
257
+ if (parsed.mode === "040000" && parsed.objectType === "tree") {
258
+ entries.push({ path: entryPath, type: "directory", bytes: null });
259
+ } else if (["100644", "100755"].includes(parsed.mode) && parsed.objectType === "blob") {
260
+ entries.push({ path: entryPath, type: "regular-file", bytes: null, oid: parsed.oid });
261
+ } else {
262
+ throw refusal("AUTH_REVIEW_BASE_ENTRY_INVALID", `base authority entry is not a regular blob or tree: ${entryPath}`);
263
+ }
264
+ }
265
+ entries.sort((left, right) => compareText(left.path, right.path));
266
+ if (hooks?.afterBaseInventory) await hooks.afterBaseInventory({ repository, baseOid, entries: entries.map(({ path: entryPath, type, oid }) => ({ path: entryPath, type, oid })) });
267
+ for (const entry of entries) {
268
+ if (entry.type !== "regular-file") continue;
269
+ entry.bytes = await checkedGit(
270
+ repository,
271
+ ["cat-file", "blob", entry.oid],
272
+ "AUTH_REVIEW_GIT_OBJECT_MISSING",
273
+ `base authority blob is missing or unreadable: ${entry.path}`,
274
+ );
275
+ delete entry.oid;
276
+ }
277
+ return entries;
278
+ }
279
+
280
+ function statToken(info) {
281
+ return [info.dev, info.ino, info.mode, info.nlink, info.size, info.mtimeNs, info.ctimeNs].map(String).join(":");
282
+ }
283
+
284
+ function sameObject(left, right) {
285
+ return left.dev === right.dev && left.ino === right.ino && left.mode === right.mode;
286
+ }
287
+
288
+ function filesystemFailure(error, initial, entryPath = SPEC_ROOT) {
289
+ if (error instanceof AuthorityReviewRefusal) return error;
290
+ if (!initial && ["ENOENT", "ENOTDIR", "ELOOP"].includes(error?.code)) {
291
+ return refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority inventory changed during capture: ${entryPath}`, entryPath);
292
+ }
293
+ return refusal("AUTH_REVIEW_WORKTREE_INVALID", `worktree authority entry is not safely readable: ${entryPath}`, entryPath);
294
+ }
295
+
296
+ async function securedDirectory(absolute, containmentRoot, entryPath, initial) {
297
+ try {
298
+ const before = await lstat(absolute, { bigint: true });
299
+ if (before.isSymbolicLink() || !before.isDirectory()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority entry is not a regular directory: ${entryPath}`, entryPath);
300
+ const beforeReal = await realpath(absolute);
301
+ if (!isPathWithin(containmentRoot, beforeReal)) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority directory escapes .nimi/spec: ${entryPath}`, entryPath);
302
+ const handle = await open(absolute, fsConstants.O_RDONLY | fsConstants.O_DIRECTORY | fsConstants.O_NOFOLLOW);
303
+ try {
304
+ const held = await handle.stat({ bigint: true });
305
+ const current = await lstat(absolute, { bigint: true });
306
+ const currentReal = await realpath(absolute);
307
+ if (!held.isDirectory() || !sameObject(before, held) || !sameObject(held, current) || !isPathWithin(containmentRoot, currentReal)) {
308
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed during capture: ${entryPath}`, entryPath);
309
+ }
310
+ return { handle, token: statToken(held) };
311
+ } catch (error) {
312
+ await handle.close().catch(() => {});
313
+ throw error;
314
+ }
315
+ } catch (error) {
316
+ throw filesystemFailure(error, initial, entryPath);
317
+ }
318
+ }
319
+
320
+ async function openSecuredFile(absolute, containmentRoot, entryPath, initial) {
321
+ try {
322
+ const before = await lstat(absolute, { bigint: true });
323
+ if (before.isSymbolicLink() || !before.isFile()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority entry is not a regular non-symlink file: ${entryPath}`, entryPath);
324
+ const beforeReal = await realpath(absolute);
325
+ if (containmentRoot !== null && !isPathWithin(containmentRoot, beforeReal)) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority file escapes .nimi/spec: ${entryPath}`, entryPath);
326
+ const handle = await open(absolute, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
327
+ try {
328
+ const held = await handle.stat({ bigint: true });
329
+ const current = await lstat(absolute, { bigint: true });
330
+ const currentReal = await realpath(absolute);
331
+ if (!held.isFile() || !sameObject(before, held) || !sameObject(held, current) || (containmentRoot !== null && !isPathWithin(containmentRoot, currentReal))) {
332
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before capture: ${entryPath}`, entryPath);
333
+ }
334
+ return { handle, token: statToken(held) };
335
+ } catch (error) {
336
+ await handle.close().catch(() => {});
337
+ throw error;
338
+ }
339
+ } catch (error) {
340
+ throw filesystemFailure(error, initial, entryPath);
341
+ }
342
+ }
343
+
344
+ async function readHandleBytes(handle, size, entryPath) {
345
+ if (size > BigInt(Number.MAX_SAFE_INTEGER)) {
346
+ throw refusal("AUTH_REVIEW_WORKTREE_INVALID", `worktree authority file is too large to capture exactly: ${entryPath}`, entryPath);
347
+ }
348
+ let bytes;
349
+ try {
350
+ bytes = Buffer.alloc(Number(size));
351
+ } catch {
352
+ throw refusal("AUTH_REVIEW_WORKTREE_INVALID", `worktree authority file is too large to capture exactly: ${entryPath}`, entryPath);
353
+ }
354
+ let offset = 0;
355
+ while (offset < bytes.length) {
356
+ const { bytesRead } = await handle.read(bytes, offset, bytes.length - offset, offset);
357
+ if (bytesRead === 0) break;
358
+ offset += bytesRead;
359
+ }
360
+ if (offset !== bytes.length) throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed while bytes were captured: ${entryPath}`, entryPath);
361
+ return bytes;
362
+ }
363
+
364
+ async function readSecuredFile(secured, absolute, containmentRoot, entryPath, initial, budget = null) {
365
+ try {
366
+ const heldBefore = await secured.handle.stat({ bigint: true });
367
+ const currentBefore = await lstat(absolute, { bigint: true });
368
+ const currentReal = await realpath(absolute);
369
+ if (!heldBefore.isFile() || statToken(heldBefore) !== secured.token || !sameObject(heldBefore, currentBefore) || (containmentRoot !== null && !isPathWithin(containmentRoot, currentReal))) {
370
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before bytes were captured: ${entryPath}`, entryPath);
371
+ }
372
+ if (budget !== null) budget.reserve(heldBefore.size, entryPath);
373
+ const bytes = await readHandleBytes(secured.handle, heldBefore.size, entryPath);
374
+ const heldAfter = await secured.handle.stat({ bigint: true });
375
+ const currentAfter = await lstat(absolute, { bigint: true });
376
+ if (statToken(heldBefore) !== statToken(heldAfter) || !sameObject(heldAfter, currentAfter) || BigInt(bytes.length) !== heldAfter.size) {
377
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed while bytes were captured: ${entryPath}`, entryPath);
378
+ }
379
+ return { bytes, token: statToken(heldAfter) };
380
+ } catch (error) {
381
+ throw filesystemFailure(error, initial, entryPath);
382
+ }
383
+ }
384
+
385
+ async function securedFile(absolute, containmentRoot, entryPath, initial, retainHandle = false, budget = null) {
386
+ const secured = await openSecuredFile(absolute, containmentRoot, entryPath, initial);
387
+ let retained = false;
388
+ try {
389
+ const captured = await readSecuredFile(secured, absolute, containmentRoot, entryPath, initial, budget);
390
+ if (retainHandle) {
391
+ retained = true;
392
+ return { ...captured, handle: secured.handle };
393
+ }
394
+ return captured;
395
+ } finally {
396
+ if (!retained) await secured.handle.close();
397
+ }
398
+ }
399
+
400
+ function decodeFileName(name) {
401
+ const bytes = Buffer.isBuffer(name) ? name : Buffer.from(name);
402
+ const value = decodeUtf8(bytes, "worktree authority entry name", "AUTH_REVIEW_WORKTREE_ENTRY_INVALID");
403
+ if (value.length === 0 || value === "." || value === ".." || value.includes("/") || value.includes("\\")) {
404
+ throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", "worktree authority entry name is not one safe portable path component");
405
+ }
406
+ return value;
407
+ }
408
+
409
+ async function closeHeldEntries(heldEntries) {
410
+ await Promise.all(heldEntries.map(({ handle }) => handle.close().catch(() => {})));
411
+ }
412
+
413
+ async function scanWorktree(repository, initial, { retainHandles = false, budget = null } = {}) {
414
+ const nimiPath = path.join(repository, ".nimi");
415
+ const specPath = path.join(repository, ".nimi", "spec");
416
+ try {
417
+ const nimi = await lstat(nimiPath, { bigint: true });
418
+ const spec = await lstat(specPath, { bigint: true });
419
+ if (nimi.isSymbolicLink() || !nimi.isDirectory() || spec.isSymbolicLink() || !spec.isDirectory()) {
420
+ throw refusal("AUTH_REVIEW_WORKTREE_INVALID", "worktree .nimi/spec must be one regular non-symlink directory", SPEC_ROOT);
421
+ }
422
+ } catch (error) {
423
+ if (!initial && !(error instanceof AuthorityReviewRefusal)) throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", "worktree .nimi/spec changed during capture", SPEC_ROOT);
424
+ throw filesystemFailure(error, initial, SPEC_ROOT);
425
+ }
426
+ const specReal = await realpath(specPath);
427
+ const entries = [];
428
+ const heldEntries = [];
429
+
430
+ async function walk(absolute, entryPath) {
431
+ const secured = await securedDirectory(absolute, specReal, entryPath, initial);
432
+ entries.push({ path: entryPath, type: "directory", bytes: null, token: secured.token });
433
+ if (retainHandles) heldEntries.push({ path: entryPath, type: "directory", absolute, containmentRoot: specReal, ...secured });
434
+ try {
435
+ let children;
436
+ try {
437
+ children = await readdir(absolute, { withFileTypes: true, encoding: "buffer" });
438
+ } catch (error) {
439
+ throw filesystemFailure(error, initial, entryPath);
440
+ }
441
+ const named = children.map((entry) => decodeFileName(entry.name)).sort(compareText);
442
+ if (new Set(named).size !== named.length) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority directory contains duplicate decoded paths: ${entryPath}`, entryPath);
443
+ for (const name of named) {
444
+ const childAbsolute = path.join(absolute, name);
445
+ const childPath = `${entryPath}/${name}`;
446
+ let childInfo;
447
+ try {
448
+ childInfo = await lstat(childAbsolute, { bigint: true });
449
+ } catch (error) {
450
+ throw filesystemFailure(error, initial, childPath);
451
+ }
452
+ if (childInfo.isSymbolicLink()) throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority input refuses symbolic link: ${childPath}`, childPath);
453
+ if (childInfo.isDirectory()) await walk(childAbsolute, childPath);
454
+ else if (childInfo.isFile()) {
455
+ const captured = await securedFile(childAbsolute, specReal, childPath, initial, retainHandles, budget);
456
+ entries.push({ path: childPath, type: "regular-file", bytes: captured.bytes, token: captured.token });
457
+ if (retainHandles) heldEntries.push({ path: childPath, type: "regular-file", absolute: childAbsolute, containmentRoot: specReal, ...captured });
458
+ } else throw refusal("AUTH_REVIEW_WORKTREE_ENTRY_INVALID", `worktree authority input contains a non-regular filesystem entry: ${childPath}`, childPath);
459
+ }
460
+ const current = await lstat(absolute, { bigint: true });
461
+ const currentReal = await realpath(absolute);
462
+ const held = await secured.handle.stat({ bigint: true });
463
+ if (statToken(held) !== secured.token || !sameObject(held, current) || !isPathWithin(specReal, currentReal)) {
464
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed during traversal: ${entryPath}`, entryPath);
465
+ }
466
+ } finally {
467
+ if (!retainHandles) await secured.handle.close();
468
+ }
469
+ }
470
+
471
+ try {
472
+ await walk(specPath, SPEC_ROOT);
473
+ entries.sort((left, right) => compareText(left.path, right.path));
474
+ heldEntries.sort((left, right) => compareText(left.path, right.path));
475
+ return { entries, heldEntries };
476
+ } catch (error) {
477
+ await closeHeldEntries(heldEntries);
478
+ throw error;
479
+ }
480
+ }
481
+
482
+ async function verifyHeldEntries(heldEntries) {
483
+ const results = await Promise.allSettled(heldEntries.map(async (entry) => {
484
+ if (entry.type === "regular-file") {
485
+ const captured = await readSecuredFile(entry, entry.absolute, entry.containmentRoot, entry.path, false);
486
+ if (captured.token !== entry.token || !captured.bytes.equals(entry.bytes)) {
487
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority file changed before capture commit: ${entry.path}`, entry.path);
488
+ }
489
+ return;
490
+ }
491
+ try {
492
+ const held = await entry.handle.stat({ bigint: true });
493
+ const current = await lstat(entry.absolute, { bigint: true });
494
+ const currentReal = await realpath(entry.absolute);
495
+ if (!held.isDirectory() || statToken(held) !== entry.token || !sameObject(held, current) || !isPathWithin(entry.containmentRoot, currentReal)) {
496
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `worktree authority directory changed before capture commit: ${entry.path}`, entry.path);
497
+ }
498
+ } catch (error) {
499
+ throw filesystemFailure(error, false, entry.path);
500
+ }
501
+ }));
502
+ const failed = results.find((result) => result.status === "rejected");
503
+ if (failed) throw failed.reason;
504
+ }
505
+
506
+ function compareCapturedEntries(first, second, includeTokens) {
507
+ if (first.length !== second.length) return false;
508
+ for (let index = 0; index < first.length; index += 1) {
509
+ const left = first[index];
510
+ const right = second[index];
511
+ if (left.path !== right.path || left.type !== right.type) return false;
512
+ if (includeTokens && left.token !== right.token) return false;
513
+ if (left.type === "regular-file" && !left.bytes.equals(right.bytes)) return false;
514
+ }
515
+ return true;
516
+ }
517
+
518
+ function semanticEntries(entries) {
519
+ return entries.map(({ path: entryPath, type, bytes }) => ({ path: entryPath, type, bytes }));
520
+ }
521
+
522
+ function frame(hash, value) {
523
+ const bytes = Buffer.isBuffer(value) ? value : Buffer.from(value, "utf8");
524
+ const length = Buffer.alloc(8);
525
+ length.writeBigUInt64BE(BigInt(bytes.length));
526
+ hash.update(length);
527
+ hash.update(bytes);
528
+ }
529
+
530
+ export function authoritySnapshotContentIdentity(entries) {
531
+ const ordered = entries
532
+ .filter((entry) => entry.type === "regular-file")
533
+ .sort((left, right) => compareText(left.path, right.path));
534
+ const hash = createHash("sha256");
535
+ frame(hash, SNAPSHOT_IDENTITY_FORMAT);
536
+ frame(hash, String(ordered.length));
537
+ for (const entry of ordered) {
538
+ frame(hash, entry.type);
539
+ frame(hash, entry.path);
540
+ frame(hash, entry.bytes ?? Buffer.alloc(0));
541
+ }
542
+ return `sha256:${hash.digest("hex")}`;
543
+ }
544
+
545
+ function snapshotCounts(entries) {
546
+ const files = entries.filter((entry) => entry.type === "regular-file");
547
+ return {
548
+ fileCount: files.length,
549
+ byteCount: files.reduce((total, entry) => total + entry.bytes.length, 0),
550
+ };
551
+ }
552
+
553
+ async function materializeEntries(root, entries, invalidCode) {
554
+ const directories = entries.filter((entry) => entry.type === "directory").sort((left, right) => left.path.split("/").length - right.path.split("/").length || compareText(left.path, right.path));
555
+ const files = entries.filter((entry) => entry.type === "regular-file").sort((left, right) => compareText(left.path, right.path));
556
+ try {
557
+ for (const entry of directories) await mkdir(path.join(root, ...entry.path.split("/")), { recursive: true, mode: 0o700 });
558
+ for (const entry of files) {
559
+ const target = path.join(root, ...entry.path.split("/"));
560
+ await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
561
+ await writeFile(target, entry.bytes, { flag: "wx", mode: 0o600 });
562
+ }
563
+ } catch (error) {
564
+ if (["EEXIST", "EINVAL", "ENAMETOOLONG", "ENOTDIR"].includes(error?.code)) {
565
+ throw refusal(invalidCode, "authority snapshot paths cannot be represented without collision or escape");
566
+ }
567
+ throw error;
568
+ }
569
+ const readback = semanticEntries((await scanWorktree(root, true)).entries);
570
+ if (!compareCapturedEntries(entries, readback, false)) throw refusal(invalidCode, "materialized authority snapshot does not match captured paths, types, and bytes");
571
+ }
572
+
573
+ async function captureWorktree(repository, hooks) {
574
+ const { entries: first, heldEntries } = await scanWorktree(repository, true, { retainHandles: true });
575
+ try {
576
+ if (hooks?.afterWorktreeCapture) await hooks.afterWorktreeCapture({ repository, entries: semanticEntries(first) });
577
+ const { entries: second } = await scanWorktree(repository, false);
578
+ if (!compareCapturedEntries(first, second, true)) throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", "worktree authority paths, types, metadata, or bytes changed during capture", SPEC_ROOT);
579
+ if (hooks?.beforeWorktreeCaptureCommit) await hooks.beforeWorktreeCaptureCommit({ repository, entries: semanticEntries(first) });
580
+ await verifyHeldEntries(heldEntries);
581
+ return semanticEntries(first);
582
+ } finally {
583
+ await closeHeldEntries(heldEntries);
584
+ }
585
+ }
586
+
587
+ function stableRegularFileBudget(maxBytes, label) {
588
+ if (maxBytes === null) return null;
589
+ return {
590
+ reserve(size) {
591
+ if (size > BigInt(maxBytes)) {
592
+ throw refusal("AUTH_REVIEW_INPUT_BUDGET", `review input requires ${size} bytes but maxBytes is ${maxBytes}: ${label}`, label);
593
+ }
594
+ },
595
+ };
596
+ }
597
+
598
+ export async function captureStableRegularFile(file, label, containmentRoot = null, maxBytes = null) {
599
+ const absolute = path.resolve(process.cwd(), file);
600
+ const first = await securedFile(absolute, containmentRoot, label, true, true, stableRegularFileBudget(maxBytes, label));
601
+ try {
602
+ const second = await securedFile(absolute, containmentRoot, label, false, false, stableRegularFileBudget(maxBytes, label));
603
+ const committed = await readSecuredFile(first, absolute, containmentRoot, label, false, stableRegularFileBudget(maxBytes, label));
604
+ if (first.token !== second.token
605
+ || first.token !== committed.token
606
+ || !first.bytes.equals(second.bytes)
607
+ || !first.bytes.equals(committed.bytes)) {
608
+ throw refusal("AUTH_REVIEW_CAPTURE_CHANGED", `review input changed during capture: ${label}`, label);
609
+ }
610
+ return first.bytes;
611
+ } finally {
612
+ await first.handle.close().catch(() => {});
613
+ }
614
+ }
615
+
616
+ async function createTemporaryRoot(protectedRoots, prefix = "nimicoding-authority-review-") {
617
+ let base;
618
+ try {
619
+ base = await realpath(os.tmpdir());
620
+ const info = await lstat(base);
621
+ if (!info.isDirectory()) throw new Error("not a directory");
622
+ } catch {
623
+ throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "system temporary directory is not one readable physical directory");
624
+ }
625
+ if (protectedRoots.some((protectedRoot) => isPathWithin(protectedRoot, base))) {
626
+ throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "system temporary directory must be outside the reviewed worktree and Git administration directories");
627
+ }
628
+ const created = await mkdtemp(path.join(base, prefix));
629
+ try {
630
+ const temporaryRoot = await realpath(created);
631
+ const info = await lstat(temporaryRoot);
632
+ if (!info.isDirectory() || protectedRoots.some((protectedRoot) => isPathWithin(protectedRoot, temporaryRoot) || isPathWithin(temporaryRoot, protectedRoot))) {
633
+ throw refusal("AUTH_REVIEW_TEMPORARY_INVALID", "authority snapshots require one isolated temporary directory outside the reviewed repository");
634
+ }
635
+ return temporaryRoot;
636
+ } catch (error) {
637
+ await rm(created, { recursive: true, force: true });
638
+ throw error;
639
+ }
640
+ }
641
+
642
+ export async function listGitTrackedFiles(repositoryPath) {
643
+ const { repository } = await resolveRepository(repositoryPath);
644
+ const bytes = await checkedGit(
645
+ repository,
646
+ ["ls-files", "--stage", "--full-name", "-z"],
647
+ "AUTH_REVIEW_GIT_INDEX_INVALID",
648
+ "Git tracked-file inventory cannot be read",
649
+ );
650
+ const files = [];
651
+ const gitlinks = [];
652
+ let offset = 0;
653
+ while (offset < bytes.length) {
654
+ const end = bytes.indexOf(0, offset);
655
+ if (end < 0) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory is not NUL-terminated");
656
+ if (end === offset) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an empty path");
657
+ const record = bytes.subarray(offset, end);
658
+ const tab = record.indexOf(9);
659
+ if (tab <= 0 || tab === record.length - 1) {
660
+ throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains a malformed index entry");
661
+ }
662
+ const metadata = decodeUtf8(
663
+ record.subarray(0, tab),
664
+ "Git tracked-file metadata",
665
+ "AUTH_REVIEW_GIT_INDEX_INVALID",
666
+ );
667
+ const match = /^(100644|100755|120000|160000) (?:[0-9a-f]{40}|[0-9a-f]{64}) ([0-3])$/.exec(metadata);
668
+ if (match === null) {
669
+ throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an unsupported index entry");
670
+ }
671
+ const file = decodeUtf8(record.subarray(tab + 1), "Git tracked-file path", "AUTH_REVIEW_GIT_INDEX_INVALID");
672
+ if (!validateRelativePath(file)) throw refusal("AUTH_REVIEW_GIT_INDEX_INVALID", "Git tracked-file inventory contains an unsafe path");
673
+ files.push(file);
674
+ if (match[1] === "160000") gitlinks.push(file);
675
+ offset = end + 1;
676
+ }
677
+ return {
678
+ repository,
679
+ files: [...new Set(files)].sort(compareText),
680
+ gitlinks: [...new Set(gitlinks)].sort(compareText),
681
+ };
682
+ }
683
+
684
+ export async function withGitAuthoritySnapshots({ repositoryPath, baseRef, hooks = null }, callback) {
685
+ const { repository, protectedRoots } = await resolveRepository(repositoryPath);
686
+ const baseOid = await resolveBaseOid(repository, baseRef);
687
+ if (hooks?.afterBaseResolved) await hooks.afterBaseResolved({ repository, baseOid });
688
+ const baseEntries = await baseTreeEntries(repository, baseOid, hooks);
689
+ const worktreeEntries = await captureWorktree(repository, hooks);
690
+ const temporaryRoot = await createTemporaryRoot(protectedRoots);
691
+ const baseRoot = path.join(temporaryRoot, "base");
692
+ const worktreeRoot = path.join(temporaryRoot, "worktree");
693
+ try {
694
+ await mkdir(baseRoot, { mode: 0o700 });
695
+ await mkdir(worktreeRoot, { mode: 0o700 });
696
+ await materializeEntries(baseRoot, baseEntries, "AUTH_REVIEW_BASE_ENTRY_INVALID");
697
+ await materializeEntries(worktreeRoot, worktreeEntries, "AUTH_REVIEW_WORKTREE_ENTRY_INVALID");
698
+ const baseCounts = snapshotCounts(baseEntries);
699
+ const worktreeCounts = snapshotCounts(worktreeEntries);
700
+ return await callback({
701
+ repository,
702
+ baseOid,
703
+ temporaryRoot,
704
+ base: {
705
+ root: baseRoot,
706
+ contentIdentity: authoritySnapshotContentIdentity(baseEntries),
707
+ ...baseCounts,
708
+ },
709
+ worktree: {
710
+ root: worktreeRoot,
711
+ contentIdentity: authoritySnapshotContentIdentity(worktreeEntries),
712
+ ...worktreeCounts,
713
+ },
714
+ });
715
+ } finally {
716
+ await rm(temporaryRoot, { recursive: true, force: true });
717
+ }
718
+ }
719
+
720
+ export { SNAPSHOT_IDENTITY_FORMAT, SPEC_ROOT };