@akanjs/devkit 3.0.0 → 3.0.2-rc.0

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 (67) hide show
  1. package/CHANGELOG.md +1832 -0
  2. package/akanConfig/akanConfig.test.ts +3 -0
  3. package/akanConfig/akanConfig.ts +3 -1
  4. package/akanContext.ts +18 -1
  5. package/artifact/implicitRootLayout.test.ts +2 -1
  6. package/artifact/implicitRootLayout.ts +2 -1
  7. package/artifact/routeSeedIndex.test.ts +0 -14
  8. package/biome.base.json +12 -1
  9. package/commandDecorators/command.ts +3 -2
  10. package/commandDecorators/commandDecorators.test.ts +7 -0
  11. package/csrE2e/csrE2eHarness.fixture.ts +3 -2
  12. package/csrE2e/csrPageStack.e2e.test.ts +25 -3
  13. package/executors.test.ts +329 -4
  14. package/executors.ts +49 -47
  15. package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
  16. package/frontendBuild/csrArtifactBuilder.ts +14 -8
  17. package/frontendBuild/csrDevModuleCompiler.ts +0 -1
  18. package/frontendBuild/csrDevResolver.ts +0 -2
  19. package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
  20. package/frontendBuild/devGeneratedIndexSync.ts +21 -4
  21. package/frontendBuild/frontendBuild.test.ts +1 -1
  22. package/frontendBuild/hmrWatcher.ts +6 -0
  23. package/integration/devStability.integration.test.ts +8 -5
  24. package/integration/devStabilityHarness.ts +2 -0
  25. package/jsonFormatter.test.ts +64 -0
  26. package/jsonFormatter.ts +78 -0
  27. package/lint/__fixtures__/README.md +12 -3
  28. package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
  29. package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
  30. package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
  31. package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
  32. package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
  33. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +43 -0
  34. package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
  35. package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -0
  36. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
  37. package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
  38. package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
  39. package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
  40. package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
  41. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
  42. package/lint/gritRules.test.ts +23 -15
  43. package/lint/no-double-brace-placeholder.grit +15 -0
  44. package/lint/no-inline-color.grit +21 -5
  45. package/lint/no-static-in-object-light-model.grit +27 -0
  46. package/lint/no-throw-raw-error.grit +18 -33
  47. package/lint/no-unguarded-endpoint.grit +73 -0
  48. package/manifestDependencies.ts +65 -0
  49. package/mobile/desktopBin.test.ts +1 -1
  50. package/mobile/desktopServerStage.test.ts +7 -4
  51. package/mobile/nativeApp.test.ts +57 -8
  52. package/mobile/nativeApp.ts +27 -9
  53. package/mobile/nativeConfig.test.ts +26 -4
  54. package/mobile/nativeConfig.ts +19 -1
  55. package/package.json +2 -2
  56. package/platformTest/LinuxDockerTarget.ts +1 -1
  57. package/platformTest/PlatformTestTarget.ts +3 -1
  58. package/platformTest/WindowsSshTarget.ts +1 -1
  59. package/qualityScanner.test.ts +51 -0
  60. package/qualityScanner.ts +22 -1
  61. package/scanInfo.ts +14 -2
  62. package/subspace.test.ts +10 -1
  63. package/testHelpers.ts +17 -0
  64. package/transforms/transforms.test.ts +13 -0
  65. package/tsconfig.json +4 -15
  66. package/workspaceLayout.test.ts +24 -1
  67. package/workspaceLayout.ts +19 -2
@@ -1,3 +1,20 @@
1
1
  export const HexInStyleObject = () => <div style={{ color: "#fff" }} />; // @flag
2
2
  export const RgbInStyleObject = () => <div style={{ background: "rgb(0,0,0)" }} />; // @flag
3
3
  export const ColorLiteralInStyleTag = () => <style>{`.x { color: #abcdef; }`}</style>; // @flag
4
+ export const FillInAStyleObject = () => <path style={{ fill: "#2b7fff" }} />; // @flag
5
+ export const FillAttribute = () => <path fill="#2b7fff" />; // @warn
6
+ export const StrokeAttribute = () => <path stroke="#909090" />; // @warn
7
+ export const StopColorAttribute = () => <stop stopColor="rgba(255,72,64,0)" />; // @warn
8
+ export const FloodColorAttribute = () => <feDropShadow floodColor="#000" />; // @warn
9
+ export const LightingColorAttribute = () => <feDiffuseLighting lightingColor="hsl(0 0% 100%)" />; // @warn
10
+ export const ColorAttribute = () => <svg color="#5bbad5" />; // @warn
11
+ export const ComponentFillProp = () => <Icon fill="#ffd700" />; // @warn
12
+ export const ExpressionValue = () => <path fill={isActive ? "#ffffff" : fillColor} />; // @warn
13
+ export const TemplateValue = () => <path stroke={`rgb(${red}, 0, 0)`} />; // @warn
14
+ export const GradientWithFallback = () => <path fill="url(#fade) #2b7fff" />; // @warn
15
+ export const StyleWrite = (path: SVGPathElement) => { path.style.fill = "#2b7fff"; }; // @warn
16
+ export const StyleFilterWrite = (path: SVGPathElement) => { path.style.filter = "drop-shadow(0 0 12px rgba(96, 165, 250, 0.7))"; }; // @warn
17
+ export const NestedStyleWrite = (ref: { current: HTMLElement }) => { ref.current.style.background = "#fff"; }; // @warn
18
+ export const ComputedStyleWrite = (el: HTMLElement) => { el.style["background-color"] = "#fff"; }; // @warn
19
+ export const TemplateStyleWrite = (el: HTMLElement) => { el.style.boxShadow = `0 0 ${blur}px rgba(0, 0, 0, 0.3)`; }; // @warn
20
+ export const SetPropertyWrite = (el: HTMLElement) => { el.style.setProperty("--glow", "#60a5fa"); }; // @warn
@@ -1,3 +1,16 @@
1
1
  export const VarReferenceInStyleObject = () => <div style={{ color: "var(--primary)" }} />; // @ok
2
2
  export const NonColorStyleProperty = () => <div style={{ width: "100%" }} />; // @ok
3
3
  export const ComputedNonColorStyle = () => <div style={{ minHeight }} />; // @ok
4
+ export const CurrentColorFill = () => <path fill="currentColor" className="text-primary" />; // @ok
5
+ export const NoneAndTransparent = () => <path fill="none" stroke="transparent" />; // @ok
6
+ export const VarStopColor = () => <stop stopColor="var(--primary)" />; // @ok
7
+ export const RuntimeFill = () => <path fill={fillColor} />; // @ok
8
+ export const GradientReference = () => <path fill="url(#fade)" />; // @ok
9
+ export const QuotedGradientReference = () => <path fill="url('#bead')" />; // @ok
10
+ export const HrefIsNotAColorAttribute = () => <use href="#cafe" />; // @ok
11
+ export const VarStyleWrite = (el: HTMLElement) => { el.style.color = "var(--primary)"; }; // @ok
12
+ export const CurrentColorStyleWrite = (el: HTMLElement) => { el.style.fill = "currentColor"; }; // @ok
13
+ export const NonColorStyleWrite = (el: HTMLElement) => { el.style.display = "none"; }; // @ok
14
+ export const FilterReferenceStyleWrite = (el: HTMLElement) => { el.style.filter = "url(#dead)"; }; // @ok
15
+ // biome-ignore lint/plugin: the Google mark keeps its brand colors
16
+ export const SuppressedBrandMark = () => <path fill="#4285f4" />; // @ok
@@ -0,0 +1,43 @@
1
+ import { enumOf, ID, Int } from "akanjs/base";
2
+ import { via } from "akanjs/constant";
3
+
4
+ export class QuizInput extends via((field) => ({
5
+ userId: field(ID, { ref: "user", immutable: true }),
6
+ })) {}
7
+
8
+ export class QuizObject extends via(QuizInput, (field) => ({
9
+ correctNum: field(Int, { default: 0, min: 0 }),
10
+ })) {
11
+ static maxNum = 4; // @flag
12
+ static readonly minNum = 1; // @flag
13
+ private static secretNum = 2; // @flag
14
+ static { // @flag
15
+ QuizObject.maxNum = 5;
16
+ }
17
+ }
18
+
19
+ export class LightQuiz extends via(
20
+ QuizObject,
21
+ ["userId", "correctNum"] as const,
22
+ (resolve) => ({}),
23
+ ) {
24
+ static choiceNum = 4; // @flag
25
+ static xpOfCorrect(comboNum: number) { // @flag
26
+ return comboNum * 10;
27
+ }
28
+ static async loadAll() { // @flag
29
+ return [];
30
+ }
31
+ static get defaultNum() { // @flag
32
+ return 1;
33
+ }
34
+ isPerfect() {
35
+ return this.correctNum === 4;
36
+ }
37
+ }
38
+
39
+ export class LightRenamed extends via(QuizObject, ["userId"] as const, (resolve) => ({})) {
40
+ static sizeNum = 1; // @flag
41
+ }
42
+
43
+ export class Quiz extends via(QuizObject, LightQuiz, (resolve) => ({})) {}
@@ -0,0 +1 @@
1
+ { "path": "lib/quiz/quiz.constant.ts" }
@@ -0,0 +1,42 @@
1
+ import { ID, Int } from "akanjs/base";
2
+ import { via } from "akanjs/constant";
3
+
4
+ export class QuizInput extends via((field) => ({
5
+ userId: field(ID, { ref: "user", immutable: true }),
6
+ })) {
7
+ static minSize = 4; // @ok
8
+ }
9
+
10
+ export class QuizObject extends via(QuizInput, (field) => ({
11
+ correctNum: field(Int, { default: 0, min: 0 }),
12
+ })) {}
13
+
14
+ export class LightQuiz extends via(QuizObject, ["userId", "correctNum"] as const, (resolve) => ({})) {
15
+ isPerfect() { // @ok
16
+ return this.correctNum === 4;
17
+ }
18
+ staticNum = 1; // @ok
19
+ }
20
+
21
+ export class Quiz extends via(QuizObject, LightQuiz, (resolve) => ({})) {
22
+ static choiceNum = 4; // @ok
23
+ static xpOfCorrect(comboNum: number) { // @ok
24
+ return comboNum * 10;
25
+ }
26
+ }
27
+
28
+ export class QuizInsight extends via(Quiz, (field) => ({})) {
29
+ static emptyNum = 0; // @ok
30
+ }
31
+
32
+ export class Coordinate extends via((field) => ({
33
+ lat: field(Int),
34
+ })) {
35
+ static getDistanceKm(a: Coordinate, b: Coordinate) { // @ok
36
+ return a.lat - b.lat;
37
+ }
38
+ }
39
+
40
+ export class ObjectRegistry {
41
+ static instance = new ObjectRegistry(); // @ok
42
+ }
@@ -2,3 +2,11 @@ export const throwsNewError = () => { throw new Error("boom"); }; // @flag
2
2
  export const throwsBareError = () => { throw Error("boom"); }; // @flag
3
3
  export const throwsWithCause = () => { throw new Error("boom", { cause: reason }); }; // @flag
4
4
  export const throwsWithNoArgument = () => { throw new Error(); }; // @flag
5
+ export const throwsWithoutParens = () => { throw new Error; }; // @flag
6
+ export const rejectsInAPromise = () => new Promise((_, reject) => reject(new Error(`k8s ${path} failed`))); // @warn
7
+ export const rejectsDirectly = () => Promise.reject(new Error("boom")); // @warn
8
+ export const throwsAStoredError = () => { const error = new Error("boom"); throw error; }; // @warn
9
+ export const returnsAnError = () => { return new Error("boom"); }; // @warn
10
+ export const callsWithoutNew = () => Error("boom"); // @warn
11
+ export const causesAnErr = () => { throw new Err("ticket.error.failed", { cause: new Error("boom") }); }; // @warn
12
+ export const throwsAWrappedError = () => { throw wrapError(new Error("boom")); }; // @warn
@@ -1,4 +1,11 @@
1
1
  export const throwsErr = () => { throw new Err("ticket.error.notFound"); }; // @ok
2
+ export const rejectsWithErr = () => Promise.reject(new Err("ticket.error.notFound")); // @ok
2
3
  export const throwsASubclass = () => { throw new HttpError("boom"); }; // @ok
3
- export const constructsWithoutThrowing = () => new Error("boom"); // @ok
4
4
  export const rethrowsACaught = () => { try { run(); } catch (error) { throw error; } }; // @ok
5
+ export const narrowsAnError = (value: unknown) => value instanceof Error; // @ok
6
+ export const readsAnAttachedField = (error: Error & { stderr: string }) => error.stderr; // @ok
7
+ export const capturesAStack = (target: object) => Error.captureStackTrace(target); // @ok
8
+ export const callsAMethodNamedError = () => logger.Error("boom"); // @ok
9
+ export const quotesTheRule = () => "throw new Error('boom')"; // @ok
10
+ export const DocumentsTheRule = () => <code>new Error</code>; // @ok
11
+ export const DocumentsTheCallForm = () => <code>Error("boom")</code>; // @ok
@@ -0,0 +1,20 @@
1
+ export class TaskSlice extends slice(srv.task, { guards: { root: Admin, get: Public, cru: Admin } }, (init) => ({
2
+ inProject: init().param("projectId", ID).exec(() => null), // @warn
3
+ inOrg: init({ mcp: false }).param("orgId", ID).exec(() => null), // @warn
4
+ inAnyone: init({ guards: [] }).exec(() => null), // @warn
5
+ })) {}
6
+
7
+ export class TaskEndpoint extends endpoint(srv.task, ({ query, mutation, pubsub, message }) => ({
8
+ taskCount: query(Int).exec(() => 0), // @warn
9
+ taskOfSlug: query(cnst.Task, { nullable: true }).param("slug", String).exec(() => null), // @warn
10
+ startTask: mutation(Boolean).exec(() => true), // @warn
11
+ archiveTask: mutation(Boolean, { guards: [] }).exec(() => true), // @warn
12
+ noteTask: mutation(Boolean, { nullable, timeout: 500 }).exec(() => true), // @warn
13
+ taskEvents: pubsub(cnst.Task).exec(() => null), // @warn
14
+ pingTask: message(Boolean, { timeout: 1000 }).exec(() => true), // @warn
15
+ })) {}
16
+
17
+ export class NoteSlice extends slice(srv.note, { guards: { get: Public, cru: Admin } }, () => ({})) {} // @warn
18
+ export class LabelSlice extends slice(srv.label, { guards: { root: [], get: Public, cru: Admin } }, () => ({})) {} // @warn
19
+ export class TagSlice extends slice(srv.tag, { mcp: false }, () => ({})) {} // @warn
20
+ export class StampSlice extends slice(srv.stamp, {}, () => ({}), libStampSlice) {} // @warn
@@ -0,0 +1 @@
1
+ { "path": "apps/demo/lib/task/task.signal.ts" }
@@ -0,0 +1,28 @@
1
+ export class TaskSlice extends slice(srv.task, { guards: { root: Admin, get: Public, cru: Admin } }, (init) => ({
2
+ inProject: init({ guards: [Member] }).param("projectId", ID).exec(() => null), // @ok
3
+ inPublic: init({ guards: [Public], mcp: false }).exec(() => null), // @ok
4
+ inShared: init(sharedSliceOption).exec(() => null), // @ok
5
+ })) {}
6
+
7
+ export class TaskEndpoint extends endpoint(srv.task, ({ query, mutation, pubsub, message }) => ({
8
+ taskCount: query(Int, { guards: [Public] }).exec(() => 0), // @ok
9
+ startTask: mutation(Boolean, { guards: [User], mcp: false }).exec(() => true), // @ok
10
+ addTaskFiles: mutation([cnst.File], { guards: [Every], fileUpload: true }).exec(() => []), // @ok
11
+ taskFrames: pubsub(Binary, { guards: [Admin], backpressure: "queue" }).exec(() => null), // @ok
12
+ pingTask: message(Boolean, { guards }).exec(() => true), // @ok
13
+ taskSummary: query(cnst.Task, { ...adminOption, nullable: true }).exec(() => null), // @ok
14
+ taskOwner: query(cnst.User, { "guards": [Admin] }).exec(() => null), // @ok
15
+ })) {}
16
+
17
+ export class TaskInternal extends internal(srv.task, ({ interval, initialize }) => ({
18
+ sweepTasks: interval(60_000).exec(() => null), // @ok
19
+ bootTasks: initialize().exec(() => null), // @ok
20
+ })) {}
21
+
22
+ export const describeTask = () => query(cnst.Task); // @ok
23
+
24
+ export class NoteSlice extends slice(srv.note, { guards: { root: [Admin, Owner], get: Public } }, () => ({}), libNoteSlice) {} // @ok
25
+ export class LabelSlice extends slice(srv.label, { guards: { root: None, get: None, cru: None } }) {} // @ok
26
+ export class TagSlice extends slice(srv.tag, sharedSliceOption, () => ({})) {} // @ok
27
+ export class StampSlice extends slice(srv.stamp, { guards: { ...adminGuards, get: Public } }, () => ({})) {} // @ok
28
+ export class SealSlice extends slice(srv.seal, { guards: sealGuards, mcp: { cru: false } }, () => ({})) {} // @ok
@@ -1,7 +1,7 @@
1
- export const share = (url: string) => navigator.share({ url }); // @flag
2
- export const canShare = () => typeof navigator.canShare === "function"; // @flag
3
- export const worker = () => navigator.serviceWorker.register("/sw.js"); // @flag
4
- export const ask = () => Notification.requestPermission(); // @flag
5
- export const permission = () => Notification.permission; // @flag
6
- export const locate = () => navigator.geolocation.getCurrentPosition(() => undefined); // @flag
7
- export const buzz = () => navigator.vibrate(50); // @flag
1
+ export const share = (url: string) => navigator.share({ url }); // @warn
2
+ export const canShare = () => typeof navigator.canShare === "function"; // @warn
3
+ export const worker = () => navigator.serviceWorker.register("/sw.js"); // @warn
4
+ export const ask = () => Notification.requestPermission(); // @warn
5
+ export const permission = () => Notification.permission; // @warn
6
+ export const locate = () => navigator.geolocation.getCurrentPosition(() => undefined); // @warn
7
+ export const buzz = () => navigator.vibrate(50); // @warn
@@ -102,23 +102,27 @@ const lintFixture = async (fixture: FixtureCase, kind: "bad" | "good") => {
102
102
  const end = output.lastIndexOf("}");
103
103
  if (start === -1 || end < start)
104
104
  throw new Error(`biome produced no JSON for ${fixture.name}/${kind}: ${output.slice(0, 400)}`);
105
- const diagnostics =
106
- (JSON.parse(output.slice(start, end + 1)) as { diagnostics?: BiomeDiagnostic[] }).diagnostics ?? [];
107
- return {
108
- diagnostics,
109
- lines: [...new Set(diagnostics.map((diagnostic) => diagnostic.location?.start?.line ?? 0))].sort(
110
- (left, right) => left - right,
111
- ),
112
- };
105
+ return (JSON.parse(output.slice(start, end + 1)) as { diagnostics?: BiomeDiagnostic[] }).diagnostics ?? [];
113
106
  };
114
107
 
115
- //? One case per line is the fixture contract.
108
+ //? One case per line is the fixture contract, and a `bad` line's marker names the severity it is reported at.
109
+ const severityOfMarker = { "// @flag": "error", "// @warn": "warning" } as const;
110
+
116
111
  const markedLines = async (fixture: FixtureCase, kind: "bad" | "good", marker: string) =>
117
112
  (await readFixture(fixture, kind))
118
113
  .split("\n")
119
114
  .map((line, index) => (line.includes(marker) ? index + 1 : 0))
120
115
  .filter((line) => line > 0);
121
116
 
117
+ const expectedReports = async (fixture: FixtureCase) =>
118
+ (await readFixture(fixture, "bad")).split("\n").flatMap((line, index) =>
119
+ Object.entries(severityOfMarker)
120
+ .filter(([marker]) => line.includes(marker))
121
+ .map(([, severity]) => `${index + 1}: ${severity}`),
122
+ );
123
+
124
+ const byLine = (left: string, right: string) => Number.parseInt(left, 10) - Number.parseInt(right, 10);
125
+
122
126
  const fixtures = await collectCases();
123
127
 
124
128
  describe("grit lint rules", () => {
@@ -140,22 +144,26 @@ describe("grit lint rules", () => {
140
144
  });
141
145
 
142
146
  for (const fixture of fixtures) {
143
- test(`${fixture.name} flags exactly the marked lines in bad`, async () => {
144
- const { lines, diagnostics } = await lintFixture(fixture, "bad");
147
+ test(`${fixture.name} reports each marked line in bad once, at its marker's severity`, async () => {
148
+ const diagnostics = await lintFixture(fixture, "bad");
145
149
  for (const diagnostic of diagnostics) expect(diagnostic.category).toBe("plugin");
146
150
  if (fixture.meta.expect === "file") {
147
151
  expect(diagnostics.length).toBeGreaterThan(0);
148
152
  return;
149
153
  }
150
- const expected = await markedLines(fixture, "bad", "// @flag");
154
+ const expected = await expectedReports(fixture);
151
155
  expect(expected.length).toBeGreaterThan(0);
152
- // Set equality: an unreported mark is a pattern that stopped matching, an unmarked report is overreach.
153
- expect(lines).toEqual(expected);
156
+ // An unreported mark stopped matching, an unmarked report is overreach, a line reported twice is one site
157
+ // reported twice, and a severity that differs is a tier that moved.
158
+ const reported = diagnostics.map(
159
+ (diagnostic) => `${diagnostic.location?.start?.line ?? 0}: ${diagnostic.severity}`,
160
+ );
161
+ expect(reported.sort(byLine)).toEqual(expected.sort(byLine));
154
162
  });
155
163
 
156
164
  test(`${fixture.name} leaves good clean`, async () => {
157
165
  expect((await markedLines(fixture, "good", "// @ok")).length).toBeGreaterThan(0);
158
- const { diagnostics } = await lintFixture(fixture, "good");
166
+ const diagnostics = await lintFixture(fixture, "good");
159
167
  expect(diagnostics.map((diagnostic) => `${diagnostic.location?.start?.line}: ${diagnostic.message}`)).toEqual([]);
160
168
  });
161
169
  }
@@ -0,0 +1,15 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `interpolateTranslation` replaces `{name}` only, so a `{{name}}` reaches the reader as typed. Only `.error()` and
5
+ // `.translate()` entries receive data; a `{{` in a label or description is literal text, such as a syntax users type.
6
+ JsCallExpression(callee = JsStaticMemberExpression(member = r"(?:error|translate)"), arguments = $entries) where {
7
+ $entries <: contains bubble or { JsStringLiteralExpression(), JsTemplateChunkElement() } as $s where {
8
+ $s <: r"[\s\S]*\{\{[\s\S]*",
9
+ register_diagnostic(
10
+ span = $s,
11
+ message = "`{{name}}` is never interpolated: a translation replaces `{name}` only, so the braces reach the reader as typed. Write `{name}`.",
12
+ severity = "error"
13
+ )
14
+ }
15
+ }
@@ -7,15 +7,31 @@ language js(typescript, jsx)
7
7
  // The `<style>` arm covers the same literal in a tag body, which is the other way a color reaches
8
8
  // the page without passing the vocabulary. Both arms are needed because the two sites are different
9
9
  // node kinds — a string in an object property, and a chunk of a template literal.
10
+ // SVG color attributes and `element.style` writes are the same bypass outside a style object. They are
11
+ // warnings, not errors, so code written before the rule reached them is not broken.
12
+ // The hex arm skips `url(#id)`: a paint-server id such as `#fade` is spelled in hex digits.
10
13
  // Cases: lint/__fixtures__/no-inline-color/{bad,good}.tsx
11
14
  or {
12
- JsStringLiteralExpression() as $s where { $s <: within `style={$obj}` },
13
- JsTemplateChunkElement() as $s where { $s <: within `<style>$body</style>` }
15
+ or {
16
+ JsStringLiteralExpression() as $s where { $s <: within `style={$obj}` },
17
+ JsTemplateChunkElement() as $s where { $s <: within `<style>$body</style>` }
18
+ } where { $tier = "error" },
19
+ or {
20
+ or { JsxString(), JsStringLiteralExpression(), JsTemplateChunkElement() } as $s where {
21
+ $s <: within JsxAttribute(name = r"(?:fill|stroke|stopColor|floodColor|lightingColor|color|stop-color|flood-color|lighting-color)")
22
+ },
23
+ or { JsStringLiteralExpression(), JsTemplateChunkElement() } as $s where {
24
+ $s <: within or { `$el.style.$prop = $value`, `$el.style[$prop] = $value`, `$el.style.setProperty($prop, $value)` }
25
+ }
26
+ } where { $tier = "warn" }
14
27
  } where {
15
- $s <: r"[\s\S]*(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb)\()[\s\S]*",
28
+ $s <: or {
29
+ r"[\s\S]*(?:(?:^|[^(\x22'])#|(?:^|[^lL])\([\x22']?#|(?:^|[^rR])[lL]\([\x22']?#|(?:^|[^uU])[rR][lL]\([\x22']?#|(?:^|[^(])[\x22']#)[0-9a-fA-F]{3,8}(?:$|[^0-9A-Za-z_-])[\s\S]*",
30
+ r"[\s\S]*(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb)\([\s\S]*"
31
+ },
16
32
  register_diagnostic(
17
33
  span = $s,
18
- message = "Inline color literal bypasses semantic tokens and theme switching. Use a token class, or style={{ color: 'var(--primary)' }} when a runtime value is unavoidable.",
19
- severity = "error"
34
+ message = "Inline color literal bypasses semantic tokens and theme switching. Use a token class, currentColor, or a token variable: style={{ color: 'var(--primary)' }}, fill='currentColor', el.style.color = 'var(--primary)'. A fixed brand or data-viz color takes // biome-ignore lint/plugin: <reason>.",
35
+ severity = $tier
20
36
  )
21
37
  }
@@ -0,0 +1,27 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `via(XObject, LightX, ...)` mixes in only the prototypes of XObject and LightX (applyMixins in
5
+ // akanjs/common), so a static declared on either never reaches the full model `X`. Statics belong on
6
+ // XInput (or a scalar), X, or XInsight. The Object class is recognised by its `*Object` name, because its
7
+ // via() shape is identical to the Insight's; the Light class also by its `[...] as const` field tuple.
8
+ or {
9
+ JsMethodClassMember(),
10
+ JsPropertyClassMember(),
11
+ JsGetterClassMember(),
12
+ JsSetterClassMember(),
13
+ JsStaticInitializationBlockClassMember()
14
+ } as $member where {
15
+ $member <: r"(?:(?:public|protected|private|declare|override|readonly|accessor)\s+)*static\b[\s\S]*",
16
+ $member <: within JsClassDeclaration() as $model where {
17
+ $model <: or {
18
+ r"class\s+(?:Light[A-Za-z0-9_$]*|[A-Za-z0-9_$]*Object)\s+extends\s+via\([\s\S]*",
19
+ r"class\s+[A-Za-z0-9_$]+\s+extends\s+via\(\s*[A-Za-z0-9_$.]+\s*,\s*\[[\s\S]*"
20
+ }
21
+ },
22
+ register_diagnostic(
23
+ span = $member,
24
+ message = "A static on an XObject or LightX class never reaches the full model, because via() mixes in their prototypes only. Declare it on XInput, the full model X, or XInsight instead.",
25
+ severity = "error"
26
+ )
27
+ }
@@ -1,40 +1,25 @@
1
1
  engine biome(1.0)
2
2
  language js(typescript, jsx)
3
3
 
4
- // Domain code in apps/ and libs/ must not throw a raw `Error`. Throw the typed
5
- // `Err` instead, which is generated per app/lib by `makeTrans`
6
- // (pkgs/akanjs/dictionary/trans.ts) and imported via `import { Err } from "../dict"`
7
- // (or the app/lib client entrypoint). `Err` carries a translatable error key,
8
- // a status code, and structured payload, so raw `Error` throws lose i18n and
9
- // HTTP-status handling.
10
- //
11
- // The `$msg` metavariable in the argument position leniently matches any arity,
12
- // so `throw new Error()`, `throw new Error("x")`, and
13
- // `throw new Error("x", { cause })` are all caught, as is the `new`-less
14
- // `throw Error(...)` call form. Only real throw statements match — a
15
- // `new Error(...)` inside a string literal or one that is not thrown is ignored.
16
- //
17
- // No autofix: `Err`'s first argument must be a typed dictionary error key
18
- // (e.g. throw new Err('model.errorKey')), so a mechanical rewrite of an
19
- // arbitrary message string would not typecheck. Each site must be migrated to a
20
- // real error key by hand.
21
- //
22
- // NOTE: the diagnostic messages below use single quotes in the example on
23
- // purpose. Escaped double quotes (\") inside a GritQL string trigger a
24
- // double-unescape bug in Biome 2.4.4 that corrupts the rendered message.
25
- or {
26
- `throw new Error($msg)` as $throw where {
4
+ // A raw `Error` loses the translatable key and status `Err` carries whether it is thrown, rejected, returned or stored.
5
+ // Thrown, it is an error; built anywhere else, a warning, so code written before the rule reached it is not broken.
6
+ // No rewrite: `Err` takes a dictionary key, which an arbitrary message is not.
7
+ // Node kinds, not snippets: a `new Error` snippet also matches the JSX text of `<code>new Error</code>`.
8
+ // Single quotes in the message: Biome unescapes a `\"` in a GritQL string twice and corrupts the text.
9
+ or { JsNewExpression(callee = `Error`), JsCallExpression(callee = `Error`) } as $error where {
10
+ or {
11
+ and {
12
+ $error <: within JsThrowStatement(argument = $error),
13
+ register_diagnostic(
14
+ span = $error,
15
+ message = "Do not throw a raw `Error`: it carries no translatable key or status. Throw the typed `Err` (import { Err } from the app/lib dict), e.g. throw new Err('<module>.error.<key>'), and register the key in that module's dictionary `.error({})`.",
16
+ severity = "error"
17
+ )
18
+ },
27
19
  register_diagnostic(
28
- span = $throw,
29
- message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
30
- severity = "error"
31
- )
32
- },
33
- `throw Error($msg)` as $throw where {
34
- register_diagnostic(
35
- span = $throw,
36
- message = "Do not throw a raw `Error`. Throw the typed `Err` instead (import { Err } from the app/lib dict), e.g. throw new Err('model.errorKey').",
37
- severity = "error"
20
+ span = $error,
21
+ message = "Do not construct a raw `Error`: rejected, returned or stored, it carries no translatable key or status. Use the typed `Err` (import { Err } from the app/lib dict), e.g. new Err('<module>.error.<key>'), and register the key in that module's dictionary `.error({})`.",
22
+ severity = "warn"
38
23
  )
39
24
  }
40
25
  }
@@ -0,0 +1,73 @@
1
+ engine biome(1.0)
2
+ language js(typescript, jsx)
3
+
4
+ // `SignalContext` checks `signalOption.guards ?? []`, so an endpoint, a named slice, or a slice's root query declaring
5
+ // none is open to every HTTP and websocket caller. An option the rule cannot read, a variable or a spread, is left alone.
6
+ or {
7
+ JsCallExpression(callee = $callee, arguments = JsCallArguments(args = $args)) as $call where {
8
+ or {
9
+ and {
10
+ $callee <: r"(?:query|mutation|pubsub|message)",
11
+ $call <: within JsCallExpression(callee = `endpoint`),
12
+ or { $args <: [$returns], $args <: [$returns, $option] }
13
+ },
14
+ and {
15
+ $callee <: `init`,
16
+ $call <: within JsCallExpression(callee = `slice`),
17
+ or { $args <: [], $args <: [$option] }
18
+ }
19
+ },
20
+ or {
21
+ $option <: undefined,
22
+ $option <: JsObjectExpression(members = $members) where {
23
+ or {
24
+ $members <: not some or {
25
+ JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')"),
26
+ JsShorthandPropertyObjectMember(name = r"guards"),
27
+ JsSpread()
28
+ },
29
+ $members <: some JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')", value = JsArrayExpression(elements = []))
30
+ }
31
+ }
32
+ },
33
+ register_diagnostic(
34
+ span = $callee,
35
+ message = "No `guards`, so every HTTP and websocket caller reaches this endpoint: a missing or empty `guards` fails open, which is also why MCP refuses it. Name the guards it needs, or write `guards: [Public]` when anonymous access is the intent.",
36
+ severity = "warn"
37
+ )
38
+ },
39
+ JsCallExpression(callee = `slice`, arguments = JsCallArguments(args = [$srv, $sliceOption, ...])) where {
40
+ $sliceOption <: JsObjectExpression(members = $sliceMembers),
41
+ or {
42
+ and {
43
+ $sliceMembers <: not some or {
44
+ JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')"),
45
+ JsShorthandPropertyObjectMember(name = r"guards"),
46
+ JsSpread()
47
+ },
48
+ register_diagnostic(
49
+ span = $sliceOption,
50
+ message = "No `guards` on this slice, so every HTTP and websocket caller reaches its root query and generated reads and writes: a missing guard fails open. Pass { guards: { root: Admin, get: …, cru: … } }, naming `Public` where anonymous access is the intent.",
51
+ severity = "warn"
52
+ )
53
+ },
54
+ and {
55
+ $sliceMembers <: some JsPropertyObjectMember(name = r"(?:guards|\x22guards\x22|'guards')", value = $guardMap),
56
+ $guardMap <: JsObjectExpression(members = $guardKeys),
57
+ or {
58
+ $guardKeys <: not some or {
59
+ JsPropertyObjectMember(name = r"(?:root|\x22root\x22|'root')"),
60
+ JsShorthandPropertyObjectMember(name = r"root"),
61
+ JsSpread()
62
+ },
63
+ $guardKeys <: some JsPropertyObjectMember(name = r"(?:root|\x22root\x22|'root')", value = JsArrayExpression(elements = []))
64
+ },
65
+ register_diagnostic(
66
+ span = $guardMap,
67
+ message = "No `root` guard on this slice, so its root query lists any filter to every HTTP and websocket caller: a missing or empty `root` fails open. Write `root: Admin`.",
68
+ severity = "warn"
69
+ )
70
+ }
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,65 @@
1
+ import { LibSource } from "./libSource";
2
+ import type { PackageJson } from "./types";
3
+
4
+ type DependencyMap = Record<string, string>;
5
+
6
+ export interface ScannedImports {
7
+ runtime: readonly string[];
8
+ typeOnly: readonly string[];
9
+ }
10
+
11
+ export class ManifestDependencies {
12
+ readonly #root: PackageJson;
13
+ readonly #runtime: Set<string>;
14
+ readonly #typeOnly: Set<string>;
15
+ readonly #isKept: (dep: string) => boolean;
16
+
17
+ constructor(root: PackageJson, { runtime, typeOnly }: ScannedImports, isKept: (dep: string) => boolean) {
18
+ this.#root = root;
19
+ this.#runtime = new Set(runtime);
20
+ this.#typeOnly = new Set(typeOnly);
21
+ this.#isKept = isKept;
22
+ }
23
+
24
+ static keptBy(manifest: PackageJson): string[] {
25
+ const { keepDependencies } = (manifest[LibSource.manifestKey] ?? {}) as { keepDependencies?: unknown };
26
+ return Array.isArray(keepDependencies)
27
+ ? keepDependencies.filter((dep): dep is string => typeof dep === "string")
28
+ : [];
29
+ }
30
+
31
+ //* The scan recognises only the names the root declares, so only those can be proven unused and pruned.
32
+ realign(manifest: PackageJson) {
33
+ const removed: string[] = [];
34
+ const unverifiable: string[] = [];
35
+ const dependencies: DependencyMap = {};
36
+ for (const [dep, version] of Object.entries(manifest.dependencies ?? {})) {
37
+ if (this.#typeOnly.has(dep)) continue;
38
+ const rootVersion = this.#rootVersionOf(dep);
39
+ if (!rootVersion) {
40
+ unverifiable.push(dep);
41
+ dependencies[dep] = version;
42
+ } else if (this.#runtime.has(dep) || this.#isKept(dep)) dependencies[dep] = rootVersion;
43
+ else removed.push(dep);
44
+ }
45
+ const runtimeOfRootDependencies = [...this.#runtime].filter((dep) => this.#root.dependencies?.[dep]);
46
+ this.#append(dependencies, runtimeOfRootDependencies);
47
+
48
+ const devDependencies: DependencyMap = {};
49
+ for (const [dep, version] of Object.entries(manifest.devDependencies ?? {}))
50
+ if (!Object.hasOwn(dependencies, dep)) devDependencies[dep] = this.#rootVersionOf(dep) ?? version;
51
+ this.#append(devDependencies, [...this.#typeOnly]);
52
+ return { dependencies, devDependencies, removed, unverifiable };
53
+ }
54
+
55
+ #append(map: DependencyMap, deps: string[]) {
56
+ for (const dep of deps.filter((dep) => !Object.hasOwn(map, dep)).sort((a, b) => (a < b ? -1 : 1))) {
57
+ const rootVersion = this.#rootVersionOf(dep);
58
+ if (rootVersion) map[dep] = rootVersion;
59
+ }
60
+ }
61
+
62
+ #rootVersionOf(dep: string) {
63
+ return this.#root.dependencies?.[dep] ?? this.#root.devDependencies?.[dep];
64
+ }
65
+ }
@@ -28,7 +28,7 @@ const stage = async (
28
28
  bin: AkanBinConfig,
29
29
  { libBins = [], libs = [] }: { libBins?: { lib: string; bin: AkanBinConfig }[]; libs?: string[] } = {},
30
30
  ) => {
31
- const binDir = path.join(root, "apps/portal/.akan/native/default/bin");
31
+ const binDir = path.join(root, "dist/native/portal/default/bin");
32
32
  const config = { app: { name: "portal" }, bin, libBins } as never;
33
33
  const carried = await new DesktopBin(fakeApp(path.join(root, "apps/portal"), libs), config).stage(binDir);
34
34
  return { carried, binDir };
@@ -241,10 +241,13 @@ describe("DesktopServerStage", () => {
241
241
  });
242
242
 
243
243
  test("a phone build cannot carry the server", async () => {
244
- const app = new NativeApp({ cwdPath: "/repo/apps/portal" } as never, {
245
- name: "default",
246
- config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
247
- });
244
+ const app = new NativeApp(
245
+ { name: "portal", cwdPath: "/repo/apps/portal", workspace: { workspaceRoot: "/repo" } } as never,
246
+ {
247
+ name: "default",
248
+ config: { name: "default", appName: "Portal", appId: "com.portal.app", version: "1.0.0", buildNum: 1 },
249
+ },
250
+ );
248
251
  const server = { dir: "/tmp/server", entry: "main.js", env: {} };
249
252
  await expect(app.build("ios", { server })).rejects.toThrow("Only a desktop app carries its server");
250
253
  await expect(app.run("android", { server })).rejects.toThrow("Only a desktop app carries its server");