@ecoma-io/archkeep 0.15.0 → 0.16.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/README.md +3 -3
- package/cli.mjs +126 -4
- package/commands.mjs +6 -0
- package/lsp.mjs +15 -2
- package/package.json +6 -2
- package/src/analysis/analyze.mjs +15 -0
- package/src/analysis/contract.md +36 -18
- package/src/analysis/csharp.mjs +485 -0
- package/src/analysis/dotnet/csproj.mjs +380 -0
- package/src/analysis/dotnet/mask.mjs +178 -0
- package/src/analysis/dotnet/namespaces.mjs +172 -0
- package/src/analysis/dotnet/resolve.mjs +89 -0
- package/src/analysis/go.mjs +289 -5
- package/src/analysis/java.mjs +329 -0
- package/src/analysis/jvm/gradle.mjs +545 -0
- package/src/analysis/jvm/mask.mjs +170 -0
- package/src/analysis/jvm/maven.mjs +612 -0
- package/src/analysis/jvm/packages.mjs +209 -0
- package/src/analysis/jvm/resolve.mjs +139 -0
- package/src/analysis/kotlin.mjs +210 -0
- package/src/analysis/manifest-util.mjs +30 -0
- package/src/analysis/python.mjs +3 -2
- package/src/analysis/registry.mjs +11 -0
- package/src/analysis/rust.mjs +171 -17
- package/src/analysis/source-util.mjs +155 -6
- package/src/analysis/typescript.mjs +9 -2
- package/src/commands/context.mjs +84 -14
- package/src/commands/provenance.mjs +7 -44
- package/src/commands/rules.mjs +775 -0
- package/src/governance/profile-registry.mjs +0 -1
- package/src/graph/create-dependencies.mjs +138 -15
- package/src/lsp/diagnose.mjs +1 -1
- package/src/lsp/server.mjs +97 -1
- package/src/lsp/workspace-index.mjs +106 -15
- package/src/options.mjs +30 -7
- package/src/process.mjs +10 -1
- package/src/providers/moon.mjs +287 -36
- package/src/providers/native/differential.fixtures.mjs +32 -6
- package/src/providers/native/discover.mjs +83 -4
- package/src/providers/native/graph.mjs +58 -0
- package/src/providers/native/model.mjs +59 -1
- package/src/rules/index.mjs +21 -6
- package/src/rules/reachability.mjs +2 -0
- package/src/rules/tags.mjs +7 -5
- package/src/rules/topology.mjs +5 -3
- package/src/workspace.mjs +115 -23
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `rules` command: CLI face for the official rules catalog.
|
|
3
|
+
*
|
|
4
|
+
* This command provides four verbs for working with the official rules catalog
|
|
5
|
+
* (`@ecoma-io/archkeep-rules`): list, info, verify, and add. It reads the catalog
|
|
6
|
+
* from the filesystem (never by import) and validates artifact integrity through
|
|
7
|
+
* the engine's real host.
|
|
8
|
+
*
|
|
9
|
+
* ## Posture
|
|
10
|
+
*
|
|
11
|
+
* All four verbs are descriptive except `verify`, which can exit 1 on a failed
|
|
12
|
+
* verification (digest mismatch or host refusal) and exit 3 on a missing or
|
|
13
|
+
* corrupt catalog. `add` exits 0 on success, 3 on any failure. `list` and `info`
|
|
14
|
+
* are purely descriptive and always exit 0.
|
|
15
|
+
*
|
|
16
|
+
* Exit 1 is a finding — a verification that ran, looked at the bytes, and came
|
|
17
|
+
* back negative. Exit 3 is the run that could not look. The two must never
|
|
18
|
+
* share a code: a script branching on the exit code has to tell "the shipped
|
|
19
|
+
* rule artifact was modified" from "the catalog could not be read", and the
|
|
20
|
+
* first of those is the one the integrity gate exists to catch.
|
|
21
|
+
*
|
|
22
|
+
* Catalog-derived paths are contained to the directory that anchors them, on
|
|
23
|
+
* the mechanism `../containment.mjs` already enforces for report output and
|
|
24
|
+
* history captures: an `artifact` field (verify, add) resolves under the
|
|
25
|
+
* catalog's own directory, an `add` target under the directory `--to` names,
|
|
26
|
+
* and an entry that escapes either fails the run loudly with the entry named.
|
|
27
|
+
* The catalog is data a consumer may have downloaded, vendored, or had
|
|
28
|
+
* modified under it — data does not get to name a path outside its tree.
|
|
29
|
+
*
|
|
30
|
+
* The catalog is read from the filesystem at a user-resolvable path, never by
|
|
31
|
+
* import or package dependency. This keeps the engine independent of the rules
|
|
32
|
+
* package — the boundary law has no row allowing scope-nx → scope-sdk.
|
|
33
|
+
*
|
|
34
|
+
* ## Catalog resolution
|
|
35
|
+
*
|
|
36
|
+
* 1. Explicit `--catalog <path>` — resolved from cwd, must exist and be valid JSON
|
|
37
|
+
* 2. Default: `node_modules/@ecoma-io/archkeep-rules/catalog.json` (workspace root relative)
|
|
38
|
+
* 3. Moon/native workspaces with no node_modules → loud exit 3 with message
|
|
39
|
+
*
|
|
40
|
+
* `check` never reads the catalog (that claim stays true). Only the `rules` verb
|
|
41
|
+
* reads it, at the user's explicit request.
|
|
42
|
+
*
|
|
43
|
+
* ## `rules add` output
|
|
44
|
+
*
|
|
45
|
+
* `add` copies the artifact bytes into the workspace and PRINTS one
|
|
46
|
+
* ready-to-paste customRules row (real sha256, `reason: "<fill this in>"`
|
|
47
|
+
* placeholder) — every dialect gets the same printout, and no code path
|
|
48
|
+
* writes a config file or programmatically edits a JavaScript module. The
|
|
49
|
+
* row carries no `params`: the catalog's `params` field is the parameter
|
|
50
|
+
* SCHEMA, and the values are the workspace's law to choose — when the rule
|
|
51
|
+
* declares parameters the printout points at `rules info` for the schema.
|
|
52
|
+
* ESLint flat configs carry no `customRules` at all, so the printout says
|
|
53
|
+
* so instead of printing a row.
|
|
54
|
+
*
|
|
55
|
+
* Never auto-downloads anything. Never programmatically edits JavaScript modules.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
59
|
+
import { createHash } from "node:crypto";
|
|
60
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from "node:fs";
|
|
61
|
+
|
|
62
|
+
import { jsonEnvelope, renderJson } from "../report/json.mjs";
|
|
63
|
+
import { resolveProvenance } from "./provenance.mjs";
|
|
64
|
+
import { loadCustomRule } from "../custom-rules/host.mjs";
|
|
65
|
+
import { EXIT } from "../verdict.mjs";
|
|
66
|
+
import { containmentViolation, pathEscapes } from "../containment.mjs";
|
|
67
|
+
|
|
68
|
+
/** Default catalog path when `--catalog` is not provided. */
|
|
69
|
+
const DEFAULT_CATALOG_PATH = "node_modules/@ecoma-io/archkeep-rules/catalog.json";
|
|
70
|
+
|
|
71
|
+
/** Default output directory for `rules add` when `--to` is not provided. */
|
|
72
|
+
const DEFAULT_RULES_DIR = "tools/rules";
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Reads and parses the catalog from a given path.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} catalogPath The catalog file path.
|
|
78
|
+
* @param {string} cwd Current working directory for relative paths.
|
|
79
|
+
* @returns {{catalog: object, path: string}} The parsed catalog and its resolved path.
|
|
80
|
+
* @throws {Error} if the catalog cannot be read or parsed.
|
|
81
|
+
*/
|
|
82
|
+
function loadCatalog(catalogPath, cwd) {
|
|
83
|
+
const resolvedPath = isAbsolute(catalogPath) ? catalogPath : resolve(cwd, catalogPath);
|
|
84
|
+
|
|
85
|
+
if (!existsSync(resolvedPath)) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
`catalog not found at ${catalogPath} — install @ecoma-io/archkeep-rules or use --catalog to point to a catalog.json file`,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const text = readFileSync(resolvedPath, "utf8");
|
|
93
|
+
const catalog = JSON.parse(text);
|
|
94
|
+
|
|
95
|
+
// Basic catalog validation
|
|
96
|
+
if (!catalog || typeof catalog !== "object") {
|
|
97
|
+
throw new Error("catalog is not an object");
|
|
98
|
+
}
|
|
99
|
+
if (!Array.isArray(catalog.rules)) {
|
|
100
|
+
throw new Error("catalog.rules is not an array");
|
|
101
|
+
}
|
|
102
|
+
if (typeof catalog.version !== "number") {
|
|
103
|
+
throw new Error("catalog.version is not a number");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { catalog, path: resolvedPath };
|
|
107
|
+
} catch (cause) {
|
|
108
|
+
if (cause?.message?.startsWith("catalog")) {
|
|
109
|
+
throw cause;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`catalog at ${catalogPath} is not valid JSON: ${cause?.message ?? cause}`, {
|
|
112
|
+
cause,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolves the catalog path from options or default.
|
|
119
|
+
*
|
|
120
|
+
* @param {{catalog?: string}} options The parsed command options.
|
|
121
|
+
* @param {string} _cwd Current working directory.
|
|
122
|
+
* @returns {string} The resolved catalog path.
|
|
123
|
+
*/
|
|
124
|
+
function resolveCatalogPath(options, _cwd) {
|
|
125
|
+
return options.catalog ?? DEFAULT_CATALOG_PATH;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The reason a catalog-derived path may not be read or written, or `null` when
|
|
130
|
+
* it is contained.
|
|
131
|
+
*
|
|
132
|
+
* Two legs, the pattern `../containment.mjs` already enforces for report
|
|
133
|
+
* output and history captures: a path whose resolved form escapes the anchor
|
|
134
|
+
* directory is refused with the escape named, and a path that stays inside
|
|
135
|
+
* lexically still goes through `containmentViolation`, so a symlink in the
|
|
136
|
+
* tree cannot walk a read out of the directory or land a write somewhere
|
|
137
|
+
* other than the path named. The caller resolves first and hands the SAME
|
|
138
|
+
* resolved string here that it reads or writes — `containment.mjs`'s own
|
|
139
|
+
* header owns why the `..`-across-a-symlink corner demands that.
|
|
140
|
+
*
|
|
141
|
+
* @param {string} anchorDir The absolute directory the entry resolves under.
|
|
142
|
+
* @param {string} candidatePath The resolved absolute candidate — the same
|
|
143
|
+
* string the read or write will act on.
|
|
144
|
+
* @param {{forWrite?: boolean}} [options] Writes carry the write policy.
|
|
145
|
+
* @returns {string|null} The refusal reason, or `null` when contained.
|
|
146
|
+
*/
|
|
147
|
+
function entryPathViolation(anchorDir, candidatePath, { forWrite = false } = {}) {
|
|
148
|
+
if (pathEscapes(anchorDir, candidatePath)) {
|
|
149
|
+
return (
|
|
150
|
+
`'${candidatePath}' resolves outside '${anchorDir}' — a catalog entry must not name a ` +
|
|
151
|
+
`path outside the directory it is resolved from`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
// `containmentViolation` probes realpaths, which needs existing components.
|
|
155
|
+
// A read anchor always exists — the catalog was read out of it. A write
|
|
156
|
+
// anchor that does not exist yet is a directory this run's `mkdir` creates a
|
|
157
|
+
// moment later, so there is nothing planted to walk, and where the caller
|
|
158
|
+
// pointed `--to` is the caller's explicit choice, not the escape this
|
|
159
|
+
// refuses.
|
|
160
|
+
if (!existsSync(anchorDir)) return null;
|
|
161
|
+
return containmentViolation(anchorDir, candidatePath, { forWrite });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Formats a rule for text output.
|
|
166
|
+
*
|
|
167
|
+
* @param {object} rule A catalog rule entry.
|
|
168
|
+
* @returns {string} Formatted text representation.
|
|
169
|
+
*/
|
|
170
|
+
function formatRule(rule) {
|
|
171
|
+
const params = Object.entries(rule.params || {})
|
|
172
|
+
.map(([key, param]) => {
|
|
173
|
+
const required = param.required ? "required" : "optional";
|
|
174
|
+
return ` ${key} (${param.type}, ${required})`;
|
|
175
|
+
})
|
|
176
|
+
.join("\n");
|
|
177
|
+
|
|
178
|
+
return [
|
|
179
|
+
` ${rule.name}`,
|
|
180
|
+
` ${rule.description || "(no description)"}`,
|
|
181
|
+
` Contract: ${rule.contract}`,
|
|
182
|
+
` Evidence: ${(rule.needs || []).join(", ") || "(none)"}`,
|
|
183
|
+
` Artifact: ${rule.artifact}`,
|
|
184
|
+
` SHA256: ${rule.sha256}`,
|
|
185
|
+
` Parameters:`,
|
|
186
|
+
params || " (none)",
|
|
187
|
+
].join("\n");
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Lists all rules in the catalog.
|
|
192
|
+
*
|
|
193
|
+
* @param {{catalog?: string}} options The parsed command options.
|
|
194
|
+
* @param {{cwd: string}} runContext The command context.
|
|
195
|
+
* @returns {Promise<{status: "ok", catalog: object, report: {text: string, json: string}}>}
|
|
196
|
+
*/
|
|
197
|
+
export async function rulesListCommand(options, { cwd }) {
|
|
198
|
+
const catalogPath = resolveCatalogPath(options, cwd);
|
|
199
|
+
const { catalog } = loadCatalog(catalogPath, cwd);
|
|
200
|
+
|
|
201
|
+
const context = {
|
|
202
|
+
root: cwd,
|
|
203
|
+
provider: /** @type {"native"} */ ("native"),
|
|
204
|
+
marker: "catalog.json",
|
|
205
|
+
provenance: resolveProvenance(cwd),
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const lines = catalog.rules.map((rule) => formatRule(rule));
|
|
209
|
+
const text =
|
|
210
|
+
`Official rules catalog (${catalog.rules.length} rule${catalog.rules.length === 1 ? "" : "s"})\n` +
|
|
211
|
+
`Source: ${catalogPath}\n\n` +
|
|
212
|
+
lines.join("\n\n");
|
|
213
|
+
|
|
214
|
+
const coverage = {
|
|
215
|
+
complete: true,
|
|
216
|
+
projects: 0,
|
|
217
|
+
analyzedFiles: 1,
|
|
218
|
+
imports: 0,
|
|
219
|
+
notAnalyzed: [],
|
|
220
|
+
blindSpots: [],
|
|
221
|
+
notes: [],
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
status: "ok",
|
|
226
|
+
catalog,
|
|
227
|
+
report: {
|
|
228
|
+
text,
|
|
229
|
+
json: renderJson(
|
|
230
|
+
jsonEnvelope({
|
|
231
|
+
command: "rules list",
|
|
232
|
+
context,
|
|
233
|
+
status: "ok",
|
|
234
|
+
exitCode: 0,
|
|
235
|
+
coverage,
|
|
236
|
+
result: { catalog: catalogPath, rules: catalog.rules },
|
|
237
|
+
}),
|
|
238
|
+
),
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Shows detailed information about one rule.
|
|
245
|
+
*
|
|
246
|
+
* @param {{catalog?: string}} options The parsed command options.
|
|
247
|
+
* @param {{cwd: string, ruleName: string}} runContext The command context.
|
|
248
|
+
* @returns {Promise<{status: "ok"|"no-verdict", catalog: object, rule: object|null, report: {text: string, json: string}}>}
|
|
249
|
+
*/
|
|
250
|
+
export async function rulesInfoCommand(options, { cwd, ruleName }) {
|
|
251
|
+
const catalogPath = resolveCatalogPath(options, cwd);
|
|
252
|
+
const { catalog } = loadCatalog(catalogPath, cwd);
|
|
253
|
+
|
|
254
|
+
const rule = catalog.rules.find((r) => r.name === ruleName);
|
|
255
|
+
|
|
256
|
+
const context = {
|
|
257
|
+
root: cwd,
|
|
258
|
+
provider: /** @type {"native"} */ ("native"),
|
|
259
|
+
marker: "catalog.json",
|
|
260
|
+
provenance: resolveProvenance(cwd),
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
if (!rule) {
|
|
264
|
+
const text =
|
|
265
|
+
`Rule "${ruleName}" not found in catalog at ${catalogPath}\n` +
|
|
266
|
+
`Available rules: ${catalog.rules.map((r) => r.name).join(", ")}`;
|
|
267
|
+
|
|
268
|
+
const coverage = {
|
|
269
|
+
complete: false,
|
|
270
|
+
projects: 0,
|
|
271
|
+
analyzedFiles: 0,
|
|
272
|
+
imports: 0,
|
|
273
|
+
notAnalyzed: [{ file: catalogPath, reason: "requested rule not found" }],
|
|
274
|
+
blindSpots: [],
|
|
275
|
+
notes: [],
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
status: "no-verdict",
|
|
280
|
+
catalog,
|
|
281
|
+
rule: null,
|
|
282
|
+
report: {
|
|
283
|
+
text,
|
|
284
|
+
json: renderJson(
|
|
285
|
+
jsonEnvelope({
|
|
286
|
+
command: "rules info",
|
|
287
|
+
context,
|
|
288
|
+
status: "no-verdict",
|
|
289
|
+
exitCode: 3,
|
|
290
|
+
coverage,
|
|
291
|
+
result: { catalog: catalogPath, availableRules: catalog.rules.map((r) => r.name) },
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const text = formatRule(rule);
|
|
299
|
+
|
|
300
|
+
const coverage = {
|
|
301
|
+
complete: true,
|
|
302
|
+
projects: 0,
|
|
303
|
+
analyzedFiles: 1,
|
|
304
|
+
imports: 0,
|
|
305
|
+
notAnalyzed: [],
|
|
306
|
+
blindSpots: [],
|
|
307
|
+
notes: [],
|
|
308
|
+
};
|
|
309
|
+
|
|
310
|
+
return {
|
|
311
|
+
status: "ok",
|
|
312
|
+
catalog,
|
|
313
|
+
rule,
|
|
314
|
+
report: {
|
|
315
|
+
text,
|
|
316
|
+
json: renderJson(
|
|
317
|
+
jsonEnvelope({
|
|
318
|
+
command: "rules info",
|
|
319
|
+
context,
|
|
320
|
+
status: "ok",
|
|
321
|
+
exitCode: 0,
|
|
322
|
+
coverage,
|
|
323
|
+
result: { catalog: catalogPath, rule },
|
|
324
|
+
}),
|
|
325
|
+
),
|
|
326
|
+
},
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Verifies catalog integrity and artifacts through the REAL host.
|
|
332
|
+
*
|
|
333
|
+
* This loads each catalog artifact through `loadCustomRule` to verify:
|
|
334
|
+
* - The artifact path stays inside the catalog's own directory
|
|
335
|
+
* - The artifact file exists
|
|
336
|
+
* - The digest matches the catalog entry
|
|
337
|
+
* - The artifact loads and describes itself correctly
|
|
338
|
+
* - The artifact speaks the declared contract
|
|
339
|
+
*
|
|
340
|
+
* @param {{catalog?: string}} options The parsed command options.
|
|
341
|
+
* @param {{cwd: string}} runContext The command context.
|
|
342
|
+
* @returns {Promise<{status: "ok"|"findings"|"no-verdict", catalog: object, report: {text: string, json: string}}>}
|
|
343
|
+
*/
|
|
344
|
+
export async function rulesVerifyCommand(options, { cwd }) {
|
|
345
|
+
const catalogPath = resolveCatalogPath(options, cwd);
|
|
346
|
+
const { catalog, path: resolvedCatalogPath } = loadCatalog(catalogPath, cwd);
|
|
347
|
+
|
|
348
|
+
const context = {
|
|
349
|
+
root: cwd,
|
|
350
|
+
provider: /** @type {"native"} */ ("native"),
|
|
351
|
+
marker: "catalog.json",
|
|
352
|
+
provenance: resolveProvenance(cwd),
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// Resolve artifact paths relative to the catalog's directory
|
|
356
|
+
const catalogDir = resolve(resolvedCatalogPath, "..");
|
|
357
|
+
|
|
358
|
+
const findings = [];
|
|
359
|
+
const passed = [];
|
|
360
|
+
const unknown = [];
|
|
361
|
+
|
|
362
|
+
for (const rule of catalog.rules) {
|
|
363
|
+
const artifactPath = resolve(catalogDir, rule.artifact);
|
|
364
|
+
|
|
365
|
+
// Contained before anything is read: the artifact field is catalog data,
|
|
366
|
+
// and a `../…` (or a symlink walked out of the tree) must fail this run
|
|
367
|
+
// with the entry named, never be read.
|
|
368
|
+
const pathRefusal = entryPathViolation(catalogDir, artifactPath);
|
|
369
|
+
if (pathRefusal !== null) {
|
|
370
|
+
findings.push({
|
|
371
|
+
rule: rule.name,
|
|
372
|
+
severity: "fail",
|
|
373
|
+
message: `Artifact '${rule.artifact}' refused: ${pathRefusal}`,
|
|
374
|
+
});
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
if (!existsSync(artifactPath)) {
|
|
379
|
+
findings.push({
|
|
380
|
+
rule: rule.name,
|
|
381
|
+
severity: "fail",
|
|
382
|
+
message: `Artifact not found: ${rule.artifact}`,
|
|
383
|
+
});
|
|
384
|
+
continue;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const artifactBytes = readFileSync(artifactPath);
|
|
389
|
+
|
|
390
|
+
// Verify digest
|
|
391
|
+
const computedSha256 = createHash("sha256").update(artifactBytes).digest("hex");
|
|
392
|
+
if (computedSha256 !== rule.sha256) {
|
|
393
|
+
findings.push({
|
|
394
|
+
rule: rule.name,
|
|
395
|
+
severity: "fail",
|
|
396
|
+
message: `Digest mismatch: catalog says ${rule.sha256}, file is ${computedSha256}`,
|
|
397
|
+
});
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// Verify through host
|
|
402
|
+
const loaded = await loadCustomRule({
|
|
403
|
+
name: rule.name,
|
|
404
|
+
artifactBytes,
|
|
405
|
+
declaredSha256: rule.sha256,
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
if (!loaded.ok) {
|
|
409
|
+
findings.push({
|
|
410
|
+
rule: rule.name,
|
|
411
|
+
severity: "fail",
|
|
412
|
+
message: loaded.failure.reason,
|
|
413
|
+
});
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// Verify contract version matches
|
|
418
|
+
if (loaded.describe.contract !== rule.contract) {
|
|
419
|
+
findings.push({
|
|
420
|
+
rule: rule.name,
|
|
421
|
+
severity: "fail",
|
|
422
|
+
message: `Contract mismatch: catalog says ${rule.contract}, artifact says ${loaded.describe.contract}`,
|
|
423
|
+
});
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
passed.push({ rule: rule.name, message: "OK" });
|
|
428
|
+
} catch (error) {
|
|
429
|
+
findings.push({
|
|
430
|
+
rule: rule.name,
|
|
431
|
+
severity: "fail",
|
|
432
|
+
message: `Verification failed: ${error.message}`,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Three states, the posture the header promises. `findings` — the check ran
|
|
438
|
+
// and produced negative results (digest mismatch, host refusal, an escaping
|
|
439
|
+
// artifact) — is the exit-1 class. `no-verdict` stays the exit-3 class: the
|
|
440
|
+
// run could not look. Collapsing the two makes "this artifact was tampered
|
|
441
|
+
// with" indistinguishable from "the catalog could not be read" for every
|
|
442
|
+
// script that branches on the exit code.
|
|
443
|
+
const status =
|
|
444
|
+
findings.length > 0 ? "findings" : passed.length === catalog.rules.length ? "ok" : "no-verdict";
|
|
445
|
+
|
|
446
|
+
const exitCode = status === "ok" ? EXIT.ok : status === "findings" ? EXIT.violations : EXIT.error;
|
|
447
|
+
|
|
448
|
+
const coverage = {
|
|
449
|
+
complete: findings.length === 0 && passed.length === catalog.rules.length,
|
|
450
|
+
projects: 0,
|
|
451
|
+
analyzedFiles: catalog.rules.length,
|
|
452
|
+
imports: 0,
|
|
453
|
+
notAnalyzed: findings.map((f) => ({ file: f.rule, reason: f.message })),
|
|
454
|
+
blindSpots: [],
|
|
455
|
+
notes: [],
|
|
456
|
+
};
|
|
457
|
+
|
|
458
|
+
const text =
|
|
459
|
+
`Catalog verification: ${findings.length === 0 ? "OK" : "FAILED"}\n` +
|
|
460
|
+
`Source: ${catalogPath}\n` +
|
|
461
|
+
`${catalog.rules.length} rule${catalog.rules.length === 1 ? "" : "s"} checked\n\n` +
|
|
462
|
+
(findings.length > 0
|
|
463
|
+
? `Failures (${findings.length}):\n${findings.map((f) => ` [${f.severity}] ${f.rule}: ${f.message}`).join("\n")}\n\n`
|
|
464
|
+
: "") +
|
|
465
|
+
(passed.length > 0
|
|
466
|
+
? `Passed (${passed.length}):\n${passed.map((p) => ` [OK] ${p.rule}`).join("\n")}\n\n`
|
|
467
|
+
: "") +
|
|
468
|
+
(unknown.length > 0
|
|
469
|
+
? `Unknown (${unknown.length}):\n${unknown.map((u) => ` [?] ${u.rule}: ${u.message}`).join("\n")}\n\n`
|
|
470
|
+
: "");
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
status,
|
|
474
|
+
catalog,
|
|
475
|
+
report: {
|
|
476
|
+
text,
|
|
477
|
+
json: renderJson(
|
|
478
|
+
jsonEnvelope({
|
|
479
|
+
command: "rules verify",
|
|
480
|
+
context,
|
|
481
|
+
status,
|
|
482
|
+
exitCode,
|
|
483
|
+
coverage,
|
|
484
|
+
result: {
|
|
485
|
+
catalog: catalogPath,
|
|
486
|
+
totalRules: catalog.rules.length,
|
|
487
|
+
passed: passed.length,
|
|
488
|
+
findingsCount: findings.length,
|
|
489
|
+
findings,
|
|
490
|
+
},
|
|
491
|
+
}),
|
|
492
|
+
),
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Adds a rule from the catalog to the workspace.
|
|
499
|
+
*
|
|
500
|
+
* This copies the exact wasm bytes to a local directory and PRINTS one
|
|
501
|
+
* ready-to-paste customRules row — no config file is written. The row
|
|
502
|
+
* carries no `params`: the catalog's `params` field is the schema, and the
|
|
503
|
+
* printout points at `rules info` for it when the rule declares parameters.
|
|
504
|
+
*
|
|
505
|
+
* @param {{catalog?: string, to?: string}} options The parsed command options.
|
|
506
|
+
* @param {{cwd: string, ruleName: string}} runContext The command context.
|
|
507
|
+
* @returns {Promise<{status: "ok"|"no-verdict", catalog: object, report: {text: string, json: string}}>}
|
|
508
|
+
*/
|
|
509
|
+
export async function rulesAddCommand(options, { cwd, ruleName }) {
|
|
510
|
+
const catalogPath = resolveCatalogPath(options, cwd);
|
|
511
|
+
const { catalog, path: resolvedCatalogPath } = loadCatalog(catalogPath, cwd);
|
|
512
|
+
|
|
513
|
+
const context = {
|
|
514
|
+
root: cwd,
|
|
515
|
+
provider: /** @type {"native"} */ ("native"),
|
|
516
|
+
marker: "catalog.json",
|
|
517
|
+
provenance: resolveProvenance(cwd),
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
const rule = catalog.rules.find((r) => r.name === ruleName);
|
|
521
|
+
|
|
522
|
+
if (!rule) {
|
|
523
|
+
const text =
|
|
524
|
+
`Rule "${ruleName}" not found in catalog at ${catalogPath}\n` +
|
|
525
|
+
`Available rules: ${catalog.rules.map((r) => r.name).join(", ")}`;
|
|
526
|
+
|
|
527
|
+
const coverage = {
|
|
528
|
+
complete: false,
|
|
529
|
+
projects: 0,
|
|
530
|
+
analyzedFiles: 0,
|
|
531
|
+
imports: 0,
|
|
532
|
+
notAnalyzed: [{ file: catalogPath, reason: "requested rule not found" }],
|
|
533
|
+
blindSpots: [],
|
|
534
|
+
notes: [],
|
|
535
|
+
};
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
status: "no-verdict",
|
|
539
|
+
catalog,
|
|
540
|
+
report: {
|
|
541
|
+
text,
|
|
542
|
+
json: renderJson(
|
|
543
|
+
jsonEnvelope({
|
|
544
|
+
command: "rules add",
|
|
545
|
+
context,
|
|
546
|
+
status: "no-verdict",
|
|
547
|
+
exitCode: 3,
|
|
548
|
+
coverage,
|
|
549
|
+
result: { catalog: catalogPath, availableRules: catalog.rules.map((r) => r.name) },
|
|
550
|
+
}),
|
|
551
|
+
),
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Resolve artifact path — from the path `loadCatalog` actually read, not a
|
|
557
|
+
// second resolution that would anchor a relative `--catalog` on the process
|
|
558
|
+
// cwd instead of this run's workspace.
|
|
559
|
+
const catalogDir = resolve(resolvedCatalogPath, "..");
|
|
560
|
+
const sourceArtifactPath = resolve(catalogDir, rule.artifact);
|
|
561
|
+
|
|
562
|
+
// Contained before anything is read — same boundary as `verify`, same
|
|
563
|
+
// reason: the artifact field is catalog data.
|
|
564
|
+
const pathRefusal = entryPathViolation(catalogDir, sourceArtifactPath);
|
|
565
|
+
if (pathRefusal !== null) {
|
|
566
|
+
const coverage = {
|
|
567
|
+
complete: false,
|
|
568
|
+
projects: 0,
|
|
569
|
+
analyzedFiles: 0,
|
|
570
|
+
imports: 0,
|
|
571
|
+
notAnalyzed: [{ file: sourceArtifactPath, reason: pathRefusal }],
|
|
572
|
+
blindSpots: [],
|
|
573
|
+
notes: [],
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
return {
|
|
577
|
+
status: "no-verdict",
|
|
578
|
+
catalog,
|
|
579
|
+
report: {
|
|
580
|
+
text: `Rule '${rule.name}' refused: artifact '${rule.artifact}' — ${pathRefusal}\n`,
|
|
581
|
+
json: renderJson(
|
|
582
|
+
jsonEnvelope({
|
|
583
|
+
command: "rules add",
|
|
584
|
+
context,
|
|
585
|
+
status: "no-verdict",
|
|
586
|
+
exitCode: 3,
|
|
587
|
+
coverage,
|
|
588
|
+
result: { catalog: catalogPath, rule: rule.name },
|
|
589
|
+
}),
|
|
590
|
+
),
|
|
591
|
+
},
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!existsSync(sourceArtifactPath)) {
|
|
596
|
+
const coverage = {
|
|
597
|
+
complete: false,
|
|
598
|
+
projects: 0,
|
|
599
|
+
analyzedFiles: 0,
|
|
600
|
+
imports: 0,
|
|
601
|
+
notAnalyzed: [{ file: sourceArtifactPath, reason: "artifact not found" }],
|
|
602
|
+
blindSpots: [],
|
|
603
|
+
notes: [],
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
return {
|
|
607
|
+
status: "no-verdict",
|
|
608
|
+
catalog,
|
|
609
|
+
report: {
|
|
610
|
+
text: `Artifact not found: ${rule.artifact}\n`,
|
|
611
|
+
json: renderJson(
|
|
612
|
+
jsonEnvelope({
|
|
613
|
+
command: "rules add",
|
|
614
|
+
context,
|
|
615
|
+
status: "no-verdict",
|
|
616
|
+
exitCode: 3,
|
|
617
|
+
coverage,
|
|
618
|
+
result: { catalog: catalogPath, rule: rule.name },
|
|
619
|
+
}),
|
|
620
|
+
),
|
|
621
|
+
},
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Verify digest before copying
|
|
626
|
+
const artifactBytes = readFileSync(sourceArtifactPath);
|
|
627
|
+
const computedSha256 = createHash("sha256").update(artifactBytes).digest("hex");
|
|
628
|
+
if (computedSha256 !== rule.sha256) {
|
|
629
|
+
const coverage = {
|
|
630
|
+
complete: false,
|
|
631
|
+
projects: 0,
|
|
632
|
+
analyzedFiles: 1,
|
|
633
|
+
imports: 0,
|
|
634
|
+
notAnalyzed: [{ file: sourceArtifactPath, reason: "digest mismatch" }],
|
|
635
|
+
blindSpots: [],
|
|
636
|
+
notes: [],
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
return {
|
|
640
|
+
status: "no-verdict",
|
|
641
|
+
catalog,
|
|
642
|
+
report: {
|
|
643
|
+
text: `Digest mismatch: catalog says ${rule.sha256}, file is ${computedSha256}\n`,
|
|
644
|
+
json: renderJson(
|
|
645
|
+
jsonEnvelope({
|
|
646
|
+
command: "rules add",
|
|
647
|
+
context,
|
|
648
|
+
status: "no-verdict",
|
|
649
|
+
exitCode: 3,
|
|
650
|
+
coverage,
|
|
651
|
+
result: { catalog: catalogPath, rule: rule.name },
|
|
652
|
+
}),
|
|
653
|
+
),
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Determine output directory
|
|
659
|
+
const outputDir = options.to ? resolve(cwd, options.to) : resolve(cwd, DEFAULT_RULES_DIR);
|
|
660
|
+
|
|
661
|
+
// The final name is catalog-derived — `ruleName` matched a catalog entry —
|
|
662
|
+
// so it is contained to the directory `--to` names, the write-side guard the
|
|
663
|
+
// report writer runs (`../../cli.mjs`). The target is resolved ONCE here and
|
|
664
|
+
// the identical string feeds the check and the write below. The lexical half
|
|
665
|
+
// runs before the directory is created, so a refused run creates nothing;
|
|
666
|
+
// when `--to` does not exist yet there is nothing planted for the symlink
|
|
667
|
+
// probe to walk, and `entryPathViolation` says so by returning `null`.
|
|
668
|
+
const targetArtifactPath = resolve(outputDir, `${ruleName}.wasm`);
|
|
669
|
+
const writeRefusal = entryPathViolation(outputDir, targetArtifactPath, { forWrite: true });
|
|
670
|
+
if (writeRefusal !== null) {
|
|
671
|
+
const coverage = {
|
|
672
|
+
complete: false,
|
|
673
|
+
projects: 0,
|
|
674
|
+
analyzedFiles: 1,
|
|
675
|
+
imports: 0,
|
|
676
|
+
notAnalyzed: [{ file: targetArtifactPath, reason: writeRefusal }],
|
|
677
|
+
blindSpots: [],
|
|
678
|
+
notes: [],
|
|
679
|
+
};
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
status: "no-verdict",
|
|
683
|
+
catalog,
|
|
684
|
+
report: {
|
|
685
|
+
text: `Rule '${ruleName}' refused: ${writeRefusal}\n`,
|
|
686
|
+
json: renderJson(
|
|
687
|
+
jsonEnvelope({
|
|
688
|
+
command: "rules add",
|
|
689
|
+
context,
|
|
690
|
+
status: "no-verdict",
|
|
691
|
+
exitCode: 3,
|
|
692
|
+
coverage,
|
|
693
|
+
result: { catalog: catalogPath, rule: rule.name },
|
|
694
|
+
}),
|
|
695
|
+
),
|
|
696
|
+
},
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Create directory if needed
|
|
701
|
+
if (!existsSync(outputDir)) {
|
|
702
|
+
mkdirSync(outputDir, { recursive: true });
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// Copy artifact bytes
|
|
706
|
+
writeFileSync(targetArtifactPath, artifactBytes);
|
|
707
|
+
|
|
708
|
+
// Generate the customRules row. `artifact` names where the bytes were just
|
|
709
|
+
// copied — workspace-root-relative with forward slashes, the way a row's
|
|
710
|
+
// `artifact` resolves — not the bare filename the destination ends with.
|
|
711
|
+
// `params` is left out on purpose: the catalog's `params` field is the
|
|
712
|
+
// parameter SCHEMA, not values, and values are the workspace's law to
|
|
713
|
+
// choose — the same reason `../custom-rules/evidence.mjs` keeps an absent
|
|
714
|
+
// `params` absent rather than defaulting one onto the declared row. The
|
|
715
|
+
// printout points at `rules info` for the schema.
|
|
716
|
+
const artifactPath = relative(cwd, targetArtifactPath).split(sep).join("/");
|
|
717
|
+
const customRulesRow = {
|
|
718
|
+
name: rule.name,
|
|
719
|
+
artifact: artifactPath,
|
|
720
|
+
sha256: rule.sha256,
|
|
721
|
+
reason: "<fill this in>",
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
const rowJson = JSON.stringify(customRulesRow, null, 2);
|
|
725
|
+
|
|
726
|
+
const paramsHint =
|
|
727
|
+
Object.keys(rule.params || {}).length > 0
|
|
728
|
+
? `This rule declares parameters the row leaves out — ` +
|
|
729
|
+
`\`archkeep rules info ${rule.name}\` shows the schema; add the values it ` +
|
|
730
|
+
`requires, since a row without them answers \`unknown\` at check time.\n`
|
|
731
|
+
: "";
|
|
732
|
+
|
|
733
|
+
const text =
|
|
734
|
+
`Rule "${rule.name}" added to workspace\n` +
|
|
735
|
+
`Artifact copied to: ${targetArtifactPath}\n\n` +
|
|
736
|
+
`Add this row to your boundary config under customRules:\n` +
|
|
737
|
+
`${rowJson}\n\n` +
|
|
738
|
+
paramsHint +
|
|
739
|
+
`For .mjs/.js module configs, paste this row and set a real reason.\n` +
|
|
740
|
+
`For ESLint flat configs, the flat config dialect does not support customRules — ` +
|
|
741
|
+
`add a module-boundaries.config.mjs to your workspace instead.\n`;
|
|
742
|
+
|
|
743
|
+
const coverage = {
|
|
744
|
+
complete: true,
|
|
745
|
+
projects: 0,
|
|
746
|
+
analyzedFiles: 2,
|
|
747
|
+
imports: 0,
|
|
748
|
+
notAnalyzed: [],
|
|
749
|
+
blindSpots: [],
|
|
750
|
+
notes: [],
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
return {
|
|
754
|
+
status: "ok",
|
|
755
|
+
catalog,
|
|
756
|
+
report: {
|
|
757
|
+
text,
|
|
758
|
+
json: renderJson(
|
|
759
|
+
jsonEnvelope({
|
|
760
|
+
command: "rules add",
|
|
761
|
+
context,
|
|
762
|
+
status: "ok",
|
|
763
|
+
exitCode: 0,
|
|
764
|
+
coverage,
|
|
765
|
+
result: {
|
|
766
|
+
catalog: catalogPath,
|
|
767
|
+
rule: rule.name,
|
|
768
|
+
artifactPath: targetArtifactPath,
|
|
769
|
+
customRulesRow,
|
|
770
|
+
},
|
|
771
|
+
}),
|
|
772
|
+
),
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
}
|