@intentset/architecture 0.2.0 → 0.4.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 +33 -0
- package/dist/areas.d.ts.map +1 -0
- package/dist/areas.js +189 -0
- package/dist/areas.js.map +1 -0
- package/dist/boundaries.d.ts +0 -40
- package/dist/boundaries.d.ts.map +1 -1
- package/dist/boundaries.js +18 -2
- package/dist/boundaries.js.map +1 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +5 -1
- package/dist/check.js.map +1 -1
- package/dist/config.d.ts +19 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +78 -1
- package/dist/config.js.map +1 -1
- package/dist/extract.d.ts +33 -0
- package/dist/extract.d.ts.map +1 -1
- package/dist/extract.js +129 -0
- package/dist/extract.js.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/regions.d.ts.map +1 -1
- package/dist/regions.js +3 -1
- package/dist/regions.js.map +1 -1
- package/package.json +2 -2
- package/src/areas.ts +233 -0
- package/src/boundaries.ts +21 -2
- package/src/check.ts +5 -1
- package/src/config.ts +92 -1
- package/src/extract.ts +150 -0
- package/src/index.ts +15 -1
- package/src/regions.ts +3 -1
package/src/areas.ts
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Areas (profile §9): a backend split into several Amplify backends, each its
|
|
3
|
+
* own CloudFormation deployment and source API, behind one AppSync Merged API.
|
|
4
|
+
* Nothing here runs unless `.intentset/architecture.yaml` declares areas.
|
|
5
|
+
*
|
|
6
|
+
* AMP007 a slice names a declared area as its domain, and its claims stay in that area
|
|
7
|
+
* AMP008 no area backend imports another; the shared backend package imports no area
|
|
8
|
+
* AMP009 each model, enum and operation is declared in one area's schema (a custom type: a warning)
|
|
9
|
+
* AMP010 no relationship or reference names another area's type
|
|
10
|
+
* AMP011 only the schema bridge calls generateClient (its schema imports are checked with AMP001)
|
|
11
|
+
*
|
|
12
|
+
* AMP012 (cross-area access and an acyclic deploy order) and AMP013 (one
|
|
13
|
+
* authorizer on the Merged API) are review assertions: neither is visible in
|
|
14
|
+
* source, so they are listed for review and never reported as passing.
|
|
15
|
+
*/
|
|
16
|
+
import type { AreaConfig } from "./config.ts";
|
|
17
|
+
import { extractSchema, findCalls, type SchemaKind } from "./extract.ts";
|
|
18
|
+
import { type Finding, finding, listSome } from "./finding.ts";
|
|
19
|
+
import type { ImportEdge } from "./imports.ts";
|
|
20
|
+
import { isCode } from "./paths.ts";
|
|
21
|
+
import { compareStrings, matchAny } from "./patterns.ts";
|
|
22
|
+
import type { Model } from "./regions.ts";
|
|
23
|
+
|
|
24
|
+
export const AREA_REVIEW_REQUIRED = [
|
|
25
|
+
"AMP012 backend code reaches another area's data through published resource names and the AWS SDK, and deploy-time dependencies between areas are acyclic: deployment wiring, not source",
|
|
26
|
+
"AMP013 one authorizer on the Merged API decides every request to every area and denies what it does not list: an authorizer's policy, not source",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
export interface AreaCheck {
|
|
30
|
+
findings: Finding[];
|
|
31
|
+
/** What could not be read: never a pass (invariant 6). */
|
|
32
|
+
unresolved: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The area whose backend holds a path, or null. */
|
|
36
|
+
export function backendAreaOf(areas: readonly AreaConfig[], path: string): AreaConfig | null {
|
|
37
|
+
return areas.find((area) => matchAny(area.backend, path)) ?? null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The area whose backend or frontend holds a path, or null. */
|
|
41
|
+
export function areaOf(areas: readonly AreaConfig[], path: string): AreaConfig | null {
|
|
42
|
+
return backendAreaOf(areas, path) ?? areas.find((area) => matchAny(area.frontend, path)) ?? null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** True when the path is one of the declared areas' schema files. */
|
|
46
|
+
export function isAreaSchema(areas: readonly AreaConfig[], path: string): boolean {
|
|
47
|
+
return areas.some((area) => area.schema === path);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const OPERATION_KINDS = new Set<SchemaKind | null>(["model", "enum", "query", "mutation", "subscription", null]);
|
|
51
|
+
|
|
52
|
+
export function checkAreas(model: Model, edges: readonly ImportEdge[]): AreaCheck {
|
|
53
|
+
const { areas, sharedBackend, schemaBridge } = model.config;
|
|
54
|
+
const findings: Finding[] = [];
|
|
55
|
+
const unresolved: string[] = [];
|
|
56
|
+
if (areas.length === 0) return { findings, unresolved };
|
|
57
|
+
const names = areas.map((area) => area.name).join(", ");
|
|
58
|
+
|
|
59
|
+
// AMP007: every slice in a declared area, and its claims inside it.
|
|
60
|
+
for (const slice of model.slices) {
|
|
61
|
+
const area = areas.find((candidate) => candidate.name === slice.meta.domain);
|
|
62
|
+
if (area === undefined) {
|
|
63
|
+
findings.push(
|
|
64
|
+
finding({
|
|
65
|
+
code: "AMP007",
|
|
66
|
+
artifact: slice.id,
|
|
67
|
+
path: slice.path,
|
|
68
|
+
field: "/intentset/slice/domain",
|
|
69
|
+
message: `${slice.id} names the domain "${slice.meta.domain}", which is not a declared area; the areas are ${names}.`,
|
|
70
|
+
remediation:
|
|
71
|
+
"Set domain to the area whose backend serves the slice, or declare the area in .intentset/architecture.yaml (profile §9, AMP007).",
|
|
72
|
+
}),
|
|
73
|
+
);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
slice.meta.claims.forEach((claim, i) => {
|
|
77
|
+
const foreign = slice.claimFiles[i].filter((file) => {
|
|
78
|
+
const other = areaOf(areas, file);
|
|
79
|
+
return other !== null && other.name !== area.name;
|
|
80
|
+
});
|
|
81
|
+
if (foreign.length === 0) return;
|
|
82
|
+
const others = [...new Set(foreign.map((file) => areaOf(areas, file)?.name as string))].sort(compareStrings);
|
|
83
|
+
findings.push(
|
|
84
|
+
finding({
|
|
85
|
+
code: "AMP007",
|
|
86
|
+
artifact: slice.id,
|
|
87
|
+
path: slice.path,
|
|
88
|
+
field: `/intentset/slice/claims/${i}/path`,
|
|
89
|
+
message: `The ${claim.kind} claim ${claim.path} of ${slice.id}, a slice of area ${area.name}, reaches into area ${others.join(" and ")}: ${listSome(foreign)}.`,
|
|
90
|
+
remediation:
|
|
91
|
+
"Narrow the claim to the slice's own area, or move the slice to the area that holds those files (profile §9, AMP007).",
|
|
92
|
+
paths: foreign,
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// AMP008: area backends deploy apart, so neither imports the other; shared backend code imports no area.
|
|
99
|
+
for (const edge of edges) {
|
|
100
|
+
if (edge.to === null || !model.inScope(edge.from)) continue;
|
|
101
|
+
const to = backendAreaOf(areas, edge.to);
|
|
102
|
+
if (to === null) continue;
|
|
103
|
+
const from = backendAreaOf(areas, edge.from);
|
|
104
|
+
const location = { line: edge.line };
|
|
105
|
+
if (from !== null && from.name !== to.name) {
|
|
106
|
+
findings.push(
|
|
107
|
+
finding({
|
|
108
|
+
code: "AMP008",
|
|
109
|
+
artifact: null,
|
|
110
|
+
path: edge.from,
|
|
111
|
+
location,
|
|
112
|
+
message: `${edge.from}, in the ${from.name} backend, imports ${edge.to} from the ${to.name} backend, and area backends deploy separately.`,
|
|
113
|
+
remediation:
|
|
114
|
+
"Move what both need into the shared backend package, and reach the other area's data through its published resource names (profile §9, AMP008, AMP012).",
|
|
115
|
+
edges: [{ from: edge.from, to: edge.to }],
|
|
116
|
+
}),
|
|
117
|
+
);
|
|
118
|
+
} else if (from === null && matchAny(sharedBackend, edge.from)) {
|
|
119
|
+
findings.push(
|
|
120
|
+
finding({
|
|
121
|
+
code: "AMP008",
|
|
122
|
+
artifact: null,
|
|
123
|
+
path: edge.from,
|
|
124
|
+
location,
|
|
125
|
+
message: `${edge.from}, in the shared backend package, imports ${edge.to} from the ${to.name} backend, and the shared package must stay domain-neutral.`,
|
|
126
|
+
remediation:
|
|
127
|
+
"Pass what the shared code needs in as a parameter, or move the code into the area that owns it (profile §9, AMP008).",
|
|
128
|
+
edges: [{ from: edge.from, to: edge.to }],
|
|
129
|
+
}),
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// AMP009 and AMP010: one owner per name across the areas' schemas, and no reference across them.
|
|
135
|
+
const declared = new Map<string, { area: string; kind: SchemaKind | null; schema: string; line: number }[]>();
|
|
136
|
+
const read = new Map<string, ReturnType<typeof extractSchema>>();
|
|
137
|
+
for (const area of areas) {
|
|
138
|
+
const text = model.files.get(area.schema);
|
|
139
|
+
if (text === undefined) {
|
|
140
|
+
findings.push(
|
|
141
|
+
finding({
|
|
142
|
+
code: "AMP009",
|
|
143
|
+
artifact: null,
|
|
144
|
+
path: area.schema,
|
|
145
|
+
message: `The schema of area ${area.name}, ${area.schema}, is not a file in the repository, so its declarations were not checked.`,
|
|
146
|
+
remediation: "Correct the area's schema path in .intentset/architecture.yaml (profile §9).",
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const extraction = extractSchema(area.schema, text);
|
|
152
|
+
read.set(area.name, extraction);
|
|
153
|
+
if (!extraction.found) {
|
|
154
|
+
unresolved.push(
|
|
155
|
+
`${area.schema}: no <builder>.schema({ ... }) call, so area ${area.name}'s declarations were not read`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
for (const problem of extraction.problems) unresolved.push(`${area.schema}:${problem.line}: ${problem.message}`);
|
|
159
|
+
for (const declaration of extraction.declarations) {
|
|
160
|
+
const list = declared.get(declaration.name) ?? [];
|
|
161
|
+
list.push({ area: area.name, kind: declaration.kind, schema: area.schema, line: declaration.line });
|
|
162
|
+
declared.set(declaration.name, list);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
for (const name of [...declared.keys()].sort(compareStrings)) {
|
|
166
|
+
const owners = declared.get(name) ?? [];
|
|
167
|
+
const inAreas = [...new Set(owners.map((owner) => owner.area))].sort(compareStrings);
|
|
168
|
+
if (inAreas.length < 2) continue;
|
|
169
|
+
const later = owners.find((owner) => owner.area === inAreas[1]) ?? owners[1];
|
|
170
|
+
const operation = owners.some((owner) => OPERATION_KINDS.has(owner.kind));
|
|
171
|
+
findings.push(
|
|
172
|
+
finding({
|
|
173
|
+
code: "AMP009",
|
|
174
|
+
...(operation ? {} : { severity: "warning" as const }),
|
|
175
|
+
artifact: null,
|
|
176
|
+
path: later.schema,
|
|
177
|
+
location: { line: later.line },
|
|
178
|
+
message: operation
|
|
179
|
+
? `${name} is declared in the schemas of areas ${inAreas.join(" and ")}; the Merged API joins every area into one namespace and refuses a field two sources resolve.`
|
|
180
|
+
: `The custom type ${name} is declared in the schemas of areas ${inAreas.join(" and ")}, which share one type namespace in the Merged API.`,
|
|
181
|
+
remediation: operation
|
|
182
|
+
? `Keep ${name} in the one area whose functions read and write it, and link to it from the others by ID (profile §9, AMP009).`
|
|
183
|
+
: `Prefix the type with its area's name, or name it after the operation that returns it (profile §9, AMP009).`,
|
|
184
|
+
paths: [...new Set(owners.map((owner) => owner.schema))].sort(compareStrings),
|
|
185
|
+
}),
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
for (const area of areas) {
|
|
189
|
+
const extraction = read.get(area.name);
|
|
190
|
+
if (extraction === undefined) continue;
|
|
191
|
+
const own = new Set(extraction.declarations.map((declaration) => declaration.name));
|
|
192
|
+
for (const reference of extraction.references) {
|
|
193
|
+
if (own.has(reference.target)) continue;
|
|
194
|
+
const elsewhere = (declared.get(reference.target) ?? []).map((owner) => owner.area);
|
|
195
|
+
if (elsewhere.length === 0) continue;
|
|
196
|
+
findings.push(
|
|
197
|
+
finding({
|
|
198
|
+
code: "AMP010",
|
|
199
|
+
artifact: null,
|
|
200
|
+
path: area.schema,
|
|
201
|
+
location: { line: reference.line },
|
|
202
|
+
message: `${area.schema} ${reference.via}("${reference.target}") names a type that area ${elsewhere.sort(compareStrings)[0]} declares; relationships and references do not cross areas.`,
|
|
203
|
+
remediation:
|
|
204
|
+
"Store the other area's ID as a plain field and resolve it in a function of this area (profile §9, AMP010).",
|
|
205
|
+
}),
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// AMP011: one client, on the Merged API, made in one place.
|
|
211
|
+
for (const [path, text] of model.files) {
|
|
212
|
+
if (path === schemaBridge || !isCode(path) || model.isTest(path) || !model.inScope(path)) continue;
|
|
213
|
+
if (model.underBackend(path) || matchAny(sharedBackend, path)) continue;
|
|
214
|
+
if (!text.includes("generateClient")) continue;
|
|
215
|
+
const lines = findCalls(path, text, "generateClient");
|
|
216
|
+
if (lines.length === 0) continue;
|
|
217
|
+
findings.push(
|
|
218
|
+
finding({
|
|
219
|
+
code: "AMP011",
|
|
220
|
+
artifact: null,
|
|
221
|
+
path,
|
|
222
|
+
location: { line: lines[0] },
|
|
223
|
+
message:
|
|
224
|
+
schemaBridge === null
|
|
225
|
+
? `${path} calls generateClient, and this repository declares no schema bridge to make the one client on the Merged API.`
|
|
226
|
+
: `${path} calls generateClient; only the schema bridge ${schemaBridge} makes the client, once, on the Merged API.`,
|
|
227
|
+
remediation:
|
|
228
|
+
"Declare schemaBridge in .intentset/architecture.yaml and import the client from it everywhere else (profile §9, AMP011).",
|
|
229
|
+
}),
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
return { findings, unresolved };
|
|
233
|
+
}
|
package/src/boundaries.ts
CHANGED
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
* so a narrow scope cannot hide its consumers and they do not fail it
|
|
39
39
|
* (VSA §9). They count toward no edge total and no slice dependency.
|
|
40
40
|
*/
|
|
41
|
+
import { isAreaSchema } from "./areas.ts";
|
|
41
42
|
import { type Finding, finding, listSome } from "./finding.ts";
|
|
42
43
|
import type { ImportEdge } from "./imports.ts";
|
|
43
44
|
import { inSeam, isPure, knownLayer, layerAllows, layerOf } from "./layers.ts";
|
|
@@ -281,9 +282,27 @@ export function checkBoundaries(model: Model, edges: readonly ImportEdge[], reso
|
|
|
281
282
|
if (!model.inScope(to) && target === undefined) continue;
|
|
282
283
|
const subject = { edges: [{ from, to }, ...(source && target ? [{ from: source.id, to: target.id }] : [])] };
|
|
283
284
|
|
|
284
|
-
// 1. AMP001
|
|
285
|
-
|
|
285
|
+
// 1. AMP001, and AMP011 for an area repository's schema bridge (profile §9). The shared backend
|
|
286
|
+
// package is backend code: what it may import is AMP008's question.
|
|
287
|
+
if (model.underBackend(to) && !model.underBackend(from) && !matchAny(config.sharedBackend, from)) {
|
|
286
288
|
const seam = source !== undefined && inSeam(source, from);
|
|
289
|
+
if (edge.typeOnly && isAreaSchema(config.areas, to)) {
|
|
290
|
+
if (from !== config.schemaBridge) {
|
|
291
|
+
findings.push(
|
|
292
|
+
finding({
|
|
293
|
+
code: "AMP011",
|
|
294
|
+
artifact,
|
|
295
|
+
path: from,
|
|
296
|
+
location,
|
|
297
|
+
message: `${from} ${how(edge)}, the schema of an area, and only the schema bridge${config.schemaBridge === null ? ", which this repository has not declared," : ` ${config.schemaBridge}`} may.`,
|
|
298
|
+
remediation:
|
|
299
|
+
"Import the client and its types from the schema bridge, which joins every area's schema into one type (profile §9, AMP011).",
|
|
300
|
+
...subject,
|
|
301
|
+
}),
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
287
306
|
if (!(seam && matchAny(config.responseParserModules, to))) {
|
|
288
307
|
findings.push(
|
|
289
308
|
finding({
|
package/src/check.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
type Registries,
|
|
21
21
|
sortDiagnostics,
|
|
22
22
|
} from "@intentset/core";
|
|
23
|
+
import { AREA_REVIEW_REQUIRED, checkAreas } from "./areas.ts";
|
|
23
24
|
import { applyBaseline, type BaselineEntry, type Mode } from "./baseline.ts";
|
|
24
25
|
import { checkBoundaries, checkUnclassified } from "./boundaries.ts";
|
|
25
26
|
import { checkClaims } from "./claims.ts";
|
|
@@ -165,6 +166,9 @@ export function checkArchitecture(
|
|
|
165
166
|
const boundaries = checkBoundaries(model, imports.edges, resolver);
|
|
166
167
|
findings.push(...boundaries.findings);
|
|
167
168
|
findings.push(...checkUnclassified(model));
|
|
169
|
+
const areaCheck = checkAreas(model, imports.edges);
|
|
170
|
+
findings.push(...areaCheck.findings);
|
|
171
|
+
unresolved.push(...areaCheck.unresolved);
|
|
168
172
|
const dependencies = checkDependencies(model, graph, boundaries.sliceEdges);
|
|
169
173
|
findings.push(...dependencies.findings);
|
|
170
174
|
|
|
@@ -207,7 +211,7 @@ export function checkArchitecture(
|
|
|
207
211
|
baselined: baseline.baselined,
|
|
208
212
|
baselineStale: baseline.stale.length,
|
|
209
213
|
unresolved: [...new Set(unresolved)].sort(compareStrings),
|
|
210
|
-
reviewRequired: [...REVIEW_REQUIRED],
|
|
214
|
+
reviewRequired: [...REVIEW_REQUIRED, ...(config.areas.length > 0 ? AREA_REVIEW_REQUIRED : [])],
|
|
211
215
|
};
|
|
212
216
|
const ownership: OwnershipEntry[] = [];
|
|
213
217
|
for (const path of model.files.keys()) {
|
package/src/config.ts
CHANGED
|
@@ -11,6 +11,21 @@ import { compareStrings, validatePattern } from "./patterns.ts";
|
|
|
11
11
|
|
|
12
12
|
export const ARCHITECTURE_CONFIG_PATH = ".intentset/architecture.yaml";
|
|
13
13
|
|
|
14
|
+
/** One area (profile §9): an Amplify backend of its own and the frontend slices it serves. */
|
|
15
|
+
export interface AreaConfig {
|
|
16
|
+
/** Lowercase name; a slice names its area in `domain`. */
|
|
17
|
+
name: string;
|
|
18
|
+
/** Patterns for the area's backend: its own CloudFormation deployment and source API. */
|
|
19
|
+
backend: string[];
|
|
20
|
+
/** The area's schema file, `data/resource.ts`: the one place its models and operations are declared (AMP009). */
|
|
21
|
+
schema: string;
|
|
22
|
+
/** Patterns for the area's frontend slices, such as src/features/<area>/**. May be empty. */
|
|
23
|
+
frontend: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** AppSync joins at most this many source APIs into one Merged API. */
|
|
27
|
+
export const MAX_AREAS = 10;
|
|
28
|
+
|
|
14
29
|
export interface ArchitectureConfig {
|
|
15
30
|
/** Declared scope (VSA §9, Core §11): files outside it are counted, never reported one by one. */
|
|
16
31
|
scope: string[];
|
|
@@ -40,6 +55,12 @@ export interface ArchitectureConfig {
|
|
|
40
55
|
layerMatrix: Record<string, string[]>;
|
|
41
56
|
/** Export conditions tried first when resolving a workspace package's entry, before types, import and default. */
|
|
42
57
|
conditions: string[];
|
|
58
|
+
/** Areas (profile §9). Empty for a repository with one backend, which none of AMP007 to AMP013 concern. */
|
|
59
|
+
areas: AreaConfig[];
|
|
60
|
+
/** Domain-neutral backend code every area may import, which imports no area (AMP008). */
|
|
61
|
+
sharedBackend: string[];
|
|
62
|
+
/** The one frontend file that may import area schemas, type-only, and call generateClient (AMP011). */
|
|
63
|
+
schemaBridge: string | null;
|
|
43
64
|
}
|
|
44
65
|
|
|
45
66
|
export const DEFAULT_ARCHITECTURE_CONFIG: ArchitectureConfig = {
|
|
@@ -63,6 +84,9 @@ export const DEFAULT_ARCHITECTURE_CONFIG: ArchitectureConfig = {
|
|
|
63
84
|
external: ["external", "model"],
|
|
64
85
|
},
|
|
65
86
|
conditions: [],
|
|
87
|
+
areas: [],
|
|
88
|
+
sharedBackend: [],
|
|
89
|
+
schemaBridge: null,
|
|
66
90
|
};
|
|
67
91
|
|
|
68
92
|
const LIST_KEYS = [
|
|
@@ -78,6 +102,7 @@ const LIST_KEYS = [
|
|
|
78
102
|
"tests",
|
|
79
103
|
"backendSdks",
|
|
80
104
|
"conditions",
|
|
105
|
+
"sharedBackend",
|
|
81
106
|
] as const;
|
|
82
107
|
|
|
83
108
|
/** Keys whose entries are path patterns and must pass validatePattern. */
|
|
@@ -93,9 +118,10 @@ const PATTERN_KEYS = new Set<string>([
|
|
|
93
118
|
"ignore",
|
|
94
119
|
"tests",
|
|
95
120
|
"screensGlob",
|
|
121
|
+
"sharedBackend",
|
|
96
122
|
]);
|
|
97
123
|
|
|
98
|
-
const KNOWN_KEYS = [...LIST_KEYS, "screensGlob", "layerMatrix"].sort(compareStrings);
|
|
124
|
+
const KNOWN_KEYS = [...LIST_KEYS, "screensGlob", "layerMatrix", "areas", "schemaBridge"].sort(compareStrings);
|
|
99
125
|
|
|
100
126
|
/** The defaults with a partial configuration laid over them; a key given replaces the default whole. */
|
|
101
127
|
export function resolveConfig(...layers: (Partial<ArchitectureConfig> | undefined)[]): ArchitectureConfig {
|
|
@@ -166,6 +192,25 @@ export function readArchitectureConfig(
|
|
|
166
192
|
} else config.screensGlob = value as string;
|
|
167
193
|
continue;
|
|
168
194
|
}
|
|
195
|
+
if (key === "schemaBridge") {
|
|
196
|
+
const why = typeof value === "string" ? literalPath(value) : "is not a string";
|
|
197
|
+
if (why !== null) {
|
|
198
|
+
report(
|
|
199
|
+
field,
|
|
200
|
+
at(field),
|
|
201
|
+
`schemaBridge ${why}.`,
|
|
202
|
+
"Name the one file, such as src/infrastructure/amplify/client.ts.",
|
|
203
|
+
);
|
|
204
|
+
} else config.schemaBridge = value as string;
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (key === "areas") {
|
|
208
|
+
const read = readAreas(value);
|
|
209
|
+
if (typeof read === "string") {
|
|
210
|
+
report(field, at(field), `areas ${read}.`, AREAS_REMEDIATION);
|
|
211
|
+
} else config.areas = read;
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
169
214
|
if (key === "layerMatrix") {
|
|
170
215
|
const matrix = readMatrix(value);
|
|
171
216
|
if (matrix === null) {
|
|
@@ -210,3 +255,49 @@ function readMatrix(value: unknown): Record<string, string[]> | null {
|
|
|
210
255
|
}
|
|
211
256
|
return matrix;
|
|
212
257
|
}
|
|
258
|
+
|
|
259
|
+
const AREAS_REMEDIATION =
|
|
260
|
+
"Write each area as a mapping of name, backend (a list of patterns), schema (one file) and frontend (a list of patterns), as profile §9 shows.";
|
|
261
|
+
const AREA_NAME = /^[a-z][a-z0-9-]*$/;
|
|
262
|
+
|
|
263
|
+
/** `areas` as a list of AreaConfig, or why it is not one. */
|
|
264
|
+
function readAreas(value: unknown): AreaConfig[] | string {
|
|
265
|
+
if (!Array.isArray(value)) return "must be a list of areas";
|
|
266
|
+
if (value.length > MAX_AREAS) {
|
|
267
|
+
return `names ${value.length} areas, and an AppSync Merged API joins at most ${MAX_AREAS} source APIs`;
|
|
268
|
+
}
|
|
269
|
+
const areas: AreaConfig[] = [];
|
|
270
|
+
for (const [i, entry] of value.entries()) {
|
|
271
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) return `entry ${i} is not a mapping`;
|
|
272
|
+
const record = entry as Record<string, unknown>;
|
|
273
|
+
const unknown = Object.keys(record).filter((key) => !["name", "backend", "schema", "frontend"].includes(key));
|
|
274
|
+
if (unknown.length > 0) return `entry ${i} has the unknown key \`${unknown[0]}\``;
|
|
275
|
+
const { name, backend, schema, frontend = [] } = record;
|
|
276
|
+
if (typeof name !== "string" || !AREA_NAME.test(name)) return `entry ${i} needs a lowercase name such as platform`;
|
|
277
|
+
if (areas.some((area) => area.name === name)) return `name the area ${name} twice`;
|
|
278
|
+
const patterns = (list: unknown, what: string, required: boolean): string[] | string => {
|
|
279
|
+
if (!Array.isArray(list) || !list.every((item) => typeof item === "string" && item !== "")) {
|
|
280
|
+
return `entry ${name} needs ${what} as a list of patterns`;
|
|
281
|
+
}
|
|
282
|
+
if (required && list.length === 0) return `entry ${name} needs at least one ${what} pattern`;
|
|
283
|
+
const bad = (list as string[]).find((pattern) => validatePattern(pattern) !== null);
|
|
284
|
+
return bad === undefined
|
|
285
|
+
? [...(list as string[])]
|
|
286
|
+
: `entry ${name}: the ${what} pattern ${bad} ${validatePattern(bad)}`;
|
|
287
|
+
};
|
|
288
|
+
const backendList = patterns(backend, "backend", true);
|
|
289
|
+
if (typeof backendList === "string") return backendList;
|
|
290
|
+
const frontendList = patterns(frontend, "frontend", false);
|
|
291
|
+
if (typeof frontendList === "string") return frontendList;
|
|
292
|
+
const why = typeof schema === "string" ? literalPath(schema) : "is missing";
|
|
293
|
+
if (why !== null) return `entry ${name}: schema ${why}`;
|
|
294
|
+
areas.push({ name, backend: backendList, schema: schema as string, frontend: frontendList });
|
|
295
|
+
}
|
|
296
|
+
return areas;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Null for one repository-relative file path with no wildcard, or why it is not one. */
|
|
300
|
+
function literalPath(value: string): string | null {
|
|
301
|
+
if (value.includes("*")) return "must name one file, not a pattern";
|
|
302
|
+
return validatePattern(value);
|
|
303
|
+
}
|
package/src/extract.ts
CHANGED
|
@@ -342,3 +342,153 @@ export function extractImports(path: string, text: string): Extraction {
|
|
|
342
342
|
|
|
343
343
|
return { imports, problems };
|
|
344
344
|
}
|
|
345
|
+
|
|
346
|
+
/** What an Amplify schema declares at its top level (profile §9, AMP009). */
|
|
347
|
+
export const SCHEMA_KINDS = ["model", "enum", "query", "mutation", "subscription", "customType"] as const;
|
|
348
|
+
export type SchemaKind = (typeof SCHEMA_KINDS)[number];
|
|
349
|
+
|
|
350
|
+
export interface SchemaDeclaration {
|
|
351
|
+
name: string;
|
|
352
|
+
/** Null when the value is not a builder call this reader recognizes, such as a variable defined elsewhere. */
|
|
353
|
+
kind: SchemaKind | null;
|
|
354
|
+
line: number;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** A relationship or reference by name inside a schema (AMP010): `belongsTo("X")`, `hasMany`, `hasOne`, `ref`. */
|
|
358
|
+
export interface SchemaReference {
|
|
359
|
+
target: string;
|
|
360
|
+
via: "belongsTo" | "hasMany" | "hasOne" | "ref";
|
|
361
|
+
line: number;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export interface SchemaExtraction {
|
|
365
|
+
/** False when the file holds no `<builder>.schema({ ... })` call at all. */
|
|
366
|
+
found: boolean;
|
|
367
|
+
declarations: SchemaDeclaration[];
|
|
368
|
+
references: SchemaReference[];
|
|
369
|
+
problems: ExtractProblem[];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const REFERENCE_CALLS = new Set(["belongsTo", "hasMany", "hasOne", "ref"]);
|
|
373
|
+
const OPENERS = new Set<SyntaxKind>([
|
|
374
|
+
SyntaxKind.OpenBraceToken,
|
|
375
|
+
SyntaxKind.OpenParenToken,
|
|
376
|
+
SyntaxKind.OpenBracketToken,
|
|
377
|
+
]);
|
|
378
|
+
const CLOSERS = new Set<SyntaxKind>([
|
|
379
|
+
SyntaxKind.CloseBraceToken,
|
|
380
|
+
SyntaxKind.CloseParenToken,
|
|
381
|
+
SyntaxKind.CloseBracketToken,
|
|
382
|
+
]);
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The top-level names an Amplify Gen 2 schema file declares, and the names its
|
|
386
|
+
* relationships and references point at, read from TypeScript's tokens as
|
|
387
|
+
* imports are. Every `<builder>.schema({ ... })` object literal counts: each
|
|
388
|
+
* key at its top level is a declaration, its kind read from a `<builder>.<kind>(`
|
|
389
|
+
* value or from a `const Name = <builder>.<kind>(` elsewhere in the file. A
|
|
390
|
+
* spread, or a value that is neither, is a problem rather than a guess.
|
|
391
|
+
*/
|
|
392
|
+
export function extractSchema(path: string, text: string): SchemaExtraction {
|
|
393
|
+
const { tokens, problems } = tokenize(path, text);
|
|
394
|
+
const declarations: SchemaDeclaration[] = [];
|
|
395
|
+
const references: SchemaReference[] = [];
|
|
396
|
+
const word = (i: number, value?: string): boolean => {
|
|
397
|
+
const token = tokens[i];
|
|
398
|
+
return (
|
|
399
|
+
token !== undefined && tokenIsIdentifierOrKeyword(token.kind) && (value === undefined || token.text === value)
|
|
400
|
+
);
|
|
401
|
+
};
|
|
402
|
+
const is = (i: number, kind: SyntaxKind): boolean => tokens[i]?.kind === kind;
|
|
403
|
+
/** `<builder>.<kind>(` starting at i, as the kind, or null. */
|
|
404
|
+
const builderCall = (i: number): SchemaKind | null => {
|
|
405
|
+
if (!word(i) || !is(i + 1, SyntaxKind.DotToken) || !word(i + 2) || !is(i + 3, SyntaxKind.OpenParenToken))
|
|
406
|
+
return null;
|
|
407
|
+
const kind = tokens[i + 2].text;
|
|
408
|
+
return (SCHEMA_KINDS as readonly string[]).includes(kind) ? (kind as SchemaKind) : null;
|
|
409
|
+
};
|
|
410
|
+
const bound = new Map<string, SchemaKind>();
|
|
411
|
+
for (let i = 0; i + 3 < tokens.length; i++) {
|
|
412
|
+
if ((word(i, "const") || word(i, "let")) && word(i + 1) && is(i + 2, SyntaxKind.EqualsToken)) {
|
|
413
|
+
const kind = builderCall(i + 3);
|
|
414
|
+
if (kind !== null) bound.set(tokens[i + 1].text, kind);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
let found = false;
|
|
419
|
+
for (let i = 0; i + 4 < tokens.length; i++) {
|
|
420
|
+
if (!(word(i) && is(i + 1, SyntaxKind.DotToken) && word(i + 2, "schema"))) continue;
|
|
421
|
+
if (!(is(i + 3, SyntaxKind.OpenParenToken) && is(i + 4, SyntaxKind.OpenBraceToken))) continue;
|
|
422
|
+
found = true;
|
|
423
|
+
let depth = 0;
|
|
424
|
+
let k = i + 5;
|
|
425
|
+
for (; k < tokens.length; k++) {
|
|
426
|
+
const token = tokens[k];
|
|
427
|
+
if (CLOSERS.has(token.kind)) {
|
|
428
|
+
if (depth === 0) break;
|
|
429
|
+
depth--;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (OPENERS.has(token.kind)) {
|
|
433
|
+
depth++;
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
if (
|
|
437
|
+
word(k) &&
|
|
438
|
+
REFERENCE_CALLS.has(token.text) &&
|
|
439
|
+
is(k - 1, SyntaxKind.DotToken) &&
|
|
440
|
+
is(k + 1, SyntaxKind.OpenParenToken)
|
|
441
|
+
) {
|
|
442
|
+
if (is(k + 2, SyntaxKind.StringLiteral)) {
|
|
443
|
+
references.push({ target: tokens[k + 2].value, via: token.text as SchemaReference["via"], line: token.line });
|
|
444
|
+
}
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
if (depth !== 0) continue;
|
|
448
|
+
const startsMember = is(k - 1, SyntaxKind.OpenBraceToken) || is(k - 1, SyntaxKind.CommaToken);
|
|
449
|
+
if (!startsMember) continue;
|
|
450
|
+
if (is(k, SyntaxKind.DotDotDotToken)) {
|
|
451
|
+
problems.push({
|
|
452
|
+
line: token.line,
|
|
453
|
+
message: "the schema spreads another object in, and its members were not read",
|
|
454
|
+
});
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
const isKey = word(k) || is(k, SyntaxKind.StringLiteral);
|
|
458
|
+
if (!isKey) continue;
|
|
459
|
+
const name = is(k, SyntaxKind.StringLiteral) ? token.value : token.text;
|
|
460
|
+
if (is(k + 1, SyntaxKind.ColonToken)) {
|
|
461
|
+
const direct = builderCall(k + 2);
|
|
462
|
+
const named = word(k + 2) && !is(k + 3, SyntaxKind.DotToken) ? (bound.get(tokens[k + 2].text) ?? null) : null;
|
|
463
|
+
const kind = direct ?? named;
|
|
464
|
+
if (kind === null) {
|
|
465
|
+
problems.push({ line: token.line, message: `the kind of the schema member ${name} could not be read` });
|
|
466
|
+
}
|
|
467
|
+
declarations.push({ name, kind, line: token.line });
|
|
468
|
+
} else if (is(k + 1, SyntaxKind.CommaToken) || is(k + 1, SyntaxKind.CloseBraceToken)) {
|
|
469
|
+
const kind = bound.get(name) ?? null;
|
|
470
|
+
if (kind === null) {
|
|
471
|
+
problems.push({ line: token.line, message: `the kind of the schema member ${name} could not be read` });
|
|
472
|
+
}
|
|
473
|
+
declarations.push({ name, kind, line: token.line });
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
i = k;
|
|
477
|
+
}
|
|
478
|
+
return { found, declarations, references, problems };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/** 1-based lines where `name` is called, as `name(` or `name<T>(`, not as a method and not where it is declared. */
|
|
482
|
+
export function findCalls(path: string, text: string, name: string): number[] {
|
|
483
|
+
const { tokens } = tokenize(path, text);
|
|
484
|
+
const lines: number[] = [];
|
|
485
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
486
|
+
const token = tokens[i];
|
|
487
|
+
if (!tokenIsIdentifierOrKeyword(token.kind) || token.text !== name) continue;
|
|
488
|
+
const before = tokens[i - 1];
|
|
489
|
+
if (before !== undefined && (before.kind === SyntaxKind.DotToken || before.text === "function")) continue;
|
|
490
|
+
const next = tokens[i + 1]?.kind;
|
|
491
|
+
if (next === SyntaxKind.OpenParenToken || next === SyntaxKind.LessThanToken) lines.push(token.line);
|
|
492
|
+
}
|
|
493
|
+
return lines;
|
|
494
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* (spec/profile-typescript-amplify-gen2-0.1.md). `checkArchitecture` is the
|
|
5
5
|
* whole check; the rest is exported for reports, tools and tests.
|
|
6
6
|
*/
|
|
7
|
+
export { AREA_REVIEW_REQUIRED, areaOf, backendAreaOf, checkAreas, isAreaSchema } from "./areas.ts";
|
|
7
8
|
export {
|
|
8
9
|
applyBaseline,
|
|
9
10
|
type BaselineEntry,
|
|
@@ -25,8 +26,10 @@ export {
|
|
|
25
26
|
export { checkClaims } from "./claims.ts";
|
|
26
27
|
export {
|
|
27
28
|
ARCHITECTURE_CONFIG_PATH,
|
|
29
|
+
type AreaConfig,
|
|
28
30
|
type ArchitectureConfig,
|
|
29
31
|
DEFAULT_ARCHITECTURE_CONFIG,
|
|
32
|
+
MAX_AREAS,
|
|
30
33
|
readArchitectureConfig,
|
|
31
34
|
resolveConfig,
|
|
32
35
|
} from "./config.ts";
|
|
@@ -40,7 +43,18 @@ export {
|
|
|
40
43
|
readExceptionRecord,
|
|
41
44
|
readExceptions,
|
|
42
45
|
} from "./exceptions.ts";
|
|
43
|
-
export {
|
|
46
|
+
export {
|
|
47
|
+
type Extraction,
|
|
48
|
+
extractImports,
|
|
49
|
+
extractSchema,
|
|
50
|
+
findCalls,
|
|
51
|
+
type RawImport,
|
|
52
|
+
SCHEMA_KINDS,
|
|
53
|
+
type SchemaDeclaration,
|
|
54
|
+
type SchemaExtraction,
|
|
55
|
+
type SchemaKind,
|
|
56
|
+
type SchemaReference,
|
|
57
|
+
} from "./extract.ts";
|
|
44
58
|
export type { Finding, SubjectEdge } from "./finding.ts";
|
|
45
59
|
export { buildImportGraph, type ImportEdge, type ImportGraph, type UnresolvedImport } from "./imports.ts";
|
|
46
60
|
export { inSeam, isPure, layerAllows, layerOf, purePatterns, seamPatterns } from "./layers.ts";
|
package/src/regions.ts
CHANGED
|
@@ -171,7 +171,9 @@ export function buildModel(
|
|
|
171
171
|
validatePattern(resource.path) === null ? production.filter((file) => matchPattern(resource.path, file)) : [],
|
|
172
172
|
}));
|
|
173
173
|
|
|
174
|
-
|
|
174
|
+
// An area's backend is backend for AMP001, AMP002 and AMP004 as the unified one is (profile §9).
|
|
175
|
+
const backendPatterns = [...config.backendRoots, ...config.areas.flatMap((area) => area.backend)];
|
|
176
|
+
const underBackend = (path: string) => matchAny(backendPatterns, path);
|
|
175
177
|
const regions = new Map<string, Region>();
|
|
176
178
|
const regionOf = (path: string): Region => {
|
|
177
179
|
const cached = regions.get(path);
|