@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/dist/lint.js CHANGED
@@ -1,30 +1,41 @@
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 { canonicalFeatureLayerPath, classifySourcePath as classifyPath, } from "./analysis/layers.js";
5
+ import { moduleReferences, } from "./analysis/source-index.js";
6
+ import { createAnalysisWorkspace, resolveLocalModulePath, } from "./analysis/workspace.js";
4
7
  import { createPainter } from "./ansi.js";
5
- import { clientDirPath, directoryPath, loadBeignetConfig, normalizePath, resolveConfig, } from "./config.js";
8
+ import { clientDirPath, directoryPath, normalizePath, } from "./config.js";
6
9
  import { formatGithubAnnotation } from "./github-annotations.js";
7
10
  /**
8
11
  * Inspect app imports and report dependency-direction violations.
9
12
  */
10
13
  export async function lintApp(options = {}) {
11
- const targetDir = path.resolve(options.cwd ?? process.cwd());
12
- await assertDirectory(targetDir);
13
- const files = await listFiles(targetDir);
14
- const config = options.config
15
- ? resolveConfig(options.config)
16
- : await loadBeignetConfig(targetDir, files);
14
+ const workspace = await createAnalysisWorkspace(options.cwd, {
15
+ config: options.config,
16
+ });
17
+ const { config, targetDir } = workspace;
17
18
  const diagnostics = [];
18
- const sourceFiles = lintSourceFiles(files);
19
- const sourceFileSet = new Set(sourceFiles);
20
- const atAliasRoot = resolveAtAliasRoot(targetDir);
21
- const sourceByFile = new Map();
19
+ const sourceFiles = lintSourceFiles(workspace.files);
20
+ const sourceFileByPath = new Map();
22
21
  const importGraph = new Map();
23
22
  for (const file of sourceFiles) {
24
23
  const sourceLayer = classifyPath(file, config);
25
- const source = await readFile(path.join(targetDir, file), "utf8");
26
- sourceByFile.set(file, source);
27
- importGraph.set(file, parseImports(source, file, sourceFileSet, atAliasRoot));
24
+ const sourceFile = await workspace.readSourceFile(file);
25
+ sourceFileByPath.set(file, sourceFile);
26
+ importGraph.set(file, await parseImports(workspace, file));
27
+ const canonicalLayerPath = canonicalFeatureLayerPath(file, config);
28
+ if (canonicalLayerPath) {
29
+ diagnostics.push({
30
+ severity: "error",
31
+ code: "BEIGNET_CANONICAL_STRUCTURE",
32
+ file,
33
+ importPath: canonicalLayerPath,
34
+ line: 1,
35
+ column: 1,
36
+ message: `feature files that belong to a canonical layer must use ${canonicalLayerPath}.`,
37
+ });
38
+ }
28
39
  if (sourceLayer === "test" || sourceLayer === "unknown")
29
40
  continue;
30
41
  if (isLegacySharedDomainPath(file, config)) {
@@ -39,21 +50,28 @@ export async function lintApp(options = {}) {
39
50
  message: `root shared-domain files are legacy. Move shared-kernel domain concepts to ${canonicalSharedDomain}.`,
40
51
  });
41
52
  }
42
- const lineStarts = computeLineStarts(source);
43
53
  for (const reference of importGraph.get(file) ?? []) {
44
- const diagnostic = lintImport(file, sourceLayer, reference, config);
54
+ const diagnostic = lintUnresolvedModuleReference(file, sourceLayer, reference) ??
55
+ (sourceLayer === "schema"
56
+ ? undefined
57
+ : lintImport(file, sourceLayer, reference, config));
45
58
  if (diagnostic) {
46
59
  diagnostics.push({
47
60
  ...diagnostic,
48
- ...positionFromOffset(lineStarts, reference.offset),
61
+ line: reference.line,
62
+ column: reference.column,
49
63
  });
50
64
  }
51
65
  }
52
66
  }
67
+ diagnostics.push(...lintTransitiveBoundaries({
68
+ config,
69
+ graph: importGraph,
70
+ }));
53
71
  diagnostics.push(...lintRuntimeBoundary({
54
72
  config,
55
73
  graph: importGraph,
56
- sourceByFile,
74
+ sourceFileByPath,
57
75
  }));
58
76
  return {
59
77
  schemaVersion: 1,
@@ -62,29 +80,6 @@ export async function lintApp(options = {}) {
62
80
  diagnostics: dedupeDiagnostics(diagnostics),
63
81
  };
64
82
  }
65
- function computeLineStarts(source) {
66
- const lineStarts = [0];
67
- for (let index = 0; index < source.length; index++) {
68
- if (source[index] === "\n") {
69
- lineStarts.push(index + 1);
70
- }
71
- }
72
- return lineStarts;
73
- }
74
- function positionFromOffset(lineStarts, offset) {
75
- let low = 0;
76
- let high = lineStarts.length - 1;
77
- while (low < high) {
78
- const middle = Math.ceil((low + high) / 2);
79
- if (lineStarts[middle] <= offset) {
80
- low = middle;
81
- }
82
- else {
83
- high = middle - 1;
84
- }
85
- }
86
- return { line: low + 1, column: offset - lineStarts[low] + 1 };
87
- }
88
83
  /**
89
84
  * Format dependency-direction lint diagnostics for CLI output.
90
85
  */
@@ -120,352 +115,27 @@ export function formatLintGithub(result) {
120
115
  }))
121
116
  .join("\n");
122
117
  }
123
- async function assertDirectory(targetDir) {
124
- try {
125
- const stats = await stat(targetDir);
126
- if (!stats.isDirectory()) {
127
- throw new Error(`${targetDir} is not a directory.`);
128
- }
129
- }
130
- catch (error) {
131
- if (error instanceof Error && error.message.includes("not a directory")) {
132
- throw error;
133
- }
134
- throw new Error(`Directory ${targetDir} does not exist.`);
135
- }
136
- }
137
- async function listFiles(targetDir) {
138
- const files = [];
139
- async function visit(relativeDir) {
140
- const absoluteDir = path.join(targetDir, relativeDir);
141
- const entries = await readdir(absoluteDir, { withFileTypes: true });
142
- for (const entry of entries) {
143
- if (ignoredDirectoryNames.has(entry.name))
144
- continue;
145
- const relativePath = normalizePath(path.join(relativeDir, entry.name));
146
- if (entry.isDirectory()) {
147
- await visit(relativePath);
148
- }
149
- else if (entry.isFile()) {
150
- files.push(relativePath);
151
- }
152
- }
153
- }
154
- await visit("");
155
- return files.sort();
156
- }
157
- const ignoredDirectoryNames = new Set([
158
- ".git",
159
- ".next",
160
- ".turbo",
161
- "coverage",
162
- "dist",
163
- "node_modules",
164
- ]);
165
118
  function lintSourceFiles(files) {
166
119
  return files.filter((file) => {
167
- if (file.endsWith(".d.ts"))
120
+ if (/\.d\.(c|m)?ts$/.test(file))
168
121
  return false;
169
122
  return /\.(c|m)?tsx?$/.test(file);
170
123
  });
171
124
  }
172
- function parseImports(source, importerFile, files, atAliasRoot) {
173
- const references = parseImportSpecifiers(source);
174
- return references.map((reference) => ({
175
- ...reference,
176
- ...resolveImport(reference.importPath, importerFile, files, atAliasRoot),
177
- }));
178
- }
179
- function resolveAtAliasRoot(targetDir) {
180
- const tsconfigPath = path.join(targetDir, "tsconfig.json");
181
- const parsed = ts.getParsedCommandLineOfConfigFile(tsconfigPath, {}, {
182
- ...ts.sys,
183
- onUnRecoverableConfigFileDiagnostic: () => { },
184
- });
185
- const aliasTarget = parsed?.options.paths?.["@/*"]?.[0];
186
- if (!aliasTarget)
187
- return "";
188
- const targetWithoutWildcard = aliasTarget.replace(/[/\\]?\*$/, "");
189
- const baseUrl = parsed.options.baseUrl ?? targetDir;
190
- const absoluteTarget = path.resolve(baseUrl, targetWithoutWildcard);
191
- const relativeTarget = normalizePath(path.relative(targetDir, absoluteTarget));
192
- if (relativeTarget === "" || relativeTarget === ".")
193
- return "";
194
- if (relativeTarget === ".." || relativeTarget.startsWith("../"))
195
- return "";
196
- return directoryPath(relativeTarget);
197
- }
198
- function parseImportSpecifiers(source) {
199
- const specifiers = [];
200
- let index = 0;
201
- while (index < source.length) {
202
- index = skipNonCode(source, index);
203
- if (isKeywordAt(source, index, "import")) {
204
- const parsed = parseImportSpecifierAt(source, index + "import".length);
205
- if (parsed?.specifier) {
206
- specifiers.push({
207
- importPath: parsed.specifier,
208
- kind: parsed.kind ?? "value",
209
- offset: index,
210
- });
211
- }
212
- index = parsed?.end ?? index + "import".length;
213
- continue;
214
- }
215
- if (isKeywordAt(source, index, "export")) {
216
- const parsed = parseExportSpecifierAt(source, index + "export".length);
217
- if (parsed?.specifier) {
218
- specifiers.push({
219
- importPath: parsed.specifier,
220
- kind: parsed.kind ?? "value",
221
- offset: index,
222
- });
223
- }
224
- index = parsed?.end ?? index + "export".length;
225
- continue;
226
- }
227
- index++;
228
- }
229
- return specifiers;
230
- }
231
- function parseImportSpecifierAt(source, index) {
232
- let cursor = skipWhitespaceAndComments(source, index);
233
- if (source[cursor] === "(") {
234
- cursor = skipWhitespaceAndComments(source, cursor + 1);
235
- const stringLiteral = readStringLiteral(source, cursor);
236
- if (!stringLiteral)
237
- return { end: cursor };
125
+ async function parseImports(workspace, file) {
126
+ return (await moduleReferences(workspace, file)).map(({ file: _file, resolvedFile, ...reference }) => {
127
+ const resolvedPath = reference.staticallyResolvable
128
+ ? (resolvedFile ??
129
+ resolveLocalModulePath(workspace, file, reference.importPath))
130
+ : undefined;
238
131
  return {
239
- specifier: stringLiteral.value,
240
- end: stringLiteral.end,
132
+ ...reference,
133
+ ...(resolvedPath ? { resolvedPath } : {}),
134
+ ...(!resolvedPath && reference.staticallyResolvable
135
+ ? { packageName: packageName(reference.importPath) }
136
+ : {}),
241
137
  };
242
- }
243
- const sideEffectImport = readStringLiteral(source, cursor);
244
- if (sideEffectImport) {
245
- return {
246
- specifier: sideEffectImport.value,
247
- kind: "value",
248
- end: sideEffectImport.end,
249
- };
250
- }
251
- const importClauseStart = cursor;
252
- let isImportType = false;
253
- if (isKeywordAt(source, cursor, "type")) {
254
- isImportType = true;
255
- cursor = cursorAfterKeyword("type", cursor);
256
- }
257
- while (cursor < source.length) {
258
- cursor = skipWhitespaceAndComments(source, cursor);
259
- if (source[cursor] === ";" || source[cursor] === "\n") {
260
- return { end: cursor + 1 };
261
- }
262
- if (isKeywordAt(source, cursor, "from")) {
263
- const fromCursor = skipWhitespaceAndComments(source, cursorAfterKeyword("from", cursor));
264
- const stringLiteral = readStringLiteral(source, fromCursor);
265
- if (!stringLiteral)
266
- return { end: fromCursor };
267
- return {
268
- specifier: stringLiteral.value,
269
- kind: isImportType
270
- ? "type"
271
- : inferImportClauseKind(source.slice(importClauseStart, cursor)),
272
- end: stringLiteral.end,
273
- };
274
- }
275
- cursor = skipToken(source, cursor);
276
- }
277
- return undefined;
278
- }
279
- function parseExportSpecifierAt(source, index) {
280
- let cursor = index;
281
- let isExportType = false;
282
- const exportClauseStart = cursor;
283
- while (cursor < source.length) {
284
- cursor = skipWhitespaceAndComments(source, cursor);
285
- if (isKeywordAt(source, cursor, "type")) {
286
- isExportType = true;
287
- cursor = cursorAfterKeyword("type", cursor);
288
- continue;
289
- }
290
- if (source[cursor] === ";" || source[cursor] === "\n") {
291
- return { end: cursor + 1 };
292
- }
293
- if (isKeywordAt(source, cursor, "from")) {
294
- const fromCursor = skipWhitespaceAndComments(source, cursorAfterKeyword("from", cursor));
295
- const stringLiteral = readStringLiteral(source, fromCursor);
296
- if (!stringLiteral)
297
- return { end: fromCursor };
298
- return {
299
- specifier: stringLiteral.value,
300
- kind: isExportType
301
- ? "type"
302
- : inferImportClauseKind(source.slice(exportClauseStart, cursor)),
303
- end: stringLiteral.end,
304
- };
305
- }
306
- cursor = skipToken(source, cursor);
307
- }
308
- return undefined;
309
- }
310
- function inferImportClauseKind(importClause) {
311
- const trimmed = importClause.trim();
312
- if (/^\{\s*type\s+[^,}]+(?:,\s*type\s+[^,}]+)*,?\s*\}$/.test(trimmed)) {
313
- return "type";
314
- }
315
- return "value";
316
- }
317
- function cursorAfterKeyword(keyword, index) {
318
- return index + keyword.length;
319
- }
320
- function skipNonCode(source, index) {
321
- let cursor = index;
322
- while (cursor < source.length) {
323
- if (source.startsWith("//", cursor)) {
324
- cursor = skipLineComment(source, cursor);
325
- continue;
326
- }
327
- if (source.startsWith("/*", cursor)) {
328
- cursor = skipBlockComment(source, cursor);
329
- continue;
330
- }
331
- if (source[cursor] === '"' || source[cursor] === "'") {
332
- cursor = skipStringLiteral(source, cursor);
333
- continue;
334
- }
335
- if (source[cursor] === "`") {
336
- cursor = skipTemplateLiteral(source, cursor);
337
- continue;
338
- }
339
- break;
340
- }
341
- return cursor;
342
- }
343
- function skipWhitespaceAndComments(source, index) {
344
- let cursor = index;
345
- while (cursor < source.length) {
346
- if (/\s/.test(source[cursor])) {
347
- cursor++;
348
- continue;
349
- }
350
- if (source.startsWith("//", cursor)) {
351
- cursor = skipLineComment(source, cursor);
352
- continue;
353
- }
354
- if (source.startsWith("/*", cursor)) {
355
- cursor = skipBlockComment(source, cursor);
356
- continue;
357
- }
358
- break;
359
- }
360
- return cursor;
361
- }
362
- function skipToken(source, index) {
363
- if (source[index] === '"' || source[index] === "'") {
364
- return skipStringLiteral(source, index);
365
- }
366
- if (source[index] === "`") {
367
- return skipTemplateLiteral(source, index);
368
- }
369
- return index + 1;
370
- }
371
- function readStringLiteral(source, index) {
372
- const quote = source[index];
373
- if (quote !== '"' && quote !== "'")
374
- return undefined;
375
- let value = "";
376
- let escaped = false;
377
- for (let cursor = index + 1; cursor < source.length; cursor++) {
378
- const char = source[cursor];
379
- if (escaped) {
380
- value += char;
381
- escaped = false;
382
- continue;
383
- }
384
- if (char === "\\") {
385
- escaped = true;
386
- continue;
387
- }
388
- if (char === quote) {
389
- return { value, end: cursor + 1 };
390
- }
391
- value += char;
392
- }
393
- return undefined;
394
- }
395
- function skipStringLiteral(source, index) {
396
- return readStringLiteral(source, index)?.end ?? source.length;
397
- }
398
- function skipTemplateLiteral(source, index) {
399
- let escaped = false;
400
- for (let cursor = index + 1; cursor < source.length; cursor++) {
401
- const char = source[cursor];
402
- if (escaped) {
403
- escaped = false;
404
- continue;
405
- }
406
- if (char === "\\") {
407
- escaped = true;
408
- continue;
409
- }
410
- if (char === "`") {
411
- return cursor + 1;
412
- }
413
- }
414
- return source.length;
415
- }
416
- function skipLineComment(source, index) {
417
- const end = source.indexOf("\n", index + 2);
418
- return end === -1 ? source.length : end + 1;
419
- }
420
- function skipBlockComment(source, index) {
421
- const end = source.indexOf("*/", index + 2);
422
- return end === -1 ? source.length : end + 2;
423
- }
424
- function isKeywordAt(source, index, keyword) {
425
- if (!source.startsWith(keyword, index))
426
- return false;
427
- const before = source[index - 1];
428
- const after = source[index + keyword.length];
429
- return !isIdentifierChar(before) && !isIdentifierChar(after);
430
- }
431
- function isIdentifierChar(char) {
432
- return Boolean(char && /[A-Za-z0-9_$]/.test(char));
433
- }
434
- function resolveImport(importPath, importerFile, files, atAliasRoot) {
435
- if (importPath.startsWith("@/")) {
436
- return {
437
- resolvedPath: resolveLocalImportPath(stripKnownExtension(path.posix.join(atAliasRoot, importPath.slice(2))), files),
438
- };
439
- }
440
- if (importPath.startsWith(".")) {
441
- return {
442
- resolvedPath: resolveLocalImportPath(stripKnownExtension(normalizePath(path.join(path.dirname(importerFile), importPath))), files),
443
- };
444
- }
445
- return {
446
- packageName: packageName(importPath),
447
- };
448
- }
449
- function resolveLocalImportPath(importPath, files) {
450
- const candidates = [
451
- importPath,
452
- `${importPath}.ts`,
453
- `${importPath}.tsx`,
454
- `${importPath}.mts`,
455
- `${importPath}.cts`,
456
- `${importPath}/index.ts`,
457
- `${importPath}/index.tsx`,
458
- `${importPath}/index.mts`,
459
- `${importPath}/index.cts`,
460
- ];
461
- for (const candidate of candidates) {
462
- for (const file of files) {
463
- if (stripKnownExtension(file) === stripKnownExtension(candidate)) {
464
- return file;
465
- }
466
- }
467
- }
468
- return stripKnownExtension(importPath);
138
+ });
469
139
  }
470
140
  function packageName(importPath) {
471
141
  if (!importPath.startsWith("@"))
@@ -520,6 +190,34 @@ function lintImport(file, sourceLayer, reference, config) {
520
190
  }
521
191
  return undefined;
522
192
  }
193
+ function lintUnresolvedModuleReference(file, sourceLayer, reference) {
194
+ if (reference.staticallyResolvable ||
195
+ ![
196
+ "agent-capability",
197
+ "domain",
198
+ "feature-helper",
199
+ "feature-port",
200
+ "infra",
201
+ "policy",
202
+ "port",
203
+ "route",
204
+ "seed",
205
+ "use-case",
206
+ "workflow",
207
+ ].includes(sourceLayer)) {
208
+ return undefined;
209
+ }
210
+ return {
211
+ severity: "error",
212
+ code: "BEIGNET_MODULE_REFERENCE_UNRESOLVED",
213
+ file,
214
+ importPath: reference.importPath,
215
+ message: `${layerLabel(sourceLayer)} files must use a string literal for ${moduleSyntaxLabel(reference.syntax)} so Beignet can verify the dependency boundary (${reference.importPath}).`,
216
+ };
217
+ }
218
+ function moduleSyntaxLabel(syntax) {
219
+ return syntax === "require" ? "require()" : "dynamic import()";
220
+ }
523
221
  function appImportViolation(sourceLayer, targetLayer) {
524
222
  if (!targetLayer)
525
223
  return undefined;
@@ -527,28 +225,89 @@ function appImportViolation(sourceLayer, targetLayer) {
527
225
  [
528
226
  "app",
529
227
  "app-context",
228
+ "agent-capability",
530
229
  "client",
531
230
  "component",
532
231
  "contract",
232
+ "feature-helper",
533
233
  "feature-port",
534
234
  "infra",
535
235
  "lib",
536
236
  "policy",
537
237
  "port",
538
238
  "route",
239
+ "schema",
240
+ "seed",
241
+ "server",
242
+ "use-case",
243
+ "workflow",
244
+ ].includes(targetLayer)) {
245
+ return targetLayer;
246
+ }
247
+ if (sourceLayer === "agent-capability" &&
248
+ [
249
+ "agent-capability",
250
+ "app",
251
+ "app-context",
252
+ "client",
253
+ "component",
254
+ "infra",
255
+ "route",
256
+ "seed",
257
+ "server",
258
+ "workflow",
259
+ ].includes(targetLayer)) {
260
+ return targetLayer;
261
+ }
262
+ if (sourceLayer === "feature-helper" &&
263
+ [
264
+ "agent-capability",
265
+ "app",
266
+ "app-context",
267
+ "client",
268
+ "component",
269
+ "infra",
270
+ "route",
271
+ "seed",
539
272
  "server",
540
273
  "use-case",
541
274
  "workflow",
542
275
  ].includes(targetLayer)) {
543
276
  return targetLayer;
544
277
  }
545
- if ((sourceLayer === "use-case" || sourceLayer === "workflow") &&
546
- ["app", "client", "component", "infra", "route", "server"].includes(targetLayer)) {
278
+ if (["use-case", "workflow"].includes(sourceLayer) &&
279
+ [
280
+ "agent-capability",
281
+ "app",
282
+ "app-context",
283
+ "client",
284
+ "component",
285
+ "infra",
286
+ "route",
287
+ "seed",
288
+ "server",
289
+ ].includes(targetLayer)) {
290
+ return targetLayer;
291
+ }
292
+ if (sourceLayer === "seed" &&
293
+ [
294
+ "agent-capability",
295
+ "app",
296
+ "app-context",
297
+ "client",
298
+ "component",
299
+ "infra",
300
+ "route",
301
+ "server",
302
+ "use-case",
303
+ "workflow",
304
+ ].includes(targetLayer)) {
547
305
  return targetLayer;
548
306
  }
549
307
  if (["feature-port", "policy", "port"].includes(sourceLayer) &&
550
308
  [
551
309
  "app",
310
+ "agent-capability",
552
311
  "client",
553
312
  "component",
554
313
  "infra",
@@ -559,11 +318,46 @@ function appImportViolation(sourceLayer, targetLayer) {
559
318
  return targetLayer;
560
319
  }
561
320
  if (sourceLayer === "contract" &&
562
- ["app", "client", "component", "infra", "route", "workflow"].includes(targetLayer)) {
321
+ [
322
+ "agent-capability",
323
+ "app",
324
+ "client",
325
+ "component",
326
+ "infra",
327
+ "route",
328
+ "seed",
329
+ "workflow",
330
+ ].includes(targetLayer)) {
563
331
  return targetLayer;
564
332
  }
565
333
  if (sourceLayer === "route" &&
566
- ["app", "client", "component", "infra", "workflow"].includes(targetLayer)) {
334
+ [
335
+ "agent-capability",
336
+ "app",
337
+ "app-context",
338
+ "client",
339
+ "component",
340
+ "infra",
341
+ "seed",
342
+ "server",
343
+ "workflow",
344
+ ].includes(targetLayer)) {
345
+ return targetLayer;
346
+ }
347
+ if (sourceLayer === "schema" &&
348
+ [
349
+ "agent-capability",
350
+ "app",
351
+ "app-context",
352
+ "client",
353
+ "component",
354
+ "infra",
355
+ "route",
356
+ "seed",
357
+ "server",
358
+ "use-case",
359
+ "workflow",
360
+ ].includes(targetLayer)) {
567
361
  return targetLayer;
568
362
  }
569
363
  if (sourceLayer === "infra" &&
@@ -579,9 +373,13 @@ function packageImportViolation(sourceLayer, importPath) {
579
373
  }
580
374
  if ([
581
375
  "contract",
376
+ "agent-capability",
377
+ "feature-helper",
582
378
  "feature-port",
583
379
  "policy",
584
380
  "port",
381
+ "schema",
382
+ "seed",
585
383
  "use-case",
586
384
  "workflow",
587
385
  ].includes(sourceLayer)) {
@@ -611,10 +409,75 @@ function matchBeignetProviderPackage(importPath) {
611
409
  const name = packageName(importPath);
612
410
  return name.startsWith("@beignet/provider-") ? name : undefined;
613
411
  }
614
- function lintRuntimeBoundary({ config, graph, sourceByFile, }) {
412
+ function lintTransitiveBoundaries({ config, graph, }) {
413
+ const diagnostics = [];
414
+ for (const root of graph.keys()) {
415
+ const rootLayer = classifyPath(root, config);
416
+ if (![
417
+ "agent-capability",
418
+ "domain",
419
+ "feature-helper",
420
+ "feature-port",
421
+ "infra",
422
+ "policy",
423
+ "port",
424
+ "route",
425
+ "seed",
426
+ "use-case",
427
+ "workflow",
428
+ ].includes(rootLayer)) {
429
+ continue;
430
+ }
431
+ diagnostics.push(...lintTransitiveBoundaryRoot({ config, graph, root, rootLayer }));
432
+ }
433
+ return diagnostics;
434
+ }
435
+ function lintTransitiveBoundaryRoot({ config, graph, root, rootLayer, }) {
436
+ const diagnostics = [];
437
+ const visited = new Set();
438
+ const queue = [
439
+ { file: root, chain: [root] },
440
+ ];
441
+ let queueIndex = 0;
442
+ while (queueIndex < queue.length) {
443
+ const current = queue[queueIndex++];
444
+ if (visited.has(current.file))
445
+ continue;
446
+ visited.add(current.file);
447
+ for (const reference of graph.get(current.file) ?? []) {
448
+ if (reference.kind === "type")
449
+ continue;
450
+ const chain = reference.resolvedPath
451
+ ? [...current.chain, reference.resolvedPath]
452
+ : current.chain;
453
+ const violation = current.file === root
454
+ ? undefined
455
+ : (lintUnresolvedModuleReference(root, rootLayer, reference) ??
456
+ lintImport(root, rootLayer, reference, config));
457
+ if (violation) {
458
+ diagnostics.push({
459
+ ...violation,
460
+ file: root,
461
+ message: `${layerLabel(rootLayer)} files must not reach forbidden dependencies through ${formatImportChain(chain)}. ${violation.message}`,
462
+ });
463
+ continue;
464
+ }
465
+ if (reference.resolvedPath &&
466
+ graph.has(reference.resolvedPath) &&
467
+ isTransparentArchitectureLayer(classifyPath(reference.resolvedPath, config))) {
468
+ queue.push({ file: reference.resolvedPath, chain });
469
+ }
470
+ }
471
+ }
472
+ return diagnostics;
473
+ }
474
+ function isTransparentArchitectureLayer(layer) {
475
+ return ["feature-helper", "lib", "unknown"].includes(layer);
476
+ }
477
+ function lintRuntimeBoundary({ config, graph, sourceFileByPath, }) {
615
478
  const diagnostics = [];
616
479
  for (const file of graph.keys()) {
617
- const rootKind = runtimeBoundaryRootKind(file, sourceByFile.get(file) ?? "", config);
480
+ const rootKind = runtimeBoundaryRootKind(file, sourceFileByPath.get(file), config);
618
481
  if (!rootKind)
619
482
  continue;
620
483
  diagnostics.push(...lintRuntimeBoundaryRoot({
@@ -632,10 +495,9 @@ function lintRuntimeBoundaryRoot({ root, rootKind, config, graph, }) {
632
495
  const queue = [
633
496
  { file: root, chain: [root] },
634
497
  ];
635
- while (queue.length > 0) {
636
- const current = queue.shift();
637
- if (!current)
638
- continue;
498
+ let queueIndex = 0;
499
+ while (queueIndex < queue.length) {
500
+ const current = queue[queueIndex++];
639
501
  if (visited.has(current.file))
640
502
  continue;
641
503
  visited.add(current.file);
@@ -647,7 +509,7 @@ function lintRuntimeBoundaryRoot({ root, rootKind, config, graph, }) {
647
509
  const chain = reference.resolvedPath
648
510
  ? [...current.chain, reference.resolvedPath]
649
511
  : current.chain;
650
- const violation = runtimeBoundaryViolation(reference, config);
512
+ const violation = runtimeBoundaryViolation(reference, config, rootKind);
651
513
  if (violation) {
652
514
  diagnostics.push({
653
515
  severity: "error",
@@ -668,36 +530,54 @@ function lintRuntimeBoundaryRoot({ root, rootKind, config, graph, }) {
668
530
  }
669
531
  return diagnostics;
670
532
  }
671
- function runtimeBoundaryRootKind(file, source, config) {
533
+ function runtimeBoundaryRootKind(file, sourceFile, config) {
672
534
  const layer = classifyPath(file, config);
673
535
  if (layer === "contract")
674
536
  return "contract";
537
+ if (layer === "schema")
538
+ return "schema";
675
539
  if (layer === "client")
676
540
  return "client";
677
- if (hasUseClientDirective(source))
541
+ if (sourceFile && hasUseClientDirective(sourceFile))
678
542
  return "client";
679
543
  if (isGeneratedClientAdapter(file, config))
680
544
  return "client";
681
545
  return undefined;
682
546
  }
683
- function hasUseClientDirective(source) {
684
- const trimmed = source.trimStart();
685
- return (trimmed.startsWith('"use client";') ||
686
- trimmed.startsWith('"use client"') ||
687
- trimmed.startsWith("'use client';") ||
688
- trimmed.startsWith("'use client'"));
547
+ function hasUseClientDirective(sourceFile) {
548
+ for (const statement of sourceFile.statements) {
549
+ if (!ts.isExpressionStatement(statement) ||
550
+ !ts.isStringLiteral(statement.expression)) {
551
+ return false;
552
+ }
553
+ if (statement.expression.text === "use client")
554
+ return true;
555
+ }
556
+ return false;
689
557
  }
690
558
  function isGeneratedClientAdapter(file, config) {
691
559
  const clientDir = clientDirPath(config);
692
560
  return ["index.ts", "api-client.ts", "rhf.ts", "rq.ts", "uploads.ts"].some((adapterFile) => file === path.posix.join(clientDir, adapterFile));
693
561
  }
694
- function runtimeBoundaryViolation(reference, config) {
562
+ function runtimeBoundaryViolation(reference, config, rootKind) {
563
+ if (!reference.staticallyResolvable) {
564
+ return `computed ${moduleSyntaxLabel(reference.syntax)} ${reference.importPath}, which cannot be verified statically`;
565
+ }
695
566
  if (reference.resolvedPath) {
696
567
  const targetLayer = classifyPath(reference.resolvedPath, config);
697
- if (targetLayer === "use-case" ||
568
+ const prohibitedLayer = rootKind === "client"
569
+ ? undefined
570
+ : appImportViolation(rootKind, targetLayer);
571
+ if (prohibitedLayer) {
572
+ return `${layerLabel(prohibitedLayer)} module ${reference.resolvedPath}`;
573
+ }
574
+ if (targetLayer === "agent-capability" ||
575
+ targetLayer === "use-case" ||
698
576
  targetLayer === "route" ||
699
577
  targetLayer === "infra" ||
700
- targetLayer === "server") {
578
+ targetLayer === "seed" ||
579
+ targetLayer === "server" ||
580
+ targetLayer === "workflow") {
701
581
  return `${layerLabel(targetLayer)} module ${reference.resolvedPath}`;
702
582
  }
703
583
  if (targetLayer === "app-context") {
@@ -707,20 +587,34 @@ function runtimeBoundaryViolation(reference, config) {
707
587
  if (reference.importPath === "@beignet/core/server-only") {
708
588
  return "server-only marker @beignet/core/server-only";
709
589
  }
710
- if (reference.importPath.startsWith("node:")) {
590
+ if (isNodeRuntimeModule(reference.importPath)) {
711
591
  return `Node runtime module ${reference.importPath}`;
712
592
  }
713
- if (serverRuntimePackages.has(reference.importPath) ||
593
+ if (rootKind !== "client" && reference.packageName) {
594
+ const prohibitedPackage = packageImportViolation(rootKind, reference.importPath);
595
+ if (prohibitedPackage) {
596
+ return `runtime or provider package ${prohibitedPackage}`;
597
+ }
598
+ }
599
+ if (matchBannedPackage(serverRuntimePackages, reference.importPath) ||
714
600
  reference.importPath.startsWith("@beignet/provider-")) {
715
601
  return `server runtime package ${reference.importPath}`;
716
602
  }
717
603
  return undefined;
718
604
  }
605
+ const nodeRuntimeModules = new Set(builtinModules);
606
+ function isNodeRuntimeModule(importPath) {
607
+ return importPath.startsWith("node:") || nodeRuntimeModules.has(importPath);
608
+ }
719
609
  function runtimeBoundaryMessage(rootKind, violation, chain) {
720
610
  return `${rootKindLabel(rootKind)} roots must not value-import server-only code. Reached ${violation} through ${formatImportChain(chain)}. 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.`;
721
611
  }
722
612
  function rootKindLabel(rootKind) {
723
- return rootKind === "contract" ? "contract" : "client";
613
+ if (rootKind === "contract")
614
+ return "contract";
615
+ if (rootKind === "schema")
616
+ return "schema";
617
+ return "client";
724
618
  }
725
619
  function formatImportChain(chain) {
726
620
  return chain.join(" -> ");
@@ -821,85 +715,6 @@ function appImportMessage(sourceLayer, importPath, targetLayer) {
821
715
  function packageImportMessage(sourceLayer, packageName) {
822
716
  return `${layerLabel(sourceLayer)} files must not import runtime or provider package ${packageName}. Depend on Beignet ports or app-owned ports instead.`;
823
717
  }
824
- function classifyPath(filePath, config) {
825
- const normalizedPath = stripKnownExtension(normalizePath(filePath));
826
- const featuresPath = directoryPath(config.paths.features);
827
- const portsDir = directoryPath(path.dirname(config.paths.ports));
828
- if (isTestPath(normalizedPath))
829
- return "test";
830
- if (normalizedPath === stripKnownExtension(config.paths.appContext)) {
831
- return "app-context";
832
- }
833
- if (isUnder(normalizedPath, directoryPath(path.dirname(config.paths.server)))) {
834
- return "server";
835
- }
836
- if (isUnder(normalizedPath, directoryPath(path.dirname(config.paths.routes)))) {
837
- return "app";
838
- }
839
- if (isUnder(normalizedPath, directoryPath(path.dirname(config.paths.portWiring)))) {
840
- return "infra";
841
- }
842
- if (isUnder(normalizedPath, portsDir))
843
- return "port";
844
- if (isUnder(normalizedPath, clientDirPath(config)))
845
- return "client";
846
- if (isUnder(normalizedPath, "components"))
847
- return "component";
848
- if (isUnder(normalizedPath, legacySharedDomainPath(config)))
849
- return "domain";
850
- if (isUnder(normalizedPath, "lib"))
851
- return "lib";
852
- const featurePath = featureRelativePath(normalizedPath, featuresPath);
853
- if (featurePath) {
854
- const layerSegment = featurePath.split("/")[1];
855
- if (layerSegment === "domain")
856
- return "domain";
857
- if (layerSegment === "contracts")
858
- return "contract";
859
- if (layerSegment?.endsWith("-contracts"))
860
- return "contract";
861
- if (layerSegment === "routes")
862
- return "route";
863
- if (layerSegment === "ports")
864
- return "feature-port";
865
- if (layerSegment === "policy")
866
- return "policy";
867
- if (layerSegment === "use-cases")
868
- return "use-case";
869
- if ([
870
- "jobs",
871
- "listeners",
872
- "notifications",
873
- "schedules",
874
- "tasks",
875
- "uploads",
876
- ].includes(layerSegment)) {
877
- return "workflow";
878
- }
879
- if (layerSegment === "components")
880
- return "component";
881
- if (layerSegment === "client")
882
- return "client";
883
- }
884
- if (directoryPath(config.paths.contracts) !== featuresPath &&
885
- isUnder(normalizedPath, directoryPath(config.paths.contracts))) {
886
- return "contract";
887
- }
888
- if (directoryPath(config.paths.useCases) !== featuresPath &&
889
- isUnder(normalizedPath, directoryPath(config.paths.useCases))) {
890
- return "use-case";
891
- }
892
- if (directoryPath(config.paths.policies) !== featuresPath &&
893
- isUnder(normalizedPath, directoryPath(config.paths.policies))) {
894
- return "policy";
895
- }
896
- return "unknown";
897
- }
898
- function isTestPath(filePath) {
899
- return (filePath.includes("/tests/") ||
900
- filePath.endsWith(".test") ||
901
- filePath.endsWith(".spec"));
902
- }
903
718
  function isUnder(filePath, directory) {
904
719
  const normalizedDir = directoryPath(directory);
905
720
  if (!normalizedDir || normalizedDir === ".")
@@ -917,10 +732,10 @@ function featureLayerInfo(filePath, config) {
917
732
  const relativePath = featureRelativePath(normalizedPath, directoryPath(config.paths.features));
918
733
  if (!relativePath)
919
734
  return undefined;
920
- const [feature, layer] = relativePath.split("/");
735
+ const [feature] = relativePath.split("/");
921
736
  if (!feature)
922
737
  return undefined;
923
- return { feature, layer };
738
+ return { feature, layer: classifyPath(filePath, config) };
924
739
  }
925
740
  function legacySharedDomainPath(config) {
926
741
  const featuresDir = directoryPath(config.paths.features);
@@ -943,7 +758,14 @@ function dedupeDiagnostics(diagnostics) {
943
758
  const seen = new Set();
944
759
  const deduped = [];
945
760
  for (const diagnostic of diagnostics) {
946
- const key = [diagnostic.code, diagnostic.file, diagnostic.importPath].join(":");
761
+ const key = JSON.stringify([
762
+ diagnostic.code,
763
+ diagnostic.file,
764
+ diagnostic.importPath,
765
+ diagnostic.line,
766
+ diagnostic.column,
767
+ diagnostic.message,
768
+ ]);
947
769
  if (seen.has(key))
948
770
  continue;
949
771
  seen.add(key);