@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.
Files changed (118) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/README.md +285 -41
  4. package/README.zh-CN.md +294 -31
  5. package/bin/nimicoding.mjs +1 -1
  6. package/cli/commands/authority.mjs +849 -0
  7. package/cli/commands/clear.mjs +27 -7
  8. package/cli/commands/doctor.mjs +10 -1
  9. package/cli/commands/start.mjs +23 -27
  10. package/cli/commands/sync.mjs +21 -101
  11. package/cli/constants.mjs +7 -31
  12. package/cli/dev/check-authority-surface.mjs +173 -0
  13. package/cli/dev/check-product-conformance.mjs +917 -0
  14. package/cli/help.mjs +10 -17
  15. package/cli/index.mjs +6 -47
  16. package/cli/lib/authority/anchors.mjs +491 -0
  17. package/cli/lib/authority/audit.mjs +490 -0
  18. package/cli/lib/authority/closed-sets.mjs +646 -0
  19. package/cli/lib/authority/compile.mjs +110 -0
  20. package/cli/lib/authority/consumers.mjs +470 -0
  21. package/cli/lib/authority/diagnostics.mjs +87 -0
  22. package/cli/lib/authority/diff.mjs +287 -0
  23. package/cli/lib/authority/discover.mjs +447 -0
  24. package/cli/lib/authority/evidence-snapshot.mjs +1 -0
  25. package/cli/lib/authority/evidence.mjs +1432 -0
  26. package/cli/lib/authority/format.mjs +197 -0
  27. package/cli/lib/authority/git-snapshot.mjs +1065 -0
  28. package/cli/lib/authority/graph.mjs +351 -0
  29. package/cli/lib/authority/impact.mjs +252 -0
  30. package/cli/lib/authority/lexicon.mjs +55 -0
  31. package/cli/lib/authority/query.mjs +162 -0
  32. package/cli/lib/authority/repository-corpus.mjs +37 -0
  33. package/cli/lib/authority/repository-inventory.mjs +34 -0
  34. package/cli/lib/authority/review.mjs +222 -0
  35. package/cli/lib/authority/sarif.mjs +181 -0
  36. package/cli/lib/authority/scope-bindings.mjs +225 -0
  37. package/cli/lib/authority/source-map.mjs +105 -0
  38. package/cli/lib/authority/source-markdown.mjs +167 -0
  39. package/cli/lib/authority/source-yaml.mjs +312 -0
  40. package/cli/lib/authority/surface.mjs +79 -0
  41. package/cli/lib/authority/terms.mjs +421 -0
  42. package/cli/lib/authority/tracked-text.mjs +70 -0
  43. package/cli/lib/authority/validate.mjs +308 -0
  44. package/cli/lib/bootstrap.mjs +55 -71
  45. package/cli/lib/doctor.mjs +40 -123
  46. package/cli/lib/entrypoints.mjs +103 -105
  47. package/cli/lib/fs-helpers.mjs +139 -15
  48. package/cli/lib/internal/governance/config.mjs +0 -36
  49. package/cli/lib/shared.mjs +8 -21
  50. package/cli/lib/sync.mjs +81 -92
  51. package/cli/lib/value-helpers.mjs +0 -26
  52. package/cli/lib/yaml-helpers.mjs +1 -124
  53. package/cli/seeds/bootstrap.mjs +31 -87
  54. package/cli/seeds/seed-policy.yaml +9 -63
  55. package/contracts/authority-evidence-bindings.schema.yaml +53 -0
  56. package/contracts/authority-evidence-probe-results.schema.yaml +38 -0
  57. package/contracts/authority-impact-dispositions.schema.yaml +13 -0
  58. package/contracts/authority-scope-bindings.schema.yaml +15 -0
  59. package/contracts/authority-source.schema.yaml +64 -0
  60. package/contracts/authority-verifier-bindings.schema.yaml +20 -0
  61. package/methodology/authority-authoring.yaml +144 -0
  62. package/methodology/core.yaml +31 -26
  63. package/package.json +10 -6
  64. package/spec/authority-anchors.yaml +99 -0
  65. package/spec/authority-audit.yaml +85 -0
  66. package/spec/authority-authoring.yaml +150 -0
  67. package/spec/authority-closed-sets.yaml +65 -0
  68. package/spec/authority-consumers.yaml +70 -0
  69. package/spec/authority-context.yaml +50 -0
  70. package/spec/authority-discovery.yaml +119 -0
  71. package/spec/authority-evidence.yaml +200 -0
  72. package/spec/authority-graph.yaml +59 -0
  73. package/spec/authority-impact.yaml +84 -0
  74. package/spec/authority-review.yaml +89 -0
  75. package/spec/authority-terms.yaml +59 -0
  76. package/spec/product-scope.yaml +36 -23
  77. package/cli/commands/blueprint-audit.mjs +0 -91
  78. package/cli/commands/classify-spec-tree.mjs +0 -5
  79. package/cli/commands/generate-spec-derived-docs.mjs +0 -151
  80. package/cli/commands/generate-spec-migration-plan.mjs +0 -30
  81. package/cli/commands/surface-validator-command.mjs +0 -49
  82. package/cli/commands/validate-domain-admission.mjs +0 -5
  83. package/cli/commands/validate-guidance-bodies.mjs +0 -5
  84. package/cli/commands/validate-placement.mjs +0 -5
  85. package/cli/commands/validate-projection-edges.mjs +0 -5
  86. package/cli/commands/validate-spec-audit.mjs +0 -27
  87. package/cli/commands/validate-spec-governance.mjs +0 -144
  88. package/cli/commands/validate-spec-tree.mjs +0 -27
  89. package/cli/commands/validate-table-family.mjs +0 -5
  90. package/cli/commands/validate-tracked-output-admission.mjs +0 -5
  91. package/cli/lib/blueprint-audit.mjs +0 -363
  92. package/cli/lib/contracts.mjs +0 -21
  93. package/cli/lib/internal/contracts-loaders.mjs +0 -47
  94. package/cli/lib/internal/contracts-parse.mjs +0 -192
  95. package/cli/lib/internal/governance/runner.mjs +0 -35
  96. package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
  97. package/cli/lib/internal/validators-shared.mjs +0 -12
  98. package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
  99. package/cli/lib/internal/validators-spec.mjs +0 -380
  100. package/cli/lib/validators.mjs +0 -30
  101. package/config/bootstrap.yaml +0 -6
  102. package/config/spec-generation-inputs.yaml +0 -41
  103. package/contracts/domain-admission.schema.yaml +0 -56
  104. package/contracts/migration-inventory.schema.yaml +0 -15
  105. package/contracts/negative-fixtures.yaml +0 -26
  106. package/contracts/placement-contract.schema.yaml +0 -127
  107. package/contracts/projection-edge.schema.yaml +0 -46
  108. package/contracts/shared-enums.yaml +0 -63
  109. package/contracts/spec-generation-audit.schema.yaml +0 -46
  110. package/contracts/spec-generation-inputs.schema.yaml +0 -43
  111. package/contracts/spec-layout.schema.yaml +0 -32
  112. package/contracts/surface-taxonomy.schema.yaml +0 -139
  113. package/contracts/table-family.schema.yaml +0 -133
  114. package/contracts/tracked-output-admission.schema.yaml +0 -17
  115. package/methodology/four-closure-policy.yaml +0 -20
  116. package/methodology/role-separation-policy.yaml +0 -22
  117. package/methodology/spec-reconstruction.yaml +0 -69
  118. package/spec/_meta/spec-tree-model.yaml +0 -44
@@ -0,0 +1,225 @@
1
+ import { lstat, readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import YAML, { isMap, isScalar, isSeq } from "yaml";
6
+
7
+ import { compareText, createLocator, makeDiagnostic, portablePath, sortDiagnostics, sourcePointer } from "./diagnostics.mjs";
8
+
9
+ const SCOPE_BINDINGS_FORMAT = "nimicoding.scope-bindings/v1";
10
+ const contractPath = fileURLToPath(new URL("../../../contracts/authority-scope-bindings.schema.yaml", import.meta.url));
11
+ const LINE_BREAK = /[\u000A\u000B\u000C\u000D\u0085\u2028\u2029]/u;
12
+
13
+ let contractPromise = null;
14
+
15
+ function exactObject(value, fields) {
16
+ return value !== null && typeof value === "object" && !Array.isArray(value)
17
+ && Object.keys(value).length === fields.length
18
+ && Object.keys(value).every((key) => fields.includes(key));
19
+ }
20
+
21
+ function pointerToken(value) {
22
+ return String(value).replaceAll("~", "~0").replaceAll("/", "~1");
23
+ }
24
+
25
+ function nodeRange(node, locator) {
26
+ const range = node?.range;
27
+ if (!Array.isArray(range)) return locator.range(0);
28
+ return locator.range(range[0], range[1] ?? range[0]);
29
+ }
30
+
31
+ function collectLocations(node, locator, pointer, locations) {
32
+ if (!node) return;
33
+ locations.set(pointer, nodeRange(node, locator));
34
+ if (isMap(node)) {
35
+ for (const pair of node.items) {
36
+ const key = isScalar(pair.key) ? String(pair.key.value) : "";
37
+ const childPointer = `${pointer}/${pointerToken(key)}`;
38
+ locations.set(childPointer, nodeRange(pair.value ?? pair.key, locator));
39
+ collectLocations(pair.value, locator, childPointer, locations);
40
+ }
41
+ } else if (isSeq(node)) {
42
+ node.items.forEach((item, index) => collectLocations(item, locator, `${pointer}/${index}`, locations));
43
+ }
44
+ }
45
+
46
+ async function loadContract() {
47
+ if (!contractPromise) contractPromise = readFile(contractPath, "utf8").then((text) => {
48
+ const contract = YAML.parse(text);
49
+ if (contract?.version !== 1 || contract?.contract?.id !== "nimicoding.scope-bindings.v1") {
50
+ throw new Error("installed scope binding contract is invalid");
51
+ }
52
+ return contract;
53
+ });
54
+ return contractPromise;
55
+ }
56
+
57
+ function scopeBindingDiagnostic(file, reason, location = null, code = "AUTH_SCOPE_BINDING_INVALID", repair = "declare the exact closed scope registry without inferring repository anchors") {
58
+ return makeDiagnostic({
59
+ code,
60
+ file,
61
+ range: location?.range ?? { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
62
+ pointer: location?.sourcePointer ?? "",
63
+ reason,
64
+ repair,
65
+ });
66
+ }
67
+
68
+ function parsedLocation(parsed, pointer) {
69
+ return {
70
+ file: parsed.file,
71
+ range: parsed.locations.get(pointer) ?? parsed.locations.get("") ?? { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
72
+ sourcePointer: pointer,
73
+ };
74
+ }
75
+
76
+ function syntaxLocation(error, file, locator) {
77
+ const positions = Array.isArray(error?.pos) ? error.pos : [0, 0];
78
+ return {
79
+ file,
80
+ range: locator.range(positions[0] ?? 0, positions[1] ?? positions[0] ?? 0),
81
+ sourcePointer: "",
82
+ };
83
+ }
84
+
85
+ function validText(value) {
86
+ return typeof value === "string"
87
+ && value.length > 0
88
+ && value.isWellFormed()
89
+ && !LINE_BREAK.test(value);
90
+ }
91
+
92
+ async function loadScopeBindings(file) {
93
+ const absolute = path.resolve(file);
94
+ const label = portablePath(absolute);
95
+ let bytes;
96
+ try {
97
+ const info = await lstat(absolute);
98
+ if (info.isSymbolicLink() || !info.isFile()) {
99
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "scope bindings must be one regular non-symlink YAML file")] };
100
+ }
101
+ bytes = await readFile(absolute);
102
+ } catch (error) {
103
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, `scope binding file is not readable: ${error.message}`)] };
104
+ }
105
+ let text;
106
+ try {
107
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
108
+ } catch {
109
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "scope binding bytes are not valid UTF-8")] };
110
+ }
111
+ const locator = createLocator(text);
112
+ const documents = YAML.parseAllDocuments(text, {
113
+ keepSourceTokens: true,
114
+ prettyErrors: false,
115
+ strict: true,
116
+ uniqueKeys: true,
117
+ version: "1.2",
118
+ });
119
+ if (documents.length !== 1 || documents[0].errors.length > 0 || !isMap(documents[0].contents)) {
120
+ const error = documents[0]?.errors?.[0];
121
+ const location = syntaxLocation(error, label, locator);
122
+ const reason = documents.length !== 1
123
+ ? "scope bindings must contain exactly one YAML document"
124
+ : error ? `invalid scope binding YAML: ${error.message.split(" at line")[0]}` : "scope bindings require one top-level mapping";
125
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, reason, location)] };
126
+ }
127
+ let data;
128
+ try {
129
+ data = documents[0].toJS({ maxAliasCount: 0 });
130
+ } catch (error) {
131
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, `invalid scope binding YAML: ${error.message}`)] };
132
+ }
133
+ const locations = new Map();
134
+ collectLocations(documents[0].contents, locator, "", locations);
135
+ const parsed = { file: label, locations, data };
136
+ const contract = await loadContract();
137
+ if (!exactObject(data, contract.fields.top)
138
+ || data.format !== SCOPE_BINDINGS_FORMAT
139
+ || !Array.isArray(data.scopes)
140
+ || data.scopes.length === 0) {
141
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "scope bindings require exact format and one non-empty scopes sequence", parsedLocation(parsed, ""))] };
142
+ }
143
+ const scopes = [];
144
+ const seenScopes = new Set();
145
+ for (let index = 0; index < data.scopes.length; index += 1) {
146
+ const value = data.scopes[index];
147
+ const pointer = `/scopes/${index}`;
148
+ if (!exactObject(value, contract.fields.scope)) {
149
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "each scope entry requires exact scope and bindings fields", parsedLocation(parsed, pointer))] };
150
+ }
151
+ if (!validText(value.scope)) {
152
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "scope must be one non-empty text line", parsedLocation(parsed, `${pointer}/scope`))] };
153
+ }
154
+ if (seenScopes.has(value.scope)) {
155
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, `duplicate registered scope: ${value.scope}`, parsedLocation(parsed, `${pointer}/scope`))] };
156
+ }
157
+ seenScopes.add(value.scope);
158
+ if (!Array.isArray(value.bindings) || value.bindings.length === 0) {
159
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "scope bindings must be a non-empty sequence", parsedLocation(parsed, `${pointer}/bindings`))] };
160
+ }
161
+ const bindings = [];
162
+ for (let bindingIndex = 0; bindingIndex < value.bindings.length; bindingIndex += 1) {
163
+ const binding = value.bindings[bindingIndex];
164
+ const bindingPointer = `${pointer}/bindings/${bindingIndex}`;
165
+ if (!exactObject(binding, contract.fields.binding)) {
166
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "each scope binding requires exact kind and value fields", parsedLocation(parsed, bindingPointer))] };
167
+ }
168
+ if (!contract.binding_kinds.includes(binding.kind)) {
169
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, `scope binding kind must be one of: ${contract.binding_kinds.join(", ")}`, parsedLocation(parsed, `${bindingPointer}/kind`))] };
170
+ }
171
+ if (!validText(binding.value)) {
172
+ return { ok: false, diagnostics: [scopeBindingDiagnostic(label, "scope binding value must be one non-empty text line", parsedLocation(parsed, `${bindingPointer}/value`))] };
173
+ }
174
+ bindings.push({
175
+ kind: binding.kind,
176
+ value: binding.value,
177
+ location: parsedLocation(parsed, `${bindingPointer}/value`),
178
+ });
179
+ }
180
+ scopes.push({ scope: value.scope, location: parsedLocation(parsed, `${pointer}/scope`), bindings });
181
+ }
182
+ scopes.sort((left, right) => compareText(left.scope, right.scope));
183
+ return { ok: true, diagnostics: [], scopes };
184
+ }
185
+
186
+ function sourceRange(source, pointer) {
187
+ return source.locations.get(pointer) ?? source.locations.get("") ?? source.locator.range(0);
188
+ }
189
+
190
+ export async function validateAuthorityScopeBindings(sources, bindingsPath) {
191
+ const parsed = await loadScopeBindings(bindingsPath);
192
+ if (!parsed.ok) return parsed;
193
+ const registered = new Set(parsed.scopes.map((entry) => entry.scope));
194
+ const used = new Set();
195
+ const diagnostics = [];
196
+ for (const source of sources) {
197
+ if (source.data.kind !== "rule" || source.data.lifecycle !== "active") continue;
198
+ source.data.scope.forEach((scope, index) => {
199
+ used.add(scope);
200
+ if (registered.has(scope)) return;
201
+ const pointer = `/scope/${index}`;
202
+ diagnostics.push(makeDiagnostic({
203
+ code: "AUTH_SCOPE_UNREGISTERED",
204
+ file: source.file,
205
+ range: sourceRange(source, pointer),
206
+ pointer: sourcePointer(source, pointer),
207
+ reason: `active rule ${source.data.id} uses unregistered scope: ${scope}`,
208
+ repair: "register the exact scope and its declared bindings; repository anchor resolution is not performed by check",
209
+ }));
210
+ });
211
+ }
212
+ for (const entry of parsed.scopes) {
213
+ if (used.has(entry.scope)) continue;
214
+ diagnostics.push(scopeBindingDiagnostic(
215
+ entry.location.file,
216
+ `registered scope has no active rule use: ${entry.scope}`,
217
+ entry.location,
218
+ "AUTH_SCOPE_BINDING_UNUSED",
219
+ "remove the dead registration or declare an active rule use only from product authority",
220
+ ));
221
+ }
222
+ return { ok: diagnostics.length === 0, diagnostics: sortDiagnostics(diagnostics) };
223
+ }
224
+
225
+ export { loadScopeBindings, SCOPE_BINDINGS_FORMAT };
@@ -0,0 +1,105 @@
1
+ import { compareText, makeDiagnostic, REPAIRS, sortDiagnostics, sourcePointer as absoluteSourcePointer } from "./diagnostics.mjs";
2
+
3
+ function addMappedLeaf(source, fields, diagnostics, outputPointer, sourcePointer) {
4
+ const range = source.locations.get(sourcePointer);
5
+ if (!range) {
6
+ diagnostics.push(makeDiagnostic({
7
+ code: "AUTH_SOURCE_MAP_MISSING",
8
+ file: source.file,
9
+ range: source.locations.get("") ?? source.locator.range(0),
10
+ pointer: absoluteSourcePointer(source, sourcePointer),
11
+ reason: `AuthorityIR leaf ${outputPointer} has no source range at ${sourcePointer}`,
12
+ repair: REPAIRS.structural,
13
+ }));
14
+ } else {
15
+ fields[outputPointer] = { file: source.file, range, sourcePointer: absoluteSourcePointer(source, sourcePointer) };
16
+ }
17
+ }
18
+
19
+ function collectIrLeaves(value, pointer = "", leaves = new Set()) {
20
+ if (Array.isArray(value)) {
21
+ if (value.length === 0) leaves.add(pointer);
22
+ else value.forEach((entry, index) => collectIrLeaves(entry, `${pointer}/${index}`, leaves));
23
+ return leaves;
24
+ }
25
+ if (value !== null && typeof value === "object") {
26
+ const entries = Object.entries(value);
27
+ if (entries.length === 0) leaves.add(pointer);
28
+ else entries.forEach(([key, entry]) => collectIrLeaves(entry, `${pointer}/${key}`, leaves));
29
+ return leaves;
30
+ }
31
+ leaves.add(pointer);
32
+ return leaves;
33
+ }
34
+
35
+ function mapUnit(source, outputIndex, fields, diagnostics) {
36
+ const base = `/units/${outputIndex}`;
37
+ for (const field of ["id", "kind", "owner", "lifecycle"]) {
38
+ addMappedLeaf(source, fields, diagnostics, `${base}/${field}`, `/${field}`);
39
+ }
40
+ addMappedLeaf(source, fields, diagnostics, `${base}/metadata/title`, "/title");
41
+ if (source.data.reason !== undefined) addMappedLeaf(source, fields, diagnostics, `${base}/metadata/reason`, "/reason");
42
+ if (source.data.lifecycle === "active" && source.data.kind === "definition") {
43
+ addMappedLeaf(source, fields, diagnostics, `${base}/semantic/meaning`, "/meaning");
44
+ }
45
+ if (source.data.lifecycle === "active" && source.data.kind === "rule") {
46
+ for (const field of ["modality", "statement", "condition", "failure"]) {
47
+ addMappedLeaf(source, fields, diagnostics, `${base}/semantic/${field}`, `/${field}`);
48
+ }
49
+ source.data.scope
50
+ .map((value, sourceIndex) => ({ value, sourceIndex }))
51
+ .sort((left, right) => compareText(left.value, right.value))
52
+ .forEach((entry, scopeIndex) => addMappedLeaf(source, fields, diagnostics, `${base}/semantic/scope/${scopeIndex}`, `/scope/${entry.sourceIndex}`));
53
+ }
54
+ if (source.data.relations.length === 0) {
55
+ addMappedLeaf(source, fields, diagnostics, `${base}/relations`, "/relations");
56
+ } else {
57
+ source.data.relations
58
+ .map((relation, sourceIndex) => ({ relation, sourceIndex }))
59
+ .sort((left, right) => compareText(left.relation.type, right.relation.type) || compareText(left.relation.target, right.relation.target))
60
+ .forEach((entry, relationIndex) => {
61
+ const outputPointer = `${base}/relations/${relationIndex}`;
62
+ const sourcePointer = `/relations/${entry.sourceIndex}`;
63
+ addMappedLeaf(source, fields, diagnostics, `${outputPointer}/type`, `${sourcePointer}/type`);
64
+ addMappedLeaf(source, fields, diagnostics, `${outputPointer}/target`, `${sourcePointer}/target`);
65
+ });
66
+ }
67
+ }
68
+
69
+ export function buildSourceMap(sources, ir) {
70
+ const diagnostics = [];
71
+ const fields = {};
72
+ const orderedSources = [...sources].sort((left, right) => compareText(left.data.id, right.data.id));
73
+ orderedSources.forEach((source, outputIndex) => mapUnit(source, outputIndex, fields, diagnostics));
74
+ const expectedLeaves = collectIrLeaves(ir);
75
+ for (const pointer of Object.keys(fields)) {
76
+ if (!expectedLeaves.has(pointer)) {
77
+ const mapped = fields[pointer];
78
+ diagnostics.push(makeDiagnostic({
79
+ code: "AUTH_SOURCE_MAP_MISSING",
80
+ file: mapped.file,
81
+ range: mapped.range,
82
+ pointer: mapped.sourcePointer,
83
+ reason: `SourceMap entry does not address an AuthorityIR leaf: ${pointer}`,
84
+ repair: REPAIRS.structural,
85
+ }));
86
+ }
87
+ }
88
+ for (const pointer of expectedLeaves) {
89
+ if (!Object.hasOwn(fields, pointer)) {
90
+ const match = pointer.match(/^\/units\/(\d+)/);
91
+ const source = orderedSources[Number(match?.[1] ?? 0)] ?? orderedSources[0];
92
+ diagnostics.push(makeDiagnostic({
93
+ code: "AUTH_SOURCE_MAP_MISSING",
94
+ file: source.file,
95
+ range: source.locations.get("") ?? source.locator.range(0),
96
+ pointer: absoluteSourcePointer(source, ""),
97
+ reason: `AuthorityIR leaf is not mapped: ${pointer}`,
98
+ repair: REPAIRS.structural,
99
+ }));
100
+ }
101
+ }
102
+ return { ok: diagnostics.length === 0, diagnostics: sortDiagnostics(diagnostics), sourceMap: { fields } };
103
+ }
104
+
105
+ export { collectIrLeaves };
@@ -0,0 +1,167 @@
1
+ import { makeDiagnostic, portablePath, REPAIRS, createLocator } from "./diagnostics.mjs";
2
+ import { FRONTMATTER_KEY_ORDER, parseRestrictedYaml, stringifyCanonicalYaml } from "./source-yaml.mjs";
3
+
4
+ const SECTION_FIELDS = new Map([
5
+ ["Meaning", "meaning"],
6
+ ["Statement", "statement"],
7
+ ["Condition", "condition"],
8
+ ["Failure", "failure"],
9
+ ["Removal reason", "reason"],
10
+ ]);
11
+ const BODY_ORDER = [
12
+ ["meaning", "Meaning"],
13
+ ["statement", "Statement"],
14
+ ["condition", "Condition"],
15
+ ["failure", "Failure"],
16
+ ["reason", "Removal reason"],
17
+ ];
18
+
19
+ function lineTable(text) {
20
+ const lines = [];
21
+ let start = 0;
22
+ for (let index = 0; index <= text.length; index += 1) {
23
+ if (index === text.length || text[index] === "\n") {
24
+ const raw = text.slice(start, index);
25
+ lines.push({ text: raw.endsWith("\r") ? raw.slice(0, -1) : raw, start, end: index });
26
+ start = index + 1;
27
+ }
28
+ }
29
+ return lines;
30
+ }
31
+
32
+ function ambiguous(file, locator, line, reason) {
33
+ return makeDiagnostic({
34
+ code: "AUTH_MARKDOWN_AMBIGUOUS",
35
+ file,
36
+ range: locator.range(line.start, line.end),
37
+ reason,
38
+ repair: REPAIRS.structural,
39
+ });
40
+ }
41
+
42
+ function isPlainMarkdownSlot(value) {
43
+ if (typeof value !== "string" || value.length === 0 || value.trim() !== value || /[\r\n\t]/.test(value)) return false;
44
+ if (!/^[\p{L}\p{N}\p{M}\p{Zs}\p{P}\p{S}]+$/u.test(value)) return false;
45
+ if (/[`*_\[\]<>\\~]/u.test(value)) return false;
46
+ if (/^(?:#{1,6}(?:\s|$)|>|[-+*](?:\s|$)|\d+[.)](?:\s|$)|(?:-{3,}|={3,})(?:\s|$))/u.test(value)) return false;
47
+ return true;
48
+ }
49
+
50
+ export function parseMarkdownAuthority(text, absolutePath, options = {}) {
51
+ const file = portablePath(absolutePath, options.cwd);
52
+ const locator = createLocator(text);
53
+ const lines = lineTable(text);
54
+ const diagnostics = [];
55
+ if (lines[0]?.text !== "---") {
56
+ diagnostics.push(ambiguous(file, locator, lines[0] ?? { start: 0, end: 0 }, "Markdown authority must start with YAML front matter"));
57
+ return { ok: false, file, locator, diagnostics, locations: new Map(), data: null, profile: "markdown" };
58
+ }
59
+ const closingIndex = lines.findIndex((line, index) => index > 0 && line.text === "---");
60
+ if (closingIndex < 0) {
61
+ diagnostics.push(ambiguous(file, locator, lines[0], "Markdown front matter is missing its closing delimiter"));
62
+ return { ok: false, file, locator, diagnostics, locations: new Map(), data: null, profile: "markdown" };
63
+ }
64
+ const frontStart = lines[0].end + 1;
65
+ const frontEnd = lines[closingIndex].start;
66
+ const front = text.slice(frontStart, frontEnd);
67
+ const parsedFront = parseRestrictedYaml(front, absolutePath, { ...options, offset: frontStart, fullText: text, keyOrder: FRONTMATTER_KEY_ORDER });
68
+ if (!parsedFront.ok) return { ...parsedFront, profile: "markdown" };
69
+ const data = { ...parsedFront.data };
70
+ const frontKeys = Object.keys(parsedFront.data);
71
+ const locations = new Map(parsedFront.locations);
72
+ const keyLocations = new Map(parsedFront.keyLocations);
73
+ const insertionPoints = new Map(parsedFront.insertionPoints);
74
+ const closingInsertion = locator.range(lines[closingIndex].start, lines[closingIndex].start);
75
+ if (!Object.hasOwn(data, "relations")) insertionPoints.set("/relations", closingInsertion);
76
+ locations.set("", locator.range(0, text.length));
77
+ let index = closingIndex + 1;
78
+ while (index < lines.length && lines[index].text === "") index += 1;
79
+ if (lines[index]?.text.startsWith("# ")) {
80
+ const title = lines[index].text.slice(2);
81
+ data.title = title;
82
+ locations.set("/title", locator.range(lines[index].start + 2, lines[index].end));
83
+ if (!isPlainMarkdownSlot(title)) diagnostics.push(ambiguous(file, locator, lines[index], "Markdown title must be one plain non-empty text line"));
84
+ index += 1;
85
+ } else if (/^#(?!#)/.test(lines[index]?.text ?? "")) {
86
+ diagnostics.push(ambiguous(file, locator, lines[index], "Markdown authority allows exactly one level-one title"));
87
+ index += 1;
88
+ }
89
+ const seenSections = new Set();
90
+ const sectionHeadings = new Map();
91
+ while (index < lines.length) {
92
+ while (index < lines.length && lines[index].text === "") index += 1;
93
+ if (index >= lines.length || (index === lines.length - 1 && lines[index].text === "")) break;
94
+ const heading = lines[index];
95
+ if (!heading.text.startsWith("## ")) {
96
+ diagnostics.push(ambiguous(file, locator, heading, "prose and constructs outside known Markdown authority slots are not admitted"));
97
+ index += 1;
98
+ continue;
99
+ }
100
+ const headingName = heading.text.slice(3);
101
+ const field = SECTION_FIELDS.get(headingName);
102
+ if (!field) {
103
+ diagnostics.push(ambiguous(file, locator, heading, `unknown Markdown authority section: ${headingName}`));
104
+ index += 1;
105
+ continue;
106
+ }
107
+ if (seenSections.has(field)) {
108
+ diagnostics.push(ambiguous(file, locator, heading, `duplicate Markdown authority section: ${headingName}`));
109
+ index += 1;
110
+ continue;
111
+ }
112
+ seenSections.add(field);
113
+ sectionHeadings.set(field, locator.range(heading.start, heading.end));
114
+ index += 1;
115
+ while (index < lines.length && lines[index].text === "") index += 1;
116
+ const body = lines[index];
117
+ if (!body || body.text.startsWith("#")) {
118
+ data[field] = "";
119
+ locations.set(`/${field}`, locator.range(heading.end, heading.end));
120
+ continue;
121
+ }
122
+ if (!isPlainMarkdownSlot(body.text)) {
123
+ diagnostics.push(ambiguous(file, locator, body, `${headingName} must contain exactly one plain text line`));
124
+ }
125
+ data[field] = body.text;
126
+ locations.set(`/${field}`, locator.range(body.start, body.end));
127
+ index += 1;
128
+ if (index < lines.length && lines[index].text !== "" && !lines[index].text.startsWith("## ")) {
129
+ diagnostics.push(ambiguous(file, locator, lines[index], `${headingName} must contain exactly one text line`));
130
+ index += 1;
131
+ }
132
+ }
133
+ const firstBodyPosition = locator.range(lines[closingIndex].end + 1, lines[closingIndex].end + 1);
134
+ if (!Object.hasOwn(data, "title")) insertionPoints.set("/title", firstBodyPosition);
135
+ for (let bodyIndex = 0; bodyIndex < BODY_ORDER.length; bodyIndex += 1) {
136
+ const [field] = BODY_ORDER[bodyIndex];
137
+ if (Object.hasOwn(data, field)) continue;
138
+ const nextField = BODY_ORDER.slice(bodyIndex + 1).map(([candidate]) => candidate).find((candidate) => sectionHeadings.has(candidate));
139
+ insertionPoints.set(`/${field}`, nextField ? { start: sectionHeadings.get(nextField).start, end: sectionHeadings.get(nextField).start } : locator.range(text.length, text.length));
140
+ }
141
+ return {
142
+ ok: diagnostics.length === 0,
143
+ file,
144
+ locator,
145
+ diagnostics,
146
+ locations,
147
+ keyLocations,
148
+ insertionPoints,
149
+ data,
150
+ profile: "markdown",
151
+ sourceText: text,
152
+ frontKeys,
153
+ };
154
+ }
155
+
156
+ export function stringifyCanonicalMarkdown(data) {
157
+ const front = {};
158
+ for (const key of ["format", "id", "kind", "owner", "lifecycle", "modality", "scope", "relations"]) {
159
+ if (Object.hasOwn(data, key)) front[key] = data[key];
160
+ }
161
+ let output = `---\n${stringifyCanonicalYaml(front, { frontmatter: true }).trimEnd()}\n---`;
162
+ if (Object.hasOwn(data, "title")) output += `\n# ${data.title}`;
163
+ for (const [field, heading] of BODY_ORDER) {
164
+ if (Object.hasOwn(data, field)) output += `\n\n## ${heading}\n\n${String(data[field])}`;
165
+ }
166
+ return `${output}\n`;
167
+ }