@intentset/architecture 0.1.0 → 0.3.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 +18 -1
- package/dist/check.d.ts.map +1 -1
- package/dist/check.js +32 -3
- package/dist/check.js.map +1 -1
- package/dist/claims.d.ts +2 -1
- package/dist/claims.d.ts.map +1 -1
- package/dist/claims.js +7 -2
- package/dist/claims.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 +4 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/ownership.d.ts +3 -1
- package/dist/ownership.d.ts.map +1 -1
- package/dist/ownership.js +5 -1
- package/dist/ownership.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 +51 -3
- package/src/claims.ts +7 -2
- package/src/config.ts +92 -1
- package/src/extract.ts +150 -0
- package/src/index.ts +16 -1
- package/src/ownership.ts +8 -2
- package/src/regions.ts +3 -1
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,
|
|
@@ -19,13 +20,16 @@ export {
|
|
|
19
20
|
type ArchitectureSummary,
|
|
20
21
|
type CheckOptions,
|
|
21
22
|
checkArchitecture,
|
|
23
|
+
ownershipReport,
|
|
22
24
|
REVIEW_REQUIRED,
|
|
23
25
|
} from "./check.ts";
|
|
24
26
|
export { checkClaims } from "./claims.ts";
|
|
25
27
|
export {
|
|
26
28
|
ARCHITECTURE_CONFIG_PATH,
|
|
29
|
+
type AreaConfig,
|
|
27
30
|
type ArchitectureConfig,
|
|
28
31
|
DEFAULT_ARCHITECTURE_CONFIG,
|
|
32
|
+
MAX_AREAS,
|
|
29
33
|
readArchitectureConfig,
|
|
30
34
|
resolveConfig,
|
|
31
35
|
} from "./config.ts";
|
|
@@ -39,7 +43,18 @@ export {
|
|
|
39
43
|
readExceptionRecord,
|
|
40
44
|
readExceptions,
|
|
41
45
|
} from "./exceptions.ts";
|
|
42
|
-
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";
|
|
43
58
|
export type { Finding, SubjectEdge } from "./finding.ts";
|
|
44
59
|
export { buildImportGraph, type ImportEdge, type ImportGraph, type UnresolvedImport } from "./imports.ts";
|
|
45
60
|
export { inSeam, isPure, layerAllows, layerOf, purePatterns, seamPatterns } from "./layers.ts";
|
package/src/ownership.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* inside one of the slice's own source claims, and no two lie in the same
|
|
11
11
|
* package (the nearest directory above holding a package.json, or the
|
|
12
12
|
* repository root): a slice has one public surface per package it spans. An
|
|
13
|
-
* empty entrypoint module is allowed for a leaf.
|
|
13
|
+
* empty entrypoint module is allowed for a leaf. While the slice is draft, an
|
|
14
|
+
* entrypoint that is not a file yet is a warning: planned, not yet owned
|
|
15
|
+
* (VSA §3).
|
|
14
16
|
*
|
|
15
17
|
* VSA012 (deleting or splitting a slice dispositions its behavior, paths and
|
|
16
18
|
* contracts) is a diff between two snapshots. This check sees one snapshot,
|
|
@@ -98,13 +100,17 @@ export function checkOwnership(model: Model, graph: Graph, level: Level): Findin
|
|
|
98
100
|
);
|
|
99
101
|
} else byPackage.set(pkg, entrypoint);
|
|
100
102
|
if (!model.files.has(entrypoint)) {
|
|
103
|
+
const planned = slice.artifact.meta.status === "draft";
|
|
101
104
|
findings.push(
|
|
102
105
|
finding({
|
|
103
106
|
code: "VSA002",
|
|
107
|
+
...(planned ? { severity: "warning" as const } : {}),
|
|
104
108
|
artifact: slice.id,
|
|
105
109
|
path: slice.path,
|
|
106
110
|
field,
|
|
107
|
-
message:
|
|
111
|
+
message: planned
|
|
112
|
+
? `The entrypoint ${entrypoint} of draft slice ${slice.id} is not a file yet; it is planned, and an error once the slice leaves draft.`
|
|
113
|
+
: `The entrypoint ${entrypoint} of ${slice.id} is not a file in the repository.`,
|
|
108
114
|
remediation: "Create the entrypoint, empty if the slice exposes nothing yet, or correct the path (VSA002).",
|
|
109
115
|
paths: [entrypoint],
|
|
110
116
|
}),
|
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);
|