@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/extract.ts ADDED
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Every module reference in one TypeScript or JavaScript file (profile TS004:
3
+ * type-only imports, dynamic imports and re-exports all count).
4
+ *
5
+ * TypeScript 7 ships its parser inside the native compiler and exposes no
6
+ * in-process `createSourceFile`; what it does ship in JavaScript is the
7
+ * scanner. So this module tokenizes with TypeScript's own scanner, which is
8
+ * what gets strings, comments, template literals and regular expressions
9
+ * right, and recognizes the handful of declaration shapes that carry a module
10
+ * specifier over the token stream:
11
+ *
12
+ * import d, { a, type B } from "x" import type { A } from "x"
13
+ * import "x" import X = require("x")
14
+ * export { a } from "x" export type { A } from "x"
15
+ * export * from "x" export * as ns from "x"
16
+ * import("x") require("x")
17
+ *
18
+ * The scanner leaves two decisions to its parser: whether `/` starts a
19
+ * regular expression, and whether `}` resumes a template literal. Both are
20
+ * made here from the preceding token and a stack of template brace depths.
21
+ * Anything this module cannot read is returned as a problem, never dropped:
22
+ * a dynamic import with a computed specifier, or a token the scanner could
23
+ * not terminate (invariant 6).
24
+ */
25
+ import { LanguageVariant, SyntaxKind } from "typescript/unstable/ast";
26
+ import { computeLineStarts, createScanner, tokenIsIdentifierOrKeyword } from "typescript/unstable/ast/scanner";
27
+
28
+ export interface RawImport {
29
+ specifier: string;
30
+ /** 1-based line of the specifier. */
31
+ line: number;
32
+ /** `import type`, `export type`, every named specifier marked `type`, or `typeof import("x")`. */
33
+ typeOnly: boolean;
34
+ /** `import("x")` or `require("x")`. */
35
+ dynamic: boolean;
36
+ /** `export ... from "x"`. */
37
+ reexport: boolean;
38
+ /** `export * from "x"` or `export * as ns from "x"`. */
39
+ wildcard: boolean;
40
+ }
41
+
42
+ export interface ExtractProblem {
43
+ line: number;
44
+ message: string;
45
+ }
46
+
47
+ export interface Extraction {
48
+ imports: RawImport[];
49
+ problems: ExtractProblem[];
50
+ }
51
+
52
+ interface Token {
53
+ kind: SyntaxKind;
54
+ text: string;
55
+ value: string;
56
+ line: number;
57
+ }
58
+
59
+ /** Keywords after which `/` begins a regular expression rather than a division. */
60
+ const REGEX_AFTER_WORD = new Set([
61
+ "return",
62
+ "typeof",
63
+ "case",
64
+ "in",
65
+ "of",
66
+ "delete",
67
+ "void",
68
+ "throw",
69
+ "new",
70
+ "else",
71
+ "do",
72
+ "instanceof",
73
+ "yield",
74
+ "await",
75
+ ]);
76
+
77
+ const VALUE_ENDING = new Set<SyntaxKind>([
78
+ SyntaxKind.NumericLiteral,
79
+ SyntaxKind.BigIntLiteral,
80
+ SyntaxKind.StringLiteral,
81
+ SyntaxKind.RegularExpressionLiteral,
82
+ SyntaxKind.NoSubstitutionTemplateLiteral,
83
+ SyntaxKind.TemplateTail,
84
+ SyntaxKind.CloseParenToken,
85
+ SyntaxKind.CloseBracketToken,
86
+ SyntaxKind.CloseBraceToken,
87
+ SyntaxKind.PlusPlusToken,
88
+ SyntaxKind.MinusMinusToken,
89
+ ]);
90
+
91
+ function regexAllowed(previous: Token | undefined): boolean {
92
+ if (previous === undefined) return true;
93
+ if (VALUE_ENDING.has(previous.kind)) return false;
94
+ if (tokenIsIdentifierOrKeyword(previous.kind)) return REGEX_AFTER_WORD.has(previous.text);
95
+ return true;
96
+ }
97
+
98
+ function isJsx(path: string): boolean {
99
+ return path.endsWith(".tsx") || path.endsWith(".jsx");
100
+ }
101
+
102
+ function tokenize(path: string, text: string): { tokens: Token[]; problems: ExtractProblem[] } {
103
+ const jsx = isJsx(path);
104
+ const scanner = createScanner(true, jsx ? LanguageVariant.JSX : LanguageVariant.Standard, text);
105
+ const starts = computeLineStarts(text);
106
+ const lineOf = (position: number): number => {
107
+ let low = 0;
108
+ let high = starts.length - 1;
109
+ while (low < high) {
110
+ const mid = (low + high + 1) >> 1;
111
+ if (starts[mid] <= position) low = mid;
112
+ else high = mid - 1;
113
+ }
114
+ return low + 1;
115
+ };
116
+ const tokens: Token[] = [];
117
+ const problems: ExtractProblem[] = [];
118
+ /** Open brace depth inside each `${` of an unfinished template literal. */
119
+ const templates: number[] = [];
120
+ let previous: Token | undefined;
121
+ let lastEnd = -1;
122
+ for (;;) {
123
+ let kind = scanner.scan();
124
+ if (kind === SyntaxKind.EndOfFile) break;
125
+ // TypeScript 7's scanner can return a token without advancing: JSX text
126
+ // such as `#{n}` scans as an empty private identifier at the `#`, forever.
127
+ // A token that ends where the last one did makes no progress, so step over
128
+ // one character. In JSX that costs nothing, since no import is written in
129
+ // JSX text; anywhere else it is reported, because something was skipped.
130
+ if (scanner.getTokenEnd() <= lastEnd) {
131
+ if (!jsx) {
132
+ problems.push({
133
+ line: lineOf(lastEnd),
134
+ message: "the scanner could not advance here, so one character was skipped",
135
+ });
136
+ }
137
+ lastEnd++;
138
+ scanner.resetTokenState(lastEnd);
139
+ continue;
140
+ }
141
+ lastEnd = scanner.getTokenEnd();
142
+ if ((kind === SyntaxKind.SlashToken || kind === SyntaxKind.SlashEqualsToken) && regexAllowed(previous)) {
143
+ kind = scanner.reScanSlashToken();
144
+ } else if (kind === SyntaxKind.OpenBraceToken && templates.length > 0) {
145
+ templates[templates.length - 1]++;
146
+ } else if (kind === SyntaxKind.CloseBraceToken && templates.length > 0) {
147
+ if (templates[templates.length - 1] === 0) {
148
+ kind = scanner.reScanTemplateToken(false);
149
+ if (kind === SyntaxKind.TemplateTail) templates.pop();
150
+ } else templates[templates.length - 1]--;
151
+ }
152
+ if (kind === SyntaxKind.TemplateHead) templates.push(0);
153
+ const line = lineOf(scanner.getTokenStart());
154
+ if (scanner.isUnterminated()) {
155
+ // In JSX, text such as `Don't` scans as a string that runs to the end of its line and no further;
156
+ // it costs at most that line, and no import is written inside JSX text.
157
+ if (!(jsx && kind === SyntaxKind.StringLiteral)) {
158
+ problems.push({ line, message: `the scanner could not terminate a ${describe(kind)} that starts here` });
159
+ }
160
+ }
161
+ const token: Token = { kind, text: scanner.getTokenText(), value: scanner.getTokenValue(), line };
162
+ tokens.push(token);
163
+ previous = token;
164
+ }
165
+ if (templates.length > 0) {
166
+ problems.push({ line: previous?.line ?? 1, message: "a template literal is still open at the end of the file" });
167
+ }
168
+ return { tokens, problems };
169
+ }
170
+
171
+ function describe(kind: SyntaxKind): string {
172
+ switch (kind) {
173
+ case SyntaxKind.StringLiteral:
174
+ return "string literal";
175
+ case SyntaxKind.RegularExpressionLiteral:
176
+ return "regular expression";
177
+ case SyntaxKind.NoSubstitutionTemplateLiteral:
178
+ case SyntaxKind.TemplateHead:
179
+ case SyntaxKind.TemplateMiddle:
180
+ case SyntaxKind.TemplateTail:
181
+ return "template literal";
182
+ default:
183
+ return "token";
184
+ }
185
+ }
186
+
187
+ /** Extract every module reference from one file. */
188
+ export function extractImports(path: string, text: string): Extraction {
189
+ const { tokens, problems } = tokenize(path, text);
190
+ const imports: RawImport[] = [];
191
+ const consumed = new Set<number>();
192
+
193
+ const word = (i: number, text?: string): boolean => {
194
+ const token = tokens[i];
195
+ return token !== undefined && tokenIsIdentifierOrKeyword(token.kind) && (text === undefined || token.text === text);
196
+ };
197
+ const is = (i: number, kind: SyntaxKind): boolean => tokens[i]?.kind === kind;
198
+ const isString = (i: number): boolean => is(i, SyntaxKind.StringLiteral);
199
+ const isLiteral = (i: number): boolean => isString(i) || is(i, SyntaxKind.NoSubstitutionTemplateLiteral);
200
+ const afterDot = (i: number): boolean => is(i - 1, SyntaxKind.DotToken) || is(i - 1, SyntaxKind.QuestionDotToken);
201
+ const add = (i: number, fields: Omit<RawImport, "specifier" | "line">) => {
202
+ imports.push({ specifier: tokens[i].value, line: tokens[i].line, ...fields });
203
+ };
204
+ /** Index of the token closing the bracket opened at i, or tokens.length. */
205
+ const closing = (i: number, open: SyntaxKind, close: SyntaxKind): number => {
206
+ let depth = 0;
207
+ for (let k = i; k < tokens.length; k++) {
208
+ if (tokens[k].kind === open) depth++;
209
+ else if (tokens[k].kind === close && --depth === 0) return k;
210
+ }
211
+ return tokens.length;
212
+ };
213
+ /** Named elements between braces: true when every one is written `type X`. */
214
+ const allTyped = (open: number, close: number): boolean => {
215
+ let count = 0;
216
+ let typed = 0;
217
+ let start = true;
218
+ for (let k = open + 1; k < close; k++) {
219
+ if (is(k, SyntaxKind.CommaToken)) {
220
+ start = true;
221
+ continue;
222
+ }
223
+ if (start) {
224
+ count++;
225
+ if (word(k, "type") && word(k + 1) && !word(k + 1, "as")) typed++;
226
+ start = false;
227
+ }
228
+ }
229
+ return count > 0 && typed === count;
230
+ };
231
+ /** A call such as `import(x)` or `require(x)` that is really a method declaration `import(x) { ... }`. */
232
+ const isMethod = (open: number): boolean =>
233
+ is(closing(open, SyntaxKind.OpenParenToken, SyntaxKind.CloseParenToken) + 1, SyntaxKind.OpenBraceToken);
234
+
235
+ for (let i = 0; i < tokens.length; i++) {
236
+ if (consumed.has(i) || afterDot(i)) continue;
237
+
238
+ if (word(i, "import") && tokens[i].kind === SyntaxKind.ImportKeyword) {
239
+ if (is(i + 1, SyntaxKind.OpenParenToken)) {
240
+ if (isLiteral(i + 2) && (is(i + 3, SyntaxKind.CloseParenToken) || is(i + 3, SyntaxKind.CommaToken))) {
241
+ add(i + 2, { typeOnly: word(i - 1, "typeof"), dynamic: true, reexport: false, wildcard: false });
242
+ } else if (!is(i + 2, SyntaxKind.CloseParenToken) && !isMethod(i + 1)) {
243
+ problems.push({
244
+ line: tokens[i].line,
245
+ message: "a dynamic import has a computed specifier, so its target cannot be known",
246
+ });
247
+ }
248
+ continue;
249
+ }
250
+ if (
251
+ is(i + 1, SyntaxKind.DotToken) ||
252
+ is(i + 1, SyntaxKind.ColonToken) ||
253
+ is(i + 1, SyntaxKind.CommaToken) ||
254
+ is(i + 1, SyntaxKind.CloseBraceToken) ||
255
+ is(i + 1, SyntaxKind.CloseParenToken)
256
+ ) {
257
+ continue;
258
+ }
259
+ let j = i + 1;
260
+ let typeOnly = false;
261
+ if (
262
+ word(j, "type") &&
263
+ !is(j + 1, SyntaxKind.CommaToken) &&
264
+ !is(j + 1, SyntaxKind.EqualsToken) &&
265
+ !(word(j + 1, "from") && isString(j + 2))
266
+ ) {
267
+ typeOnly = true;
268
+ j++;
269
+ }
270
+ if (isString(j)) {
271
+ add(j, { typeOnly, dynamic: false, reexport: false, wildcard: false });
272
+ continue;
273
+ }
274
+ if (word(j) && is(j + 1, SyntaxKind.EqualsToken)) {
275
+ if (word(j + 2, "require") && is(j + 3, SyntaxKind.OpenParenToken) && isString(j + 4)) {
276
+ add(j + 4, { typeOnly, dynamic: false, reexport: false, wildcard: false });
277
+ consumed.add(j + 2);
278
+ }
279
+ continue;
280
+ }
281
+ // Default, namespace and named bindings, then `from "x"`.
282
+ let hasPlainBinding = false;
283
+ let typedNamed = false;
284
+ for (let k = j; k < tokens.length && k < j + 4096; k++) {
285
+ if (is(k, SyntaxKind.OpenBraceToken)) {
286
+ const close = closing(k, SyntaxKind.OpenBraceToken, SyntaxKind.CloseBraceToken);
287
+ typedNamed = allTyped(k, close);
288
+ k = close;
289
+ continue;
290
+ }
291
+ if (word(k, "from") && isString(k + 1)) {
292
+ const typed = typeOnly || (typedNamed && !hasPlainBinding);
293
+ add(k + 1, { typeOnly: typed, dynamic: false, reexport: false, wildcard: false });
294
+ i = k + 1;
295
+ break;
296
+ }
297
+ if (is(k, SyntaxKind.SemicolonToken) || word(k, "import") || word(k, "export")) break;
298
+ if (is(k, SyntaxKind.AsteriskToken) || (word(k) && !word(k, "as") && !is(k - 1, SyntaxKind.AsKeyword))) {
299
+ hasPlainBinding = true;
300
+ }
301
+ }
302
+ continue;
303
+ }
304
+
305
+ if (word(i, "export") && tokens[i].kind === SyntaxKind.ExportKeyword) {
306
+ let j = i + 1;
307
+ let typeOnly = false;
308
+ if (word(j, "type") && (is(j + 1, SyntaxKind.OpenBraceToken) || is(j + 1, SyntaxKind.AsteriskToken))) {
309
+ typeOnly = true;
310
+ j++;
311
+ }
312
+ if (is(j, SyntaxKind.AsteriskToken)) {
313
+ let k = j + 1;
314
+ if (word(k, "as")) k += 2;
315
+ if (word(k, "from") && isString(k + 1)) {
316
+ add(k + 1, { typeOnly, dynamic: false, reexport: true, wildcard: true });
317
+ i = k + 1;
318
+ }
319
+ continue;
320
+ }
321
+ if (is(j, SyntaxKind.OpenBraceToken)) {
322
+ const close = closing(j, SyntaxKind.OpenBraceToken, SyntaxKind.CloseBraceToken);
323
+ if (word(close + 1, "from") && isString(close + 2)) {
324
+ add(close + 2, { typeOnly: typeOnly || allTyped(j, close), dynamic: false, reexport: true, wildcard: false });
325
+ i = close + 2;
326
+ }
327
+ }
328
+ continue;
329
+ }
330
+
331
+ if (word(i, "require") && is(i + 1, SyntaxKind.OpenParenToken) && !word(i - 1, "function")) {
332
+ if (isLiteral(i + 2) && is(i + 3, SyntaxKind.CloseParenToken)) {
333
+ add(i + 2, { typeOnly: false, dynamic: true, reexport: false, wildcard: false });
334
+ } else if (!is(i + 2, SyntaxKind.CloseParenToken) && !isMethod(i + 1)) {
335
+ problems.push({
336
+ line: tokens[i].line,
337
+ message: "a require call has a computed specifier, so its target cannot be known",
338
+ });
339
+ }
340
+ }
341
+ }
342
+
343
+ return { imports, problems };
344
+ }
package/src/finding.ts ADDED
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A diagnostic plus what it is about. Exceptions (VSA §9) match on exact
3
+ * paths and edges, which a message cannot be trusted to carry, so every
4
+ * check reports a Finding: the diagnostic in core's shape (ADR 0004 key
5
+ * order, origin "architecture") and the subject an exception can name.
6
+ */
7
+ import type { Diagnostic, Location, Severity } from "@intentset/core";
8
+
9
+ export interface SubjectEdge {
10
+ /** A file path or a slice ID. */
11
+ from: string;
12
+ /** A file path, a slice ID, or a package specifier. */
13
+ to: string;
14
+ }
15
+
16
+ export interface Finding {
17
+ diagnostic: Diagnostic;
18
+ /** Every path the finding concerns; the diagnostic's own path is always included. */
19
+ paths: string[];
20
+ /** File-level and slice-level edges the finding concerns. */
21
+ edges: SubjectEdge[];
22
+ }
23
+
24
+ export interface FindingFields {
25
+ code: string;
26
+ severity?: Severity;
27
+ artifact: string | null;
28
+ path: string | null;
29
+ location?: Location;
30
+ field?: string;
31
+ message: string;
32
+ remediation: string;
33
+ paths?: string[];
34
+ edges?: SubjectEdge[];
35
+ }
36
+
37
+ /** One diagnostic, members in contract order, optional members present only when known. */
38
+ export function finding(fields: FindingFields): Finding {
39
+ const diagnostic: Diagnostic = {
40
+ code: fields.code,
41
+ severity: fields.severity ?? "error",
42
+ origin: "architecture",
43
+ artifact: fields.artifact,
44
+ path: fields.path,
45
+ ...(fields.location !== undefined ? { location: fields.location } : {}),
46
+ ...(fields.field !== undefined ? { field: fields.field } : {}),
47
+ message: fields.message,
48
+ remediation: fields.remediation,
49
+ };
50
+ const paths = new Set(fields.paths ?? []);
51
+ if (fields.path !== null) paths.add(fields.path);
52
+ return { diagnostic, paths: [...paths].sort(), edges: fields.edges ?? [] };
53
+ }
54
+
55
+ /** "1 file" / "3 files". */
56
+ export function plural(count: number, noun: string, many = `${noun}s`): string {
57
+ return `${count} ${count === 1 ? noun : many}`;
58
+ }
59
+
60
+ /** At most `limit` items joined with commas, then "and N more". */
61
+ export function listSome(items: readonly string[], limit = 3): string {
62
+ if (items.length <= limit) return items.join(", ");
63
+ return `${items.slice(0, limit).join(", ")} and ${items.length - limit} more`;
64
+ }
package/src/imports.ts ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The resolved import graph of a tree (VSA003–VSA006, profile TS004): one
3
+ * edge per module reference in every code file, with the target file or,
4
+ * for a package outside the repository, the specifier alone. What could not
5
+ * be read or resolved is returned beside the edges, never folded into them.
6
+ */
7
+ import { extractImports } from "./extract.ts";
8
+ import { isCode } from "./paths.ts";
9
+ import { compareStrings } from "./patterns.ts";
10
+ import type { Resolver, Via } from "./resolve.ts";
11
+
12
+ export interface ImportEdge {
13
+ from: string;
14
+ /** Target file, or null for an external package. */
15
+ to: string | null;
16
+ specifier: string;
17
+ /** 1-based line of the specifier in `from`. */
18
+ line: number;
19
+ typeOnly: boolean;
20
+ dynamic: boolean;
21
+ reexport: boolean;
22
+ /** `export * from` or `export * as ns from`. */
23
+ wildcardExport: boolean;
24
+ via: Via;
25
+ /** Resolved through a `*` alias, an exports pattern or a package without exports (TS006). */
26
+ wildcardAlias: boolean;
27
+ }
28
+
29
+ export interface UnresolvedImport {
30
+ path: string;
31
+ line: number | null;
32
+ specifier: string | null;
33
+ message: string;
34
+ }
35
+
36
+ export interface ImportGraph {
37
+ edges: ImportEdge[];
38
+ unresolved: UnresolvedImport[];
39
+ }
40
+
41
+ /** Build the graph over every code file in `files` (already filtered of ignored paths). */
42
+ export function buildImportGraph(files: ReadonlyMap<string, string>, resolver: Resolver): ImportGraph {
43
+ const edges: ImportEdge[] = [];
44
+ const unresolved: UnresolvedImport[] = [];
45
+ for (const path of [...files.keys()].sort(compareStrings)) {
46
+ if (!isCode(path)) continue;
47
+ const { imports, problems } = extractImports(path, files.get(path) as string);
48
+ for (const problem of problems) {
49
+ unresolved.push({ path, line: problem.line, specifier: null, message: problem.message });
50
+ }
51
+ for (const raw of imports) {
52
+ const resolution = resolver.resolve(path, raw.specifier);
53
+ if (resolution.problem !== undefined) {
54
+ unresolved.push({
55
+ path,
56
+ line: raw.line,
57
+ specifier: raw.specifier,
58
+ message: `the specifier "${raw.specifier}" ${resolution.problem}`,
59
+ });
60
+ continue;
61
+ }
62
+ edges.push({
63
+ from: path,
64
+ to: resolution.to,
65
+ specifier: raw.specifier,
66
+ line: raw.line,
67
+ typeOnly: raw.typeOnly,
68
+ dynamic: raw.dynamic,
69
+ reexport: raw.reexport,
70
+ wildcardExport: raw.wildcard,
71
+ via: resolution.via,
72
+ wildcardAlias: resolution.wildcard,
73
+ });
74
+ }
75
+ }
76
+ return { edges, unresolved };
77
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @intentset/architecture: the Traceable VSA checker (spec/vsa-0.1.md) with
3
+ * the TypeScript + Amplify Gen 2 profile's boundaries
4
+ * (spec/profile-typescript-amplify-gen2-0.1.md). `checkArchitecture` is the
5
+ * whole check; the rest is exported for reports, tools and tests.
6
+ */
7
+ export {
8
+ applyBaseline,
9
+ type BaselineEntry,
10
+ fingerprint,
11
+ type Mode,
12
+ parseBaseline,
13
+ plainMessage,
14
+ writeBaseline,
15
+ } from "./baseline.ts";
16
+ export { type BoundaryResult, checkBoundaries, checkUnclassified, type SliceEdge } from "./boundaries.ts";
17
+ export {
18
+ type ArchitectureResult,
19
+ type ArchitectureSummary,
20
+ type CheckOptions,
21
+ checkArchitecture,
22
+ REVIEW_REQUIRED,
23
+ } from "./check.ts";
24
+ export { checkClaims } from "./claims.ts";
25
+ export {
26
+ ARCHITECTURE_CONFIG_PATH,
27
+ type ArchitectureConfig,
28
+ DEFAULT_ARCHITECTURE_CONFIG,
29
+ readArchitectureConfig,
30
+ resolveConfig,
31
+ } from "./config.ts";
32
+ export { checkDependencies, shortestCycle, stronglyConnected } from "./dependencies.ts";
33
+ export {
34
+ annotate,
35
+ applyExceptions,
36
+ covers,
37
+ EXCEPTIONS_PATTERNS,
38
+ type ExceptionRecord,
39
+ readExceptionRecord,
40
+ readExceptions,
41
+ } from "./exceptions.ts";
42
+ export { type Extraction, extractImports, type RawImport } from "./extract.ts";
43
+ export type { Finding, SubjectEdge } from "./finding.ts";
44
+ export { buildImportGraph, type ImportEdge, type ImportGraph, type UnresolvedImport } from "./imports.ts";
45
+ export { inSeam, isPure, layerAllows, layerOf, purePatterns, seamPatterns } from "./layers.ts";
46
+ export { checkOwnership } from "./ownership.ts";
47
+ export { expandClaims, matchAny, matchPattern, validatePattern } from "./patterns.ts";
48
+ export {
49
+ activeSlices,
50
+ buildModel,
51
+ type Model,
52
+ type Region,
53
+ type RegionKind,
54
+ regionLabel,
55
+ type SliceInfo,
56
+ } from "./regions.ts";
57
+ export { type Resolution, Resolver, type Via } from "./resolve.ts";
58
+ export { matchAlias, stripJsonComments, type TsConfig, TsConfigs } from "./tsconfig.ts";
package/src/layers.ts ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * A slice's internal layers (VSA006, profile §3). The slice declares its
3
+ * layers by pattern; the profile's matrix says which may import which:
4
+ *
5
+ * presentation -> application, policy, model
6
+ * application -> policy, model, external
7
+ * policy -> model
8
+ * model -> model
9
+ * external -> model
10
+ *
11
+ * each layer also importing itself. The same declaration names the slice's
12
+ * external-access seam (the `external` layer, VSA010 and AMP002) and its pure
13
+ * layers (`policy` and `model`, AMP006). A slice that declares no layers
14
+ * falls back to the reference layout's folders under an entrypoint's
15
+ * directory: client/, domain/policies/, domain/models/. A slice has one
16
+ * entrypoint per package, so a file uses the folders under the entrypoint
17
+ * directory nearest above it.
18
+ */
19
+ import type { ArchitectureConfig } from "./config.ts";
20
+ import { matchAny } from "./patterns.ts";
21
+ import { rootOf, type SliceInfo } from "./regions.ts";
22
+
23
+ export type LayerOf = { layer: string } | { layer: null; reason: "none" | "ambiguous"; candidates: string[] };
24
+
25
+ export function layerOf(slice: SliceInfo, path: string): LayerOf {
26
+ const matches = Object.keys(slice.meta.layers)
27
+ .sort()
28
+ .filter((layer) => matchAny(slice.meta.layers[layer], path));
29
+ if (matches.length === 1) return { layer: matches[0] };
30
+ return { layer: null, reason: matches.length === 0 ? "none" : "ambiguous", candidates: matches };
31
+ }
32
+
33
+ /** The fallback folders under the root a path belongs to, or under every root when no path is given. */
34
+ function under(slice: SliceInfo, folders: string[], path?: string): string[] {
35
+ const roots = path === undefined ? slice.roots : [rootOf(slice, path)].filter((root) => root !== null);
36
+ return roots.flatMap((root) => folders.map((folder) => (root === "" ? `${folder}/**` : `${root}/${folder}/**`)));
37
+ }
38
+
39
+ /** The slice's declared external-access seam; for a slice that declares none, the client/ folder under each entrypoint (or the one above `path`). */
40
+ export function seamPatterns(slice: SliceInfo, path?: string): string[] {
41
+ const declared = slice.meta.layers.external ?? [];
42
+ return declared.length > 0 ? declared : under(slice, ["client"], path);
43
+ }
44
+
45
+ /** The slice's pure layers: policies and models, which must stay free of clients and SDKs. */
46
+ export function purePatterns(slice: SliceInfo, path?: string): string[] {
47
+ const declared = [...(slice.meta.layers.policy ?? []), ...(slice.meta.layers.model ?? [])];
48
+ return declared.length > 0 ? declared : under(slice, ["domain/policies", "domain/models"], path);
49
+ }
50
+
51
+ export function inSeam(slice: SliceInfo, path: string): boolean {
52
+ return matchAny(seamPatterns(slice, path), path);
53
+ }
54
+
55
+ export function isPure(slice: SliceInfo, path: string): boolean {
56
+ return matchAny(purePatterns(slice, path), path);
57
+ }
58
+
59
+ /** May a file in layer `from` import one in layer `to`? Unknown layers are never allowed anything. */
60
+ export function layerAllows(config: ArchitectureConfig, from: string, to: string): boolean {
61
+ if (from === to) return true;
62
+ return (config.layerMatrix[from] ?? []).includes(to);
63
+ }
64
+
65
+ export function knownLayer(config: ArchitectureConfig, layer: string): boolean {
66
+ return Object.hasOwn(config.layerMatrix, layer);
67
+ }