@nimiplatform/nimi-coding 0.3.1 → 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.
- package/CHANGELOG.md +23 -0
- package/CONTRIBUTING.md +3 -3
- package/README.md +285 -41
- package/README.zh-CN.md +294 -31
- package/bin/nimicoding.mjs +1 -1
- package/cli/commands/authority.mjs +849 -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 +173 -0
- package/cli/dev/check-product-conformance.mjs +917 -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 +490 -0
- package/cli/lib/authority/closed-sets.mjs +646 -0
- package/cli/lib/authority/compile.mjs +110 -0
- package/cli/lib/authority/consumers.mjs +470 -0
- package/cli/lib/authority/diagnostics.mjs +87 -0
- package/cli/lib/authority/diff.mjs +287 -0
- package/cli/lib/authority/discover.mjs +447 -0
- package/cli/lib/authority/evidence-snapshot.mjs +1 -0
- package/cli/lib/authority/evidence.mjs +1432 -0
- package/cli/lib/authority/format.mjs +197 -0
- package/cli/lib/authority/git-snapshot.mjs +1065 -0
- package/cli/lib/authority/graph.mjs +351 -0
- package/cli/lib/authority/impact.mjs +252 -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 +222 -0
- package/cli/lib/authority/sarif.mjs +181 -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 +312 -0
- package/cli/lib/authority/surface.mjs +79 -0
- package/cli/lib/authority/terms.mjs +421 -0
- package/cli/lib/authority/tracked-text.mjs +70 -0
- package/cli/lib/authority/validate.mjs +308 -0
- package/cli/lib/bootstrap.mjs +55 -71
- package/cli/lib/doctor.mjs +40 -123
- package/cli/lib/entrypoints.mjs +103 -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-evidence-bindings.schema.yaml +53 -0
- package/contracts/authority-evidence-probe-results.schema.yaml +38 -0
- package/contracts/authority-impact-dispositions.schema.yaml +13 -0
- package/contracts/authority-scope-bindings.schema.yaml +15 -0
- package/contracts/authority-source.schema.yaml +64 -0
- package/contracts/authority-verifier-bindings.schema.yaml +20 -0
- package/methodology/authority-authoring.yaml +144 -0
- package/methodology/core.yaml +31 -26
- package/package.json +10 -6
- package/spec/authority-anchors.yaml +99 -0
- package/spec/authority-audit.yaml +85 -0
- package/spec/authority-authoring.yaml +150 -0
- package/spec/authority-closed-sets.yaml +65 -0
- package/spec/authority-consumers.yaml +70 -0
- package/spec/authority-context.yaml +50 -0
- package/spec/authority-discovery.yaml +119 -0
- package/spec/authority-evidence.yaml +200 -0
- package/spec/authority-graph.yaml +59 -0
- package/spec/authority-impact.yaml +84 -0
- package/spec/authority-review.yaml +89 -0
- package/spec/authority-terms.yaml +59 -0
- package/spec/product-scope.yaml +36 -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,110 @@
|
|
|
1
|
+
import { compareText, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
|
|
2
|
+
import { formattingDiagnostics, parseAuthorityPath } from "./format.mjs";
|
|
3
|
+
import { validateAuthorityScopeBindings } from "./scope-bindings.mjs";
|
|
4
|
+
import { buildSourceMap } from "./source-map.mjs";
|
|
5
|
+
import { validateAuthoritySources } from "./validate.mjs";
|
|
6
|
+
|
|
7
|
+
function lowerUnit(source) {
|
|
8
|
+
const data = source.data;
|
|
9
|
+
const unit = {
|
|
10
|
+
id: data.id,
|
|
11
|
+
kind: data.kind,
|
|
12
|
+
owner: data.owner,
|
|
13
|
+
lifecycle: data.lifecycle,
|
|
14
|
+
metadata: {
|
|
15
|
+
title: data.title,
|
|
16
|
+
...(data.reason === undefined ? {} : { reason: data.reason }),
|
|
17
|
+
},
|
|
18
|
+
relations: [...data.relations]
|
|
19
|
+
.map((relation) => ({ type: relation.type, target: relation.target }))
|
|
20
|
+
.sort((left, right) => compareText(left.type, right.type) || compareText(left.target, right.target)),
|
|
21
|
+
};
|
|
22
|
+
if (data.lifecycle === "active" && data.kind === "definition") unit.semantic = { meaning: data.meaning };
|
|
23
|
+
if (data.lifecycle === "active" && data.kind === "rule") unit.semantic = {
|
|
24
|
+
modality: data.modality,
|
|
25
|
+
scope: [...data.scope].sort(compareText),
|
|
26
|
+
statement: data.statement,
|
|
27
|
+
condition: data.condition,
|
|
28
|
+
failure: data.failure,
|
|
29
|
+
};
|
|
30
|
+
return unit;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function admittedSources(inputPath) {
|
|
34
|
+
const parsed = await parseAuthorityPath(inputPath);
|
|
35
|
+
const formatDiagnostics = formattingDiagnostics(parsed.documents, parsed.contract);
|
|
36
|
+
if (formatDiagnostics.length > 0) return { ok: false, diagnostics: formatDiagnostics, parsed };
|
|
37
|
+
const validation = validateAuthoritySources(parsed.sources, parsed.contract);
|
|
38
|
+
if (!validation.ok) return { ok: false, diagnostics: validation.diagnostics, parsed };
|
|
39
|
+
return { ok: true, diagnostics: [], parsed };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function requiredFormatDiagnostics(documents, requiredFormat) {
|
|
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 } = {}) {
|
|
59
|
+
const result = await admittedSources(inputPath);
|
|
60
|
+
const formatDiagnostics = requiredFormatDiagnostics(result.parsed.documents, requireFormat);
|
|
61
|
+
const diagnostics = sortDiagnostics([...result.diagnostics, ...formatDiagnostics]);
|
|
62
|
+
if (!result.ok || formatDiagnostics.length > 0 || scopeBindings === null) return {
|
|
63
|
+
ok: result.ok && formatDiagnostics.length === 0,
|
|
64
|
+
diagnostics,
|
|
65
|
+
fileCount: result.parsed.files.length,
|
|
66
|
+
unitCount: result.ok && formatDiagnostics.length === 0 ? result.parsed.sources.length : 0,
|
|
67
|
+
};
|
|
68
|
+
const scopeValidation = await validateAuthorityScopeBindings(result.parsed.sources, scopeBindings);
|
|
69
|
+
return {
|
|
70
|
+
ok: scopeValidation.ok,
|
|
71
|
+
diagnostics: scopeValidation.diagnostics,
|
|
72
|
+
fileCount: result.parsed.files.length,
|
|
73
|
+
unitCount: scopeValidation.ok ? result.parsed.sources.length : 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function compileAuthorityPath(inputPath) {
|
|
78
|
+
const admitted = await admittedSources(inputPath);
|
|
79
|
+
const inputBytes = admitted.parsed.documents.reduce((total, document) => total + document.inputBytes, 0);
|
|
80
|
+
if (!admitted.ok) return {
|
|
81
|
+
ok: false,
|
|
82
|
+
diagnostics: admitted.diagnostics,
|
|
83
|
+
fileCount: admitted.parsed.files.length,
|
|
84
|
+
unitCount: 0,
|
|
85
|
+
inputBytes,
|
|
86
|
+
ir: null,
|
|
87
|
+
sourceMap: null,
|
|
88
|
+
};
|
|
89
|
+
const sources = [...admitted.parsed.sources].sort((left, right) => compareText(left.data.id, right.data.id));
|
|
90
|
+
const ir = { units: sources.map(lowerUnit) };
|
|
91
|
+
const mapped = buildSourceMap(sources, ir);
|
|
92
|
+
if (!mapped.ok) return {
|
|
93
|
+
ok: false,
|
|
94
|
+
diagnostics: mapped.diagnostics,
|
|
95
|
+
fileCount: admitted.parsed.files.length,
|
|
96
|
+
unitCount: 0,
|
|
97
|
+
inputBytes,
|
|
98
|
+
ir: null,
|
|
99
|
+
sourceMap: null,
|
|
100
|
+
};
|
|
101
|
+
return {
|
|
102
|
+
ok: true,
|
|
103
|
+
diagnostics: [],
|
|
104
|
+
fileCount: admitted.parsed.files.length,
|
|
105
|
+
unitCount: ir.units.length,
|
|
106
|
+
inputBytes,
|
|
107
|
+
ir,
|
|
108
|
+
sourceMap: mapped.sourceMap,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
import { readFile, lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import YAML from "yaml";
|
|
5
|
+
|
|
6
|
+
import { compareText, makeDiagnostic, sortDiagnostics } from "./diagnostics.mjs";
|
|
7
|
+
import { AuthorityReviewRefusal } from "./git-snapshot.mjs";
|
|
8
|
+
import { resolveRepositoryCorpus } from "./repository-corpus.mjs";
|
|
9
|
+
import { repositoryInventory } from "./repository-inventory.mjs";
|
|
10
|
+
import { classifyTrackedTextFiles, trackedTextInventory } from "./tracked-text.mjs";
|
|
11
|
+
|
|
12
|
+
const CONSUMERS_FORMAT = "nimicoding.authority-consumers/v1";
|
|
13
|
+
// Literal references, in either slash direction, including the bare corpus
|
|
14
|
+
// root: a checker that walks ".nimi/spec" and recurses is a consumer, and
|
|
15
|
+
// requiring a trailing segment reported it as no reference at all.
|
|
16
|
+
// A separator run rather than one character: inside a source string literal a
|
|
17
|
+
// Windows path is written escaped, so the file text holds ".nimi\\spec".
|
|
18
|
+
const SPEC_REFERENCE = /\.nimi[/\\]+spec(?:[/\\]+[A-Za-z0-9_.\\/*<>-]*[A-Za-z0-9_*<>-])?/gu;
|
|
19
|
+
// A path built from adjacent string literals, e.g. path.join(root, '.nimi',
|
|
20
|
+
// 'spec', 'runtime', 'x.yaml'). Reconstructed only while every segment is a
|
|
21
|
+
// literal; the first non-literal segment makes the target unresolved.
|
|
22
|
+
const SPEC_CONSTRUCTOR = /(['"])\.nimi\1\s*,\s*(['"])spec\2((?:\s*,\s*(['"])[A-Za-z0-9_.-]+\4)*)/gu;
|
|
23
|
+
const CONSTRUCTOR_SEGMENT = /(['"])([A-Za-z0-9_.-]+)\1/gu;
|
|
24
|
+
const CONSTRUCTOR_CONTINUES = /^\s*,\s*[^'")\s]/u;
|
|
25
|
+
// An authority owned by another repository, named by an explicit scheme:
|
|
26
|
+
// "<scheme>:spec/<path>". The scheme is whatever the projects agreed on; this
|
|
27
|
+
// tool does not know any particular one, and resolves a scheme only when the
|
|
28
|
+
// caller supplies a root for it.
|
|
29
|
+
const EXTERNAL_UNIT_ID = "[a-z](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z](?:[a-z0-9-]*[a-z0-9])?)+";
|
|
30
|
+
const EXTERNAL_REFERENCE = new RegExp(
|
|
31
|
+
`(?:^|[^A-Za-z0-9_.:/-])([a-z][a-z0-9-]*):(spec/[A-Za-z0-9_./-]*[A-Za-z0-9_-])(?:#(${EXTERNAL_UNIT_ID}))?`,
|
|
32
|
+
"gu",
|
|
33
|
+
);
|
|
34
|
+
const LIFECYCLE_SCRIPTS = new Set([
|
|
35
|
+
"test", "build", "lint", "prepare", "prepublishOnly", "preinstall", "postinstall",
|
|
36
|
+
"prepack", "pack", "publish", "start", "dev", "format",
|
|
37
|
+
]);
|
|
38
|
+
function validBudget(value) {
|
|
39
|
+
return Number.isSafeInteger(value) && value > 0;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function baseDiagnostic(code, reason, repair, file = ".") {
|
|
43
|
+
return makeDiagnostic({
|
|
44
|
+
code,
|
|
45
|
+
file,
|
|
46
|
+
range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
|
|
47
|
+
reason,
|
|
48
|
+
repair,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function budgetDiagnostic(kind, required, admitted) {
|
|
53
|
+
const option = kind === "files" ? "max-files" : kind === "references" ? "max-refs" : "max-bytes";
|
|
54
|
+
return baseDiagnostic(
|
|
55
|
+
"AUTH_CONSUMERS_BUDGET",
|
|
56
|
+
`complete authority consumer analysis requires ${required} ${kind} but ${option} is ${admitted}`,
|
|
57
|
+
"increase the explicit consumer-analysis budget; partial consumption and gate-wiring reports are forbidden",
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function refused(diagnostics) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
complete: false,
|
|
65
|
+
partial: false,
|
|
66
|
+
diagnostics: sortDiagnostics(diagnostics),
|
|
67
|
+
fileCount: 0,
|
|
68
|
+
referenceCount: 0,
|
|
69
|
+
scannedBytes: 0,
|
|
70
|
+
consumers: null,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function inputDiagnostic(code, reason, file = ".") {
|
|
75
|
+
return baseDiagnostic(
|
|
76
|
+
code,
|
|
77
|
+
reason,
|
|
78
|
+
"provide one exact readable Git repository, canonical corpus directory, and UTF-8 repository inputs; partial analysis is forbidden",
|
|
79
|
+
file,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function repositoryDiagnostic(error) {
|
|
84
|
+
return inputDiagnostic("AUTH_CONSUMERS_REPOSITORY_INVALID", error.message, error.file ?? ".");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function isWithin(root, candidate) {
|
|
88
|
+
const relative = path.relative(root, candidate);
|
|
89
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function resolveCorpus(repository, specPath) {
|
|
93
|
+
return resolveRepositoryCorpus(repository, specPath, "authority consumer");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function isSpecSource(file) {
|
|
97
|
+
return file === ".nimi/spec" || file.startsWith(".nimi/spec/");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function compareReference(left, right) {
|
|
101
|
+
return compareText(left.consumer, right.consumer)
|
|
102
|
+
|| left.line - right.line
|
|
103
|
+
|| compareText(left.target, right.target);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function lineCounter(text) {
|
|
107
|
+
let cursor = 0;
|
|
108
|
+
let line = 1;
|
|
109
|
+
return (index) => {
|
|
110
|
+
while (cursor < index) {
|
|
111
|
+
if (text[cursor] === "\n") line += 1;
|
|
112
|
+
cursor += 1;
|
|
113
|
+
}
|
|
114
|
+
return line;
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractReferences(consumer, text) {
|
|
119
|
+
const found = [];
|
|
120
|
+
for (const match of text.matchAll(SPEC_REFERENCE)) {
|
|
121
|
+
const target = match[0].replace(/[/\\]+/gu, "/");
|
|
122
|
+
if (target.includes("*") || target.includes("<") || target.includes(">")) continue;
|
|
123
|
+
found.push({ index: match.index, target, reason: null });
|
|
124
|
+
}
|
|
125
|
+
for (const match of text.matchAll(SPEC_CONSTRUCTOR)) {
|
|
126
|
+
const segments = [...match[3].matchAll(CONSTRUCTOR_SEGMENT)].map((segment) => segment[2]);
|
|
127
|
+
const target = [".nimi", "spec", ...segments].join("/");
|
|
128
|
+
// A further segment that is not a string literal makes the built path
|
|
129
|
+
// unknowable here. Reporting it as unresolved keeps the scan from
|
|
130
|
+
// 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 });
|
|
133
|
+
}
|
|
134
|
+
for (const match of text.matchAll(EXTERNAL_REFERENCE)) {
|
|
135
|
+
const externalPath = match[2];
|
|
136
|
+
const fragment = match[3] ?? null;
|
|
137
|
+
const coordinate = `${match[1]}:${externalPath}${fragment === null ? "" : `#${fragment}`}`;
|
|
138
|
+
const nonCanonical = externalPath.split("/").some((segment) => segment === "." || segment === "..");
|
|
139
|
+
found.push({
|
|
140
|
+
index: match.index + match[0].indexOf(`${match[1]}:`),
|
|
141
|
+
target: coordinate,
|
|
142
|
+
scheme: match[1],
|
|
143
|
+
path: externalPath,
|
|
144
|
+
fragment,
|
|
145
|
+
reason: nonCanonical ? "non-canonical-external-path" : null,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
found.sort((left, right) => left.index - right.index);
|
|
149
|
+
const atLine = lineCounter(text);
|
|
150
|
+
const references = [];
|
|
151
|
+
const unresolved = [];
|
|
152
|
+
for (const entry of found) {
|
|
153
|
+
const record = { consumer, line: atLine(entry.index), target: entry.target };
|
|
154
|
+
if (entry.scheme !== undefined) {
|
|
155
|
+
record.external = { scheme: entry.scheme, path: entry.path };
|
|
156
|
+
if (entry.fragment !== null) record.fragment = entry.fragment;
|
|
157
|
+
}
|
|
158
|
+
if (entry.reason === null) references.push(record);
|
|
159
|
+
else unresolved.push({ ...record, reason: entry.reason });
|
|
160
|
+
}
|
|
161
|
+
return { references, unresolved };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function targetExists(target, trackedFiles) {
|
|
165
|
+
if (trackedFiles.has(target)) return true;
|
|
166
|
+
const prefix = `${target}/`;
|
|
167
|
+
return [...trackedFiles].some((file) => file.startsWith(prefix));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Local references all start at the corpus root, so the only question is
|
|
171
|
+
// whether the target exists: being inside the corpus is not proof of existing.
|
|
172
|
+
// A scheme-qualified reference names an authority this repository does not
|
|
173
|
+
// own. Without a root for that scheme the target is unknowable here, which is
|
|
174
|
+
// not the same claim as dead.
|
|
175
|
+
function classifyLocalReference(reference, trackedFiles) {
|
|
176
|
+
return targetExists(reference.target, trackedFiles) ? "canonical" : "canonicalDead";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function externalTargetStatus(root, relative) {
|
|
180
|
+
const candidate = path.resolve(root, ...relative.split("/"));
|
|
181
|
+
if (!isWithin(root, candidate)) return "non-canonical";
|
|
182
|
+
|
|
183
|
+
let probe = candidate;
|
|
184
|
+
while (true) {
|
|
185
|
+
try {
|
|
186
|
+
const resolved = await realpath(probe);
|
|
187
|
+
if (!isWithin(root, resolved)) return "non-canonical";
|
|
188
|
+
return probe === candidate ? "authority" : "dead";
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTDIR") throw error;
|
|
191
|
+
const parent = path.dirname(probe);
|
|
192
|
+
if (parent === probe) return "non-canonical";
|
|
193
|
+
probe = parent;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function externalFragmentExists(root, relative, fragment) {
|
|
199
|
+
const bytes = await readFile(path.resolve(root, ...relative.split("/")));
|
|
200
|
+
let text;
|
|
201
|
+
try {
|
|
202
|
+
text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
let container;
|
|
207
|
+
try {
|
|
208
|
+
container = YAML.parse(text);
|
|
209
|
+
} catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
return Array.isArray(container?.units)
|
|
213
|
+
&& container.units.some((unit) => unit !== null && typeof unit === "object" && unit.id === fragment);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function escapeRegularExpression(text) {
|
|
217
|
+
return text.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function containsKeyToken(text, key) {
|
|
221
|
+
const expression = new RegExp(`(?:^|[^A-Za-z0-9:_-])${escapeRegularExpression(key)}(?=$|[^A-Za-z0-9:_-])`, "u");
|
|
222
|
+
return expression.test(text);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseScripts(text) {
|
|
226
|
+
let manifest;
|
|
227
|
+
try {
|
|
228
|
+
manifest = JSON.parse(text);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new Error(`repository root package.json is not valid JSON: ${error.message}`);
|
|
231
|
+
}
|
|
232
|
+
if (manifest === null || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
233
|
+
throw new Error("repository root package.json must contain one JSON object");
|
|
234
|
+
}
|
|
235
|
+
if (manifest.scripts === undefined) return {};
|
|
236
|
+
if (manifest.scripts === null || typeof manifest.scripts !== "object" || Array.isArray(manifest.scripts)) {
|
|
237
|
+
throw new Error("repository root package.json scripts must be one object");
|
|
238
|
+
}
|
|
239
|
+
const scripts = {};
|
|
240
|
+
for (const key of Object.keys(manifest.scripts).sort(compareText)) {
|
|
241
|
+
if (key.length === 0 || typeof manifest.scripts[key] !== "string") {
|
|
242
|
+
throw new Error("repository root package.json script keys must be non-empty and commands must be strings");
|
|
243
|
+
}
|
|
244
|
+
scripts[key] = manifest.scripts[key];
|
|
245
|
+
}
|
|
246
|
+
return scripts;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function gateWiring(scripts, workflows) {
|
|
250
|
+
const keys = Object.keys(scripts).sort(compareText);
|
|
251
|
+
const scriptMap = {};
|
|
252
|
+
let workflowReachable = 0;
|
|
253
|
+
let scriptChainedOnly = 0;
|
|
254
|
+
const orphans = [];
|
|
255
|
+
for (const key of keys) {
|
|
256
|
+
const workflowMatches = workflows
|
|
257
|
+
.filter((workflow) => containsKeyToken(workflow.text, key))
|
|
258
|
+
.map((workflow) => workflow.file);
|
|
259
|
+
const referencedByScripts = keys
|
|
260
|
+
.filter((other) => other !== key && containsKeyToken(scripts[other], key));
|
|
261
|
+
const orphan = workflowMatches.length === 0
|
|
262
|
+
&& referencedByScripts.length === 0
|
|
263
|
+
&& !LIFECYCLE_SCRIPTS.has(key);
|
|
264
|
+
if (workflowMatches.length > 0) workflowReachable += 1;
|
|
265
|
+
else if (referencedByScripts.length > 0) scriptChainedOnly += 1;
|
|
266
|
+
if (orphan) orphans.push(key);
|
|
267
|
+
scriptMap[key] = { workflows: workflowMatches, referencedByScripts, orphan };
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
counts: {
|
|
271
|
+
totalKeys: keys.length,
|
|
272
|
+
workflowReachable,
|
|
273
|
+
scriptChainedOnly,
|
|
274
|
+
orphan: orphans.length,
|
|
275
|
+
},
|
|
276
|
+
orphans,
|
|
277
|
+
scripts: scriptMap,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function readScannedFiles(repository, files, maxBytes) {
|
|
282
|
+
let statedBytes = 0;
|
|
283
|
+
for (const file of files) {
|
|
284
|
+
const absolute = path.join(repository, ...file.split("/"));
|
|
285
|
+
let metadata;
|
|
286
|
+
try {
|
|
287
|
+
metadata = await lstat(absolute);
|
|
288
|
+
} catch (error) {
|
|
289
|
+
throw new Error(`${file} is not readable: ${error.message}`);
|
|
290
|
+
}
|
|
291
|
+
if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`${file} must be one regular non-symlink file`);
|
|
292
|
+
statedBytes += metadata.size;
|
|
293
|
+
}
|
|
294
|
+
if (statedBytes > maxBytes) return { budget: budgetDiagnostic("UTF-8 bytes", statedBytes, maxBytes) };
|
|
295
|
+
|
|
296
|
+
const texts = new Map();
|
|
297
|
+
let scannedBytes = 0;
|
|
298
|
+
for (const file of files) {
|
|
299
|
+
let bytes;
|
|
300
|
+
try {
|
|
301
|
+
bytes = await readFile(path.join(repository, ...file.split("/")));
|
|
302
|
+
} catch (error) {
|
|
303
|
+
throw new Error(`${file} is not readable: ${error.message}`);
|
|
304
|
+
}
|
|
305
|
+
scannedBytes += bytes.length;
|
|
306
|
+
if (scannedBytes > maxBytes) return { budget: budgetDiagnostic("UTF-8 bytes", scannedBytes, maxBytes) };
|
|
307
|
+
try {
|
|
308
|
+
texts.set(file, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
309
|
+
} catch {
|
|
310
|
+
throw new Error(`${file} is not valid UTF-8`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return { texts, scannedBytes, budget: null };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async function resolveExternalRoots(externalRoots) {
|
|
317
|
+
const resolved = new Map();
|
|
318
|
+
for (const [scheme, root] of externalRoots) {
|
|
319
|
+
let real;
|
|
320
|
+
try {
|
|
321
|
+
real = await realpath(path.resolve(root));
|
|
322
|
+
} catch (error) {
|
|
323
|
+
throw new Error(`external authority root ${scheme} is not readable: ${error.message}`);
|
|
324
|
+
}
|
|
325
|
+
const metadata = await lstat(real);
|
|
326
|
+
if (!metadata.isDirectory()) throw new Error(`external authority root ${scheme} must be one directory`);
|
|
327
|
+
resolved.set(scheme, real);
|
|
328
|
+
}
|
|
329
|
+
return resolved;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export async function consumersAuthorityRepository(repositoryPath, specPath, { maxFiles, maxRefs, maxBytes, externalRoots = [] }) {
|
|
333
|
+
if (![maxFiles, maxRefs, maxBytes].every(validBudget)) {
|
|
334
|
+
return refused([baseDiagnostic(
|
|
335
|
+
"AUTH_CONSUMERS_BUDGET",
|
|
336
|
+
"authority consumer-analysis budgets must be positive safe integers",
|
|
337
|
+
"provide explicit positive safe max-files, max-refs, and max-bytes budgets",
|
|
338
|
+
)]);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
let inventory;
|
|
342
|
+
try {
|
|
343
|
+
inventory = await repositoryInventory(repositoryPath);
|
|
344
|
+
} catch (error) {
|
|
345
|
+
if (error instanceof AuthorityReviewRefusal) return refused([repositoryDiagnostic(error)]);
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
let corpus;
|
|
350
|
+
try {
|
|
351
|
+
corpus = await resolveCorpus(inventory.repository, specPath);
|
|
352
|
+
} catch (error) {
|
|
353
|
+
return refused([inputDiagnostic("AUTH_CONSUMERS_CORPUS_INVALID", error.message)]);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const candidates = [...new Set([...inventory.files, "package.json"])].sort(compareText);
|
|
357
|
+
const classifiedFiles = await classifyTrackedTextFiles(
|
|
358
|
+
inventory.repository,
|
|
359
|
+
candidates,
|
|
360
|
+
(file) => isSpecSource(file) ? "authority-corpus" : null,
|
|
361
|
+
);
|
|
362
|
+
if (classifiedFiles.error !== undefined) {
|
|
363
|
+
return refused([inputDiagnostic("AUTH_CONSUMERS_INPUT_INVALID", classifiedFiles.error)]);
|
|
364
|
+
}
|
|
365
|
+
const scanFiles = classifiedFiles.scanned;
|
|
366
|
+
if (!scanFiles.includes("package.json")) {
|
|
367
|
+
return refused([inputDiagnostic("AUTH_CONSUMERS_MANIFEST_INVALID", "repository root package.json must be classified as UTF-8 text", "package.json")]);
|
|
368
|
+
}
|
|
369
|
+
if (scanFiles.length > maxFiles) return refused([budgetDiagnostic("files", scanFiles.length, maxFiles)]);
|
|
370
|
+
|
|
371
|
+
let scanned;
|
|
372
|
+
try {
|
|
373
|
+
scanned = await readScannedFiles(inventory.repository, scanFiles, maxBytes);
|
|
374
|
+
} catch (error) {
|
|
375
|
+
return refused([inputDiagnostic("AUTH_CONSUMERS_INPUT_INVALID", error.message)]);
|
|
376
|
+
}
|
|
377
|
+
if (scanned.budget) return refused([scanned.budget]);
|
|
378
|
+
|
|
379
|
+
const references = [];
|
|
380
|
+
const unresolvedReferences = [];
|
|
381
|
+
for (const file of scanFiles) {
|
|
382
|
+
const extracted = extractReferences(file, scanned.texts.get(file));
|
|
383
|
+
references.push(...extracted.references);
|
|
384
|
+
unresolvedReferences.push(...extracted.unresolved);
|
|
385
|
+
}
|
|
386
|
+
references.sort(compareReference);
|
|
387
|
+
unresolvedReferences.sort(compareReference);
|
|
388
|
+
const referenceTotal = references.length + unresolvedReferences.length;
|
|
389
|
+
if (referenceTotal > maxRefs) return refused([budgetDiagnostic("references", referenceTotal, maxRefs)]);
|
|
390
|
+
|
|
391
|
+
let roots;
|
|
392
|
+
try {
|
|
393
|
+
roots = await resolveExternalRoots(externalRoots);
|
|
394
|
+
} catch (error) {
|
|
395
|
+
return refused([inputDiagnostic("AUTH_CONSUMERS_EXTERNAL_ROOT_INVALID", error.message)]);
|
|
396
|
+
}
|
|
397
|
+
const trackedFiles = new Set(inventory.files);
|
|
398
|
+
const classified = {
|
|
399
|
+
canonical: [], canonicalDead: [], externalAuthority: [], externalDead: [], externalUnresolved: [],
|
|
400
|
+
};
|
|
401
|
+
try {
|
|
402
|
+
for (const reference of references) {
|
|
403
|
+
if (reference.external === undefined) {
|
|
404
|
+
classified[classifyLocalReference(reference, trackedFiles)].push(reference);
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
const root = roots.get(reference.external.scheme);
|
|
408
|
+
if (root === undefined) {
|
|
409
|
+
classified.externalUnresolved.push(reference);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
const status = await externalTargetStatus(root, reference.external.path);
|
|
413
|
+
const fragmentExists = status === "authority" && reference.fragment !== undefined
|
|
414
|
+
? await externalFragmentExists(root, reference.external.path, reference.fragment)
|
|
415
|
+
: true;
|
|
416
|
+
if (status === "authority" && fragmentExists) classified.externalAuthority.push(reference);
|
|
417
|
+
else if (status === "dead" || (status === "authority" && !fragmentExists)) classified.externalDead.push(reference);
|
|
418
|
+
else unresolvedReferences.push({ ...reference, reason: "non-canonical-external-path" });
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return refused([inputDiagnostic("AUTH_CONSUMERS_EXTERNAL_ROOT_INVALID", `external authority target cannot be resolved: ${error.message}`)]);
|
|
422
|
+
}
|
|
423
|
+
unresolvedReferences.sort(compareReference);
|
|
424
|
+
|
|
425
|
+
let scripts;
|
|
426
|
+
try {
|
|
427
|
+
scripts = parseScripts(scanned.texts.get("package.json"));
|
|
428
|
+
} catch (error) {
|
|
429
|
+
return refused([inputDiagnostic("AUTH_CONSUMERS_MANIFEST_INVALID", error.message, "package.json")]);
|
|
430
|
+
}
|
|
431
|
+
const workflows = inventory.files
|
|
432
|
+
.filter((file) => /^\.github\/workflows\/[^/]+\.ya?ml$/u.test(file))
|
|
433
|
+
.map((file) => ({ file, text: scanned.texts.get(file) }))
|
|
434
|
+
.filter((workflow) => workflow.text !== undefined);
|
|
435
|
+
const wiring = gateWiring(scripts, workflows);
|
|
436
|
+
const classifiedReferenceTotal = Object.values(classified).reduce((total, entries) => total + entries.length, 0);
|
|
437
|
+
const specConsumption = {
|
|
438
|
+
counts: {
|
|
439
|
+
total: classifiedReferenceTotal,
|
|
440
|
+
canonical: classified.canonical.length,
|
|
441
|
+
canonicalDead: classified.canonicalDead.length,
|
|
442
|
+
externalAuthority: classified.externalAuthority.length,
|
|
443
|
+
externalDead: classified.externalDead.length,
|
|
444
|
+
externalUnresolved: classified.externalUnresolved.length,
|
|
445
|
+
unresolved: unresolvedReferences.length,
|
|
446
|
+
},
|
|
447
|
+
references: classified,
|
|
448
|
+
unresolved: unresolvedReferences,
|
|
449
|
+
};
|
|
450
|
+
const consumers = {
|
|
451
|
+
format: CONSUMERS_FORMAT,
|
|
452
|
+
complete: true,
|
|
453
|
+
fileInventory: trackedTextInventory(candidates.length, classifiedFiles),
|
|
454
|
+
specConsumption,
|
|
455
|
+
gateWiring: wiring,
|
|
456
|
+
};
|
|
457
|
+
return {
|
|
458
|
+
ok: true,
|
|
459
|
+
complete: true,
|
|
460
|
+
partial: false,
|
|
461
|
+
diagnostics: [],
|
|
462
|
+
fileCount: scanFiles.length,
|
|
463
|
+
referenceCount: referenceTotal,
|
|
464
|
+
scannedBytes: scanned.scannedBytes,
|
|
465
|
+
trackedPathSetDigest: inventory.trackedPathSetDigest,
|
|
466
|
+
consumers,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
export { CONSUMERS_FORMAT, LIFECYCLE_SCRIPTS };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
export function compareText(left, right) {
|
|
4
|
+
const a = String(left);
|
|
5
|
+
const b = String(right);
|
|
6
|
+
return a === b ? 0 : a < b ? -1 : 1;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function portablePath(filePath, cwd = process.cwd()) {
|
|
10
|
+
const relative = path.relative(cwd, filePath);
|
|
11
|
+
const value = relative && !relative.startsWith("..") && !path.isAbsolute(relative) ? relative : filePath;
|
|
12
|
+
return value.split(path.sep).join(path.posix.sep);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createLocator(text) {
|
|
16
|
+
const lineStarts = [0];
|
|
17
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
18
|
+
if (text[index] === "\n") lineStarts.push(index + 1);
|
|
19
|
+
}
|
|
20
|
+
function position(offset) {
|
|
21
|
+
const bounded = Math.max(0, Math.min(offset, text.length));
|
|
22
|
+
let low = 0;
|
|
23
|
+
let high = lineStarts.length;
|
|
24
|
+
while (low + 1 < high) {
|
|
25
|
+
const middle = Math.floor((low + high) / 2);
|
|
26
|
+
if (lineStarts[middle] <= bounded) low = middle;
|
|
27
|
+
else high = middle;
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
line: low + 1,
|
|
31
|
+
column: [...text.slice(lineStarts[low], bounded)].length + 1,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
range(start, end = start) {
|
|
36
|
+
return { start: position(start), end: position(end) };
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function makeDiagnostic({ code, file, range, pointer = "", reason, repair, related = [] }) {
|
|
42
|
+
return {
|
|
43
|
+
code,
|
|
44
|
+
severity: "error",
|
|
45
|
+
path: file,
|
|
46
|
+
range,
|
|
47
|
+
pointer,
|
|
48
|
+
reason,
|
|
49
|
+
repair,
|
|
50
|
+
related,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function comparePosition(left, right) {
|
|
55
|
+
return left.line - right.line || left.column - right.column;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function sortDiagnostics(diagnostics) {
|
|
59
|
+
return [...diagnostics].sort((left, right) => (
|
|
60
|
+
compareText(left.path, right.path)
|
|
61
|
+
|| comparePosition(left.range.start, right.range.start)
|
|
62
|
+
|| compareText(left.code, right.code)
|
|
63
|
+
|| compareText(left.pointer, right.pointer)
|
|
64
|
+
));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function sourcePointer(source, pointer) {
|
|
68
|
+
return `${source.sourcePrefix ?? ""}${pointer}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function relatedLocation(source, pointer, role) {
|
|
72
|
+
return {
|
|
73
|
+
path: source.file,
|
|
74
|
+
range: source.locations.get(pointer) ?? source.locations.get("") ?? source.locator.range(0),
|
|
75
|
+
pointer: sourcePointer(source, pointer),
|
|
76
|
+
role,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const REPAIRS = {
|
|
81
|
+
structural: "make the source structure explicit without inventing product semantics",
|
|
82
|
+
required: "declare the required value from product authority",
|
|
83
|
+
invalid: "replace the value with one admitted by the authoring contract",
|
|
84
|
+
relation: "repair the explicit relation using product authority; do not infer a target",
|
|
85
|
+
duplicate: "keep one declaration chosen from product authority and remove the duplicate",
|
|
86
|
+
format: "run `nimicoding authority fmt` on this file",
|
|
87
|
+
};
|