@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.
- package/CHANGELOG.md +32 -0
- package/CONTRIBUTING.md +3 -3
- package/README.md +297 -41
- package/README.zh-CN.md +306 -31
- package/bin/nimicoding.mjs +1 -1
- package/cli/commands/authority.mjs +804 -0
- package/cli/commands/clear.mjs +27 -7
- package/cli/commands/doctor.mjs +10 -1
- package/cli/commands/start.mjs +23 -27
- package/cli/commands/sync.mjs +21 -101
- package/cli/constants.mjs +7 -31
- package/cli/dev/check-authority-surface.mjs +182 -0
- package/cli/dev/check-product-conformance.mjs +889 -0
- package/cli/help.mjs +10 -17
- package/cli/index.mjs +6 -47
- package/cli/lib/authority/anchors.mjs +491 -0
- package/cli/lib/authority/audit.mjs +567 -0
- package/cli/lib/authority/change-candidates.mjs +807 -0
- package/cli/lib/authority/closed-sets.mjs +645 -0
- package/cli/lib/authority/compile.mjs +92 -0
- package/cli/lib/authority/compiled-snapshot.mjs +143 -0
- package/cli/lib/authority/consumers.mjs +520 -0
- package/cli/lib/authority/diagnostics.mjs +93 -0
- package/cli/lib/authority/diff.mjs +310 -0
- package/cli/lib/authority/discover.mjs +447 -0
- package/cli/lib/authority/format.mjs +169 -0
- package/cli/lib/authority/git-snapshot.mjs +720 -0
- package/cli/lib/authority/graph.mjs +351 -0
- package/cli/lib/authority/impact.mjs +125 -0
- package/cli/lib/authority/lexicon.mjs +55 -0
- package/cli/lib/authority/query.mjs +162 -0
- package/cli/lib/authority/repository-corpus.mjs +37 -0
- package/cli/lib/authority/repository-inventory.mjs +34 -0
- package/cli/lib/authority/review.mjs +212 -0
- package/cli/lib/authority/sarif.mjs +174 -0
- package/cli/lib/authority/scope-bindings.mjs +225 -0
- package/cli/lib/authority/source-map.mjs +105 -0
- package/cli/lib/authority/source-markdown.mjs +167 -0
- package/cli/lib/authority/source-yaml.mjs +308 -0
- package/cli/lib/authority/surface.mjs +97 -0
- package/cli/lib/authority/terms.mjs +422 -0
- package/cli/lib/authority/tracked-text.mjs +70 -0
- package/cli/lib/authority/validate.mjs +300 -0
- package/cli/lib/bootstrap.mjs +55 -71
- package/cli/lib/doctor.mjs +40 -123
- package/cli/lib/entrypoints.mjs +104 -105
- package/cli/lib/fs-helpers.mjs +139 -15
- package/cli/lib/internal/governance/config.mjs +0 -36
- package/cli/lib/shared.mjs +8 -21
- package/cli/lib/sync.mjs +81 -92
- package/cli/lib/value-helpers.mjs +0 -26
- package/cli/lib/yaml-helpers.mjs +1 -124
- package/cli/seeds/bootstrap.mjs +31 -87
- package/cli/seeds/seed-policy.yaml +9 -63
- package/contracts/authority-scope-bindings.schema.yaml +15 -0
- package/contracts/authority-source.schema.yaml +58 -0
- package/contracts/authority-verifier-bindings.schema.yaml +31 -0
- package/methodology/authority-authoring.yaml +127 -0
- package/methodology/core.yaml +34 -27
- package/package.json +10 -6
- package/spec/authority-anchors.yaml +99 -0
- package/spec/authority-audit.yaml +89 -0
- package/spec/authority-authoring.yaml +137 -0
- package/spec/authority-change-candidates.yaml +146 -0
- package/spec/authority-closed-sets.yaml +65 -0
- package/spec/authority-consumers.yaml +77 -0
- package/spec/authority-context.yaml +50 -0
- package/spec/authority-discovery.yaml +119 -0
- package/spec/authority-graph.yaml +59 -0
- package/spec/authority-impact.yaml +61 -0
- package/spec/authority-review.yaml +71 -0
- package/spec/authority-terms.yaml +63 -0
- package/spec/product-scope.yaml +37 -23
- package/cli/commands/blueprint-audit.mjs +0 -91
- package/cli/commands/classify-spec-tree.mjs +0 -5
- package/cli/commands/generate-spec-derived-docs.mjs +0 -151
- package/cli/commands/generate-spec-migration-plan.mjs +0 -30
- package/cli/commands/surface-validator-command.mjs +0 -49
- package/cli/commands/validate-domain-admission.mjs +0 -5
- package/cli/commands/validate-guidance-bodies.mjs +0 -5
- package/cli/commands/validate-placement.mjs +0 -5
- package/cli/commands/validate-projection-edges.mjs +0 -5
- package/cli/commands/validate-spec-audit.mjs +0 -27
- package/cli/commands/validate-spec-governance.mjs +0 -144
- package/cli/commands/validate-spec-tree.mjs +0 -27
- package/cli/commands/validate-table-family.mjs +0 -5
- package/cli/commands/validate-tracked-output-admission.mjs +0 -5
- package/cli/lib/blueprint-audit.mjs +0 -363
- package/cli/lib/contracts.mjs +0 -21
- package/cli/lib/internal/contracts-loaders.mjs +0 -47
- package/cli/lib/internal/contracts-parse.mjs +0 -192
- package/cli/lib/internal/governance/runner.mjs +0 -35
- package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
- package/cli/lib/internal/validators-shared.mjs +0 -12
- package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
- package/cli/lib/internal/validators-spec.mjs +0 -380
- package/cli/lib/validators.mjs +0 -30
- package/config/bootstrap.yaml +0 -6
- package/config/spec-generation-inputs.yaml +0 -41
- package/contracts/domain-admission.schema.yaml +0 -56
- package/contracts/migration-inventory.schema.yaml +0 -15
- package/contracts/negative-fixtures.yaml +0 -26
- package/contracts/placement-contract.schema.yaml +0 -127
- package/contracts/projection-edge.schema.yaml +0 -46
- package/contracts/shared-enums.yaml +0 -63
- package/contracts/spec-generation-audit.schema.yaml +0 -46
- package/contracts/spec-generation-inputs.schema.yaml +0 -43
- package/contracts/spec-layout.schema.yaml +0 -32
- package/contracts/surface-taxonomy.schema.yaml +0 -139
- package/contracts/table-family.schema.yaml +0 -133
- package/contracts/tracked-output-admission.schema.yaml +0 -17
- package/methodology/four-closure-policy.yaml +0 -20
- package/methodology/role-separation-policy.yaml +0 -22
- package/methodology/spec-reconstruction.yaml +0 -69
- package/spec/_meta/spec-tree-model.yaml +0 -44
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { Buffer } from "node:buffer";
|
|
2
|
+
import { lstat, readFile, realpath } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
import YAML, { isMap, isScalar, isSeq } from "yaml";
|
|
6
|
+
|
|
7
|
+
import { compileAuthorityPath } from "./compile.mjs";
|
|
8
|
+
import { compareText, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
|
|
9
|
+
import { AuthorityInputError } from "./format.mjs";
|
|
10
|
+
import { AuthorityReviewRefusal } from "./git-snapshot.mjs";
|
|
11
|
+
import { resolveRepositoryCorpus } from "./repository-corpus.mjs";
|
|
12
|
+
import { repositoryInventory } from "./repository-inventory.mjs";
|
|
13
|
+
import { normativeFields } from "./lexicon.mjs";
|
|
14
|
+
import { classifyTrackedTextFiles } from "./tracked-text.mjs";
|
|
15
|
+
|
|
16
|
+
const CLOSED_SETS_FORMAT = "nimicoding.authority-closed-sets/v1";
|
|
17
|
+
const MEMBER = /^[A-Za-z][A-Za-z0-9_.-]*$/u;
|
|
18
|
+
const MEMBER_CHARACTER = /[A-Za-z0-9_.-]/u;
|
|
19
|
+
const UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
20
|
+
|
|
21
|
+
function validBudget(value) {
|
|
22
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function baseDiagnostic(code, reason, repair, file = ".") {
|
|
26
|
+
return makeDiagnostic({
|
|
27
|
+
code,
|
|
28
|
+
file,
|
|
29
|
+
range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
|
|
30
|
+
reason,
|
|
31
|
+
repair,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function budgetDiagnostic(kind, required, admitted) {
|
|
36
|
+
const option = kind === "active units" ? "max-units" : kind === "implementation sets" ? "max-sets" : "max-bytes";
|
|
37
|
+
return baseDiagnostic(
|
|
38
|
+
"AUTH_CLOSED_SETS_BUDGET",
|
|
39
|
+
`complete closed-set analysis requires ${required} ${kind} but ${option} is ${admitted}`,
|
|
40
|
+
"increase the explicit closed-set budget; partial inventories, pairings, and drift reports are forbidden",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function inputDiagnostic(code, reason, file = ".") {
|
|
45
|
+
return baseDiagnostic(
|
|
46
|
+
code,
|
|
47
|
+
reason,
|
|
48
|
+
"provide one exact readable Git repository, admitted canonical corpus, and valid tracked implementation inputs; partial analysis is forbidden",
|
|
49
|
+
file,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function refused(diagnostics, { fileCount = 0 } = {}) {
|
|
54
|
+
return {
|
|
55
|
+
ok: false,
|
|
56
|
+
complete: false,
|
|
57
|
+
partial: false,
|
|
58
|
+
diagnostics: sortDiagnostics(diagnostics),
|
|
59
|
+
fileCount,
|
|
60
|
+
unitCount: 0,
|
|
61
|
+
scannedBytes: 0,
|
|
62
|
+
inputBytes: 0,
|
|
63
|
+
summary: null,
|
|
64
|
+
closedSets: null,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function portableRelative(repository, candidate) {
|
|
69
|
+
return path.relative(repository, candidate).split(path.sep).join(path.posix.sep);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function resolveCorpus(repository, specPath) {
|
|
73
|
+
return resolveRepositoryCorpus(repository, specPath, "closed-set authority");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function normalizeClosedSetMember(member) {
|
|
77
|
+
return member.toLowerCase().replaceAll(/[_.-]/gu, "");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function memberAt(text, offset) {
|
|
81
|
+
if (offset >= text.length || !/[A-Za-z]/u.test(text[offset])) return null;
|
|
82
|
+
let end = offset + 1;
|
|
83
|
+
while (end < text.length && MEMBER_CHARACTER.test(text[end])) end += 1;
|
|
84
|
+
let value = text.slice(offset, end);
|
|
85
|
+
if (value.endsWith(".") && value.length > 1) value = value.slice(0, -1);
|
|
86
|
+
return MEMBER.test(value) ? { value, end } : null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function spacesAt(text, offset) {
|
|
90
|
+
let end = offset;
|
|
91
|
+
while (end < text.length && /[\t ]/u.test(text[end])) end += 1;
|
|
92
|
+
return end;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function conjunctionAt(text, offset) {
|
|
96
|
+
for (const word of ["and", "or"]) {
|
|
97
|
+
if (text.slice(offset, offset + word.length) !== word) continue;
|
|
98
|
+
const next = offset + word.length;
|
|
99
|
+
if (next < text.length && /[\t ]/u.test(text[next])) return spacesAt(text, next);
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasWordContinuation(text, offset) {
|
|
105
|
+
if (text[offset - 1] === ".") return false;
|
|
106
|
+
const spaced = spacesAt(text, offset);
|
|
107
|
+
return spaced > offset && /[A-Za-z]/u.test(text[spaced] ?? "");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function parseCommaRun(text, start) {
|
|
111
|
+
const first = memberAt(text, start);
|
|
112
|
+
if (first === null) return null;
|
|
113
|
+
const members = [first.value];
|
|
114
|
+
let cursor = first.end;
|
|
115
|
+
let commaCount = 0;
|
|
116
|
+
while (true) {
|
|
117
|
+
const beforeSeparator = cursor;
|
|
118
|
+
const spaced = spacesAt(text, cursor);
|
|
119
|
+
if (text[spaced] === ",") {
|
|
120
|
+
commaCount += 1;
|
|
121
|
+
let next = spacesAt(text, spaced + 1);
|
|
122
|
+
const conjunctionEnd = conjunctionAt(text, next);
|
|
123
|
+
if (conjunctionEnd !== null) {
|
|
124
|
+
const final = memberAt(text, conjunctionEnd);
|
|
125
|
+
if (final === null) return { members, end: beforeSeparator, commaCount: commaCount - 1, invalid: false };
|
|
126
|
+
members.push(final.value);
|
|
127
|
+
return { members, end: final.end, commaCount, invalid: hasWordContinuation(text, final.end) };
|
|
128
|
+
}
|
|
129
|
+
const item = memberAt(text, next);
|
|
130
|
+
if (item === null) return { members, end: beforeSeparator, commaCount: commaCount - 1, invalid: false };
|
|
131
|
+
members.push(item.value);
|
|
132
|
+
cursor = item.end;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (members.length >= 2) {
|
|
136
|
+
const conjunctionEnd = conjunctionAt(text, spaced);
|
|
137
|
+
if (conjunctionEnd !== null) {
|
|
138
|
+
const final = memberAt(text, conjunctionEnd);
|
|
139
|
+
if (final !== null) {
|
|
140
|
+
members.push(final.value);
|
|
141
|
+
return { members, end: final.end, commaCount, invalid: hasWordContinuation(text, final.end) };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { members, end: cursor, commaCount, invalid: hasWordContinuation(text, cursor) };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function extractCommaRunEnumerations(text) {
|
|
150
|
+
const groups = [];
|
|
151
|
+
let cursor = 0;
|
|
152
|
+
while (cursor < text.length) {
|
|
153
|
+
if (!/[A-Za-z]/u.test(text[cursor]) || (cursor > 0 && (MEMBER_CHARACTER.test(text[cursor - 1]) || text[cursor - 1] === "/"))) {
|
|
154
|
+
cursor += 1;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const parsed = parseCommaRun(text, cursor);
|
|
158
|
+
if (parsed !== null && !parsed.invalid && parsed.members.length >= 3 && parsed.commaCount >= 1) {
|
|
159
|
+
const following = text[parsed.end] ?? "";
|
|
160
|
+
if (following !== "/" && !MEMBER_CHARACTER.test(following)) {
|
|
161
|
+
const members = [...parsed.members];
|
|
162
|
+
if (members.at(-1)?.endsWith(".") && members.at(-1).length > 1) members[members.length - 1] = members.at(-1).slice(0, -1);
|
|
163
|
+
groups.push(members);
|
|
164
|
+
cursor = parsed.end;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const token = memberAt(text, cursor);
|
|
169
|
+
cursor = token?.end ?? cursor + 1;
|
|
170
|
+
}
|
|
171
|
+
return groups;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function canonicalGroups(compiled) {
|
|
175
|
+
const groups = [];
|
|
176
|
+
let scannedBytes = 0;
|
|
177
|
+
for (const unit of compiled.ir.units) {
|
|
178
|
+
if (unit.lifecycle !== "active") continue;
|
|
179
|
+
// Scan every normative field, not just the headline one. A rule's closed
|
|
180
|
+
// vocabulary is as often stated in its condition or failure as in its
|
|
181
|
+
// statement, and scanning two of the four fields silently dropped those.
|
|
182
|
+
for (const field of normativeFields(unit.kind)) {
|
|
183
|
+
const text = unit.semantic[field];
|
|
184
|
+
scannedBytes += Buffer.byteLength(text, "utf8");
|
|
185
|
+
for (const members of extractCommaRunEnumerations(text)) groups.push({ unitId: unit.id, field, members });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
groups.sort((left, right) => compareText(left.unitId, right.unitId) || compareText(left.field, right.field));
|
|
189
|
+
return { groups, scannedBytes };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function maskProto(text) {
|
|
193
|
+
const output = text.split("");
|
|
194
|
+
let state = "code";
|
|
195
|
+
let quote = "";
|
|
196
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
197
|
+
const character = text[index];
|
|
198
|
+
const next = text[index + 1];
|
|
199
|
+
if (state === "line-comment") {
|
|
200
|
+
if (character === "\n") state = "code";
|
|
201
|
+
else output[index] = " ";
|
|
202
|
+
} else if (state === "block-comment") {
|
|
203
|
+
if (character === "*" && next === "/") {
|
|
204
|
+
output[index] = " ";
|
|
205
|
+
output[index + 1] = " ";
|
|
206
|
+
index += 1;
|
|
207
|
+
state = "code";
|
|
208
|
+
} else if (character !== "\n" && character !== "\r") output[index] = " ";
|
|
209
|
+
} else if (state === "string") {
|
|
210
|
+
if (character === "\\") {
|
|
211
|
+
output[index] = " ";
|
|
212
|
+
if (index + 1 < text.length) {
|
|
213
|
+
output[index + 1] = " ";
|
|
214
|
+
index += 1;
|
|
215
|
+
}
|
|
216
|
+
} else if (character === quote) {
|
|
217
|
+
output[index] = " ";
|
|
218
|
+
state = "code";
|
|
219
|
+
} else if (character !== "\n" && character !== "\r") output[index] = " ";
|
|
220
|
+
} else if (character === "/" && next === "/") {
|
|
221
|
+
output[index] = " ";
|
|
222
|
+
output[index + 1] = " ";
|
|
223
|
+
index += 1;
|
|
224
|
+
state = "line-comment";
|
|
225
|
+
} else if (character === "/" && next === "*") {
|
|
226
|
+
output[index] = " ";
|
|
227
|
+
output[index + 1] = " ";
|
|
228
|
+
index += 1;
|
|
229
|
+
state = "block-comment";
|
|
230
|
+
} else if (character === "\"" || character === "'") {
|
|
231
|
+
output[index] = " ";
|
|
232
|
+
quote = character;
|
|
233
|
+
state = "string";
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return output.join("");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function closingBrace(text, opening) {
|
|
240
|
+
let depth = 0;
|
|
241
|
+
for (let index = opening; index < text.length; index += 1) {
|
|
242
|
+
if (text[index] === "{") depth += 1;
|
|
243
|
+
else if (text[index] === "}") {
|
|
244
|
+
depth -= 1;
|
|
245
|
+
if (depth === 0) return index;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return -1;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function topLevelBody(body) {
|
|
252
|
+
const output = body.split("");
|
|
253
|
+
let depth = 0;
|
|
254
|
+
for (let index = 0; index < body.length; index += 1) {
|
|
255
|
+
if (body[index] === "{") {
|
|
256
|
+
depth += 1;
|
|
257
|
+
output[index] = " ";
|
|
258
|
+
} else if (body[index] === "}") {
|
|
259
|
+
output[index] = " ";
|
|
260
|
+
depth = Math.max(0, depth - 1);
|
|
261
|
+
} else if (depth > 0 && body[index] !== "\n" && body[index] !== "\r") output[index] = " ";
|
|
262
|
+
}
|
|
263
|
+
return output.join("");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function enumPrefix(members) {
|
|
267
|
+
if (members.length === 0) return "";
|
|
268
|
+
const candidates = [];
|
|
269
|
+
for (let index = 0; index < members[0].length; index += 1) {
|
|
270
|
+
if (members[0][index] === "_") candidates.push(members[0].slice(0, index + 1));
|
|
271
|
+
}
|
|
272
|
+
return candidates.filter((candidate) => members.every((member) => member.startsWith(candidate))).at(-1) ?? "";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function extractProtoEnums(file, text) {
|
|
276
|
+
const masked = maskProto(text);
|
|
277
|
+
const records = [];
|
|
278
|
+
const expression = /\benum[\t ]+([A-Za-z][A-Za-z0-9_]*)[\t \r\n]*\{/gu;
|
|
279
|
+
let match = expression.exec(masked);
|
|
280
|
+
while (match !== null) {
|
|
281
|
+
const opening = match.index + match[0].lastIndexOf("{");
|
|
282
|
+
const closing = closingBrace(masked, opening);
|
|
283
|
+
if (closing < 0) throw new Error(`${file} contains an unterminated proto enum ${match[1]}`);
|
|
284
|
+
const body = topLevelBody(masked.slice(opening + 1, closing));
|
|
285
|
+
// Enum-value options are metadata on the value, not a different member
|
|
286
|
+
// declaration; omitting the declaration would hide it from drift deltas.
|
|
287
|
+
const rawMembers = [...body.matchAll(/\b([A-Za-z][A-Za-z0-9_]*)[\t \r\n]*=[\t \r\n]*-?(?:0[xX][0-9A-Fa-f]+|0[0-7]*|[1-9][0-9]*)[\t \r\n]*(?:\[[^\[\]]*\][\t \r\n]*)?;/gu)].map((entry) => entry[1]);
|
|
288
|
+
const prefix = enumPrefix(rawMembers);
|
|
289
|
+
records.push({
|
|
290
|
+
file,
|
|
291
|
+
enumName: match[1],
|
|
292
|
+
rawMembers,
|
|
293
|
+
normalizedMembers: rawMembers.map((member) => normalizeClosedSetMember(member.startsWith(prefix) ? member.slice(prefix.length) : member)),
|
|
294
|
+
});
|
|
295
|
+
expression.lastIndex = closing + 1;
|
|
296
|
+
match = expression.exec(masked);
|
|
297
|
+
}
|
|
298
|
+
return records.sort((left, right) => compareText(left.file, right.file) || compareText(left.enumName, right.enumName));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function yamlKeyPath(parts) {
|
|
302
|
+
return parts.reduce((result, part) => part.startsWith("[") ? `${result}${part}` : result.length === 0 ? part : `${result}.${part}`, "");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function plainMembers(node) {
|
|
306
|
+
if (!isSeq(node) || node.items.length < 3) return null;
|
|
307
|
+
const members = [];
|
|
308
|
+
for (const item of node.items) {
|
|
309
|
+
if (!isScalar(item) || item.type !== "PLAIN" || typeof item.source !== "string" || !MEMBER.test(item.source)) return null;
|
|
310
|
+
members.push(item.source);
|
|
311
|
+
}
|
|
312
|
+
return members;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function visitYaml(node, file, parts, records) {
|
|
316
|
+
if (isMap(node)) {
|
|
317
|
+
for (const pair of node.items) {
|
|
318
|
+
if (!isScalar(pair.key)) {
|
|
319
|
+
visitYaml(pair.value, file, parts, records);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
const key = typeof pair.key.source === "string" ? pair.key.source : String(pair.key.value);
|
|
323
|
+
const childParts = [...parts, key];
|
|
324
|
+
const members = plainMembers(pair.value);
|
|
325
|
+
if (members !== null) records.push({
|
|
326
|
+
file,
|
|
327
|
+
keyPath: yamlKeyPath(childParts),
|
|
328
|
+
members,
|
|
329
|
+
normalizedMembers: members.map(normalizeClosedSetMember),
|
|
330
|
+
});
|
|
331
|
+
visitYaml(pair.value, file, childParts, records);
|
|
332
|
+
}
|
|
333
|
+
} else if (isSeq(node)) {
|
|
334
|
+
node.items.forEach((item, index) => visitYaml(item, file, [...parts, `[${index}]`], records));
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function extractConfigYamlSequences(file, text) {
|
|
339
|
+
const documents = YAML.parseAllDocuments(text, {
|
|
340
|
+
keepSourceTokens: true,
|
|
341
|
+
prettyErrors: false,
|
|
342
|
+
strict: true,
|
|
343
|
+
uniqueKeys: true,
|
|
344
|
+
version: "1.2",
|
|
345
|
+
});
|
|
346
|
+
const error = documents.find((document) => document.errors.length > 0)?.errors[0];
|
|
347
|
+
if (error) throw new Error(`${file} is not valid YAML: ${error.message.split(" at line")[0]}`);
|
|
348
|
+
const records = [];
|
|
349
|
+
for (const document of documents) visitYaml(document.contents, file, [], records);
|
|
350
|
+
return records.sort((left, right) => compareText(left.file, right.file) || compareText(left.keyPath, right.keyPath));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function protoFile(file) {
|
|
354
|
+
return file.endsWith(".proto");
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function yamlFile(file) {
|
|
358
|
+
return /\.ya?ml$/u.test(file);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function implementationFileKind(file) {
|
|
362
|
+
if (protoFile(file)) return "proto";
|
|
363
|
+
if (yamlFile(file)) return "yaml";
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function implementationFileInventory(trackedFiles, classified) {
|
|
368
|
+
const excludedByReason = {};
|
|
369
|
+
for (const entry of classified.excluded) {
|
|
370
|
+
excludedByReason[entry.reason] = (excludedByReason[entry.reason] ?? 0) + 1;
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
trackedFiles,
|
|
374
|
+
scannedFiles: {
|
|
375
|
+
proto: classified.scanned.filter(protoFile).length,
|
|
376
|
+
yaml: classified.scanned.filter(yamlFile).length,
|
|
377
|
+
},
|
|
378
|
+
excludedByReason,
|
|
379
|
+
excluded: [...classified.excluded].sort((left, right) => compareText(left.file, right.file)),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
async function implementationBytes(repository, files, authorityInputBytes, authorityScannedBytes, maxBytes) {
|
|
384
|
+
let stated = BigInt(authorityInputBytes);
|
|
385
|
+
for (const file of files) {
|
|
386
|
+
let metadata;
|
|
387
|
+
try {
|
|
388
|
+
metadata = await lstat(path.join(repository, ...file.split("/")), { bigint: true });
|
|
389
|
+
} catch (error) {
|
|
390
|
+
throw new Error(`${file} is not readable: ${error.message}`);
|
|
391
|
+
}
|
|
392
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) throw new Error(`${file} must be one regular non-symlink file`);
|
|
393
|
+
stated += metadata.size;
|
|
394
|
+
}
|
|
395
|
+
if (stated > BigInt(maxBytes)) return { budget: budgetDiagnostic("UTF-8 input bytes", stated.toString(), maxBytes) };
|
|
396
|
+
|
|
397
|
+
const texts = new Map();
|
|
398
|
+
let inputBytes = authorityInputBytes;
|
|
399
|
+
let scannedBytes = authorityScannedBytes;
|
|
400
|
+
for (const file of files) {
|
|
401
|
+
let bytes;
|
|
402
|
+
try {
|
|
403
|
+
bytes = await readFile(path.join(repository, ...file.split("/")));
|
|
404
|
+
} catch (error) {
|
|
405
|
+
throw new Error(`${file} is not readable: ${error.message}`);
|
|
406
|
+
}
|
|
407
|
+
inputBytes += bytes.length;
|
|
408
|
+
scannedBytes += bytes.length;
|
|
409
|
+
if (inputBytes > maxBytes) return { budget: budgetDiagnostic("UTF-8 input bytes", inputBytes, maxBytes) };
|
|
410
|
+
try {
|
|
411
|
+
texts.set(file, UTF8.decode(bytes));
|
|
412
|
+
} catch {
|
|
413
|
+
throw new Error(`${file} is not valid UTF-8`);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
return { budget: null, inputBytes, scannedBytes, texts };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function implementationName(set) {
|
|
420
|
+
return set.kind === "proto_enum" ? set.record.enumName : set.record.keyPath;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function compareImplementation(left, right) {
|
|
424
|
+
return compareText(left.record.file, right.record.file)
|
|
425
|
+
|| compareText(implementationName(left), implementationName(right))
|
|
426
|
+
|| compareText(left.kind, right.kind);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function uniqueRawByNormalized(rawMembers, normalizedMembers, excludedRaw = null) {
|
|
430
|
+
const result = [];
|
|
431
|
+
const seen = new Set();
|
|
432
|
+
rawMembers.forEach((raw, index) => {
|
|
433
|
+
if (raw === excludedRaw) return;
|
|
434
|
+
const normalized = normalizedMembers[index];
|
|
435
|
+
if (!seen.has(normalized)) {
|
|
436
|
+
seen.add(normalized);
|
|
437
|
+
result.push({ raw, normalized });
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function comparableImplementation(set) {
|
|
444
|
+
if (set.kind === "config_yaml_sequence") return uniqueRawByNormalized(set.record.members, set.record.normalizedMembers);
|
|
445
|
+
const prefix = enumPrefix(set.record.rawMembers);
|
|
446
|
+
return uniqueRawByNormalized(set.record.rawMembers, set.record.normalizedMembers, `${prefix}UNSPECIFIED`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function publicSetReference(set) {
|
|
450
|
+
return {
|
|
451
|
+
kind: set.kind,
|
|
452
|
+
file: set.record.file,
|
|
453
|
+
name: implementationName(set),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function pairInventories(groups, implementationSets) {
|
|
458
|
+
const pairs = [];
|
|
459
|
+
const pairedGroups = new Set();
|
|
460
|
+
const pairedSets = new Set();
|
|
461
|
+
groups.forEach((group, groupIndex) => {
|
|
462
|
+
const canonical = uniqueRawByNormalized(group.members, group.members.map(normalizeClosedSetMember));
|
|
463
|
+
const canonicalNormalized = new Set(canonical.map((member) => member.normalized));
|
|
464
|
+
implementationSets.forEach((set, setIndex) => {
|
|
465
|
+
const implementation = comparableImplementation(set);
|
|
466
|
+
const implementationNormalized = new Set(implementation.map((member) => member.normalized));
|
|
467
|
+
const overlap = [...canonicalNormalized].filter((member) => implementationNormalized.has(member)).sort(compareText);
|
|
468
|
+
if (overlap.length < 2) return;
|
|
469
|
+
pairedGroups.add(groupIndex);
|
|
470
|
+
pairedSets.add(setIndex);
|
|
471
|
+
const missingInCanonical = implementation.filter((member) => !canonicalNormalized.has(member.normalized)).map((member) => member.raw);
|
|
472
|
+
const missingInImplementation = canonical.filter((member) => !implementationNormalized.has(member.normalized)).map((member) => member.raw);
|
|
473
|
+
pairs.push({
|
|
474
|
+
canonical: group,
|
|
475
|
+
implementation: publicSetReference(set),
|
|
476
|
+
overlap,
|
|
477
|
+
missingInCanonical,
|
|
478
|
+
missingInImplementation,
|
|
479
|
+
subset: missingInCanonical.length === 0 && missingInImplementation.length > 0,
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
pairs.sort((left, right) => compareText(left.canonical.unitId, right.canonical.unitId)
|
|
484
|
+
|| compareText(left.implementation.file, right.implementation.file)
|
|
485
|
+
|| compareText(left.implementation.name, right.implementation.name)
|
|
486
|
+
|| compareText(left.canonical.field, right.canonical.field)
|
|
487
|
+
|| compareText(left.implementation.kind, right.implementation.kind));
|
|
488
|
+
return {
|
|
489
|
+
pairs,
|
|
490
|
+
pairedGroups,
|
|
491
|
+
pairedSets,
|
|
492
|
+
pairsWithDeltas: pairs.filter((pair) => pair.missingInCanonical.length > 0 || pair.missingInImplementation.length > 0).length,
|
|
493
|
+
subsetPairs: pairs.filter((pair) => pair.subset).length,
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function closedSetsAuthorityRepository(repositoryPath, specPath, { maxUnits, maxSets, maxBytes }) {
|
|
498
|
+
if (![maxUnits, maxSets, maxBytes].every(validBudget)) {
|
|
499
|
+
return refused([baseDiagnostic(
|
|
500
|
+
"AUTH_CLOSED_SETS_BUDGET",
|
|
501
|
+
"closed-set analysis budgets must be positive safe integers",
|
|
502
|
+
"provide explicit positive safe max-units, max-sets, and max-bytes budgets",
|
|
503
|
+
)]);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
let inventory;
|
|
507
|
+
try {
|
|
508
|
+
inventory = await repositoryInventory(repositoryPath);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (error instanceof AuthorityReviewRefusal) {
|
|
511
|
+
return refused([inputDiagnostic("AUTH_CLOSED_SETS_REPOSITORY_INVALID", error.message, error.file ?? ".")]);
|
|
512
|
+
}
|
|
513
|
+
throw error;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
let corpus;
|
|
517
|
+
try {
|
|
518
|
+
corpus = await resolveCorpus(inventory.repository, specPath);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
return refused([inputDiagnostic("AUTH_CLOSED_SETS_CORPUS_INVALID", error.message)]);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let compiled;
|
|
524
|
+
try {
|
|
525
|
+
compiled = await compileAuthorityPath(corpus.path);
|
|
526
|
+
} catch (error) {
|
|
527
|
+
if (error instanceof AuthorityInputError) return refused([inputDiagnostic("AUTH_CLOSED_SETS_CORPUS_INVALID", error.message)]);
|
|
528
|
+
throw error;
|
|
529
|
+
}
|
|
530
|
+
if (!compiled.ok) return refused(compiled.diagnostics, { fileCount: compiled.fileCount });
|
|
531
|
+
|
|
532
|
+
const activeUnits = compiled.ir.units.filter((unit) => unit.lifecycle === "active");
|
|
533
|
+
if (activeUnits.length > maxUnits) {
|
|
534
|
+
return refused([budgetDiagnostic("active units", activeUnits.length, maxUnits)], { fileCount: compiled.fileCount });
|
|
535
|
+
}
|
|
536
|
+
const canonical = canonicalGroups(compiled);
|
|
537
|
+
|
|
538
|
+
const gitlinks = new Set(inventory.gitlinks);
|
|
539
|
+
const classifiedFiles = await classifyTrackedTextFiles(
|
|
540
|
+
inventory.repository,
|
|
541
|
+
inventory.files,
|
|
542
|
+
(file) => {
|
|
543
|
+
if (gitlinks.has(file)) return "gitlink";
|
|
544
|
+
return file === ".nimi/spec" || file.startsWith(".nimi/spec/")
|
|
545
|
+
? "authority-corpus"
|
|
546
|
+
: implementationFileKind(file) === null ? "not-a-scanned-kind" : null;
|
|
547
|
+
},
|
|
548
|
+
);
|
|
549
|
+
if (classifiedFiles.error !== undefined) {
|
|
550
|
+
return refused([inputDiagnostic("AUTH_CLOSED_SETS_IMPLEMENTATION_INPUT_INVALID", classifiedFiles.error)], { fileCount: compiled.fileCount });
|
|
551
|
+
}
|
|
552
|
+
const implementationFiles = classifiedFiles.scanned;
|
|
553
|
+
const fileInventory = implementationFileInventory(inventory.files.length, classifiedFiles);
|
|
554
|
+
let scanned;
|
|
555
|
+
try {
|
|
556
|
+
scanned = await implementationBytes(
|
|
557
|
+
inventory.repository,
|
|
558
|
+
implementationFiles,
|
|
559
|
+
compiled.inputBytes,
|
|
560
|
+
canonical.scannedBytes,
|
|
561
|
+
maxBytes,
|
|
562
|
+
);
|
|
563
|
+
} catch (error) {
|
|
564
|
+
return refused([inputDiagnostic("AUTH_CLOSED_SETS_IMPLEMENTATION_INPUT_INVALID", error.message)], { fileCount: compiled.fileCount });
|
|
565
|
+
}
|
|
566
|
+
if (scanned.budget !== null) return refused([scanned.budget], { fileCount: compiled.fileCount });
|
|
567
|
+
|
|
568
|
+
const protoEnums = [];
|
|
569
|
+
const configYamlSequences = [];
|
|
570
|
+
try {
|
|
571
|
+
for (const file of implementationFiles) {
|
|
572
|
+
if (protoFile(file)) protoEnums.push(...extractProtoEnums(file, scanned.texts.get(file)));
|
|
573
|
+
else configYamlSequences.push(...extractConfigYamlSequences(file, scanned.texts.get(file)));
|
|
574
|
+
}
|
|
575
|
+
} catch (error) {
|
|
576
|
+
return refused([inputDiagnostic("AUTH_CLOSED_SETS_IMPLEMENTATION_INPUT_INVALID", error.message)], { fileCount: compiled.fileCount });
|
|
577
|
+
}
|
|
578
|
+
protoEnums.sort((left, right) => compareText(left.file, right.file) || compareText(left.enumName, right.enumName));
|
|
579
|
+
configYamlSequences.sort((left, right) => compareText(left.file, right.file) || compareText(left.keyPath, right.keyPath));
|
|
580
|
+
const setCount = protoEnums.length + configYamlSequences.length;
|
|
581
|
+
if (setCount > maxSets) {
|
|
582
|
+
return refused([budgetDiagnostic("implementation sets", setCount, maxSets)], { fileCount: compiled.fileCount });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const implementationSets = [
|
|
586
|
+
...protoEnums.map((record) => ({ kind: "proto_enum", record })),
|
|
587
|
+
...configYamlSequences.map((record) => ({ kind: "config_yaml_sequence", record })),
|
|
588
|
+
].sort(compareImplementation);
|
|
589
|
+
const paired = pairInventories(canonical.groups, implementationSets);
|
|
590
|
+
const unpairedGroups = canonical.groups.filter((_, index) => !paired.pairedGroups.has(index));
|
|
591
|
+
const unpairedProtoEnums = [];
|
|
592
|
+
const unpairedConfigYamlSequences = [];
|
|
593
|
+
implementationSets.forEach((set, index) => {
|
|
594
|
+
if (paired.pairedSets.has(index)) return;
|
|
595
|
+
if (set.kind === "proto_enum") unpairedProtoEnums.push(set.record);
|
|
596
|
+
else unpairedConfigYamlSequences.push(set.record);
|
|
597
|
+
});
|
|
598
|
+
const summary = {
|
|
599
|
+
groups: canonical.groups.length,
|
|
600
|
+
sets: {
|
|
601
|
+
total: setCount,
|
|
602
|
+
protoEnums: protoEnums.length,
|
|
603
|
+
configYamlSequences: configYamlSequences.length,
|
|
604
|
+
},
|
|
605
|
+
pairs: paired.pairs.length,
|
|
606
|
+
pairsWithDeltas: paired.pairsWithDeltas,
|
|
607
|
+
subsetPairs: paired.subsetPairs,
|
|
608
|
+
unpairedGroups: unpairedGroups.length,
|
|
609
|
+
unpairedSets: {
|
|
610
|
+
total: unpairedProtoEnums.length + unpairedConfigYamlSequences.length,
|
|
611
|
+
protoEnums: unpairedProtoEnums.length,
|
|
612
|
+
configYamlSequences: unpairedConfigYamlSequences.length,
|
|
613
|
+
},
|
|
614
|
+
};
|
|
615
|
+
const closedSets = {
|
|
616
|
+
format: CLOSED_SETS_FORMAT,
|
|
617
|
+
complete: true,
|
|
618
|
+
fileInventory,
|
|
619
|
+
canonicalGroups: canonical.groups,
|
|
620
|
+
implementationSets: { protoEnums, configYamlSequences },
|
|
621
|
+
pairs: paired.pairs,
|
|
622
|
+
unpaired: {
|
|
623
|
+
canonicalGroups: unpairedGroups,
|
|
624
|
+
implementationSets: {
|
|
625
|
+
protoEnums: unpairedProtoEnums,
|
|
626
|
+
configYamlSequences: unpairedConfigYamlSequences,
|
|
627
|
+
},
|
|
628
|
+
},
|
|
629
|
+
};
|
|
630
|
+
return {
|
|
631
|
+
ok: true,
|
|
632
|
+
complete: true,
|
|
633
|
+
partial: false,
|
|
634
|
+
diagnostics: [],
|
|
635
|
+
fileCount: compiled.fileCount + implementationFiles.length,
|
|
636
|
+
unitCount: activeUnits.length,
|
|
637
|
+
scannedBytes: scanned.scannedBytes,
|
|
638
|
+
inputBytes: scanned.inputBytes,
|
|
639
|
+
trackedPathSetDigest: inventory.trackedPathSetDigest,
|
|
640
|
+
summary,
|
|
641
|
+
closedSets,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
export { CLOSED_SETS_FORMAT };
|