@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.
Files changed (46) hide show
  1. package/README.md +3 -3
  2. package/cli.mjs +126 -4
  3. package/commands.mjs +6 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +6 -2
  6. package/src/analysis/analyze.mjs +15 -0
  7. package/src/analysis/contract.md +36 -18
  8. package/src/analysis/csharp.mjs +485 -0
  9. package/src/analysis/dotnet/csproj.mjs +380 -0
  10. package/src/analysis/dotnet/mask.mjs +178 -0
  11. package/src/analysis/dotnet/namespaces.mjs +172 -0
  12. package/src/analysis/dotnet/resolve.mjs +89 -0
  13. package/src/analysis/go.mjs +289 -5
  14. package/src/analysis/java.mjs +329 -0
  15. package/src/analysis/jvm/gradle.mjs +545 -0
  16. package/src/analysis/jvm/mask.mjs +170 -0
  17. package/src/analysis/jvm/maven.mjs +612 -0
  18. package/src/analysis/jvm/packages.mjs +209 -0
  19. package/src/analysis/jvm/resolve.mjs +139 -0
  20. package/src/analysis/kotlin.mjs +210 -0
  21. package/src/analysis/manifest-util.mjs +30 -0
  22. package/src/analysis/python.mjs +3 -2
  23. package/src/analysis/registry.mjs +11 -0
  24. package/src/analysis/rust.mjs +171 -17
  25. package/src/analysis/source-util.mjs +155 -6
  26. package/src/analysis/typescript.mjs +9 -2
  27. package/src/commands/context.mjs +84 -14
  28. package/src/commands/provenance.mjs +7 -44
  29. package/src/commands/rules.mjs +775 -0
  30. package/src/governance/profile-registry.mjs +0 -1
  31. package/src/graph/create-dependencies.mjs +138 -15
  32. package/src/lsp/diagnose.mjs +1 -1
  33. package/src/lsp/server.mjs +97 -1
  34. package/src/lsp/workspace-index.mjs +106 -15
  35. package/src/options.mjs +30 -7
  36. package/src/process.mjs +10 -1
  37. package/src/providers/moon.mjs +287 -36
  38. package/src/providers/native/differential.fixtures.mjs +32 -6
  39. package/src/providers/native/discover.mjs +83 -4
  40. package/src/providers/native/graph.mjs +58 -0
  41. package/src/providers/native/model.mjs +59 -1
  42. package/src/rules/index.mjs +21 -6
  43. package/src/rules/reachability.mjs +2 -0
  44. package/src/rules/tags.mjs +7 -5
  45. package/src/rules/topology.mjs +5 -3
  46. 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
+ }