@akanjs/devkit 3.0.1 → 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.
- package/CHANGELOG.md +15 -2
- package/akanConfig/akanConfig.test.ts +3 -0
- package/akanConfig/akanConfig.ts +3 -1
- package/akanContext.ts +18 -1
- package/biome.base.json +8 -1
- package/commandDecorators/command.ts +3 -2
- package/commandDecorators/commandDecorators.test.ts +7 -0
- package/csrE2e/csrPageStack.e2e.test.ts +12 -3
- package/executors.test.ts +329 -4
- package/executors.ts +49 -47
- package/frontendBuild/csrArtifactBuilder.test.ts +34 -0
- package/frontendBuild/csrArtifactBuilder.ts +14 -8
- package/frontendBuild/csrDevModuleCompiler.ts +0 -1
- package/frontendBuild/csrDevResolver.ts +0 -2
- package/frontendBuild/devGeneratedIndexSync.test.ts +13 -1
- package/frontendBuild/devGeneratedIndexSync.ts +21 -4
- package/frontendBuild/frontendBuild.test.ts +1 -1
- package/jsonFormatter.test.ts +64 -0
- package/jsonFormatter.ts +78 -0
- package/lint/__fixtures__/README.md +12 -3
- package/lint/__fixtures__/no-double-brace-placeholder/bad.ts +22 -0
- package/lint/__fixtures__/no-double-brace-placeholder/fixture.json +1 -0
- package/lint/__fixtures__/no-double-brace-placeholder/good.ts +15 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +17 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +13 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +8 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +8 -1
- package/lint/__fixtures__/no-unguarded-endpoint/bad.ts +20 -0
- package/lint/__fixtures__/no-unguarded-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-unguarded-endpoint/good.ts +28 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -7
- package/lint/gritRules.test.ts +23 -15
- package/lint/no-double-brace-placeholder.grit +15 -0
- package/lint/no-inline-color.grit +21 -5
- package/lint/no-throw-raw-error.grit +18 -33
- package/lint/no-unguarded-endpoint.grit +73 -0
- package/manifestDependencies.ts +65 -0
- package/mobile/desktopBin.test.ts +1 -1
- package/mobile/desktopServerStage.test.ts +7 -4
- package/mobile/nativeApp.test.ts +57 -8
- package/mobile/nativeApp.ts +27 -9
- package/mobile/nativeConfig.test.ts +26 -4
- package/mobile/nativeConfig.ts +19 -1
- package/package.json +2 -2
- package/platformTest/LinuxDockerTarget.ts +1 -1
- package/platformTest/PlatformTestTarget.ts +3 -1
- package/platformTest/WindowsSshTarget.ts +1 -1
- package/qualityScanner.test.ts +51 -0
- package/qualityScanner.ts +22 -1
- package/scanInfo.ts +14 -2
- package/testHelpers.ts +17 -0
- package/transforms/transforms.test.ts +13 -0
- package/tsconfig.json +4 -15
- package/workspaceLayout.test.ts +24 -1
- package/workspaceLayout.ts +19 -2
|
@@ -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 }); // @
|
|
2
|
-
export const canShare = () => typeof navigator.canShare === "function"; // @
|
|
3
|
-
export const worker = () => navigator.serviceWorker.register("/sw.js"); // @
|
|
4
|
-
export const ask = () => Notification.requestPermission(); // @
|
|
5
|
-
export const permission = () => Notification.permission; // @
|
|
6
|
-
export const locate = () => navigator.geolocation.getCurrentPosition(() => undefined); // @
|
|
7
|
-
export const buzz = () => navigator.vibrate(50); // @
|
|
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
|
package/lint/gritRules.test.ts
CHANGED
|
@@ -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
|
-
|
|
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}
|
|
144
|
-
const
|
|
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
|
|
154
|
+
const expected = await expectedReports(fixture);
|
|
151
155
|
expect(expected.length).toBeGreaterThan(0);
|
|
152
|
-
//
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
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 <:
|
|
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)' }}
|
|
19
|
-
severity =
|
|
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
|
}
|
|
@@ -1,40 +1,25 @@
|
|
|
1
1
|
engine biome(1.0)
|
|
2
2
|
language js(typescript, jsx)
|
|
3
3
|
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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 = $
|
|
29
|
-
message = "Do not
|
|
30
|
-
severity = "
|
|
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, "
|
|
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(
|
|
245
|
-
name: "
|
|
246
|
-
|
|
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");
|
package/mobile/nativeApp.test.ts
CHANGED
|
@@ -22,6 +22,7 @@ const target = (config: Partial<AkanNativeTarget> = {}) => ({
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
const appDir = "/repo/apps/portal";
|
|
25
|
+
const nativeDir = "/repo/dist/native/portal";
|
|
25
26
|
|
|
26
27
|
const fakeApp = (plugins: AkanPlugin[] = []) =>
|
|
27
28
|
({
|
|
@@ -31,24 +32,67 @@ const fakeApp = (plugins: AkanPlugin[] = []) =>
|
|
|
31
32
|
getScanInfo: () => ({ getLibs: () => [] }),
|
|
32
33
|
dist: { cwdPath: "/repo/dist/apps/portal" },
|
|
33
34
|
getConfig: async () => ({ i18n: { locales: ["en", "ko"] }, api: { prefix: "/api", websocketPrefix: "/ws" } }),
|
|
35
|
+
getDevPort: async () => 8284,
|
|
34
36
|
collectPlugins: async () => plugins,
|
|
35
37
|
logger: { warn: () => undefined },
|
|
36
38
|
}) as unknown as App;
|
|
37
39
|
|
|
38
40
|
describe("NativeApp", () => {
|
|
39
|
-
test("keeps each target's web root and each platform's output apart", () => {
|
|
41
|
+
test("keeps each target's web root and each platform's output apart, outside the dist akan build empties", () => {
|
|
40
42
|
const admin = new NativeApp(fakeApp(), target({ name: "admin", basePath: "admin" }));
|
|
41
43
|
|
|
42
|
-
expect(admin.web.dir).toBe(path.join(
|
|
43
|
-
expect(admin.outDir("ios")).toBe(path.join(
|
|
44
|
-
expect(admin.outDir("android")).toBe(path.join(
|
|
45
|
-
expect(admin.devOutDir("macos")).toBe(path.join(
|
|
44
|
+
expect(admin.web.dir).toBe(path.join(nativeDir, "admin/web"));
|
|
45
|
+
expect(admin.outDir("ios")).toBe(path.join(nativeDir, "admin/build/ios"));
|
|
46
|
+
expect(admin.outDir("android")).toBe(path.join(nativeDir, "admin/build/android"));
|
|
47
|
+
expect(admin.devOutDir("macos")).toBe(path.join(nativeDir, "admin/dev/macos"));
|
|
48
|
+
expect(admin.updatesDir).toBe(path.join(nativeDir, "admin/updates"));
|
|
49
|
+
expect(path.relative("/repo/dist/apps/portal", admin.targetRoot)).toStartWith("..");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("the server a release build's page calls", () => {
|
|
53
|
+
const runtimeEnvOf = async (env: "local" | "debug", options: { server?: boolean } = {}) => {
|
|
54
|
+
const nativeApp = new NativeApp(fakeApp(), target(), env);
|
|
55
|
+
let seen: Record<string, string> | undefined;
|
|
56
|
+
nativeApp.assembleWeb = async () => undefined as never;
|
|
57
|
+
nativeApp.prepare = async () =>
|
|
58
|
+
({
|
|
59
|
+
config: {},
|
|
60
|
+
api: {
|
|
61
|
+
build: async ({ env }: { env?: Record<string, string> }) => {
|
|
62
|
+
seen = env;
|
|
63
|
+
return { artifacts: [], warnings: [] };
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
}) as never;
|
|
67
|
+
await nativeApp.build("linux", options.server ? { server: { dir: "/s", entry: "main.js", env: {} } } : {});
|
|
68
|
+
return seen;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
test("a local release calls the app's own dev server, not 8282", async () => {
|
|
72
|
+
expect(await runtimeEnvOf("local")).toEqual({ PUBLIC_AKAN_SERVER_URL: "http://localhost:8284" });
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("a cloud release and a release carrying its server name none", async () => {
|
|
76
|
+
expect(await runtimeEnvOf("debug")).toEqual({});
|
|
77
|
+
expect(await runtimeEnvOf("local", { server: true })).toEqual({});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("an AKAN_PUBLIC_SERVER_URL the bundle was built with is left to win", async () => {
|
|
81
|
+
process.env.AKAN_PUBLIC_SERVER_URL = "http://192.168.0.10:8284";
|
|
82
|
+
try {
|
|
83
|
+
expect(await runtimeEnvOf("local")).toEqual({});
|
|
84
|
+
} finally {
|
|
85
|
+
delete process.env.AKAN_PUBLIC_SERVER_URL;
|
|
86
|
+
}
|
|
87
|
+
});
|
|
46
88
|
});
|
|
47
89
|
|
|
48
90
|
test("a dev build bundles no page a release build left, and builds apart from the release it would empty", async () => {
|
|
91
|
+
const root = await makeTempRoot();
|
|
49
92
|
const app = {
|
|
50
93
|
...fakeApp(),
|
|
51
|
-
cwdPath:
|
|
94
|
+
cwdPath: path.join(root, "apps/portal"),
|
|
95
|
+
workspace: { workspaceRoot: root },
|
|
52
96
|
logger: { info: () => undefined, debug: () => undefined, warn: () => undefined },
|
|
53
97
|
} as unknown as App;
|
|
54
98
|
const nativeApp = new NativeApp(app, target());
|
|
@@ -118,7 +162,12 @@ describe("NativeApp", () => {
|
|
|
118
162
|
test("assembles each platform's web root with that platform's indexPath", async () => {
|
|
119
163
|
const root = await makeTempRoot();
|
|
120
164
|
await writeText(path.join(root, "dist/csr/index.html"), "<html><head></head><body></body></html>");
|
|
121
|
-
const app = {
|
|
165
|
+
const app = {
|
|
166
|
+
...fakeApp(),
|
|
167
|
+
cwdPath: root,
|
|
168
|
+
workspace: { workspaceRoot: root },
|
|
169
|
+
dist: { cwdPath: path.join(root, "dist") },
|
|
170
|
+
} as unknown as App;
|
|
122
171
|
const nativeApp = new NativeApp(app, target({ indexPath: "/mobile", desktop: { indexPath: "/console" } }));
|
|
123
172
|
const injected = async () => await Bun.file(path.join(nativeApp.web.dir, "index.html")).text();
|
|
124
173
|
|
|
@@ -139,7 +188,7 @@ describe("NativeApp", () => {
|
|
|
139
188
|
|
|
140
189
|
expect(warnings).toEqual([]);
|
|
141
190
|
expect(config.plugins).toEqual([...NativeConfig.basePlugins, "push"]);
|
|
142
|
-
expect(config.web.dir).toBe(path.join(
|
|
191
|
+
expect(config.web.dir).toBe(path.join(nativeDir, "default/web"));
|
|
143
192
|
});
|
|
144
193
|
|
|
145
194
|
test("a config nothing is built from (the update key's) stages no desktop bin", async () => {
|