@intentset/architecture 0.1.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/dist/baseline.d.ts +39 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +104 -0
- package/dist/baseline.js.map +1 -0
- package/dist/boundaries.d.ts +63 -0
- package/dist/boundaries.d.ts.map +1 -0
- package/dist/boundaries.js +436 -0
- package/dist/boundaries.js.map +1 -0
- package/dist/check.d.ts +73 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +130 -0
- package/dist/check.js.map +1 -0
- package/dist/claims.d.ts +24 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +201 -0
- package/dist/claims.js.map +1 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +159 -0
- package/dist/config.js.map +1 -0
- package/dist/dependencies.d.ts +30 -0
- package/dist/dependencies.d.ts.map +1 -0
- package/dist/dependencies.js +179 -0
- package/dist/dependencies.js.map +1 -0
- package/dist/exceptions.d.ts +43 -0
- package/dist/exceptions.d.ts.map +1 -0
- package/dist/exceptions.js +233 -0
- package/dist/exceptions.js.map +1 -0
- package/dist/extract.d.ts +24 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +316 -0
- package/dist/extract.js.map +1 -0
- package/dist/finding.d.ts +39 -0
- package/dist/finding.d.ts.map +1 -0
- package/dist/finding.js +29 -0
- package/dist/finding.js.map +1 -0
- package/dist/imports.d.ts +30 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +48 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/layers.d.ts +38 -0
- package/dist/layers.d.ts.map +1 -0
- package/dist/layers.js +41 -0
- package/dist/layers.js.map +1 -0
- package/dist/ownership.d.ts +25 -0
- package/dist/ownership.d.ts.map +1 -0
- package/dist/ownership.js +97 -0
- package/dist/ownership.js.map +1 -0
- package/dist/paths.d.ts +18 -0
- package/dist/paths.d.ts.map +1 -0
- package/dist/paths.js +54 -0
- package/dist/paths.js.map +1 -0
- package/dist/patterns.d.ts +25 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +101 -0
- package/dist/patterns.js.map +1 -0
- package/dist/regions.d.ts +85 -0
- package/dist/regions.d.ts.map +1 -0
- package/dist/regions.js +144 -0
- package/dist/regions.js.map +1 -0
- package/dist/resolve.d.ts +53 -0
- package/dist/resolve.d.ts.map +1 -0
- package/dist/resolve.js +268 -0
- package/dist/resolve.js.map +1 -0
- package/dist/tsconfig.d.ts +42 -0
- package/dist/tsconfig.d.ts.map +1 -0
- package/dist/tsconfig.js +186 -0
- package/dist/tsconfig.js.map +1 -0
- package/package.json +36 -0
- package/src/baseline.ts +126 -0
- package/src/boundaries.ts +499 -0
- package/src/check.ts +197 -0
- package/src/claims.ts +229 -0
- package/src/config.ts +212 -0
- package/src/dependencies.ts +211 -0
- package/src/exceptions.ts +269 -0
- package/src/extract.ts +344 -0
- package/src/finding.ts +64 -0
- package/src/imports.ts +77 -0
- package/src/index.ts +58 -0
- package/src/layers.ts +67 -0
- package/src/ownership.ts +135 -0
- package/src/paths.ts +53 -0
- package/src/patterns.ts +95 -0
- package/src/regions.ts +243 -0
- package/src/resolve.ts +283 -0
- package/src/tsconfig.ts +198 -0
package/src/ownership.ts
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Behavior ownership and contract surfaces (VSA001, VSA002; Core §5).
|
|
3
|
+
*
|
|
4
|
+
* VSA001 at L2 and above: an approved, implemented, released or deprecated
|
|
5
|
+
* behavior has exactly one incoming `implements` from a product slice. Core
|
|
6
|
+
* reports the same condition as CORE006 from the graph alone; VSA001 is the
|
|
7
|
+
* architecture's own statement of it, so a VSA report stands by itself.
|
|
8
|
+
*
|
|
9
|
+
* VSA002: each of a slice's entrypoints is a file in the repository and lies
|
|
10
|
+
* inside one of the slice's own source claims, and no two lie in the same
|
|
11
|
+
* package (the nearest directory above holding a package.json, or the
|
|
12
|
+
* repository root): a slice has one public surface per package it spans. An
|
|
13
|
+
* empty entrypoint module is allowed for a leaf.
|
|
14
|
+
*
|
|
15
|
+
* VSA012 (deleting or splitting a slice dispositions its behavior, paths and
|
|
16
|
+
* contracts) is a diff between two snapshots. This check sees one snapshot,
|
|
17
|
+
* so it emits nothing for VSA012; what a deletion leaves behind still shows
|
|
18
|
+
* here as VSA001 for the orphaned behavior and VSA009 or AMP004 for the
|
|
19
|
+
* orphaned files.
|
|
20
|
+
*/
|
|
21
|
+
import type { Artifact, Graph, Level } from "@intentset/core";
|
|
22
|
+
import { LEVELS } from "@intentset/core";
|
|
23
|
+
import { type Finding, finding } from "./finding.ts";
|
|
24
|
+
import { compareStrings, matchPattern, validatePattern } from "./patterns.ts";
|
|
25
|
+
import { describePackage, type Model } from "./regions.ts";
|
|
26
|
+
|
|
27
|
+
const OWNED_STATUSES = new Set(["approved", "implemented", "released", "deprecated"]);
|
|
28
|
+
|
|
29
|
+
export function checkOwnership(model: Model, graph: Graph, level: Level): Finding[] {
|
|
30
|
+
const findings: Finding[] = [];
|
|
31
|
+
|
|
32
|
+
if (LEVELS.indexOf(level) >= LEVELS.indexOf("L2")) {
|
|
33
|
+
const behaviors = [...graph.artifacts.values()]
|
|
34
|
+
.filter((artifact) => artifact.meta.type === "behavior" && OWNED_STATUSES.has(artifact.meta.status))
|
|
35
|
+
.sort((a, b) => compareStrings(a.meta.id, b.meta.id));
|
|
36
|
+
for (const behavior of behaviors) {
|
|
37
|
+
const id = behavior.meta.id;
|
|
38
|
+
const owners = (graph.in.get(id) ?? [])
|
|
39
|
+
.filter((edge) => edge.kind === "implements")
|
|
40
|
+
.map((edge) => graph.artifacts.get(edge.from))
|
|
41
|
+
.filter((slice): slice is Artifact => slice?.meta.slice?.kind === "product")
|
|
42
|
+
.map((slice) => slice.meta.id)
|
|
43
|
+
.sort(compareStrings);
|
|
44
|
+
const unique = [...new Set(owners)];
|
|
45
|
+
if (unique.length === 1) continue;
|
|
46
|
+
findings.push(
|
|
47
|
+
finding({
|
|
48
|
+
code: "VSA001",
|
|
49
|
+
artifact: id,
|
|
50
|
+
path: behavior.path,
|
|
51
|
+
message:
|
|
52
|
+
unique.length === 0
|
|
53
|
+
? `Behavior ${id} is ${behavior.meta.status} and no product slice implements it.`
|
|
54
|
+
: `Behavior ${id} is ${behavior.meta.status} and ${unique.length} product slices implement it: ${unique.join(", ")}.`,
|
|
55
|
+
remediation:
|
|
56
|
+
unique.length === 0
|
|
57
|
+
? "Add it to the implements of the one product slice accountable for it, or retire it with replacedBy (VSA001)."
|
|
58
|
+
: "Keep implements on the one accountable slice and express the others through dependsOn and contracts (VSA001).",
|
|
59
|
+
paths: unique.map((owner) => graph.artifacts.get(owner)!.path),
|
|
60
|
+
edges: unique.map((owner) => ({ from: owner, to: id })),
|
|
61
|
+
}),
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const slice of model.slices) {
|
|
67
|
+
// The first entrypoint seen in each package; a second in the same package is reported against it.
|
|
68
|
+
const byPackage = new Map<string, string>();
|
|
69
|
+
slice.meta.entrypoints.forEach((entrypoint, i) => {
|
|
70
|
+
const field = `/intentset/slice/entrypoints/${i}`;
|
|
71
|
+
if (entrypoint === "" || validatePattern(entrypoint) !== null || entrypoint.includes("*")) {
|
|
72
|
+
findings.push(
|
|
73
|
+
finding({
|
|
74
|
+
code: "VSA002",
|
|
75
|
+
artifact: slice.id,
|
|
76
|
+
path: slice.path,
|
|
77
|
+
field,
|
|
78
|
+
message: `${slice.id} declares the entrypoint "${entrypoint}", which is not one repository-relative file path.`,
|
|
79
|
+
remediation:
|
|
80
|
+
"Name the file that is the slice's public contract surface in its package, such as its index.ts (VSA002, TS001).",
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const pkg = model.packageOf(entrypoint);
|
|
86
|
+
const first = byPackage.get(pkg);
|
|
87
|
+
if (first !== undefined) {
|
|
88
|
+
findings.push(
|
|
89
|
+
finding({
|
|
90
|
+
code: "VSA002",
|
|
91
|
+
artifact: slice.id,
|
|
92
|
+
path: slice.path,
|
|
93
|
+
field,
|
|
94
|
+
message: `${slice.id} declares two entrypoints in ${describePackage(pkg)}, ${first} and ${entrypoint}; a slice has one public surface per package it spans.`,
|
|
95
|
+
remediation: `Export what ${entrypoint} offers from ${first} and remove it from entrypoints, or move it into the package it serves (VSA002).`,
|
|
96
|
+
paths: [first, entrypoint],
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
} else byPackage.set(pkg, entrypoint);
|
|
100
|
+
if (!model.files.has(entrypoint)) {
|
|
101
|
+
findings.push(
|
|
102
|
+
finding({
|
|
103
|
+
code: "VSA002",
|
|
104
|
+
artifact: slice.id,
|
|
105
|
+
path: slice.path,
|
|
106
|
+
field,
|
|
107
|
+
message: `The entrypoint ${entrypoint} of ${slice.id} is not a file in the repository.`,
|
|
108
|
+
remediation: "Create the entrypoint, empty if the slice exposes nothing yet, or correct the path (VSA002).",
|
|
109
|
+
paths: [entrypoint],
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const owned = slice.meta.claims.some(
|
|
115
|
+
(claim, k) =>
|
|
116
|
+
claim.kind === "source" && !slice.invalidClaims.includes(k) && matchPattern(claim.path, entrypoint),
|
|
117
|
+
);
|
|
118
|
+
if (!owned) {
|
|
119
|
+
findings.push(
|
|
120
|
+
finding({
|
|
121
|
+
code: "VSA002",
|
|
122
|
+
artifact: slice.id,
|
|
123
|
+
path: slice.path,
|
|
124
|
+
field,
|
|
125
|
+
message: `The entrypoint ${entrypoint} of ${slice.id} lies outside every source claim of the slice.`,
|
|
126
|
+
remediation: "Move the entrypoint into the slice's source claim, or extend the claim to cover it (VSA002).",
|
|
127
|
+
paths: [entrypoint],
|
|
128
|
+
}),
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
package/src/paths.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repository-relative POSIX path arithmetic. The repository root is "" and a
|
|
3
|
+
* path never starts with "/" or "./". Anything that climbs above the root is
|
|
4
|
+
* null, never clamped, because a path outside the repository is not a file
|
|
5
|
+
* this checker may attribute (VSA §3).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Join and normalize; null when the result leaves the repository. */
|
|
9
|
+
export function joinPath(base: string, relative: string): string | null {
|
|
10
|
+
const out: string[] = base === "" ? [] : base.split("/");
|
|
11
|
+
for (const segment of relative.split("/")) {
|
|
12
|
+
if (segment === "" || segment === ".") continue;
|
|
13
|
+
if (segment === "..") {
|
|
14
|
+
if (out.length === 0) return null;
|
|
15
|
+
out.pop();
|
|
16
|
+
} else out.push(segment);
|
|
17
|
+
}
|
|
18
|
+
return out.join("/");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function dirname(path: string): string {
|
|
22
|
+
const slash = path.lastIndexOf("/");
|
|
23
|
+
return slash === -1 ? "" : path.slice(0, slash);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function basename(path: string): string {
|
|
27
|
+
return path.slice(path.lastIndexOf("/") + 1);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The directory and each ancestor up to the root (""), nearest first. */
|
|
31
|
+
export function ancestors(directory: string): string[] {
|
|
32
|
+
const out: string[] = [];
|
|
33
|
+
let current = directory;
|
|
34
|
+
for (;;) {
|
|
35
|
+
out.push(current);
|
|
36
|
+
if (current === "") return out;
|
|
37
|
+
current = dirname(current);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** A path relative to a directory, or null when it is not inside it. */
|
|
42
|
+
export function within(directory: string, path: string): string | null {
|
|
43
|
+
if (directory === "") return path;
|
|
44
|
+
if (path === directory) return "";
|
|
45
|
+
return path.startsWith(`${directory}/`) ? path.slice(directory.length + 1) : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const CODE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
|
|
49
|
+
|
|
50
|
+
/** A file whose imports this checker reads. Declaration files included: their imports are type-only but still edges. */
|
|
51
|
+
export function isCode(path: string): boolean {
|
|
52
|
+
return CODE_EXTENSIONS.some((extension) => path.endsWith(extension));
|
|
53
|
+
}
|
package/src/patterns.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VSA §3 path patterns: repository-relative POSIX paths made of literal
|
|
3
|
+
* segments, `*` within one segment, and `**` across zero or more segments.
|
|
4
|
+
* Nothing else is special: `?`, `[` and `]` are literal characters, because
|
|
5
|
+
* real trees have directories named `[id]`. A `*` never matches `/`, so the
|
|
6
|
+
* only way to write a star that spans segments is `**` mixed with other
|
|
7
|
+
* characters, which validatePattern rejects.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** Why a pattern is not a v0.1 pattern, or null when it is one. */
|
|
11
|
+
export function validatePattern(pattern: string): string | null {
|
|
12
|
+
if (pattern === "") return "is empty";
|
|
13
|
+
if (pattern.startsWith("/")) return "is absolute";
|
|
14
|
+
if (pattern.includes("\\")) return "uses a backslash; patterns are POSIX paths";
|
|
15
|
+
if (/[{}]/.test(pattern)) return "uses brace expansion";
|
|
16
|
+
if (pattern.includes("!")) return "uses negation";
|
|
17
|
+
const segments = pattern.split("/");
|
|
18
|
+
if (segments.includes("..")) return "contains `..`";
|
|
19
|
+
if (segments.includes(".")) return "contains a `.` segment";
|
|
20
|
+
if (segments.includes("")) return "has an empty segment";
|
|
21
|
+
for (const segment of segments) {
|
|
22
|
+
if (segment.includes("**") && segment !== "**")
|
|
23
|
+
return `puts \`**\` inside the segment \`${segment}\`; \`**\` stands alone`;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Segment = { kind: "globstar" } | { kind: "literal"; text: string } | { kind: "glob"; regex: RegExp };
|
|
29
|
+
|
|
30
|
+
const compiled = new Map<string, Segment[]>();
|
|
31
|
+
|
|
32
|
+
function compile(pattern: string): Segment[] {
|
|
33
|
+
let segments = compiled.get(pattern);
|
|
34
|
+
if (segments !== undefined) return segments;
|
|
35
|
+
segments = pattern.split("/").map((text): Segment => {
|
|
36
|
+
if (text === "**") return { kind: "globstar" };
|
|
37
|
+
if (!text.includes("*")) return { kind: "literal", text };
|
|
38
|
+
const source = text
|
|
39
|
+
.split("*")
|
|
40
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
41
|
+
.join("[^/]*");
|
|
42
|
+
return { kind: "glob", regex: new RegExp(`^${source}$`) };
|
|
43
|
+
});
|
|
44
|
+
compiled.set(pattern, segments);
|
|
45
|
+
return segments;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Does a repository-relative path match a VSA §3 pattern? Invalid patterns match nothing. */
|
|
49
|
+
export function matchPattern(pattern: string, path: string): boolean {
|
|
50
|
+
if (validatePattern(pattern) !== null) return false;
|
|
51
|
+
const want = compile(pattern);
|
|
52
|
+
const have = path.split("/");
|
|
53
|
+
const memo = new Map<number, boolean>();
|
|
54
|
+
const step = (p: number, s: number): boolean => {
|
|
55
|
+
const key = p * (have.length + 1) + s;
|
|
56
|
+
const known = memo.get(key);
|
|
57
|
+
if (known !== undefined) return known;
|
|
58
|
+
let result: boolean;
|
|
59
|
+
if (p === want.length) result = s === have.length;
|
|
60
|
+
else {
|
|
61
|
+
const segment = want[p];
|
|
62
|
+
if (segment.kind === "globstar") result = step(p + 1, s) || (s < have.length && step(p, s + 1));
|
|
63
|
+
else if (s === have.length) result = false;
|
|
64
|
+
else if (segment.kind === "literal") result = segment.text === have[s] && step(p + 1, s + 1);
|
|
65
|
+
else result = segment.regex.test(have[s]) && step(p + 1, s + 1);
|
|
66
|
+
}
|
|
67
|
+
memo.set(key, result);
|
|
68
|
+
return result;
|
|
69
|
+
};
|
|
70
|
+
return step(0, 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True when any of the patterns matches the path. */
|
|
74
|
+
export function matchAny(patterns: readonly string[], path: string): boolean {
|
|
75
|
+
return patterns.some((pattern) => matchPattern(pattern, path));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve each claim against the repository's files: for claim i, the sorted
|
|
80
|
+
* files it matches, minus those any ignore pattern matches (VSA §3: source
|
|
81
|
+
* enumeration uses a versioned ignore list). An invalid pattern resolves to
|
|
82
|
+
* nothing; the caller reports it.
|
|
83
|
+
*/
|
|
84
|
+
export function expandClaims(
|
|
85
|
+
claims: readonly { path: string }[],
|
|
86
|
+
files: Iterable<string>,
|
|
87
|
+
ignore: readonly string[],
|
|
88
|
+
): string[][] {
|
|
89
|
+
const candidates = [...files].filter((file) => !matchAny(ignore, file)).sort(compareStrings);
|
|
90
|
+
return claims.map((claim) => candidates.filter((file) => matchPattern(claim.path, file)));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function compareStrings(a: string, b: string): number {
|
|
94
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
95
|
+
}
|
package/src/regions.ts
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who owns each file (VSA §1, §3, §5). Every file the checker sees falls in
|
|
3
|
+
* exactly one region, decided in this order:
|
|
4
|
+
*
|
|
5
|
+
* slice:<ID> claimed by a slice (any claim kind; overlaps are VSA009)
|
|
6
|
+
* composition config.composition, e.g. src/app/**
|
|
7
|
+
* shared config.shared, e.g. src/shared/**
|
|
8
|
+
* infrastructure config.infrastructure, e.g. src/infrastructure/**
|
|
9
|
+
* resource:<ID> under a backend root and matched by a registry resource path
|
|
10
|
+
* backend under a backend root and owned by nothing (AMP004)
|
|
11
|
+
* unowned a code file under a source root owned by nothing (VSA009)
|
|
12
|
+
* external everything else: root configuration, scripts, documents
|
|
13
|
+
*
|
|
14
|
+
* Test files are kept apart (profile §3): they belong to the slice whose
|
|
15
|
+
* claim patterns match them, and only the foreign-internals rules apply.
|
|
16
|
+
*/
|
|
17
|
+
import type { Artifact, Graph, Registries, Resource, SliceMeta } from "@intentset/core";
|
|
18
|
+
import type { ArchitectureConfig } from "./config.ts";
|
|
19
|
+
import { ancestors, dirname, isCode, within } from "./paths.ts";
|
|
20
|
+
import { compareStrings, expandClaims, matchAny, matchPattern, validatePattern } from "./patterns.ts";
|
|
21
|
+
|
|
22
|
+
export type RegionKind =
|
|
23
|
+
| "slice"
|
|
24
|
+
| "composition"
|
|
25
|
+
| "shared"
|
|
26
|
+
| "infrastructure"
|
|
27
|
+
| "resource"
|
|
28
|
+
| "backend"
|
|
29
|
+
| "unowned"
|
|
30
|
+
| "external";
|
|
31
|
+
|
|
32
|
+
export interface Region {
|
|
33
|
+
kind: RegionKind;
|
|
34
|
+
/** Slice ID for "slice", resource ID for "resource". */
|
|
35
|
+
owner?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function regionLabel(region: Region): string {
|
|
39
|
+
return region.owner === undefined ? region.kind : `${region.kind}:${region.owner}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SliceInfo {
|
|
43
|
+
id: string;
|
|
44
|
+
/** The slice.md path. */
|
|
45
|
+
path: string;
|
|
46
|
+
artifact: Artifact;
|
|
47
|
+
meta: SliceMeta;
|
|
48
|
+
/**
|
|
49
|
+
* Directories of the entrypoints, sorted: where the profile's ui/, client/ and domain/ folders live, one root per
|
|
50
|
+
* package the slice spans. A file belongs to the deepest root above it (`rootOf`).
|
|
51
|
+
*/
|
|
52
|
+
roots: string[];
|
|
53
|
+
dependsOn: string[];
|
|
54
|
+
exposes: string[];
|
|
55
|
+
consumes: string[];
|
|
56
|
+
/** Files per claim, in claim order; empty for an invalid pattern. */
|
|
57
|
+
claimFiles: string[][];
|
|
58
|
+
/** Claim indexes whose pattern is invalid. */
|
|
59
|
+
invalidClaims: number[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface ResourceInfo {
|
|
63
|
+
resource: Resource;
|
|
64
|
+
index: number;
|
|
65
|
+
files: string[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface Model {
|
|
69
|
+
config: ArchitectureConfig;
|
|
70
|
+
/** Every file not ignored, by path. */
|
|
71
|
+
files: ReadonlyMap<string, string>;
|
|
72
|
+
/** Files not ignored and not tests, sorted. */
|
|
73
|
+
production: string[];
|
|
74
|
+
/** Test files not ignored, sorted. */
|
|
75
|
+
tests: string[];
|
|
76
|
+
slices: SliceInfo[];
|
|
77
|
+
sliceById: Map<string, SliceInfo>;
|
|
78
|
+
/** Production file -> the slices (and claim index) that claim it, sorted by slice ID. */
|
|
79
|
+
claimOwners: Map<string, { slice: string; claim: number }[]>;
|
|
80
|
+
resources: ResourceInfo[];
|
|
81
|
+
regionOf(path: string): Region;
|
|
82
|
+
/** The slice a test file belongs to, by claim pattern, or null. */
|
|
83
|
+
testOwner(path: string): string | null;
|
|
84
|
+
/**
|
|
85
|
+
* A file's package (VSA §3): the nearest ancestor directory holding a package.json in the tree, or the
|
|
86
|
+
* repository root, "", when there is none.
|
|
87
|
+
*/
|
|
88
|
+
packageOf(path: string): string;
|
|
89
|
+
inScope(path: string): boolean;
|
|
90
|
+
isTest(path: string): boolean;
|
|
91
|
+
underBackend(path: string): boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Slices that own files: every slice artifact with slice metadata that is not retired. */
|
|
95
|
+
export function activeSlices(graph: Graph): Artifact[] {
|
|
96
|
+
return [...graph.artifacts.values()]
|
|
97
|
+
.filter(
|
|
98
|
+
(artifact) =>
|
|
99
|
+
artifact.meta.type === "slice" && artifact.meta.slice !== undefined && artifact.meta.status !== "retired",
|
|
100
|
+
)
|
|
101
|
+
.sort((a, b) => compareStrings(a.meta.id, b.meta.id));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildModel(
|
|
105
|
+
graph: Graph,
|
|
106
|
+
registries: Registries,
|
|
107
|
+
tree: ReadonlyMap<string, string>,
|
|
108
|
+
config: ArchitectureConfig,
|
|
109
|
+
): Model {
|
|
110
|
+
const files = new Map<string, string>();
|
|
111
|
+
for (const path of [...tree.keys()].sort(compareStrings)) {
|
|
112
|
+
if (!matchAny(config.ignore, path)) files.set(path, tree.get(path) as string);
|
|
113
|
+
}
|
|
114
|
+
const isTest = (path: string) => matchAny(config.tests, path);
|
|
115
|
+
const production = [...files.keys()].filter((path) => !isTest(path));
|
|
116
|
+
const tests = [...files.keys()].filter(isTest);
|
|
117
|
+
|
|
118
|
+
const slices: SliceInfo[] = activeSlices(graph).map((artifact) => {
|
|
119
|
+
const meta = artifact.meta.slice as SliceMeta;
|
|
120
|
+
const invalidClaims: number[] = [];
|
|
121
|
+
meta.claims.forEach((claim, i) => {
|
|
122
|
+
if (validatePattern(claim.path) !== null) invalidClaims.push(i);
|
|
123
|
+
});
|
|
124
|
+
// A verification claim names tests, so it resolves against them as well;
|
|
125
|
+
// every other kind resolves against production files only, so a source
|
|
126
|
+
// claim over a directory does not sweep in the tests that sit beside it.
|
|
127
|
+
const withTests = [...production, ...tests].sort(compareStrings);
|
|
128
|
+
const claimFiles = meta.claims.map((claim, i) =>
|
|
129
|
+
invalidClaims.includes(i)
|
|
130
|
+
? []
|
|
131
|
+
: (expandClaims([claim], claim.kind === "verification" ? withTests : production, [])[0] ?? []),
|
|
132
|
+
);
|
|
133
|
+
return {
|
|
134
|
+
id: artifact.meta.id,
|
|
135
|
+
path: artifact.path,
|
|
136
|
+
artifact,
|
|
137
|
+
meta,
|
|
138
|
+
roots: [...new Set(meta.entrypoints.map(dirname))].sort(compareStrings),
|
|
139
|
+
dependsOn: [...(artifact.meta.links.dependsOn ?? [])].sort(compareStrings),
|
|
140
|
+
exposes: [...(artifact.meta.links.exposes ?? [])].sort(compareStrings),
|
|
141
|
+
consumes: [...(artifact.meta.links.consumes ?? [])].sort(compareStrings),
|
|
142
|
+
claimFiles,
|
|
143
|
+
invalidClaims,
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
const sliceById = new Map(slices.map((slice) => [slice.id, slice]));
|
|
147
|
+
|
|
148
|
+
const claimOwners = new Map<string, { slice: string; claim: number }[]>();
|
|
149
|
+
for (const slice of slices) {
|
|
150
|
+
slice.claimFiles.forEach((list, claim) => {
|
|
151
|
+
for (const file of list) {
|
|
152
|
+
const owners = claimOwners.get(file) ?? [];
|
|
153
|
+
owners.push({ slice: slice.id, claim });
|
|
154
|
+
claimOwners.set(file, owners);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// A file two slices claim is VSA009 either way; for its region, the slice whose directory holds it is the
|
|
159
|
+
// likelier owner, so an overlap reports once rather than turning every import of the file into a boundary error.
|
|
160
|
+
const home = (file: string, id: string) => rootOf(sliceById.get(id)!, file)?.length ?? -1;
|
|
161
|
+
for (const [file, owners] of claimOwners) {
|
|
162
|
+
owners.sort(
|
|
163
|
+
(a, b) => home(file, b.slice) - home(file, a.slice) || compareStrings(a.slice, b.slice) || a.claim - b.claim,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const resources: ResourceInfo[] = registries.resources.map((resource, index) => ({
|
|
168
|
+
resource,
|
|
169
|
+
index,
|
|
170
|
+
files:
|
|
171
|
+
validatePattern(resource.path) === null ? production.filter((file) => matchPattern(resource.path, file)) : [],
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
const underBackend = (path: string) => matchAny(config.backendRoots, path);
|
|
175
|
+
const regions = new Map<string, Region>();
|
|
176
|
+
const regionOf = (path: string): Region => {
|
|
177
|
+
const cached = regions.get(path);
|
|
178
|
+
if (cached !== undefined) return cached;
|
|
179
|
+
let region: Region;
|
|
180
|
+
const owners = claimOwners.get(path);
|
|
181
|
+
if (owners !== undefined && owners.length > 0) region = { kind: "slice", owner: owners[0].slice };
|
|
182
|
+
else if (matchAny(config.composition, path)) region = { kind: "composition" };
|
|
183
|
+
else if (matchAny(config.shared, path)) region = { kind: "shared" };
|
|
184
|
+
else if (matchAny(config.infrastructure, path)) region = { kind: "infrastructure" };
|
|
185
|
+
else if (underBackend(path)) {
|
|
186
|
+
const resource = resources.find((info) => info.files.includes(path));
|
|
187
|
+
region = resource !== undefined ? { kind: "resource", owner: resource.resource.id } : { kind: "backend" };
|
|
188
|
+
} else if (isCode(path) && matchAny(config.sourceRoots, path)) region = { kind: "unowned" };
|
|
189
|
+
else region = { kind: "external" };
|
|
190
|
+
regions.set(path, region);
|
|
191
|
+
return region;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
const packages = new Map<string, string>();
|
|
195
|
+
const packageOf = (path: string): string => {
|
|
196
|
+
const directory = dirname(path);
|
|
197
|
+
const cached = packages.get(directory);
|
|
198
|
+
if (cached !== undefined) return cached;
|
|
199
|
+
const found =
|
|
200
|
+
ancestors(directory).find((dir) => files.has(dir === "" ? "package.json" : `${dir}/package.json`)) ?? "";
|
|
201
|
+
packages.set(directory, found);
|
|
202
|
+
return found;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const testOwner = (path: string): string | null => {
|
|
206
|
+
for (const slice of slices) {
|
|
207
|
+
if (slice.meta.claims.some((claim, i) => !slice.invalidClaims.includes(i) && matchPattern(claim.path, path)))
|
|
208
|
+
return slice.id;
|
|
209
|
+
}
|
|
210
|
+
return null;
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
config,
|
|
215
|
+
files,
|
|
216
|
+
production,
|
|
217
|
+
tests,
|
|
218
|
+
slices,
|
|
219
|
+
sliceById,
|
|
220
|
+
claimOwners,
|
|
221
|
+
resources,
|
|
222
|
+
regionOf,
|
|
223
|
+
testOwner,
|
|
224
|
+
packageOf,
|
|
225
|
+
inScope: (path) => matchAny(config.scope, path),
|
|
226
|
+
isTest,
|
|
227
|
+
underBackend,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The deepest of the slice's entrypoint directories above a file, or null when it lies under none (a backend claim, say). */
|
|
232
|
+
export function rootOf(slice: SliceInfo, path: string): string | null {
|
|
233
|
+
let found: string | null = null;
|
|
234
|
+
for (const root of slice.roots) {
|
|
235
|
+
if (within(root, path) !== null && (found === null || root.length > found.length)) found = root;
|
|
236
|
+
}
|
|
237
|
+
return found;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** "the repository root" or "the package at apps/web", for messages. */
|
|
241
|
+
export function describePackage(directory: string): string {
|
|
242
|
+
return directory === "" ? "the repository root" : `the package at ${directory}`;
|
|
243
|
+
}
|