@akanjs/devkit 3.2.0-rc.1 → 3.2.0-rc.10

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 (108) hide show
  1. package/CHANGELOG.md +233 -11
  2. package/agentAssets.ts +1 -1
  3. package/akanApp/akanApp.host.test.ts +127 -0
  4. package/akanApp/akanApp.host.ts +50 -57
  5. package/akanApp/devHostPolicy.test.ts +11 -11
  6. package/akanApp/devHostPolicy.ts +2 -4
  7. package/akanContext.ts +2 -5
  8. package/akanMcpContract.ts +21 -10
  9. package/akanMcpToolError.ts +16 -0
  10. package/apiReport/apiDeclarationPrinter.ts +120 -0
  11. package/apiReport/apiReport.test.ts +497 -0
  12. package/apiReport/apiReport.ts +232 -0
  13. package/apiReport/apiReportDiff.ts +126 -0
  14. package/apiReport/apiReportPolicy.ts +65 -0
  15. package/apiReport/apiTargets.ts +311 -0
  16. package/apiReport/changesetBump.ts +19 -0
  17. package/applicationBuildReporter.test.ts +21 -0
  18. package/applicationBuildReporter.ts +8 -0
  19. package/applicationBuildRunner.test.ts +67 -0
  20. package/applicationBuildRunner.ts +6 -1
  21. package/artifact/implicitRootLayout.test.ts +4 -0
  22. package/artifact/implicitRootLayout.ts +13 -1
  23. package/artifact/storeBootstrap.ts +3 -2
  24. package/bareErrorScanner.ts +126 -0
  25. package/biome.base.json +2 -1
  26. package/codeAgent/skills/akan-debug/SKILL.md +7 -3
  27. package/codeAgent/skills/akan-err/SKILL.md +7 -0
  28. package/codeAgent/skills/akan-mcp/SKILL.md +7 -1
  29. package/codeAgent/skills/akan-scaffold/SKILL.md +3 -2
  30. package/codeAgent/skills/akan-transport/SKILL.md +2 -0
  31. package/codeAgent/skills/akan-validate/SKILL.md +1 -0
  32. package/codeAgent/tools/McpClient.ts +28 -6
  33. package/codeAgent/tools/McpToolPack.ts +1 -1
  34. package/codeAgent/tools/akanCodePlugins.test.ts +13 -1
  35. package/commandDecorators/argMeta.ts +4 -0
  36. package/commandDecorators/cliError.ts +72 -0
  37. package/commandDecorators/command.ts +186 -96
  38. package/commandDecorators/commandDecorators.test.ts +240 -17
  39. package/commandDecorators/exitCodeError.ts +6 -7
  40. package/commandDecorators/index.ts +1 -0
  41. package/commandDecorators/targetMeta.ts +3 -0
  42. package/conventionSet.test.ts +5 -2
  43. package/csrE2e/csrE2eHarness.fixture.ts +1 -1
  44. package/errorCodeReference.test.ts +282 -0
  45. package/errorCodeReference.ts +71 -0
  46. package/errorCodeScan.ts +551 -0
  47. package/frontendBuild/allRoutesBuilder.test.ts +16 -4
  48. package/frontendBuild/allRoutesBuilder.ts +24 -6
  49. package/frontendBuild/buildRouteClient.test.ts +43 -0
  50. package/frontendBuild/bundleDefine.ts +2 -0
  51. package/frontendBuild/clientBuildTypes.ts +5 -2
  52. package/frontendBuild/clientEntryDiscovery.test.ts +80 -0
  53. package/frontendBuild/clientEntryDiscovery.ts +53 -9
  54. package/frontendBuild/csrArtifactBuilder.test.ts +48 -0
  55. package/frontendBuild/csrArtifactBuilder.ts +9 -3
  56. package/frontendBuild/csrDevBundler.ts +12 -2
  57. package/frontendBuild/csrDevModuleCompiler.test.ts +97 -0
  58. package/frontendBuild/csrDevModuleCompiler.ts +37 -8
  59. package/frontendBuild/csrDevPatcher.test.ts +89 -2
  60. package/frontendBuild/csrDevPatcher.ts +47 -7
  61. package/frontendBuild/csrDevTypes.ts +16 -0
  62. package/frontendBuild/cssCompiler.test.ts +63 -1
  63. package/frontendBuild/devBarrelImports.ts +132 -0
  64. package/frontendBuild/devRegistryBundler.ts +19 -4
  65. package/frontendBuild/mcpAppViewBuilder.test.ts +67 -0
  66. package/frontendBuild/nativeCore.test.ts +148 -0
  67. package/frontendBuild/precompressArtifacts.ts +1 -1
  68. package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
  69. package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
  70. package/frontendBuild/ssrDevBundler.ts +13 -2
  71. package/frontendBuild/vendorImportScanner.test.ts +24 -3
  72. package/frontendBuild/vendorImportScanner.ts +25 -10
  73. package/incrementalBuilder/incrementalBuilder.host.test.ts +30 -1
  74. package/incrementalBuilder/incrementalBuilder.host.ts +10 -22
  75. package/lint/__fixtures__/no-import-common-err/bad.tsx +13 -0
  76. package/lint/__fixtures__/no-import-common-err/good.tsx +12 -0
  77. package/lint/no-import-common-err.grit +31 -0
  78. package/lint/no-init-fetch-in-client.grit +1 -1
  79. package/lint/no-web-only-api-outside-webkit.grit +3 -3
  80. package/maintainabilityBaseline.test.ts +49 -0
  81. package/maintainabilityBaseline.ts +73 -8
  82. package/maintainabilityContract.test.ts +302 -1
  83. package/maintainabilityContract.ts +87 -22
  84. package/maintainabilityExceptionScanner.test.ts +14 -0
  85. package/maintainabilityExceptionScanner.ts +5 -1
  86. package/maintainabilityLimits.ts +12 -0
  87. package/maintainabilityReport.ts +20 -3
  88. package/moduleFieldNames.test.ts +56 -0
  89. package/moduleFieldNames.ts +25 -0
  90. package/package.json +3 -3
  91. package/preview/PreviewView.ts +32 -11
  92. package/preview/previewView.test.ts +76 -9
  93. package/routeJsBudget.test.ts +47 -11
  94. package/routeJsBudget.ts +88 -25
  95. package/transforms/barrelAnalyzer.ts +46 -1
  96. package/transforms/barrelImportsPlugin.ts +61 -45
  97. package/transforms/transforms.test.ts +67 -1
  98. package/typecheck/projectTypecheck.ts +5 -1
  99. package/typecheck/typecheck.proc.ts +3 -2
  100. package/typecheck/typecheckFailure.ts +6 -1
  101. package/workflow/artifacts.ts +25 -7
  102. package/workflow/fieldType.ts +92 -0
  103. package/workflow/index.ts +2 -0
  104. package/workflow/plainName.ts +28 -0
  105. package/workflow/plan.ts +67 -22
  106. package/workflow/source.ts +0 -5
  107. package/workspaceGraph.test.ts +200 -3
  108. package/workspaceGraph.ts +46 -8
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
  import path from "node:path";
3
- import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
3
+ import { AppExecutor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
4
4
  import { makeCliTempWorkspace, tempRoots, writeText } from "../testHelpers";
5
5
  import {
6
6
  App,
@@ -440,6 +440,7 @@ describe("enum arg type inference", () => {
440
440
 
441
441
  describe("a Sys that defaults to the only app", () => {
442
442
  const ownIsTTY = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
443
+ const ownCwd = process.cwd();
443
444
  const asked: unknown[] = [];
444
445
  let answer = "";
445
446
  const setTTY = (isTTY: boolean | undefined) =>
@@ -455,6 +456,7 @@ describe("a Sys that defaults to the only app", () => {
455
456
  });
456
457
  afterEach(() => {
457
458
  CommandContainer.clear();
459
+ process.chdir(ownCwd);
458
460
  if (ownIsTTY) Object.defineProperty(process.stdin, "isTTY", ownIsTTY);
459
461
  else Reflect.deleteProperty(process.stdin, "isTTY");
460
462
  });
@@ -476,6 +478,21 @@ describe("a Sys that defaults to the only app", () => {
476
478
  if (!meta) throw new Error(`no Sys meta on ${key}`);
477
479
  return meta;
478
480
  };
481
+ const track = tempRoots();
482
+ //? From the workspace root, as the CLI runs: getDirInModule stats its entries relative to the process directory.
483
+ const moduleWorkspace = async () => {
484
+ const { root, workspace } = track(await makeCliTempWorkspace());
485
+ process.chdir(root);
486
+ for (const [sys, name] of [
487
+ ["apps/web", "card"],
488
+ ["libs/util", "photo"],
489
+ ] as const) {
490
+ await writeText(path.join(root, sys, "akan.config.ts"), "export default {};\n");
491
+ await writeText(path.join(root, sys, "lib", name, `${name}.constant.ts`), "export {};\n");
492
+ }
493
+ await writeText(path.join(root, "apps/web/lib/_billing/billing.service.ts"), "export {};\n");
494
+ return workspace;
495
+ };
479
496
 
480
497
  test("the opted-in command takes a one-app workspace's app without asking, even on a piped stdin", async () => {
481
498
  setTTY(undefined);
@@ -525,19 +542,24 @@ describe("a Sys that defaults to the only app", () => {
525
542
  expect(asked).toEqual([]);
526
543
  });
527
544
 
528
- test("without a terminal, the only lib, pkg or module is not taken, as a command may be removing it", async () => {
545
+ test("without a terminal, a command that deletes nothing takes the only lib, pkg or module; a terminal is asked", async () => {
529
546
  setTTY(undefined);
530
547
  const workspace = workspaceWith([]);
531
548
  workspace.getExecs = async () => [[], ["util"], []];
532
549
  workspace.getPkgs = async () => ["tools"];
533
- const rerunOf = (name: string) => rerunCommandOf([name], [undefined], 0);
534
- await expect(
535
- getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace, rerunOf("remove-library")),
536
- ).rejects.toThrow("so rerun it as `akan remove-library <lib>` (choices: util).");
537
- await expect(
538
- getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace, rerunOf("remove-package")),
539
- ).rejects.toThrow("so rerun it as `akan remove-package <pkg>` (choices: tools).");
550
+ workspace.getDirInModule = async (basePath) => (basePath === "libs/util/lib" ? ["photo"] : []);
551
+ const lib = await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
552
+ expect(lib instanceof LibExecutor ? lib.name : null).toBe("util");
553
+ const pkg = await getInternalArgumentValue({ key: "", idx: 0, type: "Pkg" }, undefined, workspace);
554
+ expect(pkg instanceof PkgExecutor ? pkg.name : null).toBe("tools");
555
+ const mod = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, undefined, workspace);
556
+ expect(mod instanceof ModuleExecutor ? `${mod.sys.name}:${mod.name}` : null).toBe("util:photo");
540
557
  expect(asked).toEqual([]);
558
+
559
+ setTTY(true);
560
+ answer = "util";
561
+ await getInternalArgumentValue({ key: "", idx: 0, type: "Lib" }, undefined, workspace);
562
+ expect(asked).toEqual([["util"]]);
541
563
  });
542
564
 
543
565
  test("without a terminal, a named target the workspace lacks is refused, not swapped for the only one", async () => {
@@ -551,6 +573,106 @@ describe("a Sys that defaults to the only app", () => {
551
573
  );
552
574
  expect(asked).toEqual([]);
553
575
  });
576
+
577
+ test("a destructive command never takes the only app, lib, pkg or module, nor asks, terminal or not", async () => {
578
+ const workspace = workspaceWith([]);
579
+ workspace.getExecs = async () => [["web"], ["util"], []];
580
+ workspace.getPkgs = async () => ["tools"];
581
+ workspace.getDirInModule = async (basePath) => (basePath === "apps/web/lib" ? ["pokemon"] : []);
582
+ const refusedAs = (rerun: string, choices: string) =>
583
+ `: this command deletes files and never picks its target, so rerun it as \`${rerun}\` (choices: ${choices}).`;
584
+ const resolve = (type: InternalArgMeta["type"], commandName: string, operands: (string | undefined)[]) =>
585
+ getInternalArgumentValue(
586
+ { key: "", idx: operands.length - 1, type },
587
+ operands.at(-1),
588
+ workspace,
589
+ rerunCommandOf(
590
+ [commandName, ...operands.filter((operand): operand is string => operand !== undefined)],
591
+ operands,
592
+ operands.length - 1,
593
+ ),
594
+ { destructive: true },
595
+ );
596
+ for (const isTTY of [true, undefined]) {
597
+ setTTY(isTTY);
598
+ answer = "web";
599
+ await expect(resolve("App", "remove-application", [undefined])).rejects.toThrow(
600
+ `Missing app${refusedAs("akan remove-application <app>", "web")}`,
601
+ );
602
+ await expect(resolve("Lib", "remove-library", [undefined])).rejects.toThrow(
603
+ `Missing lib${refusedAs("akan remove-library <lib>", "util")}`,
604
+ );
605
+ await expect(resolve("Pkg", "remove-package", [undefined])).rejects.toThrow(
606
+ `Missing pkg${refusedAs("akan remove-package <pkg>", "tools")}`,
607
+ );
608
+ await expect(resolve("Module", "remove-module", [undefined])).rejects.toThrow(
609
+ `Missing module${refusedAs("akan remove-module <sys:module>", "web:pokemon")}`,
610
+ );
611
+ await expect(resolve("Sys", "remove-scalar", ["place", undefined])).rejects.toThrow(
612
+ `Missing app or lib${refusedAs("akan remove-scalar place <app-or-lib>", "web, util")}`,
613
+ );
614
+ await expect(resolve("App", "remove-application", ["webb"])).rejects.toThrow(
615
+ `Unknown app 'webb'${refusedAs("akan remove-application <app>", "web")}`,
616
+ );
617
+ }
618
+ expect(asked).toEqual([]);
619
+ });
620
+
621
+ test("a destructive command takes the target it is named, while another still takes the only app", async () => {
622
+ setTTY(undefined);
623
+ const workspace = workspaceWith([]);
624
+ workspace.getExecs = async () => [["web"], [], []];
625
+ const named = await getInternalArgumentValue({ key: "", idx: 0, type: "App" }, "web", workspace, undefined, {
626
+ destructive: true,
627
+ });
628
+ expect(named instanceof AppExecutor ? named.name : null).toBe("web");
629
+ const taken = await getInternalArgumentValue(sysMetaOf("typecheck"), undefined, workspace);
630
+ expect(taken instanceof AppExecutor ? taken.name : null).toBe("web");
631
+ expect(asked).toEqual([]);
632
+ });
633
+
634
+ test("a module an app or a lib lacks is refused with the modules there are, in a terminal too", async () => {
635
+ setTTY(true);
636
+ answer = "util";
637
+ const workspace = await moduleWorkspace();
638
+ for (const value of ["util:nope", "web:nope"]) {
639
+ const sysName = value.split(":")[0];
640
+ await expect(
641
+ getInternalArgumentValue(
642
+ { key: "", idx: 0, type: "Module" },
643
+ value,
644
+ workspace,
645
+ rerunCommandOf(["create-view", value], [value], 0),
646
+ ),
647
+ ).rejects.toThrow(
648
+ `Unknown module '${value}': ${sysName} has no such module, so rerun it as \`akan create-view <sys:module>\` (choices: web:card, util:photo).`,
649
+ );
650
+ }
651
+ expect(asked).toEqual([]);
652
+ const photo = await getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "util:photo", workspace);
653
+ expect(photo instanceof ModuleExecutor ? `${photo.sys.name}:${photo.name}` : null).toBe("util:photo");
654
+ });
655
+
656
+ test("a service module is taken by its exact name where the target opts in, as remove-module does", async () => {
657
+ setTTY(true);
658
+ const workspace = await moduleWorkspace();
659
+ const services = { key: "", idx: 0, type: "Module", option: { services: true } } as const;
660
+ const billing = await getInternalArgumentValue(services, "web:_billing", workspace, undefined, {
661
+ destructive: true,
662
+ });
663
+ expect(billing instanceof ModuleExecutor ? `${billing.sys.name}:${billing.name}` : null).toBe("web:_billing");
664
+ await expect(
665
+ getInternalArgumentValue(services, undefined, workspace, rerunCommandOf(["remove-module"], [undefined], 0), {
666
+ destructive: true,
667
+ }),
668
+ ).rejects.toThrow(
669
+ "so rerun it as `akan remove-module <sys:module>` (choices: web:card, web:_billing, util:photo).",
670
+ );
671
+ await expect(
672
+ getInternalArgumentValue({ key: "", idx: 0, type: "Module" }, "web:_billing", workspace),
673
+ ).rejects.toThrow("Unknown module 'web:_billing': web has no such module");
674
+ expect(asked).toEqual([]);
675
+ });
554
676
  });
555
677
 
556
678
  describe("the line a refused target is rerun with", () => {
@@ -573,7 +695,7 @@ describe("the line a refused target is rerun with", () => {
573
695
 
574
696
  describe("a target left out where no terminal can ask for it", () => {
575
697
  const track = tempRoots();
576
- const run = async (apps: string[]) => {
698
+ const run = async (apps: string[], args = ["create-thing", "photo", "--force"]) => {
577
699
  const { root } = track(await makeCliTempWorkspace());
578
700
  await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
579
701
  await writeText(path.join(root, "tsconfig.json"), "{}\n");
@@ -582,7 +704,7 @@ describe("a target left out where no terminal can ask for it", () => {
582
704
  await writeText(
583
705
  entry,
584
706
  [
585
- `import { command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
707
+ `import { App, command, runCommands, Sys } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
586
708
  'class ThingCommand extends command("thing", ({ public: target }) => ({',
587
709
  ' createThing: target({ desc: "creates a thing" })',
588
710
  ' .arg("thing", String, { desc: "name of the thing" })',
@@ -591,12 +713,17 @@ describe("a target left out where no terminal can ask for it", () => {
591
713
  " .exec(async function (thing, sys, force) {",
592
714
  ' process.stdout.write(thing + " in " + sys.name + ", force " + force + "\\n");',
593
715
  " }),",
716
+ ' removeThing: target({ desc: "removes an app", destructive: true })',
717
+ " .with(App)",
718
+ " .exec(async function (app) {",
719
+ ' process.stdout.write("removed " + app.name + "\\n");',
720
+ " }),",
594
721
  "})) {}",
595
722
  "await runCommands(ThingCommand);",
596
723
  "",
597
724
  ].join("\n"),
598
725
  );
599
- const proc = Bun.spawn(["bun", entry, "create-thing", "photo", "--force"], {
726
+ const proc = Bun.spawn(["bun", entry, ...args], {
600
727
  cwd: root,
601
728
  stdin: "ignore",
602
729
  stdout: "pipe",
@@ -621,18 +748,31 @@ describe("a target left out where no terminal can ask for it", () => {
621
748
 
622
749
  test("exits at once with the choices and the line to rerun when there are several", async () => {
623
750
  const refused = await run(["web", "admin"]);
624
- expect(refused.code).toBe(1);
751
+ expect(refused.code).toBe(2);
625
752
  expect(refused.stdout).toContain(
626
753
  "Missing app or lib: stdin is not a terminal to prompt on, so rerun it as `akan create-thing photo <app-or-lib> --force`",
627
754
  );
628
755
  expect(refused.stdout).toMatch(/\(choices: (web, admin|admin, web)\)\./);
629
756
  expect(refused.stdout).not.toContain("photo in");
630
757
  }, 30_000);
758
+
759
+ test("a destructive command exits with the line to rerun even when the workspace has one app", async () => {
760
+ const refused = await run(["web"], ["remove-thing"]);
761
+ expect(refused.code).toBe(2);
762
+ expect(refused.stdout).toContain(
763
+ "Missing app: this command deletes files and never picks its target, so rerun it as `akan remove-thing <app>` (choices: web).",
764
+ );
765
+ expect(refused.stdout).not.toContain("removed");
766
+ expect((await run(["web"], ["remove-thing", "web"])).stdout).toContain("removed web\n");
767
+ }, 30_000);
631
768
  });
632
769
 
633
770
  describe("the exit code of a command that failed", () => {
634
771
  const track = tempRoots();
635
- const run = async (failure: string, { option = "", args = [] as string[] } = {}) => {
772
+ const run = async (
773
+ failure: string,
774
+ { option = "", args = [], argv = ["verdict", ...args] }: { option?: string; args?: string[]; argv?: string[] } = {},
775
+ ) => {
636
776
  const { root } = track(await makeCliTempWorkspace());
637
777
  await writeText(path.join(root, "package.json"), '{ "name": "repo", "version": "1.0.0" }\n');
638
778
  await writeText(path.join(root, "tsconfig.json"), "{}\n");
@@ -640,7 +780,8 @@ describe("the exit code of a command that failed", () => {
640
780
  await writeText(
641
781
  entry,
642
782
  [
643
- `import { command, ExitCodeError, markCliErrorPrinted, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
783
+ `import { AkanError } from ${JSON.stringify(Bun.resolveSync("akanjs/base", import.meta.dir))};`,
784
+ `import { CliError, command, ExitCodeError, markCliErrorPrinted, runCommands, runner, script } from ${JSON.stringify(path.join(import.meta.dir, "index.ts"))};`,
644
785
  'class VerdictRunner extends runner("verdict") {}',
645
786
  'class VerdictScript extends script("verdict", [VerdictRunner]) {}',
646
787
  'class VerdictCommand extends command("verdict", [VerdictScript], ({ public: target }) => ({',
@@ -652,7 +793,7 @@ describe("the exit code of a command that failed", () => {
652
793
  "",
653
794
  ].join("\n"),
654
795
  );
655
- const proc = Bun.spawn(["bun", entry, "verdict", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" });
796
+ const proc = Bun.spawn(["bun", entry, ...argv], { cwd: root, stdout: "pipe", stderr: "pipe" });
656
797
  const [stdout, stderr, code] = await Promise.all([
657
798
  new Response(proc.stdout).text(),
658
799
  new Response(proc.stderr).text(),
@@ -668,6 +809,28 @@ describe("the exit code of a command that failed", () => {
668
809
  expect((await run('new Error("broke")')).code).toBe(1);
669
810
  }, 30_000);
670
811
 
812
+ test("is the table's for a CliError's code, and 1 for an AkanError of any other code", async () => {
813
+ const [checked, unknown, gone] = await Promise.all([
814
+ run('new CliError("checkFailed", "found problems")'),
815
+ run('new CliError("unknownTarget", "no app shop")'),
816
+ run('new AkanError("documentNotFound", "gone")'),
817
+ ]);
818
+ expect([checked.code, unknown.code, gone.code]).toEqual([3, 2, 1]);
819
+ expect(checked.stdout).toContain("found problems");
820
+ }, 30_000);
821
+
822
+ test("is 2 for a flag commander refuses, a value outside the choices and an unknown command", async () => {
823
+ const choice = ' }).option("mode", String, { enum: ["a", "b"]';
824
+ const [flag, outside, unknownCommand] = await Promise.all([
825
+ run('new Error("broke")', { args: ["--nope"] }),
826
+ run('new Error("broke")', { option: choice, args: ["--mode", "c"] }),
827
+ run('new Error("broke")', { argv: ["verdikt"] }),
828
+ ]);
829
+ expect([flag.code, outside.code, unknownCommand.code]).toEqual([2, 2, 2]);
830
+ expect(outside.stdout).toContain("option '--mode' argument 'c' is invalid");
831
+ expect(unknownCommand.stderr).toContain("unknown command 'verdikt'");
832
+ }, 30_000);
833
+
671
834
  test("is usageExitCode for a flag commander refuses before the command runs, failureExitCode after", async () => {
672
835
  const option = ", failureExitCode: 4, usageExitCode: 2";
673
836
  expect((await run('new Error("broke")', { option, args: ["--nope"] })).code).toBe(2);
@@ -695,7 +858,7 @@ describe("the exit code of a command that failed", () => {
695
858
 
696
859
  const thrown = await run('new Error("broke")', { option, args: ["--json"] });
697
860
  expect(thrown.code).toBe(1);
698
- expect(thrown.stdout).toContain("broke\n");
861
+ expect(thrown.stderr).toContain("broke\n");
699
862
  expect(lastOf(thrown.stdout)).toEqual({ message: "broke", usage: false, given: { json: true } });
700
863
 
701
864
  const help = await run('new Error("broke")', { option, args: ["--help"] });
@@ -703,6 +866,66 @@ describe("the exit code of a command that failed", () => {
703
866
  expect(help.stdout).not.toContain('"usage"');
704
867
  }, 60_000);
705
868
 
869
+ test("with JSON asked for, ends stdout with ok, error and code, and prints the message to stderr", async () => {
870
+ const json = ' }).option("json", Boolean, { default: false';
871
+ const format = ' }).option("format", String, { default: "text", enum: ["text", "json"]';
872
+ const marked =
873
+ '(() => { const error = new CliError("checkFailed", "verify failed"); markCliErrorPrinted(error); return error; })()';
874
+ const [unknown, plain, refused, answered, text] = await Promise.all([
875
+ run('new CliError("unknownTarget", "no app shop")', { option: json, args: ["--json"] }),
876
+ run('new Error("broke")', { option: format, args: ["--format", "json"] }),
877
+ run('new Error("broke")', { option: json, args: ["--json", "--nope"] }),
878
+ run(marked, { option: format, args: ["--format", "json"] }),
879
+ run('new Error("broke")', { option: format }),
880
+ ]);
881
+ expect(unknown.code).toBe(2);
882
+ expect(JSON.parse(unknown.stdout)).toEqual({ ok: false, error: "no app shop", code: "unknownTarget" });
883
+ expect(unknown.stderr).toContain("no app shop");
884
+ expect(plain.code).toBe(1);
885
+ expect(JSON.parse(plain.stdout)).toEqual({ ok: false, error: "broke", code: "failed" });
886
+ expect(refused.code).toBe(2);
887
+ expect(JSON.parse(refused.stdout)).toEqual({ ok: false, error: "unknown option '--nope'", code: "usage" });
888
+ expect(answered.code).toBe(3);
889
+ expect(answered.stdout.trim()).toBe("");
890
+ expect(text.stdout).toContain("broke\n");
891
+ expect(text.stdout).not.toContain('"code"');
892
+ }, 60_000);
893
+
894
+ test("is usageExitCode for a usage or unknown-target failure where 2 answers something else", async () => {
895
+ const option = ", usageExitCode: 1";
896
+ const [flag, unknown, verdict] = await Promise.all([
897
+ run('new Error("broke")', { option, args: ["--nope"] }),
898
+ run('new CliError("unknownTarget", "Unknown app \'shop\'")', { option }),
899
+ run('new ExitCodeError("needs a new store build", 2)', { option }),
900
+ ]);
901
+ expect([flag.code, unknown.code, verdict.code]).toEqual([1, 1, 2]);
902
+ }, 30_000);
903
+
904
+ test("is 130 for a prompt closed with Ctrl+C", async () => {
905
+ const closed = await run(
906
+ 'Object.assign(new Error("User force closed the prompt with SIGINT"), { name: "ExitPromptError" })',
907
+ );
908
+ expect(closed.code).toBe(130);
909
+ }, 30_000);
910
+
911
+ test("answers JSON for an ExitCodeError, a thrown object and an unhandled rejection", async () => {
912
+ const json = ' }).option("json", Boolean, { default: false';
913
+ const late = 'await (void Promise.reject(new CliError("refused", "late")), new Promise(() => {}))';
914
+ const [verdict, checked, thrown, rejected] = await Promise.all([
915
+ run('new ExitCodeError("needs a new store build", 2)', { option: json, args: ["--json"] }),
916
+ run('new ExitCodeError("found problems", 3)', { option: json, args: ["--json"] }),
917
+ run('{ reason: "an object" }', { option: json, args: ["--json"] }),
918
+ run(late, { option: json, args: ["--json"] }),
919
+ ]);
920
+ expect(JSON.parse(verdict.stdout)).toEqual({ ok: false, error: "needs a new store build", code: "refused" });
921
+ expect(verdict.code).toBe(2);
922
+ expect(JSON.parse(checked.stdout)).toEqual({ ok: false, error: "found problems", code: "checkFailed" });
923
+ expect(checked.code).toBe(3);
924
+ expect(JSON.parse(thrown.stdout)).toEqual({ ok: false, error: '{"reason":"an object"}', code: "failed" });
925
+ expect(JSON.parse(rejected.stdout)).toEqual({ ok: false, error: "late", code: "refused" });
926
+ expect(rejected.code).toBe(2);
927
+ }, 60_000);
928
+
706
929
  test("prints nothing more for an error the command marked as printed", async () => {
707
930
  const marked = await run(
708
931
  '(() => { const error = new Error("printed already"); markCliErrorPrinted(error); return error; })()',
@@ -1,15 +1,14 @@
1
+ import { CliError } from "./cliError";
2
+
1
3
  //* A failure that is an answer, not a breakage: the CLI exits with its code, so a CI tells the two apart.
2
- export class ExitCodeError extends Error {
4
+ export class ExitCodeError extends CliError {
3
5
  override readonly name = "ExitCodeError";
4
6
 
5
- constructor(
6
- message: string,
7
- readonly exitCode: number,
8
- ) {
9
- super(message);
7
+ constructor(message: string, exitCode: number) {
8
+ super(exitCode === 2 ? "refused" : exitCode === 3 ? "checkFailed" : "failed", message, { exitCode });
10
9
  }
11
10
 
12
11
  static codeOf(error: unknown): number | null {
13
- return error instanceof ExitCodeError ? error.exitCode : null;
12
+ return CliError.exitCodeOf(error);
14
13
  }
15
14
  }
@@ -1,4 +1,5 @@
1
1
  export * from "./argMeta";
2
+ export * from "./cliError";
2
3
  export * from "./command";
3
4
  export * from "./commandBuilder";
4
5
  export * from "./dependencyBuilder";
@@ -44,7 +44,10 @@ export interface TargetOption {
44
44
  runsOnWorkspaceRoot?: boolean;
45
45
  stdio?: boolean;
46
46
  failureExitCode?: number;
47
+ /** The exit of a `usage` or `unknownTarget` failure, for a command whose 2 already answers something else. */
47
48
  usageExitCode?: number;
49
+ /** It deletes files in its app, lib, pkg or module, so one left out or unknown is refused, never picked or asked. */
50
+ destructive?: boolean;
48
51
  /** Runs after the CLI printed a failure and before it exits, so what it writes ends the output. */
49
52
  onFailure?: (error: unknown, failure: TargetFailure) => void | Promise<void>;
50
53
  }
@@ -3,6 +3,7 @@ import fsPromise from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { ConventionSet } from "./conventionSet";
5
5
  import { MaintainabilityContract } from "./maintainabilityContract";
6
+ import { MaintainabilityLimits } from "./maintainabilityLimits";
6
7
  import { Prompter } from "./prompter";
7
8
 
8
9
  describe("ConventionSet", () => {
@@ -49,7 +50,7 @@ describe("ConventionSet", () => {
49
50
  }
50
51
  });
51
52
 
52
- test("names only checks that exist, and names every lint plugin and maintainability gate somewhere", async () => {
53
+ test("names only checks that exist, and names every lint plugin and maintainability gate a workspace runs", async () => {
53
54
  const set = await Prompter.getConventionSet();
54
55
  const named = new Set(
55
56
  [set.core, ...set.rules.map((rule) => rule.body)].flatMap((text) =>
@@ -59,7 +60,9 @@ describe("ConventionSet", () => {
59
60
  const plugins = (await fsPromise.readdir(path.join(import.meta.dir, "lint")))
60
61
  .filter((file) => file.endsWith(".grit"))
61
62
  .map((file) => file.slice(0, -".grit".length));
62
- const gates: readonly string[] = MaintainabilityContract.ruleIds;
63
+ const gates: readonly string[] = MaintainabilityContract.ruleIds.filter(
64
+ (id) => !MaintainabilityLimits.repoOnlyRules.has(id),
65
+ );
63
66
  const gateAreas = new Set(gates.map((id) => id.split(".").slice(0, 2).join(".")));
64
67
  const isPluginName = (name: string) => /^non?-[a-z]+(-[a-z]+)+$/.test(name);
65
68
  const isGateName = (name: string) =>
@@ -43,7 +43,7 @@ interface CsrE2eServer {
43
43
 
44
44
  const DEFAULT_PORT = 8391;
45
45
  const RELOAD_MARKER = "__akanE2eBootMarker";
46
- //? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/useCsrValues.ts`).
46
+ //? Longer than the frame's navigation lock (360ms after a transition commits, `webkit/useCsrNavigation.ts`).
47
47
  const SETTLE_MS = 450;
48
48
 
49
49
  /** Drives a page of a running `akan start` in a headless `Bun.WebView`: CSR (`?csr=true` + a mobile target) or SSR. */