@beignet/cli 0.0.51 → 0.0.53

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 (117) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +182 -19
  3. package/dist/analysis/layers.d.ts +2 -0
  4. package/dist/analysis/layers.d.ts.map +1 -1
  5. package/dist/analysis/layers.js +9 -2
  6. package/dist/analysis/layers.js.map +1 -1
  7. package/dist/analysis/port-wiring.d.ts +4 -2
  8. package/dist/analysis/port-wiring.d.ts.map +1 -1
  9. package/dist/analysis/port-wiring.js +41 -11
  10. package/dist/analysis/port-wiring.js.map +1 -1
  11. package/dist/analysis/source-files.d.ts +15 -0
  12. package/dist/analysis/source-files.d.ts.map +1 -0
  13. package/dist/analysis/source-files.js +52 -0
  14. package/dist/analysis/source-files.js.map +1 -0
  15. package/dist/analysis/source-index.d.ts +2 -1
  16. package/dist/analysis/source-index.d.ts.map +1 -1
  17. package/dist/analysis/source-index.js +34 -5
  18. package/dist/analysis/source-index.js.map +1 -1
  19. package/dist/analysis/workspace-routes.d.ts +8 -0
  20. package/dist/analysis/workspace-routes.d.ts.map +1 -0
  21. package/dist/analysis/workspace-routes.js +291 -0
  22. package/dist/analysis/workspace-routes.js.map +1 -0
  23. package/dist/analysis/workspace.d.ts +24 -3
  24. package/dist/analysis/workspace.d.ts.map +1 -1
  25. package/dist/analysis/workspace.js +184 -43
  26. package/dist/analysis/workspace.js.map +1 -1
  27. package/dist/app-map-changes.d.ts.map +1 -1
  28. package/dist/app-map-changes.js +24 -14
  29. package/dist/app-map-changes.js.map +1 -1
  30. package/dist/app-map.d.ts.map +1 -1
  31. package/dist/app-map.js +87 -81
  32. package/dist/app-map.js.map +1 -1
  33. package/dist/check.d.ts +12 -0
  34. package/dist/check.d.ts.map +1 -1
  35. package/dist/check.js +92 -25
  36. package/dist/check.js.map +1 -1
  37. package/dist/config.d.ts +7 -0
  38. package/dist/config.d.ts.map +1 -1
  39. package/dist/config.js +19 -0
  40. package/dist/config.js.map +1 -1
  41. package/dist/git-changes.d.ts +17 -1
  42. package/dist/git-changes.d.ts.map +1 -1
  43. package/dist/git-changes.js +118 -84
  44. package/dist/git-changes.js.map +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +23 -3
  47. package/dist/index.js.map +1 -1
  48. package/dist/inspect.d.ts.map +1 -1
  49. package/dist/inspect.js +424 -129
  50. package/dist/inspect.js.map +1 -1
  51. package/dist/lint.d.ts.map +1 -1
  52. package/dist/lint.js +108 -43
  53. package/dist/lint.js.map +1 -1
  54. package/dist/make/shared.js +1 -1
  55. package/dist/make.js +18 -1
  56. package/dist/make.js.map +1 -1
  57. package/dist/mcp.js +1 -1
  58. package/dist/mcp.js.map +1 -1
  59. package/dist/provider-add.js +2 -2
  60. package/dist/provider-add.js.map +1 -1
  61. package/dist/provider-audit.d.ts.map +1 -1
  62. package/dist/provider-audit.js +22 -6
  63. package/dist/provider-audit.js.map +1 -1
  64. package/dist/templates/agents.d.ts.map +1 -1
  65. package/dist/templates/agents.js +36 -13
  66. package/dist/templates/agents.js.map +1 -1
  67. package/dist/templates/base.js +5 -5
  68. package/dist/templates/db/mysql.d.ts.map +1 -1
  69. package/dist/templates/db/mysql.js +51 -22
  70. package/dist/templates/db/mysql.js.map +1 -1
  71. package/dist/templates/db/postgres.d.ts.map +1 -1
  72. package/dist/templates/db/postgres.js +60 -20
  73. package/dist/templates/db/postgres.js.map +1 -1
  74. package/dist/templates/db/sqlite.d.ts.map +1 -1
  75. package/dist/templates/db/sqlite.js +51 -23
  76. package/dist/templates/db/sqlite.js.map +1 -1
  77. package/dist/templates/shadcn.js +1 -1
  78. package/dist/templates/shadcn.js.map +1 -1
  79. package/dist/templates/shared.js +6 -6
  80. package/dist/templates/shared.js.map +1 -1
  81. package/dist/templates/testing.d.ts.map +1 -1
  82. package/dist/templates/testing.js +7 -19
  83. package/dist/templates/testing.js.map +1 -1
  84. package/dist/test-discovery.d.ts +9 -0
  85. package/dist/test-discovery.d.ts.map +1 -0
  86. package/dist/test-discovery.js +40 -0
  87. package/dist/test-discovery.js.map +1 -0
  88. package/package.json +8 -5
  89. package/skills/app-structure/SKILL.md +74 -7
  90. package/src/analysis/layers.ts +13 -2
  91. package/src/analysis/port-wiring.ts +68 -11
  92. package/src/analysis/source-files.ts +61 -0
  93. package/src/analysis/source-index.ts +56 -4
  94. package/src/analysis/workspace-routes.ts +385 -0
  95. package/src/analysis/workspace.ts +302 -59
  96. package/src/app-map-changes.ts +41 -13
  97. package/src/app-map.ts +114 -103
  98. package/src/check.ts +115 -25
  99. package/src/config.ts +33 -0
  100. package/src/git-changes.ts +218 -86
  101. package/src/index.ts +33 -5
  102. package/src/inspect.ts +608 -156
  103. package/src/lint.ts +152 -48
  104. package/src/make/shared.ts +1 -1
  105. package/src/make.ts +31 -1
  106. package/src/mcp.ts +1 -1
  107. package/src/provider-add.ts +2 -2
  108. package/src/provider-audit.ts +30 -10
  109. package/src/templates/agents.ts +36 -13
  110. package/src/templates/base.ts +5 -5
  111. package/src/templates/db/mysql.ts +51 -22
  112. package/src/templates/db/postgres.ts +60 -20
  113. package/src/templates/db/sqlite.ts +51 -23
  114. package/src/templates/shadcn.ts +1 -1
  115. package/src/templates/shared.ts +6 -6
  116. package/src/templates/testing.ts +12 -19
  117. package/src/test-discovery.ts +53 -0
package/src/app-map.ts CHANGED
@@ -6,6 +6,10 @@ import {
6
6
  isImportedContractDeclaration,
7
7
  } from "./analysis/contract-factories.js";
8
8
  import { analyzePortWiring } from "./analysis/port-wiring.js";
9
+ import {
10
+ isAnalysisSourceFile,
11
+ stripSourceFileExtension,
12
+ } from "./analysis/source-files.js";
9
13
  import {
10
14
  createSourceIndex,
11
15
  exportedVariableDeclarations,
@@ -19,7 +23,10 @@ import {
19
23
  import {
20
24
  type AnalysisWorkspace,
21
25
  createAnalysisWorkspace,
26
+ projectPath,
27
+ projectSource,
22
28
  } from "./analysis/workspace.js";
29
+ import { workspaceRouteContracts } from "./analysis/workspace-routes.js";
23
30
  import type { AppMapNodeKind } from "./app-map-schema.js";
24
31
  import { directoryPath } from "./config.js";
25
32
  import {
@@ -29,6 +36,11 @@ import {
29
36
  } from "./inspect.js";
30
37
  import { type LintDiagnostic, lintApp } from "./lint.js";
31
38
  import { auditProviders } from "./provider-audit.js";
39
+ import {
40
+ appTestRoots,
41
+ isAppTestFile,
42
+ isWithinAppTestRoots,
43
+ } from "./test-discovery.js";
32
44
 
33
45
  export type { AppMapNodeKind } from "./app-map-schema.js";
34
46
  export { appMapNodeKinds } from "./app-map-schema.js";
@@ -206,8 +218,8 @@ export async function mapApp(
206
218
  const definitions = new Map<string, DefinitionRecord>();
207
219
  const definitionBySource = new Map<string, string>();
208
220
  const registries: RegistryRecord[] = [];
209
- const featuresPath = directoryPath(workspace.config.paths.features);
210
- const featureNames = discoverFeatureNames(workspace, featuresPath);
221
+ const features = discoverFeatures(workspace);
222
+ const featureNames = [...features.keys()];
211
223
 
212
224
  const addNode = (node: AppMapNode) => {
213
225
  const existing = nodes.get(node.id);
@@ -225,11 +237,9 @@ export async function mapApp(
225
237
  };
226
238
  const addEdge = (edge: AppMapEdge) => edges.push(edge);
227
239
 
228
- for (const feature of featureNames) {
229
- const featureFiles = filesForFeature(
230
- workspace.files,
231
- featuresPath,
232
- feature,
240
+ for (const [feature, featurePath] of features) {
241
+ const featureFiles = workspace.files.filter((file) =>
242
+ file.startsWith(`${featurePath}/`),
233
243
  );
234
244
  const roles = summarizeFeatureFiles(featureFiles);
235
245
  addNode({
@@ -237,21 +247,31 @@ export async function mapApp(
237
247
  kind: "feature",
238
248
  name: feature,
239
249
  feature,
240
- source: { file: `${featuresPath}/${feature}` },
250
+ source: { file: featurePath },
241
251
  status: "declared",
242
252
  details: roles,
243
253
  });
254
+ }
244
255
 
245
- for (const file of featureFiles.filter(isTestFile)) {
246
- const id = `test:${file}`;
247
- addNode({
248
- id,
249
- kind: "test",
250
- name: path.posix.basename(file),
251
- feature,
252
- source: { file },
253
- status: "declared",
254
- });
256
+ for (const file of workspace.files.filter(
257
+ (candidate) =>
258
+ isAppTestFile(candidate) &&
259
+ isWithinAppTestRoots(
260
+ projectSource(workspace, candidate).file,
261
+ appTestRoots(projectSource(workspace, candidate).project.config.paths),
262
+ ),
263
+ )) {
264
+ const id = `test:${file}`;
265
+ const feature = featureForFile(file, workspace);
266
+ addNode({
267
+ id,
268
+ kind: "test",
269
+ name: path.posix.basename(file),
270
+ ...(feature ? { feature } : {}),
271
+ source: { file },
272
+ status: "declared",
273
+ });
274
+ if (feature) {
255
275
  addEdge(exactEdge("contains-test", featureId(feature), id, file));
256
276
  }
257
277
  }
@@ -260,7 +280,7 @@ export async function mapApp(
260
280
  const contractIdsByExport = new Map<string, string[]>();
261
281
  for (const contract of inspection.contracts) {
262
282
  const id = `contract:${contract.file}#${contract.exportName}`;
263
- const feature = featureForFile(contract.file, featuresPath);
283
+ const feature = featureForFile(contract.file, workspace);
264
284
  contractIds.set(symbolKey(contract.file, contract.exportName), id);
265
285
  const exports = contractIdsByExport.get(contract.exportName) ?? [];
266
286
  exports.push(id);
@@ -305,8 +325,8 @@ export async function mapApp(
305
325
  id,
306
326
  kind: "route",
307
327
  name: `${route.method} ${route.path}`,
308
- ...(featureForFile(route.file, featuresPath)
309
- ? { feature: featureForFile(route.file, featuresPath) }
328
+ ...(featureForFile(route.file, workspace)
329
+ ? { feature: featureForFile(route.file, workspace) }
310
330
  : {}),
311
331
  source: {
312
332
  file: route.handlerFile ?? route.file,
@@ -345,7 +365,7 @@ export async function mapApp(
345
365
  details: { type: "server" },
346
366
  });
347
367
  }
348
- const sourceFiles = workspace.files.filter(isTypeScriptSource);
368
+ const sourceFiles = workspace.files.filter(isAnalysisSourceFile);
349
369
  for (const file of sourceFiles) {
350
370
  const sourceFile = await workspace.readSourceFile(file);
351
371
  for (const { exportName, declaration } of exportedVariableDeclarations(
@@ -359,7 +379,7 @@ export async function mapApp(
359
379
  const runtimeName = literalText(useCaseCall.arguments[0]);
360
380
  if (runtimeName) {
361
381
  const kind = callMemberName(useCaseCall) as "command" | "query";
362
- const feature = featureForFile(file, featuresPath);
382
+ const feature = featureForFile(file, workspace);
363
383
  const id = `use-case:${runtimeName}`;
364
384
  const node: AppMapNode = {
365
385
  id,
@@ -402,7 +422,7 @@ export async function mapApp(
402
422
  : undefined;
403
423
  const runtimeName = definitionRuntimeName(call, definitionCall);
404
424
  if (kind && runtimeName) {
405
- const feature = featureForFile(file, featuresPath);
425
+ const feature = featureForFile(file, workspace);
406
426
  const id = `${kind}:${runtimeName}`;
407
427
  const status = definitionStatus(
408
428
  kind,
@@ -442,7 +462,7 @@ export async function mapApp(
442
462
 
443
463
  const policyCall = findCall(initializer, new Set(["definePolicy"]));
444
464
  if (policyCall && ts.isObjectLiteralExpression(policyCall.arguments[0])) {
445
- const feature = featureForFile(file, featuresPath);
465
+ const feature = featureForFile(file, workspace);
446
466
  const policyId = `policy:${file}#${exportName}`;
447
467
  addNode({
448
468
  id: policyId,
@@ -660,8 +680,11 @@ export async function mapApp(
660
680
  contractIdsByExport,
661
681
  routeIds,
662
682
  definitionBySource,
663
- featuresPath,
664
683
  diagnostics: inspection.diagnostics,
684
+ registeredGroups:
685
+ workspace.projects.length > 1
686
+ ? (await workspaceRouteContracts(workspace)).groups
687
+ : undefined,
665
688
  });
666
689
 
667
690
  await addOpenApiSurface({
@@ -695,7 +718,6 @@ export async function mapApp(
695
718
  await addUnresolvedLocalImports({ workspace, unresolved });
696
719
  await addFeatureDependencies({
697
720
  workspace,
698
- featuresPath,
699
721
  addEdge,
700
722
  featureNames: new Set(featureNames),
701
723
  });
@@ -912,13 +934,14 @@ async function addRouteGroups(args: {
912
934
  contractIdsByExport: Map<string, string[]>;
913
935
  routeIds: Map<string, string>;
914
936
  definitionBySource: Map<string, string>;
915
- featuresPath: string;
937
+ registeredGroups?: Set<string>;
916
938
  diagnostics: InspectDiagnostic[];
917
939
  }): Promise<void> {
918
940
  const routeFiles = args.workspace.files.filter(
919
941
  (file) =>
920
- featureForFile(file, args.featuresPath) !== undefined &&
921
- file.endsWith("/routes.ts"),
942
+ featureForFile(file, args.workspace) !== undefined &&
943
+ isAnalysisSourceFile(file) &&
944
+ stripSourceFileExtension(file).endsWith("/routes"),
922
945
  );
923
946
 
924
947
  for (const file of routeFiles) {
@@ -932,17 +955,20 @@ async function addRouteGroups(args: {
932
955
  const options = call?.arguments[0];
933
956
  if (!call || !options || !ts.isObjectLiteralExpression(options)) continue;
934
957
 
935
- const feature = featureForFile(file, args.featuresPath);
958
+ const feature = featureForFile(file, args.workspace);
936
959
  const groupName = literalText(
937
960
  propertyAssignment(options, "name")?.initializer,
938
961
  );
939
962
  const id = `route-group:${file}#${exportName}`;
940
- const missing = args.diagnostics.some(
941
- (diagnostic) =>
942
- diagnostic.code === "BEIGNET_ROUTE_GROUP_UNREGISTERED" &&
943
- diagnostic.subject?.file === file &&
944
- diagnostic.subject.exportName === exportName,
945
- );
963
+ const missing =
964
+ (args.registeredGroups !== undefined &&
965
+ !args.registeredGroups.has(`${file}#${exportName}`)) ||
966
+ args.diagnostics.some(
967
+ (diagnostic) =>
968
+ diagnostic.code === "BEIGNET_ROUTE_GROUP_UNREGISTERED" &&
969
+ diagnostic.subject?.file === file &&
970
+ diagnostic.subject.exportName === exportName,
971
+ );
946
972
  args.addNode({
947
973
  id,
948
974
  kind: "route-group",
@@ -1063,8 +1089,10 @@ async function addPorts(args: {
1063
1089
  }): Promise<void> {
1064
1090
  const portsFile = args.workspace.config.paths.ports;
1065
1091
  if (!args.workspace.fileSet.has(portsFile)) return;
1066
- const sourceFile = await args.workspace.readSourceFile(portsFile);
1067
- const analysis = await analyzePortWiring(args.workspace);
1092
+ const analysis = await analyzePortWiring(
1093
+ args.workspace,
1094
+ args.workspace.projects.length > 1 ? args.sourceIndex : undefined,
1095
+ );
1068
1096
  if (analysis.declarationIndeterminate) {
1069
1097
  args.unresolved.push({
1070
1098
  code: "app_ports_declaration_unresolved",
@@ -1081,11 +1109,12 @@ async function addPorts(args: {
1081
1109
  }
1082
1110
  for (const name of analysis.declared) {
1083
1111
  const property = analysis.properties.get(name);
1112
+ const propertyFile = analysis.propertyFiles.get(name) ?? portsFile;
1084
1113
  const feature = property
1085
1114
  ? await portFeature(
1086
1115
  property.type,
1087
- portsFile,
1088
- args.workspace.config.paths.features,
1116
+ propertyFile,
1117
+ args.workspace,
1089
1118
  args.sourceIndex,
1090
1119
  )
1091
1120
  : undefined;
@@ -1096,7 +1125,7 @@ async function addPorts(args: {
1096
1125
  name,
1097
1126
  ...(feature ? { feature } : {}),
1098
1127
  source: property
1099
- ? await args.sourceIndex.sourceLocation(portsFile, property)
1128
+ ? await args.sourceIndex.sourceLocation(propertyFile, property)
1100
1129
  : { file: args.workspace.config.paths.portWiring },
1101
1130
  status:
1102
1131
  analysis.bound.has(name) || analysis.deferred.has(name)
@@ -1108,7 +1137,7 @@ async function addPorts(args: {
1108
1137
  : analysis.deferred.has(name)
1109
1138
  ? "deferred"
1110
1139
  : "unresolved",
1111
- ...(property?.type ? { type: property.type.getText(sourceFile) } : {}),
1140
+ ...(property?.type ? { type: property.type.getText() } : {}),
1112
1141
  },
1113
1142
  });
1114
1143
  if (feature) {
@@ -1192,8 +1221,8 @@ async function addOutboxDeliveryGraph(args: {
1192
1221
  ]);
1193
1222
  for (const file of args.workspace.files) {
1194
1223
  if (
1195
- !isTypeScriptSource(file) ||
1196
- isTestFile(file) ||
1224
+ !isAnalysisSourceFile(file) ||
1225
+ isAppTestFile(file) ||
1197
1226
  ![...runtimeDirectories].some(
1198
1227
  (directory) =>
1199
1228
  directory === "." ||
@@ -1276,9 +1305,10 @@ function isProviderLifecycleSource(
1276
1305
  file: string,
1277
1306
  sourceFile: ts.SourceFile,
1278
1307
  ): boolean {
1308
+ const sourcePath = stripSourceFileExtension(file);
1279
1309
  if (
1280
- /(^|\/)provider(s)?\.ts$/.test(file) ||
1281
- /(^|\/)[^/]*provider[^/]*\.ts$/.test(file) ||
1310
+ /(^|\/)provider(s)?$/.test(sourcePath) ||
1311
+ /(^|\/)[^/]*provider[^/]*$/.test(sourcePath) ||
1282
1312
  /(^|\/)providers?\//.test(file)
1283
1313
  ) {
1284
1314
  return true;
@@ -1390,7 +1420,7 @@ async function addTables(args: {
1390
1420
  const schemaPrefix = `${infraDir}/db/schema/`;
1391
1421
  const files = args.workspace.files.filter(
1392
1422
  (file) =>
1393
- isTypeScriptSource(file) &&
1423
+ isAnalysisSourceFile(file) &&
1394
1424
  (file.startsWith(schemaPrefix) ||
1395
1425
  schemaSources.some((source) =>
1396
1426
  file.startsWith(staticPathPrefix(source)),
@@ -1429,7 +1459,6 @@ async function addTables(args: {
1429
1459
 
1430
1460
  async function addFeatureDependencies(args: {
1431
1461
  workspace: AnalysisWorkspace;
1432
- featuresPath: string;
1433
1462
  addEdge(edge: AppMapEdge): void;
1434
1463
  featureNames: Set<string>;
1435
1464
  }): Promise<void> {
@@ -1447,12 +1476,12 @@ async function addFeatureDependencies(args: {
1447
1476
  }
1448
1477
  >();
1449
1478
 
1450
- for (const file of args.workspace.files.filter(isTypeScriptSource)) {
1451
- const from = featureForFile(file, args.featuresPath);
1479
+ for (const file of args.workspace.files.filter(isAnalysisSourceFile)) {
1480
+ const from = featureForFile(file, args.workspace);
1452
1481
  if (!from || !args.featureNames.has(from)) continue;
1453
1482
  for (const reference of await localImportReferences(args.workspace, file)) {
1454
1483
  if (!reference.resolvedFile) continue;
1455
- const to = featureForFile(reference.resolvedFile, args.featuresPath);
1484
+ const to = featureForFile(reference.resolvedFile, args.workspace);
1456
1485
  if (!to || to === from || !args.featureNames.has(to)) continue;
1457
1486
  const key = `${from}->${to}`;
1458
1487
  const current = dependencies.get(key) ?? {
@@ -1496,7 +1525,7 @@ async function addUnresolvedLocalImports(args: {
1496
1525
  workspace: AnalysisWorkspace;
1497
1526
  unresolved: AppMapUnresolvedReference[];
1498
1527
  }): Promise<void> {
1499
- for (const file of args.workspace.files.filter(isTypeScriptSource)) {
1528
+ for (const file of args.workspace.files.filter(isAnalysisSourceFile)) {
1500
1529
  for (const reference of await localImportReferences(args.workspace, file)) {
1501
1530
  if (reference.resolvedFile) continue;
1502
1531
  args.unresolved.push({
@@ -1551,20 +1580,21 @@ async function addUnresolvedContractDeclarations(args: {
1551
1580
  }
1552
1581
 
1553
1582
  function appMapContractSourceFiles(workspace: AnalysisWorkspace): string[] {
1554
- const contractsPath = directoryPath(workspace.config.paths.contracts);
1555
- const featureOwned =
1556
- contractsPath === directoryPath(workspace.config.paths.features);
1557
-
1558
1583
  return workspace.files.filter((file) => {
1559
- if (!file.endsWith(".ts") || file.endsWith(".test.ts")) return false;
1584
+ if (!isAnalysisSourceFile(file) || isAppTestFile(file)) return false;
1585
+ const source = projectSource(workspace, file);
1586
+ const contractsPath = directoryPath(source.project.config.paths.contracts);
1587
+ const featureOwned =
1588
+ contractsPath === directoryPath(source.project.config.paths.features);
1589
+ const sourcePath = stripSourceFileExtension(source.file);
1560
1590
  if (featureOwned) {
1561
1591
  const prefix = `${contractsPath}/`;
1562
- if (!file.startsWith(prefix)) return false;
1563
- const parts = file.slice(prefix.length).split("/");
1564
- return parts.length === 2 && parts[1] === "contracts.ts";
1592
+ if (!source.file.startsWith(prefix)) return false;
1593
+ const parts = sourcePath.slice(prefix.length).split("/");
1594
+ return parts.length === 2 && parts[1] === "contracts";
1565
1595
  }
1566
1596
  return (
1567
- file === `${contractsPath}.ts` || file.startsWith(`${contractsPath}/`)
1597
+ sourcePath === contractsPath || sourcePath.startsWith(`${contractsPath}/`)
1568
1598
  );
1569
1599
  });
1570
1600
  }
@@ -1580,10 +1610,7 @@ async function registryRecord(args: {
1580
1610
  if (!initializer) return undefined;
1581
1611
  const registry = registryValues(initializer, args.exportName);
1582
1612
  if (!registry) return undefined;
1583
- const feature = featureForFile(
1584
- args.file,
1585
- directoryPath(args.workspace.config.paths.features),
1586
- );
1613
+ const feature = featureForFile(args.file, args.workspace);
1587
1614
  return {
1588
1615
  file: args.file,
1589
1616
  declaration: args.declaration,
@@ -1795,27 +1822,19 @@ function arrayExpressions(array: ts.ArrayLiteralExpression): ts.Expression[] {
1795
1822
  });
1796
1823
  }
1797
1824
 
1798
- function discoverFeatureNames(
1799
- workspace: AnalysisWorkspace,
1800
- featuresPath: string,
1801
- ): string[] {
1802
- const names = new Set<string>();
1803
- const prefix = `${featuresPath}/`;
1825
+ function discoverFeatures(workspace: AnalysisWorkspace): Map<string, string> {
1826
+ const features = new Map<string, string>();
1804
1827
  for (const file of workspace.files) {
1805
- if (!file.startsWith(prefix)) continue;
1806
- const feature = file.slice(prefix.length).split("/")[0];
1807
- if (feature && feature !== "shared") names.add(feature);
1828
+ const feature = featureForFile(file, workspace);
1829
+ if (!feature) continue;
1830
+ const source = projectSource(workspace, file);
1831
+ const root = directoryPath(source.project.config.paths.features);
1832
+ const name = source.file.slice(`${root}/`.length).split("/")[0];
1833
+ features.set(feature, projectPath(source.project, `${root}/${name}`));
1808
1834
  }
1809
- return [...names].sort();
1810
- }
1811
-
1812
- function filesForFeature(
1813
- files: string[],
1814
- featuresPath: string,
1815
- feature: string,
1816
- ): string[] {
1817
- const prefix = `${featuresPath}/${feature}/`;
1818
- return files.filter((file) => file.startsWith(prefix));
1835
+ return new Map(
1836
+ [...features].sort(([left], [right]) => left.localeCompare(right)),
1837
+ );
1819
1838
  }
1820
1839
 
1821
1840
  function summarizeFeatureFiles(files: string[]): Record<string, number> {
@@ -1824,24 +1843,26 @@ function summarizeFeatureFiles(files: string[]): Record<string, number> {
1824
1843
  client: files.filter((file) => file.includes("/client/")).length,
1825
1844
  components: files.filter((file) => file.includes("/components/")).length,
1826
1845
  domain: files.filter((file) => file.includes("/domain/")).length,
1827
- tests: files.filter(isTestFile).length,
1846
+ tests: files.filter(isAppTestFile).length,
1828
1847
  };
1829
1848
  }
1830
1849
 
1831
1850
  function featureForFile(
1832
1851
  file: string,
1833
- featuresPath: string,
1852
+ workspace: AnalysisWorkspace,
1834
1853
  ): string | undefined {
1835
- const prefix = `${featuresPath}/`;
1836
- if (!file.startsWith(prefix)) return undefined;
1837
- const feature = file.slice(prefix.length).split("/")[0];
1838
- return feature && feature !== "shared" ? feature : undefined;
1854
+ const source = projectSource(workspace, file);
1855
+ const prefix = `${directoryPath(source.project.config.paths.features)}/`;
1856
+ if (!source.file.startsWith(prefix)) return undefined;
1857
+ const feature = source.file.slice(prefix.length).split("/")[0];
1858
+ if (!feature || feature === "shared") return undefined;
1859
+ return source.project.prefix ? `${source.project.name}:${feature}` : feature;
1839
1860
  }
1840
1861
 
1841
1862
  async function portFeature(
1842
1863
  type: ts.TypeNode | undefined,
1843
1864
  file: string,
1844
- featuresPath: string,
1865
+ workspace: AnalysisWorkspace,
1845
1866
  sourceIndex: ReturnType<typeof createSourceIndex>,
1846
1867
  ): Promise<string | undefined> {
1847
1868
  if (
@@ -1852,9 +1873,7 @@ async function portFeature(
1852
1873
  return undefined;
1853
1874
  }
1854
1875
  const target = await sourceIndex.resolveName(file, type.typeName.text);
1855
- return target
1856
- ? featureForFile(target.file, directoryPath(featuresPath))
1857
- : undefined;
1876
+ return target ? featureForFile(target.file, workspace) : undefined;
1858
1877
  }
1859
1878
 
1860
1879
  function definitionStatus(
@@ -2017,14 +2036,6 @@ function sourceNodeEdge(
2017
2036
  };
2018
2037
  }
2019
2038
 
2020
- function isTestFile(file: string): boolean {
2021
- return /\.(test|spec)\.(ts|tsx|mts|cts)$/.test(file);
2022
- }
2023
-
2024
- function isTypeScriptSource(file: string): boolean {
2025
- return /\.(ts|tsx|mts|cts)$/.test(file) && !file.endsWith(".d.ts");
2026
- }
2027
-
2028
2039
  function staticPathPrefix(pattern: string): string {
2029
2040
  const wildcard = pattern.search(/[?*[{]/);
2030
2041
  return wildcard === -1 ? pattern : pattern.slice(0, wildcard);
package/src/check.ts CHANGED
@@ -1,7 +1,11 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
1
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { createPainter } from "./ansi.js";
4
4
  import { detectPackageManager, spawnCommand } from "./db.js";
5
+ import {
6
+ type GitSourceSnapshot,
7
+ readGitSourceSnapshot,
8
+ } from "./git-changes.js";
5
9
  import {
6
10
  applyDoctorFixes,
7
11
  formatDoctor,
@@ -33,6 +37,14 @@ export type CheckAppResult = {
33
37
  ok: boolean;
34
38
  fixes: InspectFix[];
35
39
  steps: CheckStep[];
40
+ provenance: CheckProvenance;
41
+ };
42
+
43
+ /** Source-state evidence captured around one complete check run. */
44
+ export type CheckProvenance = {
45
+ status: "current" | "stale" | "unavailable";
46
+ start: GitSourceSnapshot;
47
+ end: GitSourceSnapshot;
36
48
  };
37
49
 
38
50
  export type CheckAppOptions = {
@@ -74,19 +86,34 @@ const defaultMaxOutputBytes = 64 * 1024;
74
86
  export async function checkApp(
75
87
  options: CheckAppOptions = {},
76
88
  ): Promise<CheckAppResult> {
77
- const targetDir = path.resolve(options.cwd ?? process.cwd());
78
- const scripts = await readAppPackageScripts(targetDir);
79
- const runner = await detectPackageManager(targetDir);
89
+ options.signal?.throwIfAborted();
90
+ const targetDir = await resolveCheckTargetDir(options.cwd ?? process.cwd());
91
+ options.signal?.throwIfAborted();
92
+ // Validate the app root before fixes may write; reload execution metadata
93
+ // after the provenance baseline so the selected scripts are covered by it.
94
+ await readAppPackageScripts(targetDir);
95
+ options.signal?.throwIfAborted();
96
+ const fixes = options.fix
97
+ ? await applyDoctorFixes({ cwd: targetDir, strict: true })
98
+ : [];
99
+ options.signal?.throwIfAborted();
80
100
  const steps: CheckStep[] = [];
81
101
  const record = (step: CheckStep): void => {
82
102
  steps.push(step);
83
103
  options.onStep?.(step);
84
104
  };
85
105
 
106
+ const start = await readGitSourceSnapshot({
107
+ cwd: targetDir,
108
+ signal: options.signal,
109
+ });
110
+ options.signal?.throwIfAborted();
111
+ const scripts = await readAppPackageScripts(targetDir);
112
+ const runner = await detectPackageManager(targetDir);
86
113
  options.signal?.throwIfAborted();
87
114
  record(await lintStep(targetDir, options));
88
115
  options.signal?.throwIfAborted();
89
- const { step: doctorStep, fixes } = await runDoctorStep(targetDir, options);
116
+ const doctorStep = await runDoctorStep(targetDir, options, fixes);
90
117
  record(doctorStep);
91
118
 
92
119
  if (options.preflight || options.preflightConnect) {
@@ -105,16 +132,38 @@ export async function checkApp(
105
132
  record(await scriptStep(targetDir, runner, script, scripts, options));
106
133
  }
107
134
 
135
+ options.signal?.throwIfAborted();
136
+ const end = await readGitSourceSnapshot({
137
+ cwd: targetDir,
138
+ signal: options.signal,
139
+ });
140
+ options.signal?.throwIfAborted();
141
+ const provenance = checkProvenance(start, end);
142
+
108
143
  return {
109
144
  schemaVersion: 1,
110
145
  targetDir,
111
146
  runner,
112
- ok: steps.every((step) => step.status !== "failed"),
147
+ ok:
148
+ steps.every((step) => step.status !== "failed") &&
149
+ provenance.status !== "stale",
113
150
  fixes,
114
151
  steps,
152
+ provenance,
115
153
  };
116
154
  }
117
155
 
156
+ async function resolveCheckTargetDir(cwd: string): Promise<string> {
157
+ const targetDir = path.resolve(cwd);
158
+ try {
159
+ return await realpath(targetDir);
160
+ } catch (error) {
161
+ const code = (error as NodeJS.ErrnoException).code;
162
+ if (code === "ENOENT" || code === "ENOTDIR") return targetDir;
163
+ throw error;
164
+ }
165
+ }
166
+
118
167
  /**
119
168
  * Format a completed step as a single status line, with the failing step's
120
169
  * output indented underneath.
@@ -161,9 +210,35 @@ export function formatCheckSummary(
161
210
  const parts = [`${counts.passed} passed`];
162
211
  if (counts.failed > 0) parts.push(paint(`${counts.failed} failed`, "red"));
163
212
  if (counts.skipped > 0) parts.push(`${counts.skipped} skipped`);
213
+ if (result.provenance.status === "stale") {
214
+ parts.push(paint("source state stale", "red"));
215
+ }
164
216
  return parts.join(", ");
165
217
  }
166
218
 
219
+ /** Format the source state captured before and after validation. */
220
+ export function formatCheckProvenance(
221
+ result: CheckAppResult,
222
+ options: { color?: boolean } = {},
223
+ ): string {
224
+ const paint = createPainter(options.color);
225
+ const { provenance } = result;
226
+ if (provenance.status === "unavailable") {
227
+ return `${paint("-", "cyan")} Source provenance unavailable (not a Git worktree)`;
228
+ }
229
+ if (provenance.status === "stale") {
230
+ return `${paint("✗", "red")} Source state changed while checks were running; rerun beignet check`;
231
+ }
232
+ const snapshot = provenance.end;
233
+ if (snapshot.kind !== "git") {
234
+ throw new Error("Current check provenance must contain a Git snapshot.");
235
+ }
236
+ const head = snapshot.comparison.head
237
+ ? `HEAD ${snapshot.comparison.head.slice(0, 8)}`
238
+ : "unborn Git repository";
239
+ return `${paint("✓", "green")} Source state current (${head}, ${shortHash(snapshot.repositoryChangeHash)})`;
240
+ }
241
+
167
242
  /**
168
243
  * Format a full result at once (JSON-free callers and tests).
169
244
  */
@@ -172,6 +247,7 @@ export function formatCheck(
172
247
  options: { color?: boolean } = {},
173
248
  ): string {
174
249
  const lines = result.steps.map((step) => formatCheckStep(step, options));
250
+ lines.push(formatCheckProvenance(result, options));
175
251
  return `${lines.join("\n")}\n\n${formatCheckSummary(result, options)}`;
176
252
  }
177
253
 
@@ -195,11 +271,9 @@ async function lintStep(
195
271
  async function runDoctorStep(
196
272
  targetDir: string,
197
273
  options: CheckAppOptions,
198
- ): Promise<{ step: CheckStep; fixes: InspectFix[] }> {
274
+ fixes: InspectFix[],
275
+ ): Promise<CheckStep> {
199
276
  const startedAt = Date.now();
200
- const fixes = options.fix
201
- ? await applyDoctorFixes({ cwd: targetDir, strict: true })
202
- : [];
203
277
  const result = await inspectApp({ cwd: targetDir, strict: true });
204
278
  result.fixes = fixes;
205
279
  const failed = result.diagnostics.some(
@@ -208,24 +282,40 @@ async function runDoctorStep(
208
282
  );
209
283
 
210
284
  return {
211
- step: {
212
- name: "beignet doctor --strict",
213
- command: "beignet doctor --strict",
214
- status: failed ? "failed" : "passed",
215
- ...(fixes.length > 0
216
- ? {
217
- detail: `${fixes.length} ${fixes.length === 1 ? "fix" : "fixes"} applied`,
218
- }
219
- : {}),
220
- ...(failed
221
- ? { output: formatDoctor(result, { color: options.color }) }
222
- : {}),
223
- durationMs: Date.now() - startedAt,
224
- },
225
- fixes,
285
+ name: "beignet doctor --strict",
286
+ command: "beignet doctor --strict",
287
+ status: failed ? "failed" : "passed",
288
+ ...(fixes.length > 0
289
+ ? {
290
+ detail: `${fixes.length} ${fixes.length === 1 ? "fix" : "fixes"} applied`,
291
+ }
292
+ : {}),
293
+ ...(failed
294
+ ? { output: formatDoctor(result, { color: options.color }) }
295
+ : {}),
296
+ durationMs: Date.now() - startedAt,
226
297
  };
227
298
  }
228
299
 
300
+ function checkProvenance(
301
+ start: GitSourceSnapshot,
302
+ end: GitSourceSnapshot,
303
+ ): CheckProvenance {
304
+ if (start.kind === "unavailable" && end.kind === "unavailable") {
305
+ return { status: "unavailable", start, end };
306
+ }
307
+ const current =
308
+ start.kind === "git" &&
309
+ end.kind === "git" &&
310
+ start.repositoryRoot === end.repositoryRoot &&
311
+ start.repositoryChangeHash === end.repositoryChangeHash;
312
+ return { status: current ? "current" : "stale", start, end };
313
+ }
314
+
315
+ function shortHash(hash: string): string {
316
+ return `${hash.slice(0, 15)}…`;
317
+ }
318
+
229
319
  async function scriptStep(
230
320
  targetDir: string,
231
321
  runner: string,