@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/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 { type Extraction, extractImports, type RawImport } from "./extract.ts";
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: `The entrypoint ${entrypoint} of ${slice.id} is not a file in the repository.`,
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
- const underBackend = (path: string) => matchAny(config.backendRoots, path);
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);