@ecoma-io/archkeep 0.14.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 (68) hide show
  1. package/README.md +11 -5
  2. package/cli.mjs +571 -61
  3. package/commands.mjs +57 -0
  4. package/lsp.mjs +15 -2
  5. package/package.json +8 -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 +11 -3
  27. package/src/commands/README.md +52 -1
  28. package/src/commands/change-intent.mjs +461 -0
  29. package/src/commands/change.mjs +612 -0
  30. package/src/commands/check.mjs +2 -1
  31. package/src/commands/context.mjs +124 -16
  32. package/src/commands/custom-rules.mjs +286 -2
  33. package/src/commands/delta-classify.mjs +195 -33
  34. package/src/commands/delta-snapshot.mjs +156 -1
  35. package/src/commands/delta.mjs +142 -17
  36. package/src/commands/diff.mjs +41 -13
  37. package/src/commands/evolution.mjs +473 -0
  38. package/src/commands/history.mjs +130 -103
  39. package/src/commands/policy.mjs +57 -0
  40. package/src/commands/provenance.mjs +7 -44
  41. package/src/commands/rules.mjs +775 -0
  42. package/src/commands/trajectory.mjs +437 -0
  43. package/src/governance/profile-registry.mjs +0 -1
  44. package/src/graph/create-dependencies.mjs +138 -15
  45. package/src/lsp/diagnose.mjs +1 -1
  46. package/src/lsp/server.mjs +97 -1
  47. package/src/lsp/workspace-index.mjs +106 -15
  48. package/src/options.mjs +30 -7
  49. package/src/path-util.mjs +40 -0
  50. package/src/process.mjs +10 -1
  51. package/src/providers/moon.mjs +287 -36
  52. package/src/providers/native/differential.fixtures.mjs +32 -6
  53. package/src/providers/native/discover.mjs +83 -4
  54. package/src/providers/native/graph.mjs +58 -0
  55. package/src/providers/native/model.mjs +59 -1
  56. package/src/report/change-text.mjs +148 -0
  57. package/src/report/delta-text.mjs +82 -1
  58. package/src/report/evolution-text.mjs +83 -0
  59. package/src/report/history-text.mjs +4 -114
  60. package/src/report/sarif.mjs +255 -0
  61. package/src/report/snapshot-text.mjs +123 -0
  62. package/src/report/trajectory-text.mjs +143 -0
  63. package/src/rules/index.mjs +21 -6
  64. package/src/rules/reachability.mjs +2 -0
  65. package/src/rules/tags.mjs +7 -5
  66. package/src/rules/topology.mjs +5 -3
  67. package/src/tsconfig-paths.mjs +3 -2
  68. package/src/workspace.mjs +115 -23
@@ -19,8 +19,9 @@
19
19
  * Unlike `diff` — which compares two GRAPH snapshots edge by edge and never
20
20
  * exits 1 — `delta` is a gate: a non-waived introduced violation is a finding
21
21
  * (exit 1), which is the whole point of carrying re-judgeable evidence rather
22
- * than a graph. That makes `delta` the third verb whose verdict carries
23
- * exit 1, beside `check` and `fitness`.
22
+ * than a graph. That made `delta` the third verb whose verdict carries
23
+ * exit 1, beside `check` and `fitness`; `./change.mjs` later became the
24
+ * fourth, over a different question — declared intent versus observed delta.
24
25
  *
25
26
  * Refusals (each a throw, exit 3 upstream — a delta that could not honestly
26
27
  * classify must never read as "no change"):
@@ -48,12 +49,15 @@
48
49
  import { createRequire } from "node:module";
49
50
 
50
51
  import { isWholeFileFailure } from "../analysis/source-util.mjs";
52
+ import { stripTrailingSlashes } from "../path-util.mjs";
51
53
  import { referenceTime } from "../governance/clock.mjs";
52
54
  import { jsonEnvelope, renderJson } from "../report/json.mjs";
53
55
  import { buildDecision } from "../report/evidence.mjs";
54
56
  import { formatDeltaReport } from "../report/delta-text.mjs";
57
+ import { formatDeltaSarif } from "../report/sarif.mjs";
55
58
  import { evaluateRun } from "../rules/index.mjs";
56
- import { classifyDelta } from "./delta-classify.mjs";
59
+ import { customRulesForDelta, declaresCustomRules } from "./custom-rules.mjs";
60
+ import { classifyCustomFindings, classifyDelta } from "./delta-classify.mjs";
57
61
  import {
58
62
  buildEvidenceSnapshot,
59
63
  providerMismatch,
@@ -72,11 +76,17 @@ const { name: TOOL_NAME, version: TOOL_VERSION } = require("../../package.json")
72
76
  * Refuses the two head states no delta side may be built over, shared by both
73
77
  * modes: the unregistered-plugin graph and incomplete analysis coverage.
74
78
  *
79
+ * Exported since `change` arrived because that command builds its comparison
80
+ * over the same two head states — a graph that under-represents the tree and
81
+ * an analysis with holes would reconcile a declaration against architecture
82
+ * nobody observed — and a second copy of the refusal is where the two
83
+ * commands would drift into answering "may this head be judged?" differently.
84
+ *
75
85
  * @param {object} commandContext From `resolveCommandContext`.
76
86
  * @param {string} activity Which mode is refusing, for the message.
77
87
  * @throws {Error} on either condition.
78
88
  */
79
- function refuseUnjudgeableHead(commandContext, activity) {
89
+ export function refuseUnjudgeableHead(commandContext, activity) {
80
90
  const { provider, pluginGap } = commandContext;
81
91
  if (provider === "nx" && !pluginGap.registered && pluginGap.manifests.length > 0) {
82
92
  throw new Error(
@@ -123,6 +133,12 @@ export function captureDelta(commandContext, { config }) {
123
133
  }
124
134
  const { root, provider, graph, analysis } = commandContext;
125
135
  const snapshot = buildEvidenceSnapshot({
136
+ // The two optional custom-rule blocks, stored exactly when the capturing
137
+ // policy declares rules — an undeclaring workspace's snapshot stays
138
+ // byte-identical (`./delta-snapshot.mjs`, the optional-blocks section).
139
+ ...(declaresCustomRules(config)
140
+ ? { customRules: config.customRules, owned: commandContext.owned }
141
+ : {}),
126
142
  tool: { name: TOOL_NAME, version: TOOL_VERSION },
127
143
  provenance: resolveProvenance(root),
128
144
  provider,
@@ -223,11 +239,16 @@ export function evidenceGraphToProjectGraph(storedGraph) {
223
239
  * head does not already claim — so a base-side record living in a directory
224
240
  * the head no longer has still attributes to the project that owned it.
225
241
  *
242
+ * Exported for `./change.mjs`, which classifies the same two evidence sets
243
+ * through `./delta-classify.mjs` and must attribute unresolvable records the
244
+ * same way a delta does — a second attribution rule beside this one is how
245
+ * the two commands would disagree about which project carried a site.
246
+ *
226
247
  * @param {object} headGraph The current run's graph (`nodes` map).
227
248
  * @param {object[]} baselineProjects The snapshot's stored project rows.
228
249
  * @returns {(record: object) => string|null}
229
250
  */
230
- function sourceProjectAttributor(headGraph, baselineProjects) {
251
+ export function sourceProjectAttributor(headGraph, baselineProjects) {
231
252
  /** @type {Map<string, string>} root → project name, head winning ties. */
232
253
  const byRoot = new Map();
233
254
  for (const node of Object.values(headGraph.nodes ?? {})) {
@@ -240,7 +261,7 @@ function sourceProjectAttributor(headGraph, baselineProjects) {
240
261
  }
241
262
  }
242
263
  const entries = [...byRoot.entries()]
243
- .map(([root, name]) => [root.replace(/\/+$/u, ""), name])
264
+ .map(([root, name]) => [stripTrailingSlashes(root), name])
244
265
  .sort((a, b) => b[0].length - a[0].length);
245
266
  return (record) => {
246
267
  const file = record?.sourceFile;
@@ -273,20 +294,36 @@ const short = (fingerprint) =>
273
294
  * a tracked acceptance, not a fix — but do not fail the gate, which is what
274
295
  * a waiver is for.
275
296
  *
297
+ * Custom-rule (wasm) findings join the classification when either side
298
+ * declares them: `./custom-rules.mjs`'s `customRulesForDelta` judges every
299
+ * head-declared rule over both evidence sets, `./delta-classify.mjs`'s
300
+ * `classifyCustomFindings` buckets the findings, and the result rides the
301
+ * envelope as `result.customRules` — a block that is ABSENT (never `null`)
302
+ * when neither side declares any, so an undeclaring workspace's envelope
303
+ * stays byte-identical. An introduced custom finding gates exactly as an
304
+ * introduced violation does, with no waiver lane by construction
305
+ * (suppressions key on a `messageId` custom findings do not have); an
306
+ * unclassifiable one is a no-verdict. This is also why the function is async:
307
+ * the wasm host is.
308
+ *
276
309
  * @param {string} baselinePath Absolute path to the evidence snapshot.
277
310
  * @param {object} commandContext From `resolveCommandContext`.
278
311
  * @param {{config: object|null, readBaseline?: (path: string) => object,
279
- * now?: string}} io The resolved boundary config (required both sides
280
- * are re-judged under it), an injectable baseline reader, and the one
281
- * shared reference instant (defaults to the shared governance clock).
282
- * @returns {{status: "ok"|"findings"|"no-verdict", delta: object,
283
- * coverage: object, report: {text: string, json: string}}}
284
- * @throws {Error} on every refusal the module header lists.
312
+ * now?: string, readArtifact?: (artifact: string) => Uint8Array|null,
313
+ * timeoutMs?: number}} io The resolved boundary config (required
314
+ * both sides are re-judged under it), an injectable baseline reader, the
315
+ * one shared reference instant (defaults to the shared governance clock),
316
+ * and the custom-rule host's two injectable seams, passed through to
317
+ * `customRulesForDelta`.
318
+ * @returns {Promise<{status: "ok"|"findings"|"no-verdict", delta: object,
319
+ * coverage: object, report: {text: string, json: string, sarif: string}}>}
320
+ * @throws {Error} on every refusal the module header lists, and on a
321
+ * custom-rule LOAD failure (`./custom-rules.mjs` argues the split).
285
322
  */
286
- export function deltaCommand(
323
+ export async function deltaCommand(
287
324
  baselinePath,
288
325
  commandContext,
289
- { config, readBaseline = readEvidenceSnapshot, now = referenceTime() },
326
+ { config, readBaseline = readEvidenceSnapshot, now = referenceTime(), ...customRuleIo },
290
327
  ) {
291
328
  const { root, provider, marker, graph, analysis } = commandContext;
292
329
 
@@ -378,23 +415,93 @@ export function deltaCommand(
378
415
  );
379
416
  }
380
417
 
418
+ // The custom-rule half, present exactly when a side declares rules: judged
419
+ // two-sided where the law is identical, `unknown` with a mandatory reason
420
+ // everywhere else (`./custom-rules.mjs`'s `customRulesForDelta` owns the
421
+ // routes). `null` when NEITHER side declares any — the envelope block and
422
+ // the summary key are then absent, and an undeclaring workspace's envelope
423
+ // stays byte-identical (`../../../../AGENTS.md`, "a change to what is
424
+ // reported on an unchanged workspace is a breaking change").
425
+ /** @type {{judged: object[], skipped: object[], removed: string[],
426
+ * findings: {introduced: object[], resolved: object[], unchanged: object[],
427
+ * unknown: object[]}}|null} */
428
+ let custom = null;
429
+ // The head-declared finding catalogue, held for the SARIF face alone: the
430
+ // envelope deliberately does not carry it (the JSON contract predates the
431
+ // SARIF face and must stay byte-identical), while `sarifRules` needs it so
432
+ // an introduced custom finding's `ruleId` resolves to a descriptor.
433
+ /** @type {{ruleId: string, rule: string, findingId: string, message: string}[]} */
434
+ let customCatalogue = [];
435
+ if (declaresCustomRules(config)) {
436
+ const twoSided = await customRulesForDelta(commandContext, {
437
+ rows: config.customRules,
438
+ policy: config,
439
+ baseline,
440
+ ...customRuleIo,
441
+ });
442
+ customCatalogue = twoSided.catalogue;
443
+ custom = {
444
+ judged: twoSided.judged.map(({ name, sha256, notes: ruleNotes }) => ({
445
+ name,
446
+ sha256,
447
+ ...(ruleNotes === undefined ? {} : { notes: ruleNotes }),
448
+ })),
449
+ skipped: twoSided.unknownRules,
450
+ removed: twoSided.removedRules,
451
+ findings: classifyCustomFindings({
452
+ judged: twoSided.judged,
453
+ unknownRules: twoSided.unknownRules,
454
+ }),
455
+ };
456
+ } else if (baseline.customRules !== undefined) {
457
+ // The head declares nothing, so there is no law to judge either side
458
+ // under — every baseline rule is a removal, disclosed rather than judged.
459
+ custom = {
460
+ judged: [],
461
+ skipped: [],
462
+ removed: baseline.customRules.map((row) => row.name),
463
+ findings: { introduced: [], resolved: [], unchanged: [], unknown: [] },
464
+ };
465
+ }
466
+ if (custom !== null) {
467
+ for (const skipped of custom.skipped) {
468
+ notes.push(`custom rule "${skipped.name}" was not classified — ${skipped.reason}`);
469
+ }
470
+ for (const name of custom.removed) {
471
+ notes.push(
472
+ `custom rule "${name}" is declared in the baseline but not by the current policy — ` +
473
+ `nothing was judged for it, so its base-side findings are not classified as resolved`,
474
+ );
475
+ }
476
+ for (const rule of custom.judged) {
477
+ for (const note of rule.notes ?? []) {
478
+ notes.push(`custom rule "${rule.name}": ${note}`);
479
+ }
480
+ }
481
+ }
482
+
381
483
  const { violations, unresolvable } = classification;
382
484
  const introducedWaived = violations.introduced.filter((entry) => entry.waived === true).length;
383
485
  const introducedNotWaived = violations.introduced.length - introducedWaived;
384
- const unknownCount = violations.unknown.length + unresolvable.unknown.length;
486
+ // Custom findings have no waiver lane (`./delta-classify.mjs`'s
487
+ // `classifyCustomFindings` argues the by-construction absence), so every
488
+ // introduced one gates.
489
+ const customIntroduced = custom === null ? 0 : custom.findings.introduced.length;
490
+ const customUnknown = custom === null ? 0 : custom.findings.unknown.length;
491
+ const unknownCount = violations.unknown.length + unresolvable.unknown.length + customUnknown;
385
492
 
386
493
  /** @type {"ok"|"findings"|"no-verdict"} */
387
494
  let status;
388
495
  /** @type {0|1|3} */
389
496
  let exitCode;
390
497
  let decision;
391
- if (introducedNotWaived > 0) {
498
+ if (introducedNotWaived + customIntroduced > 0) {
392
499
  status = "findings";
393
500
  exitCode = 1;
394
501
  decision = buildDecision({
395
502
  status,
396
503
  coverageComplete: true,
397
- findings: introducedNotWaived,
504
+ findings: introducedNotWaived + customIntroduced,
398
505
  });
399
506
  } else if (unknownCount > 0) {
400
507
  status = "no-verdict";
@@ -405,6 +512,9 @@ export function deltaCommand(
405
512
  findings: 0,
406
513
  reason:
407
514
  `${unknownCount} delta item${unknownCount === 1 ? "" : "s"} could not be classified — ` +
515
+ (customUnknown > 0
516
+ ? `${customUnknown} of them custom-rule item${customUnknown === 1 ? "" : "s"} — `
517
+ : "") +
408
518
  `an item whose identity cannot be stated is never guessed into a bucket`,
409
519
  });
410
520
  } else {
@@ -454,9 +564,20 @@ export function deltaCommand(
454
564
  unchanged: unresolvable.unchanged.length,
455
565
  unknown: unresolvable.unknown.length,
456
566
  },
567
+ ...(custom === null
568
+ ? {}
569
+ : {
570
+ customFindings: {
571
+ introduced: custom.findings.introduced.length,
572
+ resolved: custom.findings.resolved.length,
573
+ unchanged: custom.findings.unchanged.length,
574
+ unknown: custom.findings.unknown.length,
575
+ },
576
+ }),
457
577
  },
458
578
  violations,
459
579
  unresolvable,
580
+ ...(custom === null ? {} : { customRules: custom }),
460
581
  };
461
582
 
462
583
  const envelope = jsonEnvelope({
@@ -476,6 +597,10 @@ export function deltaCommand(
476
597
  report: {
477
598
  text: formatDeltaReport({ delta: result, coverage }),
478
599
  json: renderJson(envelope),
600
+ // Eager beside the other two faces: the render is pure and cheap, and a
601
+ // lazy face is one a caller can forget to build — the SARIF is the same
602
+ // verdict, ready whichever face `--format` selects.
603
+ sarif: formatDeltaSarif({ delta: result, coverage, customCatalogue }),
479
604
  },
480
605
  };
481
606
  }
@@ -216,13 +216,29 @@ function buildHeadSnapshot(commandContext) {
216
216
  };
217
217
  }
218
218
 
219
+ /**
220
+ * The identity key of one dependency record: the `(source, target, type)`
221
+ * triple joined with `\0`. Extracted from `computeDiff`'s index maps so a
222
+ * second consumer (`./trajectory.mjs`'s persistence sets) keys edges the SAME
223
+ * way the diff does — a second spelling of this string would be a second
224
+ * definition of "same edge", and two definitions drift.
225
+ *
226
+ * A `static` edge becoming `dynamic` is therefore a removed edge under the
227
+ * old type and an added edge under the new one, which is exactly what a
228
+ * consumer wants to see: it is a real architectural event, not an
229
+ * implementation detail.
230
+ *
231
+ * @param {{source: string, target: string, type: string}} edge
232
+ * @returns {string}
233
+ */
234
+ export function edgeIdentityKey({ source, target, type }) {
235
+ return `${source}\0${target}\0${type}`;
236
+ }
237
+
219
238
  /**
220
239
  * Computes the diff between two graph snapshots.
221
240
  *
222
- * Edge identity is `(source, target, type)` — a `static` edge becoming
223
- * `dynamic` is an added edge under the new type and a removed edge under the
224
- * old one, which is exactly what a consumer wants to see: it is a real
225
- * architectural event, not an implementation detail.
241
+ * Edge identity is `(source, target, type)` — see `edgeIdentityKey` above.
226
242
  *
227
243
  * @param {{projects: object[], dependencies: object[]}} baseline
228
244
  * @param {{projects: object[], dependencies: object[]}} head
@@ -282,13 +298,9 @@ export function computeDiff(baseline, head) {
282
298
  removedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
283
299
  changedProjects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
284
300
 
285
- // Index edges by the (source, target, type) triple the identity key.
286
- const baselineEdges = new Map(
287
- baseline.dependencies.map((e) => [`${e.source}\0${e.target}\0${e.type}`, e]),
288
- );
289
- const headEdges = new Map(
290
- head.dependencies.map((e) => [`${e.source}\0${e.target}\0${e.type}`, e]),
291
- );
301
+ // Index edges by the identity key `edgeIdentityKey` above owns the triple.
302
+ const baselineEdges = new Map(baseline.dependencies.map((e) => [edgeIdentityKey(e), e]));
303
+ const headEdges = new Map(head.dependencies.map((e) => [edgeIdentityKey(e), e]));
292
304
 
293
305
  const addedEdges = [];
294
306
  const removedEdges = [];
@@ -410,8 +422,11 @@ export function diffCommand(
410
422
  // classifies as changes, compared here through the shared
411
423
  // `./snapshot-meta.mjs` so the two commands cannot disagree about them.
412
424
  // Each mismatch becomes a `coverage.notes` warning rather than a refusal:
413
- // a provider migration, a cross-repository diff, or a policy change between
414
- // baseline and head are all legitimate states a consumer must be told about.
425
+ // a provider migration, a cross-repository diff, a dirty-tree side, or a
426
+ // policy change between baseline and head are all legitimate states a
427
+ // consumer must be told about. The dirty-tree wording is the one `delta` and
428
+ // `change` emit over the same metadata — three commands, one sentence each,
429
+ // so a consumer reading any report reads the same caveat.
415
430
  const headProvenance = resolveProvenance(root);
416
431
  const headFingerprint = config ? computePolicyFingerprint(config) : null;
417
432
  const meta = compareSnapshotMetadata({
@@ -445,6 +460,19 @@ export function diffCommand(
445
460
  );
446
461
  }
447
462
 
463
+ if (meta.dirtyBaseline) {
464
+ coverage.notes.push(
465
+ "the baseline was captured from a dirty working tree — its evidence is not a reproducible " +
466
+ "claim about the commit it names",
467
+ );
468
+ }
469
+ if (meta.dirtyHead) {
470
+ coverage.notes.push(
471
+ "this run's working tree is dirty — the head side describes uncommitted state, not the " +
472
+ "commit HEAD names",
473
+ );
474
+ }
475
+
448
476
  if (meta.policyChanged === true) {
449
477
  // A policy change between baseline and head means every "introduced" or
450
478
  // "resolved" violation in the rule-impact analysis may be an artefact of