@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
package/src/process.mjs
CHANGED
|
@@ -71,15 +71,24 @@ export function environmentForTree(env = process.env) {
|
|
|
71
71
|
* @param {Record<string, string|undefined>} [env] Optional environment
|
|
72
72
|
* override. When omitted, `environmentForTree()` is used — which strips
|
|
73
73
|
* ambient git redirects from `process.env`.
|
|
74
|
+
* @param {{stderr?: "pipe"|"ignore"}} [io] `stderr: "ignore"` is for a call
|
|
75
|
+
* whose failure is an expected, HANDLED answer rather than something a
|
|
76
|
+
* reader of this process's terminal should see. The root walk's
|
|
77
|
+
* `git rev-parse` probe is the caller: run where no repository encloses
|
|
78
|
+
* the directory, git prints `fatal: not a git repository` straight to the
|
|
79
|
+
* caller's own stderr — noise in front of a refusal message that already
|
|
80
|
+
* says everything worth saying. Unset means Node's default, so every other
|
|
81
|
+
* caller's stdio is byte-for-byte what it was.
|
|
74
82
|
* @returns {string}
|
|
75
83
|
*/
|
|
76
|
-
export function runProcess(file, args, cwd, env) {
|
|
84
|
+
export function runProcess(file, args, cwd, env, { stderr } = {}) {
|
|
77
85
|
try {
|
|
78
86
|
return execFileSync(file, args, {
|
|
79
87
|
cwd,
|
|
80
88
|
env: env ?? environmentForTree(),
|
|
81
89
|
encoding: "utf8",
|
|
82
90
|
maxBuffer: 64 * 1024 * 1024,
|
|
91
|
+
...(stderr === undefined ? {} : { stdio: ["pipe", "pipe", stderr] }),
|
|
83
92
|
});
|
|
84
93
|
} catch (cause) {
|
|
85
94
|
throw new Error(
|
package/src/providers/moon.mjs
CHANGED
|
@@ -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
|
|
375
|
-
* (
|
|
376
|
-
* by all projects of the same `layer`:
|
|
377
|
-
* - `application`-layer projects whose
|
|
378
|
-
* - `library`-layer projects whose
|
|
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
|
|
381
|
-
* `"
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
//
|
|
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`.
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
//
|
|
416
|
-
//
|
|
417
|
-
//
|
|
418
|
-
if (
|
|
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 prefix — one 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 (
|
|
493
|
-
|
|
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
|
-
|
|
509
|
-
|
|
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:
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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`.
|
|
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: ["**"]`,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
207
|
-
if (
|
|
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 = [];
|