@nimiplatform/nimi-coding 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -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 -124
- 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,308 @@
|
|
|
1
|
+
import { compareText, makeDiagnostic, relatedLocation, REPAIRS, sortDiagnostics, sourcePointer } from "./diagnostics.mjs";
|
|
2
|
+
import { AUTHORITY_FORMAT_V1, AUTHORITY_FORMAT_V2 } from "./source-yaml.mjs";
|
|
3
|
+
|
|
4
|
+
function isObject(value) {
|
|
5
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function pointAtEnd(range) {
|
|
9
|
+
const end = range?.end ?? { line: 1, column: 1 };
|
|
10
|
+
return { start: end, end };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rangeFor(source, pointer, missing = false) {
|
|
14
|
+
if (missing && source.insertionPoints?.has(pointer)) return source.insertionPoints.get(pointer);
|
|
15
|
+
const range = source.locations.get(pointer) ?? source.locations.get("") ?? source.locator.range(0);
|
|
16
|
+
return missing ? pointAtEnd(range) : range;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function add(diagnostics, source, code, pointer, reason, repair, options = {}) {
|
|
20
|
+
diagnostics.push(makeDiagnostic({
|
|
21
|
+
code,
|
|
22
|
+
file: source.file,
|
|
23
|
+
range: rangeFor(source, pointer, options.missing),
|
|
24
|
+
pointer: sourcePointer(source, pointer),
|
|
25
|
+
reason,
|
|
26
|
+
repair,
|
|
27
|
+
related: options.related ?? [],
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function structuralDiagnostics(source, contract) {
|
|
32
|
+
const diagnostics = [];
|
|
33
|
+
const data = source.data;
|
|
34
|
+
if (!isObject(data)) {
|
|
35
|
+
add(diagnostics, source, "AUTH_FIELD_INVALID", "", "each authority unit must be a mapping", REPAIRS.structural);
|
|
36
|
+
return diagnostics;
|
|
37
|
+
}
|
|
38
|
+
const allowed = new Set(source.profile === "markdown"
|
|
39
|
+
? [...contract.fields.markdown_frontmatter_allowed, "title", "meaning", "statement", "condition", "failure", "reason"]
|
|
40
|
+
: contract.fields.yaml_unit_allowed);
|
|
41
|
+
const authoredData = source.authoredData ?? data;
|
|
42
|
+
for (const key of Object.keys(authoredData)) {
|
|
43
|
+
if (!allowed.has(key)) add(diagnostics, source, "AUTH_UNKNOWN_FIELD", `/${key}`, `unknown authority field: ${key}`, "remove the field or choose an admitted field from the authoring contract");
|
|
44
|
+
}
|
|
45
|
+
if (source.profile === "markdown") {
|
|
46
|
+
const allowedFront = new Set(contract.fields.markdown_frontmatter_allowed);
|
|
47
|
+
for (const key of source.frontKeys ?? []) {
|
|
48
|
+
if (!allowedFront.has(key)) add(diagnostics, source, "AUTH_UNKNOWN_FIELD", `/${key}`, `field ${key} is not admitted in Markdown front matter`, "move content only to its admitted Markdown slot without changing its product meaning");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
for (const [field, admitted] of [
|
|
52
|
+
// The authoring format plus whatever the contract records as a readable
|
|
53
|
+
// migration input, so fmt --upgrade and history diffs keep working without
|
|
54
|
+
// the loader inventing a compatibility list of its own.
|
|
55
|
+
...(source.profile === "markdown"
|
|
56
|
+
? [["format", [contract.format, ...(contract.migration_inputs?.format ? [contract.migration_inputs.format] : [])]]]
|
|
57
|
+
: []),
|
|
58
|
+
["kind", contract.kinds],
|
|
59
|
+
["lifecycle", contract.lifecycles],
|
|
60
|
+
]) {
|
|
61
|
+
if (!Object.hasOwn(data, field)) add(diagnostics, source, "AUTH_FIELD_REQUIRED", `/${field}`, `${field} is required before the formatter can select a source branch`, REPAIRS.required, { missing: true });
|
|
62
|
+
else if (!admitted.includes(data[field])) add(diagnostics, source, "AUTH_FIELD_INVALID", `/${field}`, `${field} must be one of: ${admitted.join(", ")}`, REPAIRS.invalid);
|
|
63
|
+
}
|
|
64
|
+
if (Object.hasOwn(data, "relations")) {
|
|
65
|
+
if (!Array.isArray(data.relations)) {
|
|
66
|
+
add(diagnostics, source, "AUTH_FIELD_INVALID", "/relations", "relations must be a list", REPAIRS.structural);
|
|
67
|
+
} else {
|
|
68
|
+
data.relations.forEach((relation, index) => {
|
|
69
|
+
const pointer = `/relations/${index}`;
|
|
70
|
+
if (!isObject(relation)) {
|
|
71
|
+
add(diagnostics, source, "AUTH_FIELD_INVALID", pointer, "each relation item must be a mapping with type and target", REPAIRS.structural);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
const keys = Object.keys(relation);
|
|
75
|
+
for (const field of contract.relation_item_fields) {
|
|
76
|
+
if (!Object.hasOwn(relation, field)) add(diagnostics, source, "AUTH_FIELD_REQUIRED", `${pointer}/${field}`, `relation item requires ${field} before deterministic sorting`, REPAIRS.structural, { missing: true });
|
|
77
|
+
else if (typeof relation[field] !== "string" || relation[field].length === 0) add(diagnostics, source, "AUTH_FIELD_INVALID", `${pointer}/${field}`, `relation ${field} must be a non-empty string`, REPAIRS.structural);
|
|
78
|
+
}
|
|
79
|
+
for (const key of keys) {
|
|
80
|
+
if (!contract.relation_item_fields.includes(key)) add(diagnostics, source, "AUTH_UNKNOWN_FIELD", `${pointer}/${key}`, `unknown relation item field: ${key}`, "remove the field; relation items contain only type and target");
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return diagnostics;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function documentStructuralDiagnostics(document, contract) {
|
|
89
|
+
if (document.profile !== "yaml") return document.units.length > 0 ? structuralDiagnostics(document.units[0], contract) : [];
|
|
90
|
+
const diagnostics = [];
|
|
91
|
+
const data = document.data;
|
|
92
|
+
if (!isObject(data)) return diagnostics;
|
|
93
|
+
const v2 = data.format === AUTHORITY_FORMAT_V2;
|
|
94
|
+
// Container field admission comes from the contract, so changing what a v2
|
|
95
|
+
// container may carry is a contract edit that the source-contract gate sees,
|
|
96
|
+
// not a constant edited here alone.
|
|
97
|
+
const allowed = new Set(v2 ? contract.fields.yaml_document : contract.fields.yaml_document_migration_input);
|
|
98
|
+
for (const key of Object.keys(data)) {
|
|
99
|
+
if (!allowed.has(key)) add(diagnostics, document, "AUTH_UNKNOWN_FIELD", `/${key}`, `unknown authority document field: ${key}`, v2
|
|
100
|
+
? "remove the field; v2 YAML authority documents contain only format, optional owner, optional scope, and units"
|
|
101
|
+
: "remove the field; v1 YAML authority documents contain only format and units");
|
|
102
|
+
}
|
|
103
|
+
if (!Object.hasOwn(data, "format")) add(diagnostics, document, "AUTH_FIELD_REQUIRED", "/format", "format is required before the formatter can select the YAML container profile", REPAIRS.required, { missing: true });
|
|
104
|
+
else if (![contract.format, contract.migration_inputs?.format].includes(data.format)) add(diagnostics, document, "AUTH_FIELD_INVALID", "/format", `format must be one of: ${contract.format}, ${contract.migration_inputs?.format}`, REPAIRS.invalid);
|
|
105
|
+
if (v2) {
|
|
106
|
+
const identifier = new RegExp(contract.identifier_pattern);
|
|
107
|
+
if (Object.hasOwn(data, "owner") && (typeof data.owner !== "string" || !identifier.test(data.owner))) {
|
|
108
|
+
add(diagnostics, document, "AUTH_FIELD_INVALID", "/owner", "container owner must be a dot-separated lower-case identifier", REPAIRS.invalid);
|
|
109
|
+
}
|
|
110
|
+
if (Object.hasOwn(data, "scope") && (typeof data.scope !== "string" || !identifier.test(data.scope))) {
|
|
111
|
+
add(diagnostics, document, "AUTH_FIELD_INVALID", "/scope", "container scope must be one dot-separated lower-case identifier string", REPAIRS.invalid);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (!Object.hasOwn(data, "units")) add(diagnostics, document, "AUTH_FIELD_REQUIRED", "/units", "units is required and must be a non-empty sequence", REPAIRS.structural, { missing: true });
|
|
115
|
+
else if (!Array.isArray(data.units) || data.units.length === 0) add(diagnostics, document, "AUTH_FIELD_INVALID", "/units", "units must be a non-empty sequence", REPAIRS.structural);
|
|
116
|
+
if (Array.isArray(data.units)) diagnostics.push(...document.units.flatMap((source) => structuralDiagnostics(source, contract)));
|
|
117
|
+
return diagnostics;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function validateFormatStructure(source, contract) {
|
|
121
|
+
const diagnostics = source.isDocument
|
|
122
|
+
? [...source.diagnostics, ...documentStructuralDiagnostics(source, contract)]
|
|
123
|
+
: [...source.diagnostics, ...structuralDiagnostics(source, contract)];
|
|
124
|
+
return sortDiagnostics(diagnostics);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function requireField(diagnostics, source, field, reason = null) {
|
|
128
|
+
if (!Object.hasOwn(source.data, field)) {
|
|
129
|
+
add(diagnostics, source, "AUTH_FIELD_REQUIRED", `/${field}`, reason ?? `${field} is required`, REPAIRS.required, { missing: true });
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function forbidField(diagnostics, source, field, branch) {
|
|
136
|
+
if (Object.hasOwn(source.data, field)) add(diagnostics, source, "AUTH_FIELD_FORBIDDEN", `/${field}`, `${field} is forbidden for ${branch}`, "remove the field without changing the admitted branch");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const AUTHORITY_LINE_BREAK = /[\u000A\u000B\u000C\u000D\u0085\u2028\u2029]/u;
|
|
140
|
+
|
|
141
|
+
export function validAuthorityText(value) {
|
|
142
|
+
return typeof value === "string"
|
|
143
|
+
&& value.length > 0
|
|
144
|
+
&& value.isWellFormed()
|
|
145
|
+
&& value.trim() === value
|
|
146
|
+
&& !AUTHORITY_LINE_BREAK.test(value);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validateUnitFields(source, contract, diagnostics) {
|
|
150
|
+
const data = source.data;
|
|
151
|
+
for (const field of contract.fields.unit_common) requireField(diagnostics, source, field);
|
|
152
|
+
if (source.profile === "markdown") requireField(diagnostics, source, "format");
|
|
153
|
+
for (const field of ["id", "owner"]) {
|
|
154
|
+
if (Object.hasOwn(data, field) && (typeof data[field] !== "string" || !(new RegExp(contract.identifier_pattern)).test(data[field]))) {
|
|
155
|
+
add(diagnostics, source, "AUTH_FIELD_INVALID", `/${field}`, `${field} must be a dot-separated lower-case identifier`, REPAIRS.invalid);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (Object.hasOwn(data, "title") && !validAuthorityText(data.title)) add(diagnostics, source, "AUTH_FIELD_INVALID", "/title", "title must be one non-empty trimmed text line", REPAIRS.invalid);
|
|
159
|
+
if (Object.hasOwn(data, "relations") && !Array.isArray(data.relations)) return;
|
|
160
|
+
const active = data.lifecycle === "active";
|
|
161
|
+
if (active && data.kind === "definition") {
|
|
162
|
+
requireField(diagnostics, source, "meaning");
|
|
163
|
+
if (Object.hasOwn(data, "meaning") && !validAuthorityText(data.meaning)) add(diagnostics, source, "AUTH_FIELD_INVALID", "/meaning", "meaning must be one non-empty trimmed text line", REPAIRS.invalid);
|
|
164
|
+
for (const field of ["modality", "scope", "statement", "condition", "failure", "reason"]) forbidField(diagnostics, source, field, "active definition");
|
|
165
|
+
} else if (active && data.kind === "rule") {
|
|
166
|
+
for (const field of contract.fields.active_rule_required) requireField(diagnostics, source, field);
|
|
167
|
+
if (Object.hasOwn(data, "modality") && !contract.modalities.includes(data.modality)) add(diagnostics, source, "AUTH_FIELD_INVALID", "/modality", `modality must be one of: ${contract.modalities.join(", ")}`, REPAIRS.invalid);
|
|
168
|
+
if (Object.hasOwn(data, "scope")) {
|
|
169
|
+
if (!Array.isArray(data.scope) || data.scope.length === 0) add(diagnostics, source, "AUTH_FIELD_INVALID", "/scope", "active rule scope must be a non-empty list", REPAIRS.invalid);
|
|
170
|
+
else {
|
|
171
|
+
const seen = new Set();
|
|
172
|
+
data.scope.forEach((entry, index) => {
|
|
173
|
+
if (typeof entry !== "string" || !(new RegExp(contract.identifier_pattern)).test(entry)) add(diagnostics, source, "AUTH_FIELD_INVALID", `/scope/${index}`, "scope values must be dot-separated lower-case identifiers", REPAIRS.invalid);
|
|
174
|
+
else if (seen.has(entry)) add(diagnostics, source, "AUTH_FIELD_INVALID", `/scope/${index}`, "scope values must be unique", "remove the duplicate scope value");
|
|
175
|
+
seen.add(entry);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const field of ["statement", "condition", "failure"]) {
|
|
180
|
+
if (Object.hasOwn(data, field) && !validAuthorityText(data[field])) add(diagnostics, source, "AUTH_FIELD_INVALID", `/${field}`, `${field} must be one non-empty trimmed text line`, REPAIRS.invalid);
|
|
181
|
+
}
|
|
182
|
+
for (const field of ["meaning", "reason"]) forbidField(diagnostics, source, field, "active rule");
|
|
183
|
+
} else if (data.lifecycle === "removed" && contract.kinds.includes(data.kind)) {
|
|
184
|
+
requireField(diagnostics, source, "reason");
|
|
185
|
+
if (Object.hasOwn(data, "reason") && !validAuthorityText(data.reason)) add(diagnostics, source, "AUTH_FIELD_INVALID", "/reason", "reason must be one non-empty trimmed text line", REPAIRS.invalid);
|
|
186
|
+
for (const field of ["meaning", "modality", "scope", "statement", "condition", "failure"]) forbidField(diagnostics, source, field, `removed ${data.kind}`);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function validateIdentity(sources, diagnostics) {
|
|
191
|
+
const groups = new Map();
|
|
192
|
+
for (const source of sources) {
|
|
193
|
+
if (typeof source.data.id !== "string") continue;
|
|
194
|
+
const group = groups.get(source.data.id) ?? [];
|
|
195
|
+
group.push(source);
|
|
196
|
+
groups.set(source.data.id, group);
|
|
197
|
+
}
|
|
198
|
+
for (const [id, group] of groups) {
|
|
199
|
+
if (group.length < 2) continue;
|
|
200
|
+
group.sort((left, right) => compareText(left.file, right.file) || compareText(left.sourcePrefix ?? "", right.sourcePrefix ?? ""));
|
|
201
|
+
const [primary, ...rest] = group;
|
|
202
|
+
const related = rest.map((source) => relatedLocation(source, "/id", "duplicate declaration"));
|
|
203
|
+
add(diagnostics, primary, "AUTH_ID_DUPLICATE", "/id", `identity ${id} is declared more than once`, REPAIRS.duplicate, { related });
|
|
204
|
+
if (new Set(group.map((source) => source.data.owner)).size > 1) {
|
|
205
|
+
add(diagnostics, primary, "AUTH_OWNER_CONFLICT", "/owner", `identity ${id} declares more than one owner`, "choose the single owner from product authority and remove conflicting declarations", {
|
|
206
|
+
related: rest.map((source) => relatedLocation(source, "/owner", "conflicting declaration")),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function relationLocation(edge, pointer = "") {
|
|
213
|
+
return relatedLocation(edge.source, `/relations/${edge.index}${pointer}`, "related relation");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function validateRelations(sources, contract, diagnostics) {
|
|
217
|
+
const byId = new Map(sources.filter((source) => typeof source.data.id === "string").map((source) => [source.data.id, source]));
|
|
218
|
+
const supersedes = [];
|
|
219
|
+
for (const source of sources) {
|
|
220
|
+
const relations = Array.isArray(source.data.relations) ? source.data.relations : [];
|
|
221
|
+
const seen = new Map();
|
|
222
|
+
let appliesTo = 0;
|
|
223
|
+
let outgoingSupersedes = 0;
|
|
224
|
+
relations.forEach((relation, index) => {
|
|
225
|
+
const pointer = `/relations/${index}`;
|
|
226
|
+
const key = `${relation.type}\0${relation.target}`;
|
|
227
|
+
if (seen.has(key)) add(diagnostics, source, "AUTH_RELATION_CARDINALITY", pointer, "duplicate relation edges are not admitted", "remove the duplicate edge", { related: [relatedLocation(source, `/relations/${seen.get(key)}`, "first duplicate edge")] });
|
|
228
|
+
else seen.set(key, index);
|
|
229
|
+
if (!Object.hasOwn(contract.relation_types, relation.type)) {
|
|
230
|
+
add(diagnostics, source, "AUTH_RELATION_UNKNOWN", `${pointer}/type`, `unknown relation type: ${relation.type}`, "choose an admitted relation type from product authority");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (!(new RegExp(contract.identifier_pattern)).test(relation.target)) {
|
|
234
|
+
add(diagnostics, source, "AUTH_FIELD_INVALID", `${pointer}/target`, "relation target must be a dot-separated lower-case identifier", REPAIRS.relation);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (relation.type === "applies_to") appliesTo += 1;
|
|
238
|
+
else outgoingSupersedes += 1;
|
|
239
|
+
const target = byId.get(relation.target);
|
|
240
|
+
if (!target) {
|
|
241
|
+
add(diagnostics, source, "AUTH_RELATION_DANGLING", `${pointer}/target`, `relation target does not resolve: ${relation.target}`, REPAIRS.relation);
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (relation.type === "applies_to") {
|
|
245
|
+
if (!(source.data.kind === "rule" && source.data.lifecycle === "active" && target.data.kind === "definition" && target.data.lifecycle === "active")) {
|
|
246
|
+
add(diagnostics, source, "AUTH_RELATION_TYPE", pointer, "applies_to requires active rule to active definition", REPAIRS.relation, { related: [relatedLocation(target, "/kind", "relation target")] });
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
supersedes.push({ source, target, index });
|
|
250
|
+
if (source.data.id === target.data.id) add(diagnostics, source, "AUTH_RELATION_CYCLE", pointer, "supersedes cannot target its own unit", REPAIRS.relation);
|
|
251
|
+
else if (!(target.data.lifecycle === "removed" && target.data.kind === source.data.kind)) {
|
|
252
|
+
add(diagnostics, source, "AUTH_RELATION_TYPE", pointer, "supersedes requires a removed same-kind target", REPAIRS.relation, { related: [relatedLocation(target, "/lifecycle", "relation target")] });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
if (source.data.kind === "rule" && source.data.lifecycle === "active" && appliesTo === 0) add(diagnostics, source, "AUTH_RELATION_CARDINALITY", "/relations", "active rule requires at least one applies_to relation", REPAIRS.relation);
|
|
257
|
+
if (outgoingSupersedes > 1) add(diagnostics, source, "AUTH_RELATION_CARDINALITY", "/relations", "a unit may declare at most one supersedes relation", REPAIRS.relation);
|
|
258
|
+
if (source.data.lifecycle === "removed" && relations.some((relation) => relation.type !== "supersedes")) add(diagnostics, source, "AUTH_RELATION_TYPE", "/relations", "removed units may only retain a supersedes relation", REPAIRS.relation);
|
|
259
|
+
if (source.data.kind === "definition" && source.data.lifecycle === "active" && relations.some((relation) => relation.type === "applies_to")) add(diagnostics, source, "AUTH_RELATION_TYPE", "/relations", "active definitions cannot declare applies_to", REPAIRS.relation);
|
|
260
|
+
}
|
|
261
|
+
const incoming = new Map();
|
|
262
|
+
for (const edge of supersedes) {
|
|
263
|
+
const list = incoming.get(edge.target.data.id) ?? [];
|
|
264
|
+
list.push(edge);
|
|
265
|
+
incoming.set(edge.target.data.id, list);
|
|
266
|
+
}
|
|
267
|
+
for (const [targetId, edges] of incoming) {
|
|
268
|
+
if (edges.length > 1) {
|
|
269
|
+
edges.sort((left, right) => compareText(left.source.file, right.source.file) || compareText(left.source.sourcePrefix ?? "", right.source.sourcePrefix ?? ""));
|
|
270
|
+
const [primary, ...rest] = edges;
|
|
271
|
+
add(diagnostics, primary.source, "AUTH_RELATION_CARDINALITY", `/relations/${primary.index}`, `removed identity ${targetId} has more than one incoming supersedes relation`, REPAIRS.relation, { related: rest.map((edge) => relationLocation(edge)) });
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const next = new Map(supersedes.map((edge) => [edge.source.data.id, edge]));
|
|
275
|
+
const reported = new Set();
|
|
276
|
+
for (const start of next.keys()) {
|
|
277
|
+
const path = [];
|
|
278
|
+
const positions = new Map();
|
|
279
|
+
let current = start;
|
|
280
|
+
while (next.has(current)) {
|
|
281
|
+
if (positions.has(current)) {
|
|
282
|
+
const cycle = path.slice(positions.get(current));
|
|
283
|
+
const cycleKey = cycle.map((edge) => edge.source.data.id).sort(compareText).join("|");
|
|
284
|
+
if (!reported.has(cycleKey)) {
|
|
285
|
+
reported.add(cycleKey);
|
|
286
|
+
cycle.sort((left, right) => compareText(left.source.file, right.source.file) || compareText(left.source.sourcePrefix ?? "", right.source.sourcePrefix ?? ""));
|
|
287
|
+
const [primary, ...rest] = cycle;
|
|
288
|
+
add(diagnostics, primary.source, "AUTH_RELATION_CYCLE", `/relations/${primary.index}`, "supersedes relations must form an acyclic lineage", REPAIRS.relation, { related: rest.map((edge) => relationLocation(edge)) });
|
|
289
|
+
}
|
|
290
|
+
break;
|
|
291
|
+
}
|
|
292
|
+
positions.set(current, path.length);
|
|
293
|
+
const edge = next.get(current);
|
|
294
|
+
path.push(edge);
|
|
295
|
+
current = edge.target.data.id;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export function validateAuthoritySources(sources, contract) {
|
|
301
|
+
const structural = sources.flatMap((source) => validateFormatStructure(source, contract));
|
|
302
|
+
if (structural.length > 0) return { ok: false, diagnostics: sortDiagnostics(structural) };
|
|
303
|
+
const diagnostics = [];
|
|
304
|
+
for (const source of sources) validateUnitFields(source, contract, diagnostics);
|
|
305
|
+
validateIdentity(sources, diagnostics);
|
|
306
|
+
validateRelations(sources, contract, diagnostics);
|
|
307
|
+
return { ok: diagnostics.length === 0, diagnostics: sortDiagnostics(diagnostics) };
|
|
308
|
+
}
|
package/cli/lib/bootstrap.mjs
CHANGED
|
@@ -2,18 +2,26 @@ import { mkdir, readFile, readdir, rm, rmdir, writeFile } from "node:fs/promises
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
BOOTSTRAP_CONTRACT_VERSION,
|
|
5
|
+
AUTHORITY_GITATTRIBUTES_ENTRIES,
|
|
7
6
|
LOCAL_GITIGNORE_ENTRIES,
|
|
8
|
-
PACKAGE_NAME,
|
|
9
7
|
REQUIRED_LOCAL_DIRS,
|
|
10
8
|
} from "../constants.mjs";
|
|
11
9
|
import { createBootstrapSeedFileMap } from "../seeds/bootstrap.mjs";
|
|
12
|
-
import {
|
|
13
|
-
|
|
10
|
+
import {
|
|
11
|
+
appendGitattributesEntries,
|
|
12
|
+
appendGitignoreEntries,
|
|
13
|
+
hasExactGitignoreRule,
|
|
14
|
+
hasExactTextLine,
|
|
15
|
+
normalizeTextToLf,
|
|
16
|
+
pathExists,
|
|
17
|
+
preflightManagedProjectPaths,
|
|
18
|
+
readUtf8FileFatal,
|
|
19
|
+
} from "./fs-helpers.mjs";
|
|
14
20
|
|
|
15
21
|
export async function previewBootstrapWrites(projectRoot) {
|
|
22
|
+
await preflightManagedProjectPaths(projectRoot);
|
|
16
23
|
const missingFiles = [];
|
|
24
|
+
const driftedFiles = [];
|
|
17
25
|
const missingDirs = [];
|
|
18
26
|
const seedMap = await createBootstrapSeedFileMap();
|
|
19
27
|
|
|
@@ -25,29 +33,46 @@ export async function previewBootstrapWrites(projectRoot) {
|
|
|
25
33
|
}
|
|
26
34
|
}
|
|
27
35
|
|
|
28
|
-
for (const relativePath of seedMap.
|
|
36
|
+
for (const [relativePath, content] of seedMap.entries()) {
|
|
29
37
|
const absolutePath = path.join(projectRoot, relativePath);
|
|
30
38
|
const info = await pathExists(absolutePath);
|
|
31
|
-
if (!info)
|
|
32
|
-
|
|
33
|
-
}
|
|
39
|
+
if (!info) missingFiles.push(relativePath);
|
|
40
|
+
else if (info.isFile() && await readFile(absolutePath, "utf8") !== normalizeTextToLf(content)) driftedFiles.push(relativePath);
|
|
34
41
|
}
|
|
35
42
|
|
|
36
|
-
const
|
|
43
|
+
const gitignoreInfo = await pathExists(path.join(projectRoot, ".gitignore"));
|
|
44
|
+
const gitignoreText = gitignoreInfo?.isFile()
|
|
45
|
+
? await readUtf8FileFatal(path.join(projectRoot, ".gitignore"), ".gitignore")
|
|
46
|
+
: null;
|
|
37
47
|
const missingGitignoreEntries = gitignoreText === null
|
|
38
48
|
? LOCAL_GITIGNORE_ENTRIES.slice()
|
|
39
|
-
: LOCAL_GITIGNORE_ENTRIES.filter((entry) => !gitignoreText
|
|
49
|
+
: LOCAL_GITIGNORE_ENTRIES.filter((entry) => !hasExactGitignoreRule(gitignoreText, entry));
|
|
50
|
+
const gitattributesInfo = await pathExists(path.join(projectRoot, ".gitattributes"));
|
|
51
|
+
const gitattributesText = gitattributesInfo?.isFile()
|
|
52
|
+
? await readUtf8FileFatal(path.join(projectRoot, ".gitattributes"), ".gitattributes")
|
|
53
|
+
: null;
|
|
54
|
+
const missingGitattributesEntries = gitattributesText === null
|
|
55
|
+
? AUTHORITY_GITATTRIBUTES_ENTRIES.slice()
|
|
56
|
+
: AUTHORITY_GITATTRIBUTES_ENTRIES.filter((entry) => !hasExactTextLine(gitattributesText, entry));
|
|
40
57
|
|
|
41
58
|
return {
|
|
42
59
|
missingFiles,
|
|
60
|
+
driftedFiles,
|
|
43
61
|
missingDirs,
|
|
44
62
|
missingGitignoreEntries,
|
|
45
|
-
|
|
63
|
+
missingGitattributesEntries,
|
|
64
|
+
hasWork: missingFiles.length > 0
|
|
65
|
+
|| driftedFiles.length > 0
|
|
66
|
+
|| missingDirs.length > 0
|
|
67
|
+
|| missingGitignoreEntries.length > 0
|
|
68
|
+
|| missingGitattributesEntries.length > 0,
|
|
46
69
|
};
|
|
47
70
|
}
|
|
48
71
|
|
|
49
72
|
export async function writeMissingBootstrapFiles(projectRoot) {
|
|
73
|
+
await preflightManagedProjectPaths(projectRoot);
|
|
50
74
|
const createdFiles = [];
|
|
75
|
+
const updatedFiles = [];
|
|
51
76
|
const createdDirs = [];
|
|
52
77
|
const seedMap = await createBootstrapSeedFileMap();
|
|
53
78
|
|
|
@@ -62,13 +87,18 @@ export async function writeMissingBootstrapFiles(projectRoot) {
|
|
|
62
87
|
|
|
63
88
|
for (const [relativePath, content] of seedMap.entries()) {
|
|
64
89
|
const absolutePath = path.join(projectRoot, relativePath);
|
|
90
|
+
const projectionContent = normalizeTextToLf(content);
|
|
65
91
|
const info = await pathExists(absolutePath);
|
|
66
|
-
if (info) {
|
|
92
|
+
if (info?.isFile()) {
|
|
93
|
+
if (await readFile(absolutePath, "utf8") !== projectionContent) {
|
|
94
|
+
await writeFile(absolutePath, projectionContent, "utf8");
|
|
95
|
+
updatedFiles.push(relativePath);
|
|
96
|
+
}
|
|
67
97
|
continue;
|
|
68
98
|
}
|
|
69
99
|
|
|
70
100
|
await mkdir(path.dirname(absolutePath), { recursive: true });
|
|
71
|
-
await writeFile(absolutePath,
|
|
101
|
+
await writeFile(absolutePath, projectionContent, "utf8");
|
|
72
102
|
createdFiles.push(relativePath);
|
|
73
103
|
}
|
|
74
104
|
|
|
@@ -76,25 +106,28 @@ export async function writeMissingBootstrapFiles(projectRoot) {
|
|
|
76
106
|
path.join(projectRoot, ".gitignore"),
|
|
77
107
|
LOCAL_GITIGNORE_ENTRIES,
|
|
78
108
|
);
|
|
109
|
+
const gitattributesUpdated = await appendGitattributesEntries(
|
|
110
|
+
path.join(projectRoot, ".gitattributes"),
|
|
111
|
+
AUTHORITY_GITATTRIBUTES_ENTRIES,
|
|
112
|
+
);
|
|
79
113
|
|
|
80
114
|
return {
|
|
81
115
|
createdFiles,
|
|
116
|
+
updatedFiles,
|
|
82
117
|
createdDirs,
|
|
83
118
|
gitignoreUpdated,
|
|
119
|
+
gitattributesUpdated,
|
|
84
120
|
};
|
|
85
121
|
}
|
|
86
122
|
|
|
87
|
-
const UNINSTALLABLE_BOOTSTRAP_PREFIXES = [
|
|
88
|
-
".nimi/config/",
|
|
89
|
-
".nimi/contracts/",
|
|
90
|
-
".nimi/methodology/",
|
|
91
|
-
];
|
|
123
|
+
const UNINSTALLABLE_BOOTSTRAP_PREFIXES = [".nimi/methodology/"];
|
|
92
124
|
|
|
93
125
|
function isUninstallableBootstrapPath(relativePath) {
|
|
94
126
|
return UNINSTALLABLE_BOOTSTRAP_PREFIXES.some((prefix) => relativePath.startsWith(prefix));
|
|
95
127
|
}
|
|
96
128
|
|
|
97
129
|
async function collectBootstrapRemovalState(projectRoot) {
|
|
130
|
+
await preflightManagedProjectPaths(projectRoot);
|
|
98
131
|
const removableFiles = [];
|
|
99
132
|
const preservedModifiedFiles = [];
|
|
100
133
|
const seedMap = await createBootstrapSeedFileMap();
|
|
@@ -111,7 +144,7 @@ async function collectBootstrapRemovalState(projectRoot) {
|
|
|
111
144
|
}
|
|
112
145
|
|
|
113
146
|
const actual = await readFile(absolutePath, "utf8");
|
|
114
|
-
if (actual === content) {
|
|
147
|
+
if (actual === normalizeTextToLf(content)) {
|
|
115
148
|
removableFiles.push(relativePath);
|
|
116
149
|
} else {
|
|
117
150
|
preservedModifiedFiles.push(relativePath);
|
|
@@ -126,11 +159,7 @@ async function collectBootstrapRemovalState(projectRoot) {
|
|
|
126
159
|
}
|
|
127
160
|
|
|
128
161
|
async function removeEmptyBootstrapDirs(projectRoot) {
|
|
129
|
-
const removableDirs = [
|
|
130
|
-
".nimi/config",
|
|
131
|
-
".nimi/contracts",
|
|
132
|
-
".nimi/methodology",
|
|
133
|
-
];
|
|
162
|
+
const removableDirs = [".nimi/methodology"];
|
|
134
163
|
const removedDirs = [];
|
|
135
164
|
|
|
136
165
|
for (const relativeDir of removableDirs) {
|
|
@@ -157,6 +186,7 @@ export async function previewBootstrapRemoval(projectRoot) {
|
|
|
157
186
|
}
|
|
158
187
|
|
|
159
188
|
export async function removeManagedBootstrapFiles(projectRoot) {
|
|
189
|
+
await preflightManagedProjectPaths(projectRoot);
|
|
160
190
|
const state = await collectBootstrapRemovalState(projectRoot);
|
|
161
191
|
|
|
162
192
|
for (const relativePath of state.removableFiles) {
|
|
@@ -171,49 +201,3 @@ export async function removeManagedBootstrapFiles(projectRoot) {
|
|
|
171
201
|
preservedModifiedFiles: state.preservedModifiedFiles,
|
|
172
202
|
};
|
|
173
203
|
}
|
|
174
|
-
|
|
175
|
-
export async function inspectBootstrapCompatibility(projectRoot) {
|
|
176
|
-
const bootstrapConfigPath = path.join(projectRoot, ".nimi", "config", "bootstrap.yaml");
|
|
177
|
-
const bootstrapConfigText = await readTextIfFile(bootstrapConfigPath);
|
|
178
|
-
|
|
179
|
-
if (!bootstrapConfigText) {
|
|
180
|
-
return {
|
|
181
|
-
status: "missing",
|
|
182
|
-
initializedBy: null,
|
|
183
|
-
contractId: null,
|
|
184
|
-
contractVersion: null,
|
|
185
|
-
};
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const initializedBy = readYamlScalar(bootstrapConfigText, "initialized_by");
|
|
189
|
-
const contractId = readYamlScalar(bootstrapConfigText, "bootstrap_contract");
|
|
190
|
-
const contractVersion = readYamlScalar(bootstrapConfigText, "bootstrap_contract_version");
|
|
191
|
-
|
|
192
|
-
if (initializedBy !== PACKAGE_NAME) {
|
|
193
|
-
return {
|
|
194
|
-
status: "unsupported",
|
|
195
|
-
initializedBy,
|
|
196
|
-
contractId,
|
|
197
|
-
contractVersion,
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
if (
|
|
202
|
-
contractId !== BOOTSTRAP_CONTRACT_ID
|
|
203
|
-
|| contractVersion !== String(BOOTSTRAP_CONTRACT_VERSION)
|
|
204
|
-
) {
|
|
205
|
-
return {
|
|
206
|
-
status: "unsupported",
|
|
207
|
-
initializedBy,
|
|
208
|
-
contractId,
|
|
209
|
-
contractVersion,
|
|
210
|
-
};
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return {
|
|
214
|
-
status: "supported",
|
|
215
|
-
initializedBy,
|
|
216
|
-
contractId,
|
|
217
|
-
contractVersion,
|
|
218
|
-
};
|
|
219
|
-
}
|