@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.
Files changed (91) hide show
  1. package/dist/baseline.d.ts +39 -0
  2. package/dist/baseline.d.ts.map +1 -0
  3. package/dist/baseline.js +104 -0
  4. package/dist/baseline.js.map +1 -0
  5. package/dist/boundaries.d.ts +63 -0
  6. package/dist/boundaries.d.ts.map +1 -0
  7. package/dist/boundaries.js +436 -0
  8. package/dist/boundaries.js.map +1 -0
  9. package/dist/check.d.ts +73 -0
  10. package/dist/check.d.ts.map +1 -0
  11. package/dist/check.js +130 -0
  12. package/dist/check.js.map +1 -0
  13. package/dist/claims.d.ts +24 -0
  14. package/dist/claims.d.ts.map +1 -0
  15. package/dist/claims.js +201 -0
  16. package/dist/claims.js.map +1 -0
  17. package/dist/config.d.ts +53 -0
  18. package/dist/config.d.ts.map +1 -0
  19. package/dist/config.js +159 -0
  20. package/dist/config.js.map +1 -0
  21. package/dist/dependencies.d.ts +30 -0
  22. package/dist/dependencies.d.ts.map +1 -0
  23. package/dist/dependencies.js +179 -0
  24. package/dist/dependencies.js.map +1 -0
  25. package/dist/exceptions.d.ts +43 -0
  26. package/dist/exceptions.d.ts.map +1 -0
  27. package/dist/exceptions.js +233 -0
  28. package/dist/exceptions.js.map +1 -0
  29. package/dist/extract.d.ts +24 -0
  30. package/dist/extract.d.ts.map +1 -0
  31. package/dist/extract.js +316 -0
  32. package/dist/extract.js.map +1 -0
  33. package/dist/finding.d.ts +39 -0
  34. package/dist/finding.d.ts.map +1 -0
  35. package/dist/finding.js +29 -0
  36. package/dist/finding.js.map +1 -0
  37. package/dist/imports.d.ts +30 -0
  38. package/dist/imports.d.ts.map +1 -0
  39. package/dist/imports.js +48 -0
  40. package/dist/imports.js.map +1 -0
  41. package/dist/index.d.ts +23 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +22 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/layers.d.ts +38 -0
  46. package/dist/layers.d.ts.map +1 -0
  47. package/dist/layers.js +41 -0
  48. package/dist/layers.js.map +1 -0
  49. package/dist/ownership.d.ts +25 -0
  50. package/dist/ownership.d.ts.map +1 -0
  51. package/dist/ownership.js +97 -0
  52. package/dist/ownership.js.map +1 -0
  53. package/dist/paths.d.ts +18 -0
  54. package/dist/paths.d.ts.map +1 -0
  55. package/dist/paths.js +54 -0
  56. package/dist/paths.js.map +1 -0
  57. package/dist/patterns.d.ts +25 -0
  58. package/dist/patterns.d.ts.map +1 -0
  59. package/dist/patterns.js +101 -0
  60. package/dist/patterns.js.map +1 -0
  61. package/dist/regions.d.ts +85 -0
  62. package/dist/regions.d.ts.map +1 -0
  63. package/dist/regions.js +144 -0
  64. package/dist/regions.js.map +1 -0
  65. package/dist/resolve.d.ts +53 -0
  66. package/dist/resolve.d.ts.map +1 -0
  67. package/dist/resolve.js +268 -0
  68. package/dist/resolve.js.map +1 -0
  69. package/dist/tsconfig.d.ts +42 -0
  70. package/dist/tsconfig.d.ts.map +1 -0
  71. package/dist/tsconfig.js +186 -0
  72. package/dist/tsconfig.js.map +1 -0
  73. package/package.json +36 -0
  74. package/src/baseline.ts +126 -0
  75. package/src/boundaries.ts +499 -0
  76. package/src/check.ts +197 -0
  77. package/src/claims.ts +229 -0
  78. package/src/config.ts +212 -0
  79. package/src/dependencies.ts +211 -0
  80. package/src/exceptions.ts +269 -0
  81. package/src/extract.ts +344 -0
  82. package/src/finding.ts +64 -0
  83. package/src/imports.ts +77 -0
  84. package/src/index.ts +58 -0
  85. package/src/layers.ts +67 -0
  86. package/src/ownership.ts +135 -0
  87. package/src/paths.ts +53 -0
  88. package/src/patterns.ts +95 -0
  89. package/src/regions.ts +243 -0
  90. package/src/resolve.ts +283 -0
  91. package/src/tsconfig.ts +198 -0
package/src/check.ts ADDED
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `checkArchitecture`: the Traceable VSA check over one snapshot (VSA §2,
3
+ * profile §2–§4). It is pure: the graph and registries come from core, the
4
+ * repository arrives as a map of path to text, and nothing is read from disk,
5
+ * executed or fetched (invariant 7), so the conformance harness can run it
6
+ * over an in-memory tree.
7
+ *
8
+ * Order: configuration (`.intentset/architecture.yaml` in the tree, then the
9
+ * caller's), claims and ownership, the import graph and its boundaries,
10
+ * dependencies and cycles, then exceptions (VSA §9), then the baseline in
11
+ * migration mode. What could not be checked is listed in `unresolved` and,
12
+ * for imports, reported as a TS004 warning: never a pass (invariant 6).
13
+ */
14
+ import { type Diagnostic, type Graph, type Level, type Registries, sortDiagnostics } from "@intentset/core";
15
+ import { applyBaseline, type BaselineEntry, type Mode } from "./baseline.ts";
16
+ import { checkBoundaries, checkUnclassified } from "./boundaries.ts";
17
+ import { checkClaims } from "./claims.ts";
18
+ import { ARCHITECTURE_CONFIG_PATH, type ArchitectureConfig, readArchitectureConfig, resolveConfig } from "./config.ts";
19
+ import { checkDependencies } from "./dependencies.ts";
20
+ import { applyExceptions, type ExceptionRecord, readExceptionRecord, readExceptions } from "./exceptions.ts";
21
+ import { type Finding, finding } from "./finding.ts";
22
+ import { buildImportGraph, type ImportEdge } from "./imports.ts";
23
+ import { checkOwnership } from "./ownership.ts";
24
+ import { isCode } from "./paths.ts";
25
+ import { compareStrings } from "./patterns.ts";
26
+ import { buildModel } from "./regions.ts";
27
+ import { Resolver } from "./resolve.ts";
28
+
29
+ export interface CheckOptions {
30
+ /** Laid over the defaults and over `.intentset/architecture.yaml`. */
31
+ config?: Partial<ArchitectureConfig>;
32
+ /** Conformance level; VSA001 runs at L2 and above. Default L2. */
33
+ level?: Level;
34
+ /** Exception records handed in, in addition to architecture/exceptions/*.yaml in the tree. */
35
+ exceptions?: readonly (ExceptionRecord | Record<string, unknown>)[];
36
+ baseline?: readonly BaselineEntry[];
37
+ /** "migration" applies the baseline; "strict" ignores it. Default migration. */
38
+ mode?: Mode;
39
+ /** The date exceptions expire against, YYYY-MM-DD. Default today, UTC. */
40
+ today?: string;
41
+ /** Where registries.yaml lives, for diagnostics about resources. Default "registries.yaml". */
42
+ registriesPath?: string;
43
+ }
44
+
45
+ export interface ArchitectureSummary {
46
+ mode: Mode;
47
+ level: Level;
48
+ /** Active (non-retired) slices. */
49
+ slices: number;
50
+ /** Files, tests excluded, that some slice claims. */
51
+ filesClaimed: number;
52
+ /** In-scope code files under source and backend roots that no slice, region or resource owns. */
53
+ filesUnclaimed: number;
54
+ /** Code files outside the declared scope: counted, never reported one by one (Core §11). */
55
+ outOfScope: number;
56
+ /** Resolved file-to-file import edges from in-scope production files. */
57
+ edges: number;
58
+ /** Those that cross from one slice into another. */
59
+ crossSliceEdges: number;
60
+ /** Dependency cycles found (VSA005), excepted or not. */
61
+ cycles: number;
62
+ /** Diagnostics a current exception turned into warnings. */
63
+ excepted: number;
64
+ /** Diagnostics the baseline turned into warnings (migration mode only). */
65
+ baselined: number;
66
+ /** Baseline entries that matched nothing and can be retired. */
67
+ baselineStale: number;
68
+ /** What could not be checked: never a pass (invariant 6). */
69
+ unresolved: string[];
70
+ /** Rules this check cannot decide from source and leaves to review (VSA §2). */
71
+ reviewRequired: string[];
72
+ }
73
+
74
+ export interface ArchitectureResult {
75
+ diagnostics: Diagnostic[];
76
+ summary: ArchitectureSummary;
77
+ /** The resolved import graph, for reports and impact views. */
78
+ edges: ImportEdge[];
79
+ }
80
+
81
+ /** VSA §2: the rules that are review assertions, snapshot diffs or contract diffs rather than structure. */
82
+ export const REVIEW_REQUIRED = [
83
+ "VSA007 business behavior is not owned by shared, infrastructure or composition: an architecture review assertion",
84
+ "VSA008 verification definitions cover negative and authorization cases: core links plus review",
85
+ "VSA010 backend access passes through the seam: imports are checked, dynamic network access is not",
86
+ "VSA011 public contract changes carry a compatibility assessment: needs a contract diff and review record",
87
+ "VSA012 deleting or splitting a slice dispositions what it owned: needs a diff between two snapshots",
88
+ "TS005 runtime resolver and type-checker agree on aliases: bundler configuration is not read",
89
+ ];
90
+
91
+ function today(): string {
92
+ return new Date().toISOString().slice(0, 10);
93
+ }
94
+
95
+ export function checkArchitecture(
96
+ graph: Graph,
97
+ registries: Registries,
98
+ tree: { files: ReadonlyMap<string, string> },
99
+ options: CheckOptions = {},
100
+ ): ArchitectureResult {
101
+ const level = options.level ?? "L2";
102
+ const mode = options.mode ?? "migration";
103
+ const registriesPath = options.registriesPath ?? "registries.yaml";
104
+ const configDiagnostics: Diagnostic[] = [];
105
+ let fileConfig: Partial<ArchitectureConfig> | undefined;
106
+ const configText = tree.files.get(ARCHITECTURE_CONFIG_PATH);
107
+ if (configText !== undefined) {
108
+ const read = readArchitectureConfig(configText, ARCHITECTURE_CONFIG_PATH);
109
+ fileConfig = read.config;
110
+ configDiagnostics.push(...read.diagnostics);
111
+ }
112
+ const config = resolveConfig(fileConfig, options.config);
113
+ const model = buildModel(graph, registries, tree.files, config);
114
+ const unresolved: string[] = [];
115
+
116
+ for (const slice of model.slices) {
117
+ for (const i of slice.invalidClaims) {
118
+ unresolved.push(
119
+ `${slice.path}: claim ${i} of ${slice.id} (${slice.meta.claims[i].path}) is not a valid pattern and was not resolved`,
120
+ );
121
+ }
122
+ }
123
+
124
+ const codeFiles = new Map([...model.files].filter(([path]) => isCode(path)));
125
+ const resolver = new Resolver(model.files, config);
126
+ const imports = buildImportGraph(codeFiles, resolver);
127
+ const findings: Finding[] = [];
128
+ for (const item of imports.unresolved) {
129
+ if (!model.inScope(item.path)) continue;
130
+ const where = item.line === null ? item.path : `${item.path}:${item.line}`;
131
+ unresolved.push(`${where}: ${item.message}`);
132
+ findings.push(
133
+ finding({
134
+ code: "TS004",
135
+ severity: "warning",
136
+ artifact: model.regionOf(item.path).kind === "slice" ? (model.regionOf(item.path).owner ?? null) : null,
137
+ path: item.path,
138
+ ...(item.line !== null ? { location: { line: item.line } } : {}),
139
+ message: `${item.message.charAt(0).toUpperCase()}${item.message.slice(1)}; the reference was left out of boundary analysis.`,
140
+ remediation:
141
+ "Use a literal specifier that resolves to a file in the repository or to a package, so the boundary can be checked (TS004).",
142
+ }),
143
+ );
144
+ }
145
+ for (const note of [...resolver.tsconfigs.notes, ...resolver.notes]) unresolved.push(`${note.path}: ${note.message}`);
146
+
147
+ findings.push(...checkClaims(model, graph, registriesPath));
148
+ findings.push(...checkOwnership(model, graph, level));
149
+ const boundaries = checkBoundaries(model, imports.edges, resolver);
150
+ findings.push(...boundaries.findings);
151
+ findings.push(...checkUnclassified(model));
152
+ const dependencies = checkDependencies(model, graph, boundaries.sliceEdges);
153
+ findings.push(...dependencies.findings);
154
+
155
+ // Exceptions: the tree's records and any handed in.
156
+ const fromTree = readExceptions(model.files);
157
+ const records = [...fromTree.records];
158
+ const recordProblems = [...fromTree.findings];
159
+ for (const raw of options.exceptions ?? []) {
160
+ const { source, ...fields } = raw as Record<string, unknown>;
161
+ const read = readExceptionRecord(fields, typeof source === "string" ? source : null);
162
+ recordProblems.push(...read.findings);
163
+ if (read.record !== null) records.push(read.record);
164
+ }
165
+ const excepted = applyExceptions(findings, records, options.today ?? today());
166
+
167
+ let diagnostics: Diagnostic[] = [
168
+ ...configDiagnostics,
169
+ ...excepted.findings.map((item) => item.diagnostic),
170
+ ...recordProblems.map((item) => item.diagnostic),
171
+ ...excepted.recordFindings.map((item) => item.diagnostic),
172
+ ];
173
+ const baseline = applyBaseline(diagnostics, options.baseline ?? [], mode);
174
+ diagnostics = sortDiagnostics(baseline.diagnostics);
175
+
176
+ const owned = (path: string) => {
177
+ const kind = model.regionOf(path).kind;
178
+ return kind !== "backend" && kind !== "unowned";
179
+ };
180
+ const summary: ArchitectureSummary = {
181
+ mode,
182
+ level,
183
+ slices: model.slices.length,
184
+ filesClaimed: model.production.filter((path) => (model.claimOwners.get(path) ?? []).length > 0).length,
185
+ filesUnclaimed: model.production.filter((path) => isCode(path) && model.inScope(path) && !owned(path)).length,
186
+ outOfScope: [...model.files.keys()].filter((path) => isCode(path) && !model.inScope(path)).length,
187
+ edges: boundaries.fileEdges,
188
+ crossSliceEdges: boundaries.crossSliceEdges,
189
+ cycles: dependencies.cycles,
190
+ excepted: excepted.excepted,
191
+ baselined: baseline.baselined,
192
+ baselineStale: baseline.stale.length,
193
+ unresolved: [...new Set(unresolved)].sort(compareStrings),
194
+ reviewRequired: [...REVIEW_REQUIRED],
195
+ };
196
+ return { diagnostics, summary, edges: imports.edges };
197
+ }
package/src/claims.ts ADDED
@@ -0,0 +1,229 @@
1
+ /**
2
+ * Path and resource ownership (VSA §3, §7; VSA009; profile AMP004).
3
+ *
4
+ * - A nonempty claim that matches no file is VSA009.
5
+ * - Two slices resolving one file is VSA009, once per pair of claims, naming
6
+ * both slices, both claims and the files.
7
+ * - A file claimed by a slice and recorded as a registry resource has two
8
+ * owners, which is VSA009 too: a shared resource has one recorded owner.
9
+ * - A registry resource path that matches no file, or names a consumer that
10
+ * is no slice, is VSA009; consumers and `usesResources` that disagree are
11
+ * a VSA009 warning, since the registry restates what the slices declare.
12
+ * - A code file under a backend root owned by no slice and no resource is
13
+ * AMP004; one under a source root owned by nothing is VSA009. Both only
14
+ * inside the declared scope.
15
+ *
16
+ * A resource that a slice uses but the registry does not declare is core's
17
+ * CORE003, and an invalid claim pattern core already rejects is CORE001;
18
+ * neither is repeated here.
19
+ */
20
+ import { type Graph, patternProblem } from "@intentset/core";
21
+ import { type Finding, finding, listSome, plural } from "./finding.ts";
22
+ import { isCode } from "./paths.ts";
23
+ import { compareStrings, validatePattern } from "./patterns.ts";
24
+ import type { Model } from "./regions.ts";
25
+
26
+ export function checkClaims(model: Model, graph: Graph, registriesPath: string): Finding[] {
27
+ const findings: Finding[] = [];
28
+
29
+ for (const slice of model.slices) {
30
+ slice.meta.claims.forEach((claim, i) => {
31
+ const field = `/intentset/slice/claims/${i}/path`;
32
+ const why = validatePattern(claim.path);
33
+ if (why !== null) {
34
+ if (patternProblem(claim.path) === null) {
35
+ findings.push(
36
+ finding({
37
+ code: "VSA009",
38
+ artifact: slice.id,
39
+ path: slice.path,
40
+ field,
41
+ message: `Claim ${i} of ${slice.id}, ${claim.path}, ${why}, so it resolves to no file.`,
42
+ remediation: "Use literal segments, `*` within a segment and `**` standing alone as a segment (VSA §3).",
43
+ }),
44
+ );
45
+ }
46
+ return;
47
+ }
48
+ if (slice.claimFiles[i].length === 0) {
49
+ findings.push(
50
+ finding({
51
+ code: "VSA009",
52
+ artifact: slice.id,
53
+ path: slice.path,
54
+ field,
55
+ message: `The ${claim.kind} claim ${claim.path} of ${slice.id} matches no file in the repository.`,
56
+ remediation:
57
+ "Correct the path, add the files it promises, or remove the claim; an empty claim is not ownership (VSA §3).",
58
+ }),
59
+ );
60
+ }
61
+ });
62
+ }
63
+
64
+ // Two slices, one file: one finding per pair of claims.
65
+ const pairs = new Map<
66
+ string,
67
+ { a: { slice: string; claim: number }; b: { slice: string; claim: number }; files: string[] }
68
+ >();
69
+ for (const [file, owners] of [...model.claimOwners].sort(([x], [y]) => compareStrings(x, y))) {
70
+ for (let x = 0; x < owners.length; x++) {
71
+ for (let y = x + 1; y < owners.length; y++) {
72
+ if (owners[x].slice === owners[y].slice) continue;
73
+ const [a, b] = [owners[x], owners[y]].sort((p, q) => compareStrings(p.slice, q.slice) || p.claim - q.claim);
74
+ const key = `${a.slice}#${a.claim}|${b.slice}#${b.claim}`;
75
+ const entry = pairs.get(key) ?? { a, b, files: [] };
76
+ entry.files.push(file);
77
+ pairs.set(key, entry);
78
+ }
79
+ }
80
+ }
81
+ for (const key of [...pairs.keys()].sort(compareStrings)) {
82
+ const { a, b, files } = pairs.get(key)!;
83
+ const sliceA = model.sliceById.get(a.slice)!;
84
+ const sliceB = model.sliceById.get(b.slice)!;
85
+ const claimA = sliceA.meta.claims[a.claim].path;
86
+ const claimB = sliceB.meta.claims[b.claim].path;
87
+ findings.push(
88
+ finding({
89
+ code: "VSA009",
90
+ artifact: a.slice,
91
+ path: files[0],
92
+ message: `${a.slice} (claim ${claimA}) and ${b.slice} (claim ${claimB}) both resolve ${plural(files.length, "file")}: ${listSome(files)}.`,
93
+ remediation: `Narrow one claim so each file has one owner, or extract the shared code into a contract or a technical owner (VSA §3).`,
94
+ paths: [...files, sliceA.path, sliceB.path],
95
+ edges: [{ from: a.slice, to: b.slice }],
96
+ }),
97
+ );
98
+ }
99
+
100
+ // A resource has one recorded owner; a slice claiming its file is a second.
101
+ for (const info of model.resources) {
102
+ const { resource, index, files } = info;
103
+ const field = `/resources/${index}/path`;
104
+ const why = validatePattern(resource.path);
105
+ if (why !== null) {
106
+ findings.push(
107
+ finding({
108
+ code: "VSA009",
109
+ artifact: null,
110
+ path: registriesPath,
111
+ field,
112
+ message: `Resource ${resource.id} has the path ${resource.path}, which ${why}.`,
113
+ remediation: "Give the repository-relative path or pattern of the files that define the resource (VSA §3).",
114
+ }),
115
+ );
116
+ continue;
117
+ }
118
+ if (files.length === 0) {
119
+ findings.push(
120
+ finding({
121
+ code: "VSA009",
122
+ artifact: null,
123
+ path: registriesPath,
124
+ field,
125
+ message: `Resource ${resource.id} records the path ${resource.path}, which matches no file in the repository.`,
126
+ remediation: "Correct the path to the file that defines the resource, or remove the record (VSA §7).",
127
+ }),
128
+ );
129
+ }
130
+ const claimedBy = new Map<string, string[]>();
131
+ for (const file of files) {
132
+ for (const owner of model.claimOwners.get(file) ?? []) {
133
+ const list = claimedBy.get(owner.slice) ?? [];
134
+ if (!list.includes(file)) list.push(file);
135
+ claimedBy.set(owner.slice, list);
136
+ }
137
+ }
138
+ for (const sliceId of [...claimedBy.keys()].sort(compareStrings)) {
139
+ const claimed = claimedBy.get(sliceId)!;
140
+ findings.push(
141
+ finding({
142
+ code: "VSA009",
143
+ artifact: sliceId,
144
+ path: claimed[0],
145
+ message: `${listSome(claimed)} ${claimed.length === 1 ? "is" : "are"} recorded as resource ${resource.id}, owned by ${resource.owner}, and also claimed by ${sliceId}.`,
146
+ remediation: `Keep the resource's one recorded owner and have ${sliceId} list ${resource.id} in usesResources instead of claiming its file (VSA §7, profile §6).`,
147
+ paths: [...claimed, model.sliceById.get(sliceId)!.path],
148
+ edges: [{ from: sliceId, to: resource.id }],
149
+ }),
150
+ );
151
+ }
152
+
153
+ resource.consumers.forEach((consumer, k) => {
154
+ const artifact = graph.artifacts.get(consumer);
155
+ if (artifact === undefined || artifact.meta.type !== "slice") {
156
+ findings.push(
157
+ finding({
158
+ code: "VSA009",
159
+ artifact: null,
160
+ path: registriesPath,
161
+ field: `/resources/${index}/consumers/${k}`,
162
+ message: `Resource ${resource.id} lists the consumer ${consumer}, which is not a slice in the graph.`,
163
+ remediation: "List the IDs of the slices that use the resource, as they appear in their usesResources.",
164
+ }),
165
+ );
166
+ } else if (!(artifact.meta.slice?.usesResources ?? []).includes(resource.id)) {
167
+ findings.push(
168
+ finding({
169
+ code: "VSA009",
170
+ severity: "warning",
171
+ artifact: consumer,
172
+ path: registriesPath,
173
+ field: `/resources/${index}/consumers/${k}`,
174
+ message: `Resource ${resource.id} lists ${consumer} as a consumer, but ${consumer} does not list it in usesResources.`,
175
+ remediation: `Add ${resource.id} to the usesResources of ${consumer}, or remove ${consumer} from the resource's consumers.`,
176
+ }),
177
+ );
178
+ }
179
+ });
180
+ }
181
+ const resourceById = new Map(model.resources.map((info) => [info.resource.id, info.resource]));
182
+ for (const slice of model.slices) {
183
+ slice.meta.usesResources.forEach((id, k) => {
184
+ const resource = resourceById.get(id);
185
+ if (resource === undefined || resource.consumers.includes(slice.id)) return;
186
+ findings.push(
187
+ finding({
188
+ code: "VSA009",
189
+ severity: "warning",
190
+ artifact: slice.id,
191
+ path: slice.path,
192
+ field: `/intentset/slice/usesResources/${k}`,
193
+ message: `${slice.id} uses resource ${id}, but the registry does not list it among the resource's consumers.`,
194
+ remediation: `Add ${slice.id} to the consumers of ${id} in the registry, so the deployment inventory matches the slices.`,
195
+ }),
196
+ );
197
+ });
198
+ }
199
+
200
+ // Unowned files inside the declared scope.
201
+ for (const file of model.production) {
202
+ if (!isCode(file) || !model.inScope(file)) continue;
203
+ const region = model.regionOf(file);
204
+ if (region.kind === "backend") {
205
+ findings.push(
206
+ finding({
207
+ code: "AMP004",
208
+ artifact: null,
209
+ path: file,
210
+ message: `Backend file ${file} is claimed by no slice and recorded as no resource.`,
211
+ remediation:
212
+ "Add it to the backend claim of the slice that owns it, or record it in the resource registry with its technical owner (AMP004).",
213
+ }),
214
+ );
215
+ } else if (region.kind === "unowned") {
216
+ findings.push(
217
+ finding({
218
+ code: "VSA009",
219
+ artifact: null,
220
+ path: file,
221
+ message: `Source file ${file} is claimed by no slice and lies in no shared, infrastructure or composition region.`,
222
+ remediation: "Claim it from the slice that owns it, or move it into a declared region (VSA §3, VSA009).",
223
+ }),
224
+ );
225
+ }
226
+ }
227
+
228
+ return findings;
229
+ }
package/src/config.ts ADDED
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Where the regions are, what counts as a test, which files may import
3
+ * screens or the response parser, and the declared scope (VSA §5, §6, §9;
4
+ * profile §2–§4). Every value has a default for the reference layout of
5
+ * profile §1. A repository states its own in `.intentset/architecture.yaml`,
6
+ * a file of its own because `.intentset/config.yaml` belongs to core and
7
+ * rejects keys it does not know.
8
+ */
9
+ import { type Diagnostic, parseYamlDetailed } from "@intentset/core";
10
+ import { compareStrings, validatePattern } from "./patterns.ts";
11
+
12
+ export const ARCHITECTURE_CONFIG_PATH = ".intentset/architecture.yaml";
13
+
14
+ export interface ArchitectureConfig {
15
+ /** Declared scope (VSA §9, Core §11): files outside it are counted, never reported one by one. */
16
+ scope: string[];
17
+ /** Where product source lives; an in-scope code file here that nothing owns is VSA009. */
18
+ sourceRoots: string[];
19
+ /** Neutral shared abstractions (VSA §5). */
20
+ shared: string[];
21
+ /** Technical infrastructure (VSA §5). */
22
+ infrastructure: string[];
23
+ /** Application and route composition (VSA §6). */
24
+ composition: string[];
25
+ /** The unified backend deployment root (profile §1, AMP001, AMP004). */
26
+ backendRoots: string[];
27
+ /** The named Router files that may import screens directly (TS003). Never widened to a directory by default. */
28
+ routerFiles: string[];
29
+ /** The named response-parser modules a client seam may import from the backend (profile §4 inherited exception). */
30
+ responseParserModules: string[];
31
+ /** Generated, vendored and build output: excluded from everything (VSA §3 versioned ignore list). */
32
+ ignore: string[];
33
+ /** Test files: excluded from boundary checks except foreign internals (profile §3). */
34
+ tests: string[];
35
+ /** Screens; only a router file may import another slice's (TS003). */
36
+ screensGlob: string;
37
+ /** Backend SDK specifiers, matched as patterns against the import specifier (AMP002, AMP006). */
38
+ backendSdks: string[];
39
+ /** The internal layer matrix (profile §3): each layer may import itself and the layers listed. */
40
+ layerMatrix: Record<string, string[]>;
41
+ /** Export conditions tried first when resolving a workspace package's entry, before types, import and default. */
42
+ conditions: string[];
43
+ }
44
+
45
+ export const DEFAULT_ARCHITECTURE_CONFIG: ArchitectureConfig = {
46
+ scope: ["**"],
47
+ sourceRoots: ["src/**"],
48
+ shared: ["src/shared/**"],
49
+ infrastructure: ["src/infrastructure/**"],
50
+ composition: ["src/app/**"],
51
+ backendRoots: ["amplify/**"],
52
+ routerFiles: [],
53
+ responseParserModules: [],
54
+ ignore: ["**/node_modules/**", "**/dist/**", ".git/**"],
55
+ tests: ["**/*.test.ts", "**/*.test.tsx", "**/*.spec.ts", "**/*.spec.tsx"],
56
+ screensGlob: "**/ui/screens/**",
57
+ backendSdks: ["aws-amplify/**", "@aws-amplify/**", "@aws-sdk/**"],
58
+ layerMatrix: {
59
+ presentation: ["presentation", "application", "policy", "model"],
60
+ application: ["application", "policy", "model", "external"],
61
+ policy: ["policy", "model"],
62
+ model: ["model"],
63
+ external: ["external", "model"],
64
+ },
65
+ conditions: [],
66
+ };
67
+
68
+ const LIST_KEYS = [
69
+ "scope",
70
+ "sourceRoots",
71
+ "shared",
72
+ "infrastructure",
73
+ "composition",
74
+ "backendRoots",
75
+ "routerFiles",
76
+ "responseParserModules",
77
+ "ignore",
78
+ "tests",
79
+ "backendSdks",
80
+ "conditions",
81
+ ] as const;
82
+
83
+ /** Keys whose entries are path patterns and must pass validatePattern. */
84
+ const PATTERN_KEYS = new Set<string>([
85
+ "scope",
86
+ "sourceRoots",
87
+ "shared",
88
+ "infrastructure",
89
+ "composition",
90
+ "backendRoots",
91
+ "routerFiles",
92
+ "responseParserModules",
93
+ "ignore",
94
+ "tests",
95
+ "screensGlob",
96
+ ]);
97
+
98
+ const KNOWN_KEYS = [...LIST_KEYS, "screensGlob", "layerMatrix"].sort(compareStrings);
99
+
100
+ /** The defaults with a partial configuration laid over them; a key given replaces the default whole. */
101
+ export function resolveConfig(...layers: (Partial<ArchitectureConfig> | undefined)[]): ArchitectureConfig {
102
+ const config: ArchitectureConfig = structuredClone(DEFAULT_ARCHITECTURE_CONFIG);
103
+ for (const layer of layers) {
104
+ if (layer === undefined) continue;
105
+ for (const [key, value] of Object.entries(layer)) {
106
+ if (value !== undefined) (config as unknown as Record<string, unknown>)[key] = structuredClone(value);
107
+ }
108
+ }
109
+ return config;
110
+ }
111
+
112
+ /**
113
+ * Read `.intentset/architecture.yaml` with core's strict reader. Every key is
114
+ * optional; an unknown key, a wrong shape or an invalid pattern is CFG001
115
+ * with origin "architecture", and that key keeps its default.
116
+ */
117
+ export function readArchitectureConfig(
118
+ text: string,
119
+ path: string = ARCHITECTURE_CONFIG_PATH,
120
+ ): { config: Partial<ArchitectureConfig>; diagnostics: Diagnostic[] } {
121
+ const config: Partial<ArchitectureConfig> = {};
122
+ const diagnostics: Diagnostic[] = [];
123
+ const parsed = parseYamlDetailed(text);
124
+ const report = (field: string | undefined, line: number | undefined, message: string, remediation: string) => {
125
+ diagnostics.push({
126
+ code: "CFG001",
127
+ severity: "error",
128
+ origin: "architecture",
129
+ artifact: null,
130
+ path,
131
+ ...(line !== undefined ? { location: { line } } : {}),
132
+ ...(field !== undefined ? { field } : {}),
133
+ message,
134
+ remediation,
135
+ });
136
+ };
137
+ if (parsed.error !== null) {
138
+ report(
139
+ undefined,
140
+ parsed.error.line,
141
+ `The architecture configuration does not parse: ${parsed.error.message}`,
142
+ "Write it as a block mapping of the keys documented for ArchitectureConfig.",
143
+ );
144
+ return { config, diagnostics };
145
+ }
146
+ const root = parsed.value;
147
+ const at = (field: string) => parsed.lines.get(field);
148
+
149
+ for (const key of Object.keys(root).sort(compareStrings)) {
150
+ const field = `/${key}`;
151
+ const value = root[key];
152
+ if (!KNOWN_KEYS.includes(key)) {
153
+ report(
154
+ field,
155
+ at(field),
156
+ `Unknown architecture configuration key \`${key}\`.`,
157
+ `Remove it; the keys are ${KNOWN_KEYS.join(", ")}.`,
158
+ );
159
+ continue;
160
+ }
161
+ if (value === null) continue;
162
+ if (key === "screensGlob") {
163
+ const why = typeof value === "string" ? validatePattern(value) : "is not a string";
164
+ if (why !== null) {
165
+ report(field, at(field), `screensGlob ${why}.`, "Give one pattern such as **/ui/screens/**.");
166
+ } else config.screensGlob = value as string;
167
+ continue;
168
+ }
169
+ if (key === "layerMatrix") {
170
+ const matrix = readMatrix(value);
171
+ if (matrix === null) {
172
+ report(
173
+ field,
174
+ at(field),
175
+ "layerMatrix must map each layer name to a list of layer names.",
176
+ "Write each layer as `presentation: [presentation, application]`.",
177
+ );
178
+ } else config.layerMatrix = matrix;
179
+ continue;
180
+ }
181
+ if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item !== "")) {
182
+ report(field, at(field), `\`${key}\` must be a list of non-empty strings.`, `Write ${key} as a sequence.`);
183
+ continue;
184
+ }
185
+ const list = value as string[];
186
+ if (PATTERN_KEYS.has(key)) {
187
+ const bad = list.findIndex((pattern) => validatePattern(pattern) !== null);
188
+ if (bad !== -1) {
189
+ const pointer = `${field}/${bad}`;
190
+ report(
191
+ pointer,
192
+ at(pointer) ?? at(field),
193
+ `Pattern ${list[bad]} in \`${key}\` ${validatePattern(list[bad])}.`,
194
+ "Use literal segments, `*` within a segment and `**` across segments only (VSA §3).",
195
+ );
196
+ continue;
197
+ }
198
+ }
199
+ (config as Record<string, unknown>)[key] = [...list];
200
+ }
201
+ return { config, diagnostics };
202
+ }
203
+
204
+ function readMatrix(value: unknown): Record<string, string[]> | null {
205
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
206
+ const matrix: Record<string, string[]> = {};
207
+ for (const [layer, targets] of Object.entries(value)) {
208
+ if (!Array.isArray(targets) || !targets.every((item) => typeof item === "string")) return null;
209
+ matrix[layer] = [...(targets as string[])];
210
+ }
211
+ return matrix;
212
+ }