@akanjs/devkit 3.2.0-rc.0 → 3.2.0-rc.2

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 (38) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/akanContext.ts +1 -3
  3. package/akanMcpContract.ts +4 -3
  4. package/artifact/storeBootstrap.ts +3 -2
  5. package/codeAgent/skills/akan-scaffold/SKILL.md +2 -1
  6. package/commandDecorators/command.ts +35 -18
  7. package/commandDecorators/commandDecorators.test.ts +75 -3
  8. package/commandDecorators/targetMeta.ts +2 -0
  9. package/constantFieldNames.test.ts +34 -0
  10. package/constantFieldNames.ts +18 -0
  11. package/createTunnel.test.ts +12 -2
  12. package/frontendBuild/buildRouteClient.test.ts +43 -0
  13. package/frontendBuild/clientEntryDiscovery.test.ts +22 -0
  14. package/frontendBuild/clientEntryDiscovery.ts +28 -3
  15. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  16. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  17. package/frontendBuild/csrDevBundler.ts +12 -2
  18. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  19. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  20. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  21. package/frontendBuild/csrDevPatcher.ts +47 -7
  22. package/frontendBuild/csrDevTypes.ts +16 -0
  23. package/frontendBuild/cssCompiler.test.ts +63 -1
  24. package/frontendBuild/devBarrelImports.ts +132 -0
  25. package/frontendBuild/devRegistryBundler.ts +19 -4
  26. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  27. package/frontendBuild/ssrDevBundler.ts +13 -2
  28. package/package.json +3 -3
  29. package/preview/PreviewChrome.ts +3 -3
  30. package/preview/PreviewView.ts +26 -8
  31. package/preview/previewView.test.ts +87 -3
  32. package/transforms/barrelAnalyzer.ts +46 -1
  33. package/transforms/barrelImportsPlugin.ts +61 -45
  34. package/transforms/transforms.test.ts +67 -1
  35. package/workflow/artifacts.ts +17 -0
  36. package/workflow/plan.ts +54 -14
  37. package/workspaceGraph.test.ts +116 -3
  38. package/workspaceGraph.ts +6 -3
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { Logger } from "akanjs/common";
3
4
  import { parseNamedList } from "./moduleSyntax";
@@ -41,6 +42,8 @@ export class BarrelAnalyzer {
41
42
  readonly #logger = new Logger("BarrelAnalyzer");
42
43
  readonly #opts: BarrelAnalyzerOptions;
43
44
  readonly #cache = new Map<string, Promise<BarrelExportMap | null>>();
45
+ readonly #read = new Map<string, Set<string>>();
46
+ readonly #realDirs = new Map<string, string>();
44
47
  readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
45
48
  readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
46
49
 
@@ -56,12 +59,44 @@ export class BarrelAnalyzer {
56
59
  return promise;
57
60
  }
58
61
 
62
+ async filesOf(pkgName: string): Promise<string[]> {
63
+ await this.analyze(pkgName);
64
+ return [...(this.#read.get(pkgName) ?? [])];
65
+ }
66
+
67
+ //? A file beside one the analysis read counts too: created, it can take over a star export's specifier (`Foo.tsx`
68
+ //? beside `Foo/index.tsx`) with no read file changed. Both sides by realpath: a watcher names a symlinked lib's
69
+ //? files by their real path, where the walk went through the link.
70
+ forget(files: string[]): Map<string, Promise<BarrelExportMap | null>> {
71
+ const dirs = new Set(files.map((file) => realDirOf(file)));
72
+ const forgotten = new Map<string, Promise<BarrelExportMap | null>>();
73
+ for (const [pkgName, read] of this.#read) {
74
+ const analysis = this.#cache.get(pkgName);
75
+ if (!analysis || ![...read].some((file) => dirs.has(this.#realDirOf(file)))) continue;
76
+ forgotten.set(pkgName, analysis);
77
+ this.#cache.delete(pkgName);
78
+ this.#read.delete(pkgName);
79
+ }
80
+ return forgotten;
81
+ }
82
+
83
+ #realDirOf(file: string): string {
84
+ let dir = this.#realDirs.get(file);
85
+ if (dir === undefined) {
86
+ dir = realDirOf(file);
87
+ this.#realDirs.set(file, dir);
88
+ }
89
+ return dir;
90
+ }
91
+
59
92
  async #analyzeSafe(pkgName: string): Promise<BarrelExportMap | null> {
60
93
  try {
61
94
  const pkg = await this.#opts.resolvePackage(pkgName);
62
95
  if (!pkg) return null;
63
96
  const map: BarrelExportMap = new Map();
64
- await this.#walk(pkg.entryFile, pkg, map, new Set());
97
+ const read = new Set<string>();
98
+ this.#read.set(pkgName, read);
99
+ await this.#walk(pkg.entryFile, pkg, map, read);
65
100
  return map;
66
101
  } catch (err) {
67
102
  this.#logger.error(`analyze failed for ${pkgName}: ${(err as Error).message}`);
@@ -170,6 +205,16 @@ export class BarrelAnalyzer {
170
205
  }
171
206
  }
172
207
 
208
+ const realDirOf = (file: string): string => {
209
+ const dir = path.dirname(path.resolve(file));
210
+ try {
211
+ return fs.realpathSync.native(dir);
212
+ } catch {
213
+ // A directory removed with the file keeps its own path.
214
+ return dir;
215
+ }
216
+ };
217
+
173
218
  const isRelative = (spec: string): boolean => {
174
219
  return spec.startsWith("./") || spec.startsWith("../") || spec === "." || spec === "..";
175
220
  };
@@ -4,7 +4,7 @@ import ts from "typescript";
4
4
  import { StoreBootstrap } from "../artifact/storeBootstrap";
5
5
  import type { App } from "../commandDecorators";
6
6
  import { BarrelAnalyzer, type BarrelExportMap, type PackageEntry } from "./barrelAnalyzer";
7
- import { loaderFor, type NamedItem, parseNamedList } from "./moduleSyntax";
7
+ import { loaderFor, type NamedItem } from "./moduleSyntax";
8
8
 
9
9
  export interface BarrelImportsPluginOptions {
10
10
  /** Absolute paths whose content is returned unchanged (e.g. node_modules). */
@@ -234,10 +234,22 @@ const resolveFileCandidate = async (candidate: string): Promise<string | null> =
234
234
 
235
235
  const MACRO_ATTR_RE = /with\s*\{\s*type\s*:\s*["']macro["']\s*\}/;
236
236
 
237
+ export interface RewriteBarrelImportsOptions {
238
+ /** Each replacement takes one line plus the newlines the statement spanned, so later lines keep their numbers. */
239
+ keepLines?: boolean;
240
+ specifierOf?: (subpath: string) => string;
241
+ /** Called for each import of a listed barrel with the value names whose rewrite reads its export map. */
242
+ onImport?: (barrel: string, names: string[]) => void;
243
+ }
244
+
245
+ export const workspaceBarrels = (barrels: readonly string[]): string[] =>
246
+ barrels.filter((barrel) => /^@(?:apps|libs)\//.test(barrel));
247
+
237
248
  export const rewriteBarrelImports = async (
238
249
  source: string,
239
250
  barrels: string[],
240
251
  analyzer: BarrelAnalyzer,
252
+ { keepLines = false, specifierOf = (subpath: string) => subpath, onImport }: RewriteBarrelImportsOptions = {},
241
253
  ): Promise<string | null> => {
242
254
  // Sound pre-filter before the costly parse: a static import cannot name a specifier absent from the text.
243
255
  if (!barrels.some((barrel) => source.includes(barrel))) return null;
@@ -251,20 +263,32 @@ export const rewriteBarrelImports = async (
251
263
  const stmt = statements[i];
252
264
  if (!stmt) continue;
253
265
  if (!barrels.includes(stmt.specifier)) continue;
266
+ const names = valueNamesOf(stmt.clause);
267
+ if (names.length > 0) onImport?.(stmt.specifier, names);
254
268
  const map = await analyzer.analyze(stmt.specifier);
255
269
  if (!map || map.size === 0) continue;
256
- const replacement = rewriteSingleStatement(stmt, map);
257
- if (replacement === null) continue;
270
+ const lines = rewriteSingleStatement(stmt, map, specifierOf);
271
+ if (lines === null) continue;
272
+ const replacement = keepLines
273
+ ? `${lines.join(" ")}${"\n".repeat(source.slice(stmt.start, stmt.end).split("\n").length - 1)}`
274
+ : lines.join("\n");
258
275
  out = out.slice(0, stmt.start) + replacement + out.slice(stmt.end);
259
276
  changed = true;
260
277
  }
261
278
  return changed ? out : null;
262
279
  };
263
280
 
281
+ interface ParsedClause {
282
+ defaultImport?: string;
283
+ namespaceImport?: string;
284
+ named?: NamedItem[];
285
+ typeOnly: boolean;
286
+ }
287
+
264
288
  interface ImportStatement {
265
289
  start: number;
266
290
  end: number;
267
- clause: string;
291
+ clause: ParsedClause | null;
268
292
  specifier: string;
269
293
  }
270
294
 
@@ -286,52 +310,42 @@ const findImportStatements = (source: string): ImportStatement[] => {
286
310
  statements.push({
287
311
  start: statement.getStart(sourceFile),
288
312
  end: statement.end,
289
- clause: source.slice(importClause.getStart(sourceFile), importClause.end).trim(),
313
+ clause: clauseOf(importClause),
290
314
  specifier: statement.moduleSpecifier.text,
291
315
  });
292
316
  }
293
317
  return statements;
294
318
  };
295
319
 
296
- interface ParsedClause {
297
- defaultImport?: string;
298
- namespaceImport?: string;
299
- named?: NamedItem[];
300
- typeOnly: boolean;
301
- }
302
-
303
- const parseImportClause = (clause: string): ParsedClause | null => {
304
- let rest = clause.trim();
305
- let typeOnly = false;
306
- if (rest.startsWith("type ")) {
307
- typeOnly = true;
308
- rest = rest.slice(5).trim();
309
- }
310
- const parsed: ParsedClause = { typeOnly };
311
- const commaMatch = /^(\w+)\s*,\s*(.+)$/.exec(rest);
312
- if (commaMatch) {
313
- parsed.defaultImport = commaMatch[1];
314
- rest = commaMatch[2] ?? "";
315
- } else if (/^\w+$/.test(rest)) {
316
- parsed.defaultImport = rest;
317
- return parsed;
318
- }
319
- if (rest.startsWith("*")) {
320
- const ns = /^\*\s+as\s+(\w+)$/.exec(rest);
321
- if (!ns) return null;
322
- parsed.namespaceImport = ns[1];
323
- return parsed;
324
- }
325
- if (rest.startsWith("{")) {
326
- const close = rest.indexOf("}");
327
- if (close === -1) return null;
328
- parsed.named = parseNamedList(rest.slice(1, close));
320
+ const clauseOf = (importClause: ts.ImportClause): ParsedClause | null => {
321
+ const parsed: ParsedClause = { typeOnly: importClause.phaseModifier === ts.SyntaxKind.TypeKeyword };
322
+ if (importClause.name) parsed.defaultImport = importClause.name.text;
323
+ const bindings = importClause.namedBindings;
324
+ if (!bindings) return parsed;
325
+ if (ts.isNamespaceImport(bindings)) return { ...parsed, namespaceImport: bindings.name.text };
326
+ const named: NamedItem[] = [];
327
+ for (const element of bindings.elements) {
328
+ if (element.propertyName && !ts.isIdentifier(element.propertyName)) return null;
329
+ named.push({
330
+ imported: (element.propertyName ?? element.name).text,
331
+ local: element.name.text,
332
+ isType: element.isTypeOnly,
333
+ });
329
334
  }
330
- return parsed;
335
+ return { ...parsed, named };
331
336
  };
332
337
 
333
- const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): string | null => {
334
- const clause = parseImportClause(stmt.clause);
338
+ const valueNamesOf = (clause: ParsedClause | null): string[] => {
339
+ if (!clause || clause.namespaceImport || clause.typeOnly) return [];
340
+ return (clause.named ?? []).filter((item) => !item.isType).map((item) => item.imported);
341
+ };
342
+
343
+ const rewriteSingleStatement = (
344
+ stmt: ImportStatement,
345
+ map: BarrelExportMap,
346
+ specifierOf: (subpath: string) => string,
347
+ ): string[] | null => {
348
+ const { clause } = stmt;
335
349
  if (!clause) return null;
336
350
  // Namespace imports need the whole barrel — cannot rewrite safely.
337
351
  if (clause.namespaceImport) return null;
@@ -353,7 +367,7 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
353
367
  continue;
354
368
  }
355
369
  if (target.namespace) {
356
- namespaceLines.push(`import * as ${item.local} from "${target.subpath}";`);
370
+ namespaceLines.push(`import * as ${item.local} from ${JSON.stringify(specifierOf(target.subpath))};`);
357
371
  continue;
358
372
  }
359
373
  const list = rewrites.get(target.subpath) ?? [];
@@ -364,7 +378,9 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
364
378
  if (rewrites.size === 0 && namespaceLines.length === 0) return null;
365
379
 
366
380
  //? An app or lib client barrel's import-time side effects are its client runtime and store, not its module UI.
367
- const lines: string[] = [...(StoreBootstrap.ofClientBarrel(stmt.specifier, map) ?? [])];
381
+ const lines = (StoreBootstrap.ofClientBarrel(stmt.specifier, map) ?? []).map(
382
+ (specifier) => `import ${JSON.stringify(specifierOf(specifier))};`,
383
+ );
368
384
 
369
385
  if (clause.defaultImport || remaining.length > 0) {
370
386
  const parts: string[] = [];
@@ -374,11 +390,11 @@ const rewriteSingleStatement = (stmt: ImportStatement, map: BarrelExportMap): st
374
390
  }
375
391
 
376
392
  for (const [subpath, items] of rewrites) {
377
- lines.push(`import { ${items.map(serializeNamedItem).join(", ")} } from "${subpath}";`);
393
+ lines.push(`import { ${items.map(serializeNamedItem).join(", ")} } from ${JSON.stringify(specifierOf(subpath))};`);
378
394
  }
379
395
  lines.push(...namespaceLines);
380
396
 
381
- return lines.join("\n");
397
+ return lines;
382
398
  };
383
399
 
384
400
  const serializeNamedItem = (item: NamedItem): string => {
@@ -1,4 +1,5 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { ApplicationBuildReporter } from "../applicationBuildReporter";
4
5
  import { tempDirs, writeText as write } from "../testHelpers";
@@ -262,6 +263,27 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
262
263
  expect(await rewriteBarrelImports(source, ["@libs/util/common"], analyzer)).toBeNull();
263
264
  });
264
265
 
266
+ test("forgets an analysis when a file beside one it read changes, under the link or the real path", async () => {
267
+ const root = await makeTempRoot();
268
+ await write(path.join(root, "real/ui/index.ts"), 'export * from "./Foo";\n');
269
+ await write(path.join(root, "real/ui/Foo.ts"), "export const Foo = 1;\n");
270
+ await write(path.join(root, "real/other/Bar.ts"), "export const Bar = 1;\n");
271
+ fs.symlinkSync(path.join(root, "real"), path.join(root, "linked"));
272
+ const pkgDir = path.join(root, "linked/ui");
273
+ const analyzer = new BarrelAnalyzer({
274
+ resolvePackage: async () => ({ pkgName: "@scope/ui", entryFile: path.join(pkgDir, "index.ts"), pkgDir }),
275
+ });
276
+ const analyzed = async () => {
277
+ await analyzer.analyze("@scope/ui");
278
+ return analyzer;
279
+ };
280
+
281
+ expect([...(await analyzed()).forget([path.join(root, "real/other/Bar.ts")]).keys()]).toEqual([]);
282
+ expect([...(await analyzed()).forget([path.join(root, "real/ui/Foo.ts")]).keys()]).toEqual(["@scope/ui"]);
283
+ expect([...(await analyzed()).forget([path.join(pkgDir, "Created.ts")]).keys()]).toEqual(["@scope/ui"]);
284
+ expect([...analyzer.forget([path.join(pkgDir, "Foo.ts")]).keys()]).toEqual([]);
285
+ });
286
+
265
287
  test("rewrites flattenable named imports and preserves default, type, and unknown imports", async () => {
266
288
  const analyzer = fakeAnalyzer([
267
289
  ["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
@@ -287,6 +309,27 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
287
309
  expect(await rewriteBarrelImports('import { A } from "@other/pkg";', ["@scope/pkg"], analyzer)).toBeNull();
288
310
  });
289
311
 
312
+ test("reads every name of an import whose braces hold comments, aliases and type-only names", async () => {
313
+ const analyzer = fakeAnalyzer([
314
+ ["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
315
+ ["st", { subpath: "@scope/pkg/store", originalName: "st" }],
316
+ ]);
317
+ const rewrite = async (source: string) => await rewriteBarrelImports(source, ["@scope/pkg"], analyzer);
318
+ const narrowed = 'import { A } from "@scope/pkg/leaf";\nimport { st } from "@scope/pkg/store";\n';
319
+
320
+ expect(await rewrite('import {\n A, // the leaf\n st,\n} from "@scope/pkg";\n')).toBe(narrowed);
321
+ expect(await rewrite('import { A, /* the store */ st } from "@scope/pkg";\n')).toBe(narrowed);
322
+ expect(await rewrite('import { type Shape, A as Leaf, st as store } from "@scope/pkg";\n')).toBe(
323
+ [
324
+ 'import { type Shape } from "@scope/pkg";',
325
+ 'import { A as Leaf } from "@scope/pkg/leaf";',
326
+ 'import { st as store } from "@scope/pkg/store";',
327
+ "",
328
+ ].join("\n"),
329
+ );
330
+ expect(await rewrite('import { "A" as quoted } from "@scope/pkg";\n')).toBeNull();
331
+ });
332
+
290
333
  test("keeps a client barrel's runtime and store, never its module UI, when flattening an app or lib import", async () => {
291
334
  const root = await makeTempRoot();
292
335
  for (const sys of ["apps/demo", "libs/kit"]) {
@@ -308,13 +351,15 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
308
351
  }
309
352
  await write(path.join(root, "libs/hand/client.ts"), 'export { thing } from "./lib/thing";\n');
310
353
  await write(path.join(root, "libs/hand/lib/thing.ts"), "export const thing = 1;\n");
354
+ await write(path.join(root, "libs/half/client.ts"), 'export { st } from "./lib/st";\n');
355
+ await write(path.join(root, "libs/half/lib/st.ts"), "export const st = {};\n");
311
356
  const analyzer = new BarrelAnalyzer({
312
357
  resolvePackage: await createTsconfigPackageResolver({
313
358
  workspace: { workspaceRoot: root },
314
359
  getTsConfig: async () => ({ compilerOptions: { paths: { "@apps/*": ["./apps/*"], "@libs/*": ["./libs/*"] } } }),
315
360
  } as never),
316
361
  });
317
- const barrels = ["@apps/demo/client", "@libs/kit/client", "@libs/hand/client"];
362
+ const barrels = ["@apps/demo/client", "@libs/kit/client", "@libs/hand/client", "@libs/half/client"];
318
363
  const rewrite = async (source: string) => await rewriteBarrelImports(source, barrels, analyzer);
319
364
 
320
365
  expect(await rewrite('import { Memo, st } from "@apps/demo/client";\n')).toBe(
@@ -348,6 +393,27 @@ describe("BarrelAnalyzer and rewriteBarrelImports", () => {
348
393
  expect(await rewrite('import { thing } from "@libs/hand/client";\n')).toBe(
349
394
  ['import "@libs/hand/client";', 'import { thing } from "@libs/hand/lib/thing";', ""].join("\n"),
350
395
  );
396
+ expect(await rewrite('import { st } from "@libs/half/client";\n')).toBe(
397
+ ['import "@libs/half/client";', 'import { st } from "@libs/half/lib/st";', ""].join("\n"),
398
+ );
399
+ });
400
+
401
+ test("keeps each statement's line count and writes the specifier specifierOf gives a target", async () => {
402
+ const analyzer = fakeAnalyzer([
403
+ ["A", { subpath: "@scope/pkg/leaf", originalName: "A" }],
404
+ ["ns", { subpath: "@scope/pkg/namespace", originalName: "ns", namespace: true }],
405
+ ]);
406
+ const source = 'import {\n A,\n ns,\n} from "@scope/pkg";\nconsole.log(A, ns);\n';
407
+
408
+ const rewritten = await rewriteBarrelImports(source, ["@scope/pkg"], analyzer, {
409
+ keepLines: true,
410
+ specifierOf: (subpath) => `./${subpath.slice("@scope/pkg/".length)}.ts`,
411
+ });
412
+
413
+ expect(rewritten).toBe(
414
+ 'import { A } from "./leaf.ts"; import * as ns from "./namespace.ts";\n\n\n\nconsole.log(A, ns);\n',
415
+ );
416
+ expect(rewritten?.split("\n")).toHaveLength(source.split("\n").length);
351
417
  });
352
418
 
353
419
  test("does not rewrite import-looking code inside template literals", async () => {
@@ -16,6 +16,7 @@ import type {
16
16
  WorkflowNextActionCode,
17
17
  WorkflowOverallStatus,
18
18
  WorkflowPlan,
19
+ WorkflowRecommendation,
19
20
  WorkflowRunArtifact,
20
21
  WorkflowRunSource,
21
22
  WorkflowValidationCommandResult,
@@ -133,6 +134,11 @@ export const createWorkflowApplyReport = ({
133
134
  };
134
135
  };
135
136
 
137
+ export const withoutApplyHints = (recommendations: readonly WorkflowRecommendation[]) =>
138
+ recommendations.filter(
139
+ (recommendation) => recommendation.kind !== "auto-apply" && recommendation.kind !== "validation",
140
+ );
141
+
136
142
  export const resolveWorkflowCommand = (command: string, plan: WorkflowPlan) => {
137
143
  const target = typeof plan.inputs.app === "string" ? plan.inputs.app : "<app-or-lib>";
138
144
  return command
@@ -526,6 +532,17 @@ export const createWorkflowValidationRunReport = async ({
526
532
  };
527
533
 
528
534
  export const createDryRunWorkflowApplyReport = (plan: WorkflowPlan) => {
535
+ if (workflowStatus(plan.diagnostics) === "failed")
536
+ return createWorkflowApplyReport({
537
+ workflow: plan.workflow,
538
+ mode: "dry-run",
539
+ changedFiles: [],
540
+ generatedFiles: [],
541
+ diagnostics: [...plan.diagnostics],
542
+ recommendations: withoutApplyHints(plan.recommendations),
543
+ nextActions: [],
544
+ plan,
545
+ });
529
546
  const changedFiles: PrimitiveChangedFile[] = plan.predictedChanges.flatMap((change) => {
530
547
  if (change.action !== "create" && change.action !== "modify") return [];
531
548
  return [
package/workflow/plan.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { capitalize } from "akanjs/common";
2
2
  import type { Sys, Workspace } from "../commandDecorators";
3
- import { workflowPlanApproval } from "./artifacts";
3
+ import { withoutApplyHints, workflowPlanApproval } from "./artifacts";
4
4
  import { resolveWorkflowSys } from "./executor";
5
5
  import { InteractionScaffold } from "./interactionScaffold";
6
6
  import { KeyRename } from "./keyRename";
@@ -17,6 +17,7 @@ import type {
17
17
  WorkflowSurfaceMode,
18
18
  } from "./types";
19
19
  import { addFieldUiPolicyForType } from "./uiPolicy";
20
+ import { workflowStatus } from "./utils";
20
21
 
21
22
  const surfaceModes = new Set<WorkflowSurfaceMode>(["infer", "include", "skip"]);
22
23
 
@@ -487,7 +488,7 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
487
488
  .map(async (name) => await resolveWorkflowSys(workspace, name)),
488
489
  );
489
490
  const holding = await Promise.all(
490
- others.map(async (other) => (other && (await other.readdir("lib")).includes(module) ? other : null)),
491
+ others.map(async (other) => (other && (await other.getDatabaseModules()).includes(module) ? other : null)),
491
492
  );
492
493
  return holding.filter((other): other is Sys => other !== null);
493
494
  };
@@ -495,24 +496,53 @@ const syssHolding = async (workspace: Workspace, module: string, except: Sys) =>
495
496
  const moduleMissing = async (workspace: Workspace, sys: Sys, module: string): Promise<WorkflowDiagnostic | null> => {
496
497
  if ((await sys.readdir("lib")).includes(module)) return null;
497
498
  const root = getSysRoot(sys);
498
- const modules = [...(await sys.getDatabaseModules()), ...(await sys.getServiceModules())];
499
- const sameName = modules.find((name) => name.replace(/^_/, "").toLowerCase() === module.toLowerCase());
499
+ const modules = await sys.getDatabaseModules();
500
+ const sameName = modules.find((name) => name.toLowerCase() === module.toLowerCase());
501
+ const service = (await sys.getServiceModules()).find(
502
+ (name) => name.replace(/^_/, "").toLowerCase() === module.replace(/^_/, "").toLowerCase(),
503
+ );
500
504
  const isScalar = (await sys.getScalarModules()).includes(module);
501
- const [holder] = sameName || isScalar ? [] : await syssHolding(workspace, module, sys);
505
+ const [holder] = sameName || service || isScalar ? [] : await syssHolding(workspace, module, sys);
502
506
  return {
503
507
  severity: "error",
504
508
  code: "workflow-module-missing",
505
509
  input: "module",
506
510
  message: [
507
511
  `No module named ${module} in ${root}: ${root}/lib/${module} does not exist.`,
508
- modules.length ? `Its modules are ${modules.join(", ")}.` : `${root} has no modules yet.`,
512
+ modules.length ? `Its database modules are ${modules.join(", ")}.` : `${root} has no database modules yet.`,
509
513
  sameName
510
514
  ? `Did you mean ${sameName}?`
511
- : isScalar
512
- ? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
513
- : holder
514
- ? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
515
- : `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
515
+ : service
516
+ ? `${root}/lib/${service} is a service module, which stores no model, and this workflow writes only into a database module.`
517
+ : isScalar
518
+ ? `${root}/lib/__scalar/${module} is a scalar, and this workflow writes only into a module under ${root}/lib.`
519
+ : holder
520
+ ? `${module} is in ${getSysRoot(holder)}, so plan this with --app ${holder.name}.`
521
+ : `If ${module} is a new model, create it with the create-module workflow first, then plan this again.`,
522
+ ].join(" "),
523
+ };
524
+ };
525
+
526
+ const moduleNotDatabase = async (sys: Sys, spec: WorkflowSpec, module: string): Promise<WorkflowDiagnostic | null> => {
527
+ const databaseModules = await sys.getDatabaseModules();
528
+ if (databaseModules.includes(module)) return null;
529
+ const root = getSysRoot(sys);
530
+ const isServiceModule = module.startsWith("_") && !module.startsWith("__");
531
+ return {
532
+ severity: "error",
533
+ code: "workflow-module-not-database",
534
+ input: "module",
535
+ message: [
536
+ isServiceModule
537
+ ? `${module} in ${root} is a service module, which stores no model.`
538
+ : `${root}/lib/${module} is not a database module: it has no ${module}.constant.ts.`,
539
+ `${spec.name} needs a database module, one with lib/<model>/<model>.constant.ts.`,
540
+ databaseModules.length
541
+ ? `Its database modules are ${databaseModules.join(", ")}.`
542
+ : `${root} has no database modules yet.`,
543
+ spec.inputs.field
544
+ ? "Add the field to one of them, or create its model with the create-module workflow first; a value several models embed is a scalar, made with create-scalar and added to a model as a field of that type."
545
+ : "Plan this on one of them, or create the model with the create-module workflow first.",
516
546
  ].join(" "),
517
547
  };
518
548
  };
@@ -539,11 +569,11 @@ export const workflowTargetDiagnostics = async (
539
569
  const module = planString(inputs, "module");
540
570
  const moduleSpec: WorkflowInputSpec | undefined = spec.inputs.module;
541
571
  if (!module || !moduleSpec?.mustExist) return [];
542
- const missing = await moduleMissing(workspace, sys, module);
543
- return missing ? [missing] : [];
572
+ const refused = (await moduleMissing(workspace, sys, module)) ?? (await moduleNotDatabase(sys, spec, module));
573
+ return refused ? [refused] : [];
544
574
  };
545
575
 
546
- export const inspectWorkflowPlan = async (
576
+ const inspectPlanTarget = async (
547
577
  workspace: Workspace,
548
578
  spec: WorkflowSpec,
549
579
  plan: WorkflowPlan,
@@ -586,3 +616,13 @@ export const inspectWorkflowPlan = async (
586
616
  recommendations: [...plan.recommendations, ...inspected.recommendations],
587
617
  };
588
618
  };
619
+
620
+ export const inspectWorkflowPlan = async (
621
+ workspace: Workspace,
622
+ spec: WorkflowSpec,
623
+ plan: WorkflowPlan,
624
+ ): Promise<WorkflowPlan> => {
625
+ const inspected = await inspectPlanTarget(workspace, spec, plan);
626
+ if (workflowStatus(inspected.diagnostics) === "passed") return inspected;
627
+ return { ...inspected, recommendations: withoutApplyHints(inspected.recommendations) };
628
+ };
@@ -1,7 +1,16 @@
1
- import { describe, expect, it } from "bun:test";
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
2
5
  import type { AkanChangeMap, AkanGraphEdge, AkanGraphNode } from "@akanjs/language";
6
+ import type { Workspace } from "./commandDecorators";
3
7
  import { WorkspaceGraph } from "./workspaceGraph";
4
8
 
9
+ const roots: string[] = [];
10
+ afterEach(async () => {
11
+ await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
12
+ });
13
+
5
14
  const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]): AkanGraphNode => ({
6
15
  id,
7
16
  kind: id.split(":")[0] as AkanGraphNode["kind"],
@@ -13,10 +22,15 @@ const node = (id: string, owner: string | null, change?: AkanGraphNode["change"]
13
22
  at: null,
14
23
  ...(change ? { change } : {}),
15
24
  });
16
- const edge = (from: string, to: string, change?: AkanGraphEdge["change"]): AkanGraphEdge => ({
25
+ const edge = (
26
+ from: string,
27
+ to: string,
28
+ change?: AkanGraphEdge["change"],
29
+ kind: AkanGraphEdge["kind"] = "calls",
30
+ ): AkanGraphEdge => ({
17
31
  from,
18
32
  to,
19
- kind: "calls",
33
+ kind,
20
34
  label: null,
21
35
  count: 1,
22
36
  at: null,
@@ -102,6 +116,60 @@ describe("WorkspaceGraph.answer", () => {
102
116
  });
103
117
  });
104
118
 
119
+ describe("WorkspaceGraph.answer past the node limit", () => {
120
+ const changeMapOf = (nodes: AkanGraphNode[], edges: AkanGraphEdge[]): AkanChangeMap => ({
121
+ version: 1,
122
+ nodes,
123
+ edges,
124
+ since: "0123456789ab",
125
+ summary: { added: 0, removed: 0, changed: nodes.filter((item) => item.change).length },
126
+ unmapped: [],
127
+ });
128
+
129
+ it("drops the layout that only wraps a changed route, and keeps the route", () => {
130
+ const fillers = Array.from({ length: 599 }, (_, idx) => node(`method:apps/demo:memo.m${idx}`, "apps/demo"));
131
+ const answer = WorkspaceGraph.answer(
132
+ {
133
+ version: 1,
134
+ nodes: [
135
+ node("layout:apps/demo:/", "apps/demo"),
136
+ node("route:apps/demo:/memo", "apps/demo", "changed"),
137
+ ...fillers,
138
+ ],
139
+ edges: [edge("layout:apps/demo:/", "route:apps/demo:/memo", undefined, "wraps")],
140
+ },
141
+ {},
142
+ );
143
+ if ("diagnostics" in answer) throw new Error("expected an answer");
144
+ expect(answer.nodes.filter((item) => item.kind !== "method").map(({ id }) => id)).toEqual([
145
+ "route:apps/demo:/memo",
146
+ ]);
147
+ expect(answer.edges).toEqual([]);
148
+ });
149
+
150
+ it("answers a Change Map whole once its unchanged pages are set aside", () => {
151
+ const routes = Array.from({ length: 10 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo"));
152
+ const changed = Array.from({ length: 595 }, (_, idx) =>
153
+ node(`method:apps/demo:memo.m${idx}`, "apps/demo", "changed"),
154
+ );
155
+ const answer = WorkspaceGraph.answerChanges(changeMapOf([...routes, ...changed], []), {});
156
+ if ("diagnostics" in answer) throw new Error("expected an answer");
157
+ expect(answer.nodes).toHaveLength(595);
158
+ expect(answer.summary).toEqual({ added: 0, removed: 0, changed: 595 });
159
+ });
160
+
161
+ it("counts the changed pages it keeps when it names the owners to scope to", () => {
162
+ const routes = Array.from({ length: 650 }, (_, idx) => node(`route:apps/demo:/r${idx}`, "apps/demo", "changed"));
163
+ const answer = WorkspaceGraph.answerChanges(
164
+ changeMapOf([...routes, node("method:libs/shared:file.touch", "libs/shared", "changed")], []),
165
+ {},
166
+ );
167
+ if (!("owners" in answer)) throw new Error("expected a scope request");
168
+ expect(answer.diagnostics.map(({ message }) => message).join("\n")).toContain("651 nodes");
169
+ expect(answer.owners).toEqual({ "apps/demo": 650, "libs/shared": 1 });
170
+ });
171
+ });
172
+
105
173
  describe("WorkspaceGraph.scope", () => {
106
174
  it("keeps a lib's guard without pulling in every app endpoint that uses it", () => {
107
175
  const guard = "guard:libs/shared:Admin";
@@ -119,3 +187,48 @@ describe("WorkspaceGraph.scope", () => {
119
187
  expect(scoped.edges).toEqual([edge(own, guard)]);
120
188
  });
121
189
  });
190
+
191
+ describe("WorkspaceGraph.changesAt", () => {
192
+ it("maps an edit to a factory in one lib onto the guards it makes in another, and lists no file as unmapped", async () => {
193
+ const root = await mkdtemp(path.join(tmpdir(), "akan-graph-"));
194
+ roots.push(root);
195
+ const git = (...args: string[]) => {
196
+ const result = Bun.spawnSync(
197
+ ["git", "-c", "user.name=akan", "-c", "user.email=akan@example.com", "-c", "commit.gpgsign=false", ...args],
198
+ { cwd: root, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" } },
199
+ );
200
+ expect(result.exitCode).toBe(0);
201
+ return result.stdout.toString().trim();
202
+ };
203
+ const write = async (file: string, text: string) => {
204
+ await mkdir(path.dirname(path.join(root, file)), { recursive: true });
205
+ await writeFile(path.join(root, file), text);
206
+ };
207
+ const factory = (body: string) => `import type { Guard } from "akanjs/signal";
208
+ export const resourceGuard = (field: string) =>
209
+ class implements Guard {
210
+ canPass() {
211
+ return ${body};
212
+ }
213
+ };
214
+ `;
215
+ git("init", "-q", "-b", "main");
216
+ await write("libs/util/srvkit/resource.ts", factory("!!field"));
217
+ await write(
218
+ "apps/demo/srvkit/guards.ts",
219
+ `import { resourceGuard } from "@libs/util/srvkit";\nexport const Member = resourceGuard("member");\n`,
220
+ );
221
+ git("add", "-A");
222
+ git("commit", "-q", "--no-verify", "-m", "base");
223
+ await write("libs/util/srvkit/resource.ts", factory("field.length > 0"));
224
+ const changes = await WorkspaceGraph.changesAt(
225
+ { workspaceRoot: root } as unknown as Workspace,
226
+ git("rev-parse", "HEAD"),
227
+ );
228
+ expect(changes.nodes.filter((node) => node.change).map(({ id, change }) => `${change} ${id}`)).toEqual([
229
+ "changed guard:apps/demo:Member",
230
+ ]);
231
+ expect(changes.unmapped).toEqual([]);
232
+ expect(changes.nodes.some((node) => "sources" in node)).toBe(false);
233
+ });
234
+ });