@beignet/cli 0.0.1 → 0.0.4

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 (98) hide show
  1. package/CHANGELOG.md +231 -0
  2. package/README.md +497 -90
  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 +72 -0
  8. package/dist/choices.d.ts.map +1 -0
  9. package/dist/choices.js +88 -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 +39 -0
  16. package/dist/config.d.ts.map +1 -1
  17. package/dist/config.js +26 -0
  18. package/dist/config.js.map +1 -1
  19. package/dist/create-prompts.d.ts +42 -0
  20. package/dist/create-prompts.d.ts.map +1 -0
  21. package/dist/create-prompts.js +136 -0
  22. package/dist/create-prompts.js.map +1 -0
  23. package/dist/create.d.ts +12 -0
  24. package/dist/create.d.ts.map +1 -1
  25. package/dist/create.js +19 -24
  26. package/dist/create.js.map +1 -1
  27. package/dist/db.d.ts +37 -0
  28. package/dist/db.d.ts.map +1 -0
  29. package/dist/db.js +146 -0
  30. package/dist/db.js.map +1 -0
  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 -7
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +710 -400
  38. package/dist/index.js.map +1 -1
  39. package/dist/inspect.d.ts +45 -2
  40. package/dist/inspect.d.ts.map +1 -1
  41. package/dist/inspect.js +2191 -100
  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 +22 -1
  48. package/dist/lint.d.ts.map +1 -1
  49. package/dist/lint.js +370 -38
  50. package/dist/lint.js.map +1 -1
  51. package/dist/make.d.ts +109 -1
  52. package/dist/make.d.ts.map +1 -1
  53. package/dist/make.js +2225 -333
  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.d.ts +12 -8
  68. package/dist/templates.d.ts.map +1 -1
  69. package/dist/templates.js +2144 -385
  70. package/dist/templates.js.map +1 -1
  71. package/dist/version.d.ts +8 -0
  72. package/dist/version.d.ts.map +1 -0
  73. package/dist/version.js +18 -0
  74. package/dist/version.js.map +1 -0
  75. package/package.json +9 -8
  76. package/src/ansi.ts +30 -0
  77. package/src/choices.ts +137 -0
  78. package/src/completion.ts +169 -0
  79. package/src/config.ts +47 -0
  80. package/src/create-prompts.ts +182 -0
  81. package/src/create.ts +32 -28
  82. package/src/db.ts +222 -0
  83. package/src/github-annotations.ts +37 -0
  84. package/src/index.ts +1119 -535
  85. package/src/inspect.ts +3372 -134
  86. package/src/lib.ts +45 -0
  87. package/src/lint.ts +533 -45
  88. package/src/make.ts +3010 -397
  89. package/src/outbox.ts +249 -0
  90. package/src/schedule.ts +272 -0
  91. package/src/task.ts +169 -0
  92. package/src/templates.ts +2282 -462
  93. package/src/version.ts +20 -0
  94. package/dist/create-bin.d.ts +0 -3
  95. package/dist/create-bin.d.ts.map +0 -1
  96. package/dist/create-bin.js +0 -9
  97. package/dist/create-bin.js.map +0 -1
  98. package/src/create-bin.ts +0 -11
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;
@@ -30,28 +32,49 @@ type SourceLayer =
30
32
  | "server"
31
33
  | "test"
32
34
  | "use-case"
35
+ | "workflow"
33
36
  | "unknown";
34
37
 
35
38
  type ImportReference = {
36
39
  importPath: string;
40
+ kind: ImportKind;
41
+ offset: number;
37
42
  resolvedPath?: string;
38
43
  packageName?: string;
39
44
  };
40
45
 
46
+ type ImportKind = "type" | "value";
47
+
48
+ type ImportGraph = Map<string, ImportReference[]>;
49
+
50
+ type RuntimeBoundaryRootKind = "client" | "contract";
51
+
52
+ /**
53
+ * Dependency-direction lint diagnostic.
54
+ */
41
55
  export type LintDiagnostic = {
42
56
  severity: "error";
43
57
  code: string;
44
58
  message: string;
45
59
  file: string;
46
60
  importPath: string;
61
+ line?: number;
62
+ column?: number;
47
63
  };
48
64
 
65
+ /**
66
+ * Result returned by `beignet lint`.
67
+ */
49
68
  export type LintAppResult = {
69
+ schemaVersion: 1;
50
70
  targetDir: string;
51
71
  config: ResolvedBeignetConfig;
52
72
  diagnostics: LintDiagnostic[];
53
73
  };
54
74
 
75
+ /**
76
+ * Inspect app imports and report dependency-direction violations.
77
+ */
55
78
  export async function lintApp(
56
79
  options: LintAppOptions = {},
57
80
  ): Promise<LintAppResult> {
@@ -63,26 +86,87 @@ export async function lintApp(
63
86
  ? resolveConfig(options.config)
64
87
  : await loadBeignetConfig(targetDir, files);
65
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();
66
93
 
67
- for (const file of lintSourceFiles(files)) {
94
+ for (const file of sourceFiles) {
68
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
+
69
100
  if (sourceLayer === "test" || sourceLayer === "unknown") continue;
70
101
 
71
- const source = await readFile(path.join(targetDir, file), "utf8");
72
- for (const reference of parseImports(source, file)) {
102
+ const lineStarts = computeLineStarts(source);
103
+ for (const reference of importGraph.get(file) ?? []) {
73
104
  const diagnostic = lintImport(file, sourceLayer, reference, config);
74
- if (diagnostic) diagnostics.push(diagnostic);
105
+ if (diagnostic) {
106
+ diagnostics.push({
107
+ ...diagnostic,
108
+ ...positionFromOffset(lineStarts, reference.offset),
109
+ });
110
+ }
75
111
  }
76
112
  }
77
113
 
114
+ diagnostics.push(
115
+ ...lintRuntimeBoundary({
116
+ config,
117
+ graph: importGraph,
118
+ sourceByFile,
119
+ }),
120
+ );
121
+
78
122
  return {
123
+ schemaVersion: 1,
79
124
  targetDir,
80
125
  config,
81
126
  diagnostics: dedupeDiagnostics(diagnostics),
82
127
  };
83
128
  }
84
129
 
85
- export function formatLint(result: LintAppResult): string {
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
+
161
+ /**
162
+ * Format dependency-direction lint diagnostics for CLI output.
163
+ */
164
+ export function formatLint(
165
+ result: LintAppResult,
166
+ options: { color?: boolean } = {},
167
+ ): string {
168
+ const paint = createPainter(options.color);
169
+
86
170
  if (result.diagnostics.length === 0) {
87
171
  return `No Beignet lint issues found in ${result.targetDir}.`;
88
172
  }
@@ -93,12 +177,39 @@ export function formatLint(result: LintAppResult): string {
93
177
  } in ${result.targetDir}:`,
94
178
  "",
95
179
  ...result.diagnostics.map(
96
- (diagnostic) => `ERROR ${diagnostic.code} ${diagnostic.file}
180
+ (
181
+ diagnostic,
182
+ ) => `${paint("ERROR", "red")} ${diagnostic.code} ${lintDiagnosticLocation(diagnostic)}
97
183
  ${diagnostic.message}`,
98
184
  ),
99
185
  ].join("\n");
100
186
  }
101
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
+
102
213
  async function assertDirectory(targetDir: string): Promise<void> {
103
214
  try {
104
215
  const stats = await stat(targetDir);
@@ -152,17 +263,27 @@ function lintSourceFiles(files: string[]): string[] {
152
263
  });
153
264
  }
154
265
 
155
- function parseImports(source: string, importerFile: string): ImportReference[] {
266
+ function parseImports(
267
+ source: string,
268
+ importerFile: string,
269
+ files: Set<string>,
270
+ ): ImportReference[] {
156
271
  const references = parseImportSpecifiers(source);
157
272
 
158
- return references.map((importPath) => ({
159
- importPath,
160
- ...resolveImport(importPath, importerFile),
273
+ return references.map((reference) => ({
274
+ ...reference,
275
+ ...resolveImport(reference.importPath, importerFile, files),
161
276
  }));
162
277
  }
163
278
 
164
- function parseImportSpecifiers(source: string): string[] {
165
- 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
+ }> = [];
166
287
  let index = 0;
167
288
 
168
289
  while (index < source.length) {
@@ -170,14 +291,26 @@ function parseImportSpecifiers(source: string): string[] {
170
291
 
171
292
  if (isKeywordAt(source, index, "import")) {
172
293
  const parsed = parseImportSpecifierAt(source, index + "import".length);
173
- 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
+ }
174
301
  index = parsed?.end ?? index + "import".length;
175
302
  continue;
176
303
  }
177
304
 
178
305
  if (isKeywordAt(source, index, "export")) {
179
306
  const parsed = parseExportSpecifierAt(source, index + "export".length);
180
- 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
+ }
181
314
  index = parsed?.end ?? index + "export".length;
182
315
  continue;
183
316
  }
@@ -191,7 +324,7 @@ function parseImportSpecifiers(source: string): string[] {
191
324
  function parseImportSpecifierAt(
192
325
  source: string,
193
326
  index: number,
194
- ): { specifier?: string; end: number } | undefined {
327
+ ): { specifier?: string; kind?: ImportKind; end: number } | undefined {
195
328
  let cursor = skipWhitespaceAndComments(source, index);
196
329
  if (source[cursor] === "(") {
197
330
  cursor = skipWhitespaceAndComments(source, cursor + 1);
@@ -207,10 +340,18 @@ function parseImportSpecifierAt(
207
340
  if (sideEffectImport) {
208
341
  return {
209
342
  specifier: sideEffectImport.value,
343
+ kind: "value",
210
344
  end: sideEffectImport.end,
211
345
  };
212
346
  }
213
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
+
214
355
  while (cursor < source.length) {
215
356
  cursor = skipWhitespaceAndComments(source, cursor);
216
357
 
@@ -227,6 +368,9 @@ function parseImportSpecifierAt(
227
368
  if (!stringLiteral) return { end: fromCursor };
228
369
  return {
229
370
  specifier: stringLiteral.value,
371
+ kind: isImportType
372
+ ? "type"
373
+ : inferImportClauseKind(source.slice(importClauseStart, cursor)),
230
374
  end: stringLiteral.end,
231
375
  };
232
376
  }
@@ -240,12 +384,20 @@ function parseImportSpecifierAt(
240
384
  function parseExportSpecifierAt(
241
385
  source: string,
242
386
  index: number,
243
- ): { specifier?: string; end: number } | undefined {
387
+ ): { specifier?: string; kind?: ImportKind; end: number } | undefined {
244
388
  let cursor = index;
389
+ let isExportType = false;
390
+ const exportClauseStart = cursor;
245
391
 
246
392
  while (cursor < source.length) {
247
393
  cursor = skipWhitespaceAndComments(source, cursor);
248
394
 
395
+ if (isKeywordAt(source, cursor, "type")) {
396
+ isExportType = true;
397
+ cursor = cursorAfterKeyword("type", cursor);
398
+ continue;
399
+ }
400
+
249
401
  if (source[cursor] === ";" || source[cursor] === "\n") {
250
402
  return { end: cursor + 1 };
251
403
  }
@@ -259,6 +411,9 @@ function parseExportSpecifierAt(
259
411
  if (!stringLiteral) return { end: fromCursor };
260
412
  return {
261
413
  specifier: stringLiteral.value,
414
+ kind: isExportType
415
+ ? "type"
416
+ : inferImportClauseKind(source.slice(exportClauseStart, cursor)),
262
417
  end: stringLiteral.end,
263
418
  };
264
419
  }
@@ -269,6 +424,14 @@ function parseExportSpecifierAt(
269
424
  return undefined;
270
425
  }
271
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
+
272
435
  function cursorAfterKeyword(keyword: string, index: number): number {
273
436
  return index + keyword.length;
274
437
  }
@@ -412,17 +575,24 @@ function isIdentifierChar(char: string | undefined): boolean {
412
575
  function resolveImport(
413
576
  importPath: string,
414
577
  importerFile: string,
578
+ files: Set<string>,
415
579
  ): Pick<ImportReference, "packageName" | "resolvedPath"> {
416
580
  if (importPath.startsWith("@/")) {
417
581
  return {
418
- resolvedPath: stripKnownExtension(importPath.slice(2)),
582
+ resolvedPath: resolveLocalImportPath(
583
+ stripKnownExtension(importPath.slice(2)),
584
+ files,
585
+ ),
419
586
  };
420
587
  }
421
588
 
422
589
  if (importPath.startsWith(".")) {
423
590
  return {
424
- resolvedPath: stripKnownExtension(
425
- normalizePath(path.join(path.dirname(importerFile), importPath)),
591
+ resolvedPath: resolveLocalImportPath(
592
+ stripKnownExtension(
593
+ normalizePath(path.join(path.dirname(importerFile), importPath)),
594
+ ),
595
+ files,
426
596
  ),
427
597
  };
428
598
  }
@@ -432,6 +602,33 @@ function resolveImport(
432
602
  };
433
603
  }
434
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
+
435
632
  function packageName(importPath: string): string {
436
633
  if (!importPath.startsWith("@")) return importPath.split("/")[0];
437
634
 
@@ -449,9 +646,24 @@ function lintImport(
449
646
  reference: ImportReference,
450
647
  config: ResolvedBeignetConfig,
451
648
  ): LintDiagnostic | undefined {
649
+ if (reference.kind === "type") return undefined;
650
+
452
651
  const targetLayer = reference.resolvedPath
453
652
  ? classifyPath(reference.resolvedPath, config)
454
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
+
455
667
  const appImport = reference.resolvedPath
456
668
  ? appImportViolation(sourceLayer, targetLayer)
457
669
  : undefined;
@@ -459,23 +671,24 @@ function lintImport(
459
671
  if (appImport) {
460
672
  return {
461
673
  severity: "error",
462
- code: "CK_IMPORT_DIRECTION",
674
+ code: "BEIGNET_IMPORT_DIRECTION",
463
675
  file,
464
676
  importPath: reference.importPath,
465
677
  message: appImportMessage(sourceLayer, reference.importPath, appImport),
466
678
  };
467
679
  }
468
680
 
469
- if (
470
- reference.packageName &&
471
- packageImportViolation(sourceLayer, reference.packageName)
472
- ) {
681
+ const bannedPackage = reference.packageName
682
+ ? packageImportViolation(sourceLayer, reference.importPath)
683
+ : undefined;
684
+
685
+ if (bannedPackage) {
473
686
  return {
474
687
  severity: "error",
475
- code: "CK_IMPORT_DIRECTION",
688
+ code: "BEIGNET_IMPORT_DIRECTION",
476
689
  file,
477
690
  importPath: reference.importPath,
478
- message: packageImportMessage(sourceLayer, reference.packageName),
691
+ message: packageImportMessage(sourceLayer, bannedPackage),
479
692
  };
480
693
  }
481
694
 
@@ -504,13 +717,14 @@ function appImportViolation(
504
717
  "route",
505
718
  "server",
506
719
  "use-case",
720
+ "workflow",
507
721
  ].includes(targetLayer)
508
722
  ) {
509
723
  return targetLayer;
510
724
  }
511
725
 
512
726
  if (
513
- sourceLayer === "use-case" &&
727
+ (sourceLayer === "use-case" || sourceLayer === "workflow") &&
514
728
  ["app", "client", "component", "infra", "route", "server"].includes(
515
729
  targetLayer,
516
730
  )
@@ -520,23 +734,38 @@ function appImportViolation(
520
734
 
521
735
  if (
522
736
  ["feature-port", "policy", "port"].includes(sourceLayer) &&
523
- ["app", "client", "component", "infra", "route", "server"].includes(
524
- targetLayer,
525
- )
737
+ [
738
+ "app",
739
+ "client",
740
+ "component",
741
+ "infra",
742
+ "route",
743
+ "server",
744
+ "workflow",
745
+ ].includes(targetLayer)
526
746
  ) {
527
747
  return targetLayer;
528
748
  }
529
749
 
530
750
  if (
531
751
  sourceLayer === "contract" &&
532
- ["app", "client", "component", "infra", "route"].includes(targetLayer)
752
+ ["app", "client", "component", "infra", "route", "workflow"].includes(
753
+ targetLayer,
754
+ )
533
755
  ) {
534
756
  return targetLayer;
535
757
  }
536
758
 
537
759
  if (
538
760
  sourceLayer === "route" &&
539
- ["app", "client", "component", "infra"].includes(targetLayer)
761
+ ["app", "client", "component", "infra", "workflow"].includes(targetLayer)
762
+ ) {
763
+ return targetLayer;
764
+ }
765
+
766
+ if (
767
+ sourceLayer === "infra" &&
768
+ ["app", "client", "component", "route", "server"].includes(targetLayer)
540
769
  ) {
541
770
  return targetLayer;
542
771
  }
@@ -546,35 +775,265 @@ function appImportViolation(
546
775
 
547
776
  function packageImportViolation(
548
777
  sourceLayer: SourceLayer,
549
- packageName: string,
550
- ): boolean {
778
+ importPath: string,
779
+ ): string | undefined {
551
780
  if (sourceLayer === "domain") {
552
781
  return (
553
- domainBannedPackages.has(packageName) ||
554
- packageName.startsWith("@beignet/provider-")
782
+ matchBannedPackage(domainBannedPackages, importPath) ??
783
+ matchBeignetProviderPackage(importPath)
555
784
  );
556
785
  }
557
786
 
558
787
  if (
559
- ["contract", "feature-port", "policy", "port", "use-case"].includes(
560
- sourceLayer,
561
- )
788
+ [
789
+ "contract",
790
+ "feature-port",
791
+ "policy",
792
+ "port",
793
+ "use-case",
794
+ "workflow",
795
+ ].includes(sourceLayer)
562
796
  ) {
563
797
  return (
564
- coreBannedPackages.has(packageName) ||
565
- serverRuntimePackages.has(packageName) ||
566
- packageName.startsWith("@beignet/provider-")
798
+ matchBannedPackage(coreBannedPackages, importPath) ??
799
+ matchBannedPackage(serverRuntimePackages, importPath) ??
800
+ matchBeignetProviderPackage(importPath)
567
801
  );
568
802
  }
569
803
 
570
804
  if (sourceLayer === "route") {
571
805
  return (
572
- coreBannedPackages.has(packageName) ||
573
- 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
+ }),
574
862
  );
575
863
  }
576
864
 
577
- 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.`;
578
1037
  }
579
1038
 
580
1039
  const domainBannedPackages = new Set([
@@ -716,6 +1175,18 @@ function classifyPath(
716
1175
  if (layerSegment === "ports") return "feature-port";
717
1176
  if (layerSegment === "policy") return "policy";
718
1177
  if (layerSegment === "use-cases") return "use-case";
1178
+ if (
1179
+ [
1180
+ "jobs",
1181
+ "listeners",
1182
+ "notifications",
1183
+ "schedules",
1184
+ "tasks",
1185
+ "uploads",
1186
+ ].includes(layerSegment)
1187
+ ) {
1188
+ return "workflow";
1189
+ }
719
1190
  if (layerSegment === "components") return "component";
720
1191
  }
721
1192
 
@@ -756,6 +1227,23 @@ function featureRelativePath(
756
1227
  return filePath.slice(prefix.length);
757
1228
  }
758
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
+
759
1247
  function sharedDomainPath(config: ResolvedBeignetConfig): string {
760
1248
  const featuresDir = directoryPath(config.paths.features);
761
1249
  const featuresParent = directoryPath(path.dirname(featuresDir));