@beignet/cli 0.0.48 → 0.0.49

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 (88) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +76 -21
  3. package/dist/analysis/contract-factories.d.ts +17 -0
  4. package/dist/analysis/contract-factories.d.ts.map +1 -0
  5. package/dist/analysis/contract-factories.js +176 -0
  6. package/dist/analysis/contract-factories.js.map +1 -0
  7. package/dist/analysis/layers.d.ts +5 -0
  8. package/dist/analysis/layers.d.ts.map +1 -0
  9. package/dist/analysis/layers.js +172 -0
  10. package/dist/analysis/layers.js.map +1 -0
  11. package/dist/analysis/port-wiring.d.ts +23 -0
  12. package/dist/analysis/port-wiring.d.ts.map +1 -0
  13. package/dist/analysis/port-wiring.js +379 -0
  14. package/dist/analysis/port-wiring.js.map +1 -0
  15. package/dist/analysis/source-index.d.ts +5 -0
  16. package/dist/analysis/source-index.d.ts.map +1 -1
  17. package/dist/analysis/source-index.js +171 -42
  18. package/dist/analysis/source-index.js.map +1 -1
  19. package/dist/analysis/workspace.d.ts +10 -4
  20. package/dist/analysis/workspace.d.ts.map +1 -1
  21. package/dist/analysis/workspace.js +66 -6
  22. package/dist/analysis/workspace.js.map +1 -1
  23. package/dist/app-map.d.ts.map +1 -1
  24. package/dist/app-map.js +311 -92
  25. package/dist/app-map.js.map +1 -1
  26. package/dist/check.js +37 -3
  27. package/dist/check.js.map +1 -1
  28. package/dist/explain.js +4 -4
  29. package/dist/explain.js.map +1 -1
  30. package/dist/inspect.d.ts.map +1 -1
  31. package/dist/inspect.js +415 -168
  32. package/dist/inspect.js.map +1 -1
  33. package/dist/lint.d.ts.map +1 -1
  34. package/dist/lint.js +309 -487
  35. package/dist/lint.js.map +1 -1
  36. package/dist/make/inbox.d.ts.map +1 -1
  37. package/dist/make/inbox.js +11 -4
  38. package/dist/make/inbox.js.map +1 -1
  39. package/dist/make/payments.js +2 -2
  40. package/dist/make.d.ts.map +1 -1
  41. package/dist/make.js +144 -8
  42. package/dist/make.js.map +1 -1
  43. package/dist/mcp.d.ts +1 -1
  44. package/dist/mcp.d.ts.map +1 -1
  45. package/dist/mcp.js +19 -19
  46. package/dist/mcp.js.map +1 -1
  47. package/dist/provider-add.d.ts.map +1 -1
  48. package/dist/provider-add.js +142 -7
  49. package/dist/provider-add.js.map +1 -1
  50. package/dist/registry-edits.d.ts +7 -0
  51. package/dist/registry-edits.d.ts.map +1 -1
  52. package/dist/registry-edits.js +237 -53
  53. package/dist/registry-edits.js.map +1 -1
  54. package/dist/templates/agents.d.ts.map +1 -1
  55. package/dist/templates/agents.js +16 -0
  56. package/dist/templates/agents.js.map +1 -1
  57. package/dist/templates/base.d.ts.map +1 -1
  58. package/dist/templates/base.js +9 -1
  59. package/dist/templates/base.js.map +1 -1
  60. package/dist/templates/server.d.ts.map +1 -1
  61. package/dist/templates/server.js +2 -0
  62. package/dist/templates/server.js.map +1 -1
  63. package/dist/templates/shared.d.ts +1 -0
  64. package/dist/templates/shared.d.ts.map +1 -1
  65. package/dist/templates/shared.js +3 -2
  66. package/dist/templates/shared.js.map +1 -1
  67. package/package.json +7 -6
  68. package/skills/app-structure/SKILL.md +46 -5
  69. package/src/analysis/contract-factories.ts +256 -0
  70. package/src/analysis/layers.ts +266 -0
  71. package/src/analysis/port-wiring.ts +506 -0
  72. package/src/analysis/source-index.ts +225 -57
  73. package/src/analysis/workspace.ts +115 -6
  74. package/src/app-map.ts +418 -109
  75. package/src/check.ts +51 -3
  76. package/src/explain.ts +4 -4
  77. package/src/inspect.ts +583 -215
  78. package/src/lint.ts +393 -643
  79. package/src/make/inbox.ts +11 -4
  80. package/src/make/payments.ts +2 -2
  81. package/src/make.ts +225 -8
  82. package/src/mcp.ts +19 -22
  83. package/src/provider-add.ts +186 -7
  84. package/src/registry-edits.ts +313 -63
  85. package/src/templates/agents.ts +16 -0
  86. package/src/templates/base.ts +9 -1
  87. package/src/templates/server.ts +2 -0
  88. package/src/templates/shared.ts +3 -2
package/src/lint.ts CHANGED
@@ -1,15 +1,26 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
1
+ import { builtinModules } from "node:module";
2
2
  import path from "node:path";
3
3
  import ts from "typescript";
4
+ import {
5
+ canonicalFeatureLayerPath,
6
+ classifySourcePath as classifyPath,
7
+ type SourceLayer,
8
+ } from "./analysis/layers.js";
9
+ import {
10
+ type ModuleReference,
11
+ moduleReferences,
12
+ } from "./analysis/source-index.js";
13
+ import {
14
+ createAnalysisWorkspace,
15
+ resolveLocalModulePath,
16
+ } from "./analysis/workspace.js";
4
17
  import { createPainter } from "./ansi.js";
5
18
  import {
6
19
  type BeignetConfig,
7
20
  clientDirPath,
8
21
  directoryPath,
9
- loadBeignetConfig,
10
22
  normalizePath,
11
23
  type ResolvedBeignetConfig,
12
- resolveConfig,
13
24
  } from "./config.js";
14
25
  import { formatGithubAnnotation } from "./github-annotations.js";
15
26
 
@@ -18,38 +29,14 @@ type LintAppOptions = {
18
29
  config?: BeignetConfig;
19
30
  };
20
31
 
21
- type SourceLayer =
22
- | "app"
23
- | "app-context"
24
- | "client"
25
- | "component"
26
- | "contract"
27
- | "domain"
28
- | "feature-port"
29
- | "infra"
30
- | "lib"
31
- | "policy"
32
- | "port"
33
- | "route"
34
- | "server"
35
- | "test"
36
- | "use-case"
37
- | "workflow"
38
- | "unknown";
39
-
40
- type ImportReference = {
41
- importPath: string;
42
- kind: ImportKind;
43
- offset: number;
32
+ type ImportReference = Omit<ModuleReference, "file" | "resolvedFile"> & {
44
33
  resolvedPath?: string;
45
34
  packageName?: string;
46
35
  };
47
36
 
48
- type ImportKind = "type" | "value";
49
-
50
37
  type ImportGraph = Map<string, ImportReference[]>;
51
38
 
52
- type RuntimeBoundaryRootKind = "client" | "contract";
39
+ type RuntimeBoundaryRootKind = "client" | "contract" | "schema";
53
40
 
54
41
  /**
55
42
  * Dependency-direction lint diagnostic.
@@ -80,28 +67,33 @@ export type LintAppResult = {
80
67
  export async function lintApp(
81
68
  options: LintAppOptions = {},
82
69
  ): Promise<LintAppResult> {
83
- const targetDir = path.resolve(options.cwd ?? process.cwd());
84
- await assertDirectory(targetDir);
85
-
86
- const files = await listFiles(targetDir);
87
- const config = options.config
88
- ? resolveConfig(options.config)
89
- : await loadBeignetConfig(targetDir, files);
70
+ const workspace = await createAnalysisWorkspace(options.cwd, {
71
+ config: options.config,
72
+ });
73
+ const { config, targetDir } = workspace;
90
74
  const diagnostics: LintDiagnostic[] = [];
91
- const sourceFiles = lintSourceFiles(files);
92
- const sourceFileSet = new Set(sourceFiles);
93
- const atAliasRoot = resolveAtAliasRoot(targetDir);
94
- const sourceByFile = new Map<string, string>();
75
+ const sourceFiles = lintSourceFiles(workspace.files);
76
+ const sourceFileByPath = new Map<string, ts.SourceFile>();
95
77
  const importGraph: ImportGraph = new Map();
96
78
 
97
79
  for (const file of sourceFiles) {
98
80
  const sourceLayer = classifyPath(file, config);
99
- const source = await readFile(path.join(targetDir, file), "utf8");
100
- sourceByFile.set(file, source);
101
- importGraph.set(
102
- file,
103
- parseImports(source, file, sourceFileSet, atAliasRoot),
104
- );
81
+ const sourceFile = await workspace.readSourceFile(file);
82
+ sourceFileByPath.set(file, sourceFile);
83
+ importGraph.set(file, await parseImports(workspace, file));
84
+
85
+ const canonicalLayerPath = canonicalFeatureLayerPath(file, config);
86
+ if (canonicalLayerPath) {
87
+ diagnostics.push({
88
+ severity: "error",
89
+ code: "BEIGNET_CANONICAL_STRUCTURE",
90
+ file,
91
+ importPath: canonicalLayerPath,
92
+ line: 1,
93
+ column: 1,
94
+ message: `feature files that belong to a canonical layer must use ${canonicalLayerPath}.`,
95
+ });
96
+ }
105
97
 
106
98
  if (sourceLayer === "test" || sourceLayer === "unknown") continue;
107
99
 
@@ -118,23 +110,34 @@ export async function lintApp(
118
110
  });
119
111
  }
120
112
 
121
- const lineStarts = computeLineStarts(source);
122
113
  for (const reference of importGraph.get(file) ?? []) {
123
- const diagnostic = lintImport(file, sourceLayer, reference, config);
114
+ const diagnostic =
115
+ lintUnresolvedModuleReference(file, sourceLayer, reference) ??
116
+ (sourceLayer === "schema"
117
+ ? undefined
118
+ : lintImport(file, sourceLayer, reference, config));
124
119
  if (diagnostic) {
125
120
  diagnostics.push({
126
121
  ...diagnostic,
127
- ...positionFromOffset(lineStarts, reference.offset),
122
+ line: reference.line,
123
+ column: reference.column,
128
124
  });
129
125
  }
130
126
  }
131
127
  }
132
128
 
129
+ diagnostics.push(
130
+ ...lintTransitiveBoundaries({
131
+ config,
132
+ graph: importGraph,
133
+ }),
134
+ );
135
+
133
136
  diagnostics.push(
134
137
  ...lintRuntimeBoundary({
135
138
  config,
136
139
  graph: importGraph,
137
- sourceByFile,
140
+ sourceFileByPath,
138
141
  }),
139
142
  );
140
143
 
@@ -146,37 +149,6 @@ export async function lintApp(
146
149
  };
147
150
  }
148
151
 
149
- function computeLineStarts(source: string): number[] {
150
- const lineStarts = [0];
151
-
152
- for (let index = 0; index < source.length; index++) {
153
- if (source[index] === "\n") {
154
- lineStarts.push(index + 1);
155
- }
156
- }
157
-
158
- return lineStarts;
159
- }
160
-
161
- function positionFromOffset(
162
- lineStarts: number[],
163
- offset: number,
164
- ): { line: number; column: number } {
165
- let low = 0;
166
- let high = lineStarts.length - 1;
167
-
168
- while (low < high) {
169
- const middle = Math.ceil((low + high) / 2);
170
- if (lineStarts[middle] <= offset) {
171
- low = middle;
172
- } else {
173
- high = middle - 1;
174
- }
175
- }
176
-
177
- return { line: low + 1, column: offset - lineStarts[low] + 1 };
178
- }
179
-
180
152
  /**
181
153
  * Format dependency-direction lint diagnostics for CLI output.
182
154
  */
@@ -229,449 +201,32 @@ export function formatLintGithub(result: LintAppResult): string {
229
201
  .join("\n");
230
202
  }
231
203
 
232
- async function assertDirectory(targetDir: string): Promise<void> {
233
- try {
234
- const stats = await stat(targetDir);
235
- if (!stats.isDirectory()) {
236
- throw new Error(`${targetDir} is not a directory.`);
237
- }
238
- } catch (error) {
239
- if (error instanceof Error && error.message.includes("not a directory")) {
240
- throw error;
241
- }
242
- throw new Error(`Directory ${targetDir} does not exist.`);
243
- }
244
- }
245
-
246
- async function listFiles(targetDir: string): Promise<string[]> {
247
- const files: string[] = [];
248
-
249
- async function visit(relativeDir: string): Promise<void> {
250
- const absoluteDir = path.join(targetDir, relativeDir);
251
- const entries = await readdir(absoluteDir, { withFileTypes: true });
252
-
253
- for (const entry of entries) {
254
- if (ignoredDirectoryNames.has(entry.name)) continue;
255
-
256
- const relativePath = normalizePath(path.join(relativeDir, entry.name));
257
- if (entry.isDirectory()) {
258
- await visit(relativePath);
259
- } else if (entry.isFile()) {
260
- files.push(relativePath);
261
- }
262
- }
263
- }
264
-
265
- await visit("");
266
- return files.sort();
267
- }
268
-
269
- const ignoredDirectoryNames = new Set([
270
- ".git",
271
- ".next",
272
- ".turbo",
273
- "coverage",
274
- "dist",
275
- "node_modules",
276
- ]);
277
-
278
204
  function lintSourceFiles(files: string[]): string[] {
279
205
  return files.filter((file) => {
280
- if (file.endsWith(".d.ts")) return false;
206
+ if (/\.d\.(c|m)?ts$/.test(file)) return false;
281
207
  return /\.(c|m)?tsx?$/.test(file);
282
208
  });
283
209
  }
284
210
 
285
- function parseImports(
286
- source: string,
287
- importerFile: string,
288
- files: Set<string>,
289
- atAliasRoot: string,
290
- ): ImportReference[] {
291
- const references = parseImportSpecifiers(source);
292
-
293
- return references.map((reference) => ({
294
- ...reference,
295
- ...resolveImport(reference.importPath, importerFile, files, atAliasRoot),
296
- }));
297
- }
298
-
299
- function resolveAtAliasRoot(targetDir: string): string {
300
- const tsconfigPath = path.join(targetDir, "tsconfig.json");
301
- const parsed = ts.getParsedCommandLineOfConfigFile(
302
- tsconfigPath,
303
- {},
304
- {
305
- ...ts.sys,
306
- onUnRecoverableConfigFileDiagnostic: () => {},
307
- },
308
- );
309
- const aliasTarget = parsed?.options.paths?.["@/*"]?.[0];
310
- if (!aliasTarget) return "";
311
-
312
- const targetWithoutWildcard = aliasTarget.replace(/[/\\]?\*$/, "");
313
- const baseUrl = parsed.options.baseUrl ?? targetDir;
314
- const absoluteTarget = path.resolve(baseUrl, targetWithoutWildcard);
315
- const relativeTarget = normalizePath(
316
- path.relative(targetDir, absoluteTarget),
317
- );
318
- if (relativeTarget === "" || relativeTarget === ".") return "";
319
- if (relativeTarget === ".." || relativeTarget.startsWith("../")) return "";
320
- return directoryPath(relativeTarget);
321
- }
322
-
323
- function parseImportSpecifiers(
324
- source: string,
325
- ): Array<{ importPath: string; kind: ImportKind; offset: number }> {
326
- const specifiers: Array<{
327
- importPath: string;
328
- kind: ImportKind;
329
- offset: number;
330
- }> = [];
331
- let index = 0;
332
-
333
- while (index < source.length) {
334
- index = skipNonCode(source, index);
335
-
336
- if (isKeywordAt(source, index, "import")) {
337
- const parsed = parseImportSpecifierAt(source, index + "import".length);
338
- if (parsed?.specifier) {
339
- specifiers.push({
340
- importPath: parsed.specifier,
341
- kind: parsed.kind ?? "value",
342
- offset: index,
343
- });
344
- }
345
- index = parsed?.end ?? index + "import".length;
346
- continue;
347
- }
348
-
349
- if (isKeywordAt(source, index, "export")) {
350
- const parsed = parseExportSpecifierAt(source, index + "export".length);
351
- if (parsed?.specifier) {
352
- specifiers.push({
353
- importPath: parsed.specifier,
354
- kind: parsed.kind ?? "value",
355
- offset: index,
356
- });
357
- }
358
- index = parsed?.end ?? index + "export".length;
359
- continue;
360
- }
361
-
362
- index++;
363
- }
364
-
365
- return specifiers;
366
- }
367
-
368
- function parseImportSpecifierAt(
369
- source: string,
370
- index: number,
371
- ): { specifier?: string; kind?: ImportKind; end: number } | undefined {
372
- let cursor = skipWhitespaceAndComments(source, index);
373
- if (source[cursor] === "(") {
374
- cursor = skipWhitespaceAndComments(source, cursor + 1);
375
- const stringLiteral = readStringLiteral(source, cursor);
376
- if (!stringLiteral) return { end: cursor };
377
- return {
378
- specifier: stringLiteral.value,
379
- end: stringLiteral.end,
380
- };
381
- }
382
-
383
- const sideEffectImport = readStringLiteral(source, cursor);
384
- if (sideEffectImport) {
385
- return {
386
- specifier: sideEffectImport.value,
387
- kind: "value",
388
- end: sideEffectImport.end,
389
- };
390
- }
391
-
392
- const importClauseStart = cursor;
393
- let isImportType = false;
394
- if (isKeywordAt(source, cursor, "type")) {
395
- isImportType = true;
396
- cursor = cursorAfterKeyword("type", cursor);
397
- }
398
-
399
- while (cursor < source.length) {
400
- cursor = skipWhitespaceAndComments(source, cursor);
401
-
402
- if (source[cursor] === ";" || source[cursor] === "\n") {
403
- return { end: cursor + 1 };
404
- }
405
-
406
- if (isKeywordAt(source, cursor, "from")) {
407
- const fromCursor = skipWhitespaceAndComments(
408
- source,
409
- cursorAfterKeyword("from", cursor),
410
- );
411
- const stringLiteral = readStringLiteral(source, fromCursor);
412
- if (!stringLiteral) return { end: fromCursor };
413
- return {
414
- specifier: stringLiteral.value,
415
- kind: isImportType
416
- ? "type"
417
- : inferImportClauseKind(source.slice(importClauseStart, cursor)),
418
- end: stringLiteral.end,
419
- };
420
- }
421
-
422
- cursor = skipToken(source, cursor);
423
- }
424
-
425
- return undefined;
426
- }
427
-
428
- function parseExportSpecifierAt(
429
- source: string,
430
- index: number,
431
- ): { specifier?: string; kind?: ImportKind; end: number } | undefined {
432
- let cursor = index;
433
- let isExportType = false;
434
- const exportClauseStart = cursor;
435
-
436
- while (cursor < source.length) {
437
- cursor = skipWhitespaceAndComments(source, cursor);
438
-
439
- if (isKeywordAt(source, cursor, "type")) {
440
- isExportType = true;
441
- cursor = cursorAfterKeyword("type", cursor);
442
- continue;
443
- }
444
-
445
- if (source[cursor] === ";" || source[cursor] === "\n") {
446
- return { end: cursor + 1 };
447
- }
448
-
449
- if (isKeywordAt(source, cursor, "from")) {
450
- const fromCursor = skipWhitespaceAndComments(
451
- source,
452
- cursorAfterKeyword("from", cursor),
453
- );
454
- const stringLiteral = readStringLiteral(source, fromCursor);
455
- if (!stringLiteral) return { end: fromCursor };
211
+ async function parseImports(
212
+ workspace: Awaited<ReturnType<typeof createAnalysisWorkspace>>,
213
+ file: string,
214
+ ): Promise<ImportReference[]> {
215
+ return (await moduleReferences(workspace, file)).map(
216
+ ({ file: _file, resolvedFile, ...reference }) => {
217
+ const resolvedPath = reference.staticallyResolvable
218
+ ? (resolvedFile ??
219
+ resolveLocalModulePath(workspace, file, reference.importPath))
220
+ : undefined;
456
221
  return {
457
- specifier: stringLiteral.value,
458
- kind: isExportType
459
- ? "type"
460
- : inferImportClauseKind(source.slice(exportClauseStart, cursor)),
461
- end: stringLiteral.end,
222
+ ...reference,
223
+ ...(resolvedPath ? { resolvedPath } : {}),
224
+ ...(!resolvedPath && reference.staticallyResolvable
225
+ ? { packageName: packageName(reference.importPath) }
226
+ : {}),
462
227
  };
463
- }
464
-
465
- cursor = skipToken(source, cursor);
466
- }
467
-
468
- return undefined;
469
- }
470
-
471
- function inferImportClauseKind(importClause: string): ImportKind {
472
- const trimmed = importClause.trim();
473
- if (/^\{\s*type\s+[^,}]+(?:,\s*type\s+[^,}]+)*,?\s*\}$/.test(trimmed)) {
474
- return "type";
475
- }
476
- return "value";
477
- }
478
-
479
- function cursorAfterKeyword(keyword: string, index: number): number {
480
- return index + keyword.length;
481
- }
482
-
483
- function skipNonCode(source: string, index: number): number {
484
- let cursor = index;
485
-
486
- while (cursor < source.length) {
487
- if (source.startsWith("//", cursor)) {
488
- cursor = skipLineComment(source, cursor);
489
- continue;
490
- }
491
- if (source.startsWith("/*", cursor)) {
492
- cursor = skipBlockComment(source, cursor);
493
- continue;
494
- }
495
- if (source[cursor] === '"' || source[cursor] === "'") {
496
- cursor = skipStringLiteral(source, cursor);
497
- continue;
498
- }
499
- if (source[cursor] === "`") {
500
- cursor = skipTemplateLiteral(source, cursor);
501
- continue;
502
- }
503
- break;
504
- }
505
-
506
- return cursor;
507
- }
508
-
509
- function skipWhitespaceAndComments(source: string, index: number): number {
510
- let cursor = index;
511
-
512
- while (cursor < source.length) {
513
- if (/\s/.test(source[cursor])) {
514
- cursor++;
515
- continue;
516
- }
517
- if (source.startsWith("//", cursor)) {
518
- cursor = skipLineComment(source, cursor);
519
- continue;
520
- }
521
- if (source.startsWith("/*", cursor)) {
522
- cursor = skipBlockComment(source, cursor);
523
- continue;
524
- }
525
- break;
526
- }
527
-
528
- return cursor;
529
- }
530
-
531
- function skipToken(source: string, index: number): number {
532
- if (source[index] === '"' || source[index] === "'") {
533
- return skipStringLiteral(source, index);
534
- }
535
- if (source[index] === "`") {
536
- return skipTemplateLiteral(source, index);
537
- }
538
-
539
- return index + 1;
540
- }
541
-
542
- function readStringLiteral(
543
- source: string,
544
- index: number,
545
- ): { value: string; end: number } | undefined {
546
- const quote = source[index];
547
- if (quote !== '"' && quote !== "'") return undefined;
548
-
549
- let value = "";
550
- let escaped = false;
551
-
552
- for (let cursor = index + 1; cursor < source.length; cursor++) {
553
- const char = source[cursor];
554
- if (escaped) {
555
- value += char;
556
- escaped = false;
557
- continue;
558
- }
559
- if (char === "\\") {
560
- escaped = true;
561
- continue;
562
- }
563
- if (char === quote) {
564
- return { value, end: cursor + 1 };
565
- }
566
- value += char;
567
- }
568
-
569
- return undefined;
570
- }
571
-
572
- function skipStringLiteral(source: string, index: number): number {
573
- return readStringLiteral(source, index)?.end ?? source.length;
574
- }
575
-
576
- function skipTemplateLiteral(source: string, index: number): number {
577
- let escaped = false;
578
-
579
- for (let cursor = index + 1; cursor < source.length; cursor++) {
580
- const char = source[cursor];
581
- if (escaped) {
582
- escaped = false;
583
- continue;
584
- }
585
- if (char === "\\") {
586
- escaped = true;
587
- continue;
588
- }
589
- if (char === "`") {
590
- return cursor + 1;
591
- }
592
- }
593
-
594
- return source.length;
595
- }
596
-
597
- function skipLineComment(source: string, index: number): number {
598
- const end = source.indexOf("\n", index + 2);
599
- return end === -1 ? source.length : end + 1;
600
- }
601
-
602
- function skipBlockComment(source: string, index: number): number {
603
- const end = source.indexOf("*/", index + 2);
604
- return end === -1 ? source.length : end + 2;
605
- }
606
-
607
- function isKeywordAt(source: string, index: number, keyword: string): boolean {
608
- if (!source.startsWith(keyword, index)) return false;
609
-
610
- const before = source[index - 1];
611
- const after = source[index + keyword.length];
612
- return !isIdentifierChar(before) && !isIdentifierChar(after);
613
- }
614
-
615
- function isIdentifierChar(char: string | undefined): boolean {
616
- return Boolean(char && /[A-Za-z0-9_$]/.test(char));
617
- }
618
-
619
- function resolveImport(
620
- importPath: string,
621
- importerFile: string,
622
- files: Set<string>,
623
- atAliasRoot: string,
624
- ): Pick<ImportReference, "packageName" | "resolvedPath"> {
625
- if (importPath.startsWith("@/")) {
626
- return {
627
- resolvedPath: resolveLocalImportPath(
628
- stripKnownExtension(path.posix.join(atAliasRoot, importPath.slice(2))),
629
- files,
630
- ),
631
- };
632
- }
633
-
634
- if (importPath.startsWith(".")) {
635
- return {
636
- resolvedPath: resolveLocalImportPath(
637
- stripKnownExtension(
638
- normalizePath(path.join(path.dirname(importerFile), importPath)),
639
- ),
640
- files,
641
- ),
642
- };
643
- }
644
-
645
- return {
646
- packageName: packageName(importPath),
647
- };
648
- }
649
-
650
- function resolveLocalImportPath(
651
- importPath: string,
652
- files: Set<string>,
653
- ): string | undefined {
654
- const candidates = [
655
- importPath,
656
- `${importPath}.ts`,
657
- `${importPath}.tsx`,
658
- `${importPath}.mts`,
659
- `${importPath}.cts`,
660
- `${importPath}/index.ts`,
661
- `${importPath}/index.tsx`,
662
- `${importPath}/index.mts`,
663
- `${importPath}/index.cts`,
664
- ];
665
-
666
- for (const candidate of candidates) {
667
- for (const file of files) {
668
- if (stripKnownExtension(file) === stripKnownExtension(candidate)) {
669
- return file;
670
- }
671
- }
672
- }
673
-
674
- return stripKnownExtension(importPath);
228
+ },
229
+ );
675
230
  }
676
231
 
677
232
  function packageName(importPath: string): string {
@@ -740,6 +295,43 @@ function lintImport(
740
295
  return undefined;
741
296
  }
742
297
 
298
+ function lintUnresolvedModuleReference(
299
+ file: string,
300
+ sourceLayer: SourceLayer,
301
+ reference: ImportReference,
302
+ ): LintDiagnostic | undefined {
303
+ if (
304
+ reference.staticallyResolvable ||
305
+ ![
306
+ "agent-capability",
307
+ "domain",
308
+ "feature-helper",
309
+ "feature-port",
310
+ "infra",
311
+ "policy",
312
+ "port",
313
+ "route",
314
+ "seed",
315
+ "use-case",
316
+ "workflow",
317
+ ].includes(sourceLayer)
318
+ ) {
319
+ return undefined;
320
+ }
321
+
322
+ return {
323
+ severity: "error",
324
+ code: "BEIGNET_MODULE_REFERENCE_UNRESOLVED",
325
+ file,
326
+ importPath: reference.importPath,
327
+ message: `${layerLabel(sourceLayer)} files must use a string literal for ${moduleSyntaxLabel(reference.syntax)} so Beignet can verify the dependency boundary (${reference.importPath}).`,
328
+ };
329
+ }
330
+
331
+ function moduleSyntaxLabel(syntax: ModuleReference["syntax"]): string {
332
+ return syntax === "require" ? "require()" : "dynamic import()";
333
+ }
334
+
743
335
  function appImportViolation(
744
336
  sourceLayer: SourceLayer,
745
337
  targetLayer: SourceLayer | undefined,
@@ -751,15 +343,19 @@ function appImportViolation(
751
343
  [
752
344
  "app",
753
345
  "app-context",
346
+ "agent-capability",
754
347
  "client",
755
348
  "component",
756
349
  "contract",
350
+ "feature-helper",
757
351
  "feature-port",
758
352
  "infra",
759
353
  "lib",
760
354
  "policy",
761
355
  "port",
762
356
  "route",
357
+ "schema",
358
+ "seed",
763
359
  "server",
764
360
  "use-case",
765
361
  "workflow",
@@ -769,10 +365,73 @@ function appImportViolation(
769
365
  }
770
366
 
771
367
  if (
772
- (sourceLayer === "use-case" || sourceLayer === "workflow") &&
773
- ["app", "client", "component", "infra", "route", "server"].includes(
774
- targetLayer,
775
- )
368
+ sourceLayer === "agent-capability" &&
369
+ [
370
+ "agent-capability",
371
+ "app",
372
+ "app-context",
373
+ "client",
374
+ "component",
375
+ "infra",
376
+ "route",
377
+ "seed",
378
+ "server",
379
+ "workflow",
380
+ ].includes(targetLayer)
381
+ ) {
382
+ return targetLayer;
383
+ }
384
+
385
+ if (
386
+ sourceLayer === "feature-helper" &&
387
+ [
388
+ "agent-capability",
389
+ "app",
390
+ "app-context",
391
+ "client",
392
+ "component",
393
+ "infra",
394
+ "route",
395
+ "seed",
396
+ "server",
397
+ "use-case",
398
+ "workflow",
399
+ ].includes(targetLayer)
400
+ ) {
401
+ return targetLayer;
402
+ }
403
+
404
+ if (
405
+ ["use-case", "workflow"].includes(sourceLayer) &&
406
+ [
407
+ "agent-capability",
408
+ "app",
409
+ "app-context",
410
+ "client",
411
+ "component",
412
+ "infra",
413
+ "route",
414
+ "seed",
415
+ "server",
416
+ ].includes(targetLayer)
417
+ ) {
418
+ return targetLayer;
419
+ }
420
+
421
+ if (
422
+ sourceLayer === "seed" &&
423
+ [
424
+ "agent-capability",
425
+ "app",
426
+ "app-context",
427
+ "client",
428
+ "component",
429
+ "infra",
430
+ "route",
431
+ "server",
432
+ "use-case",
433
+ "workflow",
434
+ ].includes(targetLayer)
776
435
  ) {
777
436
  return targetLayer;
778
437
  }
@@ -781,6 +440,7 @@ function appImportViolation(
781
440
  ["feature-port", "policy", "port"].includes(sourceLayer) &&
782
441
  [
783
442
  "app",
443
+ "agent-capability",
784
444
  "client",
785
445
  "component",
786
446
  "infra",
@@ -794,16 +454,52 @@ function appImportViolation(
794
454
 
795
455
  if (
796
456
  sourceLayer === "contract" &&
797
- ["app", "client", "component", "infra", "route", "workflow"].includes(
798
- targetLayer,
799
- )
457
+ [
458
+ "agent-capability",
459
+ "app",
460
+ "client",
461
+ "component",
462
+ "infra",
463
+ "route",
464
+ "seed",
465
+ "workflow",
466
+ ].includes(targetLayer)
800
467
  ) {
801
468
  return targetLayer;
802
469
  }
803
470
 
804
471
  if (
805
472
  sourceLayer === "route" &&
806
- ["app", "client", "component", "infra", "workflow"].includes(targetLayer)
473
+ [
474
+ "agent-capability",
475
+ "app",
476
+ "app-context",
477
+ "client",
478
+ "component",
479
+ "infra",
480
+ "seed",
481
+ "server",
482
+ "workflow",
483
+ ].includes(targetLayer)
484
+ ) {
485
+ return targetLayer;
486
+ }
487
+
488
+ if (
489
+ sourceLayer === "schema" &&
490
+ [
491
+ "agent-capability",
492
+ "app",
493
+ "app-context",
494
+ "client",
495
+ "component",
496
+ "infra",
497
+ "route",
498
+ "seed",
499
+ "server",
500
+ "use-case",
501
+ "workflow",
502
+ ].includes(targetLayer)
807
503
  ) {
808
504
  return targetLayer;
809
505
  }
@@ -832,9 +528,13 @@ function packageImportViolation(
832
528
  if (
833
529
  [
834
530
  "contract",
531
+ "agent-capability",
532
+ "feature-helper",
835
533
  "feature-port",
836
534
  "policy",
837
535
  "port",
536
+ "schema",
537
+ "seed",
838
538
  "use-case",
839
539
  "workflow",
840
540
  ].includes(sourceLayer)
@@ -878,21 +578,121 @@ function matchBeignetProviderPackage(importPath: string): string | undefined {
878
578
  return name.startsWith("@beignet/provider-") ? name : undefined;
879
579
  }
880
580
 
581
+ function lintTransitiveBoundaries({
582
+ config,
583
+ graph,
584
+ }: {
585
+ config: ResolvedBeignetConfig;
586
+ graph: ImportGraph;
587
+ }): LintDiagnostic[] {
588
+ const diagnostics: LintDiagnostic[] = [];
589
+
590
+ for (const root of graph.keys()) {
591
+ const rootLayer = classifyPath(root, config);
592
+ if (
593
+ ![
594
+ "agent-capability",
595
+ "domain",
596
+ "feature-helper",
597
+ "feature-port",
598
+ "infra",
599
+ "policy",
600
+ "port",
601
+ "route",
602
+ "seed",
603
+ "use-case",
604
+ "workflow",
605
+ ].includes(rootLayer)
606
+ ) {
607
+ continue;
608
+ }
609
+
610
+ diagnostics.push(
611
+ ...lintTransitiveBoundaryRoot({ config, graph, root, rootLayer }),
612
+ );
613
+ }
614
+
615
+ return diagnostics;
616
+ }
617
+
618
+ function lintTransitiveBoundaryRoot({
619
+ config,
620
+ graph,
621
+ root,
622
+ rootLayer,
623
+ }: {
624
+ config: ResolvedBeignetConfig;
625
+ graph: ImportGraph;
626
+ root: string;
627
+ rootLayer: SourceLayer;
628
+ }): LintDiagnostic[] {
629
+ const diagnostics: LintDiagnostic[] = [];
630
+ const visited = new Set<string>();
631
+ const queue: Array<{ file: string; chain: string[] }> = [
632
+ { file: root, chain: [root] },
633
+ ];
634
+ let queueIndex = 0;
635
+
636
+ while (queueIndex < queue.length) {
637
+ const current = queue[queueIndex++];
638
+ if (visited.has(current.file)) continue;
639
+ visited.add(current.file);
640
+
641
+ for (const reference of graph.get(current.file) ?? []) {
642
+ if (reference.kind === "type") continue;
643
+
644
+ const chain = reference.resolvedPath
645
+ ? [...current.chain, reference.resolvedPath]
646
+ : current.chain;
647
+ const violation =
648
+ current.file === root
649
+ ? undefined
650
+ : (lintUnresolvedModuleReference(root, rootLayer, reference) ??
651
+ lintImport(root, rootLayer, reference, config));
652
+
653
+ if (violation) {
654
+ diagnostics.push({
655
+ ...violation,
656
+ file: root,
657
+ message: `${layerLabel(rootLayer)} files must not reach forbidden dependencies through ${formatImportChain(chain)}. ${violation.message}`,
658
+ });
659
+ continue;
660
+ }
661
+
662
+ if (
663
+ reference.resolvedPath &&
664
+ graph.has(reference.resolvedPath) &&
665
+ isTransparentArchitectureLayer(
666
+ classifyPath(reference.resolvedPath, config),
667
+ )
668
+ ) {
669
+ queue.push({ file: reference.resolvedPath, chain });
670
+ }
671
+ }
672
+ }
673
+
674
+ return diagnostics;
675
+ }
676
+
677
+ function isTransparentArchitectureLayer(layer: SourceLayer): boolean {
678
+ return ["feature-helper", "lib", "unknown"].includes(layer);
679
+ }
680
+
881
681
  function lintRuntimeBoundary({
882
682
  config,
883
683
  graph,
884
- sourceByFile,
684
+ sourceFileByPath,
885
685
  }: {
886
686
  config: ResolvedBeignetConfig;
887
687
  graph: ImportGraph;
888
- sourceByFile: Map<string, string>;
688
+ sourceFileByPath: Map<string, ts.SourceFile>;
889
689
  }): LintDiagnostic[] {
890
690
  const diagnostics: LintDiagnostic[] = [];
891
691
 
892
692
  for (const file of graph.keys()) {
893
693
  const rootKind = runtimeBoundaryRootKind(
894
694
  file,
895
- sourceByFile.get(file) ?? "",
695
+ sourceFileByPath.get(file),
896
696
  config,
897
697
  );
898
698
  if (!rootKind) continue;
@@ -926,10 +726,10 @@ function lintRuntimeBoundaryRoot({
926
726
  const queue: Array<{ file: string; chain: string[] }> = [
927
727
  { file: root, chain: [root] },
928
728
  ];
729
+ let queueIndex = 0;
929
730
 
930
- while (queue.length > 0) {
931
- const current = queue.shift();
932
- if (!current) continue;
731
+ while (queueIndex < queue.length) {
732
+ const current = queue[queueIndex++];
933
733
  if (visited.has(current.file)) continue;
934
734
  visited.add(current.file);
935
735
 
@@ -944,7 +744,7 @@ function lintRuntimeBoundaryRoot({
944
744
  const chain = reference.resolvedPath
945
745
  ? [...current.chain, reference.resolvedPath]
946
746
  : current.chain;
947
- const violation = runtimeBoundaryViolation(reference, config);
747
+ const violation = runtimeBoundaryViolation(reference, config, rootKind);
948
748
 
949
749
  if (violation) {
950
750
  diagnostics.push({
@@ -971,25 +771,29 @@ function lintRuntimeBoundaryRoot({
971
771
 
972
772
  function runtimeBoundaryRootKind(
973
773
  file: string,
974
- source: string,
774
+ sourceFile: ts.SourceFile | undefined,
975
775
  config: ResolvedBeignetConfig,
976
776
  ): RuntimeBoundaryRootKind | undefined {
977
777
  const layer = classifyPath(file, config);
978
778
  if (layer === "contract") return "contract";
779
+ if (layer === "schema") return "schema";
979
780
  if (layer === "client") return "client";
980
- if (hasUseClientDirective(source)) return "client";
781
+ if (sourceFile && hasUseClientDirective(sourceFile)) return "client";
981
782
  if (isGeneratedClientAdapter(file, config)) return "client";
982
783
  return undefined;
983
784
  }
984
785
 
985
- function hasUseClientDirective(source: string): boolean {
986
- const trimmed = source.trimStart();
987
- return (
988
- trimmed.startsWith('"use client";') ||
989
- trimmed.startsWith('"use client"') ||
990
- trimmed.startsWith("'use client';") ||
991
- trimmed.startsWith("'use client'")
992
- );
786
+ function hasUseClientDirective(sourceFile: ts.SourceFile): boolean {
787
+ for (const statement of sourceFile.statements) {
788
+ if (
789
+ !ts.isExpressionStatement(statement) ||
790
+ !ts.isStringLiteral(statement.expression)
791
+ ) {
792
+ return false;
793
+ }
794
+ if (statement.expression.text === "use client") return true;
795
+ }
796
+ return false;
993
797
  }
994
798
 
995
799
  function isGeneratedClientAdapter(
@@ -1005,14 +809,29 @@ function isGeneratedClientAdapter(
1005
809
  function runtimeBoundaryViolation(
1006
810
  reference: ImportReference,
1007
811
  config: ResolvedBeignetConfig,
812
+ rootKind: RuntimeBoundaryRootKind,
1008
813
  ): string | undefined {
814
+ if (!reference.staticallyResolvable) {
815
+ return `computed ${moduleSyntaxLabel(reference.syntax)} ${reference.importPath}, which cannot be verified statically`;
816
+ }
817
+
1009
818
  if (reference.resolvedPath) {
1010
819
  const targetLayer = classifyPath(reference.resolvedPath, config);
820
+ const prohibitedLayer =
821
+ rootKind === "client"
822
+ ? undefined
823
+ : appImportViolation(rootKind, targetLayer);
824
+ if (prohibitedLayer) {
825
+ return `${layerLabel(prohibitedLayer)} module ${reference.resolvedPath}`;
826
+ }
1011
827
  if (
828
+ targetLayer === "agent-capability" ||
1012
829
  targetLayer === "use-case" ||
1013
830
  targetLayer === "route" ||
1014
831
  targetLayer === "infra" ||
1015
- targetLayer === "server"
832
+ targetLayer === "seed" ||
833
+ targetLayer === "server" ||
834
+ targetLayer === "workflow"
1016
835
  ) {
1017
836
  return `${layerLabel(targetLayer)} module ${reference.resolvedPath}`;
1018
837
  }
@@ -1025,12 +844,22 @@ function runtimeBoundaryViolation(
1025
844
  return "server-only marker @beignet/core/server-only";
1026
845
  }
1027
846
 
1028
- if (reference.importPath.startsWith("node:")) {
847
+ if (isNodeRuntimeModule(reference.importPath)) {
1029
848
  return `Node runtime module ${reference.importPath}`;
1030
849
  }
1031
850
 
851
+ if (rootKind !== "client" && reference.packageName) {
852
+ const prohibitedPackage = packageImportViolation(
853
+ rootKind,
854
+ reference.importPath,
855
+ );
856
+ if (prohibitedPackage) {
857
+ return `runtime or provider package ${prohibitedPackage}`;
858
+ }
859
+ }
860
+
1032
861
  if (
1033
- serverRuntimePackages.has(reference.importPath) ||
862
+ matchBannedPackage(serverRuntimePackages, reference.importPath) ||
1034
863
  reference.importPath.startsWith("@beignet/provider-")
1035
864
  ) {
1036
865
  return `server runtime package ${reference.importPath}`;
@@ -1039,6 +868,12 @@ function runtimeBoundaryViolation(
1039
868
  return undefined;
1040
869
  }
1041
870
 
871
+ const nodeRuntimeModules = new Set(builtinModules);
872
+
873
+ function isNodeRuntimeModule(importPath: string): boolean {
874
+ return importPath.startsWith("node:") || nodeRuntimeModules.has(importPath);
875
+ }
876
+
1042
877
  function runtimeBoundaryMessage(
1043
878
  rootKind: RuntimeBoundaryRootKind,
1044
879
  violation: string,
@@ -1052,7 +887,9 @@ function runtimeBoundaryMessage(
1052
887
  }
1053
888
 
1054
889
  function rootKindLabel(rootKind: RuntimeBoundaryRootKind): string {
1055
- return rootKind === "contract" ? "contract" : "client";
890
+ if (rootKind === "contract") return "contract";
891
+ if (rootKind === "schema") return "schema";
892
+ return "client";
1056
893
  }
1057
894
 
1058
895
  function formatImportChain(chain: string[]): string {
@@ -1177,98 +1014,6 @@ function packageImportMessage(
1177
1014
  )} files must not import runtime or provider package ${packageName}. Depend on Beignet ports or app-owned ports instead.`;
1178
1015
  }
1179
1016
 
1180
- function classifyPath(
1181
- filePath: string,
1182
- config: ResolvedBeignetConfig,
1183
- ): SourceLayer {
1184
- const normalizedPath = stripKnownExtension(normalizePath(filePath));
1185
- const featuresPath = directoryPath(config.paths.features);
1186
- const portsDir = directoryPath(path.dirname(config.paths.ports));
1187
-
1188
- if (isTestPath(normalizedPath)) return "test";
1189
- if (normalizedPath === stripKnownExtension(config.paths.appContext)) {
1190
- return "app-context";
1191
- }
1192
- if (
1193
- isUnder(normalizedPath, directoryPath(path.dirname(config.paths.server)))
1194
- ) {
1195
- return "server";
1196
- }
1197
- if (
1198
- isUnder(normalizedPath, directoryPath(path.dirname(config.paths.routes)))
1199
- ) {
1200
- return "app";
1201
- }
1202
- if (
1203
- isUnder(
1204
- normalizedPath,
1205
- directoryPath(path.dirname(config.paths.portWiring)),
1206
- )
1207
- ) {
1208
- return "infra";
1209
- }
1210
- if (isUnder(normalizedPath, portsDir)) return "port";
1211
- if (isUnder(normalizedPath, clientDirPath(config))) return "client";
1212
- if (isUnder(normalizedPath, "components")) return "component";
1213
- if (isUnder(normalizedPath, legacySharedDomainPath(config))) return "domain";
1214
- if (isUnder(normalizedPath, "lib")) return "lib";
1215
-
1216
- const featurePath = featureRelativePath(normalizedPath, featuresPath);
1217
- if (featurePath) {
1218
- const layerSegment = featurePath.split("/")[1];
1219
- if (layerSegment === "domain") return "domain";
1220
- if (layerSegment === "contracts") return "contract";
1221
- if (layerSegment?.endsWith("-contracts")) return "contract";
1222
- if (layerSegment === "routes") return "route";
1223
- if (layerSegment === "ports") return "feature-port";
1224
- if (layerSegment === "policy") return "policy";
1225
- if (layerSegment === "use-cases") return "use-case";
1226
- if (
1227
- [
1228
- "jobs",
1229
- "listeners",
1230
- "notifications",
1231
- "schedules",
1232
- "tasks",
1233
- "uploads",
1234
- ].includes(layerSegment)
1235
- ) {
1236
- return "workflow";
1237
- }
1238
- if (layerSegment === "components") return "component";
1239
- if (layerSegment === "client") return "client";
1240
- }
1241
-
1242
- if (
1243
- directoryPath(config.paths.contracts) !== featuresPath &&
1244
- isUnder(normalizedPath, directoryPath(config.paths.contracts))
1245
- ) {
1246
- return "contract";
1247
- }
1248
- if (
1249
- directoryPath(config.paths.useCases) !== featuresPath &&
1250
- isUnder(normalizedPath, directoryPath(config.paths.useCases))
1251
- ) {
1252
- return "use-case";
1253
- }
1254
- if (
1255
- directoryPath(config.paths.policies) !== featuresPath &&
1256
- isUnder(normalizedPath, directoryPath(config.paths.policies))
1257
- ) {
1258
- return "policy";
1259
- }
1260
-
1261
- return "unknown";
1262
- }
1263
-
1264
- function isTestPath(filePath: string): boolean {
1265
- return (
1266
- filePath.includes("/tests/") ||
1267
- filePath.endsWith(".test") ||
1268
- filePath.endsWith(".spec")
1269
- );
1270
- }
1271
-
1272
1017
  function isUnder(filePath: string, directory: string): boolean {
1273
1018
  const normalizedDir = directoryPath(directory);
1274
1019
  if (!normalizedDir || normalizedDir === ".") return false;
@@ -1288,7 +1033,7 @@ function featureRelativePath(
1288
1033
  function featureLayerInfo(
1289
1034
  filePath: string,
1290
1035
  config: ResolvedBeignetConfig,
1291
- ): { feature: string; layer: string | undefined } | undefined {
1036
+ ): { feature: string; layer: SourceLayer } | undefined {
1292
1037
  const normalizedPath = stripKnownExtension(normalizePath(filePath));
1293
1038
  const relativePath = featureRelativePath(
1294
1039
  normalizedPath,
@@ -1296,10 +1041,10 @@ function featureLayerInfo(
1296
1041
  );
1297
1042
  if (!relativePath) return undefined;
1298
1043
 
1299
- const [feature, layer] = relativePath.split("/");
1044
+ const [feature] = relativePath.split("/");
1300
1045
  if (!feature) return undefined;
1301
1046
 
1302
- return { feature, layer };
1047
+ return { feature, layer: classifyPath(filePath, config) };
1303
1048
  }
1304
1049
 
1305
1050
  function legacySharedDomainPath(config: ResolvedBeignetConfig): string {
@@ -1331,9 +1076,14 @@ function dedupeDiagnostics(diagnostics: LintDiagnostic[]): LintDiagnostic[] {
1331
1076
  const deduped: LintDiagnostic[] = [];
1332
1077
 
1333
1078
  for (const diagnostic of diagnostics) {
1334
- const key = [diagnostic.code, diagnostic.file, diagnostic.importPath].join(
1335
- ":",
1336
- );
1079
+ const key = JSON.stringify([
1080
+ diagnostic.code,
1081
+ diagnostic.file,
1082
+ diagnostic.importPath,
1083
+ diagnostic.line,
1084
+ diagnostic.column,
1085
+ diagnostic.message,
1086
+ ]);
1337
1087
  if (seen.has(key)) continue;
1338
1088
  seen.add(key);
1339
1089
  deduped.push(diagnostic);