@akanjs/devkit 3.0.1 → 3.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -2
- package/akanApp/akanApp.host.test.ts +62 -0
- package/akanApp/akanApp.host.ts +13 -2
- package/akanApp/devHostPolicy.test.ts +14 -0
- package/akanApp/devHostPolicy.ts +11 -0
- package/akanConfig/akanConfig.test.ts +4 -1
- 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/createTunnel.test.ts +51 -0
- package/createTunnel.ts +32 -14
- 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 +78 -1
- package/workspaceLayout.ts +19 -2
|
@@ -114,7 +114,8 @@ export class DevGeneratedIndexSync {
|
|
|
114
114
|
|
|
115
115
|
async #facetContent(dir: string): Promise<string | null> {
|
|
116
116
|
const nameCasePattern = path.basename(dir) === "ui" ? FACET_PASCAL_CASE_RE : FACET_CAMEL_CASE_RE;
|
|
117
|
-
const entries = await readdir(dir, { withFileTypes: true }).catch(() =>
|
|
117
|
+
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
|
|
118
|
+
if (!entries) return null;
|
|
118
119
|
const exportNames = entries
|
|
119
120
|
.flatMap((entry) => {
|
|
120
121
|
const name = entry.name;
|
|
@@ -125,8 +126,8 @@ export class DevGeneratedIndexSync {
|
|
|
125
126
|
const exportName = name.replace(FACET_SOURCE_FILE_RE, "");
|
|
126
127
|
return nameCasePattern.test(exportName) ? [exportName] : [];
|
|
127
128
|
})
|
|
128
|
-
.sort();
|
|
129
|
-
if (exportNames.length === 0) return
|
|
129
|
+
.sort(compareExportNames);
|
|
130
|
+
if (exportNames.length === 0) return "export {};\n";
|
|
130
131
|
return `${exportNames.map((name) => `export * from "./${name}";`).join("\n")}\n`;
|
|
131
132
|
}
|
|
132
133
|
|
|
@@ -149,10 +150,26 @@ export class DevGeneratedIndexSync {
|
|
|
149
150
|
if (await exists(path.join(dir, `${modelName}.${type}.tsx`))) fileTypes.push(type);
|
|
150
151
|
}
|
|
151
152
|
if (fileTypes.length === 0) return null;
|
|
152
|
-
return
|
|
153
|
+
return `${fileTypes.map((type) => `import * as ${type} from "./${modelName}.${type}";`).join("\n")}\n\nexport const ${modelName} = { ${fileTypes.join(", ")} };\n`;
|
|
153
154
|
}
|
|
154
155
|
}
|
|
155
156
|
|
|
157
|
+
//? Biome's organizeImports order, or `akan lint` reorders the barrel: A < a < B < b, a digit before a letter, and a
|
|
158
|
+
//? run of digits compared by its length first. The facetIndex template sorts the same way.
|
|
159
|
+
const letterRankOf = (char: string) => char.toLowerCase().charCodeAt(0) * 2 + (char === char.toLowerCase() ? 1 : 0);
|
|
160
|
+
const digitRunAt = (name: string, idx: number) => /^\d+/.exec(name.slice(idx))?.[0] ?? "";
|
|
161
|
+
const compareExportNames = (a: string, b: string) => {
|
|
162
|
+
for (let idx = 0; idx < a.length && idx < b.length; ) {
|
|
163
|
+
const [runA, runB] = [digitRunAt(a, idx), digitRunAt(b, idx)];
|
|
164
|
+
if (runA && runB && runA !== runB) return runA.length - runB.length || (runA < runB ? -1 : 1);
|
|
165
|
+
if (runA && runB) idx += runA.length;
|
|
166
|
+
else if (runA || runB) return runA ? -1 : 1;
|
|
167
|
+
else if (a.charAt(idx) !== b.charAt(idx)) return letterRankOf(a.charAt(idx)) - letterRankOf(b.charAt(idx));
|
|
168
|
+
else idx++;
|
|
169
|
+
}
|
|
170
|
+
return a.length - b.length;
|
|
171
|
+
};
|
|
172
|
+
|
|
156
173
|
const exists = async (file: string) =>
|
|
157
174
|
stat(file)
|
|
158
175
|
.then(() => true)
|
|
@@ -354,7 +354,7 @@ describe("DevGeneratedIndexSync", () => {
|
|
|
354
354
|
|
|
355
355
|
expect(removed.errors).toEqual([]);
|
|
356
356
|
expect(removed.changedFiles).toEqual([index]);
|
|
357
|
-
expect(await
|
|
357
|
+
expect(await readFile(index, "utf8")).toBe("export {};\n");
|
|
358
358
|
});
|
|
359
359
|
|
|
360
360
|
test("ignores server/client folders as barrel facets", async () => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { JsonFormatter } from "./jsonFormatter";
|
|
3
|
+
import { formatWithBiome, hasBiome } from "./testHelpers";
|
|
4
|
+
|
|
5
|
+
const pad = (length: number) => "x".repeat(length);
|
|
6
|
+
const thousands = Array.from({ length: 40 }, (_, idx) => idx * 1000);
|
|
7
|
+
|
|
8
|
+
const fixtures: Record<string, unknown> = {
|
|
9
|
+
collapsed: { scalars: ["accessToken", "accessLog"], empty: [], none: {}, nested: [["a"], [{}]] },
|
|
10
|
+
commaAtTheEdge: { a: [pad(108)], b: [pad(109)], c: [pad(109)] },
|
|
11
|
+
objectsInArray: { routes: [{ path: "/" }, { path: "/a" }] },
|
|
12
|
+
numbers: { sizes: thousands, words: thousands.map(String) },
|
|
13
|
+
wide: { a: ["한".repeat(54)], b: ["한".repeat(55)] },
|
|
14
|
+
rootArray: [pad(60), pad(60)],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("JsonFormatter", () => {
|
|
18
|
+
test("collapses an array that fits the line with its trailing comma, and keeps every object expanded", () => {
|
|
19
|
+
expect(JsonFormatter.stringify(fixtures.collapsed, "akan.lib.json")).toBe(
|
|
20
|
+
'{\n "scalars": ["accessToken", "accessLog"],\n "empty": [],\n "none": {},\n "nested": [["a"], [{}]]\n}\n',
|
|
21
|
+
);
|
|
22
|
+
expect(JsonFormatter.stringify(fixtures.commaAtTheEdge, "akan.lib.json").split("\n")).toEqual([
|
|
23
|
+
"{",
|
|
24
|
+
` "a": ["${pad(108)}"],`,
|
|
25
|
+
' "b": [',
|
|
26
|
+
` "${pad(109)}"`,
|
|
27
|
+
" ],",
|
|
28
|
+
` "c": ["${pad(109)}"]`,
|
|
29
|
+
"}",
|
|
30
|
+
"",
|
|
31
|
+
]);
|
|
32
|
+
expect(JsonFormatter.stringify(fixtures.objectsInArray, "akan.lib.json")).toContain('"routes": [\n {\n');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("fills an array of numbers that overflows, and keeps any other overflowing array one item a line", () => {
|
|
36
|
+
const [, sizesOpen, firstRow, secondRow] = JsonFormatter.stringify(fixtures.numbers, "akan.app.json").split("\n");
|
|
37
|
+
expect(sizesOpen).toBe(' "sizes": [');
|
|
38
|
+
expect(firstRow).toStartWith(" 0, 1000, 2000,");
|
|
39
|
+
expect(firstRow?.length).toBeLessThanOrEqual(JsonFormatter.lineWidth);
|
|
40
|
+
expect(secondRow).toStartWith(" 18000,");
|
|
41
|
+
expect(JsonFormatter.stringify(fixtures.numbers, "akan.app.json")).toContain('"words": [\n "0",\n "1000",');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("measures a wide character as two columns", () => {
|
|
45
|
+
const [, fits, overflows] = JsonFormatter.stringify(fixtures.wide, "akan.lib.json").split("\n");
|
|
46
|
+
expect(fits).toBe(` "a": ["${"한".repeat(54)}"],`);
|
|
47
|
+
expect(overflows).toBe(' "b": [');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("keeps a package.json expanded the way JSON.stringify writes it", () => {
|
|
51
|
+
const manifest = { name: "x", workspaces: ["pkgs/*"], dependencies: {} };
|
|
52
|
+
expect(JsonFormatter.stringify(manifest, "libs/x/package.json")).toBe(`${JSON.stringify(manifest, null, 2)}\n`);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test.skipIf(!hasBiome)("prints what Biome prints", async () => {
|
|
56
|
+
expect(await formatWithBiome('{"a":[1]}', "libs/fixture/akan.lib.json")).toBe('{ "a": [1] }\n');
|
|
57
|
+
for (const [name, fixture] of Object.entries(fixtures)) {
|
|
58
|
+
const printed = JsonFormatter.stringify(fixture, `libs/fixture/${name}.json`);
|
|
59
|
+
expect(await formatWithBiome(printed, `libs/fixture/${name}.json`)).toBe(printed);
|
|
60
|
+
}
|
|
61
|
+
const manifest = JsonFormatter.stringify({ name: "x", workspaces: ["a", "b"] }, "package.json");
|
|
62
|
+
expect(await formatWithBiome(manifest, "libs/fixture/package.json")).toBe(manifest);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/jsonFormatter.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
|
|
3
|
+
type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };
|
|
4
|
+
|
|
5
|
+
interface Placement {
|
|
6
|
+
column: number;
|
|
7
|
+
trailing: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
//* Prints what `biome format` would, or `akan lint` and `akan sync` flip-flop a generated .json. Biome keeps a
|
|
11
|
+
//* package.json fully expanded (`expand: always`); elsewhere (`auto`) it keeps an object expanded, collapses an array
|
|
12
|
+
//* that fits the 120-column line (a trailing comma included), fills an array of numbers, and ends with a newline.
|
|
13
|
+
export class JsonFormatter {
|
|
14
|
+
static readonly lineWidth = 120;
|
|
15
|
+
|
|
16
|
+
static stringify(value: unknown, filePath: string) {
|
|
17
|
+
const json = JSON.stringify(value, null, 2);
|
|
18
|
+
if (path.basename(filePath) === "package.json") return `${json}\n`;
|
|
19
|
+
return `${JsonFormatter.#print(JSON.parse(json) as JsonValue, 0, { column: 0, trailing: 0 })}\n`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
static #print(value: JsonValue, depth: number, placement: Placement): string {
|
|
23
|
+
if (Array.isArray(value)) return JsonFormatter.#printArray(value, depth, placement);
|
|
24
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
25
|
+
const entries = Object.entries(value);
|
|
26
|
+
if (!entries.length) return "{}";
|
|
27
|
+
const indent = " ".repeat(depth + 1);
|
|
28
|
+
const members = entries.map(([key, member], idx) => {
|
|
29
|
+
const prefix = `${indent}${JSON.stringify(key)}: `;
|
|
30
|
+
const trailing = idx < entries.length - 1 ? 1 : 0;
|
|
31
|
+
return `${prefix}${JsonFormatter.#print(member, depth + 1, { column: Bun.stringWidth(prefix), trailing })}`;
|
|
32
|
+
});
|
|
33
|
+
return `{\n${members.join(",\n")}\n${" ".repeat(depth)}}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
static #printArray(items: JsonValue[], depth: number, { column, trailing }: Placement) {
|
|
37
|
+
if (!items.length) return "[]";
|
|
38
|
+
const flat = JsonFormatter.#flat(items);
|
|
39
|
+
if (flat !== null && column + Bun.stringWidth(flat) + trailing <= JsonFormatter.lineWidth) return flat;
|
|
40
|
+
const indent = " ".repeat(depth + 1);
|
|
41
|
+
const isLast = (idx: number) => idx === items.length - 1;
|
|
42
|
+
const body = items.every((item) => typeof item === "number")
|
|
43
|
+
? JsonFormatter.#fill(
|
|
44
|
+
items.map((item, idx) => `${JSON.stringify(item)}${isLast(idx) ? "" : ","}`),
|
|
45
|
+
indent,
|
|
46
|
+
)
|
|
47
|
+
: items
|
|
48
|
+
.map((item, idx) => {
|
|
49
|
+
const placement = { column: indent.length, trailing: isLast(idx) ? 0 : 1 };
|
|
50
|
+
return `${indent}${JsonFormatter.#print(item, depth + 1, placement)}`;
|
|
51
|
+
})
|
|
52
|
+
.join(",\n");
|
|
53
|
+
return `[\n${body}\n${" ".repeat(depth)}]`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
static #fill(tokens: string[], indent: string) {
|
|
57
|
+
const lines: string[] = [];
|
|
58
|
+
let line = "";
|
|
59
|
+
for (const token of tokens) {
|
|
60
|
+
if (line && Bun.stringWidth(line) + 1 + Bun.stringWidth(token) <= JsonFormatter.lineWidth) {
|
|
61
|
+
line = `${line} ${token}`;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (line) lines.push(line);
|
|
65
|
+
line = `${indent}${token}`;
|
|
66
|
+
}
|
|
67
|
+
return [...lines, line].join("\n");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static #flat(value: JsonValue): string | null {
|
|
71
|
+
if (Array.isArray(value)) {
|
|
72
|
+
const items = value.map((item) => JsonFormatter.#flat(item));
|
|
73
|
+
return items.includes(null) ? null : `[${items.join(", ")}]`;
|
|
74
|
+
}
|
|
75
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
76
|
+
return Object.keys(value).length ? null : "{}";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -7,9 +7,12 @@ fixture` fails when a rule is added without one.
|
|
|
7
7
|
|
|
8
8
|
## Contract
|
|
9
9
|
|
|
10
|
-
- One case per line. `// @flag` marks a line the rule must report
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
- One case per line. In `bad`, `// @flag` marks a line the rule must report as an error and `// @warn` one it
|
|
11
|
+
must report as a warning; `// @ok` marks a line in `good`.
|
|
12
|
+
- The `bad` assertion is exact, not containment: every marked line is reported once, at its marker's severity,
|
|
13
|
+
and nothing else is. A marked line nobody reported is a pattern that stopped matching, a reported line nobody
|
|
14
|
+
marked is the rule reaching further than the fixture claims, a line reported twice is one site reported
|
|
15
|
+
twice, and a different severity is a tier that moved.
|
|
13
16
|
- `good` is the half that matters most: an over-matching rule is what makes developers distrust the gate.
|
|
14
17
|
- `fixture.json` is optional:
|
|
15
18
|
- `path` — where the source is written inside the temp workspace, for a rule that reads `$filename`
|
|
@@ -35,6 +38,12 @@ Each of these made a rule silently report nothing, and each was found by adding
|
|
|
35
38
|
that is `no-bang-comment-in-client`'s blind spot, and why its fixture puts the marker between statements.
|
|
36
39
|
- A regex capture (`r"..."($var)`) carries no source range: a diagnostic spanned on one gets no location,
|
|
37
40
|
and `$var` does not interpolate into a `message`.
|
|
41
|
+
- A code snippet also matches JSX text that parses as it: `` `new Error` `` reports `<code>new Error</code>` in a
|
|
42
|
+
docs page. Where a rule meets `.tsx`, match the node kind instead (`JsNewExpression(callee = `Error`)`).
|
|
43
|
+
- A regex is anchored by bare concatenation, so a top-level alternation leaks out of the anchors: `r"fill|stroke"`
|
|
44
|
+
is `^fill|stroke$` and matches `fillOpacity`. Group it: `r"(?:fill|stroke)"`.
|
|
45
|
+
- A `\"` in a GritQL string is unescaped twice and corrupts it: messages quote with `'`, and a regex spells the
|
|
46
|
+
character `\x22`.
|
|
38
47
|
|
|
39
48
|
The five vocabulary fixtures were ported from `frontendBuild/styleGuard.test.ts`, the pre-grit
|
|
40
49
|
implementation of the same rules, when that scanner was retired.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export const dictionary = modelDictionary(["en", "ko"])
|
|
2
|
+
.of((t) => t(["Video Board", "비디오 보드"]).desc(["A board of cuts", "컷 보드"]))
|
|
3
|
+
.error({
|
|
4
|
+
videoBlockedByPolicy: [
|
|
5
|
+
"The provider refused this video: {{reason}}", // @flag
|
|
6
|
+
"제공자가 거절했습니다: {{reason}}", // @flag
|
|
7
|
+
],
|
|
8
|
+
invalidValues: [
|
|
9
|
+
"The device rejects these values: {{fields}} ({{command}}).", // @flag
|
|
10
|
+
"기기가 받지 않는 값입니다: {{fields}} ({{command}}).", // @flag
|
|
11
|
+
],
|
|
12
|
+
})
|
|
13
|
+
.translate({
|
|
14
|
+
greeting: [
|
|
15
|
+
`Hello {{name}}`, // @flag
|
|
16
|
+
`안녕하세요 {{name}}`, // @flag
|
|
17
|
+
],
|
|
18
|
+
turnSteps: [
|
|
19
|
+
`Turn ${"left"} in {{steps}} steps`, // @flag
|
|
20
|
+
"{{steps}}걸음 뒤 회전", // @flag
|
|
21
|
+
],
|
|
22
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "path": "apps/demo/lib/videoBoard/videoBoard.dictionary.ts" }
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export const dictionary = modelDictionary(["en", "ko"])
|
|
2
|
+
.of((t) => t(["Video Board", "비디오 보드"]).desc(["Cuts name a {{slot}} the user types", "컷에 {{슬롯}}을 적습니다"])) // @ok
|
|
3
|
+
.model((t) => ({
|
|
4
|
+
refAssets: t(["Ref Assets", "참고 자산"]).desc(["Each plays a {{slot}} of the plan", "기획안의 {{슬롯}}을 맡습니다"]), // @ok
|
|
5
|
+
}))
|
|
6
|
+
.endpoint((fn) => ({
|
|
7
|
+
bindSlot: fn(["Bind Slot", "슬롯 연결"]).desc(["Bind an asset to a {{slot}}", "{{슬롯}}에 자산을 연결합니다"]), // @ok
|
|
8
|
+
}))
|
|
9
|
+
.error({
|
|
10
|
+
videoBlockedByPolicy: ["The provider refused this video: {reason}", "제공자가 거절했습니다: {reason}"], // @ok
|
|
11
|
+
})
|
|
12
|
+
.translate({
|
|
13
|
+
greeting: [`Hello {name}`, `안녕하세요 {name}`], // @ok
|
|
14
|
+
bracesExplained: ["Wrap a name in { and }", "이름을 { 와 }로 감쌉니다"], // @ok
|
|
15
|
+
});
|
|
@@ -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
|
|
@@ -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 }); // @
|
|
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
|
}
|