@intentset/architecture 0.8.0 → 0.9.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/areas.d.ts +1 -1
- package/dist/areas.d.ts.map +1 -1
- package/dist/areas.js +3 -0
- package/dist/areas.js.map +1 -1
- package/dist/baseline.d.ts +96 -11
- package/dist/baseline.d.ts.map +1 -1
- package/dist/baseline.js +219 -22
- package/dist/baseline.js.map +1 -1
- package/dist/boundaries.d.ts +11 -2
- package/dist/boundaries.d.ts.map +1 -1
- package/dist/boundaries.js +175 -17
- package/dist/boundaries.js.map +1 -1
- package/dist/check.d.ts +9 -2
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +21 -6
- package/dist/check.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +32 -1
- package/dist/config.js.map +1 -1
- package/dist/extract.d.ts +36 -1
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +142 -22
- package/dist/extract.js.map +1 -1
- package/dist/finding.d.ts +12 -0
- package/dist/finding.d.ts.map +1 -1
- package/dist/finding.js +6 -1
- package/dist/finding.js.map +1 -1
- package/dist/imports.d.ts +16 -0
- package/dist/imports.d.ts.map +1 -1
- package/dist/imports.js +17 -2
- package/dist/imports.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -5
- package/dist/index.js.map +1 -1
- package/dist/layers.d.ts +6 -0
- package/dist/layers.d.ts.map +1 -1
- package/dist/layers.js +19 -0
- package/dist/layers.js.map +1 -1
- package/dist/names.d.ts +48 -0
- package/dist/names.d.ts.map +1 -0
- package/dist/names.js +194 -0
- package/dist/names.js.map +1 -0
- package/package.json +2 -2
- package/src/areas.ts +3 -1
- package/src/baseline.ts +266 -24
- package/src/boundaries.ts +191 -22
- package/src/check.ts +34 -7
- package/src/config.ts +44 -1
- package/src/extract.ts +168 -22
- package/src/finding.ts +19 -1
- package/src/imports.ts +25 -3
- package/src/index.ts +16 -4
- package/src/layers.ts +18 -0
- package/src/names.ts +215 -0
package/src/boundaries.ts
CHANGED
|
@@ -20,12 +20,24 @@
|
|
|
20
20
|
* package first, since that is the surface it should use.
|
|
21
21
|
* 5. AMP006 a policy or model file importing its slice's client seam or
|
|
22
22
|
* infrastructure, or a backend SDK.
|
|
23
|
-
* 6. VSA006 an edge inside one slice that the layer matrix does not allow
|
|
23
|
+
* 6. VSA006 an edge inside one slice that the layer matrix does not allow;
|
|
24
|
+
* or, where the edge's own target is allowed or in no layer (an
|
|
25
|
+
* entrypoint, a barrel in a permitted layer, another slice's
|
|
26
|
+
* entrypoint), a name it takes that is defined in a layer the
|
|
27
|
+
* importer's may not import. The file that defines a name
|
|
28
|
+
* decides, however many files pass it on, and another slice's
|
|
29
|
+
* contract does not widen what a layer may reach (VSA §5,
|
|
30
|
+
* profile §3). A name that cannot be followed is listed as not
|
|
31
|
+
* checked.
|
|
24
32
|
*
|
|
25
33
|
* Beside those: TS002 (warning) when one slice reaches another's entrypoint
|
|
26
34
|
* by relative path although an alias for it is configured; AMP002 when a
|
|
27
|
-
* slice file outside its client seam imports a backend SDK;
|
|
28
|
-
*
|
|
35
|
+
* slice file outside its client seam imports a backend SDK; TS007 when a
|
|
36
|
+
* slice file imports a package its layer's `forbiddenPackages` names, such as
|
|
37
|
+
* a UI framework in a policy, a model or the client seam; TS001 (warning)
|
|
38
|
+
* when an entrypoint re-exports with `export *`. TS008, a slice file outside
|
|
39
|
+
* presentation that renders JSX, is `checkJsx`: it reads the file, not an
|
|
40
|
+
* edge.
|
|
29
41
|
*
|
|
30
42
|
* Test files get the separate pass profile §3 asks for: they may import
|
|
31
43
|
* their own slice's internals, and VSA003 and TS003 still apply to anyone
|
|
@@ -40,9 +52,10 @@
|
|
|
40
52
|
*/
|
|
41
53
|
import { isAreaSchema } from "./areas.ts";
|
|
42
54
|
import { type Finding, finding, listSome } from "./finding.ts";
|
|
43
|
-
import type
|
|
44
|
-
import { inSeam, isPure, knownLayer, layerAllows, layerOf } from "./layers.ts";
|
|
45
|
-
import {
|
|
55
|
+
import { type ImportEdge, takenBy } from "./imports.ts";
|
|
56
|
+
import { fileLayer, inSeam, isPure, knownLayer, layerAllows, layerOf } from "./layers.ts";
|
|
57
|
+
import { NameIndex } from "./names.ts";
|
|
58
|
+
import { compareStrings, matchAny, matchPattern } from "./patterns.ts";
|
|
46
59
|
import { describePackage, type Model, type Region, type SliceInfo } from "./regions.ts";
|
|
47
60
|
import type { Resolver } from "./resolve.ts";
|
|
48
61
|
|
|
@@ -61,6 +74,8 @@ export interface BoundaryResult {
|
|
|
61
74
|
fileEdges: number;
|
|
62
75
|
/** Those between two different slices. */
|
|
63
76
|
crossSliceEdges: number;
|
|
77
|
+
/** Names a layered file takes that could not be followed to the file that defines them: not checked, never passed. */
|
|
78
|
+
untraced: string[];
|
|
64
79
|
}
|
|
65
80
|
|
|
66
81
|
function how(edge: ImportEdge): string {
|
|
@@ -118,9 +133,15 @@ function describeRegion(region: Region): string {
|
|
|
118
133
|
}
|
|
119
134
|
}
|
|
120
135
|
|
|
121
|
-
export function checkBoundaries(
|
|
136
|
+
export function checkBoundaries(
|
|
137
|
+
model: Model,
|
|
138
|
+
edges: readonly ImportEdge[],
|
|
139
|
+
resolver: Resolver,
|
|
140
|
+
names: NameIndex = new NameIndex(edges, new Map()),
|
|
141
|
+
): BoundaryResult {
|
|
122
142
|
const { config } = model;
|
|
123
143
|
const findings: Finding[] = [];
|
|
144
|
+
const untraced: string[] = [];
|
|
124
145
|
const sliceEdges = new Map<string, SliceEdge>();
|
|
125
146
|
let fileEdges = 0;
|
|
126
147
|
let crossSliceEdges = 0;
|
|
@@ -144,7 +165,10 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
144
165
|
if (source !== undefined && inSeam(source, from) && matchAny(config.responseParserModules, to)) return null;
|
|
145
166
|
}
|
|
146
167
|
const who = test ? `Test file ${from}` : from;
|
|
147
|
-
const subject = {
|
|
168
|
+
const subject = {
|
|
169
|
+
edges: [{ from, to }, ...(owner !== null ? [{ from: owner, to: target.id }] : [])],
|
|
170
|
+
taken: takenBy(edge),
|
|
171
|
+
};
|
|
148
172
|
const why = "It is a warning because the importer lies outside the declared scope (VSA §9).";
|
|
149
173
|
if (isScreen(to)) {
|
|
150
174
|
if (!test && matchAny(config.routerFiles, from)) return null;
|
|
@@ -171,6 +195,76 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
171
195
|
});
|
|
172
196
|
};
|
|
173
197
|
|
|
198
|
+
/** What the layer matrix lets `layer` import, itself first. */
|
|
199
|
+
const allowedFrom = (layer: string): string[] =>
|
|
200
|
+
[layer, ...(config.layerMatrix[layer] ?? [])].filter((value, i, all) => all.indexOf(value) === i);
|
|
201
|
+
|
|
202
|
+
// VSA006 for the names an edge takes through files that pass them on: each is judged by the layer of the file
|
|
203
|
+
// that defines it, in the importer's slice or in the slice whose entrypoint it came through.
|
|
204
|
+
const passedOn = (edge: ImportEdge, source: SliceInfo): Finding | null => {
|
|
205
|
+
const from = edge.from;
|
|
206
|
+
const a = layerOf(source, from);
|
|
207
|
+
if (a.layer === null || !knownLayer(config, a.layer)) return null;
|
|
208
|
+
type Refused = { names: string[]; typed: string[]; through: string[]; layer: string; owner: SliceInfo };
|
|
209
|
+
const refused = new Map<string, Refused>();
|
|
210
|
+
for (const definition of names.definitions(edge)) {
|
|
211
|
+
const { origin } = definition;
|
|
212
|
+
if (origin.kind === "untraced") {
|
|
213
|
+
const what = definition.name === "*" ? "the names" : `the name ${definition.name}`;
|
|
214
|
+
untraced.push(
|
|
215
|
+
`${from}:${edge.line}: ${what} "${edge.specifier}" passes on could not be followed to the file that defines ${definition.name === "*" ? "them" : "it"} (${origin.why}), so ${definition.name === "*" ? "their layers were" : "its layer was"} not checked`,
|
|
216
|
+
);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
// A name the edge's own target defines is judged with the edge itself; a package's, by the package rules.
|
|
220
|
+
if (origin.kind === "package" || origin.through.length === 0) continue;
|
|
221
|
+
const owner = sliceOf(model.regionOf(origin.file));
|
|
222
|
+
if (owner === undefined) continue;
|
|
223
|
+
const b = layerOf(owner, origin.file);
|
|
224
|
+
if (b.layer === null || !knownLayer(config, b.layer) || layerAllows(config, a.layer, b.layer)) continue;
|
|
225
|
+
const entry = refused.get(origin.file) ?? {
|
|
226
|
+
names: [],
|
|
227
|
+
typed: [],
|
|
228
|
+
through: origin.through,
|
|
229
|
+
layer: b.layer,
|
|
230
|
+
owner,
|
|
231
|
+
};
|
|
232
|
+
entry.names.push(definition.name);
|
|
233
|
+
entry.typed.push(`${definition.typeOnly ? "type " : ""}${definition.name} from ${origin.file}`);
|
|
234
|
+
refused.set(origin.file, entry);
|
|
235
|
+
}
|
|
236
|
+
if (refused.size === 0) return null;
|
|
237
|
+
const files = [...refused.keys()].sort(compareStrings);
|
|
238
|
+
const foreign = files.some((file) => refused.get(file)?.owner.id !== source.id);
|
|
239
|
+
const parts = files.map((file) => {
|
|
240
|
+
const entry = refused.get(file) as Refused;
|
|
241
|
+
const of = entry.owner.id === source.id ? "" : ` of ${entry.owner.id}`;
|
|
242
|
+
const via = entry.through.length > 1 ? ` by way of ${entry.through.slice(1).join(", ")}` : "";
|
|
243
|
+
return `${entry.names.join(", ")} from ${file}, in the ${entry.layer} layer${of}${via}`;
|
|
244
|
+
});
|
|
245
|
+
const owners = [...new Set(files.map((file) => refused.get(file)?.owner.id as string))].filter(
|
|
246
|
+
(id) => id !== source.id,
|
|
247
|
+
);
|
|
248
|
+
return finding({
|
|
249
|
+
code: "VSA006",
|
|
250
|
+
artifact: source.id,
|
|
251
|
+
path: from,
|
|
252
|
+
location: { line: edge.line },
|
|
253
|
+
message: `${from} is in the ${a.layer} layer of ${source.id} and ${how(edge)}, which passes on ${parts.join("; and ")}.`,
|
|
254
|
+
remediation: `The ${a.layer} layer may import ${allowedFrom(a.layer).join(", ")}, whichever file passes a name on${foreign ? ", and another slice's contract does not widen that" : ""}; take what it needs from a layer it may import, such as a use-case that makes the call (profile §3).`,
|
|
255
|
+
edges: [
|
|
256
|
+
{ from, to: edge.to as string },
|
|
257
|
+
...files.map((file) => ({ from, to: file })),
|
|
258
|
+
...owners.map((id) => ({ from: source.id, to: id })),
|
|
259
|
+
],
|
|
260
|
+
// Each name with the file that defines it, so a name passed on from another file is a new entry.
|
|
261
|
+
taken: {
|
|
262
|
+
...takenBy(edge),
|
|
263
|
+
names: files.flatMap((file) => (refused.get(file) as Refused).typed).sort(compareStrings),
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
};
|
|
267
|
+
|
|
174
268
|
for (const edge of edges) {
|
|
175
269
|
const from = edge.from;
|
|
176
270
|
if (!model.inScope(from)) {
|
|
@@ -195,6 +289,7 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
195
289
|
message: `Test file ${from} ${how(edge)}, a screen of ${target.id}, and tests must not bypass another slice's contract.`,
|
|
196
290
|
remediation: `Test ${target.id}'s screens from ${target.id}'s own tests, or drive them through the router (profile §3).`,
|
|
197
291
|
edges: [{ from, to: edge.to }],
|
|
292
|
+
taken: takenBy(edge),
|
|
198
293
|
}),
|
|
199
294
|
);
|
|
200
295
|
} else if (!target.meta.entrypoints.includes(edge.to)) {
|
|
@@ -207,6 +302,7 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
207
302
|
message: `Test file ${from} ${how(edge)}, a private file of ${target.id}; ${describeSurfaces(model, target, from)}.`,
|
|
208
303
|
remediation: `${importInstead(model, target, from)} instead; tests may reach only their own slice's internals (profile §3).`,
|
|
209
304
|
edges: [{ from, to: edge.to }],
|
|
305
|
+
taken: takenBy(edge),
|
|
210
306
|
}),
|
|
211
307
|
);
|
|
212
308
|
}
|
|
@@ -228,16 +324,14 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
228
324
|
message: `The entrypoint of ${source.id} re-exports everything from "${edge.specifier}" with a wildcard, which can publish private modules as contract.`,
|
|
229
325
|
remediation: "Export the reviewed names explicitly (VSA §4, TS001).",
|
|
230
326
|
edges: [{ from, to: edge.to ?? edge.specifier }],
|
|
327
|
+
taken: takenBy(edge),
|
|
231
328
|
}),
|
|
232
329
|
);
|
|
233
330
|
}
|
|
234
331
|
|
|
235
332
|
if (edge.to === null) {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
!model.underBackend(from) &&
|
|
239
|
-
matchAny(config.backendSdks, edge.specifier.replace(/^node:/, ""))
|
|
240
|
-
) {
|
|
333
|
+
const specifier = edge.specifier.replace(/^node:/, "");
|
|
334
|
+
if (source !== undefined && !model.underBackend(from) && matchAny(config.backendSdks, specifier)) {
|
|
241
335
|
if (isPure(source, from)) {
|
|
242
336
|
findings.push(
|
|
243
337
|
finding({
|
|
@@ -249,6 +343,7 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
249
343
|
remediation:
|
|
250
344
|
"Keep SDK and generated client types out of policies and models; adapt them in the client seam (AMP006).",
|
|
251
345
|
edges: [{ from, to: edge.specifier }],
|
|
346
|
+
taken: takenBy(edge),
|
|
252
347
|
}),
|
|
253
348
|
);
|
|
254
349
|
} else if (!inSeam(source, from)) {
|
|
@@ -261,9 +356,28 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
261
356
|
message: `${from} ${how(edge)}, a backend SDK, outside the client seam of ${source.id}.`,
|
|
262
357
|
remediation: `Move the backend call into ${source.id}'s client seam and call that from here (AMP002, VSA010).`,
|
|
263
358
|
edges: [{ from, to: edge.specifier }],
|
|
359
|
+
taken: takenBy(edge),
|
|
264
360
|
}),
|
|
265
361
|
);
|
|
266
362
|
}
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
const layer = source !== undefined && !model.underBackend(from) ? fileLayer(source, from) : null;
|
|
366
|
+
const pattern =
|
|
367
|
+
layer === null ? undefined : (config.forbiddenPackages[layer] ?? []).find((p) => matchPattern(p, specifier));
|
|
368
|
+
if (source !== undefined && layer !== null && pattern !== undefined) {
|
|
369
|
+
findings.push(
|
|
370
|
+
finding({
|
|
371
|
+
code: "TS007",
|
|
372
|
+
artifact,
|
|
373
|
+
path: from,
|
|
374
|
+
location,
|
|
375
|
+
message: `${from} is in the ${layer} layer of ${source.id} and ${how(edge)}, a package the ${layer} layer may not import (forbiddenPackages: ${pattern}).`,
|
|
376
|
+
remediation: forbiddenRemediation(layer, specifier),
|
|
377
|
+
edges: [{ from, to: edge.specifier }],
|
|
378
|
+
taken: takenBy(edge),
|
|
379
|
+
}),
|
|
380
|
+
);
|
|
267
381
|
}
|
|
268
382
|
continue;
|
|
269
383
|
}
|
|
@@ -280,7 +394,10 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
280
394
|
sliceEdges.set(key, entry);
|
|
281
395
|
}
|
|
282
396
|
if (!model.inScope(to) && target === undefined) continue;
|
|
283
|
-
const subject = {
|
|
397
|
+
const subject = {
|
|
398
|
+
edges: [{ from, to }, ...(source && target ? [{ from: source.id, to: target.id }] : [])],
|
|
399
|
+
taken: takenBy(edge),
|
|
400
|
+
};
|
|
284
401
|
|
|
285
402
|
// 1. AMP001, and AMP011 for an area repository's schema bridge (profile §9). The shared backend
|
|
286
403
|
// package is backend code: what it may import is AMP008's question.
|
|
@@ -415,6 +532,10 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
415
532
|
);
|
|
416
533
|
}
|
|
417
534
|
}
|
|
535
|
+
if (source !== undefined) {
|
|
536
|
+
const passed = passedOn(edge, source);
|
|
537
|
+
if (passed !== null) findings.push(passed);
|
|
538
|
+
}
|
|
418
539
|
continue;
|
|
419
540
|
}
|
|
420
541
|
|
|
@@ -455,12 +576,13 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
455
576
|
if (target !== undefined) {
|
|
456
577
|
const a = layerOf(source, from);
|
|
457
578
|
const b = layerOf(source, to);
|
|
458
|
-
if (
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
)
|
|
579
|
+
if (
|
|
580
|
+
a.layer !== null &&
|
|
581
|
+
b.layer !== null &&
|
|
582
|
+
knownLayer(config, a.layer) &&
|
|
583
|
+
knownLayer(config, b.layer) &&
|
|
584
|
+
!layerAllows(config, a.layer, b.layer)
|
|
585
|
+
) {
|
|
464
586
|
findings.push(
|
|
465
587
|
finding({
|
|
466
588
|
code: "VSA006",
|
|
@@ -468,15 +590,62 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
468
590
|
path: from,
|
|
469
591
|
location,
|
|
470
592
|
message: `${from} is in the ${a.layer} layer of ${source.id} and ${how(edge)}, which is in the ${b.layer} layer.`,
|
|
471
|
-
remediation: `The ${a.layer} layer may import ${
|
|
593
|
+
remediation: `The ${a.layer} layer may import ${allowedFrom(a.layer).join(", ")}; move the dependency so it flows downward (profile §3).`,
|
|
472
594
|
...subject,
|
|
473
595
|
}),
|
|
474
596
|
);
|
|
597
|
+
continue;
|
|
475
598
|
}
|
|
599
|
+
const passed = passedOn(edge, source);
|
|
600
|
+
if (passed !== null) findings.push(passed);
|
|
476
601
|
}
|
|
477
602
|
}
|
|
478
603
|
|
|
479
|
-
return { findings, sliceEdges, fileEdges, crossSliceEdges };
|
|
604
|
+
return { findings, sliceEdges, fileEdges, crossSliceEdges, untraced: [...new Set(untraced)].sort(compareStrings) };
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function forbiddenRemediation(layer: string, specifier: string): string {
|
|
608
|
+
if (layer === "policy" || layer === "model") {
|
|
609
|
+
return `Policies and models stay free of UI and transport: keep what needs "${specifier}" in the presentation layer and pass plain values in (profile §3, TS007).`;
|
|
610
|
+
}
|
|
611
|
+
if (layer === "external") {
|
|
612
|
+
return `The client seam is transport: return plain values from it, and let a hook in the presentation layer hold the state "${specifier}" provides (profile §3, TS007).`;
|
|
613
|
+
}
|
|
614
|
+
return `Move what needs "${specifier}" to a layer that may import it, or change forbiddenPackages in .intentset/architecture.yaml if the policy is wrong (TS007).`;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** The profile's layers other than presentation, which render no JSX (TS008). A layer of the repository's own naming is not judged. */
|
|
618
|
+
const NO_JSX_LAYERS = ["application", "policy", "model", "external"];
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* TS008: a slice file outside the presentation layer that renders JSX. The automatic runtime needs no import for it,
|
|
622
|
+
* so this reads the file rather than an edge. Composition, shared code and tests may render; a file in no layer is
|
|
623
|
+
* VSA006's warning already.
|
|
624
|
+
*/
|
|
625
|
+
export function checkJsx(model: Model, jsx: ReadonlyMap<string, number>): Finding[] {
|
|
626
|
+
const findings: Finding[] = [];
|
|
627
|
+
for (const path of [...jsx.keys()].sort(compareStrings)) {
|
|
628
|
+
if (!model.inScope(path) || model.isTest(path) || model.underBackend(path)) continue;
|
|
629
|
+
const region = model.regionOf(path);
|
|
630
|
+
const slice = region.kind === "slice" ? model.sliceById.get(region.owner as string) : undefined;
|
|
631
|
+
if (slice === undefined || slice.meta.entrypoints.includes(path)) continue;
|
|
632
|
+
const layer = fileLayer(slice, path);
|
|
633
|
+
if (layer === null || !NO_JSX_LAYERS.includes(layer)) continue;
|
|
634
|
+
findings.push(
|
|
635
|
+
finding({
|
|
636
|
+
code: "TS008",
|
|
637
|
+
artifact: slice.id,
|
|
638
|
+
path,
|
|
639
|
+
location: { line: jsx.get(path) as number },
|
|
640
|
+
message: `${path} is in the ${layer} layer of ${slice.id} and renders JSX, which only the presentation layer may.`,
|
|
641
|
+
remediation:
|
|
642
|
+
layer === "external"
|
|
643
|
+
? "The client seam is transport: return data from it and render it in a component under the presentation layer (profile §3, TS008)."
|
|
644
|
+
: "Move the markup into a component in the presentation layer, and pass it the values this file computes (profile §3, TS008).",
|
|
645
|
+
}),
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return findings;
|
|
480
649
|
}
|
|
481
650
|
|
|
482
651
|
/** One warning per slice whose code files are in no declared layer, or in a layer the matrix does not know (VSA006). */
|
package/src/check.ts
CHANGED
|
@@ -21,14 +21,21 @@ import {
|
|
|
21
21
|
sortDiagnostics,
|
|
22
22
|
} from "@intentset/core";
|
|
23
23
|
import { AREA_REVIEW_REQUIRED, checkAreas } from "./areas.ts";
|
|
24
|
-
import {
|
|
25
|
-
|
|
24
|
+
import {
|
|
25
|
+
applyBaseline,
|
|
26
|
+
type BaselineEntry,
|
|
27
|
+
type BaselineSubject,
|
|
28
|
+
type Mode,
|
|
29
|
+
staleEntryDiagnostics,
|
|
30
|
+
} from "./baseline.ts";
|
|
31
|
+
import { checkBoundaries, checkJsx, checkUnclassified } from "./boundaries.ts";
|
|
26
32
|
import { checkClaims } from "./claims.ts";
|
|
27
33
|
import { ARCHITECTURE_CONFIG_PATH, type ArchitectureConfig, readArchitectureConfig, resolveConfig } from "./config.ts";
|
|
28
34
|
import { checkDependencies } from "./dependencies.ts";
|
|
29
35
|
import { applyExceptions, type ExceptionRecord, readExceptionRecord, readExceptions } from "./exceptions.ts";
|
|
30
36
|
import { type Finding, finding } from "./finding.ts";
|
|
31
37
|
import { buildImportGraph, type ImportEdge } from "./imports.ts";
|
|
38
|
+
import { NameIndex } from "./names.ts";
|
|
32
39
|
import { checkOwnership } from "./ownership.ts";
|
|
33
40
|
import { isCode } from "./paths.ts";
|
|
34
41
|
import { compareStrings } from "./patterns.ts";
|
|
@@ -43,6 +50,8 @@ export interface CheckOptions {
|
|
|
43
50
|
/** Exception records handed in, in addition to architecture/exceptions/*.yaml in the tree. */
|
|
44
51
|
exceptions?: readonly (ExceptionRecord | Record<string, unknown>)[];
|
|
45
52
|
baseline?: readonly BaselineEntry[];
|
|
53
|
+
/** The baseline file's repository-relative path, which a stale entry's VSA014 names. Default none. */
|
|
54
|
+
baselinePath?: string;
|
|
46
55
|
/** "migration" applies the baseline; "strict" ignores it. Default migration. */
|
|
47
56
|
mode?: Mode;
|
|
48
57
|
/** The date exceptions expire against, YYYY-MM-DD. Default today, UTC. */
|
|
@@ -72,7 +81,7 @@ export interface ArchitectureSummary {
|
|
|
72
81
|
excepted: number;
|
|
73
82
|
/** Diagnostics the baseline turned into warnings (migration mode only). */
|
|
74
83
|
baselined: number;
|
|
75
|
-
/** Baseline entries that matched nothing
|
|
84
|
+
/** Baseline entries that matched nothing: each is a VSA014 error until the baseline is written without it. */
|
|
76
85
|
baselineStale: number;
|
|
77
86
|
/** What could not be checked: never a pass (invariant 6). */
|
|
78
87
|
unresolved: string[];
|
|
@@ -85,6 +94,11 @@ export interface ArchitectureResult {
|
|
|
85
94
|
summary: ArchitectureSummary;
|
|
86
95
|
/** The resolved import graph, for reports and impact views. */
|
|
87
96
|
edges: ImportEdge[];
|
|
97
|
+
/**
|
|
98
|
+
* What each diagnostic that came from a finding is about, keyed by the diagnostic objects in `diagnostics`: what
|
|
99
|
+
* `writeBaseline` needs to write entries a later run matches (VSA §9).
|
|
100
|
+
*/
|
|
101
|
+
subjects: ReadonlyMap<Diagnostic, BaselineSubject>;
|
|
88
102
|
/**
|
|
89
103
|
* Where every file falls, sorted by path: each production file's region and
|
|
90
104
|
* owner, every test file with the slice its claims match, and nothing for a
|
|
@@ -163,8 +177,10 @@ export function checkArchitecture(
|
|
|
163
177
|
|
|
164
178
|
findings.push(...checkClaims(model, graph, registriesPath));
|
|
165
179
|
findings.push(...checkOwnership(model, graph, level));
|
|
166
|
-
const boundaries = checkBoundaries(model, imports.edges, resolver);
|
|
180
|
+
const boundaries = checkBoundaries(model, imports.edges, resolver, new NameIndex(imports.edges, imports.exports));
|
|
167
181
|
findings.push(...boundaries.findings);
|
|
182
|
+
unresolved.push(...boundaries.untraced);
|
|
183
|
+
findings.push(...checkJsx(model, imports.jsx));
|
|
168
184
|
findings.push(...checkUnclassified(model));
|
|
169
185
|
const areaCheck = checkAreas(model, imports.edges);
|
|
170
186
|
findings.push(...areaCheck.findings);
|
|
@@ -190,8 +206,19 @@ export function checkArchitecture(
|
|
|
190
206
|
...recordProblems.map((item) => item.diagnostic),
|
|
191
207
|
...excepted.recordFindings.map((item) => item.diagnostic),
|
|
192
208
|
];
|
|
193
|
-
const
|
|
194
|
-
|
|
209
|
+
const about = new Map<Diagnostic, BaselineSubject>();
|
|
210
|
+
for (const item of [...excepted.findings, ...recordProblems, ...excepted.recordFindings]) {
|
|
211
|
+
about.set(item.diagnostic, { edges: item.edges, ...(item.taken !== undefined ? { taken: item.taken } : {}) });
|
|
212
|
+
}
|
|
213
|
+
const baseline = applyBaseline(diagnostics, options.baseline ?? [], mode, (diagnostic) => about.get(diagnostic));
|
|
214
|
+
// applyBaseline maps one for one, so the subjects follow the diagnostics it returns.
|
|
215
|
+
const subjects = new Map<Diagnostic, BaselineSubject>();
|
|
216
|
+
baseline.diagnostics.forEach((diagnostic, i) => {
|
|
217
|
+
const subject = about.get(diagnostics[i]);
|
|
218
|
+
if (subject !== undefined) subjects.set(diagnostic, subject);
|
|
219
|
+
});
|
|
220
|
+
const stale = staleEntryDiagnostics(baseline.stale, options.baseline ?? [], options.baselinePath ?? null);
|
|
221
|
+
diagnostics = sortDiagnostics([...baseline.diagnostics, ...stale]);
|
|
195
222
|
|
|
196
223
|
const owned = (path: string) => {
|
|
197
224
|
const kind = model.regionOf(path).kind;
|
|
@@ -224,7 +251,7 @@ export function checkArchitecture(
|
|
|
224
251
|
ownership.push({ path, region: region.kind, owner: region.owner ?? null });
|
|
225
252
|
}
|
|
226
253
|
ownership.sort((a, b) => compareStrings(a.path, b.path));
|
|
227
|
-
return { diagnostics, summary, edges: imports.edges, ownership };
|
|
254
|
+
return { diagnostics, summary, edges: imports.edges, ownership, subjects };
|
|
228
255
|
}
|
|
229
256
|
|
|
230
257
|
/**
|
package/src/config.ts
CHANGED
|
@@ -53,6 +53,11 @@ export interface ArchitectureConfig {
|
|
|
53
53
|
backendSdks: string[];
|
|
54
54
|
/** The internal layer matrix (profile §3): each layer may import itself and the layers listed. */
|
|
55
55
|
layerMatrix: Record<string, string[]>;
|
|
56
|
+
/**
|
|
57
|
+
* Packages a layer may not import, by layer name: specifier patterns matched, as backendSdks are, against the
|
|
58
|
+
* specifier as written with `node:` removed (TS007). A layer not named may import any package.
|
|
59
|
+
*/
|
|
60
|
+
forbiddenPackages: Record<string, string[]>;
|
|
56
61
|
/** Export conditions tried first when resolving a workspace package's entry, before types, import and default. */
|
|
57
62
|
conditions: string[];
|
|
58
63
|
/** Areas (profile §9). Empty for a repository with one backend, which none of AMP007 to AMP013 concern. */
|
|
@@ -63,6 +68,21 @@ export interface ArchitectureConfig {
|
|
|
63
68
|
schemaBridge: string | null;
|
|
64
69
|
}
|
|
65
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The UI frameworks profile §3 keeps out of policies, models and the client seam by default. A repository adds its
|
|
73
|
+
* UI kit and data-fetching hooks (`@mui/**`, `@tanstack/react-query/**`) in `forbiddenPackages`.
|
|
74
|
+
*/
|
|
75
|
+
export const UI_FRAMEWORKS = [
|
|
76
|
+
"react/**",
|
|
77
|
+
"react-dom/**",
|
|
78
|
+
"react-native/**",
|
|
79
|
+
"preact/**",
|
|
80
|
+
"vue/**",
|
|
81
|
+
"svelte/**",
|
|
82
|
+
"solid-js/**",
|
|
83
|
+
"@angular/**",
|
|
84
|
+
];
|
|
85
|
+
|
|
66
86
|
export const DEFAULT_ARCHITECTURE_CONFIG: ArchitectureConfig = {
|
|
67
87
|
scope: ["**"],
|
|
68
88
|
sourceRoots: ["src/**"],
|
|
@@ -83,6 +103,7 @@ export const DEFAULT_ARCHITECTURE_CONFIG: ArchitectureConfig = {
|
|
|
83
103
|
model: ["model"],
|
|
84
104
|
external: ["external", "model"],
|
|
85
105
|
},
|
|
106
|
+
forbiddenPackages: { policy: UI_FRAMEWORKS, model: UI_FRAMEWORKS, external: UI_FRAMEWORKS },
|
|
86
107
|
conditions: [],
|
|
87
108
|
areas: [],
|
|
88
109
|
sharedBackend: [],
|
|
@@ -121,7 +142,9 @@ const PATTERN_KEYS = new Set<string>([
|
|
|
121
142
|
"sharedBackend",
|
|
122
143
|
]);
|
|
123
144
|
|
|
124
|
-
const KNOWN_KEYS = [...LIST_KEYS, "screensGlob", "layerMatrix", "areas", "schemaBridge"].sort(
|
|
145
|
+
const KNOWN_KEYS = [...LIST_KEYS, "screensGlob", "layerMatrix", "forbiddenPackages", "areas", "schemaBridge"].sort(
|
|
146
|
+
compareStrings,
|
|
147
|
+
);
|
|
125
148
|
|
|
126
149
|
/** The defaults with a partial configuration laid over them; a key given replaces the default whole. */
|
|
127
150
|
export function resolveConfig(...layers: (Partial<ArchitectureConfig> | undefined)[]): ArchitectureConfig {
|
|
@@ -223,6 +246,26 @@ export function readArchitectureConfig(
|
|
|
223
246
|
} else config.layerMatrix = matrix;
|
|
224
247
|
continue;
|
|
225
248
|
}
|
|
249
|
+
if (key === "forbiddenPackages") {
|
|
250
|
+
const lists = readMatrix(value);
|
|
251
|
+
const bad =
|
|
252
|
+
lists === null
|
|
253
|
+
? undefined
|
|
254
|
+
: Object.values(lists)
|
|
255
|
+
.flat()
|
|
256
|
+
.find((pattern) => pattern === "" || validatePattern(pattern) !== null);
|
|
257
|
+
if (lists === null || bad !== undefined) {
|
|
258
|
+
report(
|
|
259
|
+
field,
|
|
260
|
+
at(field),
|
|
261
|
+
lists === null
|
|
262
|
+
? "forbiddenPackages must map each layer name to a list of package patterns."
|
|
263
|
+
: `Pattern ${bad} in \`forbiddenPackages\` ${bad === "" ? "is empty" : validatePattern(bad as string)}.`,
|
|
264
|
+
'Write each layer as `policy: [react/**, "@mui/**"]`.',
|
|
265
|
+
);
|
|
266
|
+
} else config.forbiddenPackages = lists;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
226
269
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item !== "")) {
|
|
227
270
|
report(field, at(field), `\`${key}\` must be a list of non-empty strings.`, `Write ${key} as a sequence.`);
|
|
228
271
|
continue;
|