@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.
Files changed (56) hide show
  1. package/dist/areas.d.ts +1 -1
  2. package/dist/areas.d.ts.map +1 -1
  3. package/dist/areas.js +3 -0
  4. package/dist/areas.js.map +1 -1
  5. package/dist/baseline.d.ts +96 -11
  6. package/dist/baseline.d.ts.map +1 -1
  7. package/dist/baseline.js +219 -22
  8. package/dist/baseline.js.map +1 -1
  9. package/dist/boundaries.d.ts +11 -2
  10. package/dist/boundaries.d.ts.map +1 -1
  11. package/dist/boundaries.js +175 -17
  12. package/dist/boundaries.js.map +1 -1
  13. package/dist/check.d.ts +9 -2
  14. package/dist/check.d.ts.map +1 -1
  15. package/dist/check.js +21 -6
  16. package/dist/check.js.map +1 -1
  17. package/dist/config.d.ts +10 -0
  18. package/dist/config.d.ts.map +1 -1
  19. package/dist/config.js +32 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/extract.d.ts +36 -1
  22. package/dist/extract.d.ts.map +1 -1
  23. package/dist/extract.js +142 -22
  24. package/dist/extract.js.map +1 -1
  25. package/dist/finding.d.ts +12 -0
  26. package/dist/finding.d.ts.map +1 -1
  27. package/dist/finding.js +6 -1
  28. package/dist/finding.js.map +1 -1
  29. package/dist/imports.d.ts +16 -0
  30. package/dist/imports.d.ts.map +1 -1
  31. package/dist/imports.js +17 -2
  32. package/dist/imports.js.map +1 -1
  33. package/dist/index.d.ts +8 -7
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +6 -5
  36. package/dist/index.js.map +1 -1
  37. package/dist/layers.d.ts +6 -0
  38. package/dist/layers.d.ts.map +1 -1
  39. package/dist/layers.js +19 -0
  40. package/dist/layers.js.map +1 -1
  41. package/dist/names.d.ts +48 -0
  42. package/dist/names.d.ts.map +1 -0
  43. package/dist/names.js +194 -0
  44. package/dist/names.js.map +1 -0
  45. package/package.json +2 -2
  46. package/src/areas.ts +3 -1
  47. package/src/baseline.ts +266 -24
  48. package/src/boundaries.ts +191 -22
  49. package/src/check.ts +34 -7
  50. package/src/config.ts +44 -1
  51. package/src/extract.ts +168 -22
  52. package/src/finding.ts +19 -1
  53. package/src/imports.ts +25 -3
  54. package/src/index.ts +16 -4
  55. package/src/layers.ts +18 -0
  56. 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; TS001 (warning)
28
- * when an entrypoint re-exports with `export *`.
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 { ImportEdge } from "./imports.ts";
44
- import { inSeam, isPure, knownLayer, layerAllows, layerOf } from "./layers.ts";
45
- import { matchAny, matchPattern } from "./patterns.ts";
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(model: Model, edges: readonly ImportEdge[], resolver: Resolver): BoundaryResult {
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 = { edges: [{ from, to }, ...(owner !== null ? [{ from: owner, to: target.id }] : [])] };
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
- if (
237
- source !== undefined &&
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 = { edges: [{ from, to }, ...(source && target ? [{ from: source.id, to: target.id }] : [])] };
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 (a.layer === null || b.layer === null || !knownLayer(config, a.layer) || !knownLayer(config, b.layer))
459
- continue;
460
- if (!layerAllows(config, a.layer, b.layer)) {
461
- const allowed = [a.layer, ...(config.layerMatrix[a.layer] ?? [])].filter(
462
- (value, i, all) => all.indexOf(value) === i,
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 ${allowed.join(", ")}; move the dependency so it flows downward (profile §3).`,
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 { applyBaseline, type BaselineEntry, type Mode } from "./baseline.ts";
25
- import { checkBoundaries, checkUnclassified } from "./boundaries.ts";
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 and can be retired. */
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 baseline = applyBaseline(diagnostics, options.baseline ?? [], mode);
194
- diagnostics = sortDiagnostics(baseline.diagnostics);
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(compareStrings);
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;