@nimiplatform/nimi-coding 0.3.0 → 0.4.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 (118) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/README.md +285 -41
  4. package/README.zh-CN.md +294 -31
  5. package/bin/nimicoding.mjs +1 -1
  6. package/cli/commands/authority.mjs +849 -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 +173 -0
  13. package/cli/dev/check-product-conformance.mjs +917 -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 +490 -0
  18. package/cli/lib/authority/closed-sets.mjs +646 -0
  19. package/cli/lib/authority/compile.mjs +110 -0
  20. package/cli/lib/authority/consumers.mjs +470 -0
  21. package/cli/lib/authority/diagnostics.mjs +87 -0
  22. package/cli/lib/authority/diff.mjs +287 -0
  23. package/cli/lib/authority/discover.mjs +447 -0
  24. package/cli/lib/authority/evidence-snapshot.mjs +1 -0
  25. package/cli/lib/authority/evidence.mjs +1432 -0
  26. package/cli/lib/authority/format.mjs +197 -0
  27. package/cli/lib/authority/git-snapshot.mjs +1065 -0
  28. package/cli/lib/authority/graph.mjs +351 -0
  29. package/cli/lib/authority/impact.mjs +252 -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 +222 -0
  35. package/cli/lib/authority/sarif.mjs +181 -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 +312 -0
  40. package/cli/lib/authority/surface.mjs +79 -0
  41. package/cli/lib/authority/terms.mjs +421 -0
  42. package/cli/lib/authority/tracked-text.mjs +70 -0
  43. package/cli/lib/authority/validate.mjs +308 -0
  44. package/cli/lib/bootstrap.mjs +55 -71
  45. package/cli/lib/doctor.mjs +40 -123
  46. package/cli/lib/entrypoints.mjs +103 -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-evidence-bindings.schema.yaml +53 -0
  56. package/contracts/authority-evidence-probe-results.schema.yaml +38 -0
  57. package/contracts/authority-impact-dispositions.schema.yaml +13 -0
  58. package/contracts/authority-scope-bindings.schema.yaml +15 -0
  59. package/contracts/authority-source.schema.yaml +64 -0
  60. package/contracts/authority-verifier-bindings.schema.yaml +20 -0
  61. package/methodology/authority-authoring.yaml +144 -0
  62. package/methodology/core.yaml +31 -26
  63. package/package.json +10 -6
  64. package/spec/authority-anchors.yaml +99 -0
  65. package/spec/authority-audit.yaml +85 -0
  66. package/spec/authority-authoring.yaml +150 -0
  67. package/spec/authority-closed-sets.yaml +65 -0
  68. package/spec/authority-consumers.yaml +70 -0
  69. package/spec/authority-context.yaml +50 -0
  70. package/spec/authority-discovery.yaml +119 -0
  71. package/spec/authority-evidence.yaml +200 -0
  72. package/spec/authority-graph.yaml +59 -0
  73. package/spec/authority-impact.yaml +84 -0
  74. package/spec/authority-review.yaml +89 -0
  75. package/spec/authority-terms.yaml +59 -0
  76. package/spec/product-scope.yaml +36 -23
  77. package/cli/commands/blueprint-audit.mjs +0 -91
  78. package/cli/commands/classify-spec-tree.mjs +0 -5
  79. package/cli/commands/generate-spec-derived-docs.mjs +0 -151
  80. package/cli/commands/generate-spec-migration-plan.mjs +0 -30
  81. package/cli/commands/surface-validator-command.mjs +0 -49
  82. package/cli/commands/validate-domain-admission.mjs +0 -5
  83. package/cli/commands/validate-guidance-bodies.mjs +0 -5
  84. package/cli/commands/validate-placement.mjs +0 -5
  85. package/cli/commands/validate-projection-edges.mjs +0 -5
  86. package/cli/commands/validate-spec-audit.mjs +0 -27
  87. package/cli/commands/validate-spec-governance.mjs +0 -124
  88. package/cli/commands/validate-spec-tree.mjs +0 -27
  89. package/cli/commands/validate-table-family.mjs +0 -5
  90. package/cli/commands/validate-tracked-output-admission.mjs +0 -5
  91. package/cli/lib/blueprint-audit.mjs +0 -363
  92. package/cli/lib/contracts.mjs +0 -21
  93. package/cli/lib/internal/contracts-loaders.mjs +0 -47
  94. package/cli/lib/internal/contracts-parse.mjs +0 -192
  95. package/cli/lib/internal/governance/runner.mjs +0 -35
  96. package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
  97. package/cli/lib/internal/validators-shared.mjs +0 -12
  98. package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
  99. package/cli/lib/internal/validators-spec.mjs +0 -380
  100. package/cli/lib/validators.mjs +0 -30
  101. package/config/bootstrap.yaml +0 -6
  102. package/config/spec-generation-inputs.yaml +0 -41
  103. package/contracts/domain-admission.schema.yaml +0 -56
  104. package/contracts/migration-inventory.schema.yaml +0 -15
  105. package/contracts/negative-fixtures.yaml +0 -26
  106. package/contracts/placement-contract.schema.yaml +0 -127
  107. package/contracts/projection-edge.schema.yaml +0 -46
  108. package/contracts/shared-enums.yaml +0 -63
  109. package/contracts/spec-generation-audit.schema.yaml +0 -46
  110. package/contracts/spec-generation-inputs.schema.yaml +0 -43
  111. package/contracts/spec-layout.schema.yaml +0 -32
  112. package/contracts/surface-taxonomy.schema.yaml +0 -139
  113. package/contracts/table-family.schema.yaml +0 -133
  114. package/contracts/tracked-output-admission.schema.yaml +0 -17
  115. package/methodology/four-closure-policy.yaml +0 -20
  116. package/methodology/role-separation-policy.yaml +0 -22
  117. package/methodology/spec-reconstruction.yaml +0 -69
  118. package/spec/_meta/spec-tree-model.yaml +0 -44
@@ -0,0 +1,421 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { lstat, readFile, realpath } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { compileAuthorityPath } from "./compile.mjs";
6
+ import { compareText, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
7
+ import { AuthorityInputError } from "./format.mjs";
8
+ import { AuthorityReviewRefusal } from "./git-snapshot.mjs";
9
+ import { resolveRepositoryCorpus } from "./repository-corpus.mjs";
10
+ import { repositoryInventory } from "./repository-inventory.mjs";
11
+ import { CODE_EXTENSIONS } from "./lexicon.mjs";
12
+ import { classifyTrackedTextFiles, trackedTextInventory } from "./tracked-text.mjs";
13
+
14
+ // Only used to keep "foo.ts" from being read as a dotted term; file selection
15
+ // is decided by content, not by this list.
16
+ const CODE_EXTENSION_SET = new Set(CODE_EXTENSIONS);
17
+ const TERMS_FORMAT = "nimicoding.authority-terms/v1";
18
+ const TERM_CLASS_ORDER = ["snake_case", "camelCase", "UPPER_SNAKE", "PascalCase", "dotted"];
19
+ const TERM_EXPRESSIONS = [
20
+ ["snake_case", /[a-z][a-z0-9]*(?:_[a-z0-9]+)+/gu],
21
+ ["camelCase", /[a-z][a-z0-9]*(?:[A-Z][a-z0-9]*)+/gu],
22
+ ["UPPER_SNAKE", /[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+/gu],
23
+ ["PascalCase", /[A-Z][a-z0-9]+(?:[A-Z][a-z0-9]*)+/gu],
24
+ ["dotted", /[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+/gu],
25
+ ];
26
+ const AUTHORITY_FIELDS = ["title", "meaning", "statement", "condition", "failure"];
27
+ const IDENTIFIER_CHARACTER = /[A-Za-z0-9_]/u;
28
+ const PATH_TOKEN_CHARACTER = /[A-Za-z0-9_./-]/u;
29
+ const UTF8 = new TextDecoder("utf-8", { fatal: true });
30
+
31
+ export const AUTHORITY_TERM_GRAMMAR_HELP = [
32
+ "Authority terms use a closed lexical grammar over active title, meaning, statement, condition, and failure fields:",
33
+ " snake_case: [a-z][a-z0-9]*(_[a-z0-9]+)+",
34
+ " camelCase: [a-z][a-z0-9]*([A-Z][a-z0-9]*)+",
35
+ " UPPER_SNAKE: [A-Z][A-Z0-9]*(_[A-Z0-9]+)+",
36
+ " PascalCase: [A-Z][a-z0-9]+([A-Z][a-z0-9]*)+",
37
+ " dotted: [A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+, excluding final segments that are code extensions.",
38
+ "Matches require ASCII identifier boundaries and are excluded from longer path-like tokens containing '/'.",
39
+ ].join("\n");
40
+
41
+ function validBudget(value) {
42
+ return Number.isSafeInteger(value) && value > 0;
43
+ }
44
+
45
+ function baseDiagnostic(code, reason, repair, file = ".") {
46
+ return makeDiagnostic({
47
+ code,
48
+ file,
49
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
50
+ reason,
51
+ repair,
52
+ });
53
+ }
54
+
55
+ function budgetDiagnostic(kind, required, admitted) {
56
+ const option = kind === "active units" ? "max-units" : kind === "distinct terms" ? "max-terms" : "max-bytes";
57
+ return baseDiagnostic(
58
+ "AUTH_TERMS_BUDGET",
59
+ `complete authority term analysis requires ${required} ${kind} but ${option} is ${admitted}`,
60
+ "increase the explicit term-analysis budget; partial indexes and cross-references are forbidden",
61
+ );
62
+ }
63
+
64
+ function refused(diagnostics, { fileCount = 0 } = {}) {
65
+ return {
66
+ ok: false,
67
+ complete: false,
68
+ partial: false,
69
+ diagnostics: sortDiagnostics(diagnostics),
70
+ fileCount,
71
+ unitCount: 0,
72
+ termCount: 0,
73
+ scannedBytes: 0,
74
+ inputBytes: 0,
75
+ terms: null,
76
+ };
77
+ }
78
+
79
+ function inputDiagnostic(code, reason, file = ".") {
80
+ return baseDiagnostic(
81
+ code,
82
+ reason,
83
+ "provide one exact readable Git repository, admitted canonical corpus, and valid UTF-8 tracked code inputs; partial analysis is forbidden",
84
+ file,
85
+ );
86
+ }
87
+
88
+ function isWithin(root, candidate) {
89
+ const relative = path.relative(root, candidate);
90
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
91
+ }
92
+
93
+ function portableRelative(repository, candidate) {
94
+ return path.relative(repository, candidate).split(path.sep).join(path.posix.sep);
95
+ }
96
+
97
+ async function resolveCorpus(repository, specPath) {
98
+ return resolveRepositoryCorpus(repository, specPath, "authority term");
99
+ }
100
+
101
+ function hasIdentifierBoundary(text, start, end) {
102
+ const preceding = start === 0 ? "" : text[start - 1];
103
+ const following = end === text.length ? "" : text[end];
104
+ return !IDENTIFIER_CHARACTER.test(preceding) && !IDENTIFIER_CHARACTER.test(following);
105
+ }
106
+
107
+ function insidePathToken(text, start, end) {
108
+ let tokenStart = start;
109
+ let tokenEnd = end;
110
+ while (tokenStart > 0 && PATH_TOKEN_CHARACTER.test(text[tokenStart - 1])) tokenStart -= 1;
111
+ while (tokenEnd < text.length && PATH_TOKEN_CHARACTER.test(text[tokenEnd])) tokenEnd += 1;
112
+ return text.slice(tokenStart, tokenEnd).includes("/");
113
+ }
114
+
115
+ function finalDottedSegment(term) {
116
+ return term.slice(term.lastIndexOf(".") + 1);
117
+ }
118
+
119
+ export function extractLexicalTerms(text) {
120
+ const terms = new Map();
121
+ for (const [termClass, expression] of TERM_EXPRESSIONS) {
122
+ for (const match of text.matchAll(expression)) {
123
+ const start = match.index;
124
+ const end = start + match[0].length;
125
+ if (!hasIdentifierBoundary(text, start, end) || insidePathToken(text, start, end)) continue;
126
+ if (termClass === "dotted" && CODE_EXTENSION_SET.has(finalDottedSegment(match[0]))) continue;
127
+ if (!terms.has(match[0])) terms.set(match[0], new Set());
128
+ terms.get(match[0]).add(termClass);
129
+ }
130
+ }
131
+ return [...terms.entries()]
132
+ .sort(([left], [right]) => compareText(left, right))
133
+ .map(([term, classes]) => ({ term, classes: TERM_CLASS_ORDER.filter((termClass) => classes.has(termClass)) }));
134
+ }
135
+
136
+ function unitFieldEntries(unit) {
137
+ const entries = [["title", unit.metadata.title]];
138
+ if (unit.kind === "definition") entries.push(["meaning", unit.semantic.meaning]);
139
+ else entries.push(
140
+ ["statement", unit.semantic.statement],
141
+ ["condition", unit.semantic.condition],
142
+ ["failure", unit.semantic.failure],
143
+ );
144
+ return entries;
145
+ }
146
+
147
+ function sourceContainer(compiled, unitIndex) {
148
+ const mapped = compiled.sourceMap.fields[`/units/${unitIndex}/metadata/title`];
149
+ return path.basename(mapped.file);
150
+ }
151
+
152
+ function extractCorpusTerms(compiled) {
153
+ const active = [];
154
+ const records = new Map();
155
+ let scannedBytes = 0;
156
+ compiled.ir.units.forEach((unit, unitIndex) => {
157
+ if (unit.lifecycle !== "active") return;
158
+ const container = sourceContainer(compiled, unitIndex);
159
+ const fields = new Map();
160
+ active.push({ unit, unitIndex });
161
+ for (const [field, text] of unitFieldEntries(unit)) {
162
+ scannedBytes += Buffer.byteLength(text, "utf8");
163
+ for (const extracted of extractLexicalTerms(text)) {
164
+ if (!fields.has(extracted.term)) fields.set(extracted.term, { classes: new Set(), fields: new Set() });
165
+ const occurrence = fields.get(extracted.term);
166
+ extracted.classes.forEach((termClass) => occurrence.classes.add(termClass));
167
+ occurrence.fields.add(field);
168
+ }
169
+ }
170
+ for (const [term, occurrence] of fields) {
171
+ if (!records.has(term)) records.set(term, { classes: new Set(), units: new Map(), containers: new Set() });
172
+ const record = records.get(term);
173
+ occurrence.classes.forEach((termClass) => record.classes.add(termClass));
174
+ record.units.set(unit.id, { fields: occurrence.fields, container });
175
+ record.containers.add(container);
176
+ }
177
+ });
178
+ return { active, records, scannedBytes };
179
+ }
180
+
181
+ function classCounts(records) {
182
+ const counts = Object.fromEntries(TERM_CLASS_ORDER.map((termClass) => [termClass, 0]));
183
+ for (const record of records.values()) {
184
+ for (const termClass of record.classes) counts[termClass] += 1;
185
+ }
186
+ return counts;
187
+ }
188
+
189
+ function indexEntries(records) {
190
+ return [...records.entries()].sort(([left], [right]) => compareText(left, right)).map(([term, record]) => {
191
+ const unitIds = [...record.units.keys()].sort(compareText);
192
+ return {
193
+ term,
194
+ classes: TERM_CLASS_ORDER.filter((termClass) => record.classes.has(termClass)),
195
+ unitCount: unitIds.length,
196
+ containers: [...record.containers].sort(compareText),
197
+ unitIds: unitIds.slice(0, 20),
198
+ ...(unitIds.length > 20 ? { truncatedUnitIds: true } : {}),
199
+ };
200
+ });
201
+ }
202
+
203
+ function exactDetail(term, termClasses, records, code, codeReferences, codeInventory) {
204
+ const record = records.get(term);
205
+ const units = record === undefined ? [] : [...record.units.entries()]
206
+ .sort(([left], [right]) => compareText(left, right))
207
+ .map(([unitId, occurrence]) => ({ unitId, fields: [...occurrence.fields].sort(compareText) }));
208
+ return {
209
+ format: TERMS_FORMAT,
210
+ complete: true,
211
+ mode: "detail",
212
+ term,
213
+ classes: termClasses,
214
+ unitCount: units.length,
215
+ units,
216
+ code,
217
+ ...(code ? { codeReferences, codeInventory } : {}),
218
+ };
219
+ }
220
+
221
+ function atOrBelow(file, root) {
222
+ return file === root || file.startsWith(`${root}/`);
223
+ }
224
+
225
+ function corpusFile(file, corpus) {
226
+ if (atOrBelow(file, ".nimi/spec")) return true;
227
+ return corpus.directory ? atOrBelow(file, corpus.relative) : file === corpus.relative;
228
+ }
229
+
230
+ async function codeFileBytes(repository, files) {
231
+ let total = 0n;
232
+ for (const file of files) {
233
+ let metadata;
234
+ try {
235
+ metadata = await lstat(path.join(repository, ...file.split("/")), { bigint: true });
236
+ } catch (error) {
237
+ throw new Error(`${file} is not readable: ${error.message}`);
238
+ }
239
+ if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error(`${file} must be one regular non-symlink file`);
240
+ total += metadata.size;
241
+ }
242
+ return total;
243
+ }
244
+
245
+ function exactLineReferences(file, text, term) {
246
+ const references = [];
247
+ let offset = 0;
248
+ let line = 1;
249
+ let lineCursor = 0;
250
+ let lastLine = 0;
251
+ while (offset <= text.length - term.length) {
252
+ const index = text.indexOf(term, offset);
253
+ if (index < 0) break;
254
+ while (lineCursor < index) {
255
+ if (text[lineCursor] === "\n") line += 1;
256
+ lineCursor += 1;
257
+ }
258
+ if (hasIdentifierBoundary(text, index, index + term.length) && line !== lastLine) {
259
+ references.push({ file, line });
260
+ lastLine = line;
261
+ }
262
+ offset = index + Math.max(term.length, 1);
263
+ }
264
+ return references;
265
+ }
266
+
267
+ async function scanCode(repository, files, term, authorityInputBytes, authorityScannedBytes, maxBytes) {
268
+ let statedBytes;
269
+ try {
270
+ statedBytes = await codeFileBytes(repository, files);
271
+ } catch (error) {
272
+ return { input: inputDiagnostic("AUTH_TERMS_CODE_INPUT_INVALID", error.message), budget: null };
273
+ }
274
+ const statedTotal = BigInt(authorityInputBytes) + statedBytes;
275
+ if (statedTotal > BigInt(maxBytes)) {
276
+ return { input: null, budget: budgetDiagnostic("UTF-8 input bytes", statedTotal.toString(), maxBytes) };
277
+ }
278
+
279
+ const texts = [];
280
+ let inputBytes = authorityInputBytes;
281
+ let scannedBytes = authorityScannedBytes;
282
+ for (const file of files) {
283
+ let bytes;
284
+ try {
285
+ bytes = await readFile(path.join(repository, ...file.split("/")));
286
+ } catch (error) {
287
+ return { input: inputDiagnostic("AUTH_TERMS_CODE_INPUT_INVALID", `${file} is not readable: ${error.message}`, file), budget: null };
288
+ }
289
+ inputBytes += bytes.length;
290
+ scannedBytes += bytes.length;
291
+ if (inputBytes > maxBytes) return { input: null, budget: budgetDiagnostic("UTF-8 input bytes", inputBytes, maxBytes) };
292
+ try {
293
+ texts.push({ file, text: UTF8.decode(bytes) });
294
+ } catch {
295
+ return { input: inputDiagnostic("AUTH_TERMS_CODE_INPUT_INVALID", `${file} is not valid UTF-8`, file), budget: null };
296
+ }
297
+ }
298
+ return {
299
+ input: null,
300
+ budget: null,
301
+ inputBytes,
302
+ scannedBytes,
303
+ references: texts.flatMap(({ file, text }) => exactLineReferences(file, text, term)),
304
+ };
305
+ }
306
+
307
+ export async function termsAuthorityRepository(repositoryPath, specPath, { term = null, code = false, maxUnits, maxTerms, maxBytes }) {
308
+ if (![maxUnits, maxTerms, maxBytes].every(validBudget)) {
309
+ return refused([baseDiagnostic(
310
+ "AUTH_TERMS_BUDGET",
311
+ "authority term-analysis budgets must be positive safe integers",
312
+ "provide explicit positive safe max-units, max-terms, and max-bytes budgets",
313
+ )]);
314
+ }
315
+ if (code && term === null) {
316
+ return refused([inputDiagnostic("AUTH_TERMS_CODE_TERM_REQUIRED", "authority code cross-reference requires one exact term")]);
317
+ }
318
+ let termClasses = null;
319
+ if (term !== null) {
320
+ if (typeof term !== "string" || term.length === 0 || /[\0\r\n]/u.test(term)) {
321
+ return refused([inputDiagnostic("AUTH_TERMS_TERM_INVALID", "authority term detail requires one non-empty single-line term")]);
322
+ }
323
+ const exact = extractLexicalTerms(term).find((entry) => entry.term === term);
324
+ if (exact === undefined) {
325
+ return refused([inputDiagnostic("AUTH_TERMS_TERM_INVALID", "authority term detail requires one exact term admitted by the closed lexical grammar")]);
326
+ }
327
+ termClasses = exact.classes;
328
+ }
329
+
330
+ let inventory;
331
+ try {
332
+ inventory = await repositoryInventory(repositoryPath);
333
+ } catch (error) {
334
+ if (error instanceof AuthorityReviewRefusal) {
335
+ return refused([inputDiagnostic("AUTH_TERMS_REPOSITORY_INVALID", error.message, error.file ?? ".")]);
336
+ }
337
+ throw error;
338
+ }
339
+
340
+ let corpus;
341
+ try {
342
+ corpus = await resolveCorpus(inventory.repository, specPath);
343
+ } catch (error) {
344
+ return refused([inputDiagnostic("AUTH_TERMS_CORPUS_INVALID", error.message)]);
345
+ }
346
+
347
+ let compiled;
348
+ try {
349
+ compiled = await compileAuthorityPath(corpus.path);
350
+ } catch (error) {
351
+ if (error instanceof AuthorityInputError) {
352
+ return refused([inputDiagnostic("AUTH_TERMS_CORPUS_INVALID", error.message)]);
353
+ }
354
+ throw error;
355
+ }
356
+ if (!compiled.ok) return refused(compiled.diagnostics, { fileCount: compiled.fileCount });
357
+
358
+ const extracted = extractCorpusTerms(compiled);
359
+ if (extracted.active.length > maxUnits) {
360
+ return refused([budgetDiagnostic("active units", extracted.active.length, maxUnits)], { fileCount: compiled.fileCount });
361
+ }
362
+ if (extracted.records.size > maxTerms) {
363
+ return refused([budgetDiagnostic("distinct terms", extracted.records.size, maxTerms)], { fileCount: compiled.fileCount });
364
+ }
365
+ if (!code && compiled.inputBytes > maxBytes) {
366
+ return refused([budgetDiagnostic("UTF-8 input bytes", compiled.inputBytes, maxBytes)], { fileCount: compiled.fileCount });
367
+ }
368
+
369
+ let scannedBytes = extracted.scannedBytes;
370
+ let inputBytes = compiled.inputBytes;
371
+ let codeReferences = [];
372
+ let codeInventory = null;
373
+ if (code) {
374
+ const classified = await classifyTrackedTextFiles(
375
+ inventory.repository,
376
+ inventory.files,
377
+ (file) => corpusFile(file, corpus) ? "authority-corpus" : null,
378
+ );
379
+ if (classified.error !== undefined) {
380
+ return refused([inputDiagnostic("AUTH_TERMS_CODE_INPUT_INVALID", classified.error)], { fileCount: compiled.fileCount });
381
+ }
382
+ const scanned = await scanCode(
383
+ inventory.repository,
384
+ classified.scanned,
385
+ term,
386
+ compiled.inputBytes,
387
+ extracted.scannedBytes,
388
+ maxBytes,
389
+ );
390
+ if (scanned.input !== null) return refused([scanned.input], { fileCount: compiled.fileCount });
391
+ if (scanned.budget !== null) return refused([scanned.budget], { fileCount: compiled.fileCount });
392
+ scannedBytes = scanned.scannedBytes;
393
+ inputBytes = scanned.inputBytes;
394
+ codeReferences = scanned.references;
395
+ codeInventory = trackedTextInventory(inventory.files.length, classified);
396
+ }
397
+
398
+ const counts = {
399
+ units: extracted.active.length,
400
+ distinctTerms: extracted.records.size,
401
+ classes: classCounts(extracted.records),
402
+ };
403
+ const terms = term === null
404
+ ? { format: TERMS_FORMAT, complete: true, mode: "index", counts, index: indexEntries(extracted.records) }
405
+ : exactDetail(term, termClasses, extracted.records, code, codeReferences, codeInventory);
406
+ return {
407
+ ok: true,
408
+ complete: true,
409
+ partial: false,
410
+ diagnostics: [],
411
+ fileCount: compiled.fileCount,
412
+ unitCount: extracted.active.length,
413
+ termCount: extracted.records.size,
414
+ scannedBytes,
415
+ inputBytes,
416
+ trackedPathSetDigest: inventory.trackedPathSetDigest,
417
+ terms,
418
+ };
419
+ }
420
+
421
+ export { AUTHORITY_FIELDS, TERMS_FORMAT, TERM_CLASS_ORDER };
@@ -0,0 +1,70 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { lstat, open } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { compareText } from "./diagnostics.mjs";
6
+
7
+ const TEXT_PROBE_BYTES = 8192;
8
+
9
+ export async function classifyTrackedTextFiles(repository, files, excludedReason = () => null) {
10
+ const scanned = [];
11
+ const excluded = [];
12
+ for (const file of files) {
13
+ const reason = excludedReason(file);
14
+ if (reason !== null) {
15
+ excluded.push({ file, reason });
16
+ continue;
17
+ }
18
+ const absolute = path.join(repository, ...file.split("/"));
19
+ let metadata;
20
+ try {
21
+ metadata = await lstat(absolute);
22
+ } catch (error) {
23
+ return { error: `${file} is not readable: ${error.message}` };
24
+ }
25
+ if (metadata.isSymbolicLink() || !metadata.isFile()) {
26
+ return { error: `${file} must be one regular non-symlink file` };
27
+ }
28
+ let probe;
29
+ try {
30
+ const handle = await open(absolute, "r");
31
+ try {
32
+ const buffer = Buffer.alloc(Math.min(TEXT_PROBE_BYTES, metadata.size));
33
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
34
+ probe = buffer.subarray(0, bytesRead);
35
+ } finally {
36
+ await handle.close();
37
+ }
38
+ } catch (error) {
39
+ return { error: `${file} is not readable: ${error.message}` };
40
+ }
41
+ if (probe.includes(0)) {
42
+ excluded.push({ file, reason: "binary-content" });
43
+ continue;
44
+ }
45
+ try {
46
+ // Probe boundaries can split a multi-byte character. Streaming accepts
47
+ // that pending suffix without carrying decoder state into another file.
48
+ const truncated = probe.length === TEXT_PROBE_BYTES;
49
+ new TextDecoder("utf-8", { fatal: true }).decode(probe, { stream: truncated });
50
+ } catch {
51
+ excluded.push({ file, reason: "not-utf8" });
52
+ continue;
53
+ }
54
+ scanned.push(file);
55
+ }
56
+ return { scanned, excluded };
57
+ }
58
+
59
+ export function trackedTextInventory(fileCount, classified) {
60
+ const excludedByReason = {};
61
+ for (const entry of classified.excluded) {
62
+ excludedByReason[entry.reason] = (excludedByReason[entry.reason] ?? 0) + 1;
63
+ }
64
+ return {
65
+ trackedFiles: fileCount,
66
+ scannedFiles: classified.scanned.length,
67
+ excludedByReason,
68
+ excluded: [...classified.excluded].sort((left, right) => compareText(left.file, right.file)),
69
+ };
70
+ }