llmnav 0.5.1
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 +113 -0
- package/LICENSE +21 -0
- package/README.md +294 -0
- package/ROADMAP.md +71 -0
- package/bin/llmnav.js +16 -0
- package/docs/agent-integration.md +114 -0
- package/docs/api.md +290 -0
- package/docs/architecture.md +286 -0
- package/docs/benchmarking.md +164 -0
- package/docs/ci.md +196 -0
- package/docs/cli.md +233 -0
- package/docs/configuration.md +117 -0
- package/docs/editor-integration.md +29 -0
- package/docs/faq.md +59 -0
- package/docs/graph.md +92 -0
- package/docs/language-examples.md +130 -0
- package/docs/migration.md +130 -0
- package/docs/performance-v0.2.md +42 -0
- package/docs/provider-neutral-integration.md +66 -0
- package/docs/publishing.md +86 -0
- package/docs/quickstart.md +139 -0
- package/docs/research.md +31 -0
- package/docs/spec.md +424 -0
- package/examples/provider-neutral-host.d.mts +17 -0
- package/examples/provider-neutral-host.mjs +40 -0
- package/package.json +79 -0
- package/schema/config.schema.json +296 -0
- package/src/agent-protocol.js +117 -0
- package/src/agent-tools.js +61 -0
- package/src/agents.js +127 -0
- package/src/boundaries.js +50 -0
- package/src/changes.js +168 -0
- package/src/cli.js +459 -0
- package/src/config.js +305 -0
- package/src/contracts.js +70 -0
- package/src/declaration.js +334 -0
- package/src/doctor.js +124 -0
- package/src/editor.js +107 -0
- package/src/evaluation.js +67 -0
- package/src/files.js +81 -0
- package/src/formatter.js +23 -0
- package/src/generator.js +528 -0
- package/src/graph-input.js +157 -0
- package/src/graph.js +403 -0
- package/src/incremental.js +262 -0
- package/src/index.d.ts +673 -0
- package/src/index.js +115 -0
- package/src/initializer.js +137 -0
- package/src/inverted-index.js +350 -0
- package/src/parser.js +449 -0
- package/src/project.js +65 -0
- package/src/prompt-bundle.js +108 -0
- package/src/registry.js +107 -0
- package/src/sarif.js +70 -0
- package/src/search-shards.js +75 -0
- package/src/search.js +636 -0
- package/src/spec.d.ts +27 -0
- package/src/spec.js +237 -0
- package/src/tokenizer.js +37 -0
- package/src/transaction.js +557 -0
- package/src/util.js +256 -0
- package/src/validator.js +635 -0
- package/templates/file-card.txt +8 -0
- package/templates/lexicon.json +7 -0
- package/templates/line-card.txt +9 -0
- package/templates/module-card.txt +9 -0
- package/templates/queries.jsonl +1 -0
- package/templates/symbol-card.txt +10 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
ALLOWED_KEYS,
|
|
4
|
+
DEFAULT_CONFIG,
|
|
5
|
+
EFFECT_KINDS,
|
|
6
|
+
FORBIDDEN_STRUCTURE_RELATIONS,
|
|
7
|
+
RELATION_KINDS,
|
|
8
|
+
RISK_KINDS,
|
|
9
|
+
SCOPES,
|
|
10
|
+
STABILITIES,
|
|
11
|
+
} from "./spec.js";
|
|
12
|
+
import { deepMerge, readJson } from "./util.js";
|
|
13
|
+
|
|
14
|
+
const REPOSITORY_ID_PATTERN = /^[a-z][a-z0-9-]{0,63}$/u;
|
|
15
|
+
const CONTROLLED_KIND_PATTERN = /^[a-z][a-z0-9.-]*$/u;
|
|
16
|
+
const RELATION_KIND_PATTERN = /^[a-z][a-z0-9-]*$/u;
|
|
17
|
+
const TOP_LEVEL_KEYS = new Set(Object.keys(DEFAULT_CONFIG));
|
|
18
|
+
const LINT_KEYS = new Set(Object.keys(DEFAULT_CONFIG.lint));
|
|
19
|
+
const GENERATION_KEYS = new Set(Object.keys(DEFAULT_CONFIG.generation));
|
|
20
|
+
const EVALUATION_KEYS = new Set(Object.keys(DEFAULT_CONFIG.evaluation));
|
|
21
|
+
const GRAPH_KEYS = new Set(Object.keys(DEFAULT_CONFIG.graph));
|
|
22
|
+
const COVERAGE_KEYS = new Set(["name", "match", "scope", "requiredFields"]);
|
|
23
|
+
|
|
24
|
+
export async function loadConfig(root) {
|
|
25
|
+
const configPath = path.join(root, ".llmnav", "config.json");
|
|
26
|
+
const custom = await readJson(configPath, {});
|
|
27
|
+
const config = deepMerge(DEFAULT_CONFIG, custom);
|
|
28
|
+
validateConfig(config, configPath);
|
|
29
|
+
return { config, configPath };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function validateConfig(config, configPath = ".llmnav/config.json") {
|
|
33
|
+
const problems = [];
|
|
34
|
+
if (!isObject(config)) throw new Error(`${configPath}:\n configuration must be a JSON object`);
|
|
35
|
+
validateObjectKeys(config, TOP_LEVEL_KEYS, "configuration", problems);
|
|
36
|
+
|
|
37
|
+
if (config.$schema !== undefined && (typeof config.$schema !== "string" || !config.$schema.trim())) {
|
|
38
|
+
problems.push("$schema must be a non-empty string when present");
|
|
39
|
+
}
|
|
40
|
+
if (config.version !== 1) problems.push("version must be 1");
|
|
41
|
+
if (typeof config.repositoryId !== "string" || !REPOSITORY_ID_PATTERN.test(config.repositoryId)) {
|
|
42
|
+
problems.push("repositoryId must match ^[a-z][a-z0-9-]{0,63}$");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
validateStringArray(config.sourceRoots, "sourceRoots", problems, { minimum: 1, unique: true });
|
|
46
|
+
if (Array.isArray(config.sourceRoots)) {
|
|
47
|
+
for (const [index, sourceRoot] of config.sourceRoots.entries()) {
|
|
48
|
+
const problem = validateProjectRelativePath(sourceRoot, { allowDot: true });
|
|
49
|
+
if (problem) problems.push(`sourceRoots[${index}] ${problem}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
validateStringArray(config.includeExtensions, "includeExtensions", problems, { minimum: 1, unique: true });
|
|
54
|
+
if (Array.isArray(config.includeExtensions)) {
|
|
55
|
+
for (const [index, extension] of config.includeExtensions.entries()) {
|
|
56
|
+
if (!extension.startsWith(".") || /[\\/\s]/u.test(extension)) {
|
|
57
|
+
problems.push(`includeExtensions[${index}] must be a dot-prefixed extension without path separators`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
validateStringArray(config.excludeDirectories, "excludeDirectories", problems, { unique: true });
|
|
62
|
+
validateStringArray(config.excludeFiles, "excludeFiles", problems, { unique: true });
|
|
63
|
+
validateCoverageRules(config.coverageRules, problems);
|
|
64
|
+
validateGraph(config.graph, problems);
|
|
65
|
+
|
|
66
|
+
validateLint(config.lint, problems);
|
|
67
|
+
validateGeneration(config.generation, problems);
|
|
68
|
+
validateEvaluation(config.evaluation, problems);
|
|
69
|
+
|
|
70
|
+
if (problems.length > 0) {
|
|
71
|
+
throw new Error(`${configPath}:\n${problems.map((problem) => ` ${problem}`).join("\n")}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function validateGraph(graph, problems) {
|
|
76
|
+
if (!isObject(graph)) {
|
|
77
|
+
problems.push("graph must be an object");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
validateObjectKeys(graph, GRAPH_KEYS, "graph", problems);
|
|
81
|
+
validateStringArray(graph.indexFiles, "graph.indexFiles", problems, { unique: true });
|
|
82
|
+
if (Array.isArray(graph.indexFiles)) {
|
|
83
|
+
for (const [index, file] of graph.indexFiles.entries()) {
|
|
84
|
+
const problem = validateProjectRelativePath(file);
|
|
85
|
+
if (problem) problems.push(`graph.indexFiles[${index}] ${problem}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateCoverageRules(rules, problems) {
|
|
91
|
+
if (!Array.isArray(rules)) {
|
|
92
|
+
problems.push("coverageRules must be an array");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
for (const [index, rule] of rules.entries()) {
|
|
96
|
+
const name = `coverageRules[${index}]`;
|
|
97
|
+
if (!isObject(rule)) {
|
|
98
|
+
problems.push(`${name} must be an object`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
validateObjectKeys(rule, COVERAGE_KEYS, name, problems);
|
|
102
|
+
if (rule.name !== undefined && (typeof rule.name !== "string" || !rule.name.trim())) {
|
|
103
|
+
problems.push(`${name}.name must be a non-empty string when present`);
|
|
104
|
+
}
|
|
105
|
+
validateStringArray(rule.match, `${name}.match`, problems, { minimum: 1, unique: true });
|
|
106
|
+
if (rule.scope !== undefined && !SCOPES.includes(rule.scope)) {
|
|
107
|
+
problems.push(`${name}.scope must be one of ${SCOPES.join(", ")}`);
|
|
108
|
+
}
|
|
109
|
+
if (rule.requiredFields !== undefined) {
|
|
110
|
+
validateStringArray(rule.requiredFields, `${name}.requiredFields`, problems, { unique: true });
|
|
111
|
+
if (Array.isArray(rule.requiredFields)) {
|
|
112
|
+
for (const field of rule.requiredFields) {
|
|
113
|
+
if (!ALLOWED_KEYS.includes(field)) problems.push(`${name}.requiredFields contains unknown field ${JSON.stringify(field)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateLint(lint, problems) {
|
|
121
|
+
if (!isObject(lint)) {
|
|
122
|
+
problems.push("lint must be an object");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
validateObjectKeys(lint, LINT_KEYS, "lint", problems);
|
|
126
|
+
|
|
127
|
+
validateInteger(lint.maxRoleLength, "lint.maxRoleLength", problems, 40);
|
|
128
|
+
validateInteger(lint.maxSearchTerms, "lint.maxSearchTerms", problems, 1);
|
|
129
|
+
validateInteger(lint.minSearchTerms, "lint.minSearchTerms", problems, 0);
|
|
130
|
+
validateInteger(lint.maxInvariants, "lint.maxInvariants", problems, 0);
|
|
131
|
+
validateInteger(lint.maxEffects, "lint.maxEffects", problems, 0);
|
|
132
|
+
validateInteger(lint.maxRelations, "lint.maxRelations", problems, 0);
|
|
133
|
+
validateInteger(lint.minimumSourceBytesForRatio, "lint.minimumSourceBytesForRatio", problems, 0);
|
|
134
|
+
validateInteger(lint.minimumCardsForSaturation, "lint.minimumCardsForSaturation", problems, 1);
|
|
135
|
+
if (Number.isInteger(lint.minSearchTerms) && Number.isInteger(lint.maxSearchTerms) && lint.minSearchTerms > lint.maxSearchTerms) {
|
|
136
|
+
problems.push("lint.minSearchTerms must not exceed lint.maxSearchTerms");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
for (const key of ["maxSemanticRatio", "searchTermSaturation"]) {
|
|
140
|
+
if (typeof lint[key] !== "number" || !Number.isFinite(lint[key]) || lint[key] < 0 || lint[key] > 1) {
|
|
141
|
+
problems.push(`lint.${key} must be a number from 0 to 1`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!isObject(lint.maxBlockBytes)) {
|
|
146
|
+
problems.push("lint.maxBlockBytes must be an object");
|
|
147
|
+
} else {
|
|
148
|
+
validateObjectKeys(lint.maxBlockBytes, new Set(SCOPES), "lint.maxBlockBytes", problems);
|
|
149
|
+
for (const scope of SCOPES) validateInteger(lint.maxBlockBytes[scope], `lint.maxBlockBytes.${scope}`, problems, 100);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const key of [
|
|
153
|
+
"genericSearchTerms",
|
|
154
|
+
"vagueRoleWords",
|
|
155
|
+
"strictRisks",
|
|
156
|
+
"additionalEffects",
|
|
157
|
+
"additionalRisks",
|
|
158
|
+
"additionalRelations",
|
|
159
|
+
]) {
|
|
160
|
+
validateStringArray(lint[key], `lint.${key}`, problems, { unique: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
validateVocabularyExtensions(lint, problems);
|
|
164
|
+
for (const key of ["requireCanonicalOrder", "requireCanonicalFormatting"]) {
|
|
165
|
+
if (typeof lint[key] !== "boolean") problems.push(`lint.${key} must be a boolean`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function validateVocabularyExtensions(lint, problems) {
|
|
170
|
+
const baseEffects = new Set(EFFECT_KINDS);
|
|
171
|
+
const baseRisks = new Set(RISK_KINDS);
|
|
172
|
+
const baseRelations = new Set(RELATION_KINDS);
|
|
173
|
+
const forbiddenRelations = new Set(FORBIDDEN_STRUCTURE_RELATIONS);
|
|
174
|
+
|
|
175
|
+
for (const [index, effect] of (Array.isArray(lint.additionalEffects) ? lint.additionalEffects : []).entries()) {
|
|
176
|
+
if (!CONTROLLED_KIND_PATTERN.test(effect)) {
|
|
177
|
+
problems.push(`lint.additionalEffects[${index}] must be a controlled effect kind such as queue.publish`);
|
|
178
|
+
} else if (baseEffects.has(effect)) {
|
|
179
|
+
problems.push(`lint.additionalEffects[${index}] duplicates base effect ${effect}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
for (const [index, risk] of (Array.isArray(lint.additionalRisks) ? lint.additionalRisks : []).entries()) {
|
|
183
|
+
if (!CONTROLLED_KIND_PATTERN.test(risk)) {
|
|
184
|
+
problems.push(`lint.additionalRisks[${index}] must be a controlled lower-case identifier`);
|
|
185
|
+
} else if (baseRisks.has(risk)) {
|
|
186
|
+
problems.push(`lint.additionalRisks[${index}] duplicates base risk ${risk}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const [index, relation] of (Array.isArray(lint.additionalRelations) ? lint.additionalRelations : []).entries()) {
|
|
190
|
+
if (!RELATION_KIND_PATTERN.test(relation)) {
|
|
191
|
+
problems.push(`lint.additionalRelations[${index}] must be a controlled lower-case relation type`);
|
|
192
|
+
} else if (baseRelations.has(relation) || forbiddenRelations.has(relation)) {
|
|
193
|
+
problems.push(`lint.additionalRelations[${index}] uses reserved relation ${relation}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const allowedRisks = new Set([
|
|
198
|
+
...RISK_KINDS,
|
|
199
|
+
...(Array.isArray(lint.additionalRisks) ? lint.additionalRisks : []),
|
|
200
|
+
]);
|
|
201
|
+
for (const [index, risk] of (Array.isArray(lint.strictRisks) ? lint.strictRisks : []).entries()) {
|
|
202
|
+
if (!allowedRisks.has(risk)) problems.push(`lint.strictRisks[${index}] contains unknown risk ${JSON.stringify(risk)}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function validateGeneration(generation, problems) {
|
|
207
|
+
if (!isObject(generation)) {
|
|
208
|
+
problems.push("generation must be an object");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
validateObjectKeys(generation, GENERATION_KEYS, "generation", problems);
|
|
212
|
+
const cacheProblem = validateProjectRelativePath(generation.cacheDirectory, {
|
|
213
|
+
requiredPrefix: ".llmnav/",
|
|
214
|
+
});
|
|
215
|
+
if (cacheProblem) problems.push(`generation.cacheDirectory ${cacheProblem}`);
|
|
216
|
+
if (typeof generation.cacheDirectory === "string") {
|
|
217
|
+
const normalized = generation.cacheDirectory.replace(/\/+$/u, "");
|
|
218
|
+
const reserved = [
|
|
219
|
+
".llmnav/.transactions",
|
|
220
|
+
".llmnav/config.json",
|
|
221
|
+
".llmnav/generation-transaction.json",
|
|
222
|
+
".llmnav/generation.lock",
|
|
223
|
+
".llmnav/ids.jsonl",
|
|
224
|
+
".llmnav/order.lock",
|
|
225
|
+
];
|
|
226
|
+
if (reserved.some((entry) => normalized === entry || normalized.startsWith(`${entry}/`) || entry.startsWith(`${normalized}/`))) {
|
|
227
|
+
problems.push("generation.cacheDirectory must not overlap LLMNav control state");
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (!Number.isInteger(generation.moduleDepth) || generation.moduleDepth < 1 || generation.moduleDepth > 6) {
|
|
231
|
+
problems.push("generation.moduleDepth must be an integer from 1 to 6");
|
|
232
|
+
}
|
|
233
|
+
validateInteger(generation.searchShardSize, "generation.searchShardSize", problems, 0);
|
|
234
|
+
for (const key of ["repositoryCatalogStabilities", "moduleCatalogStabilities"]) {
|
|
235
|
+
validateStringArray(generation[key], `generation.${key}`, problems, { unique: true });
|
|
236
|
+
if (Array.isArray(generation[key])) {
|
|
237
|
+
for (const value of generation[key]) {
|
|
238
|
+
if (!STABILITIES.includes(value)) problems.push(`generation.${key} contains unknown stability ${JSON.stringify(value)}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function validateEvaluation(evaluation, problems) {
|
|
245
|
+
if (!isObject(evaluation)) {
|
|
246
|
+
problems.push("evaluation must be an object");
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
validateObjectKeys(evaluation, EVALUATION_KEYS, "evaluation", problems);
|
|
250
|
+
const queryProblem = validateProjectRelativePath(evaluation.queryFile);
|
|
251
|
+
if (queryProblem) problems.push(`evaluation.queryFile ${queryProblem}`);
|
|
252
|
+
for (const key of ["minimumRecallAt1", "minimumRecallAt5"]) {
|
|
253
|
+
if (typeof evaluation[key] !== "number" || !Number.isFinite(evaluation[key]) || evaluation[key] < 0 || evaluation[key] > 1) {
|
|
254
|
+
problems.push(`evaluation.${key} must be a number from 0 to 1`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function validateObjectKeys(value, allowed, name, problems) {
|
|
260
|
+
for (const key of Object.keys(value)) {
|
|
261
|
+
if (!allowed.has(key)) problems.push(`${name} contains unknown property ${JSON.stringify(key)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function validateInteger(value, name, problems, minimum) {
|
|
266
|
+
if (!Number.isInteger(value) || value < minimum) problems.push(`${name} must be an integer of at least ${minimum}`);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateStringArray(value, name, problems, options = {}) {
|
|
270
|
+
if (!Array.isArray(value)) {
|
|
271
|
+
problems.push(`${name} must be an array`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
if (value.length < (options.minimum ?? 0)) problems.push(`${name} must contain at least ${options.minimum} value(s)`);
|
|
275
|
+
const seen = new Set();
|
|
276
|
+
for (const [index, item] of value.entries()) {
|
|
277
|
+
if (typeof item !== "string" || !item.trim()) {
|
|
278
|
+
problems.push(`${name}[${index}] must be a non-empty string`);
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
if (options.unique) {
|
|
282
|
+
const normalized = item.normalize("NFKC").toLocaleLowerCase("en-US");
|
|
283
|
+
if (seen.has(normalized)) problems.push(`${name}[${index}] duplicates an earlier value`);
|
|
284
|
+
seen.add(normalized);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function validateProjectRelativePath(value, options = {}) {
|
|
290
|
+
if (typeof value !== "string" || !value.trim()) return "must be a non-empty relative path";
|
|
291
|
+
if (/\p{Cc}/u.test(value)) return "must not contain control characters";
|
|
292
|
+
if (value.includes("\\")) return "must use forward slashes";
|
|
293
|
+
if (/^(?:[A-Za-z]:|\/|~\/)/u.test(value)) return "must remain relative to the repository root";
|
|
294
|
+
const segments = value.split("/").filter((segment) => segment !== "");
|
|
295
|
+
if (segments.includes("..")) return "must not contain parent-directory traversal";
|
|
296
|
+
if (!options.allowDot && (value === "." || value === "./")) return "must name a path below the repository root";
|
|
297
|
+
if (options.requiredPrefix && !value.startsWith(options.requiredPrefix)) {
|
|
298
|
+
return `must remain under ${options.requiredPrefix}`;
|
|
299
|
+
}
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isObject(value) {
|
|
304
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
305
|
+
}
|
package/src/contracts.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/* llmnav/1 module
|
|
2
|
+
id=llmnav.contract.fingerprint
|
|
3
|
+
role=Fingerprint repository API and configuration contracts without coupling them to source locations.
|
|
4
|
+
owns=contract fingerprint schema|export selection|contract drift comparison
|
|
5
|
+
excludes=semantic version decisions|source mutation
|
|
6
|
+
search=API fingerprint|configuration fingerprint|contract drift
|
|
7
|
+
rel=workflow>llmnav.index.generate
|
|
8
|
+
stability=architecture
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { compareText, sha256, stableJson } from "./util.js";
|
|
13
|
+
|
|
14
|
+
export const CONTRACT_FINGERPRINT_SCHEMA_VERSION = 1;
|
|
15
|
+
|
|
16
|
+
export function buildContractFingerprints(project, cards) {
|
|
17
|
+
const exportedApi = cards
|
|
18
|
+
.filter(isExportedApiCard)
|
|
19
|
+
.map((card) => ({
|
|
20
|
+
id: card.id,
|
|
21
|
+
kind: card.location.kind,
|
|
22
|
+
symbol: card.location.symbol,
|
|
23
|
+
signature: card.location.signature,
|
|
24
|
+
}))
|
|
25
|
+
.sort((left, right) => compareText(left.id, right.id));
|
|
26
|
+
const { $schema: _schemaLocation, ...effectiveConfig } = project.config;
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
schemaVersion: CONTRACT_FINGERPRINT_SCHEMA_VERSION,
|
|
30
|
+
exportedApi: {
|
|
31
|
+
count: exportedApi.length,
|
|
32
|
+
sha256: sha256(stableJson(exportedApi)),
|
|
33
|
+
},
|
|
34
|
+
configuration: {
|
|
35
|
+
sha256: sha256(stableJson(effectiveConfig)),
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function compareContractFingerprints(previous, current) {
|
|
41
|
+
if (previous?.schemaVersion !== CONTRACT_FINGERPRINT_SCHEMA_VERSION) return [];
|
|
42
|
+
if (current?.schemaVersion !== CONTRACT_FINGERPRINT_SCHEMA_VERSION) return [];
|
|
43
|
+
|
|
44
|
+
const changes = [];
|
|
45
|
+
for (const kind of ["exportedApi", "configuration"]) {
|
|
46
|
+
if (previous[kind]?.sha256 === current[kind]?.sha256) continue;
|
|
47
|
+
changes.push({
|
|
48
|
+
kind,
|
|
49
|
+
previous: previous[kind]?.sha256 ?? null,
|
|
50
|
+
current: current[kind]?.sha256 ?? null,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return changes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isExportedApiCard(card) {
|
|
57
|
+
const location = card.location;
|
|
58
|
+
if (!location?.symbol || !location.signature) return false;
|
|
59
|
+
if (typeof location.exported === "boolean") return location.exported;
|
|
60
|
+
const extension = path.extname(location.path).toLowerCase();
|
|
61
|
+
const signature = location.signature.trim();
|
|
62
|
+
|
|
63
|
+
if ([".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"].includes(extension)) {
|
|
64
|
+
return /^(?:export\s+(?:default\s+)?)/u.test(signature);
|
|
65
|
+
}
|
|
66
|
+
if (extension === ".go") return /^[A-Z]/u.test(location.symbol);
|
|
67
|
+
if (extension === ".rs") return /^pub(?:\([^)]*\))?\s+/u.test(signature);
|
|
68
|
+
if (extension === ".py") return !location.symbol.startsWith("_");
|
|
69
|
+
return /^(?:public|export)\s+/u.test(signature);
|
|
70
|
+
}
|