@nimiplatform/nimi-coding 0.4.0 → 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 +9 -0
- package/README.md +47 -35
- package/README.zh-CN.md +48 -36
- package/cli/commands/authority.mjs +80 -125
- package/cli/constants.mjs +1 -1
- package/cli/dev/check-authority-surface.mjs +17 -8
- package/cli/dev/check-product-conformance.mjs +4 -32
- package/cli/lib/authority/audit.mjs +262 -185
- package/cli/lib/authority/change-candidates.mjs +807 -0
- package/cli/lib/authority/closed-sets.mjs +7 -8
- package/cli/lib/authority/compile.mjs +6 -24
- package/cli/lib/authority/compiled-snapshot.mjs +143 -0
- package/cli/lib/authority/consumers.mjs +69 -19
- package/cli/lib/authority/diagnostics.mjs +7 -1
- package/cli/lib/authority/diff.mjs +77 -54
- package/cli/lib/authority/format.mjs +4 -32
- package/cli/lib/authority/git-snapshot.mjs +34 -379
- package/cli/lib/authority/graph.mjs +10 -10
- package/cli/lib/authority/impact.mjs +17 -144
- package/cli/lib/authority/review.mjs +33 -43
- package/cli/lib/authority/sarif.mjs +1 -8
- package/cli/lib/authority/source-yaml.mjs +0 -4
- package/cli/lib/authority/surface.mjs +19 -1
- package/cli/lib/authority/terms.mjs +10 -9
- package/cli/lib/authority/validate.mjs +6 -14
- package/cli/lib/entrypoints.mjs +2 -1
- package/contracts/authority-source.schema.yaml +0 -6
- package/contracts/authority-verifier-bindings.schema.yaml +21 -10
- package/methodology/authority-authoring.yaml +9 -26
- package/methodology/core.yaml +5 -3
- package/package.json +1 -1
- package/spec/authority-audit.yaml +56 -52
- package/spec/authority-authoring.yaml +3 -16
- package/spec/authority-change-candidates.yaml +146 -0
- package/spec/authority-consumers.yaml +7 -0
- package/spec/authority-discovery.yaml +2 -2
- package/spec/authority-graph.yaml +1 -1
- package/spec/authority-impact.yaml +16 -39
- package/spec/authority-review.yaml +29 -47
- package/spec/authority-terms.yaml +5 -1
- package/spec/product-scope.yaml +8 -7
- package/cli/lib/authority/evidence-snapshot.mjs +0 -1
- package/cli/lib/authority/evidence.mjs +0 -1432
- package/contracts/authority-evidence-bindings.schema.yaml +0 -53
- package/contracts/authority-evidence-probe-results.schema.yaml +0 -38
- package/contracts/authority-impact-dispositions.schema.yaml +0 -13
- package/spec/authority-evidence.yaml +0 -200
|
@@ -65,11 +65,6 @@ function refused(diagnostics, { fileCount = 0 } = {}) {
|
|
|
65
65
|
};
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
function isWithin(root, candidate) {
|
|
69
|
-
const relative = path.relative(root, candidate);
|
|
70
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
71
|
-
}
|
|
72
|
-
|
|
73
68
|
function portableRelative(repository, candidate) {
|
|
74
69
|
return path.relative(repository, candidate).split(path.sep).join(path.posix.sep);
|
|
75
70
|
}
|
|
@@ -540,12 +535,16 @@ export async function closedSetsAuthorityRepository(repositoryPath, specPath, {
|
|
|
540
535
|
}
|
|
541
536
|
const canonical = canonicalGroups(compiled);
|
|
542
537
|
|
|
538
|
+
const gitlinks = new Set(inventory.gitlinks);
|
|
543
539
|
const classifiedFiles = await classifyTrackedTextFiles(
|
|
544
540
|
inventory.repository,
|
|
545
541
|
inventory.files,
|
|
546
|
-
(file) =>
|
|
547
|
-
|
|
548
|
-
|
|
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
|
+
},
|
|
549
548
|
);
|
|
550
549
|
if (classifiedFiles.error !== undefined) {
|
|
551
550
|
return refused([inputDiagnostic("AUTH_CLOSED_SETS_IMPLEMENTATION_INPUT_INVALID", classifiedFiles.error)], { fileCount: compiled.fileCount });
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { compareText
|
|
1
|
+
import { compareText } from "./diagnostics.mjs";
|
|
2
2
|
import { formattingDiagnostics, parseAuthorityPath } from "./format.mjs";
|
|
3
3
|
import { validateAuthorityScopeBindings } from "./scope-bindings.mjs";
|
|
4
4
|
import { buildSourceMap } from "./source-map.mjs";
|
|
@@ -39,31 +39,13 @@ async function admittedSources(inputPath) {
|
|
|
39
39
|
return { ok: true, diagnostics: [], parsed };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
function
|
|
43
|
-
if (requiredFormat === null) return [];
|
|
44
|
-
return sortDiagnostics(documents.flatMap((document) => {
|
|
45
|
-
const found = document.data?.format;
|
|
46
|
-
if (found === requiredFormat) return [];
|
|
47
|
-
return [makeDiagnostic({
|
|
48
|
-
code: "AUTH_FORMAT_REQUIRED",
|
|
49
|
-
file: document.file,
|
|
50
|
-
range: document.locations.get("/format") ?? document.locations.get("") ?? document.locator.range(0),
|
|
51
|
-
pointer: "/format",
|
|
52
|
-
reason: `authority check requires format ${requiredFormat}, but ${document.file} declares ${found}`,
|
|
53
|
-
repair: "rewrite the container in the explicitly required admitted format without changing product semantics",
|
|
54
|
-
})];
|
|
55
|
-
}));
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
export async function checkAuthorityPath(inputPath, { scopeBindings = null, requireFormat = null } = {}) {
|
|
42
|
+
export async function checkAuthorityPath(inputPath, { scopeBindings = null } = {}) {
|
|
59
43
|
const result = await admittedSources(inputPath);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
ok: result.ok && formatDiagnostics.length === 0,
|
|
64
|
-
diagnostics,
|
|
44
|
+
if (!result.ok || scopeBindings === null) return {
|
|
45
|
+
ok: result.ok,
|
|
46
|
+
diagnostics: result.diagnostics,
|
|
65
47
|
fileCount: result.parsed.files.length,
|
|
66
|
-
unitCount: result.ok
|
|
48
|
+
unitCount: result.ok ? result.parsed.sources.length : 0,
|
|
67
49
|
};
|
|
68
50
|
const scopeValidation = await validateAuthorityScopeBindings(result.parsed.sources, scopeBindings);
|
|
69
51
|
return {
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
import { compareText, portablePath } from "./diagnostics.mjs";
|
|
4
|
+
|
|
5
|
+
class ReadonlyIndex {
|
|
6
|
+
#values;
|
|
7
|
+
|
|
8
|
+
constructor(entries) {
|
|
9
|
+
this.#values = new Map(entries);
|
|
10
|
+
Object.freeze(this);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
get size() {
|
|
14
|
+
return this.#values.size;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get(key) {
|
|
18
|
+
return this.#values.get(key);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
has(key) {
|
|
22
|
+
return this.#values.has(key);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
*entries() {
|
|
26
|
+
for (const [key, value] of this.#values) yield Object.freeze([key, value]);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
keys() {
|
|
30
|
+
return this.#values.keys();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
values() {
|
|
34
|
+
return this.#values.values();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
forEach(callback, thisArg = undefined) {
|
|
38
|
+
for (const [key, value] of this.#values) callback.call(thisArg, value, key, this);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
[Symbol.iterator]() {
|
|
42
|
+
return this.entries();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get [Symbol.toStringTag]() {
|
|
46
|
+
return "ReadonlyIndex";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function freezeTree(value, seen = new Set()) {
|
|
51
|
+
if (value === null || typeof value !== "object" || Object.isFrozen(value) || seen.has(value)) return value;
|
|
52
|
+
seen.add(value);
|
|
53
|
+
for (const key of Reflect.ownKeys(value)) freezeTree(value[key], seen);
|
|
54
|
+
return Object.freeze(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function readonlyIndex(entries) {
|
|
58
|
+
return new ReadonlyIndex([...entries].sort(([left], [right]) => compareText(left, right)));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function groupedIndex(entries, keys) {
|
|
62
|
+
const groups = new Map();
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
for (const key of keys(entry)) {
|
|
65
|
+
const values = groups.get(key) ?? [];
|
|
66
|
+
values.push(entry);
|
|
67
|
+
groups.set(key, values);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return readonlyIndex([...groups.entries()].map(([key, values]) => [
|
|
71
|
+
key,
|
|
72
|
+
Object.freeze(values.sort((left, right) => compareText(left.unit.id, right.unit.id))),
|
|
73
|
+
]));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function compareEdge(left, right) {
|
|
77
|
+
return compareText(left.source, right.source)
|
|
78
|
+
|| compareText(left.type, right.type)
|
|
79
|
+
|| compareText(left.target, right.target);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function adjacencyIndexes(entries) {
|
|
83
|
+
const outgoing = new Map(entries.map((entry) => [entry.unit.id, []]));
|
|
84
|
+
const incoming = new Map(entries.map((entry) => [entry.unit.id, []]));
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
entry.unit.relations.forEach((relation, relationIndex) => {
|
|
87
|
+
const edge = Object.freeze({
|
|
88
|
+
source: entry.unit.id,
|
|
89
|
+
type: relation.type,
|
|
90
|
+
target: relation.target,
|
|
91
|
+
sourceUnitIndex: entry.unitIndex,
|
|
92
|
+
relationIndex,
|
|
93
|
+
});
|
|
94
|
+
outgoing.get(edge.source).push(edge);
|
|
95
|
+
incoming.get(edge.target).push(edge);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const finish = (index) => readonlyIndex([...index.entries()].map(([id, edges]) => [
|
|
99
|
+
id,
|
|
100
|
+
Object.freeze(edges.sort(compareEdge)),
|
|
101
|
+
]));
|
|
102
|
+
return { outgoing: finish(outgoing), incoming: finish(incoming) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function createCompiledAuthoritySnapshot(compiled, inputRoot) {
|
|
106
|
+
if (
|
|
107
|
+
compiled?.ok !== true
|
|
108
|
+
|| !Array.isArray(compiled?.ir?.units)
|
|
109
|
+
|| compiled?.sourceMap?.fields === null
|
|
110
|
+
|| typeof compiled?.sourceMap?.fields !== "object"
|
|
111
|
+
) {
|
|
112
|
+
throw new TypeError("compiled authority snapshot requires one successful compiler result");
|
|
113
|
+
}
|
|
114
|
+
if (typeof inputRoot !== "string" || inputRoot.length === 0) {
|
|
115
|
+
throw new TypeError("compiled authority snapshot requires one non-empty input root");
|
|
116
|
+
}
|
|
117
|
+
const resolvedRoot = path.resolve(inputRoot);
|
|
118
|
+
const entries = compiled.ir.units.map((unit, unitIndex) => {
|
|
119
|
+
const mapped = compiled.sourceMap.fields[`/units/${unitIndex}/id`];
|
|
120
|
+
if (!mapped) throw new TypeError(`compiled authority snapshot cannot locate unit identity: ${unit.id}`);
|
|
121
|
+
return Object.freeze({
|
|
122
|
+
unit,
|
|
123
|
+
unitIndex,
|
|
124
|
+
container: portablePath(mapped.file, resolvedRoot),
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
const byId = readonlyIndex(entries.map((entry) => [entry.unit.id, entry]));
|
|
128
|
+
const byOwner = groupedIndex(entries, (entry) => [entry.unit.owner]);
|
|
129
|
+
const byScope = groupedIndex(entries, (entry) => entry.unit.semantic?.scope ?? []);
|
|
130
|
+
const byContainer = groupedIndex(entries, (entry) => [entry.container]);
|
|
131
|
+
const { incoming, outgoing } = adjacencyIndexes(entries);
|
|
132
|
+
freezeTree(compiled);
|
|
133
|
+
return Object.freeze({
|
|
134
|
+
inputRoot: resolvedRoot,
|
|
135
|
+
compiled,
|
|
136
|
+
byId,
|
|
137
|
+
byOwner,
|
|
138
|
+
byScope,
|
|
139
|
+
byContainer,
|
|
140
|
+
incoming,
|
|
141
|
+
outgoing,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
|
|
4
4
|
import YAML from "yaml";
|
|
5
5
|
|
|
6
|
-
import { compareText, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
|
|
6
|
+
import { compareText, isPathWithin, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
|
|
7
7
|
import { AuthorityReviewRefusal } from "./git-snapshot.mjs";
|
|
8
8
|
import { resolveRepositoryCorpus } from "./repository-corpus.mjs";
|
|
9
9
|
import { repositoryInventory } from "./repository-inventory.mjs";
|
|
@@ -15,7 +15,10 @@ const CONSUMERS_FORMAT = "nimicoding.authority-consumers/v1";
|
|
|
15
15
|
// requiring a trailing segment reported it as no reference at all.
|
|
16
16
|
// A separator run rather than one character: inside a source string literal a
|
|
17
17
|
// Windows path is written escaped, so the file text holds ".nimi\\spec".
|
|
18
|
-
|
|
18
|
+
// A single backslash followed by a common source-string escape starter ends a
|
|
19
|
+
// local path instead of creating another path segment. Escaped Windows
|
|
20
|
+
// separators remain visible in source as a run of at least two backslashes.
|
|
21
|
+
const SPEC_REFERENCE = /\.nimi(?:\/+|\\{2,}|\\(?![nrtbfv0xu'"`$]))spec(?:(?:\/+|\\{2,}|\\(?![nrtbfv0xu'"`$]))[A-Za-z0-9_.*<>-]+)*/gu;
|
|
19
22
|
// A path built from adjacent string literals, e.g. path.join(root, '.nimi',
|
|
20
23
|
// 'spec', 'runtime', 'x.yaml'). Reconstructed only while every segment is a
|
|
21
24
|
// literal; the first non-literal segment makes the target unresolved.
|
|
@@ -84,11 +87,6 @@ function repositoryDiagnostic(error) {
|
|
|
84
87
|
return inputDiagnostic("AUTH_CONSUMERS_REPOSITORY_INVALID", error.message, error.file ?? ".");
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
function isWithin(root, candidate) {
|
|
88
|
-
const relative = path.relative(root, candidate);
|
|
89
|
-
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
90
|
async function resolveCorpus(repository, specPath) {
|
|
93
91
|
return resolveRepositoryCorpus(repository, specPath, "authority consumer");
|
|
94
92
|
}
|
|
@@ -115,29 +113,66 @@ function lineCounter(text) {
|
|
|
115
113
|
};
|
|
116
114
|
}
|
|
117
115
|
|
|
116
|
+
function collapseEscapedLineContinuations(text) {
|
|
117
|
+
const chunks = [];
|
|
118
|
+
const continuations = [];
|
|
119
|
+
let segmentStart = 0;
|
|
120
|
+
let searchFrom = 0;
|
|
121
|
+
let normalizedLength = 0;
|
|
122
|
+
for (let index = text.indexOf("\\", searchFrom); index !== -1; index = text.indexOf("\\", searchFrom)) {
|
|
123
|
+
const width = text[index + 1] === "\n"
|
|
124
|
+
? 2
|
|
125
|
+
: text[index + 1] === "\r" && text[index + 2] === "\n" ? 3 : 0;
|
|
126
|
+
if (width === 0) {
|
|
127
|
+
searchFrom = index + 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const chunk = text.slice(segmentStart, index);
|
|
131
|
+
chunks.push(chunk);
|
|
132
|
+
normalizedLength += chunk.length;
|
|
133
|
+
continuations.push({ offset: normalizedLength, width });
|
|
134
|
+
segmentStart = index + width;
|
|
135
|
+
searchFrom = segmentStart;
|
|
136
|
+
}
|
|
137
|
+
if (continuations.length === 0) return { text, originalOffset: (index) => index };
|
|
138
|
+
chunks.push(text.slice(segmentStart));
|
|
139
|
+
return {
|
|
140
|
+
text: chunks.join(""),
|
|
141
|
+
originalOffset(index) {
|
|
142
|
+
let removedWidth = 0;
|
|
143
|
+
for (const continuation of continuations) {
|
|
144
|
+
if (continuation.offset > index) break;
|
|
145
|
+
removedWidth += continuation.width;
|
|
146
|
+
}
|
|
147
|
+
return index + removedWidth;
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
118
152
|
function extractReferences(consumer, text) {
|
|
153
|
+
const logical = collapseEscapedLineContinuations(text);
|
|
119
154
|
const found = [];
|
|
120
|
-
for (const match of text.matchAll(SPEC_REFERENCE)) {
|
|
155
|
+
for (const match of logical.text.matchAll(SPEC_REFERENCE)) {
|
|
121
156
|
const target = match[0].replace(/[/\\]+/gu, "/");
|
|
122
157
|
if (target.includes("*") || target.includes("<") || target.includes(">")) continue;
|
|
123
|
-
found.push({ index: match.index, target, reason: null });
|
|
158
|
+
found.push({ index: logical.originalOffset(match.index), target, reason: null });
|
|
124
159
|
}
|
|
125
|
-
for (const match of text.matchAll(SPEC_CONSTRUCTOR)) {
|
|
160
|
+
for (const match of logical.text.matchAll(SPEC_CONSTRUCTOR)) {
|
|
126
161
|
const segments = [...match[3].matchAll(CONSTRUCTOR_SEGMENT)].map((segment) => segment[2]);
|
|
127
162
|
const target = [".nimi", "spec", ...segments].join("/");
|
|
128
163
|
// A further segment that is not a string literal makes the built path
|
|
129
164
|
// unknowable here. Reporting it as unresolved keeps the scan from
|
|
130
165
|
// presenting silence as proof that nothing consumes the corpus.
|
|
131
|
-
const continues = CONSTRUCTOR_CONTINUES.test(text.slice(match.index + match[0].length));
|
|
132
|
-
found.push({ index: match.index, target, reason: continues ? "non-literal-path-segment" : null });
|
|
166
|
+
const continues = CONSTRUCTOR_CONTINUES.test(logical.text.slice(match.index + match[0].length));
|
|
167
|
+
found.push({ index: logical.originalOffset(match.index), target, reason: continues ? "non-literal-path-segment" : null });
|
|
133
168
|
}
|
|
134
|
-
for (const match of text.matchAll(EXTERNAL_REFERENCE)) {
|
|
169
|
+
for (const match of logical.text.matchAll(EXTERNAL_REFERENCE)) {
|
|
135
170
|
const externalPath = match[2];
|
|
136
171
|
const fragment = match[3] ?? null;
|
|
137
172
|
const coordinate = `${match[1]}:${externalPath}${fragment === null ? "" : `#${fragment}`}`;
|
|
138
173
|
const nonCanonical = externalPath.split("/").some((segment) => segment === "." || segment === "..");
|
|
139
174
|
found.push({
|
|
140
|
-
index: match.index + match[0].indexOf(`${match[1]}:`),
|
|
175
|
+
index: logical.originalOffset(match.index + match[0].indexOf(`${match[1]}:`)),
|
|
141
176
|
target: coordinate,
|
|
142
177
|
scheme: match[1],
|
|
143
178
|
path: externalPath,
|
|
@@ -173,18 +208,28 @@ function targetExists(target, trackedFiles) {
|
|
|
173
208
|
// own. Without a root for that scheme the target is unknowable here, which is
|
|
174
209
|
// not the same claim as dead.
|
|
175
210
|
function classifyLocalReference(reference, trackedFiles) {
|
|
176
|
-
|
|
211
|
+
if (targetExists(reference.target, trackedFiles)) return { classification: "canonical", reference };
|
|
212
|
+
if (reference.target.endsWith(".")) {
|
|
213
|
+
const withoutSentencePeriod = reference.target.slice(0, -1);
|
|
214
|
+
if (targetExists(withoutSentencePeriod, trackedFiles)) {
|
|
215
|
+
return {
|
|
216
|
+
classification: "canonical",
|
|
217
|
+
reference: { ...reference, target: withoutSentencePeriod },
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { classification: "canonicalDead", reference };
|
|
177
222
|
}
|
|
178
223
|
|
|
179
224
|
async function externalTargetStatus(root, relative) {
|
|
180
225
|
const candidate = path.resolve(root, ...relative.split("/"));
|
|
181
|
-
if (!
|
|
226
|
+
if (!isPathWithin(root, candidate)) return "non-canonical";
|
|
182
227
|
|
|
183
228
|
let probe = candidate;
|
|
184
229
|
while (true) {
|
|
185
230
|
try {
|
|
186
231
|
const resolved = await realpath(probe);
|
|
187
|
-
if (!
|
|
232
|
+
if (!isPathWithin(root, resolved)) return "non-canonical";
|
|
188
233
|
return probe === candidate ? "authority" : "dead";
|
|
189
234
|
} catch (error) {
|
|
190
235
|
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
|
|
@@ -354,10 +399,14 @@ export async function consumersAuthorityRepository(repositoryPath, specPath, { m
|
|
|
354
399
|
}
|
|
355
400
|
|
|
356
401
|
const candidates = [...new Set([...inventory.files, "package.json"])].sort(compareText);
|
|
402
|
+
const gitlinks = new Set(inventory.gitlinks);
|
|
357
403
|
const classifiedFiles = await classifyTrackedTextFiles(
|
|
358
404
|
inventory.repository,
|
|
359
405
|
candidates,
|
|
360
|
-
(file) =>
|
|
406
|
+
(file) => {
|
|
407
|
+
if (gitlinks.has(file)) return "gitlink";
|
|
408
|
+
return isSpecSource(file) ? "authority-corpus" : null;
|
|
409
|
+
},
|
|
361
410
|
);
|
|
362
411
|
if (classifiedFiles.error !== undefined) {
|
|
363
412
|
return refused([inputDiagnostic("AUTH_CONSUMERS_INPUT_INVALID", classifiedFiles.error)]);
|
|
@@ -401,7 +450,8 @@ export async function consumersAuthorityRepository(repositoryPath, specPath, { m
|
|
|
401
450
|
try {
|
|
402
451
|
for (const reference of references) {
|
|
403
452
|
if (reference.external === undefined) {
|
|
404
|
-
|
|
453
|
+
const local = classifyLocalReference(reference, trackedFiles);
|
|
454
|
+
classified[local.classification].push(local.reference);
|
|
405
455
|
continue;
|
|
406
456
|
}
|
|
407
457
|
const root = roots.get(reference.external.scheme);
|
|
@@ -6,9 +6,15 @@ export function compareText(left, right) {
|
|
|
6
6
|
return a === b ? 0 : a < b ? -1 : 1;
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
export function isPathWithin(root, candidate) {
|
|
10
|
+
const relative = path.relative(root, candidate);
|
|
11
|
+
return relative === ""
|
|
12
|
+
|| (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
|
|
13
|
+
}
|
|
14
|
+
|
|
9
15
|
export function portablePath(filePath, cwd = process.cwd()) {
|
|
10
16
|
const relative = path.relative(cwd, filePath);
|
|
11
|
-
const value = relative &&
|
|
17
|
+
const value = relative && isPathWithin(cwd, filePath) ? relative : filePath;
|
|
12
18
|
return value.split(path.sep).join(path.posix.sep);
|
|
13
19
|
}
|
|
14
20
|
|
|
@@ -3,6 +3,7 @@ import { stat } from "node:fs/promises";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
|
|
5
5
|
import { compileAuthorityPath } from "./compile.mjs";
|
|
6
|
+
import { createCompiledAuthoritySnapshot } from "./compiled-snapshot.mjs";
|
|
6
7
|
import { compareText, makeDiagnostic, portablePath, sortDiagnostics } from "./diagnostics.mjs";
|
|
7
8
|
|
|
8
9
|
const DIFF_FORMAT = "nimicoding.authority-diff/v1";
|
|
@@ -46,14 +47,14 @@ function portableMapping(mapping, basePath) {
|
|
|
46
47
|
return { ...mapping, file: portablePath(mapping.file, basePath) };
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
function mappedAt(
|
|
50
|
-
const exact = compiled.sourceMap.fields[`/units/${unitIndex}${pointer}`];
|
|
51
|
-
if (exact) return portableMapping(exact,
|
|
50
|
+
function mappedAt(snapshot, unitIndex, pointer) {
|
|
51
|
+
const exact = snapshot.compiled.sourceMap.fields[`/units/${unitIndex}${pointer}`];
|
|
52
|
+
if (exact) return portableMapping(exact, snapshot.inputRoot);
|
|
52
53
|
const prefix = `/units/${unitIndex}${pointer}/`;
|
|
53
|
-
const nested = Object.entries(compiled.sourceMap.fields)
|
|
54
|
+
const nested = Object.entries(snapshot.compiled.sourceMap.fields)
|
|
54
55
|
.filter(([candidate]) => candidate.startsWith(prefix))
|
|
55
56
|
.sort(([left], [right]) => compareText(left, right))[0]?.[1];
|
|
56
|
-
return portableMapping(nested,
|
|
57
|
+
return portableMapping(nested, snapshot.inputRoot);
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
function addChange(changes, { unitId, operation = "modified", pointer, before, after, beforeSource = null, afterSource = null }) {
|
|
@@ -68,7 +69,7 @@ function addChange(changes, { unitId, operation = "modified", pointer, before, a
|
|
|
68
69
|
});
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
function compareScalar(changes,
|
|
72
|
+
function compareScalar(changes, beforeSnapshot, afterSnapshot, beforeEntry, afterEntry, pointer) {
|
|
72
73
|
const before = beforeEntry.unit;
|
|
73
74
|
const after = afterEntry.unit;
|
|
74
75
|
const read = (value, parts) => parts.reduce((current, part) => current?.[part], value);
|
|
@@ -81,21 +82,21 @@ function compareScalar(changes, beforeEntry, afterEntry, pointer, beforeBase, af
|
|
|
81
82
|
pointer,
|
|
82
83
|
before: left,
|
|
83
84
|
after: right,
|
|
84
|
-
beforeSource: mappedAt(
|
|
85
|
-
afterSource: mappedAt(
|
|
85
|
+
beforeSource: mappedAt(beforeSnapshot, beforeEntry.unitIndex, pointer),
|
|
86
|
+
afterSource: mappedAt(afterSnapshot, afterEntry.unitIndex, pointer),
|
|
86
87
|
});
|
|
87
88
|
}
|
|
88
89
|
|
|
89
|
-
function compareValueSet(changes,
|
|
90
|
+
function compareValueSet(changes, beforeSnapshot, afterSnapshot, beforeEntry, afterEntry, pointer) {
|
|
90
91
|
const parts = pointer.slice(1).split("/");
|
|
91
92
|
const read = (unit) => parts.reduce((current, part) => current?.[part], unit) ?? [];
|
|
92
93
|
const beforeValues = read(beforeEntry.unit);
|
|
93
94
|
const afterValues = read(afterEntry.unit);
|
|
94
95
|
const beforeSet = new Set(beforeValues.map((value) => JSON.stringify(value)));
|
|
95
96
|
const afterSet = new Set(afterValues.map((value) => JSON.stringify(value)));
|
|
96
|
-
for (const [operation, values, otherSet,
|
|
97
|
-
["removed", beforeValues, afterSet,
|
|
98
|
-
["added", afterValues, beforeSet,
|
|
97
|
+
for (const [operation, values, otherSet, snapshot, entry, sourceSide] of [
|
|
98
|
+
["removed", beforeValues, afterSet, beforeSnapshot, beforeEntry, "beforeSource"],
|
|
99
|
+
["added", afterValues, beforeSet, afterSnapshot, afterEntry, "afterSource"],
|
|
99
100
|
]) {
|
|
100
101
|
values.forEach((value, index) => {
|
|
101
102
|
if (otherSet.has(JSON.stringify(value))) return;
|
|
@@ -105,20 +106,20 @@ function compareValueSet(changes, beforeEntry, afterEntry, pointer, beforeBase,
|
|
|
105
106
|
pointer,
|
|
106
107
|
before: operation === "removed" ? value : null,
|
|
107
108
|
after: operation === "added" ? value : null,
|
|
108
|
-
[sourceSide]: mappedAt(
|
|
109
|
+
[sourceSide]: mappedAt(snapshot, entry.unitIndex, `${pointer}/${index}`),
|
|
109
110
|
});
|
|
110
111
|
});
|
|
111
112
|
}
|
|
112
113
|
}
|
|
113
114
|
|
|
114
|
-
function compareRelations(changes,
|
|
115
|
+
function compareRelations(changes, beforeSnapshot, afterSnapshot, beforeEntry, afterEntry) {
|
|
115
116
|
const beforeRelations = beforeEntry.unit.relations;
|
|
116
117
|
const afterRelations = afterEntry.unit.relations;
|
|
117
118
|
const beforeSet = new Set(beforeRelations.map((edge) => `${edge.type}\0${edge.target}`));
|
|
118
119
|
const afterSet = new Set(afterRelations.map((edge) => `${edge.type}\0${edge.target}`));
|
|
119
|
-
for (const [operation, edges, otherSet,
|
|
120
|
-
["removed", beforeRelations, afterSet,
|
|
121
|
-
["added", afterRelations, beforeSet,
|
|
120
|
+
for (const [operation, edges, otherSet, snapshot, entry, sourceSide] of [
|
|
121
|
+
["removed", beforeRelations, afterSet, beforeSnapshot, beforeEntry, "beforeSource"],
|
|
122
|
+
["added", afterRelations, beforeSet, afterSnapshot, afterEntry, "afterSource"],
|
|
122
123
|
]) {
|
|
123
124
|
edges.forEach((edge, index) => {
|
|
124
125
|
if (otherSet.has(`${edge.type}\0${edge.target}`)) return;
|
|
@@ -128,23 +129,23 @@ function compareRelations(changes, beforeEntry, afterEntry, beforeBase, afterBas
|
|
|
128
129
|
pointer: "/relations",
|
|
129
130
|
before: operation === "removed" ? edge : null,
|
|
130
131
|
after: operation === "added" ? edge : null,
|
|
131
|
-
[sourceSide]: mappedAt(
|
|
132
|
+
[sourceSide]: mappedAt(snapshot, entry.unitIndex, `/relations/${index}`),
|
|
132
133
|
});
|
|
133
134
|
});
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
|
|
137
|
-
function sideMappedAt(
|
|
138
|
-
const mapped = mappedAt(
|
|
138
|
+
function sideMappedAt(snapshot, unitIndex, pointer, side) {
|
|
139
|
+
const mapped = mappedAt(snapshot, unitIndex, pointer);
|
|
139
140
|
return { ...mapped, file: path.posix.join(side, mapped.file) };
|
|
140
141
|
}
|
|
141
142
|
|
|
142
|
-
function transitionDiagnostics(before, after
|
|
143
|
+
function transitionDiagnostics(before, after) {
|
|
143
144
|
const diagnostics = [];
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
if (
|
|
147
|
-
const mapped = sideMappedAt(before,
|
|
145
|
+
for (const entry of before.byId.values()) {
|
|
146
|
+
const { unit, unitIndex } = entry;
|
|
147
|
+
if (after.byId.has(unit.id)) continue;
|
|
148
|
+
const mapped = sideMappedAt(before, unitIndex, "/id", "before");
|
|
148
149
|
diagnostics.push(makeDiagnostic({
|
|
149
150
|
code: "AUTH_DIFF_TRANSITION_INVALID",
|
|
150
151
|
file: mapped.file,
|
|
@@ -155,12 +156,12 @@ function transitionDiagnostics(before, after, beforeBase, afterBase) {
|
|
|
155
156
|
? "retain the removed tombstone; permanent reserved-ID storage is not admitted"
|
|
156
157
|
: "replace the active unit with a valid removed tombstone carrying an explicit reason",
|
|
157
158
|
}));
|
|
158
|
-
}
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
const previous =
|
|
162
|
-
if (!previous)
|
|
163
|
-
const mapped = sideMappedAt(after,
|
|
159
|
+
}
|
|
160
|
+
for (const entry of after.byId.values()) {
|
|
161
|
+
const { unit, unitIndex } = entry;
|
|
162
|
+
const previous = before.byId.get(unit.id)?.unit;
|
|
163
|
+
if (!previous) continue;
|
|
164
|
+
const mapped = sideMappedAt(after, unitIndex, "/id", "after");
|
|
164
165
|
if (previous.lifecycle === "removed" && unit.lifecycle !== "removed") diagnostics.push(makeDiagnostic({
|
|
165
166
|
code: "AUTH_DIFF_TRANSITION_INVALID",
|
|
166
167
|
file: mapped.file,
|
|
@@ -177,18 +178,16 @@ function transitionDiagnostics(before, after, beforeBase, afterBase) {
|
|
|
177
178
|
reason: `authority identity cannot change kind from ${previous.kind} to ${unit.kind}: ${unit.id}`,
|
|
178
179
|
repair: "retain the original identity and declare a distinct ID for the new kind",
|
|
179
180
|
}));
|
|
180
|
-
}
|
|
181
|
+
}
|
|
181
182
|
return sortDiagnostics(diagnostics);
|
|
182
183
|
}
|
|
183
184
|
|
|
184
|
-
function compareUnits(before, after
|
|
185
|
+
function compareUnits(before, after) {
|
|
185
186
|
const changes = [];
|
|
186
|
-
const
|
|
187
|
-
const afterById = new Map(after.ir.units.map((unit, index) => [unit.id, { unit, index, compiled: after }]));
|
|
188
|
-
const ids = [...new Set([...beforeById.keys(), ...afterById.keys()])].sort(compareText);
|
|
187
|
+
const ids = [...new Set([...before.byId.keys(), ...after.byId.keys()])].sort(compareText);
|
|
189
188
|
for (const id of ids) {
|
|
190
|
-
const left =
|
|
191
|
-
const right =
|
|
189
|
+
const left = before.byId.get(id);
|
|
190
|
+
const right = after.byId.get(id);
|
|
192
191
|
if (!left) {
|
|
193
192
|
addChange(changes, {
|
|
194
193
|
unitId: id,
|
|
@@ -196,16 +195,16 @@ function compareUnits(before, after, beforeBase, afterBase) {
|
|
|
196
195
|
pointer: "",
|
|
197
196
|
before: null,
|
|
198
197
|
after: right.unit,
|
|
199
|
-
afterSource: mappedAt(after, right.
|
|
198
|
+
afterSource: mappedAt(after, right.unitIndex, "/id"),
|
|
200
199
|
});
|
|
201
200
|
continue;
|
|
202
201
|
}
|
|
203
202
|
if (!right) continue;
|
|
204
203
|
for (const pointer of ["/kind", "/owner", "/lifecycle", "/metadata/title", "/metadata/reason", "/semantic/meaning", "/semantic/modality", "/semantic/statement", "/semantic/condition", "/semantic/failure"]) {
|
|
205
|
-
compareScalar(changes,
|
|
204
|
+
compareScalar(changes, before, after, left, right, pointer);
|
|
206
205
|
}
|
|
207
|
-
compareValueSet(changes, left, right, "/semantic/scope"
|
|
208
|
-
compareRelations(changes,
|
|
206
|
+
compareValueSet(changes, before, after, left, right, "/semantic/scope");
|
|
207
|
+
compareRelations(changes, before, after, left, right);
|
|
209
208
|
}
|
|
210
209
|
return changes.sort((left, right) => (
|
|
211
210
|
compareText(left.unitId, right.unitId)
|
|
@@ -231,7 +230,7 @@ function budgetDiagnostic(requiredBytes, maxBytes) {
|
|
|
231
230
|
});
|
|
232
231
|
}
|
|
233
232
|
|
|
234
|
-
export async function
|
|
233
|
+
export async function diffAuthorityPathsWithSnapshots(beforePath, afterPath, { maxBytes = null } = {}) {
|
|
235
234
|
const [before, after, beforeBase, afterBase] = await Promise.all([
|
|
236
235
|
compileAuthorityPath(beforePath),
|
|
237
236
|
compileAuthorityPath(afterPath),
|
|
@@ -239,24 +238,46 @@ export async function diffAuthorityPaths(beforePath, afterPath, { maxBytes = nul
|
|
|
239
238
|
sourceRoot(afterPath),
|
|
240
239
|
]);
|
|
241
240
|
if (!before.ok || !after.ok) return {
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
241
|
+
beforeSnapshot: null,
|
|
242
|
+
afterSnapshot: null,
|
|
243
|
+
result: {
|
|
244
|
+
ok: false,
|
|
245
|
+
diagnostics: sortDiagnostics([
|
|
246
|
+
...sideDiagnostics(before.diagnostics, beforeBase, "before"),
|
|
247
|
+
...sideDiagnostics(after.diagnostics, afterBase, "after"),
|
|
248
|
+
]),
|
|
249
|
+
fileCount: before.fileCount + after.fileCount,
|
|
250
|
+
unitCount: 0,
|
|
251
|
+
diff: null,
|
|
252
|
+
partial: false,
|
|
253
|
+
},
|
|
254
|
+
};
|
|
255
|
+
const beforeSnapshot = createCompiledAuthoritySnapshot(before, beforeBase);
|
|
256
|
+
const afterSnapshot = createCompiledAuthoritySnapshot(after, afterBase);
|
|
257
|
+
return {
|
|
258
|
+
beforeSnapshot,
|
|
259
|
+
afterSnapshot,
|
|
260
|
+
result: diffAuthoritySnapshots(beforeSnapshot, afterSnapshot, { maxBytes }),
|
|
250
261
|
};
|
|
251
|
-
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function diffAuthorityPaths(beforePath, afterPath, options = {}) {
|
|
265
|
+
return (await diffAuthorityPathsWithSnapshots(beforePath, afterPath, options)).result;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function diffAuthoritySnapshots(beforeSnapshot, afterSnapshot, { maxBytes = null } = {}) {
|
|
269
|
+
const before = beforeSnapshot.compiled;
|
|
270
|
+
const after = afterSnapshot.compiled;
|
|
271
|
+
const invalidTransitions = transitionDiagnostics(beforeSnapshot, afterSnapshot);
|
|
252
272
|
if (invalidTransitions.length > 0) return {
|
|
253
273
|
ok: false,
|
|
254
274
|
diagnostics: invalidTransitions,
|
|
255
275
|
fileCount: before.fileCount + after.fileCount,
|
|
256
276
|
unitCount: 0,
|
|
257
277
|
diff: null,
|
|
278
|
+
partial: false,
|
|
258
279
|
};
|
|
259
|
-
const changes = compareUnits(
|
|
280
|
+
const changes = compareUnits(beforeSnapshot, afterSnapshot);
|
|
260
281
|
const diff = {
|
|
261
282
|
format: DIFF_FORMAT,
|
|
262
283
|
changes,
|
|
@@ -273,15 +294,17 @@ export async function diffAuthorityPaths(beforePath, afterPath, { maxBytes = nul
|
|
|
273
294
|
unitCount: 0,
|
|
274
295
|
payloadBytes,
|
|
275
296
|
diff: null,
|
|
297
|
+
partial: false,
|
|
276
298
|
};
|
|
277
299
|
return {
|
|
278
300
|
ok: true,
|
|
279
301
|
diagnostics: [],
|
|
280
302
|
fileCount: before.fileCount + after.fileCount,
|
|
281
|
-
unitCount: new Set([...
|
|
303
|
+
unitCount: new Set([...beforeSnapshot.byId.keys(), ...afterSnapshot.byId.keys()]).size,
|
|
282
304
|
payloadBytes,
|
|
283
305
|
diff,
|
|
284
306
|
before,
|
|
285
307
|
after,
|
|
308
|
+
partial: false,
|
|
286
309
|
};
|
|
287
310
|
}
|