@beignet/cli 0.0.3 → 0.0.5

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 (138) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/README.md +379 -61
  3. package/dist/ansi.d.ts +10 -0
  4. package/dist/ansi.d.ts.map +1 -0
  5. package/dist/ansi.js +20 -0
  6. package/dist/ansi.js.map +1 -0
  7. package/dist/choices.d.ts +49 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +53 -0
  10. package/dist/choices.js.map +1 -0
  11. package/dist/completion.d.ts +47 -0
  12. package/dist/completion.d.ts.map +1 -0
  13. package/dist/completion.js +123 -0
  14. package/dist/completion.js.map +1 -0
  15. package/dist/config.d.ts +8 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +8 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +41 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +78 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +10 -5
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +28 -47
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +1 -0
  28. package/dist/db.d.ts.map +1 -1
  29. package/dist/db.js +37 -2
  30. package/dist/db.js.map +1 -1
  31. package/dist/github-annotations.d.ts +18 -0
  32. package/dist/github-annotations.d.ts.map +1 -0
  33. package/dist/github-annotations.js +22 -0
  34. package/dist/github-annotations.js.map +1 -0
  35. package/dist/index.d.ts +1 -9
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +665 -603
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +21 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +1938 -131
  42. package/dist/inspect.js.map +1 -1
  43. package/dist/lib.d.ts +20 -0
  44. package/dist/lib.d.ts.map +1 -0
  45. package/dist/lib.js +17 -0
  46. package/dist/lib.js.map +1 -0
  47. package/dist/lint.d.ts +10 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +340 -33
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +20 -3
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +1896 -382
  54. package/dist/make.js.map +1 -1
  55. package/dist/outbox.d.ts +24 -0
  56. package/dist/outbox.d.ts.map +1 -0
  57. package/dist/outbox.js +138 -0
  58. package/dist/outbox.js.map +1 -0
  59. package/dist/schedule.d.ts +36 -0
  60. package/dist/schedule.d.ts.map +1 -0
  61. package/dist/schedule.js +155 -0
  62. package/dist/schedule.js.map +1 -0
  63. package/dist/task.d.ts +26 -0
  64. package/dist/task.d.ts.map +1 -0
  65. package/dist/task.js +106 -0
  66. package/dist/task.js.map +1 -0
  67. package/dist/templates/base.d.ts +13 -0
  68. package/dist/templates/base.d.ts.map +1 -0
  69. package/dist/templates/base.js +300 -0
  70. package/dist/templates/base.js.map +1 -0
  71. package/dist/templates/db.d.ts +14 -0
  72. package/dist/templates/db.d.ts.map +1 -0
  73. package/dist/templates/db.js +962 -0
  74. package/dist/templates/db.js.map +1 -0
  75. package/dist/templates/index.d.ts +14 -0
  76. package/dist/templates/index.d.ts.map +1 -0
  77. package/dist/templates/index.js +123 -0
  78. package/dist/templates/index.js.map +1 -0
  79. package/dist/templates/server.d.ts +26 -0
  80. package/dist/templates/server.d.ts.map +1 -0
  81. package/dist/templates/server.js +520 -0
  82. package/dist/templates/server.js.map +1 -0
  83. package/dist/templates/shadcn.d.ts +5 -0
  84. package/dist/templates/shadcn.d.ts.map +1 -0
  85. package/dist/templates/shadcn.js +555 -0
  86. package/dist/templates/shadcn.js.map +1 -0
  87. package/dist/templates/shared.d.ts +49 -0
  88. package/dist/templates/shared.d.ts.map +1 -0
  89. package/dist/templates/shared.js +57 -0
  90. package/dist/templates/shared.js.map +1 -0
  91. package/dist/templates/shell.d.ts +5 -0
  92. package/dist/templates/shell.d.ts.map +1 -0
  93. package/dist/templates/shell.js +1190 -0
  94. package/dist/templates/shell.js.map +1 -0
  95. package/dist/templates/todos.d.ts +17 -0
  96. package/dist/templates/todos.d.ts.map +1 -0
  97. package/dist/templates/todos.js +607 -0
  98. package/dist/templates/todos.js.map +1 -0
  99. package/dist/version.d.ts +8 -0
  100. package/dist/version.d.ts.map +1 -0
  101. package/dist/version.js +18 -0
  102. package/dist/version.js.map +1 -0
  103. package/package.json +9 -8
  104. package/src/ansi.ts +30 -0
  105. package/src/choices.ts +83 -0
  106. package/src/completion.ts +169 -0
  107. package/src/config.ts +16 -0
  108. package/src/create-prompts.ts +114 -0
  109. package/src/create.ts +40 -64
  110. package/src/db.ts +60 -4
  111. package/src/github-annotations.ts +37 -0
  112. package/src/index.ts +1075 -817
  113. package/src/inspect.ts +2859 -115
  114. package/src/lib.ts +45 -0
  115. package/src/lint.ts +493 -39
  116. package/src/make.ts +2389 -406
  117. package/src/outbox.ts +249 -0
  118. package/src/schedule.ts +272 -0
  119. package/src/task.ts +169 -0
  120. package/src/templates/base.ts +377 -0
  121. package/src/templates/db.ts +963 -0
  122. package/src/templates/index.ts +148 -0
  123. package/src/templates/server.ts +528 -0
  124. package/src/templates/shadcn.ts +570 -0
  125. package/src/templates/shared.ts +90 -0
  126. package/src/templates/shell.ts +1219 -0
  127. package/src/templates/todos.ts +607 -0
  128. package/src/version.ts +20 -0
  129. package/dist/create-bin.d.ts +0 -3
  130. package/dist/create-bin.d.ts.map +0 -1
  131. package/dist/create-bin.js +0 -9
  132. package/dist/create-bin.js.map +0 -1
  133. package/dist/templates.d.ts +0 -55
  134. package/dist/templates.d.ts.map +0 -1
  135. package/dist/templates.js +0 -3520
  136. package/dist/templates.js.map +0 -1
  137. package/src/create-bin.ts +0 -11
  138. package/src/templates.ts +0 -3774
package/src/lint.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { createPainter } from "./ansi.js";
3
4
  import {
4
5
  type BeignetConfig,
5
6
  directoryPath,
@@ -8,6 +9,7 @@ import {
8
9
  type ResolvedBeignetConfig,
9
10
  resolveConfig,
10
11
  } from "./config.js";
12
+ import { formatGithubAnnotation } from "./github-annotations.js";
11
13
 
12
14
  type LintAppOptions = {
13
15
  cwd?: string;
@@ -35,10 +37,18 @@ type SourceLayer =
35
37
 
36
38
  type ImportReference = {
37
39
  importPath: string;
40
+ kind: ImportKind;
41
+ offset: number;
38
42
  resolvedPath?: string;
39
43
  packageName?: string;
40
44
  };
41
45
 
46
+ type ImportKind = "type" | "value";
47
+
48
+ type ImportGraph = Map<string, ImportReference[]>;
49
+
50
+ type RuntimeBoundaryRootKind = "client" | "contract";
51
+
42
52
  /**
43
53
  * Dependency-direction lint diagnostic.
44
54
  */
@@ -48,12 +58,15 @@ export type LintDiagnostic = {
48
58
  message: string;
49
59
  file: string;
50
60
  importPath: string;
61
+ line?: number;
62
+ column?: number;
51
63
  };
52
64
 
53
65
  /**
54
66
  * Result returned by `beignet lint`.
55
67
  */
56
68
  export type LintAppResult = {
69
+ schemaVersion: 1;
57
70
  targetDir: string;
58
71
  config: ResolvedBeignetConfig;
59
72
  diagnostics: LintDiagnostic[];
@@ -73,29 +86,87 @@ export async function lintApp(
73
86
  ? resolveConfig(options.config)
74
87
  : await loadBeignetConfig(targetDir, files);
75
88
  const diagnostics: LintDiagnostic[] = [];
89
+ const sourceFiles = lintSourceFiles(files);
90
+ const sourceFileSet = new Set(sourceFiles);
91
+ const sourceByFile = new Map<string, string>();
92
+ const importGraph: ImportGraph = new Map();
76
93
 
77
- for (const file of lintSourceFiles(files)) {
94
+ for (const file of sourceFiles) {
78
95
  const sourceLayer = classifyPath(file, config);
96
+ const source = await readFile(path.join(targetDir, file), "utf8");
97
+ sourceByFile.set(file, source);
98
+ importGraph.set(file, parseImports(source, file, sourceFileSet));
99
+
79
100
  if (sourceLayer === "test" || sourceLayer === "unknown") continue;
80
101
 
81
- const source = await readFile(path.join(targetDir, file), "utf8");
82
- for (const reference of parseImports(source, file)) {
102
+ const lineStarts = computeLineStarts(source);
103
+ for (const reference of importGraph.get(file) ?? []) {
83
104
  const diagnostic = lintImport(file, sourceLayer, reference, config);
84
- if (diagnostic) diagnostics.push(diagnostic);
105
+ if (diagnostic) {
106
+ diagnostics.push({
107
+ ...diagnostic,
108
+ ...positionFromOffset(lineStarts, reference.offset),
109
+ });
110
+ }
85
111
  }
86
112
  }
87
113
 
114
+ diagnostics.push(
115
+ ...lintRuntimeBoundary({
116
+ config,
117
+ graph: importGraph,
118
+ sourceByFile,
119
+ }),
120
+ );
121
+
88
122
  return {
123
+ schemaVersion: 1,
89
124
  targetDir,
90
125
  config,
91
126
  diagnostics: dedupeDiagnostics(diagnostics),
92
127
  };
93
128
  }
94
129
 
130
+ function computeLineStarts(source: string): number[] {
131
+ const lineStarts = [0];
132
+
133
+ for (let index = 0; index < source.length; index++) {
134
+ if (source[index] === "\n") {
135
+ lineStarts.push(index + 1);
136
+ }
137
+ }
138
+
139
+ return lineStarts;
140
+ }
141
+
142
+ function positionFromOffset(
143
+ lineStarts: number[],
144
+ offset: number,
145
+ ): { line: number; column: number } {
146
+ let low = 0;
147
+ let high = lineStarts.length - 1;
148
+
149
+ while (low < high) {
150
+ const middle = Math.ceil((low + high) / 2);
151
+ if (lineStarts[middle] <= offset) {
152
+ low = middle;
153
+ } else {
154
+ high = middle - 1;
155
+ }
156
+ }
157
+
158
+ return { line: low + 1, column: offset - lineStarts[low] + 1 };
159
+ }
160
+
95
161
  /**
96
162
  * Format dependency-direction lint diagnostics for CLI output.
97
163
  */
98
- export function formatLint(result: LintAppResult): string {
164
+ export function formatLint(
165
+ result: LintAppResult,
166
+ options: { color?: boolean } = {},
167
+ ): string {
168
+ const paint = createPainter(options.color);
169
+
99
170
  if (result.diagnostics.length === 0) {
100
171
  return `No Beignet lint issues found in ${result.targetDir}.`;
101
172
  }
@@ -106,12 +177,39 @@ export function formatLint(result: LintAppResult): string {
106
177
  } in ${result.targetDir}:`,
107
178
  "",
108
179
  ...result.diagnostics.map(
109
- (diagnostic) => `ERROR ${diagnostic.code} ${diagnostic.file}
180
+ (
181
+ diagnostic,
182
+ ) => `${paint("ERROR", "red")} ${diagnostic.code} ${lintDiagnosticLocation(diagnostic)}
110
183
  ${diagnostic.message}`,
111
184
  ),
112
185
  ].join("\n");
113
186
  }
114
187
 
188
+ function lintDiagnosticLocation(diagnostic: LintDiagnostic): string {
189
+ if (diagnostic.line === undefined || diagnostic.column === undefined) {
190
+ return diagnostic.file;
191
+ }
192
+
193
+ return `${diagnostic.file}:${diagnostic.line}:${diagnostic.column}`;
194
+ }
195
+
196
+ /**
197
+ * Format dependency-direction lint diagnostics as GitHub Actions annotations.
198
+ */
199
+ export function formatLintGithub(result: LintAppResult): string {
200
+ return result.diagnostics
201
+ .map((diagnostic) =>
202
+ formatGithubAnnotation({
203
+ severity: "error",
204
+ file: diagnostic.file,
205
+ line: diagnostic.line,
206
+ col: diagnostic.column,
207
+ message: `${diagnostic.code}: ${diagnostic.message}`,
208
+ }),
209
+ )
210
+ .join("\n");
211
+ }
212
+
115
213
  async function assertDirectory(targetDir: string): Promise<void> {
116
214
  try {
117
215
  const stats = await stat(targetDir);
@@ -165,17 +263,27 @@ function lintSourceFiles(files: string[]): string[] {
165
263
  });
166
264
  }
167
265
 
168
- function parseImports(source: string, importerFile: string): ImportReference[] {
266
+ function parseImports(
267
+ source: string,
268
+ importerFile: string,
269
+ files: Set<string>,
270
+ ): ImportReference[] {
169
271
  const references = parseImportSpecifiers(source);
170
272
 
171
- return references.map((importPath) => ({
172
- importPath,
173
- ...resolveImport(importPath, importerFile),
273
+ return references.map((reference) => ({
274
+ ...reference,
275
+ ...resolveImport(reference.importPath, importerFile, files),
174
276
  }));
175
277
  }
176
278
 
177
- function parseImportSpecifiers(source: string): string[] {
178
- const specifiers: string[] = [];
279
+ function parseImportSpecifiers(
280
+ source: string,
281
+ ): Array<{ importPath: string; kind: ImportKind; offset: number }> {
282
+ const specifiers: Array<{
283
+ importPath: string;
284
+ kind: ImportKind;
285
+ offset: number;
286
+ }> = [];
179
287
  let index = 0;
180
288
 
181
289
  while (index < source.length) {
@@ -183,14 +291,26 @@ function parseImportSpecifiers(source: string): string[] {
183
291
 
184
292
  if (isKeywordAt(source, index, "import")) {
185
293
  const parsed = parseImportSpecifierAt(source, index + "import".length);
186
- if (parsed?.specifier) specifiers.push(parsed.specifier);
294
+ if (parsed?.specifier) {
295
+ specifiers.push({
296
+ importPath: parsed.specifier,
297
+ kind: parsed.kind ?? "value",
298
+ offset: index,
299
+ });
300
+ }
187
301
  index = parsed?.end ?? index + "import".length;
188
302
  continue;
189
303
  }
190
304
 
191
305
  if (isKeywordAt(source, index, "export")) {
192
306
  const parsed = parseExportSpecifierAt(source, index + "export".length);
193
- if (parsed?.specifier) specifiers.push(parsed.specifier);
307
+ if (parsed?.specifier) {
308
+ specifiers.push({
309
+ importPath: parsed.specifier,
310
+ kind: parsed.kind ?? "value",
311
+ offset: index,
312
+ });
313
+ }
194
314
  index = parsed?.end ?? index + "export".length;
195
315
  continue;
196
316
  }
@@ -204,7 +324,7 @@ function parseImportSpecifiers(source: string): string[] {
204
324
  function parseImportSpecifierAt(
205
325
  source: string,
206
326
  index: number,
207
- ): { specifier?: string; end: number } | undefined {
327
+ ): { specifier?: string; kind?: ImportKind; end: number } | undefined {
208
328
  let cursor = skipWhitespaceAndComments(source, index);
209
329
  if (source[cursor] === "(") {
210
330
  cursor = skipWhitespaceAndComments(source, cursor + 1);
@@ -220,10 +340,18 @@ function parseImportSpecifierAt(
220
340
  if (sideEffectImport) {
221
341
  return {
222
342
  specifier: sideEffectImport.value,
343
+ kind: "value",
223
344
  end: sideEffectImport.end,
224
345
  };
225
346
  }
226
347
 
348
+ const importClauseStart = cursor;
349
+ let isImportType = false;
350
+ if (isKeywordAt(source, cursor, "type")) {
351
+ isImportType = true;
352
+ cursor = cursorAfterKeyword("type", cursor);
353
+ }
354
+
227
355
  while (cursor < source.length) {
228
356
  cursor = skipWhitespaceAndComments(source, cursor);
229
357
 
@@ -240,6 +368,9 @@ function parseImportSpecifierAt(
240
368
  if (!stringLiteral) return { end: fromCursor };
241
369
  return {
242
370
  specifier: stringLiteral.value,
371
+ kind: isImportType
372
+ ? "type"
373
+ : inferImportClauseKind(source.slice(importClauseStart, cursor)),
243
374
  end: stringLiteral.end,
244
375
  };
245
376
  }
@@ -253,12 +384,20 @@ function parseImportSpecifierAt(
253
384
  function parseExportSpecifierAt(
254
385
  source: string,
255
386
  index: number,
256
- ): { specifier?: string; end: number } | undefined {
387
+ ): { specifier?: string; kind?: ImportKind; end: number } | undefined {
257
388
  let cursor = index;
389
+ let isExportType = false;
390
+ const exportClauseStart = cursor;
258
391
 
259
392
  while (cursor < source.length) {
260
393
  cursor = skipWhitespaceAndComments(source, cursor);
261
394
 
395
+ if (isKeywordAt(source, cursor, "type")) {
396
+ isExportType = true;
397
+ cursor = cursorAfterKeyword("type", cursor);
398
+ continue;
399
+ }
400
+
262
401
  if (source[cursor] === ";" || source[cursor] === "\n") {
263
402
  return { end: cursor + 1 };
264
403
  }
@@ -272,6 +411,9 @@ function parseExportSpecifierAt(
272
411
  if (!stringLiteral) return { end: fromCursor };
273
412
  return {
274
413
  specifier: stringLiteral.value,
414
+ kind: isExportType
415
+ ? "type"
416
+ : inferImportClauseKind(source.slice(exportClauseStart, cursor)),
275
417
  end: stringLiteral.end,
276
418
  };
277
419
  }
@@ -282,6 +424,14 @@ function parseExportSpecifierAt(
282
424
  return undefined;
283
425
  }
284
426
 
427
+ function inferImportClauseKind(importClause: string): ImportKind {
428
+ const trimmed = importClause.trim();
429
+ if (/^\{\s*type\s+[^,}]+(?:,\s*type\s+[^,}]+)*,?\s*\}$/.test(trimmed)) {
430
+ return "type";
431
+ }
432
+ return "value";
433
+ }
434
+
285
435
  function cursorAfterKeyword(keyword: string, index: number): number {
286
436
  return index + keyword.length;
287
437
  }
@@ -425,17 +575,24 @@ function isIdentifierChar(char: string | undefined): boolean {
425
575
  function resolveImport(
426
576
  importPath: string,
427
577
  importerFile: string,
578
+ files: Set<string>,
428
579
  ): Pick<ImportReference, "packageName" | "resolvedPath"> {
429
580
  if (importPath.startsWith("@/")) {
430
581
  return {
431
- resolvedPath: stripKnownExtension(importPath.slice(2)),
582
+ resolvedPath: resolveLocalImportPath(
583
+ stripKnownExtension(importPath.slice(2)),
584
+ files,
585
+ ),
432
586
  };
433
587
  }
434
588
 
435
589
  if (importPath.startsWith(".")) {
436
590
  return {
437
- resolvedPath: stripKnownExtension(
438
- normalizePath(path.join(path.dirname(importerFile), importPath)),
591
+ resolvedPath: resolveLocalImportPath(
592
+ stripKnownExtension(
593
+ normalizePath(path.join(path.dirname(importerFile), importPath)),
594
+ ),
595
+ files,
439
596
  ),
440
597
  };
441
598
  }
@@ -445,6 +602,33 @@ function resolveImport(
445
602
  };
446
603
  }
447
604
 
605
+ function resolveLocalImportPath(
606
+ importPath: string,
607
+ files: Set<string>,
608
+ ): string | undefined {
609
+ const candidates = [
610
+ importPath,
611
+ `${importPath}.ts`,
612
+ `${importPath}.tsx`,
613
+ `${importPath}.mts`,
614
+ `${importPath}.cts`,
615
+ `${importPath}/index.ts`,
616
+ `${importPath}/index.tsx`,
617
+ `${importPath}/index.mts`,
618
+ `${importPath}/index.cts`,
619
+ ];
620
+
621
+ for (const candidate of candidates) {
622
+ for (const file of files) {
623
+ if (stripKnownExtension(file) === stripKnownExtension(candidate)) {
624
+ return file;
625
+ }
626
+ }
627
+ }
628
+
629
+ return stripKnownExtension(importPath);
630
+ }
631
+
448
632
  function packageName(importPath: string): string {
449
633
  if (!importPath.startsWith("@")) return importPath.split("/")[0];
450
634
 
@@ -462,9 +646,24 @@ function lintImport(
462
646
  reference: ImportReference,
463
647
  config: ResolvedBeignetConfig,
464
648
  ): LintDiagnostic | undefined {
649
+ if (reference.kind === "type") return undefined;
650
+
465
651
  const targetLayer = reference.resolvedPath
466
652
  ? classifyPath(reference.resolvedPath, config)
467
653
  : undefined;
654
+ const featureImport = reference.resolvedPath
655
+ ? featureImportViolation(file, sourceLayer, reference.resolvedPath, config)
656
+ : undefined;
657
+ if (featureImport) {
658
+ return {
659
+ severity: "error",
660
+ code: "BEIGNET_IMPORT_DIRECTION",
661
+ file,
662
+ importPath: reference.importPath,
663
+ message: featureImport,
664
+ };
665
+ }
666
+
468
667
  const appImport = reference.resolvedPath
469
668
  ? appImportViolation(sourceLayer, targetLayer)
470
669
  : undefined;
@@ -472,23 +671,24 @@ function lintImport(
472
671
  if (appImport) {
473
672
  return {
474
673
  severity: "error",
475
- code: "CK_IMPORT_DIRECTION",
674
+ code: "BEIGNET_IMPORT_DIRECTION",
476
675
  file,
477
676
  importPath: reference.importPath,
478
677
  message: appImportMessage(sourceLayer, reference.importPath, appImport),
479
678
  };
480
679
  }
481
680
 
482
- if (
483
- reference.packageName &&
484
- packageImportViolation(sourceLayer, reference.packageName)
485
- ) {
681
+ const bannedPackage = reference.packageName
682
+ ? packageImportViolation(sourceLayer, reference.importPath)
683
+ : undefined;
684
+
685
+ if (bannedPackage) {
486
686
  return {
487
687
  severity: "error",
488
- code: "CK_IMPORT_DIRECTION",
688
+ code: "BEIGNET_IMPORT_DIRECTION",
489
689
  file,
490
690
  importPath: reference.importPath,
491
- message: packageImportMessage(sourceLayer, reference.packageName),
691
+ message: packageImportMessage(sourceLayer, bannedPackage),
492
692
  };
493
693
  }
494
694
 
@@ -563,17 +763,24 @@ function appImportViolation(
563
763
  return targetLayer;
564
764
  }
565
765
 
766
+ if (
767
+ sourceLayer === "infra" &&
768
+ ["app", "client", "component", "route", "server"].includes(targetLayer)
769
+ ) {
770
+ return targetLayer;
771
+ }
772
+
566
773
  return undefined;
567
774
  }
568
775
 
569
776
  function packageImportViolation(
570
777
  sourceLayer: SourceLayer,
571
- packageName: string,
572
- ): boolean {
778
+ importPath: string,
779
+ ): string | undefined {
573
780
  if (sourceLayer === "domain") {
574
781
  return (
575
- domainBannedPackages.has(packageName) ||
576
- packageName.startsWith("@beignet/provider-")
782
+ matchBannedPackage(domainBannedPackages, importPath) ??
783
+ matchBeignetProviderPackage(importPath)
577
784
  );
578
785
  }
579
786
 
@@ -588,20 +795,245 @@ function packageImportViolation(
588
795
  ].includes(sourceLayer)
589
796
  ) {
590
797
  return (
591
- coreBannedPackages.has(packageName) ||
592
- serverRuntimePackages.has(packageName) ||
593
- packageName.startsWith("@beignet/provider-")
798
+ matchBannedPackage(coreBannedPackages, importPath) ??
799
+ matchBannedPackage(serverRuntimePackages, importPath) ??
800
+ matchBeignetProviderPackage(importPath)
594
801
  );
595
802
  }
596
803
 
597
804
  if (sourceLayer === "route") {
598
805
  return (
599
- coreBannedPackages.has(packageName) ||
600
- packageName.startsWith("@beignet/provider-")
806
+ matchBannedPackage(coreBannedPackages, importPath) ??
807
+ matchBeignetProviderPackage(importPath)
808
+ );
809
+ }
810
+
811
+ return undefined;
812
+ }
813
+
814
+ function matchBannedPackage(
815
+ bannedPackages: ReadonlySet<string>,
816
+ importPath: string,
817
+ ): string | undefined {
818
+ let candidate = importPath;
819
+
820
+ while (candidate.length > 0) {
821
+ if (bannedPackages.has(candidate)) return candidate;
822
+
823
+ const separator = candidate.lastIndexOf("/");
824
+ if (separator === -1) return undefined;
825
+ candidate = candidate.slice(0, separator);
826
+ }
827
+
828
+ return undefined;
829
+ }
830
+
831
+ function matchBeignetProviderPackage(importPath: string): string | undefined {
832
+ const name = packageName(importPath);
833
+ return name.startsWith("@beignet/provider-") ? name : undefined;
834
+ }
835
+
836
+ function lintRuntimeBoundary({
837
+ config,
838
+ graph,
839
+ sourceByFile,
840
+ }: {
841
+ config: ResolvedBeignetConfig;
842
+ graph: ImportGraph;
843
+ sourceByFile: Map<string, string>;
844
+ }): LintDiagnostic[] {
845
+ const diagnostics: LintDiagnostic[] = [];
846
+
847
+ for (const file of graph.keys()) {
848
+ const rootKind = runtimeBoundaryRootKind(
849
+ file,
850
+ sourceByFile.get(file) ?? "",
851
+ config,
852
+ );
853
+ if (!rootKind) continue;
854
+
855
+ diagnostics.push(
856
+ ...lintRuntimeBoundaryRoot({
857
+ root: file,
858
+ rootKind,
859
+ config,
860
+ graph,
861
+ }),
601
862
  );
602
863
  }
603
864
 
604
- return false;
865
+ return diagnostics;
866
+ }
867
+
868
+ function lintRuntimeBoundaryRoot({
869
+ root,
870
+ rootKind,
871
+ config,
872
+ graph,
873
+ }: {
874
+ root: string;
875
+ rootKind: RuntimeBoundaryRootKind;
876
+ config: ResolvedBeignetConfig;
877
+ graph: ImportGraph;
878
+ }): LintDiagnostic[] {
879
+ const diagnostics: LintDiagnostic[] = [];
880
+ const visited = new Set<string>();
881
+ const queue: Array<{ file: string; chain: string[] }> = [
882
+ { file: root, chain: [root] },
883
+ ];
884
+
885
+ while (queue.length > 0) {
886
+ const current = queue.shift();
887
+ if (!current) continue;
888
+ if (visited.has(current.file)) continue;
889
+ visited.add(current.file);
890
+
891
+ for (const reference of graph.get(current.file) ?? []) {
892
+ if (
893
+ reference.kind === "type" &&
894
+ reference.importPath !== "@beignet/core/server-only"
895
+ ) {
896
+ continue;
897
+ }
898
+
899
+ const chain = reference.resolvedPath
900
+ ? [...current.chain, reference.resolvedPath]
901
+ : current.chain;
902
+ const violation = runtimeBoundaryViolation(reference, config);
903
+
904
+ if (violation) {
905
+ diagnostics.push({
906
+ severity: "error",
907
+ code: "BEIGNET_RUNTIME_BOUNDARY",
908
+ file: root,
909
+ importPath: reference.importPath,
910
+ message: runtimeBoundaryMessage(rootKind, violation, chain),
911
+ });
912
+ continue;
913
+ }
914
+
915
+ if (reference.resolvedPath && graph.has(reference.resolvedPath)) {
916
+ queue.push({
917
+ file: reference.resolvedPath,
918
+ chain,
919
+ });
920
+ }
921
+ }
922
+ }
923
+
924
+ return diagnostics;
925
+ }
926
+
927
+ function runtimeBoundaryRootKind(
928
+ file: string,
929
+ source: string,
930
+ config: ResolvedBeignetConfig,
931
+ ): RuntimeBoundaryRootKind | undefined {
932
+ const layer = classifyPath(file, config);
933
+ if (layer === "contract") return "contract";
934
+ if (layer === "client") return "client";
935
+ if (hasUseClientDirective(source)) return "client";
936
+ if (isGeneratedClientAdapter(file)) return "client";
937
+ return undefined;
938
+ }
939
+
940
+ function hasUseClientDirective(source: string): boolean {
941
+ const trimmed = source.trimStart();
942
+ return (
943
+ trimmed.startsWith('"use client";') ||
944
+ trimmed.startsWith('"use client"') ||
945
+ trimmed.startsWith("'use client';") ||
946
+ trimmed.startsWith("'use client'")
947
+ );
948
+ }
949
+
950
+ function isGeneratedClientAdapter(file: string): boolean {
951
+ return (
952
+ file === "client/index.ts" ||
953
+ file === "client/api-client.ts" ||
954
+ file === "client/rhf.ts" ||
955
+ file === "client/rq.ts" ||
956
+ file === "client/uploads.ts"
957
+ );
958
+ }
959
+
960
+ function runtimeBoundaryViolation(
961
+ reference: ImportReference,
962
+ config: ResolvedBeignetConfig,
963
+ ): string | undefined {
964
+ if (reference.resolvedPath) {
965
+ const targetLayer = classifyPath(reference.resolvedPath, config);
966
+ if (
967
+ targetLayer === "use-case" ||
968
+ targetLayer === "route" ||
969
+ targetLayer === "infra" ||
970
+ targetLayer === "server"
971
+ ) {
972
+ return `${layerLabel(targetLayer)} module ${reference.resolvedPath}`;
973
+ }
974
+ if (targetLayer === "app-context") {
975
+ return `app context module ${reference.resolvedPath}`;
976
+ }
977
+ }
978
+
979
+ if (reference.importPath === "@beignet/core/server-only") {
980
+ return "server-only marker @beignet/core/server-only";
981
+ }
982
+
983
+ if (reference.importPath.startsWith("node:")) {
984
+ return `Node runtime module ${reference.importPath}`;
985
+ }
986
+
987
+ if (
988
+ serverRuntimePackages.has(reference.importPath) ||
989
+ reference.importPath.startsWith("@beignet/provider-")
990
+ ) {
991
+ return `server runtime package ${reference.importPath}`;
992
+ }
993
+
994
+ return undefined;
995
+ }
996
+
997
+ function runtimeBoundaryMessage(
998
+ rootKind: RuntimeBoundaryRootKind,
999
+ violation: string,
1000
+ chain: string[],
1001
+ ): string {
1002
+ return `${rootKindLabel(
1003
+ rootKind,
1004
+ )} roots must not value-import server-only code. Reached ${violation} through ${formatImportChain(
1005
+ chain,
1006
+ )}. Move shared DTO and validation schemas to features/<feature>/schemas.ts, keep use cases/routes/infra/server modules behind server entrypoints, or import server-shaped modules with import type only.`;
1007
+ }
1008
+
1009
+ function rootKindLabel(rootKind: RuntimeBoundaryRootKind): string {
1010
+ return rootKind === "contract" ? "contract" : "client";
1011
+ }
1012
+
1013
+ function formatImportChain(chain: string[]): string {
1014
+ return chain.join(" -> ");
1015
+ }
1016
+
1017
+ function featureImportViolation(
1018
+ file: string,
1019
+ sourceLayer: SourceLayer,
1020
+ resolvedPath: string,
1021
+ config: ResolvedBeignetConfig,
1022
+ ): string | undefined {
1023
+ if (sourceLayer !== "domain") return undefined;
1024
+
1025
+ const sourceFeature = featureLayerInfo(file, config);
1026
+ const targetFeature = featureLayerInfo(resolvedPath, config);
1027
+ if (!sourceFeature || !targetFeature) return undefined;
1028
+ if (sourceFeature.layer !== "domain" || targetFeature.layer !== "domain") {
1029
+ return undefined;
1030
+ }
1031
+ if (sourceFeature.feature === targetFeature.feature) return undefined;
1032
+ if (targetFeature.feature === "shared") return undefined;
1033
+
1034
+ return `feature-specific domain files must not import another feature's domain (${stripKnownExtension(
1035
+ resolvedPath,
1036
+ )}). Move shared concepts to features/shared/domain.`;
605
1037
  }
606
1038
 
607
1039
  const domainBannedPackages = new Set([
@@ -744,9 +1176,14 @@ function classifyPath(
744
1176
  if (layerSegment === "policy") return "policy";
745
1177
  if (layerSegment === "use-cases") return "use-case";
746
1178
  if (
747
- ["jobs", "listeners", "notifications", "schedules", "uploads"].includes(
748
- layerSegment,
749
- )
1179
+ [
1180
+ "jobs",
1181
+ "listeners",
1182
+ "notifications",
1183
+ "schedules",
1184
+ "tasks",
1185
+ "uploads",
1186
+ ].includes(layerSegment)
750
1187
  ) {
751
1188
  return "workflow";
752
1189
  }
@@ -790,6 +1227,23 @@ function featureRelativePath(
790
1227
  return filePath.slice(prefix.length);
791
1228
  }
792
1229
 
1230
+ function featureLayerInfo(
1231
+ filePath: string,
1232
+ config: ResolvedBeignetConfig,
1233
+ ): { feature: string; layer: string | undefined } | undefined {
1234
+ const normalizedPath = stripKnownExtension(normalizePath(filePath));
1235
+ const relativePath = featureRelativePath(
1236
+ normalizedPath,
1237
+ directoryPath(config.paths.features),
1238
+ );
1239
+ if (!relativePath) return undefined;
1240
+
1241
+ const [feature, layer] = relativePath.split("/");
1242
+ if (!feature) return undefined;
1243
+
1244
+ return { feature, layer };
1245
+ }
1246
+
793
1247
  function sharedDomainPath(config: ResolvedBeignetConfig): string {
794
1248
  const featuresDir = directoryPath(config.paths.features);
795
1249
  const featuresParent = directoryPath(path.dirname(featuresDir));