@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
@@ -1,5 +1,6 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import ts from "typescript";
3
4
  import {
4
5
  type ProviderPresetName,
5
6
  providerPresetChoices,
@@ -164,7 +165,10 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
164
165
  "Add a worker entrypoint that runs createBullMQJobWorker(...) in a worker process; dispatched jobs do not execute without one. See the jobs docs page.",
165
166
  "Run beignet provider audit and beignet doctor --strict.",
166
167
  ],
167
- conflictingProviderEntries: ["createInngestJobsProvider"],
168
+ conflictingProviderEntries: [
169
+ "createInngestJobsProvider",
170
+ "inlineJobsProvider",
171
+ ],
168
172
  },
169
173
  "jobs-inngest": {
170
174
  name: "jobs-inngest",
@@ -215,7 +219,10 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
215
219
  "For non-Next runtimes, call createAppInngestFunctions(...) after creating the app server, then mount the result with Inngest's host adapter.",
216
220
  "Run beignet provider audit and beignet doctor --strict.",
217
221
  ],
218
- conflictingProviderEntries: ["createBullMQJobsProvider"],
222
+ conflictingProviderEntries: [
223
+ "createBullMQJobsProvider",
224
+ "inlineJobsProvider",
225
+ ],
219
226
  },
220
227
  "mail-resend": {
221
228
  name: "mail-resend",
@@ -276,7 +283,14 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
276
283
  importName: "MailerPort",
277
284
  importFrom: "@beignet/core/mail",
278
285
  },
279
- envVars: ["MAIL_HOST", "MAIL_PORT", "MAIL_USER", "MAIL_PASS", "MAIL_FROM"],
286
+ envVars: [
287
+ "MAIL_HOST",
288
+ "MAIL_PORT",
289
+ "MAIL_USER",
290
+ "MAIL_PASS",
291
+ "MAIL_FROM",
292
+ "MAIL_REQUIRE_TLS",
293
+ ],
280
294
  envExample: [
281
295
  "# SMTP mail provider",
282
296
  "MAIL_HOST=smtp.example.com",
@@ -284,6 +298,7 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
284
298
  "MAIL_USER=dev-placeholder-change-me",
285
299
  "MAIL_PASS=dev-placeholder-change-me",
286
300
  "MAIL_FROM=Beignet App <noreply@example.com>",
301
+ "MAIL_REQUIRE_TLS=true",
287
302
  "",
288
303
  ].join("\n"),
289
304
  docs: [
@@ -293,12 +308,13 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
293
308
  "- Peer dependency: `nodemailer`",
294
309
  "- The preset wires `createSmtpMailProvider()` in `server/providers.ts` and defers `mailer` in `infra/port-wiring.ts`.",
295
310
  "- `.env.example` ships dev placeholders so the app boots; set real `MAIL_*` values before sending mail.",
311
+ "- Encrypted transport is required by default: port 465 uses implicit TLS and other ports must negotiate STARTTLS.",
296
312
  "- Use `ctx.ports.mailer` in app code; `ctx.ports.smtp` is available as an escape hatch.",
297
313
  "",
298
314
  ].join("\n"),
299
315
  nextSteps: [
300
316
  "Run your package manager install command.",
301
- "Set MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, and MAIL_FROM in .env.local or your deployment environment.",
317
+ "Set MAIL_HOST, MAIL_PORT, MAIL_USER, MAIL_PASS, and MAIL_FROM in .env.local or your deployment environment; keep MAIL_REQUIRE_TLS=true outside trusted local capture services.",
302
318
  "Run beignet provider audit and beignet doctor --strict.",
303
319
  ],
304
320
  conflictingProviderEntries: [
@@ -561,6 +577,7 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
561
577
  dependencies: (packageJson) => ({
562
578
  "@beignet/provider-storage-s3": beignetDependencyVersion(packageJson),
563
579
  "@aws-sdk/client-s3": externalVersions.awsSdkS3,
580
+ "@aws-sdk/lib-storage": externalVersions.awsSdkLibStorage,
564
581
  "@aws-sdk/s3-request-presigner": externalVersions.awsSdkS3,
565
582
  }),
566
583
  appPort: {
@@ -601,7 +618,7 @@ const presets: Record<ProviderPresetName, ProviderPreset> = {
601
618
  "## S3-compatible storage",
602
619
  "",
603
620
  "- Package: `@beignet/provider-storage-s3`",
604
- "- Peer dependencies: `@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner`",
621
+ "- Peer dependencies: `@aws-sdk/client-s3`, `@aws-sdk/lib-storage`, and `@aws-sdk/s3-request-presigner`",
605
622
  "- The preset wires `createS3StorageProvider()` in `server/providers.ts` and defers `storage` in `infra/port-wiring.ts`.",
606
623
  "- Set `STORAGE_S3_BUCKET` before booting the app; set endpoint and credentials when using an S3-compatible host outside AWS defaults.",
607
624
  "- Use `ctx.ports.storage` in app code; `ctx.ports.s3Storage` is available as an escape hatch.",
@@ -1179,9 +1196,13 @@ function updatePackageJson(
1179
1196
  }
1180
1197
 
1181
1198
  function updateServerProviders(source: string, preset: ProviderPreset): string {
1182
- assertNoConflictingProviders(source, preset);
1199
+ const withoutGeneratedFallback =
1200
+ preset.appPort.key === "jobs"
1201
+ ? removeGeneratedInlineJobsProvider(source)
1202
+ : source;
1203
+ assertNoConflictingProviders(withoutGeneratedFallback, preset);
1183
1204
 
1184
- let next = source;
1205
+ let next = withoutGeneratedFallback;
1185
1206
  for (const importLine of preset.additionalImports ?? []) {
1186
1207
  if (!next.includes(importLine)) next = insertAfterImports(next, importLine);
1187
1208
  }
@@ -1205,6 +1226,164 @@ function updateServerProviders(source: string, preset: ProviderPreset): string {
1205
1226
  return result.kind === "updated" ? result.source : next;
1206
1227
  }
1207
1228
 
1229
+ function removeGeneratedInlineJobsProvider(source: string): string {
1230
+ const marker = "// beignet:generated-inline-jobs-provider";
1231
+ const sourceFile = ts.createSourceFile(
1232
+ "providers.ts",
1233
+ source,
1234
+ ts.ScriptTarget.Latest,
1235
+ true,
1236
+ ts.ScriptKind.TS,
1237
+ );
1238
+ const statement = sourceFile.statements.find((candidate) => {
1239
+ if (!ts.isVariableStatement(candidate)) return false;
1240
+ if (candidate.declarationList.declarations.length !== 1) return false;
1241
+ const statementStart = candidate.getStart(sourceFile);
1242
+ const markerStart = source.lastIndexOf(marker, statementStart);
1243
+ if (
1244
+ markerStart < 0 ||
1245
+ source.slice(markerStart + marker.length, statementStart).trim() !== ""
1246
+ ) {
1247
+ return false;
1248
+ }
1249
+ const declaration = candidate.declarationList.declarations.find(
1250
+ (item) =>
1251
+ ts.isIdentifier(item.name) && item.name.text === "inlineJobsProvider",
1252
+ );
1253
+ if (!declaration?.initializer) return false;
1254
+ const text = declaration.initializer.getText(sourceFile);
1255
+ return (
1256
+ /\bcreateProvider\b/.test(text) &&
1257
+ /\bcreateInlineJobDispatcher\b/.test(text) &&
1258
+ /\bname\s*:\s*["']inline-jobs["']/.test(text)
1259
+ );
1260
+ });
1261
+ if (!statement) return source;
1262
+
1263
+ const statementStart = statement.getStart(sourceFile);
1264
+ const markerStart = source.lastIndexOf(marker, statementStart);
1265
+ const removeStart =
1266
+ markerStart >= 0 &&
1267
+ source.slice(markerStart + marker.length, statementStart).trim() === ""
1268
+ ? markerStart
1269
+ : statementStart;
1270
+ let next = `${source.slice(0, removeStart)}${source.slice(statement.end)}`;
1271
+ const registry = arrayInitializerInfo(next, "providers");
1272
+ if (!registry) {
1273
+ throw new Error(
1274
+ "Could not remove the generated inline jobs provider because `export const providers = [...]` is missing.",
1275
+ );
1276
+ }
1277
+ const lines = registry.text.split(/\r?\n/);
1278
+ const entryIndex = lines.findIndex(
1279
+ (line) =>
1280
+ line.trim() === "inlineJobsProvider," ||
1281
+ line.trim() === "inlineJobsProvider",
1282
+ );
1283
+ let nextRegistry: string | undefined;
1284
+ if (entryIndex >= 0) {
1285
+ lines.splice(entryIndex, 1);
1286
+ nextRegistry = lines.join(registry.text.includes("\r\n") ? "\r\n" : "\n");
1287
+ } else if (!registry.text.includes("\n")) {
1288
+ const beforeFollowingEntry = /\binlineJobsProvider\s*,\s*/;
1289
+ const afterPreviousEntry = /,\s*inlineJobsProvider\b/;
1290
+ const onlyEntry = /^\[\s*inlineJobsProvider\s*\]$/;
1291
+ if (onlyEntry.test(registry.text)) nextRegistry = "[]";
1292
+ else if (beforeFollowingEntry.test(registry.text)) {
1293
+ nextRegistry = registry.text.replace(beforeFollowingEntry, "");
1294
+ } else if (afterPreviousEntry.test(registry.text)) {
1295
+ nextRegistry = registry.text.replace(afterPreviousEntry, "");
1296
+ }
1297
+ }
1298
+ if (nextRegistry === undefined) {
1299
+ throw new Error(
1300
+ "Could not remove the generated inline jobs provider from `export const providers = [...]`.",
1301
+ );
1302
+ }
1303
+ next = `${next.slice(0, registry.start)}${nextRegistry}${next.slice(registry.end)}`;
1304
+
1305
+ return removeNamedProviderImport(
1306
+ next,
1307
+ "createInlineJobDispatcher",
1308
+ "@beignet/core/jobs",
1309
+ );
1310
+ }
1311
+
1312
+ function removeNamedProviderImport(
1313
+ source: string,
1314
+ name: string,
1315
+ moduleName: string,
1316
+ ): string {
1317
+ const escapedModule = moduleName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1318
+ const pattern = new RegExp(
1319
+ `import \\{([^}]*)\\} from ["']${escapedModule}["'];`,
1320
+ );
1321
+ const match = pattern.exec(source);
1322
+ if (!match) return source;
1323
+ const names = match[1]
1324
+ .split(",")
1325
+ .map((entry) => entry.trim())
1326
+ .filter(Boolean);
1327
+ if (!names.includes(name)) return source;
1328
+ if (hasIdentifierReferenceOutsideImport(source, name, moduleName)) {
1329
+ return source;
1330
+ }
1331
+ const remaining = names.filter((entry) => entry !== name);
1332
+ if (remaining.length === 0) {
1333
+ const lineBreak = source.startsWith("\r\n", match.index + match[0].length)
1334
+ ? "\r\n"
1335
+ : source[match.index + match[0].length] === "\n"
1336
+ ? "\n"
1337
+ : "";
1338
+ return `${source.slice(0, match.index)}${source.slice(match.index + match[0].length + lineBreak.length)}`;
1339
+ }
1340
+ const indent = match[1].match(/\n([\t ]+)/)?.[1] ?? "\t";
1341
+ const replacement = match[1].includes("\n")
1342
+ ? `import {\n${indent}${remaining.join(`,\n${indent}`)},\n} from "${moduleName}";`
1343
+ : `import { ${remaining.join(", ")} } from "${moduleName}";`;
1344
+ return `${source.slice(0, match.index)}${replacement}${source.slice(match.index + match[0].length)}`;
1345
+ }
1346
+
1347
+ function hasIdentifierReferenceOutsideImport(
1348
+ source: string,
1349
+ name: string,
1350
+ moduleName: string,
1351
+ ): boolean {
1352
+ const sourceFile = ts.createSourceFile(
1353
+ "providers.ts",
1354
+ source,
1355
+ ts.ScriptTarget.Latest,
1356
+ true,
1357
+ ts.ScriptKind.TS,
1358
+ );
1359
+ const importDeclaration = sourceFile.statements.find(
1360
+ (statement): statement is ts.ImportDeclaration =>
1361
+ ts.isImportDeclaration(statement) &&
1362
+ ts.isStringLiteral(statement.moduleSpecifier) &&
1363
+ statement.moduleSpecifier.text === moduleName &&
1364
+ Boolean(
1365
+ statement.importClause?.namedBindings &&
1366
+ ts.isNamedImports(statement.importClause.namedBindings) &&
1367
+ statement.importClause.namedBindings.elements.some(
1368
+ (element) => !element.propertyName && element.name.text === name,
1369
+ ),
1370
+ ),
1371
+ );
1372
+ if (!importDeclaration) return true;
1373
+
1374
+ let referenced = false;
1375
+ const visit = (node: ts.Node): void => {
1376
+ if (node === importDeclaration || referenced) return;
1377
+ if (ts.isIdentifier(node) && node.text === name) {
1378
+ referenced = true;
1379
+ return;
1380
+ }
1381
+ ts.forEachChild(node, visit);
1382
+ };
1383
+ ts.forEachChild(sourceFile, visit);
1384
+ return referenced;
1385
+ }
1386
+
1208
1387
  function assertNoConflictingProviders(
1209
1388
  source: string,
1210
1389
  preset: ProviderPreset,
@@ -308,24 +308,324 @@ export function outboxRegistryValueInfo(
308
308
  source: string,
309
309
  property: "events" | "jobs",
310
310
  ): InitializerInfo | undefined {
311
- const call = /\bdefineOutboxRegistry\s*(?:<[^>]*>\s*)?\(/.exec(source);
312
- if (!call) return undefined;
311
+ const match = outboxRegistryPropertyAnalysis(source, property);
312
+ if (!match?.value || match.indeterminate) return undefined;
313
+ const start = match.value.getStart(match.sourceFile);
314
+ return {
315
+ text: source.slice(start, match.value.end),
316
+ start,
317
+ end: match.value.end,
318
+ };
319
+ }
320
+
321
+ export type OutboxRegistryEntryState =
322
+ | "absent"
323
+ | "empty"
324
+ | "present"
325
+ | "indeterminate";
326
+
327
+ /** Static state of one outbox registry property. */
328
+ export function outboxRegistryEntryState(
329
+ source: string,
330
+ property: "events" | "jobs",
331
+ ): OutboxRegistryEntryState {
332
+ const match = outboxRegistryPropertyAnalysis(source, property);
333
+ if (!match) return "absent";
334
+ if (match.indeterminate) return "indeterminate";
335
+ if (!match.value) return "empty";
336
+ return resolvedRegistryExpressions(match.value, match.sourceFile).length > 0
337
+ ? "present"
338
+ : "empty";
339
+ }
313
340
 
314
- const openBrace = source.indexOf("{", call.index);
315
- if (openBrace === -1) return undefined;
341
+ /** Whether a registry property is non-empty or cannot be proven empty. */
342
+ export function outboxRegistryHasEntries(
343
+ source: string,
344
+ property: "events" | "jobs",
345
+ ): boolean {
346
+ const state = outboxRegistryEntryState(source, property);
347
+ return state === "present" || state === "indeterminate";
348
+ }
316
349
 
317
- const closeBrace = matchingDelimiterIndex(source, openBrace, "{", "}");
318
- if (closeBrace === -1) return undefined;
350
+ /** Identifiers referenced by a registry property after resolving local array aliases. */
351
+ export function outboxRegistryIdentifiers(
352
+ source: string,
353
+ property: "events" | "jobs",
354
+ ): Set<string> {
355
+ const match = outboxRegistryPropertyAnalysis(source, property);
356
+ if (!match?.value || match.indeterminate) return new Set();
357
+
358
+ const identifiers = new Set<string>();
359
+ for (const expression of resolvedRegistryExpressions(
360
+ match.value,
361
+ match.sourceFile,
362
+ )) {
363
+ const start = expression.getStart(match.sourceFile);
364
+ for (const identifier of identifiersFromArrayExpression(
365
+ source.slice(start, expression.end),
366
+ )) {
367
+ identifiers.add(identifier);
368
+ }
369
+ }
370
+ return identifiers;
371
+ }
319
372
 
320
- const objectText = source.slice(openBrace, closeBrace + 1);
321
- const propertyMatch = new RegExp(`\\b${property}\\s*:`).exec(objectText);
322
- if (!propertyMatch) return undefined;
373
+ type OutboxRegistryPropertyAnalysis = {
374
+ sourceFile: ts.SourceFile;
375
+ value?: ts.Expression;
376
+ indeterminate: boolean;
377
+ };
323
378
 
324
- const valueStart = openBrace + propertyMatch.index + propertyMatch[0].length;
325
- const value = topLevelValueSlice(source, valueStart, closeBrace);
326
- if (!value) return undefined;
379
+ function outboxRegistryPropertyAnalysis(
380
+ source: string,
381
+ property: "events" | "jobs",
382
+ ): OutboxRegistryPropertyAnalysis | undefined {
383
+ const sourceFile = ts.createSourceFile(
384
+ "outbox-registry.ts",
385
+ source,
386
+ ts.ScriptTarget.Latest,
387
+ true,
388
+ ts.ScriptKind.TS,
389
+ );
390
+ const initializers = registryInitializers(sourceFile);
391
+ const selected = selectOutboxRegistryCall(sourceFile);
392
+ if (!selected.call) {
393
+ return selected.indeterminate
394
+ ? { sourceFile, indeterminate: true }
395
+ : undefined;
396
+ }
327
397
 
328
- return value;
398
+ const firstArgument = selected.call.arguments[0];
399
+ const object = firstArgument
400
+ ? resolveRegistryExpression(firstArgument, initializers)
401
+ : undefined;
402
+ if (!object || !ts.isObjectLiteralExpression(object)) {
403
+ return { sourceFile, indeterminate: true };
404
+ }
405
+
406
+ let value: ts.Expression | undefined;
407
+ let indeterminate = false;
408
+ for (const member of object.properties) {
409
+ if (ts.isSpreadAssignment(member)) {
410
+ value = undefined;
411
+ indeterminate = true;
412
+ continue;
413
+ }
414
+ const name = member.name ? registryPropertyName(member.name) : undefined;
415
+ if (!name) {
416
+ value = undefined;
417
+ indeterminate = true;
418
+ continue;
419
+ }
420
+ if (name !== property) continue;
421
+ if (
422
+ ts.isPropertyAssignment(member) ||
423
+ ts.isShorthandPropertyAssignment(member)
424
+ ) {
425
+ value = ts.isPropertyAssignment(member)
426
+ ? member.initializer
427
+ : member.name;
428
+ indeterminate = false;
429
+ } else {
430
+ value = undefined;
431
+ indeterminate = true;
432
+ }
433
+ }
434
+ if (value && !isStaticRegistryValue(value, initializers)) {
435
+ return { sourceFile, indeterminate: true };
436
+ }
437
+ return { sourceFile, value, indeterminate };
438
+ }
439
+
440
+ function isStaticRegistryValue(
441
+ expression: ts.Expression,
442
+ initializers: Map<string, ts.Expression>,
443
+ visited = new Set<string>(),
444
+ ): boolean {
445
+ const current = unwrapRegistryExpression(expression);
446
+ if (ts.isArrayLiteralExpression(current)) return true;
447
+ if (!ts.isIdentifier(current)) return false;
448
+
449
+ const initializer = initializers.get(current.text);
450
+ if (!initializer) return true;
451
+ if (visited.has(current.text)) return false;
452
+
453
+ const nextVisited = new Set(visited);
454
+ nextVisited.add(current.text);
455
+ return isStaticRegistryValue(initializer, initializers, nextVisited);
456
+ }
457
+
458
+ function selectOutboxRegistryCall(sourceFile: ts.SourceFile): {
459
+ call?: ts.CallExpression;
460
+ indeterminate: boolean;
461
+ } {
462
+ const exportedNames = locallyExportedRegistryNames(sourceFile);
463
+ const candidates: {
464
+ name?: string;
465
+ exported: boolean;
466
+ calls: ts.CallExpression[];
467
+ }[] = [];
468
+
469
+ for (const statement of sourceFile.statements) {
470
+ if (!ts.isVariableStatement(statement)) continue;
471
+ const statementExported = Boolean(
472
+ statement.modifiers?.some(
473
+ (modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword,
474
+ ),
475
+ );
476
+ for (const declaration of statement.declarationList.declarations) {
477
+ if (!declaration.initializer) continue;
478
+ const calls = outboxRegistryCalls(declaration.initializer);
479
+ if (calls.length === 0) continue;
480
+ const name = ts.isIdentifier(declaration.name)
481
+ ? declaration.name.text
482
+ : undefined;
483
+ candidates.push({
484
+ ...(name ? { name } : {}),
485
+ exported: statementExported || (name ? exportedNames.has(name) : false),
486
+ calls,
487
+ });
488
+ }
489
+ }
490
+
491
+ const canonical = candidates.filter(
492
+ (candidate) => candidate.name === "outboxRegistry" && candidate.exported,
493
+ );
494
+ const exported = candidates.filter((candidate) => candidate.exported);
495
+ const selected =
496
+ canonical.length > 0
497
+ ? canonical
498
+ : exported.length > 0
499
+ ? exported
500
+ : candidates;
501
+ if (selected.length === 1 && selected[0].calls.length === 1) {
502
+ return { call: selected[0].calls[0], indeterminate: false };
503
+ }
504
+ if (selected.length > 0) return { indeterminate: true };
505
+
506
+ const fallback = outboxRegistryCalls(sourceFile);
507
+ return fallback.length === 1
508
+ ? { call: fallback[0], indeterminate: false }
509
+ : { indeterminate: fallback.length > 0 };
510
+ }
511
+
512
+ function locallyExportedRegistryNames(sourceFile: ts.SourceFile): Set<string> {
513
+ const names = new Set<string>();
514
+ for (const statement of sourceFile.statements) {
515
+ if (
516
+ !ts.isExportDeclaration(statement) ||
517
+ !statement.exportClause ||
518
+ !ts.isNamedExports(statement.exportClause) ||
519
+ statement.moduleSpecifier
520
+ ) {
521
+ continue;
522
+ }
523
+ for (const element of statement.exportClause.elements) {
524
+ names.add(element.propertyName?.text ?? element.name.text);
525
+ }
526
+ }
527
+ return names;
528
+ }
529
+
530
+ function outboxRegistryCalls(node: ts.Node): ts.CallExpression[] {
531
+ const calls: ts.CallExpression[] = [];
532
+ const visit = (child: ts.Node): void => {
533
+ if (
534
+ ts.isCallExpression(child) &&
535
+ ((ts.isIdentifier(child.expression) &&
536
+ child.expression.text === "defineOutboxRegistry") ||
537
+ (ts.isPropertyAccessExpression(child.expression) &&
538
+ child.expression.name.text === "defineOutboxRegistry"))
539
+ ) {
540
+ calls.push(child);
541
+ }
542
+ ts.forEachChild(child, visit);
543
+ };
544
+ visit(node);
545
+ return calls;
546
+ }
547
+
548
+ function resolvedRegistryExpressions(
549
+ expression: ts.Expression,
550
+ sourceFile: ts.SourceFile,
551
+ ): ts.Expression[] {
552
+ const initializers = registryInitializers(sourceFile);
553
+
554
+ const resolve = (
555
+ candidate: ts.Expression,
556
+ visited: Set<string>,
557
+ ): ts.Expression[] => {
558
+ const current = unwrapRegistryExpression(candidate);
559
+ if (ts.isArrayLiteralExpression(current)) {
560
+ return current.elements.flatMap((element) =>
561
+ ts.isSpreadElement(element)
562
+ ? resolve(element.expression, new Set(visited))
563
+ : ts.isExpression(element)
564
+ ? [element]
565
+ : [],
566
+ );
567
+ }
568
+ if (ts.isIdentifier(current) && !visited.has(current.text)) {
569
+ const initializer = initializers.get(current.text);
570
+ if (initializer) {
571
+ const nextVisited = new Set(visited);
572
+ nextVisited.add(current.text);
573
+ return resolve(initializer, nextVisited);
574
+ }
575
+ }
576
+ return [current];
577
+ };
578
+
579
+ return resolve(expression, new Set());
580
+ }
581
+
582
+ function registryInitializers(
583
+ sourceFile: ts.SourceFile,
584
+ ): Map<string, ts.Expression> {
585
+ const initializers = new Map<string, ts.Expression>();
586
+ for (const statement of sourceFile.statements) {
587
+ if (!ts.isVariableStatement(statement)) continue;
588
+ for (const declaration of statement.declarationList.declarations) {
589
+ if (ts.isIdentifier(declaration.name) && declaration.initializer) {
590
+ initializers.set(declaration.name.text, declaration.initializer);
591
+ }
592
+ }
593
+ }
594
+ return initializers;
595
+ }
596
+
597
+ function resolveRegistryExpression(
598
+ expression: ts.Expression,
599
+ initializers: Map<string, ts.Expression>,
600
+ visited = new Set<string>(),
601
+ ): ts.Expression {
602
+ const current = unwrapRegistryExpression(expression);
603
+ if (!ts.isIdentifier(current) || visited.has(current.text)) return current;
604
+ const initializer = initializers.get(current.text);
605
+ if (!initializer) return current;
606
+ const nextVisited = new Set(visited);
607
+ nextVisited.add(current.text);
608
+ return resolveRegistryExpression(initializer, initializers, nextVisited);
609
+ }
610
+
611
+ function unwrapRegistryExpression(expression: ts.Expression): ts.Expression {
612
+ let current = expression;
613
+ while (
614
+ ts.isParenthesizedExpression(current) ||
615
+ ts.isAsExpression(current) ||
616
+ ts.isSatisfiesExpression(current) ||
617
+ ts.isTypeAssertionExpression(current) ||
618
+ ts.isNonNullExpression(current)
619
+ ) {
620
+ current = current.expression;
621
+ }
622
+ return current;
623
+ }
624
+
625
+ function registryPropertyName(name: ts.PropertyName): string | undefined {
626
+ return ts.isIdentifier(name) || ts.isStringLiteralLike(name)
627
+ ? name.text
628
+ : undefined;
329
629
  }
330
630
 
331
631
  /**
@@ -390,53 +690,3 @@ function appendEntry(
390
690
  if (next === source) return { kind: "unchanged" };
391
691
  return { kind: "updated", source: next };
392
692
  }
393
-
394
- function topLevelValueSlice(
395
- source: string,
396
- valueStart: number,
397
- hardEnd: number,
398
- ): InitializerInfo | undefined {
399
- let start = valueStart;
400
- while (start < hardEnd && /\s/.test(source[start])) start++;
401
- if (start >= hardEnd) return undefined;
402
-
403
- let depth = 0;
404
-
405
- for (const index of codeCharacterIndexes(source, start, hardEnd + 1)) {
406
- const char = source[index];
407
-
408
- if (char === "[" || char === "(" || char === "{") {
409
- depth++;
410
- continue;
411
- }
412
-
413
- if (char === "]" || char === ")" || char === "}") {
414
- if (depth === 0) {
415
- return trimmedInfo(source, start, index);
416
- }
417
- depth--;
418
- continue;
419
- }
420
-
421
- if (char === "," && depth === 0) {
422
- return trimmedInfo(source, start, index);
423
- }
424
- }
425
-
426
- return undefined;
427
- }
428
-
429
- function trimmedInfo(
430
- source: string,
431
- start: number,
432
- end: number,
433
- ): InitializerInfo {
434
- let trimmedEnd = end;
435
- while (trimmedEnd > start && /\s/.test(source[trimmedEnd - 1])) trimmedEnd--;
436
-
437
- return {
438
- text: source.slice(start, trimmedEnd),
439
- start,
440
- end: trimmedEnd,
441
- };
442
- }
@@ -41,6 +41,9 @@ https://beignetjs.com (agent-friendly index at https://beignetjs.com/llms.txt).
41
41
  README.md covers setup and the app map; this file covers what is not
42
42
  discoverable from the code.
43
43
 
44
+ This app requires Node.js 22.12 or newer. Bun 1.3.14 or newer is supported for
45
+ Beignet commands.
46
+
44
47
  ## Registration is explicit
45
48
 
46
49
  Hand-writing an artifact file does not wire it into the app. Non-registration
@@ -54,6 +57,11 @@ is a silent failure — the file exists but never runs:
54
57
  - Tasks must be added to \`defineTasks([...])\` in \`server/tasks.ts\`.
55
58
  - Outbox events and jobs must be registered in \`defineOutboxRegistry({...})\`
56
59
  in \`server/outbox.ts\`.
60
+ - An outbox registry containing jobs also requires \`jobs: JobDispatcherPort\`
61
+ to be bound directly or deferred to a provider. \`${cli} make job\` installs
62
+ an app-owned inline dispatcher only when no dispatcher is already wired;
63
+ adding an Inngest or BullMQ preset later replaces only that marked generated
64
+ fallback and rejects unmarked custom inline wiring as a conflict.
57
65
  - Listeners must be added to the \`listeners\` array in \`server/listeners.ts\`
58
66
  and wired through a \`registerListeners(...)\` call in server provider wiring.
59
67
  - Apps using Inngest must also spread feature job registries into
@@ -106,6 +114,7 @@ apps have reimplemented by accident:
106
114
 
107
115
  | Need | Use |
108
116
  | --- | --- |${capabilityClientRow}
117
+ | Query parameters on an HTTP contract | Pass both the Standard Schema and an explicit \`defineQueryTransport({ ... })\` to \`.query(schema, transport)\`. Use \`query.string()\`, \`query.number()\`, \`query.integer()\`, \`query.boolean()\`, \`query.dateTime()\`, \`query.date()\`, repeated scalar arrays, or one flat \`deepObject\`; never make schema coercion define the URL format. |
109
118
  | App context outside HTTP — agents, queues, scripts, backfills | \`server.createServiceContext(input)\`, or \`server.runServiceContext(input, fn)\` in one-off scripts. Never hand-assemble a context or call \`gate.attach(...)\` — the server owns gate attachment. |
110
119
  | Ports outside a request — auth callbacks, module-level helpers | \`const { ports } = await getServer()\` (dynamic \`import("@/server")\` breaks module cycles). Do not construct parallel provider clients or fall back to \`console.*\` when \`ports.logger\` exists. |
111
120
  | Routes that cannot be contracts — webhooks, third-party callbacks, streaming | \`createWebhookRoute\`, \`createPaymentWebhookRoute\`, \`createScheduleRoute\`, \`createOutboxDrainRoute\` from \`@beignet/next\`; \`server.rawRoute(...)\` for anything else. All run the hooks pipeline — never hand-enforce rate limits in a route body. |
@@ -133,6 +142,13 @@ formatting. The individual commands (\`${lint}\`, \`${cli} lint\`,
133
142
  \`${cli} doctor --strict\`, \`${test}\`, \`${typecheck}\`) still work when you
134
143
  need one check alone.
135
144
 
145
+ Beignet's architecture lint parses TypeScript imports and re-exports,
146
+ \`require()\`, import assignments, and literal dynamic imports, then follows
147
+ local value imports through feature-root helpers. When lint cannot verify a
148
+ computed module reference inside a constrained layer, use a string-literal
149
+ specifier or move runtime loading behind an allowed boundary. Move near-miss
150
+ feature layers to the canonical path named in the diagnostic.
151
+
136
152
  Before exploring unfamiliar code, run \`${cli} map\` for the human inventory
137
153
  or \`${cli} map --json --feature <name>\` for an evidence-backed graph scoped
138
154
  to one feature. The graph honors the app's complete TypeScript \`baseUrl\` and