@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
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { existsSync } from "node:fs";
31
- import { delimiter, join } from "node:path";
31
+ import { delimiter, join, posix } from "node:path";
32
32
 
33
33
  import { environmentForTree, runProcess } from "../process.mjs";
34
34
  import { buildDependencies } from "./native/graph.mjs";
@@ -55,6 +55,30 @@ export const MOON_DIR = ".moon";
55
55
  */
56
56
  export const MOON_ALT_DIR = ".config/moon";
57
57
 
58
+ /**
59
+ * The file that makes a Moon directory a workspace-root marker for the walk
60
+ * (`../workspace.mjs`'s `findWorkspaceRoot`): `workspace.yml`, the one file
61
+ * moonrepo's own documentation marks "required" of a workspace. The directory
62
+ * alone is NOT a marker — `~/.moon` exists on every machine moonrepo has ever
63
+ * run on, as moonrepo's user-level state (its documentation puts the shared
64
+ * cache at `~/.moon/cache/shared`), and a walk that treated directory
65
+ * presence as enough selected the user's home directory as a "Moon
66
+ * workspace" (#339). These constants name the FILE so the walk's marker list
67
+ * (`../commands/context.mjs`'s `WORKSPACE_MARKERS`) holds the shaped marker.
68
+ *
69
+ * The provider gate (`moonMarkerAt` below) keeps reading directory presence,
70
+ * deliberately: given a root the walk already chose, a `.moon` directory
71
+ * beside an `nx.json` is two project models at one root whether or not the
72
+ * `.moon` is shaped — a decision nobody made — and refusing that stays loud.
73
+ */
74
+ export const MOON_WORKSPACE_MARKER = posix.join(MOON_DIR, "workspace.yml");
75
+
76
+ /**
77
+ * The v2 spelling of {@link MOON_WORKSPACE_MARKER}, under
78
+ * {@link MOON_ALT_DIR} — same shape, same reason.
79
+ */
80
+ export const MOON_ALT_WORKSPACE_MARKER = posix.join(MOON_ALT_DIR, "workspace.yml");
81
+
58
82
  /**
59
83
  * Which Moon directory marks `root` as a Moonrepo workspace — `.moon/`,
60
84
  * `.config/moon/`, or neither.
@@ -367,20 +391,93 @@ function deriveTags(projectNode) {
367
391
  return [...tags].sort();
368
392
  }
369
393
 
394
+ /**
395
+ * A Moon project `source`'s canonical workspace-relative root, or the reason
396
+ * it cannot have one.
397
+ *
398
+ * `node.source` is the only spelling of a project's location this provider
399
+ * gets, and every consumer downstream answers by comparing it against
400
+ * workspace-relative tracked paths — `../analysis/source-util.mjs`'s
401
+ * `projectOwning` matches `path.startsWith(`${root}/`)`, byte for byte. The
402
+ * other two providers already hold that discipline: `../workspace.mjs`
403
+ * normalises Nx's `"."` to `""` at ingestion, and
404
+ * `./native/model.mjs`'s `declaredProjectViolations` refuses `\`,
405
+ * leading/trailing slashes and `.`/`..` segments at config load. This
406
+ * function is the Moon provider's copy of it, applied where Moon's output is
407
+ * ingested:
408
+ *
409
+ * - **normalised** — spellings that provably name the same directory a
410
+ * canonical one would: `""` and `"."` (the root project, which Moon has
411
+ * spelled both ways) become `""`; a leading `./` and any trailing `/` are
412
+ * dropped, so `"./apps/web/"` reads as `"apps/web"`.
413
+ * - **refused** — everything else, returned as a named problem rather than
414
+ * rewritten or kept: a backslash (paths are matched posix-style regardless
415
+ * of platform), a leading `/` (absolute — a directory outside the
416
+ * workspace), a `.`/`..` segment, an empty `//` segment, or a non-string.
417
+ * A root that matches no tracked path's spelling makes its project own
418
+ * nothing, silently — `projectOwning` cannot report a miss it structurally
419
+ * cannot see (#367) — so the spelling is refused loudly instead.
420
+ *
421
+ * @param {unknown} source A project node's `source` from
422
+ * `moon project-graph --json`.
423
+ * @returns {{root: string, problem: null}|{root: null, problem: string}}
424
+ */
425
+ function canonicalMoonRoot(source) {
426
+ if (typeof source !== "string") {
427
+ return {
428
+ root: null,
429
+ problem: `must be a string, got ${typeof source} (${JSON.stringify(source) ?? String(source)})`,
430
+ };
431
+ }
432
+ let spelling = source;
433
+ while (spelling.startsWith("./")) spelling = spelling.slice(2);
434
+ while (spelling.endsWith("/")) spelling = spelling.slice(0, -1);
435
+ if (spelling === "" || spelling === ".") return { root: "", problem: null };
436
+ if (spelling.includes("\\")) {
437
+ return {
438
+ root: null,
439
+ problem:
440
+ `'${source}' must use forward slashes — this tool matches paths posix-style ` +
441
+ "regardless of the platform the tree is checked out on",
442
+ };
443
+ }
444
+ if (spelling.startsWith("/")) {
445
+ return {
446
+ root: null,
447
+ problem: `'${source}' must be workspace-relative — a leading slash names a directory outside the workspace`,
448
+ };
449
+ }
450
+ for (const segment of spelling.split("/")) {
451
+ if (segment === "." || segment === "..") {
452
+ return {
453
+ root: null,
454
+ problem: `'${source}' must be a canonical path — no '.' or '..' segment`,
455
+ };
456
+ }
457
+ if (segment === "") {
458
+ return {
459
+ root: null,
460
+ problem: `'${source}' must be a canonical path — no empty '//' segment`,
461
+ };
462
+ }
463
+ }
464
+ return { root: spelling, problem: null };
465
+ }
466
+
370
467
  /**
371
468
  * Infers `workspaceLayout` from the source paths of Moon's projects.
372
469
  *
373
470
  * Moon does not declare `appsDir`/`libsDir` — there is no `nx.json`-style
374
- * `workspaceLayout` key. This function examines each project's `source`
375
- * (workspace-relative root) and checks for a common directory prefix shared
376
- * by all projects of the same `layer`:
377
- * - `application`-layer projects whose sources all share a prefix → `appsDir`
378
- * - `library`-layer projects whose sources all share a prefix → `libsDir`
471
+ * `workspaceLayout` key. This function examines each project's canonical
472
+ * root (`canonicalMoonRoot`'s answer, not Moon's raw spelling) and checks for
473
+ * a common directory prefix shared by all projects of the same `layer`:
474
+ * - `application`-layer projects whose roots all share a prefix → `appsDir`
475
+ * - `library`-layer projects whose roots all share a prefix → `libsDir`
379
476
  *
380
- * A project at the workspace ROOT contributes to neither. Its `source` is
381
- * `"."` (or, on some Moon versions, `""`), whose top segment names no
382
- * directory the workspace keeps apps or libs in — see the guard in the loop
383
- * below for what inferring one from it would do.
477
+ * A project at the workspace ROOT contributes to neither: its canonical root
478
+ * is `""` (whatever spelling Moon used for it), which names no directory the
479
+ * workspace keeps apps or libs in — see the guard in the loop below for what
480
+ * inferring one from it would do.
384
481
  *
385
482
  * A prefix is "shared" when every project of that layer starts with the same
386
483
  * top-level directory. If no consistent prefix exists, that key is omitted
@@ -395,27 +492,31 @@ function deriveTags(projectNode) {
395
492
  * is returned for the same reason.
396
493
  *
397
494
  * @param {object[]} projectNodes Project nodes from Moon's `data` map values.
495
+ * @param {Map<string, string>} rootById Each accepted project's canonical
496
+ * root, the map `transformMoonGraph` builds — the same spelling the built
497
+ * node carries in `data.root`, so the layout this infers and the graph it
498
+ * ships cannot disagree about where a project lives.
398
499
  * @returns {{appsDir: string, libsDir: string}|null}
399
500
  */
400
- function inferWorkspaceLayout(projectNodes) {
501
+ function inferWorkspaceLayout(projectNodes, rootById) {
401
502
  const appDirs = new Set();
402
503
  const libDirs = new Set();
403
504
  for (const node of projectNodes) {
404
- if (!node.source) continue;
405
- const topDir = node.source.split("/")[0];
406
- // A source that names no directory BELOW the workspace root contributes no
407
- // prefix. Moon spells the root project's source `"."`, and
408
- // `".".split("/")[0]` is `"."` — which infers `appsDir: "."` and makes
505
+ const root = rootById.get(node.id);
506
+ // `undefined` is a node the transform did not accept; `""` is the
507
+ // workspace root itself. Either way it names no directory BELOW the
508
+ // workspace root, and inferring `appsDir: "."` from one would make
409
509
  // `../rules/specifiers.mjs`'s `isAbsoluteImportIntoAnotherProject` test
410
510
  // `imp.startsWith("./")`: EVERY ordinary relative import in the workspace
411
511
  // reported as an absolute import into another project, from one
412
- // root-level `moon.yml`. The guard is on the resolved top SEGMENT rather
413
- // than on `source` itself, so every spelling that lands there — `"."`,
414
- // `"./"`, `"./apps/web"`, a leading `/` is covered by one test instead
415
- // of a list the next spelling escapes. Excluding a root project can only
416
- // leave a prefix set empty, which returns the incomplete layout `null`
417
- // above and falls back to the complete default: the loud direction.
418
- if (topDir === "" || topDir === "." || topDir === "..") continue;
512
+ // root-level `moon.yml`. Every other spelling has been refused by
513
+ // `canonicalMoonRoot` before it reaches here, so no `.`/`..`/empty top
514
+ // segment can land in a prefixone test on the canonical root covers
515
+ // the whole vocabulary. Excluding a root project can only leave a prefix
516
+ // set empty, which returns the incomplete layout `null` below and falls
517
+ // back to the complete default: the loud direction.
518
+ if (root === undefined || root === "") continue;
519
+ const topDir = root.split("/")[0];
419
520
  if (node.layer === "application") appDirs.add(topDir);
420
521
  else if (node.layer === "library") libDirs.add(topDir);
421
522
  }
@@ -455,8 +556,51 @@ function inferWorkspaceLayout(projectNodes) {
455
556
  * prototype rather than adding an entry — silent, and exactly the shape
456
557
  * `../../../AGENTS.md`'s invariant refuses.
457
558
  *
559
+ * **Two disciplines meet here, and both must refuse rather than drop.**
560
+ *
561
+ * **Anomalous output is refused loudly, never skipped** (#365) — the same
562
+ * posture `./native/discover.mjs` holds for the native provider's graph:
563
+ * collect every anomaly, throw once naming each. A `data` entry that is not a
564
+ * project node (no `id`, or no `source` at all), one project `id` claimed by
565
+ * two entries, and an edge or declared dependency naming a project the graph
566
+ * does not contain, would each drop a project or an edge from the graph
567
+ * silently — a graph judged over less than the tree, byte-for-byte identical
568
+ * to one that never had them. Two facts pin the boundaries of "anomalous":
569
+ *
570
+ * - Every `Project` Moon serializes carries a non-optional `id` and `source`
571
+ * (moon's `crates/project` source, read at @moonrepo/cli 2.5.3), and its
572
+ * graph is keyed by `id` — so an entry missing either field, or a duplicate
573
+ * `id`, did not come from a well-formed `moon project-graph`.
574
+ * - A root-level project's `source` is spelled `"."` on some Moon versions
575
+ * and `""` on others (moon's own `is_root_level_source`, in `crates/common`,
576
+ * accepts both — `"."` is the spelling 2.5.3 emits — and
577
+ * `inferWorkspaceLayout`'s header owns the pair). The empty spelling is a
578
+ * project root, not a missing one, and is accepted here — a falsy-source
579
+ * skip would vanish the root project from the graph on the versions that
580
+ * spell it that way.
581
+ *
582
+ * One anomaly class is reachable from ordinary configuration rather than
583
+ * malformed output, measured on @moonrepo/cli 2.5.3: a `moon.yml`
584
+ * `dependsOn:` naming a project that does not exist exits 0, keeps the record
585
+ * in the owning node's own `dependencies[]`, and drops it from
586
+ * `graph.edges`. Before #365 this provider dropped it too (`!nodes[target]`),
587
+ * vanishing a hand-declared edge — the #262 defect shape, reached through a
588
+ * typo'd id.
589
+ *
590
+ * On top of that, each node's `data.root` is the `source`'s CANONICAL
591
+ * spelling (`canonicalMoonRoot`, #367), never Moon's raw one — and a `source`
592
+ * with no canonical spelling (a backslash, a leading `/`, a `.`/`..`/empty
593
+ * `//` segment, a non-string) refuses the transform before any graph is
594
+ * built, because a root that matches no tracked path's spelling makes its
595
+ * project own nothing silently. Unlike an anomaly, which is collected and
596
+ * thrown with the rest at the end, this refusal is its own throw naming every
597
+ * offending project and spelling — a distinct failure class.
598
+ *
458
599
  * @param {object} raw The parsed JSON from `moon project-graph --json`.
459
600
  * @returns {{nodes: Record<string, object>, dependencies: Record<string, object[]>, workspaceLayout?: object}}
601
+ * @throws {Error} naming every anomaly at once when the payload carries any,
602
+ * and separately when any project's `source` cannot be read as a
603
+ * workspace-relative root, naming every offending project and spelling.
460
604
  */
461
605
  export function transformMoonGraph(raw) {
462
606
  if (!raw?.data) {
@@ -488,9 +632,15 @@ export function transformMoonGraph(raw) {
488
632
  // two loops below discover it.
489
633
  /** @type {Map<string, {source: string, target: string, type: string}>} */
490
634
  const edgesByPair = new Map();
635
+ // Both edge loops validate their endpoints BEFORE calling: membership in
636
+ // `nodes` is checked where a refusal can name the dropped edge, not in
637
+ // here where it could only drop it namelessly — the exact silent `return`
638
+ // this function used to carry, and the shape #365 exists to remove.
491
639
  const add = (source, target, type) => {
492
- if (!source || !target || source === target) return;
493
- if (!nodes[target]) return;
640
+ if (source === target) {
641
+ anomalies.push(`self-edge [${source}, ${target}] is not a dependency between projects`);
642
+ return;
643
+ }
494
644
  const key = JSON.stringify([source, target]);
495
645
  const existing = edgesByPair.get(key);
496
646
  if (existing) {
@@ -502,11 +652,60 @@ export function transformMoonGraph(raw) {
502
652
  (dependencies[source] ??= []).push(entry);
503
653
  };
504
654
 
655
+ // Every anomaly this provider refuses to judge over, collected across all
656
+ // three loops and thrown as ONE error — `./native/discover.mjs`'s
657
+ // collect-and-throw posture, so a payload carrying several defects names
658
+ // them all in a single refusal instead of failing one-per-run.
659
+ /** @type {string[]} */
660
+ const anomalies = [];
661
+
505
662
  // Build nodes from Moon's data map. Each entry is indexed by integer key
506
663
  // but identified by its `id` string — which becomes the Archkeep node key.
664
+ // An entry that is not a project node, or a second entry claiming an `id`
665
+ // already keyed, is an anomaly: skipping the first or letting the second
666
+ // overwrite it would judge the graph over one project fewer — the silent
667
+ // direction. See this function's header for the moon-source facts that
668
+ // bound "anomalous" (including why `source: ""` is not).
669
+ /** @type {Map<string, {key: string, source: string}>} */
670
+ const firstClaimOf = new Map();
507
671
  const projectNodes = Object.values(raw.data);
508
- for (const node of projectNodes) {
509
- if (!node.id || !node.source) continue;
672
+ /**
673
+ * Each accepted project's canonical workspace-relative root — the one
674
+ * spelling the built node, `inferWorkspaceLayout` and every consumer after
675
+ * them judge by (`canonicalMoonRoot`'s answer, never Moon's raw spelling).
676
+ *
677
+ * @type {Map<string, string>}
678
+ */
679
+ const rootById = new Map();
680
+ /** @type {string[]} */
681
+ const rootProblems = [];
682
+ for (const [key, node] of Object.entries(raw.data)) {
683
+ if (typeof node?.id !== "string" || node.id === "") {
684
+ anomalies.push(`data entry ${key} carries no id — it names no project`);
685
+ continue;
686
+ }
687
+ if (node?.source == null) {
688
+ anomalies.push(`data entry ${key} ('${node.id}') carries no source path`);
689
+ continue;
690
+ }
691
+ const first = firstClaimOf.get(node.id);
692
+ if (first) {
693
+ anomalies.push(
694
+ `data entries ${first.key} and ${key} both declare project '${node.id}' ` +
695
+ `(sources '${first.source}' and '${node.source}') — the second would overwrite the first`,
696
+ );
697
+ continue;
698
+ }
699
+ firstClaimOf.set(node.id, { key, source: node.source });
700
+ // #367 — the source is now a confirmed string: canonicalise it to the
701
+ // workspace-relative root, and refuse (as a rootProblem) any spelling
702
+ // that has no canonical form rather than let the project own nothing.
703
+ const canonical = canonicalMoonRoot(node.source);
704
+ if (canonical.problem !== null) {
705
+ rootProblems.push(` ${node.id}: ${canonical.problem}`);
706
+ continue;
707
+ }
708
+ rootById.set(node.id, canonical.root);
510
709
  const tags = deriveTags(node);
511
710
  // Nx's `implicitDependencies` is the hand-declared list — so it is Moon's
512
711
  // `"explicit"` dependencies that belong here, not its `"implicit"` ones.
@@ -519,7 +718,7 @@ export function transformMoonGraph(raw) {
519
718
  name: node.id,
520
719
  type: nodeTypeFromLayer(node.layer),
521
720
  data: {
522
- root: node.source,
721
+ root: canonical.root,
523
722
  tags,
524
723
  implicitDependencies: implicitDeps,
525
724
  ...(taskTargets.length > 0
@@ -539,14 +738,43 @@ export function transformMoonGraph(raw) {
539
738
  };
540
739
  }
541
740
 
542
- // Build dependencies from Moon's graph edges.
741
+ // A source with no canonical spelling refuses the whole transform, naming
742
+ // every project and every spelling: a root that matches no tracked path's
743
+ // spelling makes its project own nothing, silently (#367) — the forbidden
744
+ // direction. Downstream, this throw is the CLI's exit 3 and the language
745
+ // server's named index gap, never an empty-looking graph.
746
+ if (rootProblems.length > 0) {
747
+ throw new Error(
748
+ "archkeep: `moon project-graph --json` emitted project sources this tool " +
749
+ "cannot read as workspace-relative roots:\n" +
750
+ rootProblems.join("\n") +
751
+ "\nA project whose root matches no tracked path owns nothing, with nothing " +
752
+ "naming why — refusing instead.",
753
+ );
754
+ }
755
+
756
+ // Build dependencies from Moon's graph edges. The scope check runs FIRST:
757
+ // a "root"-scoped edge judges nothing (see `edgeTypeFromScope`), so what it
758
+ // names at either end is Moon's own bookkeeping and can never be a boundary
759
+ // this provider dropped.
543
760
  if (Array.isArray(raw.graph?.edges)) {
544
761
  for (const [srcIdx, tgtIdx, scope] of raw.graph.edges) {
545
- const srcNode = raw.data[String(srcIdx)];
546
- const tgtNode = raw.data[String(tgtIdx)];
547
- if (!srcNode?.id || !tgtNode?.id) continue;
548
762
  const type = edgeTypeFromScope(scope);
549
763
  if (type === undefined) continue; // e.g. "root" scope
764
+ const srcNode = raw.data[String(srcIdx)];
765
+ const tgtNode = raw.data[String(tgtIdx)];
766
+ if (srcNode === undefined || tgtNode === undefined) {
767
+ anomalies.push(
768
+ `graph edge [${srcIdx}, ${tgtIdx}, ${scope}] names a data entry the payload does not carry`,
769
+ );
770
+ continue;
771
+ }
772
+ // An endpoint whose entry was already refused by the node loop's checks
773
+ // has been named there; dropping this edge is a consequence of that
774
+ // anomaly, not a second one — and the function throws before any of it
775
+ // reaches a verdict.
776
+ if (typeof srcNode?.id !== "string" || srcNode.id === "") continue;
777
+ if (typeof tgtNode?.id !== "string" || tgtNode.id === "") continue;
550
778
  add(srcNode.id, tgtNode.id, type);
551
779
  }
552
780
  }
@@ -557,16 +785,36 @@ export function transformMoonGraph(raw) {
557
785
  // `dep.source` is this loop's own reason to exist over the one above: it is
558
786
  // the only place this function's `source` argument is ever real.
559
787
  for (const node of projectNodes) {
560
- if (!node.id || !Array.isArray(node.dependencies)) continue;
788
+ // An entry already refused by the node loop was named there.
789
+ if (typeof node?.id !== "string" || node.id === "" || !Array.isArray(node.dependencies))
790
+ continue;
561
791
  for (const dep of node.dependencies) {
562
- if (!dep.id) continue;
792
+ if (!dep?.id) {
793
+ anomalies.push(`project '${node.id}' has a dependency record with no id`);
794
+ continue;
795
+ }
563
796
  const type = edgeTypeFromScope(dep.scope, dep.source);
564
797
  if (type === undefined) continue;
798
+ if (!nodes[dep.id]) {
799
+ anomalies.push(
800
+ `project '${node.id}' declares a dependency on '${dep.id}', which the graph does not contain`,
801
+ );
802
+ continue;
803
+ }
565
804
  add(node.id, dep.id, type);
566
805
  }
567
806
  }
568
807
 
569
- const workspaceLayout = inferWorkspaceLayout(projectNodes);
808
+ if (anomalies.length > 0) {
809
+ throw new Error(
810
+ "archkeep: `moon project-graph` produced anomalous output this provider refuses to judge " +
811
+ "over — every line below would otherwise drop a project or an edge from the graph " +
812
+ "silently, and a graph judged over less than the tree is the forbidden direction:\n " +
813
+ anomalies.join("\n "),
814
+ );
815
+ }
816
+
817
+ const workspaceLayout = inferWorkspaceLayout(projectNodes, rootById);
570
818
  return workspaceLayout === null
571
819
  ? { nodes, dependencies }
572
820
  : { nodes, dependencies, workspaceLayout };
@@ -687,7 +935,10 @@ function isMoonBinaryMissing(error) {
687
935
  * @throws {Error} naming the install action when the Moon binary is in neither
688
936
  * place it is looked for. Every other spawn failure, and a JSON parse
689
937
  * failure, propagates untouched — already named by `../process.mjs`'s
690
- * `runProcess`. No path here answers with an empty graph.
938
+ * `runProcess`. Anomalous graph shape duplicate project ids, entries that
939
+ * are not project nodes, dependencies naming projects the graph does not
940
+ * contain — is refused by `transformMoonGraph`, naming every instance in
941
+ * one error. No path here answers with an empty graph.
691
942
  */
692
943
  export function readProjectGraph(
693
944
  workspaceRoot,
@@ -1069,7 +1069,9 @@ export function buildCompositeNativeTree(
1069
1069
  // `projects.infer` key means "the declared list is exhaustive, no
1070
1070
  // inference" (`./model.mjs`'s own comment on the key), and this
1071
1071
  // fixture needs the opposite of that. `{}` takes every default:
1072
- // `DEFAULT_MANIFEST_NAMES`, `include: ["**"]`, `exclude: []`.
1072
+ // `DEFAULT_MANIFEST_NAMES`, `include: ["**"]`, and
1073
+ // `DEFAULT_INFER_EXCLUDE` — none of whose paths this tree touches,
1074
+ // every manifest here sitting at the root or under `libs/`.
1073
1075
  infer: {},
1074
1076
  },
1075
1077
  projectRules: [
@@ -1185,18 +1187,36 @@ export const moduleBoundaryOptions = {
1185
1187
  };
1186
1188
  `;
1187
1189
 
1188
- const LAYOUT_GO_FILES = {
1190
+ /**
1191
+ * The layout pair's sources. The `workspaceLayout` proof has to be written in
1192
+ * a language whose imports can BE paths: a bare `packages/…` deep import is
1193
+ * one in the JavaScript family — resolved through `tsconfig.base.json`
1194
+ * `paths`, exactly the convention the absolute-import check judges — while a
1195
+ * Go module path of the same shape is a NAME that check must not read as a
1196
+ * path (#376). So the crossing demonstrating the custom `libsDir` reaching
1197
+ * `isAbsoluteImportIntoAnotherProject` lives in `thing.ts`, and the `.go`
1198
+ * file carries only the tag-violation control. The deep import resolves (the
1199
+ * harness refuses to compare partial scans), which changes nothing: the
1200
+ * check fires on the specifier's text before any resolution.
1201
+ */
1202
+ const LAYOUT_FILES = {
1203
+ "tsconfig.base.json": `${JSON.stringify(
1204
+ { compilerOptions: { baseUrl: ".", paths: { "packages/*": ["packages/*"] } } },
1205
+ null,
1206
+ 2,
1207
+ )}\n`,
1189
1208
  "packages/thing/go.mod": "module example.com/thing\n\ngo 1.24\n",
1190
1209
  "packages/thing/thing.go": `package thing
1191
1210
 
1192
1211
  import (
1193
1212
  "example.com/blocked"
1194
- "packages/elsewhere"
1195
1213
  )
1196
1214
 
1197
1215
  var _ = blocked.Name
1198
- var _ = elsewhere.Name
1199
1216
  `,
1217
+ "packages/thing/thing.ts":
1218
+ 'import { Name } from "packages/blocked/value";\n\nexport const name = Name;\n',
1219
+ "packages/blocked/value.ts": 'export const Name = "value";\n',
1200
1220
  "packages/blocked/go.mod": "module example.com/blocked\n\ngo 1.24\n",
1201
1221
  "packages/blocked/blocked.go": 'package blocked\n\nvar Name = "blocked"\n',
1202
1222
  };
@@ -1221,13 +1241,16 @@ export function buildLayoutNxTree(root, { boundaryConfig = "module-boundaries.co
1221
1241
  "packages/blocked/project.json",
1222
1242
  JSON.stringify({ name: "blocked", tags: ["layer:blocked"] }),
1223
1243
  );
1224
- writeAll(write, LAYOUT_GO_FILES);
1244
+ writeAll(write, LAYOUT_FILES);
1225
1245
  return [
1226
1246
  "nx.json",
1227
1247
  boundaryConfig,
1248
+ "tsconfig.base.json",
1228
1249
  "packages/thing/project.json",
1229
1250
  "packages/thing/go.mod",
1230
1251
  "packages/thing/thing.go",
1252
+ "packages/thing/thing.ts",
1253
+ "packages/blocked/value.ts",
1231
1254
  "packages/blocked/project.json",
1232
1255
  "packages/blocked/go.mod",
1233
1256
  "packages/blocked/blocked.go",
@@ -1265,12 +1288,15 @@ export function buildLayoutNativeTree(
1265
1288
  }),
1266
1289
  );
1267
1290
  write(boundaryConfig, LAYOUT_BOUNDARY_CONFIG);
1268
- writeAll(write, LAYOUT_GO_FILES);
1291
+ writeAll(write, LAYOUT_FILES);
1269
1292
  return [
1270
1293
  "archkeep.json",
1271
1294
  boundaryConfig,
1295
+ "tsconfig.base.json",
1272
1296
  "packages/thing/go.mod",
1273
1297
  "packages/thing/thing.go",
1298
+ "packages/thing/thing.ts",
1299
+ "packages/blocked/value.ts",
1274
1300
  "packages/blocked/go.mod",
1275
1301
  "packages/blocked/blocked.go",
1276
1302
  ];
@@ -31,6 +31,7 @@
31
31
  * (`../../process.mjs`'s header) — for two functions that need nothing beyond
32
32
  * a string.
33
33
  */
34
+ import { basenameMatches } from "../../analysis/manifest-util.mjs";
34
35
  import { fileFailure } from "../../analysis/source-util.mjs";
35
36
  import { parseNxJson } from "../../nx-json.mjs";
36
37
  import { projectPatternError } from "../../rules/match.mjs";
@@ -192,6 +193,60 @@ function readPackageName(projectRoot, readFile, isTracked) {
192
193
  }
193
194
  }
194
195
 
196
+ /**
197
+ * Generated .NET build output (ADR 0006, Decision 2), judged by ROLE and not
198
+ * by name: a tracked `.csproj` is generated output exactly when it sits under
199
+ * an `obj`/`bin` directory whose parent directory carries the tracked
200
+ * `.csproj` whose build produced it — the layout `dotnet build` itself writes,
201
+ * `Api.csproj` beside the `obj/` and `bin/` it fills. That owning manifest is
202
+ * the cheap, reliable signal: restore and build always write output under the
203
+ * project that ran them, so an `obj`/`bin` segment with a `.csproj` beside it
204
+ * is build output by construction, while a source project that merely lives
205
+ * under a directory NAMED `obj` or `bin` (no manifest above the segment) is a
206
+ * project like any other — excluding it by name alone silently dropped a real
207
+ * project from the model, the direction this repository treats as the worse
208
+ * error. `dotnet build` offers no marker file this reader could trust more:
209
+ * `project.assets.json` only exists after a restore, and verdicts may not
210
+ * depend on whether someone ran restore before committing (ADR 0006, Decision
211
+ * 4), so the owning manifest is the one signal that is both always present and
212
+ * static.
213
+ *
214
+ * @param {string} file Workspace-relative path of a tracked `.csproj`.
215
+ * @param {string[]} files Every tracked file, to find the owning manifest.
216
+ * @returns {boolean}
217
+ */
218
+ const isDotnetGeneratedOutput = (file, files) => {
219
+ const segments = file.split("/");
220
+ for (let i = 0; i < segments.length - 1; i++) {
221
+ if (segments[i] !== "obj" && segments[i] !== "bin") continue;
222
+ const owner = segments.slice(0, i).join("/");
223
+ if (files.some((tracked) => tracked.endsWith(".csproj") && directoryOf(tracked) === owner)) {
224
+ return true;
225
+ }
226
+ }
227
+ return false;
228
+ };
229
+
230
+ /**
231
+ * Whether a tracked file's own directory is a root `projects.infer` would
232
+ * anchor: its basename is an inferred manifest, its path passes `include`,
233
+ * and it fails no `exclude`. Split out of `inferProjectRoots` so the
234
+ * two-csproj guard in `discoverNativeProjects` judges exactly the files
235
+ * inference would anchor, no more.
236
+ *
237
+ * @param {string} file Workspace-relative path.
238
+ * @param {{manifests: string[], include: string[], exclude: string[]}} infer
239
+ * @returns {boolean}
240
+ */
241
+ function manifestAnchorsRoot(file, infer) {
242
+ const base = file.slice(file.lastIndexOf("/") + 1);
243
+ return (
244
+ basenameMatches(base, infer.manifests, matchesGlob) &&
245
+ infer.include.some((pattern) => matchesGlob(file, pattern)) &&
246
+ !infer.exclude.some((pattern) => matchesGlob(file, pattern))
247
+ );
248
+ }
249
+
195
250
  /**
196
251
  * Every workspace-relative directory `projects.infer` covers: a tracked
197
252
  * manifest's own directory, when its basename is in `infer.manifests` and its
@@ -203,10 +258,8 @@ function readPackageName(projectRoot, readFile, isTracked) {
203
258
  function inferProjectRoots({ files, infer }) {
204
259
  const roots = new Set();
205
260
  for (const file of files) {
206
- const base = file.slice(file.lastIndexOf("/") + 1);
207
- if (!infer.manifests.includes(base)) continue;
208
- if (!infer.include.some((pattern) => matchesGlob(file, pattern))) continue;
209
- if (infer.exclude.some((pattern) => matchesGlob(file, pattern))) continue;
261
+ if (!manifestAnchorsRoot(file, infer)) continue;
262
+ if (file.endsWith(".csproj") && isDotnetGeneratedOutput(file, files)) continue;
210
263
  roots.add(directoryOf(file));
211
264
  }
212
265
  return roots;
@@ -262,6 +315,32 @@ export function discoverNativeProjects({ root, files, readFile, model }) {
262
315
  ? inferProjectRoots({ files, infer: model.projects.infer })
263
316
  : new Set();
264
317
 
318
+ // Two `.csproj` files in one directory are an ambiguous model, not a
319
+ // first-come anchor (ADR 0006, Decision 2): both claim the name inference
320
+ // would derive from that directory, and silently picking one reads the
321
+ // other's references onto it. Judged only over files inference would
322
+ // anchor — an excluded or non-inferred pair is the declared list's
323
+ // business, not this guard's.
324
+ if (model.projects.infer) {
325
+ const csprojByDir = new Map();
326
+ for (const file of files) {
327
+ if (!file.endsWith(".csproj")) continue;
328
+ if (isDotnetGeneratedOutput(file, files)) continue;
329
+ if (!manifestAnchorsRoot(file, model.projects.infer)) continue;
330
+ const dir = directoryOf(file);
331
+ csprojByDir.set(dir, [...(csprojByDir.get(dir) ?? []), file]);
332
+ }
333
+ for (const [dir, manifests] of csprojByDir) {
334
+ if (manifests.length > 1) {
335
+ violations.push(
336
+ `projects.infer: two .csproj files anchor the same root '${dir}' ` +
337
+ `(${manifests.join(", ")}) — the model cannot pick one; split them ` +
338
+ `into their own directories or declare the projects`,
339
+ );
340
+ }
341
+ }
342
+ }
343
+
265
344
  const allRoots = new Set([...declaredByRoot.keys(), ...inferredRoots]);
266
345
 
267
346
  const resolved = [];