@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/baseline.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A baseline of known violations for adoption by declared scope (VSA §9):
|
|
3
|
+
* baseline the existing violations, block new ones, retire the baseline
|
|
4
|
+
* progressively. In migration mode a diagnostic the baseline knows becomes a
|
|
5
|
+
* warning marked "(baselined)" and is counted apart; in strict mode the
|
|
6
|
+
* baseline is ignored, so the two modes are always distinguishable.
|
|
7
|
+
*
|
|
8
|
+
* An entry fingerprints the code, path, artifact, field and message, and the
|
|
9
|
+
* messages carry no line numbers, so moving an import does not unbaseline it
|
|
10
|
+
* while a new file, a new target or a larger cycle is a new fingerprint and
|
|
11
|
+
* stays an error. Matching is one entry per diagnostic: a baseline of one
|
|
12
|
+
* violation does not absorb a second copy of it.
|
|
13
|
+
*/
|
|
14
|
+
import { canonicalJson, type Diagnostic, sha256Hex } from "@intentset/core";
|
|
15
|
+
import { annotate } from "./exceptions.ts";
|
|
16
|
+
import { compareStrings } from "./patterns.ts";
|
|
17
|
+
|
|
18
|
+
export type Mode = "migration" | "strict";
|
|
19
|
+
|
|
20
|
+
export interface BaselineEntry {
|
|
21
|
+
code: string;
|
|
22
|
+
path: string | null;
|
|
23
|
+
artifact?: string;
|
|
24
|
+
fingerprint: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const NOTES = / \((?:baselined|excepted by [^()]*|exception [^()]* expired [^()]*)\)(?=\.?$)/;
|
|
28
|
+
|
|
29
|
+
/** The message without the notes exceptions and baselines add. */
|
|
30
|
+
export function plainMessage(message: string): string {
|
|
31
|
+
let out = message;
|
|
32
|
+
for (let previous = ""; previous !== out; ) {
|
|
33
|
+
previous = out;
|
|
34
|
+
out = out.replace(NOTES, "");
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function fingerprint(diagnostic: Diagnostic): string {
|
|
40
|
+
return sha256Hex(
|
|
41
|
+
canonicalJson({
|
|
42
|
+
code: diagnostic.code,
|
|
43
|
+
path: diagnostic.path,
|
|
44
|
+
artifact: diagnostic.artifact,
|
|
45
|
+
field: diagnostic.field ?? null,
|
|
46
|
+
message: plainMessage(diagnostic.message),
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isBaselined(diagnostic: Diagnostic): boolean {
|
|
52
|
+
return diagnostic.severity === "warning" && / \(baselined\)\.?$/.test(diagnostic.message);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Record every error, and every diagnostic already baselined, as the baseline to adopt. Sorted. */
|
|
56
|
+
export function writeBaseline(diagnostics: readonly Diagnostic[]): BaselineEntry[] {
|
|
57
|
+
return diagnostics
|
|
58
|
+
.filter((diagnostic) => diagnostic.severity === "error" || isBaselined(diagnostic))
|
|
59
|
+
.map((diagnostic) => ({
|
|
60
|
+
code: diagnostic.code,
|
|
61
|
+
path: diagnostic.path,
|
|
62
|
+
...(diagnostic.artifact !== null ? { artifact: diagnostic.artifact } : {}),
|
|
63
|
+
fingerprint: fingerprint(diagnostic),
|
|
64
|
+
}))
|
|
65
|
+
.sort(
|
|
66
|
+
(a, b) =>
|
|
67
|
+
compareStrings(a.code, b.code) ||
|
|
68
|
+
compareStrings(a.path ?? "", b.path ?? "") ||
|
|
69
|
+
compareStrings(a.fingerprint, b.fingerprint),
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parse a baseline file. Throws on a shape that is not an array of entries, since a misread baseline must not pass silently. */
|
|
74
|
+
export function parseBaseline(text: string): BaselineEntry[] {
|
|
75
|
+
const value: unknown = JSON.parse(text);
|
|
76
|
+
if (!Array.isArray(value)) throw new Error("a baseline is a JSON array of entries");
|
|
77
|
+
return value.map((item, i) => {
|
|
78
|
+
const entry = item as Record<string, unknown>;
|
|
79
|
+
if (
|
|
80
|
+
typeof entry !== "object" ||
|
|
81
|
+
entry === null ||
|
|
82
|
+
typeof entry.code !== "string" ||
|
|
83
|
+
typeof entry.fingerprint !== "string" ||
|
|
84
|
+
!(typeof entry.path === "string" || entry.path === null) ||
|
|
85
|
+
!(entry.artifact === undefined || typeof entry.artifact === "string")
|
|
86
|
+
) {
|
|
87
|
+
throw new Error(`baseline entry ${i} is not { code, path, artifact?, fingerprint }`);
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
code: entry.code,
|
|
91
|
+
path: entry.path as string | null,
|
|
92
|
+
...(typeof entry.artifact === "string" ? { artifact: entry.artifact } : {}),
|
|
93
|
+
fingerprint: entry.fingerprint,
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Apply a baseline. In migration mode each entry turns one matching error
|
|
100
|
+
* into a "(baselined)" warning; entries nothing matched are returned as
|
|
101
|
+
* stale. Strict mode returns the diagnostics unchanged.
|
|
102
|
+
*/
|
|
103
|
+
export function applyBaseline(
|
|
104
|
+
diagnostics: readonly Diagnostic[],
|
|
105
|
+
baseline: readonly BaselineEntry[],
|
|
106
|
+
mode: Mode = "migration",
|
|
107
|
+
): { diagnostics: Diagnostic[]; baselined: number; stale: BaselineEntry[] } {
|
|
108
|
+
if (mode === "strict") return { diagnostics: [...diagnostics], baselined: 0, stale: [] };
|
|
109
|
+
const remaining = new Map<string, BaselineEntry[]>();
|
|
110
|
+
for (const entry of baseline) {
|
|
111
|
+
const list = remaining.get(entry.fingerprint) ?? [];
|
|
112
|
+
list.push(entry);
|
|
113
|
+
remaining.set(entry.fingerprint, list);
|
|
114
|
+
}
|
|
115
|
+
let baselined = 0;
|
|
116
|
+
const out = diagnostics.map((diagnostic) => {
|
|
117
|
+
if (diagnostic.severity !== "error") return diagnostic;
|
|
118
|
+
const list = remaining.get(fingerprint(diagnostic));
|
|
119
|
+
if (list === undefined || list.length === 0) return diagnostic;
|
|
120
|
+
list.pop();
|
|
121
|
+
baselined++;
|
|
122
|
+
return { ...diagnostic, severity: "warning" as const, message: annotate(diagnostic.message, "baselined") };
|
|
123
|
+
});
|
|
124
|
+
const stale = [...remaining.values()].flat().sort((a, b) => compareStrings(a.fingerprint, b.fingerprint));
|
|
125
|
+
return { diagnostics: out, baselined, stale };
|
|
126
|
+
}
|
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rules one import edge can break (VSA003, VSA006, region boundaries;
|
|
3
|
+
* profile TS001–TS003, TS006, AMP001, AMP002, AMP006). Each edge gets at
|
|
4
|
+
* most one boundary finding, the most specific that applies, in this order:
|
|
5
|
+
*
|
|
6
|
+
* 1. AMP001 frontend code importing anything under a backend root, unless
|
|
7
|
+
* a client-seam file imports a named response-parser module.
|
|
8
|
+
* 2. REG001 region boundary: shared importing infrastructure, composition
|
|
9
|
+
* or a slice; infrastructure importing composition or a slice;
|
|
10
|
+
* a slice importing composition (VSA §5). VSA007 is a review
|
|
11
|
+
* assertion about business ownership, so a structural region
|
|
12
|
+
* violation has its own code.
|
|
13
|
+
* 3. TS003 a screen of another slice imported by anything but a named
|
|
14
|
+
* router file; or one of a slice's entrypoints importing its own
|
|
15
|
+
* screen.
|
|
16
|
+
* 4. VSA003 another slice's file that is none of its entrypoints, by
|
|
17
|
+
* relative path, alias or re-export; with TS006 beside it when a
|
|
18
|
+
* wildcard alias opened the path. Every entrypoint is a public
|
|
19
|
+
* surface, and the message names the one in the importer's own
|
|
20
|
+
* package first, since that is the surface it should use.
|
|
21
|
+
* 5. AMP006 a policy or model file importing its slice's client seam or
|
|
22
|
+
* infrastructure, or a backend SDK.
|
|
23
|
+
* 6. VSA006 an edge inside one slice that the layer matrix does not allow.
|
|
24
|
+
*
|
|
25
|
+
* Beside those: TS002 (warning) when one slice reaches another's entrypoint
|
|
26
|
+
* by relative path although an alias for it is configured; AMP002 when a
|
|
27
|
+
* slice file outside its client seam imports a backend SDK; TS001 (warning)
|
|
28
|
+
* when an entrypoint re-exports with `export *`.
|
|
29
|
+
*
|
|
30
|
+
* Test files get the separate pass profile §3 asks for: they may import
|
|
31
|
+
* their own slice's internals, and VSA003 and TS003 still apply to anyone
|
|
32
|
+
* else's.
|
|
33
|
+
*
|
|
34
|
+
* Edges from files inside the declared scope are checked, including those to
|
|
35
|
+
* a file outside it that a slice claims. Edges from files outside the scope
|
|
36
|
+
* are checked for one thing only: reaching past a slice's public surfaces to
|
|
37
|
+
* a file inside the scope, which is VSA003 (TS003 for a screen) as a warning,
|
|
38
|
+
* so a narrow scope cannot hide its consumers and they do not fail it
|
|
39
|
+
* (VSA §9). They count toward no edge total and no slice dependency.
|
|
40
|
+
*/
|
|
41
|
+
import { type Finding, finding, listSome } from "./finding.ts";
|
|
42
|
+
import type { ImportEdge } from "./imports.ts";
|
|
43
|
+
import { inSeam, isPure, knownLayer, layerAllows, layerOf } from "./layers.ts";
|
|
44
|
+
import { matchAny, matchPattern } from "./patterns.ts";
|
|
45
|
+
import { describePackage, type Model, type Region, type SliceInfo } from "./regions.ts";
|
|
46
|
+
import type { Resolver } from "./resolve.ts";
|
|
47
|
+
|
|
48
|
+
export interface SliceEdge {
|
|
49
|
+
from: string;
|
|
50
|
+
to: string;
|
|
51
|
+
/** The import edges behind it, in file and line order. */
|
|
52
|
+
imports: ImportEdge[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface BoundaryResult {
|
|
56
|
+
findings: Finding[];
|
|
57
|
+
/** Observed slice-to-slice dependencies from production files in scope, keyed "FROM->TO". */
|
|
58
|
+
sliceEdges: Map<string, SliceEdge>;
|
|
59
|
+
/** Resolved file-to-file edges from production files. */
|
|
60
|
+
fileEdges: number;
|
|
61
|
+
/** Those between two different slices. */
|
|
62
|
+
crossSliceEdges: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function how(edge: ImportEdge): string {
|
|
66
|
+
const kind = edge.reexport
|
|
67
|
+
? "re-exports"
|
|
68
|
+
: edge.dynamic
|
|
69
|
+
? "dynamically imports"
|
|
70
|
+
: edge.typeOnly
|
|
71
|
+
? "type-imports"
|
|
72
|
+
: "imports";
|
|
73
|
+
return `${kind} "${edge.specifier}"`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function through(edge: ImportEdge): string {
|
|
77
|
+
return edge.via === "relative"
|
|
78
|
+
? "a relative path"
|
|
79
|
+
: edge.via === "workspace"
|
|
80
|
+
? "a package path"
|
|
81
|
+
: `the ${edge.via === "baseUrl" ? "baseUrl" : "alias"} "${edge.specifier}"`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A slice's entrypoints with those in the importer's own package first, since that is the surface it should use. */
|
|
85
|
+
function surfacesFor(model: Model, target: SliceInfo, from: string): { own: string[]; others: string[] } {
|
|
86
|
+
const pkg = model.packageOf(from);
|
|
87
|
+
const own = target.meta.entrypoints.filter((entry) => model.packageOf(entry) === pkg);
|
|
88
|
+
return { own, others: target.meta.entrypoints.filter((entry) => !own.includes(entry)) };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** "its public surface is X", or with several, the one in the importer's package first. */
|
|
92
|
+
function describeSurfaces(model: Model, target: SliceInfo, from: string): string {
|
|
93
|
+
const entries = target.meta.entrypoints;
|
|
94
|
+
if (entries.length === 1) return `its public surface is ${entries[0]}`;
|
|
95
|
+
const { own, others } = surfacesFor(model, target, from);
|
|
96
|
+
if (own.length === 0) return `its public surfaces are ${listSome(others, others.length)}`;
|
|
97
|
+
const elsewhere = others.length === 0 ? "" : `, and elsewhere ${listSome(others, others.length)}`;
|
|
98
|
+
return `its public surface in ${describePackage(model.packageOf(from))} is ${listSome(own, own.length)}${elsewhere}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The file to import instead: the entrypoint in the importer's package, or the only one. */
|
|
102
|
+
function importInstead(model: Model, target: SliceInfo, from: string): string {
|
|
103
|
+
const { own } = surfacesFor(model, target, from);
|
|
104
|
+
if (own.length > 0) return `Import ${own[0]}`;
|
|
105
|
+
const entries = target.meta.entrypoints;
|
|
106
|
+
return entries.length === 1 ? `Import ${entries[0]}` : `Import one of ${target.id}'s entrypoints`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function describeRegion(region: Region): string {
|
|
110
|
+
switch (region.kind) {
|
|
111
|
+
case "slice":
|
|
112
|
+
return `slice ${region.owner}`;
|
|
113
|
+
case "resource":
|
|
114
|
+
return `resource ${region.owner}`;
|
|
115
|
+
default:
|
|
116
|
+
return region.kind;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function checkBoundaries(model: Model, edges: readonly ImportEdge[], resolver: Resolver): BoundaryResult {
|
|
121
|
+
const { config } = model;
|
|
122
|
+
const findings: Finding[] = [];
|
|
123
|
+
const sliceEdges = new Map<string, SliceEdge>();
|
|
124
|
+
let fileEdges = 0;
|
|
125
|
+
let crossSliceEdges = 0;
|
|
126
|
+
const sliceOf = (region: Region): SliceInfo | undefined =>
|
|
127
|
+
region.kind === "slice" ? model.sliceById.get(region.owner as string) : undefined;
|
|
128
|
+
const isScreen = (path: string) => matchPattern(config.screensGlob, path);
|
|
129
|
+
|
|
130
|
+
// VSA §9: from outside the declared scope, only an import past a slice's public surfaces into the scope is
|
|
131
|
+
// reported, and as a warning. The exemptions are those that hold inside the scope.
|
|
132
|
+
const outOfScope = (edge: ImportEdge): Finding | null => {
|
|
133
|
+
const { from, to } = edge;
|
|
134
|
+
if (to === null || !model.inScope(to)) return null;
|
|
135
|
+
const target = sliceOf(model.regionOf(to));
|
|
136
|
+
if (target === undefined || target.meta.entrypoints.includes(to)) return null;
|
|
137
|
+
const test = model.isTest(from);
|
|
138
|
+
const source = test ? undefined : sliceOf(model.regionOf(from));
|
|
139
|
+
const owner = test ? model.testOwner(from) : (source?.id ?? null);
|
|
140
|
+
if (owner === target.id) return null;
|
|
141
|
+
if (!test && model.underBackend(to)) {
|
|
142
|
+
if (source === undefined && model.underBackend(from)) return null; // the unified backend composition (VSA §7)
|
|
143
|
+
if (source !== undefined && inSeam(source, from) && matchAny(config.responseParserModules, to)) return null;
|
|
144
|
+
}
|
|
145
|
+
const who = test ? `Test file ${from}` : from;
|
|
146
|
+
const subject = { edges: [{ from, to }, ...(owner !== null ? [{ from: owner, to: target.id }] : [])] };
|
|
147
|
+
const why = "It is a warning because the importer lies outside the declared scope (VSA §9).";
|
|
148
|
+
if (isScreen(to)) {
|
|
149
|
+
if (!test && matchAny(config.routerFiles, from)) return null;
|
|
150
|
+
return finding({
|
|
151
|
+
code: "TS003",
|
|
152
|
+
severity: "warning",
|
|
153
|
+
artifact: owner,
|
|
154
|
+
path: from,
|
|
155
|
+
location: { line: edge.line },
|
|
156
|
+
message: `${who} lies outside the declared scope and ${how(edge)}, a screen of ${target.id}; only a named router file may import screens directly.`,
|
|
157
|
+
remediation: `Let the router compose ${target.id}'s screen, or consume a component API that ${target.id} exposes through an entrypoint (TS003). ${why}`,
|
|
158
|
+
...subject,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return finding({
|
|
162
|
+
code: "VSA003",
|
|
163
|
+
severity: "warning",
|
|
164
|
+
artifact: owner,
|
|
165
|
+
path: from,
|
|
166
|
+
location: { line: edge.line },
|
|
167
|
+
message: `${who} lies outside the declared scope and ${how(edge)}, a private file of ${target.id} reached through ${through(edge)}; ${describeSurfaces(model, target, from)}.`,
|
|
168
|
+
remediation: `${importInstead(model, target, from)} and have it export what is needed (VSA003). ${why}`,
|
|
169
|
+
...subject,
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
for (const edge of edges) {
|
|
174
|
+
const from = edge.from;
|
|
175
|
+
if (!model.inScope(from)) {
|
|
176
|
+
const leak = outOfScope(edge);
|
|
177
|
+
if (leak !== null) findings.push(leak);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const location = { line: edge.line };
|
|
181
|
+
|
|
182
|
+
if (model.isTest(from)) {
|
|
183
|
+
if (edge.to === null) continue;
|
|
184
|
+
const owner = model.testOwner(from);
|
|
185
|
+
const target = sliceOf(model.regionOf(edge.to));
|
|
186
|
+
if (target === undefined || target.id === owner) continue;
|
|
187
|
+
if (isScreen(edge.to)) {
|
|
188
|
+
findings.push(
|
|
189
|
+
finding({
|
|
190
|
+
code: "TS003",
|
|
191
|
+
artifact: owner,
|
|
192
|
+
path: from,
|
|
193
|
+
location,
|
|
194
|
+
message: `Test file ${from} ${how(edge)}, a screen of ${target.id}, and tests must not bypass another slice's contract.`,
|
|
195
|
+
remediation: `Test ${target.id}'s screens from ${target.id}'s own tests, or drive them through the router (profile §3).`,
|
|
196
|
+
edges: [{ from, to: edge.to }],
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
} else if (!target.meta.entrypoints.includes(edge.to)) {
|
|
200
|
+
findings.push(
|
|
201
|
+
finding({
|
|
202
|
+
code: "VSA003",
|
|
203
|
+
artifact: owner,
|
|
204
|
+
path: from,
|
|
205
|
+
location,
|
|
206
|
+
message: `Test file ${from} ${how(edge)}, a private file of ${target.id}; ${describeSurfaces(model, target, from)}.`,
|
|
207
|
+
remediation: `${importInstead(model, target, from)} instead; tests may reach only their own slice's internals (profile §3).`,
|
|
208
|
+
edges: [{ from, to: edge.to }],
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const regionFrom = model.regionOf(from);
|
|
216
|
+
const source = sliceOf(regionFrom);
|
|
217
|
+
const artifact = source?.id ?? null;
|
|
218
|
+
|
|
219
|
+
if (source?.meta.entrypoints.includes(from) && edge.wildcardExport) {
|
|
220
|
+
findings.push(
|
|
221
|
+
finding({
|
|
222
|
+
code: "TS001",
|
|
223
|
+
severity: "warning",
|
|
224
|
+
artifact,
|
|
225
|
+
path: from,
|
|
226
|
+
location,
|
|
227
|
+
message: `The entrypoint of ${source.id} re-exports everything from "${edge.specifier}" with a wildcard, which can publish private modules as contract.`,
|
|
228
|
+
remediation: "Export the reviewed names explicitly (VSA §4, TS001).",
|
|
229
|
+
edges: [{ from, to: edge.to ?? edge.specifier }],
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (edge.to === null) {
|
|
235
|
+
if (
|
|
236
|
+
source !== undefined &&
|
|
237
|
+
!model.underBackend(from) &&
|
|
238
|
+
matchAny(config.backendSdks, edge.specifier.replace(/^node:/, ""))
|
|
239
|
+
) {
|
|
240
|
+
if (isPure(source, from)) {
|
|
241
|
+
findings.push(
|
|
242
|
+
finding({
|
|
243
|
+
code: "AMP006",
|
|
244
|
+
artifact,
|
|
245
|
+
path: from,
|
|
246
|
+
location,
|
|
247
|
+
message: `${from} is a policy or model file of ${source.id} and ${how(edge)}, a backend SDK.`,
|
|
248
|
+
remediation:
|
|
249
|
+
"Keep SDK and generated client types out of policies and models; adapt them in the client seam (AMP006).",
|
|
250
|
+
edges: [{ from, to: edge.specifier }],
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
} else if (!inSeam(source, from)) {
|
|
254
|
+
findings.push(
|
|
255
|
+
finding({
|
|
256
|
+
code: "AMP002",
|
|
257
|
+
artifact,
|
|
258
|
+
path: from,
|
|
259
|
+
location,
|
|
260
|
+
message: `${from} ${how(edge)}, a backend SDK, outside the client seam of ${source.id}.`,
|
|
261
|
+
remediation: `Move the backend call into ${source.id}'s client seam and call that from here (AMP002, VSA010).`,
|
|
262
|
+
edges: [{ from, to: edge.specifier }],
|
|
263
|
+
}),
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const to = edge.to;
|
|
271
|
+
const regionTo = model.regionOf(to);
|
|
272
|
+
const target = sliceOf(regionTo);
|
|
273
|
+
fileEdges++;
|
|
274
|
+
if (source !== undefined && target !== undefined && source.id !== target.id) {
|
|
275
|
+
crossSliceEdges++;
|
|
276
|
+
const key = `${source.id}->${target.id}`;
|
|
277
|
+
const entry = sliceEdges.get(key) ?? { from: source.id, to: target.id, imports: [] };
|
|
278
|
+
entry.imports.push(edge);
|
|
279
|
+
sliceEdges.set(key, entry);
|
|
280
|
+
}
|
|
281
|
+
if (!model.inScope(to) && target === undefined) continue;
|
|
282
|
+
const subject = { edges: [{ from, to }, ...(source && target ? [{ from: source.id, to: target.id }] : [])] };
|
|
283
|
+
|
|
284
|
+
// 1. AMP001
|
|
285
|
+
if (model.underBackend(to) && !model.underBackend(from)) {
|
|
286
|
+
const seam = source !== undefined && inSeam(source, from);
|
|
287
|
+
if (!(seam && matchAny(config.responseParserModules, to))) {
|
|
288
|
+
findings.push(
|
|
289
|
+
finding({
|
|
290
|
+
code: "AMP001",
|
|
291
|
+
artifact,
|
|
292
|
+
path: from,
|
|
293
|
+
location,
|
|
294
|
+
message: `${from} ${how(edge)}, the backend file ${to}${edge.typeOnly ? " (type-only imports count)" : ""}, and frontend code must not import backend definitions.`,
|
|
295
|
+
remediation: seam
|
|
296
|
+
? "Only the named response-parser modules may be imported by a client seam; adapt everything else to the slice's own types (profile §4)."
|
|
297
|
+
: "Reach the backend through the slice's client seam with its own transport types, or record an approved type-bridge ADR as an exception (profile §4).",
|
|
298
|
+
...subject,
|
|
299
|
+
}),
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 2. Region boundaries.
|
|
306
|
+
const regionViolation =
|
|
307
|
+
(regionFrom.kind === "shared" && ["infrastructure", "composition", "slice"].includes(regionTo.kind)) ||
|
|
308
|
+
(regionFrom.kind === "infrastructure" && ["composition", "slice"].includes(regionTo.kind)) ||
|
|
309
|
+
(regionFrom.kind === "slice" && regionTo.kind === "composition");
|
|
310
|
+
if (regionViolation) {
|
|
311
|
+
findings.push(
|
|
312
|
+
finding({
|
|
313
|
+
code: "REG001",
|
|
314
|
+
artifact,
|
|
315
|
+
path: from,
|
|
316
|
+
location,
|
|
317
|
+
message: `Region boundary: ${describeRegion(regionFrom)} file ${from} ${how(edge)}, which is ${describeRegion(regionTo)}.`,
|
|
318
|
+
remediation:
|
|
319
|
+
regionFrom.kind === "shared"
|
|
320
|
+
? "Shared code depends only on shared neutral abstractions; invert the dependency so infrastructure imports shared (VSA §5)."
|
|
321
|
+
: regionFrom.kind === "infrastructure"
|
|
322
|
+
? "Infrastructure provides mechanisms and must not know slices or composition; have composition wire them together (VSA §5)."
|
|
323
|
+
: "Composition depends on slices, never the reverse; pass what the slice needs in through its contract (VSA §5, §6).",
|
|
324
|
+
...subject,
|
|
325
|
+
}),
|
|
326
|
+
);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// A unified backend root is compatible with VSA (§7): the backend composition (amplify/backend.ts, a recorded
|
|
331
|
+
// resource) wires every slice's resource definitions. Between two slices' backend files the rules below still hold.
|
|
332
|
+
if (source === undefined && model.underBackend(from) && model.underBackend(to)) continue;
|
|
333
|
+
|
|
334
|
+
// 3 and 4. Another slice's screens and private files.
|
|
335
|
+
if (target !== undefined && target.id !== source?.id) {
|
|
336
|
+
if (isScreen(to)) {
|
|
337
|
+
if (!matchAny(config.routerFiles, from)) {
|
|
338
|
+
findings.push(
|
|
339
|
+
finding({
|
|
340
|
+
code: "TS003",
|
|
341
|
+
artifact,
|
|
342
|
+
path: from,
|
|
343
|
+
location,
|
|
344
|
+
message: `${from} ${how(edge)}, a screen of ${target.id}, and only a named router file may import screens directly.`,
|
|
345
|
+
remediation:
|
|
346
|
+
source !== undefined
|
|
347
|
+
? `Let the router compose ${target.id}'s screen, or consume a component API that ${target.id} exposes through an entrypoint (TS003).`
|
|
348
|
+
: "Import the screen from the router file named in routerFiles, or name this file there if it is the router (TS003).",
|
|
349
|
+
...subject,
|
|
350
|
+
}),
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (!target.meta.entrypoints.includes(to)) {
|
|
356
|
+
findings.push(
|
|
357
|
+
finding({
|
|
358
|
+
code: "VSA003",
|
|
359
|
+
artifact,
|
|
360
|
+
path: from,
|
|
361
|
+
location,
|
|
362
|
+
message: `${source ? source.id : describeRegion(regionFrom)} ${how(edge)}, a private file of ${target.id} reached through ${through(edge)}; ${describeSurfaces(model, target, from)}.`,
|
|
363
|
+
remediation: `${importInstead(model, target, from)} and have it export what is needed, or move the shared rule into a contract (VSA003).`,
|
|
364
|
+
...subject,
|
|
365
|
+
}),
|
|
366
|
+
);
|
|
367
|
+
if (edge.wildcardAlias && (edge.via === "alias" || edge.via === "workspace")) {
|
|
368
|
+
findings.push(
|
|
369
|
+
finding({
|
|
370
|
+
code: "TS006",
|
|
371
|
+
artifact,
|
|
372
|
+
path: from,
|
|
373
|
+
location,
|
|
374
|
+
message: `The wildcard in "${edge.specifier}" opens ${to}, a private file of ${target.id}, to code outside the slice.`,
|
|
375
|
+
remediation: `Map the alias for ${target.id} to its entrypoints only, so the alias cannot reach internals (TS006).`,
|
|
376
|
+
...subject,
|
|
377
|
+
}),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (source !== undefined && (edge.via === "relative" || edge.via === "baseUrl")) {
|
|
383
|
+
const alias = resolver.aliasFor(from, to);
|
|
384
|
+
if (alias !== null) {
|
|
385
|
+
findings.push(
|
|
386
|
+
finding({
|
|
387
|
+
code: "TS002",
|
|
388
|
+
severity: "warning",
|
|
389
|
+
artifact,
|
|
390
|
+
path: from,
|
|
391
|
+
location,
|
|
392
|
+
message: `${source.id} reaches the entrypoint of ${target.id} through "${edge.specifier}" rather than its configured alias "${alias}".`,
|
|
393
|
+
remediation: `Import "${alias}" (TS002).`,
|
|
394
|
+
...subject,
|
|
395
|
+
}),
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
continue;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (source === undefined) continue;
|
|
403
|
+
|
|
404
|
+
// Inside one slice, or out to shared and infrastructure.
|
|
405
|
+
if (target !== undefined && source.meta.entrypoints.includes(from) && isScreen(to)) {
|
|
406
|
+
findings.push(
|
|
407
|
+
finding({
|
|
408
|
+
code: "TS003",
|
|
409
|
+
artifact,
|
|
410
|
+
path: from,
|
|
411
|
+
location,
|
|
412
|
+
message: `The entrypoint of ${source.id} ${how(edge)}, one of its screens, and screens must not be exported from a slice's contract.`,
|
|
413
|
+
remediation: "Remove the screen from the entrypoint and let the named router import it (TS003).",
|
|
414
|
+
...subject,
|
|
415
|
+
}),
|
|
416
|
+
);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
// 5. AMP006
|
|
420
|
+
if (isPure(source, from) && ((target !== undefined && inSeam(source, to)) || regionTo.kind === "infrastructure")) {
|
|
421
|
+
findings.push(
|
|
422
|
+
finding({
|
|
423
|
+
code: "AMP006",
|
|
424
|
+
artifact,
|
|
425
|
+
path: from,
|
|
426
|
+
location,
|
|
427
|
+
message: `${from} is a policy or model file of ${source.id} and ${how(edge)}, which is ${regionTo.kind === "infrastructure" ? "infrastructure" : "its client seam"}.`,
|
|
428
|
+
remediation:
|
|
429
|
+
"Policies and models import models only; have a use-case call the client and pass plain values in (AMP006).",
|
|
430
|
+
...subject,
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
// 6. VSA006
|
|
436
|
+
if (target !== undefined) {
|
|
437
|
+
const a = layerOf(source, from);
|
|
438
|
+
const b = layerOf(source, to);
|
|
439
|
+
if (a.layer === null || b.layer === null || !knownLayer(config, a.layer) || !knownLayer(config, b.layer))
|
|
440
|
+
continue;
|
|
441
|
+
if (!layerAllows(config, a.layer, b.layer)) {
|
|
442
|
+
const allowed = [a.layer, ...(config.layerMatrix[a.layer] ?? [])].filter(
|
|
443
|
+
(value, i, all) => all.indexOf(value) === i,
|
|
444
|
+
);
|
|
445
|
+
findings.push(
|
|
446
|
+
finding({
|
|
447
|
+
code: "VSA006",
|
|
448
|
+
artifact,
|
|
449
|
+
path: from,
|
|
450
|
+
location,
|
|
451
|
+
message: `${from} is in the ${a.layer} layer of ${source.id} and ${how(edge)}, which is in the ${b.layer} layer.`,
|
|
452
|
+
remediation: `The ${a.layer} layer may import ${allowed.join(", ")}; move the dependency so it flows downward (profile §3).`,
|
|
453
|
+
...subject,
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
return { findings, sliceEdges, fileEdges, crossSliceEdges };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** One warning per slice whose code files are in no declared layer, or in a layer the matrix does not know (VSA006). */
|
|
464
|
+
export function checkUnclassified(model: Model): Finding[] {
|
|
465
|
+
const findings: Finding[] = [];
|
|
466
|
+
for (const slice of model.slices) {
|
|
467
|
+
const unclassified: string[] = [];
|
|
468
|
+
const files = new Set(slice.claimFiles.flat());
|
|
469
|
+
for (const file of [...files].sort()) {
|
|
470
|
+
if (
|
|
471
|
+
!/\.[cm]?[jt]sx?$/.test(file) ||
|
|
472
|
+
matchAny(model.config.tests, file) ||
|
|
473
|
+
slice.meta.entrypoints.includes(file) ||
|
|
474
|
+
model.underBackend(file) ||
|
|
475
|
+
!model.inScope(file)
|
|
476
|
+
)
|
|
477
|
+
continue;
|
|
478
|
+
// A file another slice also claims is VSA009 already, and its region is that slice's.
|
|
479
|
+
if (model.regionOf(file).owner !== slice.id) continue;
|
|
480
|
+
const placed = layerOf(slice, file);
|
|
481
|
+
if (placed.layer === null || !knownLayer(model.config, placed.layer)) unclassified.push(file);
|
|
482
|
+
}
|
|
483
|
+
if (unclassified.length === 0) continue;
|
|
484
|
+
findings.push(
|
|
485
|
+
finding({
|
|
486
|
+
code: "VSA006",
|
|
487
|
+
severity: "warning",
|
|
488
|
+
artifact: slice.id,
|
|
489
|
+
path: slice.path,
|
|
490
|
+
field: "/intentset/slice/layers",
|
|
491
|
+
message: `${unclassified.length === 1 ? "1 file" : `${unclassified.length} files`} of ${slice.id} ${unclassified.length === 1 ? "is" : "are"} in no single layer the matrix knows, so ${unclassified.length === 1 ? "its" : "their"} imports were not layer-checked: ${unclassified.slice(0, 3).join(", ")}${unclassified.length > 3 ? ` and ${unclassified.length - 3} more` : ""}.`,
|
|
492
|
+
remediation:
|
|
493
|
+
"Cover each file with exactly one of the presentation, application, policy, model and external layer patterns (VSA006).",
|
|
494
|
+
paths: unclassified,
|
|
495
|
+
}),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
return findings;
|
|
499
|
+
}
|