@nimiplatform/nimi-coding 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/CONTRIBUTING.md +3 -3
  3. package/README.md +297 -41
  4. package/README.zh-CN.md +306 -31
  5. package/bin/nimicoding.mjs +1 -1
  6. package/cli/commands/authority.mjs +804 -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 +182 -0
  13. package/cli/dev/check-product-conformance.mjs +889 -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 +567 -0
  18. package/cli/lib/authority/change-candidates.mjs +807 -0
  19. package/cli/lib/authority/closed-sets.mjs +645 -0
  20. package/cli/lib/authority/compile.mjs +92 -0
  21. package/cli/lib/authority/compiled-snapshot.mjs +143 -0
  22. package/cli/lib/authority/consumers.mjs +520 -0
  23. package/cli/lib/authority/diagnostics.mjs +93 -0
  24. package/cli/lib/authority/diff.mjs +310 -0
  25. package/cli/lib/authority/discover.mjs +447 -0
  26. package/cli/lib/authority/format.mjs +169 -0
  27. package/cli/lib/authority/git-snapshot.mjs +720 -0
  28. package/cli/lib/authority/graph.mjs +351 -0
  29. package/cli/lib/authority/impact.mjs +125 -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 +212 -0
  35. package/cli/lib/authority/sarif.mjs +174 -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 +308 -0
  40. package/cli/lib/authority/surface.mjs +97 -0
  41. package/cli/lib/authority/terms.mjs +422 -0
  42. package/cli/lib/authority/tracked-text.mjs +70 -0
  43. package/cli/lib/authority/validate.mjs +300 -0
  44. package/cli/lib/bootstrap.mjs +55 -71
  45. package/cli/lib/doctor.mjs +40 -123
  46. package/cli/lib/entrypoints.mjs +104 -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-scope-bindings.schema.yaml +15 -0
  56. package/contracts/authority-source.schema.yaml +58 -0
  57. package/contracts/authority-verifier-bindings.schema.yaml +31 -0
  58. package/methodology/authority-authoring.yaml +127 -0
  59. package/methodology/core.yaml +34 -27
  60. package/package.json +10 -6
  61. package/spec/authority-anchors.yaml +99 -0
  62. package/spec/authority-audit.yaml +89 -0
  63. package/spec/authority-authoring.yaml +137 -0
  64. package/spec/authority-change-candidates.yaml +146 -0
  65. package/spec/authority-closed-sets.yaml +65 -0
  66. package/spec/authority-consumers.yaml +77 -0
  67. package/spec/authority-context.yaml +50 -0
  68. package/spec/authority-discovery.yaml +119 -0
  69. package/spec/authority-graph.yaml +59 -0
  70. package/spec/authority-impact.yaml +61 -0
  71. package/spec/authority-review.yaml +71 -0
  72. package/spec/authority-terms.yaml +63 -0
  73. package/spec/product-scope.yaml +37 -23
  74. package/cli/commands/blueprint-audit.mjs +0 -91
  75. package/cli/commands/classify-spec-tree.mjs +0 -5
  76. package/cli/commands/generate-spec-derived-docs.mjs +0 -151
  77. package/cli/commands/generate-spec-migration-plan.mjs +0 -30
  78. package/cli/commands/surface-validator-command.mjs +0 -49
  79. package/cli/commands/validate-domain-admission.mjs +0 -5
  80. package/cli/commands/validate-guidance-bodies.mjs +0 -5
  81. package/cli/commands/validate-placement.mjs +0 -5
  82. package/cli/commands/validate-projection-edges.mjs +0 -5
  83. package/cli/commands/validate-spec-audit.mjs +0 -27
  84. package/cli/commands/validate-spec-governance.mjs +0 -144
  85. package/cli/commands/validate-spec-tree.mjs +0 -27
  86. package/cli/commands/validate-table-family.mjs +0 -5
  87. package/cli/commands/validate-tracked-output-admission.mjs +0 -5
  88. package/cli/lib/blueprint-audit.mjs +0 -363
  89. package/cli/lib/contracts.mjs +0 -21
  90. package/cli/lib/internal/contracts-loaders.mjs +0 -47
  91. package/cli/lib/internal/contracts-parse.mjs +0 -192
  92. package/cli/lib/internal/governance/runner.mjs +0 -35
  93. package/cli/lib/internal/surface-taxonomy-validators.mjs +0 -1147
  94. package/cli/lib/internal/validators-shared.mjs +0 -12
  95. package/cli/lib/internal/validators-spec-helpers.mjs +0 -52
  96. package/cli/lib/internal/validators-spec.mjs +0 -380
  97. package/cli/lib/validators.mjs +0 -30
  98. package/config/bootstrap.yaml +0 -6
  99. package/config/spec-generation-inputs.yaml +0 -41
  100. package/contracts/domain-admission.schema.yaml +0 -56
  101. package/contracts/migration-inventory.schema.yaml +0 -15
  102. package/contracts/negative-fixtures.yaml +0 -26
  103. package/contracts/placement-contract.schema.yaml +0 -127
  104. package/contracts/projection-edge.schema.yaml +0 -46
  105. package/contracts/shared-enums.yaml +0 -63
  106. package/contracts/spec-generation-audit.schema.yaml +0 -46
  107. package/contracts/spec-generation-inputs.schema.yaml +0 -43
  108. package/contracts/spec-layout.schema.yaml +0 -32
  109. package/contracts/surface-taxonomy.schema.yaml +0 -139
  110. package/contracts/table-family.schema.yaml +0 -133
  111. package/contracts/tracked-output-admission.schema.yaml +0 -17
  112. package/methodology/four-closure-policy.yaml +0 -20
  113. package/methodology/role-separation-policy.yaml +0 -22
  114. package/methodology/spec-reconstruction.yaml +0 -69
  115. package/spec/_meta/spec-tree-model.yaml +0 -44
@@ -0,0 +1,37 @@
1
+ import { lstat, realpath } from "node:fs/promises";
2
+ import path from "node:path";
3
+
4
+ // The repository-bound report products -- anchors, consumers, terms and
5
+ // closed-sets -- read a repository and the authority that governs it, then
6
+ // report one against the other. Their guard used to be "somewhere inside this
7
+ // Git repository", which let a fixture directory stand in as the canonical
8
+ // corpus and still produce a report marked complete. The corpus for these
9
+ // products is one exact location.
10
+ //
11
+ // This does not apply to fmt, check, diff, compile or impact: those accept an
12
+ // arbitrary path on purpose, because they read history snapshots and migration
13
+ // inputs that are not the working corpus.
14
+ export const REPOSITORY_CORPUS_SEGMENTS = [".nimi", "spec"];
15
+ export const REPOSITORY_CORPUS_RELATIVE = REPOSITORY_CORPUS_SEGMENTS.join("/");
16
+
17
+ export async function resolveRepositoryCorpus(repositoryPath, specPath, label) {
18
+ const repository = await realpath(path.resolve(repositoryPath));
19
+ const absolute = path.resolve(specPath);
20
+ let metadata;
21
+ try {
22
+ metadata = await lstat(absolute);
23
+ } catch (error) {
24
+ throw new Error(`${label} corpus is not readable: ${error.message}`);
25
+ }
26
+ if (metadata.isSymbolicLink() || !metadata.isDirectory()) {
27
+ throw new Error(`${label} corpus must be one regular non-symlink directory`);
28
+ }
29
+ const resolved = await realpath(absolute);
30
+ const expected = path.join(repository, ...REPOSITORY_CORPUS_SEGMENTS);
31
+ if (resolved !== expected) {
32
+ throw new Error(
33
+ `${label} corpus must be the repository authority corpus at ${REPOSITORY_CORPUS_RELATIVE}`,
34
+ );
35
+ }
36
+ return { path: resolved, directory: true, relative: REPOSITORY_CORPUS_RELATIVE };
37
+ }
@@ -0,0 +1,34 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ import { compareText } from "./diagnostics.mjs";
4
+ import { listGitTrackedFiles } from "./git-snapshot.mjs";
5
+
6
+ const REPOSITORY_INVENTORY_FORMAT = "nimicoding.repository-inventory/v1";
7
+
8
+ function trackedPathSetDigest(files) {
9
+ const ordered = [...new Set(files)].sort(compareText);
10
+ // The SHA-256 preimage is exactly the UTF-8 encoding of this compact JSON:
11
+ // {"format":"nimicoding.repository-inventory/v1","files":[<ordered paths>]}
12
+ // where paths are the unique Git-decoded tracked paths sorted by compareText.
13
+ // The repository root is intentionally absent: it locates the inventory but
14
+ // is not part of the state two reports compare.
15
+ //
16
+ // This digest covers the tracked path SET and nothing else - not blob
17
+ // contents, not the commit, not worktree cleanliness. Two commits whose
18
+ // files were all rewritten but none renamed produce the same digest. The
19
+ // name says so on purpose: a field called inventoryDigest would be read as
20
+ // "these reports describe the same repository state", which this value does
21
+ // not establish. Content identity is a separate, additive field.
22
+ const preimage = JSON.stringify({ format: REPOSITORY_INVENTORY_FORMAT, files: ordered });
23
+ return createHash("sha256").update(preimage, "utf8").digest("hex");
24
+ }
25
+
26
+ export async function repositoryInventory(repositoryPath) {
27
+ const inventory = await listGitTrackedFiles(repositoryPath);
28
+ return {
29
+ ...inventory,
30
+ trackedPathSetDigest: trackedPathSetDigest(inventory.files),
31
+ };
32
+ }
33
+
34
+ export { REPOSITORY_INVENTORY_FORMAT, trackedPathSetDigest };
@@ -0,0 +1,212 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { mkdir, realpath, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { auditAuthoritySnapshot } from "./audit.mjs";
6
+ import { compileAuthorityPath } from "./compile.mjs";
7
+ import { createCompiledAuthoritySnapshot } from "./compiled-snapshot.mjs";
8
+ import { isPathWithin, makeDiagnostic, portablePath, sortDiagnostics } from "./diagnostics.mjs";
9
+ import { diffAuthoritySnapshots } from "./diff.mjs";
10
+ import { AuthorityInputError } from "./format.mjs";
11
+ import {
12
+ AuthorityReviewRefusal,
13
+ authorityReviewRefusalDiagnostic,
14
+ captureStableRegularFile,
15
+ withGitAuthoritySnapshots,
16
+ } from "./git-snapshot.mjs";
17
+ import { impactAuthorityDiff } from "./impact.mjs";
18
+
19
+ const REVIEW_FORMAT = "nimicoding.authority-review/v2";
20
+
21
+ function validBudget(value) {
22
+ return Number.isSafeInteger(value) && value > 0;
23
+ }
24
+
25
+ function refused(diagnostics, reviewBytes = 0) {
26
+ return {
27
+ ok: false,
28
+ diagnostics: sortDiagnostics(diagnostics),
29
+ fileCount: 0,
30
+ unitCount: 0,
31
+ reviewBytes,
32
+ review: null,
33
+ partial: false,
34
+ };
35
+ }
36
+
37
+ function reviewBudgetDiagnostic(requiredBytes, maxBytes) {
38
+ return makeDiagnostic({
39
+ code: "AUTH_REVIEW_BUDGET",
40
+ file: ".",
41
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
42
+ pointer: "",
43
+ reason: `complete authority review analysis requires ${requiredBytes} UTF-8 bytes but max-bytes is ${maxBytes}`,
44
+ repair: "increase the explicit review byte budget; partial change analysis is forbidden",
45
+ });
46
+ }
47
+
48
+ function normalizePath(file, root, side) {
49
+ const relative = portablePath(file, root);
50
+ const safe = path.isAbsolute(relative) ? path.basename(relative) : relative;
51
+ return path.posix.join(side, safe || ".");
52
+ }
53
+
54
+ function normalizeCompilerDiagnostics(diagnostics, root, side) {
55
+ return diagnostics.map((diagnostic) => ({
56
+ ...diagnostic,
57
+ path: normalizePath(diagnostic.path, root, side),
58
+ related: diagnostic.related.map((related) => ({
59
+ ...related,
60
+ path: normalizePath(related.path, root, side),
61
+ })),
62
+ }));
63
+ }
64
+
65
+ function corpusInputDiagnostic(error, root, side) {
66
+ const portableRoot = root.split(path.sep).join(path.posix.sep);
67
+ const reason = error.message
68
+ .split(root).join(side)
69
+ .split(portableRoot).join(side)
70
+ .split(path.sep).join(path.posix.sep);
71
+ return makeDiagnostic({
72
+ code: "AUTH_REVIEW_CORPUS_INVALID",
73
+ file: side,
74
+ range: { start: { line: 1, column: 1 }, end: { line: 1, column: 1 } },
75
+ pointer: "",
76
+ reason,
77
+ repair: "provide one complete canonical authority corpus; unsupported or partial inputs are refused",
78
+ });
79
+ }
80
+
81
+ async function compileSnapshot(root, side) {
82
+ try {
83
+ const result = await compileAuthorityPath(root);
84
+ if (result.ok) return { result, diagnostics: [] };
85
+ return { result: null, diagnostics: normalizeCompilerDiagnostics(result.diagnostics, root, side) };
86
+ } catch (error) {
87
+ if (error instanceof AuthorityInputError) return { result: null, diagnostics: [corpusInputDiagnostic(error, root, side)] };
88
+ throw error;
89
+ }
90
+ }
91
+
92
+ async function inputLabel(file, repository, fallback) {
93
+ const requested = path.resolve(process.cwd(), file);
94
+ const absolute = await realpath(requested).catch(() => requested);
95
+ const relative = path.relative(repository, absolute);
96
+ if (relative && isPathWithin(repository, absolute)) return relative.split(path.sep).join(path.posix.sep);
97
+ return path.basename(requested) || fallback;
98
+ }
99
+
100
+ async function captureReviewInput(file, temporaryRoot, name, label) {
101
+ let bytes;
102
+ try {
103
+ bytes = await captureStableRegularFile(file, label);
104
+ } catch (error) {
105
+ if (error instanceof AuthorityReviewRefusal) {
106
+ if (error.code === "AUTH_REVIEW_CAPTURE_CHANGED") throw error;
107
+ throw new AuthorityReviewRefusal("AUTH_REVIEW_INPUT_INVALID", `${name} must be one readable regular non-symlink file`, label);
108
+ }
109
+ throw error;
110
+ }
111
+ const directory = path.join(temporaryRoot, "inputs");
112
+ await mkdir(directory, { recursive: true, mode: 0o700 });
113
+ const target = path.join(directory, `${name}.yaml`);
114
+ await writeFile(target, bytes, { flag: "wx", mode: 0o600 });
115
+ return target;
116
+ }
117
+
118
+ export function canonicalReviewBytes(review) {
119
+ return Buffer.byteLength(JSON.stringify(review), "utf8");
120
+ }
121
+
122
+ async function composeReview(snapshot, bindingsPath, budgets) {
123
+ const bindingsLabel = await inputLabel(bindingsPath, snapshot.repository, "bindings.yaml");
124
+ const capturedBindings = await captureReviewInput(bindingsPath, snapshot.temporaryRoot, "bindings", bindingsLabel);
125
+ const [baseCompiled, worktreeCompiled] = await Promise.all([
126
+ compileSnapshot(snapshot.base.root, "base"),
127
+ compileSnapshot(snapshot.worktree.root, "worktree"),
128
+ ]);
129
+ const compilerDiagnostics = [...baseCompiled.diagnostics, ...worktreeCompiled.diagnostics];
130
+ if (compilerDiagnostics.length > 0) return refused(compilerDiagnostics);
131
+ if (baseCompiled.result.fileCount !== snapshot.base.fileCount || worktreeCompiled.result.fileCount !== snapshot.worktree.fileCount) {
132
+ throw new Error("captured authority file counts do not match complete compiler admission");
133
+ }
134
+
135
+ const baseSnapshot = createCompiledAuthoritySnapshot(baseCompiled.result, snapshot.base.root);
136
+ const worktreeSnapshot = createCompiledAuthoritySnapshot(worktreeCompiled.result, snapshot.worktree.root);
137
+ const diffResult = diffAuthoritySnapshots(baseSnapshot, worktreeSnapshot, { maxBytes: budgets.maxBytes });
138
+ if (!diffResult.ok || diffResult.diff === null) return refused(diffResult.diagnostics);
139
+ const impactResult = impactAuthorityDiff(diffResult, { maxBytes: budgets.maxBytes });
140
+ if (!impactResult.ok || impactResult.impact === null) return refused(impactResult.diagnostics);
141
+ const auditResult = await auditAuthoritySnapshot(worktreeSnapshot, capturedBindings, { ...budgets, bindingLabel: bindingsLabel });
142
+ if (auditResult.audit === null) return refused(auditResult.diagnostics);
143
+ if (JSON.stringify(diffResult.diff) !== JSON.stringify(impactResult.diff)) throw new Error("diff and impact oracles returned different semantic diffs for immutable snapshots");
144
+
145
+ const review = {
146
+ format: REVIEW_FORMAT,
147
+ operationStatus: "completed",
148
+ analysisComplete: true,
149
+ partial: false,
150
+ businessSemanticStatus: "not_evaluated",
151
+ implementationConformanceStatus: "not_evaluated",
152
+ snapshots: {
153
+ base: {
154
+ commitOid: snapshot.baseOid,
155
+ contentIdentity: snapshot.base.contentIdentity,
156
+ counts: {
157
+ files: snapshot.base.fileCount,
158
+ units: baseCompiled.result.unitCount,
159
+ bytes: snapshot.base.byteCount,
160
+ },
161
+ },
162
+ worktree: {
163
+ contentIdentity: snapshot.worktree.contentIdentity,
164
+ counts: {
165
+ files: snapshot.worktree.fileCount,
166
+ units: worktreeCompiled.result.unitCount,
167
+ bytes: snapshot.worktree.byteCount,
168
+ },
169
+ },
170
+ },
171
+ components: {
172
+ snapshotCapture: { status: "captured" },
173
+ corpusAdmission: { status: "admitted" },
174
+ semanticDiff: { status: "computed" },
175
+ declaredImpact: { status: "computed" },
176
+ configuredAudit: { status: "evaluated", blockingStatus: auditResult.audit.blockingStatus },
177
+ },
178
+ diff: diffResult.diff,
179
+ impact: impactResult.impact,
180
+ audit: auditResult.audit,
181
+ budgets,
182
+ };
183
+ const reviewBytes = canonicalReviewBytes(review);
184
+ if (reviewBytes > budgets.maxBytes) return refused([reviewBudgetDiagnostic(reviewBytes, budgets.maxBytes)], reviewBytes);
185
+ return {
186
+ ok: true,
187
+ diagnostics: [],
188
+ fileCount: snapshot.base.fileCount + snapshot.worktree.fileCount,
189
+ unitCount: worktreeCompiled.result.unitCount,
190
+ reviewBytes,
191
+ review,
192
+ partial: false,
193
+ };
194
+ }
195
+
196
+ export async function reviewAuthorityRepository(repositoryPath, baseRef, bindingsPath, budgets, options = {}) {
197
+ if (![budgets?.maxUnits, budgets?.maxEdges, budgets?.maxBytes].every(validBudget)) {
198
+ return refused([reviewBudgetDiagnostic(1, budgets?.maxBytes ?? 0)]);
199
+ }
200
+ try {
201
+ return await withGitAuthoritySnapshots({
202
+ repositoryPath,
203
+ baseRef,
204
+ hooks: options.snapshotHooks ?? null,
205
+ }, (snapshot) => composeReview(snapshot, bindingsPath, budgets));
206
+ } catch (error) {
207
+ if (error instanceof AuthorityReviewRefusal) return refused([authorityReviewRefusalDiagnostic(error)]);
208
+ throw error;
209
+ }
210
+ }
211
+
212
+ export { REVIEW_FORMAT };
@@ -0,0 +1,174 @@
1
+ const SARIF_SCHEMA =
2
+ "https://docs.oasis-open.org/sarif/sarif/v2.1.0/errata01/os/schemas/sarif-schema-2.1.0.json";
3
+ const SARIF_VERSION = "2.1.0";
4
+ const TOOL_NAME = "@nimiplatform/nimi-coding";
5
+ const FINGERPRINT_KEY = "nimicoding.semanticFingerprint/v1";
6
+
7
+ function copy(value) {
8
+ return value === undefined ? undefined : structuredClone(value);
9
+ }
10
+
11
+ function portableUri(file) {
12
+ return String(file)
13
+ .replaceAll("\\", "/")
14
+ .split("/")
15
+ .map((segment) => encodeURIComponent(segment))
16
+ .join("/");
17
+ }
18
+
19
+ function physicalLocation(location) {
20
+ const file = location?.file ?? location?.path;
21
+ if (typeof file !== "string" || !location?.range) return null;
22
+ const value = {
23
+ artifactLocation: { uri: portableUri(file) },
24
+ region: {
25
+ startLine: location.range.start.line,
26
+ startColumn: location.range.start.column,
27
+ endLine: location.range.end.line,
28
+ endColumn: location.range.end.column,
29
+ },
30
+ };
31
+ const sourcePointer = location.sourcePointer ?? location.pointer;
32
+ if (typeof sourcePointer === "string") value.properties = { sourcePointer };
33
+ return value;
34
+ }
35
+
36
+ function primaryLocation(location) {
37
+ const physical = physicalLocation(location);
38
+ return physical ? [{ physicalLocation: physical }] : [];
39
+ }
40
+
41
+ function relatedLocations(values = []) {
42
+ return values.flatMap((entry, index) => {
43
+ const physical = physicalLocation(entry.location ?? entry);
44
+ if (!physical) return [];
45
+ const role = entry.role ?? entry.location?.role;
46
+ const related = {
47
+ id: index + 1,
48
+ physicalLocation: physical,
49
+ };
50
+ if (typeof role === "string") {
51
+ related.message = { text: role };
52
+ related.properties = { role };
53
+ }
54
+ return [related];
55
+ });
56
+ }
57
+
58
+ function itemLevel(kind, item) {
59
+ return item.policy === "blocking" ? "error" : "warning";
60
+ }
61
+
62
+ function auditItemResult(kind, item) {
63
+ const result = {
64
+ ruleId: item.code,
65
+ level: itemLevel(kind, item),
66
+ message: { text: item.message },
67
+ locations: primaryLocation(item.primaryLocation),
68
+ relatedLocations: relatedLocations(item.relatedLocations),
69
+ partialFingerprints: { [FINGERPRINT_KEY]: item.fingerprint },
70
+ properties: {
71
+ kind,
72
+ code: item.code,
73
+ fingerprint: item.fingerprint,
74
+ detector: item.detector,
75
+ binding: item.binding,
76
+ premise: item.premise,
77
+ basis: item.basis,
78
+ policy: item.policy,
79
+ authorityRefs: copy(item.authorityRefs),
80
+ witness: copy(item.witness),
81
+ },
82
+ };
83
+ if (typeof item.target === "string") result.properties.target = item.target;
84
+ if (result.locations.length === 0) delete result.locations;
85
+ if (result.relatedLocations.length === 0) delete result.relatedLocations;
86
+ return result;
87
+ }
88
+
89
+ function diagnosticResult(diagnostic) {
90
+ const result = {
91
+ ruleId: diagnostic.code,
92
+ level: "error",
93
+ message: { text: diagnostic.reason },
94
+ locations: primaryLocation({
95
+ path: diagnostic.path,
96
+ range: diagnostic.range,
97
+ pointer: diagnostic.pointer,
98
+ }),
99
+ relatedLocations: relatedLocations(diagnostic.related),
100
+ properties: {
101
+ kind: "diagnostic",
102
+ code: diagnostic.code,
103
+ severity: diagnostic.severity,
104
+ pointer: diagnostic.pointer,
105
+ repair: diagnostic.repair,
106
+ },
107
+ };
108
+ if (result.locations.length === 0) delete result.locations;
109
+ if (result.relatedLocations.length === 0) delete result.relatedLocations;
110
+ return result;
111
+ }
112
+
113
+ function rulesFor(results) {
114
+ const codes = [...new Set(results.map((result) => result.ruleId))].sort();
115
+ return codes.map((code) => ({
116
+ id: code,
117
+ shortDescription: { text: code },
118
+ }));
119
+ }
120
+
121
+ function invocationProperties(result) {
122
+ if (!result.audit) {
123
+ return {
124
+ operationStatus: "refused",
125
+ };
126
+ }
127
+ return {
128
+ format: result.audit.format,
129
+ operationStatus: result.audit.operationStatus,
130
+ blockingStatus: result.audit.blockingStatus,
131
+ counts: copy(result.audit.counts),
132
+ budgets: copy(result.audit.budgets),
133
+ };
134
+ }
135
+
136
+ export function authorityAuditResultToSarif(result, { toolVersion }) {
137
+ if (!result || typeof result !== "object")
138
+ throw new TypeError("authority audit result must be an object");
139
+ if (typeof toolVersion !== "string" || toolVersion.length === 0)
140
+ throw new TypeError("toolVersion must be a non-empty string");
141
+
142
+ const results = result.audit
143
+ ? [
144
+ ...(result.audit.findings ?? []).map((item) => auditItemResult("finding", item)),
145
+ ]
146
+ : (result.diagnostics ?? []).map(diagnosticResult);
147
+ const rules = rulesFor(results);
148
+ const ruleIndexes = new Map(rules.map((rule, index) => [rule.id, index]));
149
+ for (const entry of results) entry.ruleIndex = ruleIndexes.get(entry.ruleId);
150
+
151
+ return {
152
+ $schema: SARIF_SCHEMA,
153
+ version: SARIF_VERSION,
154
+ runs: [
155
+ {
156
+ tool: {
157
+ driver: {
158
+ name: TOOL_NAME,
159
+ version: toolVersion,
160
+ rules,
161
+ },
162
+ },
163
+ columnKind: "unicodeCodePoints",
164
+ invocations: [
165
+ {
166
+ executionSuccessful: result.ok === true,
167
+ properties: invocationProperties(result),
168
+ },
169
+ ],
170
+ results,
171
+ },
172
+ ],
173
+ };
174
+ }
@@ -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 };