@beignet/cli 0.0.49 → 0.0.50

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 (47) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +41 -1
  3. package/dist/analysis/workspace.d.ts +1 -0
  4. package/dist/analysis/workspace.d.ts.map +1 -1
  5. package/dist/analysis/workspace.js +20 -10
  6. package/dist/analysis/workspace.js.map +1 -1
  7. package/dist/app-map-changes.d.ts +103 -0
  8. package/dist/app-map-changes.d.ts.map +1 -0
  9. package/dist/app-map-changes.js +949 -0
  10. package/dist/app-map-changes.js.map +1 -0
  11. package/dist/git-changes.d.ts +30 -0
  12. package/dist/git-changes.d.ts.map +1 -0
  13. package/dist/git-changes.js +367 -0
  14. package/dist/git-changes.js.map +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +26 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/inspect.js +291 -18
  19. package/dist/inspect.js.map +1 -1
  20. package/dist/lib.d.ts +3 -0
  21. package/dist/lib.d.ts.map +1 -1
  22. package/dist/lib.js +1 -0
  23. package/dist/lib.js.map +1 -1
  24. package/dist/make/shared.d.ts.map +1 -1
  25. package/dist/make/shared.js +31 -27
  26. package/dist/make/shared.js.map +1 -1
  27. package/dist/mcp.d.ts.map +1 -1
  28. package/dist/mcp.js +24 -2
  29. package/dist/mcp.js.map +1 -1
  30. package/dist/templates/agents.d.ts.map +1 -1
  31. package/dist/templates/agents.js +21 -1
  32. package/dist/templates/agents.js.map +1 -1
  33. package/dist/templates/base.d.ts.map +1 -1
  34. package/dist/templates/base.js +4 -1
  35. package/dist/templates/base.js.map +1 -1
  36. package/package.json +2 -2
  37. package/skills/app-structure/SKILL.md +25 -2
  38. package/src/analysis/workspace.ts +25 -9
  39. package/src/app-map-changes.ts +1462 -0
  40. package/src/git-changes.ts +511 -0
  41. package/src/index.ts +39 -1
  42. package/src/inspect.ts +422 -23
  43. package/src/lib.ts +21 -0
  44. package/src/make/shared.ts +31 -27
  45. package/src/mcp.ts +32 -2
  46. package/src/templates/agents.ts +21 -1
  47. package/src/templates/base.ts +4 -1
package/src/inspect.ts CHANGED
@@ -4404,6 +4404,7 @@ type WorkflowRegistrationDrift = {
4404
4404
  unregistered: UnregisteredWorkflowRegistry[];
4405
4405
  wiringFile?: string;
4406
4406
  eventBusFile?: string;
4407
+ unsafeLifecycleFiles: string[];
4407
4408
  };
4408
4409
  };
4409
4410
 
@@ -4482,6 +4483,15 @@ async function inspectWorkflowRegistrationDrift(
4482
4483
  }
4483
4484
 
4484
4485
  const infraDir = directoryPath(path.dirname(config.paths.portWiring));
4486
+ for (const file of drift.listeners.unsafeLifecycleFiles) {
4487
+ diagnostics.push({
4488
+ severity: "warning",
4489
+ code: "BEIGNET_LISTENER_LIFECYCLE_UNSAFE",
4490
+ file,
4491
+ message: `${file} calls registerListeners(...) without the complete provider lifecycle, so server startup can resolve before listeners are ready or shutdown can leak subscriptions. Register listeners in start(), return or await registration.ready, and return or await registration.unsubscribe() in stop().`,
4492
+ });
4493
+ }
4494
+
4485
4495
  const listenerTarget = drift.listeners.wiringFile
4486
4496
  ? `${drift.listeners.wiringFile}, which already calls registerListeners(...)`
4487
4497
  : drift.listeners.eventBusFile
@@ -4652,7 +4662,7 @@ async function workflowRegistrationDrift(
4652
4662
  events: [],
4653
4663
  jobs: [],
4654
4664
  },
4655
- listeners: { unregistered: [] },
4665
+ listeners: { unregistered: [], unsafeLifecycleFiles: [] },
4656
4666
  };
4657
4667
  if (registries.length === 0) return drift;
4658
4668
 
@@ -4714,7 +4724,12 @@ async function workflowRegistrationDrift(
4714
4724
 
4715
4725
  const listenerRegistries = byKind("listeners");
4716
4726
  if (listenerRegistries.length > 0) {
4717
- const wiring = await listenerWiringReferences(targetDir, files, config);
4727
+ const wiring = await listenerWiringReferences(
4728
+ targetDir,
4729
+ files,
4730
+ config,
4731
+ listenerRegistries,
4732
+ );
4718
4733
  drift.listeners = {
4719
4734
  unregistered: unregisteredWorkflowRegistries(
4720
4735
  listenerRegistries,
@@ -4722,6 +4737,7 @@ async function workflowRegistrationDrift(
4722
4737
  ),
4723
4738
  wiringFile: wiring.wiringFile,
4724
4739
  eventBusFile: wiring.eventBusFile,
4740
+ unsafeLifecycleFiles: wiring.unsafeLifecycleFiles,
4725
4741
  };
4726
4742
  }
4727
4743
 
@@ -5056,12 +5072,15 @@ async function listenerWiringReferences(
5056
5072
  targetDir: string,
5057
5073
  files: string[],
5058
5074
  config: ResolvedBeignetConfig,
5075
+ listenerRegistries: FeatureWorkflowRegistry[],
5059
5076
  ): Promise<{
5060
5077
  identifiers: Set<string>;
5061
5078
  wiringFile?: string;
5062
5079
  eventBusFile?: string;
5080
+ unsafeLifecycleFiles: string[];
5063
5081
  }> {
5064
5082
  const identifiers = new Set<string>();
5083
+ const unsafeLifecycleFiles = new Set<string>();
5065
5084
  let wiringFile: string | undefined;
5066
5085
  let eventBusFile: string | undefined;
5067
5086
  let centralListenerRegistryReferenced = false;
@@ -5073,35 +5092,54 @@ async function listenerWiringReferences(
5073
5092
 
5074
5093
  const source = await readFile(path.join(targetDir, file), "utf8");
5075
5094
  const namedImports = parseNamedImportSources(source);
5076
- let foundCall = false;
5077
-
5078
- for (const match of source.matchAll(/\bregisterListeners\s*\(/g)) {
5079
- const openParen = (match.index ?? 0) + match[0].length - 1;
5080
- const closeParen = matchingDelimiterIndex(source, openParen, "(", ")");
5081
- const argsText =
5082
- closeParen === -1
5083
- ? source.slice(openParen)
5084
- : source.slice(openParen + 1, closeParen);
5095
+ const sourceFile = ts.createSourceFile(
5096
+ file,
5097
+ source,
5098
+ ts.ScriptTarget.Latest,
5099
+ true,
5100
+ file.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
5101
+ );
5102
+ const registerListenersBindings = importedRegisterListenersBindings(
5103
+ source,
5104
+ namedImports,
5105
+ );
5106
+ const calls = registerListenersCalls(sourceFile, registerListenersBindings);
5107
+ let foundRelevantCall = false;
5085
5108
 
5086
- foundCall = true;
5109
+ for (const call of calls) {
5110
+ const argsText = call.arguments
5111
+ .map((argument) => argument.getText(sourceFile))
5112
+ .join(",");
5087
5113
  const callIdentifiers = identifiersFromArrayExpression(argsText);
5114
+ const referencesCentral = callReferencesCentralListenerRegistry({
5115
+ callIdentifiers,
5116
+ namedImports,
5117
+ importerFile: file,
5118
+ listenerRegistryFile,
5119
+ files,
5120
+ });
5121
+ const referencesFeature = callReferencesFeatureListenerRegistry({
5122
+ callIdentifiers,
5123
+ namedImports,
5124
+ importerFile: file,
5125
+ files,
5126
+ listenerRegistries,
5127
+ });
5128
+ if (!referencesCentral && !referencesFeature) continue;
5129
+
5130
+ foundRelevantCall = true;
5088
5131
  for (const identifier of callIdentifiers) {
5089
5132
  identifiers.add(identifier);
5090
5133
  }
5091
- if (
5092
- callReferencesCentralListenerRegistry({
5093
- callIdentifiers,
5094
- namedImports,
5095
- importerFile: file,
5096
- listenerRegistryFile,
5097
- files,
5098
- })
5099
- ) {
5134
+ if (referencesCentral) {
5100
5135
  centralListenerRegistryReferenced = true;
5101
5136
  }
5137
+ if (!hasSafeListenerRegistrationLifecycle(call, sourceFile)) {
5138
+ unsafeLifecycleFiles.add(file);
5139
+ }
5102
5140
  }
5103
5141
 
5104
- if (foundCall) {
5142
+ if (foundRelevantCall) {
5105
5143
  wiringFile ??= file;
5106
5144
  } else if (!eventBusFile && /\bcreate\w*EventBus\s*\(/.test(source)) {
5107
5145
  eventBusFile = file;
@@ -5124,7 +5162,368 @@ async function listenerWiringReferences(
5124
5162
  }
5125
5163
  }
5126
5164
 
5127
- return { identifiers, wiringFile, eventBusFile };
5165
+ return {
5166
+ identifiers,
5167
+ wiringFile,
5168
+ eventBusFile,
5169
+ unsafeLifecycleFiles: [...unsafeLifecycleFiles].sort(),
5170
+ };
5171
+ }
5172
+
5173
+ type ListenerLifecycleHook =
5174
+ | ts.MethodDeclaration
5175
+ | ts.FunctionExpression
5176
+ | ts.ArrowFunction;
5177
+
5178
+ interface ListenerLifecycleTarget {
5179
+ text: string;
5180
+ binding?: ts.Node;
5181
+ }
5182
+
5183
+ function registerListenersCalls(
5184
+ sourceFile: ts.SourceFile,
5185
+ bindings: ReadonlySet<string>,
5186
+ ): ts.CallExpression[] {
5187
+ const calls: ts.CallExpression[] = [];
5188
+ const visit = (node: ts.Node): void => {
5189
+ if (
5190
+ ts.isCallExpression(node) &&
5191
+ ((ts.isIdentifier(node.expression) &&
5192
+ bindings.has(node.expression.text)) ||
5193
+ (ts.isPropertyAccessExpression(node.expression) &&
5194
+ node.expression.name.text === "registerListeners" &&
5195
+ ts.isIdentifier(node.expression.expression) &&
5196
+ bindings.has(`${node.expression.expression.text}.*`)))
5197
+ ) {
5198
+ calls.push(node);
5199
+ }
5200
+ ts.forEachChild(node, visit);
5201
+ };
5202
+ visit(sourceFile);
5203
+ return calls;
5204
+ }
5205
+
5206
+ function importedRegisterListenersBindings(
5207
+ source: string,
5208
+ namedImports: Map<string, { importedName: string; sourcePath: string }>,
5209
+ ): Set<string> {
5210
+ const bindings = new Set<string>();
5211
+ for (const [localName, imported] of namedImports) {
5212
+ if (
5213
+ imported.importedName === "registerListeners" &&
5214
+ imported.sourcePath === "@beignet/core/events"
5215
+ ) {
5216
+ bindings.add(localName);
5217
+ }
5218
+ }
5219
+ const namespaceImport =
5220
+ /import\s+\*\s+as\s+([A-Za-z_$][\w$]*)\s+from\s+["']@beignet\/core\/events["']/g;
5221
+ for (const match of source.matchAll(namespaceImport)) {
5222
+ bindings.add(`${match[1]}.*`);
5223
+ }
5224
+ return bindings;
5225
+ }
5226
+
5227
+ function hasSafeListenerRegistrationLifecycle(
5228
+ call: ts.CallExpression,
5229
+ sourceFile: ts.SourceFile,
5230
+ ): boolean {
5231
+ const startHook = enclosingListenerLifecycleHook(call, "start");
5232
+ const target = listenerRegistrationTarget(call, sourceFile);
5233
+ if (!startHook || !target) return false;
5234
+ const lifecycleOwner = listenerLifecycleOwner(startHook);
5235
+ if (!lifecycleOwner) return false;
5236
+
5237
+ let observesReadiness = false;
5238
+ let observesCleanup = false;
5239
+ const visit = (node: ts.Node): void => {
5240
+ if (
5241
+ !observesReadiness &&
5242
+ ts.isPropertyAccessExpression(node) &&
5243
+ node.name.text === "ready" &&
5244
+ listenerLifecycleTargetsMatch(
5245
+ listenerLifecycleTarget(node.expression, sourceFile),
5246
+ target,
5247
+ ) &&
5248
+ enclosingListenerLifecycleHook(node, "start") === startHook &&
5249
+ listenerLifecyclePromiseIsObserved(node, startHook)
5250
+ ) {
5251
+ observesReadiness = true;
5252
+ }
5253
+
5254
+ if (
5255
+ !observesCleanup &&
5256
+ ts.isCallExpression(node) &&
5257
+ ts.isPropertyAccessExpression(node.expression) &&
5258
+ node.expression.name.text === "unsubscribe" &&
5259
+ listenerLifecycleTargetsMatch(
5260
+ listenerLifecycleTarget(node.expression.expression, sourceFile),
5261
+ target,
5262
+ )
5263
+ ) {
5264
+ const stopHook = enclosingListenerLifecycleHook(node, "stop");
5265
+ if (
5266
+ stopHook &&
5267
+ listenerLifecycleOwner(stopHook) === lifecycleOwner &&
5268
+ listenerLifecyclePromiseIsObserved(node, stopHook)
5269
+ ) {
5270
+ observesCleanup = true;
5271
+ }
5272
+ }
5273
+
5274
+ if (!observesReadiness || !observesCleanup) {
5275
+ ts.forEachChild(node, visit);
5276
+ }
5277
+ };
5278
+ visit(lifecycleOwner);
5279
+ return observesReadiness && observesCleanup;
5280
+ }
5281
+
5282
+ function listenerRegistrationTarget(
5283
+ call: ts.CallExpression,
5284
+ sourceFile: ts.SourceFile,
5285
+ ): ListenerLifecycleTarget | undefined {
5286
+ let expression: ts.Expression = call;
5287
+ while (
5288
+ ts.isParenthesizedExpression(expression.parent) ||
5289
+ ts.isAsExpression(expression.parent) ||
5290
+ ts.isSatisfiesExpression(expression.parent) ||
5291
+ ts.isNonNullExpression(expression.parent)
5292
+ ) {
5293
+ expression = expression.parent;
5294
+ }
5295
+
5296
+ const parent = expression.parent;
5297
+ if (
5298
+ ts.isVariableDeclaration(parent) &&
5299
+ parent.initializer === expression &&
5300
+ ts.isIdentifier(parent.name)
5301
+ ) {
5302
+ return { text: parent.name.text, binding: parent };
5303
+ }
5304
+ if (
5305
+ ts.isBinaryExpression(parent) &&
5306
+ parent.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
5307
+ parent.right === expression
5308
+ ) {
5309
+ return listenerLifecycleTarget(parent.left, sourceFile);
5310
+ }
5311
+ return undefined;
5312
+ }
5313
+
5314
+ function listenerLifecycleTarget(
5315
+ expression: ts.Expression,
5316
+ sourceFile: ts.SourceFile,
5317
+ ): ListenerLifecycleTarget | undefined {
5318
+ let current = expression;
5319
+ while (
5320
+ ts.isParenthesizedExpression(current) ||
5321
+ ts.isAsExpression(current) ||
5322
+ ts.isSatisfiesExpression(current) ||
5323
+ ts.isNonNullExpression(current)
5324
+ ) {
5325
+ current = current.expression;
5326
+ }
5327
+ if (!ts.isIdentifier(current) && !ts.isPropertyAccessExpression(current)) {
5328
+ return undefined;
5329
+ }
5330
+
5331
+ return {
5332
+ text: current.getText(sourceFile),
5333
+ binding: listenerLifecycleRootIdentifier(current)
5334
+ ? resolveListenerLifecycleBinding(
5335
+ listenerLifecycleRootIdentifier(current) as ts.Identifier,
5336
+ )
5337
+ : undefined,
5338
+ };
5339
+ }
5340
+
5341
+ function listenerLifecycleRootIdentifier(
5342
+ expression: ts.Identifier | ts.PropertyAccessExpression,
5343
+ ): ts.Identifier | undefined {
5344
+ let current: ts.Expression = expression;
5345
+ while (ts.isPropertyAccessExpression(current)) {
5346
+ current = current.expression;
5347
+ }
5348
+ return ts.isIdentifier(current) ? current : undefined;
5349
+ }
5350
+
5351
+ function resolveListenerLifecycleBinding(
5352
+ identifier: ts.Identifier,
5353
+ ): ts.Node | undefined {
5354
+ let current: ts.Node | undefined = identifier;
5355
+ while (current) {
5356
+ if (ts.isBlock(current) || ts.isSourceFile(current)) {
5357
+ for (const statement of current.statements) {
5358
+ if (!ts.isVariableStatement(statement)) continue;
5359
+ for (const declaration of statement.declarationList.declarations) {
5360
+ if (
5361
+ ts.isIdentifier(declaration.name) &&
5362
+ declaration.name.text === identifier.text
5363
+ ) {
5364
+ return declaration;
5365
+ }
5366
+ }
5367
+ }
5368
+ }
5369
+
5370
+ if (ts.isFunctionLike(current)) {
5371
+ for (const parameter of current.parameters) {
5372
+ if (
5373
+ ts.isIdentifier(parameter.name) &&
5374
+ parameter.name.text === identifier.text
5375
+ ) {
5376
+ return parameter;
5377
+ }
5378
+ }
5379
+ }
5380
+
5381
+ if (
5382
+ ts.isCatchClause(current) &&
5383
+ current.variableDeclaration &&
5384
+ ts.isIdentifier(current.variableDeclaration.name) &&
5385
+ current.variableDeclaration.name.text === identifier.text
5386
+ ) {
5387
+ return current.variableDeclaration;
5388
+ }
5389
+ current = current.parent;
5390
+ }
5391
+ return undefined;
5392
+ }
5393
+
5394
+ function listenerLifecycleTargetsMatch(
5395
+ left: ListenerLifecycleTarget | undefined,
5396
+ right: ListenerLifecycleTarget,
5397
+ ): boolean {
5398
+ return (
5399
+ left?.text === right.text &&
5400
+ (left.binding !== undefined || right.binding !== undefined
5401
+ ? left.binding === right.binding
5402
+ : true)
5403
+ );
5404
+ }
5405
+
5406
+ function enclosingListenerLifecycleHook(
5407
+ node: ts.Node,
5408
+ hookName: "start" | "stop",
5409
+ ): ListenerLifecycleHook | undefined {
5410
+ let current: ts.Node | undefined = node.parent;
5411
+ while (current) {
5412
+ if (ts.isMethodDeclaration(current)) {
5413
+ return staticPropertyName(current.name) === hookName
5414
+ ? current
5415
+ : undefined;
5416
+ }
5417
+ if (ts.isFunctionExpression(current) || ts.isArrowFunction(current)) {
5418
+ const parent = current.parent;
5419
+ return ts.isPropertyAssignment(parent) &&
5420
+ staticPropertyName(parent.name) === hookName
5421
+ ? current
5422
+ : undefined;
5423
+ }
5424
+ if (ts.isFunctionDeclaration(current)) return undefined;
5425
+ current = current.parent;
5426
+ }
5427
+ return undefined;
5428
+ }
5429
+
5430
+ function listenerLifecycleOwner(
5431
+ hook: ListenerLifecycleHook,
5432
+ ): ts.ObjectLiteralExpression | undefined {
5433
+ if (ts.isMethodDeclaration(hook)) {
5434
+ return ts.isObjectLiteralExpression(hook.parent) ? hook.parent : undefined;
5435
+ }
5436
+ const property = hook.parent;
5437
+ return ts.isPropertyAssignment(property) &&
5438
+ ts.isObjectLiteralExpression(property.parent)
5439
+ ? property.parent
5440
+ : undefined;
5441
+ }
5442
+
5443
+ function listenerLifecyclePromiseIsObserved(
5444
+ node: ts.Node,
5445
+ hook: ListenerLifecycleHook,
5446
+ ): boolean {
5447
+ let current: ts.Node | undefined = node;
5448
+ while (current && current !== hook) {
5449
+ const parent: ts.Node = current.parent;
5450
+ if (
5451
+ (ts.isAwaitExpression(parent) && parent.expression === current) ||
5452
+ (ts.isReturnStatement(parent) && parent.expression === current)
5453
+ ) {
5454
+ return true;
5455
+ }
5456
+ if (
5457
+ ts.isParenthesizedExpression(parent) ||
5458
+ ts.isAsExpression(parent) ||
5459
+ ts.isSatisfiesExpression(parent) ||
5460
+ ts.isNonNullExpression(parent)
5461
+ ) {
5462
+ current = parent;
5463
+ continue;
5464
+ }
5465
+ if (
5466
+ ts.isPropertyAccessExpression(parent) &&
5467
+ parent.expression === current
5468
+ ) {
5469
+ const method = parent.name.text;
5470
+ const invocation = parent.parent;
5471
+ if (
5472
+ !["then", "catch", "finally"].includes(method) ||
5473
+ !ts.isCallExpression(invocation) ||
5474
+ invocation.expression !== parent ||
5475
+ method === "catch" ||
5476
+ (method === "then" && invocation.arguments.length > 1)
5477
+ ) {
5478
+ return false;
5479
+ }
5480
+ current = invocation;
5481
+ continue;
5482
+ }
5483
+ return false;
5484
+ }
5485
+ return false;
5486
+ }
5487
+
5488
+ function callReferencesFeatureListenerRegistry(args: {
5489
+ callIdentifiers: Set<string>;
5490
+ namedImports: Map<string, { importedName: string; sourcePath: string }>;
5491
+ importerFile: string;
5492
+ files: string[];
5493
+ listenerRegistries: FeatureWorkflowRegistry[];
5494
+ }): boolean {
5495
+ for (const identifier of args.callIdentifiers) {
5496
+ for (const registry of args.listenerRegistries) {
5497
+ if (
5498
+ identifier === registry.registryName ||
5499
+ registry.members.includes(identifier)
5500
+ ) {
5501
+ return true;
5502
+ }
5503
+ }
5504
+
5505
+ const imported = args.namedImports.get(identifier);
5506
+ if (!imported) continue;
5507
+ const importedFile = sourceFileFromImport(
5508
+ imported.sourcePath,
5509
+ args.importerFile,
5510
+ args.files,
5511
+ );
5512
+ if (!importedFile) continue;
5513
+
5514
+ for (const registry of args.listenerRegistries) {
5515
+ if (
5516
+ (imported.importedName === registry.registryName &&
5517
+ importedFile === registry.indexFile) ||
5518
+ (registry.members.includes(imported.importedName) &&
5519
+ (importedFile === registry.indexFile ||
5520
+ registry.memberFiles.get(imported.importedName) === importedFile))
5521
+ ) {
5522
+ return true;
5523
+ }
5524
+ }
5525
+ }
5526
+ return false;
5128
5527
  }
5129
5528
 
5130
5529
  function callReferencesCentralListenerRegistry(args: {
package/src/lib.ts CHANGED
@@ -25,6 +25,23 @@ export {
25
25
  mapApp,
26
26
  projectAppMap,
27
27
  } from "./app-map.js";
28
+ export type {
29
+ AppChangedFile,
30
+ AppChangeFileScope,
31
+ AppChangeGap,
32
+ AppChangeImpact,
33
+ AppChangeImpactedNode,
34
+ AppChangeImpactResult,
35
+ AppChangeReason,
36
+ AppChangeRelationship,
37
+ AppMapChangesOptions,
38
+ BoundedAppChangeGapSection,
39
+ BoundedAppChangeSection,
40
+ } from "./app-map-changes.js";
41
+ export {
42
+ formatAppChangeImpact,
43
+ mapAppChanges,
44
+ } from "./app-map-changes.js";
28
45
  export type { CreateOptions } from "./create.js";
29
46
  export { createProject } from "./create.js";
30
47
  export type {
@@ -71,6 +88,10 @@ export {
71
88
  explainTargetKinds,
72
89
  formatExplain,
73
90
  } from "./explain.js";
91
+ export type {
92
+ GitChangeComparison,
93
+ GitChangedFileStatus,
94
+ } from "./git-changes.js";
74
95
  export { main } from "./index.js";
75
96
  export type {
76
97
  ApplyDoctorFixPlanOptions,
@@ -453,36 +453,40 @@ export function wireListenersProviderSource(
453
453
  >()({
454
454
  \tname: "app-listeners",
455
455
  \tsetup({ ports, createServiceContext }) {
456
- \t\tconst unregister = registerListeners(ports.eventBus, listeners, {
457
- \t\t\tctx: () =>
458
- \t\t\t\tcreateServiceContext({
459
- \t\t\t\t\tactor: createServiceActor("beignet-listener"),
460
- \t\t\t\t}),
461
- \t\t\tonError(error, listener) {
462
- \t\t\t\tports.logger.error("Event listener failed", {
463
- \t\t\t\t\terror,
464
- \t\t\t\t\tlistenerName: listener.name,
465
- \t\t\t\t});
466
- \t\t\t\tvoid tryReportException({
467
- \t\t\t\t\treporter: ports.errorReporter,
468
- \t\t\t\t\terror,
469
- \t\t\t\t\treportOptions: {
470
- \t\t\t\t\t\tlevel: "error",
471
- \t\t\t\t\t\tmechanism: "beignet.listener",
472
- \t\t\t\t\t\thandled: false,
473
- \t\t\t\t\t\ttags: {
474
- \t\t\t\t\t\t\t"beignet.kind": "listener",
475
- \t\t\t\t\t\t\t"beignet.listener": listener.name,
476
- \t\t\t\t\t\t},
477
- \t\t\t\t\t\tcontexts: { listener: { name: listener.name } },
456
+ \t\tlet registration: ReturnType<typeof registerListeners> | undefined;
457
+
458
+ \t\treturn {
459
+ \t\t\tasync start() {
460
+ \t\t\t\tregistration = registerListeners(ports.eventBus, listeners, {
461
+ \t\t\t\t\tctx: () =>
462
+ \t\t\t\t\t\tcreateServiceContext({
463
+ \t\t\t\t\t\t\tactor: createServiceActor("beignet-listener"),
464
+ \t\t\t\t\t\t}),
465
+ \t\t\t\t\tonError(error, listener) {
466
+ \t\t\t\t\t\tports.logger.error("Event listener failed", {
467
+ \t\t\t\t\t\t\terror,
468
+ \t\t\t\t\t\t\tlistenerName: listener.name,
469
+ \t\t\t\t\t\t});
470
+ \t\t\t\t\t\tvoid tryReportException({
471
+ \t\t\t\t\t\t\treporter: ports.errorReporter,
472
+ \t\t\t\t\t\t\terror,
473
+ \t\t\t\t\t\t\treportOptions: {
474
+ \t\t\t\t\t\t\t\tlevel: "error",
475
+ \t\t\t\t\t\t\t\tmechanism: "beignet.listener",
476
+ \t\t\t\t\t\t\t\thandled: false,
477
+ \t\t\t\t\t\t\t\ttags: {
478
+ \t\t\t\t\t\t\t\t\t"beignet.kind": "listener",
479
+ \t\t\t\t\t\t\t\t\t"beignet.listener": listener.name,
480
+ \t\t\t\t\t\t\t\t},
481
+ \t\t\t\t\t\t\t\tcontexts: { listener: { name: listener.name } },
482
+ \t\t\t\t\t\t\t},
483
+ \t\t\t\t\t\t});
478
484
  \t\t\t\t\t},
479
485
  \t\t\t\t});
486
+ \t\t\t\tawait registration.ready;
480
487
  \t\t\t},
481
- \t\t});
482
-
483
- \t\treturn {
484
- \t\t\tstop() {
485
- \t\t\t\tunregister();
488
+ \t\t\tasync stop() {
489
+ \t\t\t\tawait registration?.unsubscribe();
486
490
  \t\t\t},
487
491
  \t\t};
488
492
  \t},
package/src/mcp.ts CHANGED
@@ -4,6 +4,7 @@ import { McpServer, ResourceTemplate } from "@modelcontextprotocol/server";
4
4
  import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
5
5
  import { z } from "zod/v4";
6
6
  import { mapApp, projectAppMap } from "./app-map.js";
7
+ import { mapAppChanges } from "./app-map-changes.js";
7
8
  import { appMapNodeKinds } from "./app-map-schema.js";
8
9
  import { checkApp } from "./check.js";
9
10
  import {
@@ -579,8 +580,21 @@ export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
579
580
  "app_map",
580
581
  {
581
582
  description:
582
- "Return a deterministic graph of this Beignet app: features, contracts, routes, use cases, authorization, workflows, ports, providers, OpenAPI, tests, dependency edges, and validation findings. Project by feature or node kind to keep agent context focused.",
583
+ "Return a deterministic graph of this Beignet app, or map the current Git change set to bounded, source-backed concepts with changed=true. Changed mode is report-only and exposes unresolved evidence without claiming verification.",
583
584
  inputSchema: {
585
+ changed: z
586
+ .boolean()
587
+ .optional()
588
+ .describe(
589
+ "Map the current Git change set instead of returning the complete app graph.",
590
+ ),
591
+ base: z
592
+ .string()
593
+ .min(1)
594
+ .optional()
595
+ .describe(
596
+ "Include committed changes since the merge base of this already-local Git ref. Requires changed=true; Beignet never fetches it.",
597
+ ),
584
598
  feature: z
585
599
  .string()
586
600
  .optional()
@@ -598,6 +612,22 @@ export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
598
612
  },
599
613
  async (input) =>
600
614
  safeToolResult(async () => {
615
+ if (input.base !== undefined && !input.changed) {
616
+ throw new Error("app_map base requires changed=true.");
617
+ }
618
+ if (
619
+ input.changed &&
620
+ (input.feature !== undefined ||
621
+ input.kinds !== undefined ||
622
+ input.includeDiagnostics !== undefined)
623
+ ) {
624
+ throw new Error(
625
+ "app_map changed=true cannot be combined with feature, kinds, or includeDiagnostics.",
626
+ );
627
+ }
628
+ if (input.changed) {
629
+ return jsonResult(await mapAppChanges({ cwd, base: input.base }));
630
+ }
601
631
  const result = await mapApp({ cwd, strict: true });
602
632
  return jsonResult(
603
633
  projectAppMap(result, {
@@ -1166,7 +1196,7 @@ export function buildBeignetMcpServer(options: McpServerOptions): McpServer {
1166
1196
  async function readAppGuidance(cwd: string): Promise<string> {
1167
1197
  const guidancePath = await resolveAppGuidancePath(cwd);
1168
1198
  if (!guidancePath) {
1169
- return "# Beignet app guidance\n\nNo app-local AGENTS.md was found. Use the app_map and explain tools before editing, prefer Beignet generators, and run check after changes.\n";
1199
+ return "# Beignet app guidance\n\nNo app-local AGENTS.md was found. Use app_map and explain before editing, prefer Beignet generators, use app_map with changed=true to inspect potential consumers after editing in Git, and then run check.\n";
1170
1200
  }
1171
1201
 
1172
1202
  let file: Awaited<ReturnType<typeof open>>;