@shivaedev/quality 0.27.0 → 0.28.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 +17 -0
- package/README.md +26 -1
- package/dist/biome/run.d.ts.map +1 -1
- package/dist/biome/run.js +15 -2
- package/dist/biome/run.js.map +1 -1
- package/dist/cli/args.d.ts +3 -1
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +3 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/cli/messages.d.ts +1 -1
- package/dist/cli/messages.d.ts.map +1 -1
- package/dist/cli/messages.js +3 -1
- package/dist/cli/messages.js.map +1 -1
- package/dist/cli/program.d.ts.map +1 -1
- package/dist/cli/program.js +3 -0
- package/dist/cli/program.js.map +1 -1
- package/dist/hooks/install.d.ts.map +1 -1
- package/dist/hooks/install.js +9 -8
- package/dist/hooks/install.js.map +1 -1
- package/dist/hooks/shim.d.ts.map +1 -1
- package/dist/hooks/shim.js +11 -1
- package/dist/hooks/shim.js.map +1 -1
- package/dist/hooks/status.d.ts +6 -0
- package/dist/hooks/status.d.ts.map +1 -0
- package/dist/hooks/status.js +45 -0
- package/dist/hooks/status.js.map +1 -0
- package/dist/hooks/uninstall.d.ts.map +1 -1
- package/dist/hooks/uninstall.js +6 -0
- package/dist/hooks/uninstall.js.map +1 -1
- package/dist/inventory/collect.d.ts.map +1 -1
- package/dist/inventory/collect.js +3 -0
- package/dist/inventory/collect.js.map +1 -1
- package/dist/inventory/shadcnAlias.d.ts +2 -0
- package/dist/inventory/shadcnAlias.d.ts.map +1 -0
- package/dist/inventory/shadcnAlias.js +73 -0
- package/dist/inventory/shadcnAlias.js.map +1 -0
- package/dist/inventory/shadcnFolders.d.ts +4 -0
- package/dist/inventory/shadcnFolders.d.ts.map +1 -0
- package/dist/inventory/shadcnFolders.js +25 -0
- package/dist/inventory/shadcnFolders.js.map +1 -0
- package/dist/naming/migrationFile.d.ts +4 -0
- package/dist/naming/migrationFile.d.ts.map +1 -0
- package/dist/naming/migrationFile.js +8 -0
- package/dist/naming/migrationFile.js.map +1 -0
- package/dist/rules/builtIn.d.ts +7 -1
- package/dist/rules/builtIn.d.ts.map +1 -1
- package/dist/rules/builtIn.js +16 -0
- package/dist/rules/builtIn.js.map +1 -1
- package/dist/rules/files/folderNames.d.ts.map +1 -1
- package/dist/rules/files/folderNames.js +1 -2
- package/dist/rules/files/folderNames.js.map +1 -1
- package/dist/rules/files/namedAfterExport.d.ts.map +1 -1
- package/dist/rules/files/namedAfterExport.js +4 -1
- package/dist/rules/files/namedAfterExport.js.map +1 -1
- package/dist/rules/files/otherNames.js +1 -1
- package/dist/rules/files/otherNames.js.map +1 -1
- package/dist/rules/stories/authSql.d.ts +4 -0
- package/dist/rules/stories/authSql.d.ts.map +1 -0
- package/dist/rules/stories/authSql.js +102 -0
- package/dist/rules/stories/authSql.js.map +1 -0
- package/dist/rules/stories/httpStatus.d.ts +4 -0
- package/dist/rules/stories/httpStatus.d.ts.map +1 -0
- package/dist/rules/stories/httpStatus.js +47 -0
- package/dist/rules/stories/httpStatus.js.map +1 -0
- package/dist/rules/stories/matchesAny.d.ts +2 -0
- package/dist/rules/stories/matchesAny.d.ts.map +1 -0
- package/dist/rules/stories/matchesAny.js +5 -0
- package/dist/rules/stories/matchesAny.js.map +1 -0
- package/dist/rules/stories/namedValues.d.ts +2 -0
- package/dist/rules/stories/namedValues.d.ts.map +1 -0
- package/dist/rules/stories/namedValues.js +15 -0
- package/dist/rules/stories/namedValues.js.map +1 -0
- package/dist/rules/stories/noExpect.d.ts +2 -0
- package/dist/rules/stories/noExpect.d.ts.map +1 -0
- package/dist/rules/stories/noExpect.js +33 -0
- package/dist/rules/stories/noExpect.js.map +1 -0
- package/dist/rules/stories/noLimitImports.d.ts +2 -0
- package/dist/rules/stories/noLimitImports.d.ts.map +1 -0
- package/dist/rules/stories/noLimitImports.js +66 -0
- package/dist/rules/stories/noLimitImports.js.map +1 -0
- package/dist/rules/stories/queueSql.d.ts +2 -0
- package/dist/rules/stories/queueSql.d.ts.map +1 -0
- package/dist/rules/stories/queueSql.js +15 -0
- package/dist/rules/stories/queueSql.js.map +1 -0
- package/dist/rules/stories/specHelpers.d.ts +2 -0
- package/dist/rules/stories/specHelpers.d.ts.map +1 -0
- package/dist/rules/stories/specHelpers.js +9 -0
- package/dist/rules/stories/specHelpers.js.map +1 -0
- package/dist/rules/stories/sqlText.d.ts +8 -0
- package/dist/rules/stories/sqlText.d.ts.map +1 -0
- package/dist/rules/stories/sqlText.js +86 -0
- package/dist/rules/stories/sqlText.js.map +1 -0
- package/dist/rules/stories/supportImports.d.ts +4 -0
- package/dist/rules/stories/supportImports.d.ts.map +1 -0
- package/dist/rules/stories/supportImports.js +28 -0
- package/dist/rules/stories/supportImports.js.map +1 -0
- package/dist/rules/stories/syntax.d.ts +11 -0
- package/dist/rules/stories/syntax.d.ts.map +1 -0
- package/dist/rules/stories/syntax.js +71 -0
- package/dist/rules/stories/syntax.js.map +1 -0
- package/docs/debt-and-hooks.md +4 -2
- package/package.json +5 -2
- package/src/biome/run.ts +16 -2
- package/src/cli/args.ts +4 -0
- package/src/cli/messages.ts +3 -1
- package/src/cli/program.ts +3 -0
- package/src/hooks/install.ts +11 -12
- package/src/hooks/shim.ts +11 -1
- package/src/hooks/status.ts +50 -0
- package/src/hooks/uninstall.ts +6 -0
- package/src/inventory/collect.ts +3 -0
- package/src/inventory/shadcnAlias.ts +88 -0
- package/src/inventory/shadcnFolders.ts +30 -0
- package/src/naming/migrationFile.ts +9 -0
- package/src/rules/builtIn.ts +16 -0
- package/src/rules/files/folderNames.ts +1 -3
- package/src/rules/files/namedAfterExport.ts +4 -1
- package/src/rules/files/otherNames.ts +1 -1
- package/src/rules/stories/authSql.ts +117 -0
- package/src/rules/stories/httpStatus.ts +56 -0
- package/src/rules/stories/matchesAny.ts +5 -0
- package/src/rules/stories/namedValues.ts +21 -0
- package/src/rules/stories/noExpect.ts +39 -0
- package/src/rules/stories/noLimitImports.ts +80 -0
- package/src/rules/stories/queueSql.ts +18 -0
- package/src/rules/stories/specHelpers.ts +10 -0
- package/src/rules/stories/sqlText.ts +100 -0
- package/src/rules/stories/supportImports.ts +31 -0
- package/src/rules/stories/syntax.ts +80 -0
package/src/hooks/shim.ts
CHANGED
|
@@ -16,13 +16,23 @@ export function hookShim(directory: string, command: HookCommand): string {
|
|
|
16
16
|
OWNER,
|
|
17
17
|
'top="$(git rev-parse --show-toplevel)" || exit 2',
|
|
18
18
|
'if [ -n "$GIT_DIR" ] && [ -z "$GIT_WORK_TREE" ]; then export GIT_WORK_TREE="$top"; fi',
|
|
19
|
+
'cd "$top" || exit 2',
|
|
20
|
+
"status=0",
|
|
21
|
+
'global_path="$(git config --global --includes --type=path --get core.hooksPath)"',
|
|
22
|
+
'if [ -n "$global_path" ]; then',
|
|
23
|
+
'\tglobal_hook="$global_path/pre-commit"',
|
|
24
|
+
'\tif [ -x "$global_hook" ] && ! [ "$global_hook" -ef "$0" ]; then',
|
|
25
|
+
'\t\t"$global_hook" "$@" || status=$?',
|
|
26
|
+
"\tfi",
|
|
27
|
+
"fi",
|
|
19
28
|
`cd "$top"${folder} || exit 2`,
|
|
20
29
|
`quality=${shellWords([command.launcher])}`,
|
|
21
30
|
'if [ ! -e "$quality" ]; then',
|
|
22
31
|
'\tprintf \'quality: cannot check this commit: %s/%s does not exist.\\nhelp: install the dependencies of this worktree, then commit again.\\n\' "$PWD" "$quality" >&2',
|
|
23
32
|
"\texit 2",
|
|
24
33
|
"fi",
|
|
25
|
-
|
|
34
|
+
`${exec.join(" ")} || status=$?`,
|
|
35
|
+
'exit "$status"',
|
|
26
36
|
"",
|
|
27
37
|
].join("\n");
|
|
28
38
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, realpath } from "node:fs/promises";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { Console, Effect } from "effect";
|
|
5
|
+
import { GateFailed, SetupFailure } from "#failure.ts";
|
|
6
|
+
import { git, gitOrFail } from "#git/command.ts";
|
|
7
|
+
import { readOptionalText } from "#inventory/filesystem.ts";
|
|
8
|
+
import { type HookLocation, hookLocation } from "./location.ts";
|
|
9
|
+
import { isHookShim } from "./shim.ts";
|
|
10
|
+
|
|
11
|
+
function executable(file: string) {
|
|
12
|
+
return Effect.promise(async () => {
|
|
13
|
+
try {
|
|
14
|
+
await access(file, constants.X_OK);
|
|
15
|
+
return await realpath(file);
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const globalHookStatus = Effect.fn("Hooks.globalHookStatus")(function* (location: HookLocation) {
|
|
23
|
+
const global = yield* git(location.toplevel, ["config", "--global", "--includes", "--type=path", "--get", "core.hooksPath"]);
|
|
24
|
+
if (global.code !== 0 && global.code !== 1) {
|
|
25
|
+
return yield* new SetupFailure({ message: `cannot read global core.hooksPath: ${global.stderr.trim()}` });
|
|
26
|
+
}
|
|
27
|
+
const path = global.stdout.trim();
|
|
28
|
+
const hook = resolve(location.toplevel, path, "pre-commit");
|
|
29
|
+
const target = path === "" ? undefined : yield* executable(hook);
|
|
30
|
+
const own = yield* executable(location.file);
|
|
31
|
+
yield* Console.log(
|
|
32
|
+
target !== undefined && target !== own
|
|
33
|
+
? `quality: chaining global pre-commit hook at ${hook}.`
|
|
34
|
+
: "quality: no executable global pre-commit hook to chain.",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const hookStatus = Effect.fn("Hooks.hookStatus")(function* (cwd: string) {
|
|
39
|
+
const location = yield* hookLocation(cwd);
|
|
40
|
+
const path = (yield* gitOrFail(cwd, ["rev-parse", "--path-format=absolute", "--git-path", "hooks/pre-commit"])).trim();
|
|
41
|
+
const text = yield* readOptionalText(path).pipe(Effect.mapError((error) => new SetupFailure({ message: error.message })));
|
|
42
|
+
if (text === undefined || !isHookShim(text) || (yield* executable(path)) === undefined) {
|
|
43
|
+
yield* Console.error(
|
|
44
|
+
`quality: the effective hooks path ${dirname(path)} does not run Quality's pre-commit hook.\nhelp: run \`quality hooks install\` to check commits.`,
|
|
45
|
+
);
|
|
46
|
+
return yield* new GateFailed();
|
|
47
|
+
}
|
|
48
|
+
yield* Console.log(`quality: git runs Quality's pre-commit hook at ${path}.`);
|
|
49
|
+
yield* globalHookStatus(location);
|
|
50
|
+
});
|
package/src/hooks/uninstall.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
1
2
|
import { Console, Effect, FileSystem } from "effect";
|
|
2
3
|
import { SetupFailure } from "#failure.ts";
|
|
4
|
+
import { git, gitOrFail } from "#git/command.ts";
|
|
3
5
|
import { readOptionalText } from "#inventory/filesystem.ts";
|
|
4
6
|
import { hookLocation } from "./location.ts";
|
|
5
7
|
import { isHookShim } from "./shim.ts";
|
|
@@ -15,5 +17,9 @@ export const uninstallHook = Effect.fn("Hooks.uninstallHook")(function* (cwd: st
|
|
|
15
17
|
return yield* Console.error(`quality: kept ${file}: it is not quality's pre-commit hook.`);
|
|
16
18
|
}
|
|
17
19
|
yield* Effect.mapError(fs.remove(file), (error) => new SetupFailure({ message: `cannot remove ${file}: ${error.message}` }));
|
|
20
|
+
const local = yield* git(cwd, ["config", "--local", "--get", "core.hooksPath"]);
|
|
21
|
+
if (local.stdout.trim() === dirname(file)) {
|
|
22
|
+
yield* gitOrFail(cwd, ["config", "--local", "--unset-all", "core.hooksPath"]);
|
|
23
|
+
}
|
|
18
24
|
yield* Console.log(`quality: removed the pre-commit hook at ${file}.`);
|
|
19
25
|
});
|
package/src/inventory/collect.ts
CHANGED
|
@@ -4,6 +4,7 @@ import ignore from "ignore";
|
|
|
4
4
|
import type { SourceFile } from "#rule.ts";
|
|
5
5
|
import { FilesystemFailure, orWhenAbsent, readRequiredText } from "./filesystem.ts";
|
|
6
6
|
import { posix } from "./ignoreScope.ts";
|
|
7
|
+
import { shadcnFolders } from "./shadcnFolders.ts";
|
|
7
8
|
import { scopeAbove, walk } from "./walk.ts";
|
|
8
9
|
|
|
9
10
|
export interface InventoryScope {
|
|
@@ -49,10 +50,12 @@ export const collectInventory = Effect.fn("Inventory.collectInventory")(function
|
|
|
49
50
|
scope: InventoryScope,
|
|
50
51
|
): Effect.fn.Return<Inventory, FilesystemFailure, FileSystem.FileSystem> {
|
|
51
52
|
const excluded = ignore().add([...scope.exclude]);
|
|
53
|
+
const toolOwned = yield* shadcnFolders(root);
|
|
52
54
|
const zones = yield* Effect.forEach(scope.sources, (zone) => zoneFiles(root, zone), { concurrency: READ_CONCURRENCY });
|
|
53
55
|
const relativePaths = zones.flat().map((absolute) => posix(relative(root, absolute)));
|
|
54
56
|
const files = [...new Set(relativePaths)]
|
|
55
57
|
.filter((path) => !excluded.ignores(path))
|
|
58
|
+
.filter((path) => !toolOwned.some((folder) => path.startsWith(`${folder}/`)))
|
|
56
59
|
.sort((left, right) => {
|
|
57
60
|
if (left === right) {
|
|
58
61
|
return 0;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
|
|
4
|
+
const PROBE = "__quality_shadcn__.tsx";
|
|
5
|
+
|
|
6
|
+
function capture(pattern: string, alias: string): string | undefined {
|
|
7
|
+
const star = pattern.indexOf("*");
|
|
8
|
+
if (star === -1) {
|
|
9
|
+
return pattern === alias ? "" : undefined;
|
|
10
|
+
}
|
|
11
|
+
const prefix = pattern.slice(0, star);
|
|
12
|
+
const suffix = pattern.slice(star + 1);
|
|
13
|
+
return alias.startsWith(prefix) && alias.endsWith(suffix)
|
|
14
|
+
? alias.slice(prefix.length, suffix.length === 0 ? undefined : -suffix.length)
|
|
15
|
+
: undefined;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function pathTarget(alias: string, options: ts.CompilerOptions, directory: string): string | undefined {
|
|
19
|
+
const pathsBase = Reflect.get(options, "pathsBasePath");
|
|
20
|
+
const base = options.baseUrl ?? (typeof pathsBase === "string" ? pathsBase : directory);
|
|
21
|
+
const patterns = Object.entries(options.paths ?? {}).toSorted(([left], [right]) => right.indexOf("*") - left.indexOf("*"));
|
|
22
|
+
const exact = options.paths?.[alias]?.[0];
|
|
23
|
+
if (exact !== undefined) {
|
|
24
|
+
return resolve(base, exact);
|
|
25
|
+
}
|
|
26
|
+
for (const [pattern, targets] of patterns) {
|
|
27
|
+
const captured = capture(pattern, alias);
|
|
28
|
+
const target = targets[0];
|
|
29
|
+
if (captured !== undefined && target !== undefined) {
|
|
30
|
+
return resolve(base, target.replace("*", captured));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function shadcnAlias(directory: string, alias: string): string {
|
|
37
|
+
if (alias.startsWith(".") || isAbsolute(alias)) {
|
|
38
|
+
return resolve(directory, alias);
|
|
39
|
+
}
|
|
40
|
+
const config = ts.findConfigFile(directory, ts.sys.fileExists);
|
|
41
|
+
const parsed =
|
|
42
|
+
config === undefined
|
|
43
|
+
? undefined
|
|
44
|
+
: ts.getParsedCommandLineOfConfigFile(
|
|
45
|
+
config,
|
|
46
|
+
{},
|
|
47
|
+
{
|
|
48
|
+
...ts.sys,
|
|
49
|
+
onUnRecoverableConfigFileDiagnostic: () => undefined,
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
if (parsed?.errors.length) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
ts.formatDiagnostics(parsed.errors, {
|
|
55
|
+
getCanonicalFileName: (file) => file,
|
|
56
|
+
getCurrentDirectory: () => directory,
|
|
57
|
+
getNewLine: () => "\n",
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
const options = parsed?.options ?? {};
|
|
62
|
+
const target = pathTarget(alias, options, config === undefined ? directory : dirname(config));
|
|
63
|
+
if (target !== undefined) {
|
|
64
|
+
return target;
|
|
65
|
+
}
|
|
66
|
+
if (!alias.startsWith("#")) {
|
|
67
|
+
throw new Error(`Cannot resolve shadcn UI alias "${alias}" from ${directory}. Check tsconfig paths.`);
|
|
68
|
+
}
|
|
69
|
+
// A synthetic child lets TypeScript resolve directory aliases through package imports without requiring a stock component to exist.
|
|
70
|
+
const resolved = ts.resolveModuleName(
|
|
71
|
+
`${alias}/${PROBE}`,
|
|
72
|
+
join(directory, "components.tsx"),
|
|
73
|
+
{
|
|
74
|
+
...options,
|
|
75
|
+
customConditions: ["source", ...(options.customConditions ?? [])],
|
|
76
|
+
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
...ts.sys,
|
|
80
|
+
directoryExists: () => true,
|
|
81
|
+
fileExists: (file) => file.endsWith(`/${PROBE}`) || ts.sys.fileExists(file),
|
|
82
|
+
},
|
|
83
|
+
).resolvedModule;
|
|
84
|
+
if (resolved === undefined) {
|
|
85
|
+
throw new Error(`Cannot resolve shadcn UI alias "${alias}" from ${directory}. Check tsconfig paths or package imports.`);
|
|
86
|
+
}
|
|
87
|
+
return dirname(resolved.resolvedFileName);
|
|
88
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { basename, dirname, relative } from "node:path";
|
|
2
|
+
import { Effect, type FileSystem, Schema } from "effect";
|
|
3
|
+
import { FilesystemFailure, readRequiredText } from "./filesystem.ts";
|
|
4
|
+
import { posix } from "./ignoreScope.ts";
|
|
5
|
+
import { shadcnAlias } from "./shadcnAlias.ts";
|
|
6
|
+
import { walk } from "./walk.ts";
|
|
7
|
+
|
|
8
|
+
const Components = Schema.fromJsonString(Schema.Struct({ aliases: Schema.Struct({ ui: Schema.NonEmptyString }) }));
|
|
9
|
+
|
|
10
|
+
export const shadcnFolders = Effect.fn("Inventory.shadcnFolders")(function* (
|
|
11
|
+
root: string,
|
|
12
|
+
): Effect.fn.Return<readonly string[], FilesystemFailure, FileSystem.FileSystem> {
|
|
13
|
+
const paths = (yield* walk(root)).filter((path) => basename(path) === "components.json");
|
|
14
|
+
return yield* Effect.forEach(paths, (path) =>
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const text = yield* readRequiredText(path);
|
|
17
|
+
return yield* Effect.try({
|
|
18
|
+
catch: (cause) => new FilesystemFailure({ message: `cannot resolve ${path}: ${String(cause)}`, path }),
|
|
19
|
+
try: () => {
|
|
20
|
+
const { aliases } = Schema.decodeUnknownSync(Components)(text);
|
|
21
|
+
const folder = posix(relative(root, shadcnAlias(dirname(path), aliases.ui)));
|
|
22
|
+
if (folder === "" || folder === ".." || folder.startsWith("../")) {
|
|
23
|
+
throw new Error("the shadcn UI folder must be inside the repository and cannot be its root");
|
|
24
|
+
}
|
|
25
|
+
return folder;
|
|
26
|
+
},
|
|
27
|
+
});
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
|
|
3
|
+
export const MIGRATIONS_DIRECTORY = "src/server/migrations";
|
|
4
|
+
export const MIGRATION_FILE_PATTERN = /^[0-9]{4}_[a-z][a-z0-9]*(?:_[a-z0-9]+)*\.ts$/u;
|
|
5
|
+
|
|
6
|
+
export function isMigrationFile(path: string): boolean {
|
|
7
|
+
const directory = posix.dirname(path);
|
|
8
|
+
return (directory === MIGRATIONS_DIRECTORY || directory.endsWith(`/${MIGRATIONS_DIRECTORY}`)) && MIGRATION_FILE_PATTERN.test(posix.basename(path));
|
|
9
|
+
}
|
package/src/rules/builtIn.ts
CHANGED
|
@@ -16,8 +16,16 @@ import { importsManifest } from "#rules/imports/manifest.ts";
|
|
|
16
16
|
import { noTestCode } from "#rules/imports/noTestCode.ts";
|
|
17
17
|
import { importsResolvable } from "#rules/imports/resolvable.ts";
|
|
18
18
|
import { importsRuntime } from "#rules/imports/runtime.ts";
|
|
19
|
+
import { authSql } from "#rules/stories/authSql.ts";
|
|
20
|
+
import { httpStatus } from "#rules/stories/httpStatus.ts";
|
|
21
|
+
import { namedValues } from "#rules/stories/namedValues.ts";
|
|
22
|
+
import { noExpect } from "#rules/stories/noExpect.ts";
|
|
23
|
+
import { noLimitImports } from "#rules/stories/noLimitImports.ts";
|
|
24
|
+
import { queueSql } from "#rules/stories/queueSql.ts";
|
|
19
25
|
import { storySetup } from "#rules/stories/setup.ts";
|
|
26
|
+
import { specHelpers } from "#rules/stories/specHelpers.ts";
|
|
20
27
|
import { storySpecs } from "#rules/stories/specs.ts";
|
|
28
|
+
import { supportImports } from "#rules/stories/supportImports.ts";
|
|
21
29
|
import { biomeOverrides } from "#rules/suppressions/biomeOverrides.ts";
|
|
22
30
|
import { biomeRecommended } from "#rules/suppressions/biomeRecommended.ts";
|
|
23
31
|
import { noDoubleCast } from "#rules/suppressions/noDoubleCast.ts";
|
|
@@ -66,4 +74,12 @@ export const builtInRules = [
|
|
|
66
74
|
testsColocated,
|
|
67
75
|
storySetup,
|
|
68
76
|
storySpecs,
|
|
77
|
+
queueSql,
|
|
78
|
+
authSql,
|
|
79
|
+
noExpect,
|
|
80
|
+
specHelpers,
|
|
81
|
+
supportImports,
|
|
82
|
+
httpStatus,
|
|
83
|
+
namedValues,
|
|
84
|
+
noLimitImports,
|
|
69
85
|
] as const;
|
|
@@ -5,15 +5,13 @@ import { packageLayout } from "#naming/packageLayout.ts";
|
|
|
5
5
|
import { KEBAB, PASCAL } from "#naming/words.ts";
|
|
6
6
|
import { defineRule, type Finding } from "#rule.ts";
|
|
7
7
|
|
|
8
|
-
const DEFAULT_TOOL_OWNED: readonly string[] = ["migrations/"];
|
|
9
|
-
|
|
10
8
|
const SNAKE = /^[a-z0-9]+(?:_[a-z0-9]+)*$/u;
|
|
11
9
|
|
|
12
10
|
const CODE = /\.[cm]?[jt]sx?$/u;
|
|
13
11
|
|
|
14
12
|
const FolderNamesOptions = Schema.Struct({
|
|
15
13
|
content: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
16
|
-
toolOwned: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed(
|
|
14
|
+
toolOwned: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
17
15
|
});
|
|
18
16
|
|
|
19
17
|
function foldersIn(files: readonly string[]): readonly string[] {
|
|
@@ -3,6 +3,7 @@ import { Effect, Schema } from "effect";
|
|
|
3
3
|
import ignore from "ignore";
|
|
4
4
|
import { exportFits } from "#naming/exportFits.ts";
|
|
5
5
|
import { mainExport } from "#naming/mainExport.ts";
|
|
6
|
+
import { isMigrationFile } from "#naming/migrationFile.ts";
|
|
6
7
|
import { ownExports } from "#naming/ownExports.ts";
|
|
7
8
|
import { type PackageLayout, packageLayout } from "#naming/packageLayout.ts";
|
|
8
9
|
import { CAMEL, KEBAB, sameWord, wordsOf } from "#naming/words.ts";
|
|
@@ -86,7 +87,9 @@ export const namedAfterExport = defineRule({
|
|
|
86
87
|
check: async ({ files, options, readText, sources }) => {
|
|
87
88
|
const layout = await packageLayout({ files, readText });
|
|
88
89
|
const toolOwned = ignore().add([...options.toolOwned]);
|
|
89
|
-
return sources
|
|
90
|
+
return sources
|
|
91
|
+
.filter((file) => !(TEST.test(file.path) || isMigrationFile(file.path) || toolOwned.ignores(file.path)))
|
|
92
|
+
.flatMap((file) => findingsOf(file, layout));
|
|
90
93
|
},
|
|
91
94
|
description:
|
|
92
95
|
"A file is named after its main export, with its folders as the prefix; a file of several exports is a camelCase topic file, and a script is kebab-case. Rename the file or the export; nothing renames them for you.",
|
|
@@ -5,7 +5,7 @@ import { CAMEL, KEBAB } from "#naming/words.ts";
|
|
|
5
5
|
import { defineRule, type Finding } from "#rule.ts";
|
|
6
6
|
import { stylesheetImporters } from "./stylesheetImporters.ts";
|
|
7
7
|
|
|
8
|
-
const DEFAULT_TOOL_OWNED: readonly string[] = [".*", "package.json", "tsconfig*.json", "biome.json", "biome.jsonc", "*.config.*"
|
|
8
|
+
const DEFAULT_TOOL_OWNED: readonly string[] = [".*", "package.json", "tsconfig*.json", "biome.json", "biome.jsonc", "*.config.*"];
|
|
9
9
|
|
|
10
10
|
const CONVENTIONAL: ReadonlySet<string> = new Set(["AGENTS", "CHANGELOG", "CLAUDE", "CONTRIBUTING", "LICENSE", "README", "SECURITY", "SKILL"]);
|
|
11
11
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import { defineRule } from "#rule.ts";
|
|
3
|
+
import { matchesAny } from "./matchesAny.ts";
|
|
4
|
+
import { sqlTexts } from "./sqlText.ts";
|
|
5
|
+
|
|
6
|
+
const AuthSqlOptions = Schema.Struct({
|
|
7
|
+
owners: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
const TOKENS = /--[^\n]*|\/\*[\s\S]*?\*\/|'(?:''|[^'])*'|"(?:""|[^"])*"|[\w$]+|[.,;()]/gu;
|
|
11
|
+
const TABLE_START = new Set(["from", "join", "update", "into", "table", "truncate", "references", "using"]);
|
|
12
|
+
const TABLE_END = new Set(["where", "group", "order", "having", "limit", "returning", "set", "values", "on", ";"]);
|
|
13
|
+
const SKIP = new Set(["if", "not", "exists", "only", "table"]);
|
|
14
|
+
const AUTH_TABLES = new Set(["user", "account", "session", "verification"]);
|
|
15
|
+
const EXPRESSION_FROM = new Set(["extract", "overlay", "substring", "trim"]);
|
|
16
|
+
const NON_TABLE_TARGETS = new Set([
|
|
17
|
+
"all",
|
|
18
|
+
"database",
|
|
19
|
+
"domain",
|
|
20
|
+
"foreign",
|
|
21
|
+
"function",
|
|
22
|
+
"language",
|
|
23
|
+
"large",
|
|
24
|
+
"parameter",
|
|
25
|
+
"procedure",
|
|
26
|
+
"routine",
|
|
27
|
+
"schema",
|
|
28
|
+
"sequence",
|
|
29
|
+
"tablespace",
|
|
30
|
+
"type",
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function tableAt(tokens: readonly string[], index: number): string {
|
|
34
|
+
let next = index;
|
|
35
|
+
while (SKIP.has(tokens[next]?.toLowerCase() ?? "")) {
|
|
36
|
+
const only = tokens[next]?.toLowerCase() === "only";
|
|
37
|
+
next += 1;
|
|
38
|
+
if (only && tokens[next] === "(") {
|
|
39
|
+
next += 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
while (tokens[next + 1] === ".") {
|
|
43
|
+
next += 2;
|
|
44
|
+
}
|
|
45
|
+
return (tokens[next] ?? "").replaceAll('"', "").toLowerCase();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function tableBoundary(tokens: readonly string[], index: number, statementStart: number, fromIsTable: boolean) {
|
|
49
|
+
const word = tokens[index]?.toLowerCase() ?? "";
|
|
50
|
+
const permissions = ["grant", "revoke"].includes(tokens[statementStart]?.toLowerCase() ?? "");
|
|
51
|
+
const roleTarget = permissions && (word === "to" || word === "from");
|
|
52
|
+
const permissionTarget = permissions && word === "on" && !NON_TABLE_TARGETS.has(tokens[index + 1]?.toLowerCase() ?? "");
|
|
53
|
+
const indexTarget = word === "on" && tokens.slice(statementStart, index).some((part) => part.toLowerCase() === "index");
|
|
54
|
+
const tableClause = TABLE_START.has(word) && (word !== "from" || fromIsTable);
|
|
55
|
+
return {
|
|
56
|
+
ends: TABLE_END.has(word) || roleTarget,
|
|
57
|
+
starts: !roleTarget && (tableClause || indexTarget || permissionTarget),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface SqlContext {
|
|
62
|
+
depth: number;
|
|
63
|
+
readonly expressionFromDepths: Set<number>;
|
|
64
|
+
statementStart: number;
|
|
65
|
+
readonly tablesAtDepth: Set<number>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function advanceContext(context: SqlContext, token: string, index: number, previous: string | undefined): void {
|
|
69
|
+
if (token === ";") {
|
|
70
|
+
context.statementStart = index + 1;
|
|
71
|
+
context.expressionFromDepths.clear();
|
|
72
|
+
}
|
|
73
|
+
if (token === "(") {
|
|
74
|
+
context.depth += 1;
|
|
75
|
+
if (EXPRESSION_FROM.has(previous?.toLowerCase() ?? "")) {
|
|
76
|
+
context.expressionFromDepths.add(context.depth);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (token === ")") {
|
|
80
|
+
context.tablesAtDepth.delete(context.depth);
|
|
81
|
+
context.expressionFromDepths.delete(context.depth);
|
|
82
|
+
context.depth -= 1;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function authTableIn(text: string): boolean {
|
|
87
|
+
const tokens = [...text.matchAll(TOKENS)]
|
|
88
|
+
.map(([token]) => token)
|
|
89
|
+
.filter((token) => !(token.startsWith("--") || token.startsWith("/*") || token.startsWith("'")));
|
|
90
|
+
const context: SqlContext = { depth: 0, expressionFromDepths: new Set(), statementStart: 0, tablesAtDepth: new Set() };
|
|
91
|
+
return tokens.some((token, index) => {
|
|
92
|
+
advanceContext(context, token, index, tokens[index - 1]);
|
|
93
|
+
const boundary = tableBoundary(tokens, index, context.statementStart, !context.expressionFromDepths.has(context.depth));
|
|
94
|
+
if (boundary.ends) {
|
|
95
|
+
context.tablesAtDepth.delete(context.depth);
|
|
96
|
+
}
|
|
97
|
+
if (boundary.starts) {
|
|
98
|
+
context.tablesAtDepth.add(context.depth);
|
|
99
|
+
}
|
|
100
|
+
return (boundary.starts || (token === "," && context.tablesAtDepth.has(context.depth))) && AUTH_TABLES.has(tableAt(tokens, index + 1));
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const authSql = defineRule({
|
|
105
|
+
check: async ({ options, ...inputs }) =>
|
|
106
|
+
(await sqlTexts(inputs))
|
|
107
|
+
.filter((query) => !matchesAny(query.file, options.owners) && authTableIn(query.text))
|
|
108
|
+
.map((query) => ({
|
|
109
|
+
file: query.file,
|
|
110
|
+
line: query.line,
|
|
111
|
+
message:
|
|
112
|
+
"SQL accesses a Better Auth table (user, account, session or verification). Use platform's auth API; only platform's auth implementation and its migrations, named in the repository's auth-sql owners, own those tables.",
|
|
113
|
+
})),
|
|
114
|
+
description: "Apps use platform's auth API instead of querying or modifying Better Auth's tables.",
|
|
115
|
+
id: "boundaries/auth-sql",
|
|
116
|
+
options: Schema.toStandardSchemaV1(AuthSqlOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
117
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { Effect, Schema } from "effect";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { defineRule } from "#rule.ts";
|
|
4
|
+
import { matchesAny } from "./matchesAny.ts";
|
|
5
|
+
import { memberName, nodesWhere, numericValue, syntaxFindings, unwrapped } from "./syntax.ts";
|
|
6
|
+
|
|
7
|
+
const MESSAGE =
|
|
8
|
+
"Branches on an HTTP error status outside the HTTP edge. Handle the typed domain error here and translate it to an HTTP status in the configured edge module.";
|
|
9
|
+
|
|
10
|
+
const HttpStatusOptions = Schema.Struct({
|
|
11
|
+
edge: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
const COMPARISONS = new Set([
|
|
15
|
+
ts.SyntaxKind.EqualsEqualsToken,
|
|
16
|
+
ts.SyntaxKind.EqualsEqualsEqualsToken,
|
|
17
|
+
ts.SyntaxKind.ExclamationEqualsToken,
|
|
18
|
+
ts.SyntaxKind.ExclamationEqualsEqualsToken,
|
|
19
|
+
ts.SyntaxKind.LessThanToken,
|
|
20
|
+
ts.SyntaxKind.LessThanEqualsToken,
|
|
21
|
+
ts.SyntaxKind.GreaterThanToken,
|
|
22
|
+
ts.SyntaxKind.GreaterThanEqualsToken,
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
function isErrorStatus(node: ts.Expression): boolean {
|
|
26
|
+
const number = numericValue(node);
|
|
27
|
+
return number !== undefined && number >= 400 && number <= 599;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function statusBranch(node: ts.Node): boolean {
|
|
31
|
+
if (ts.isBinaryExpression(node) && COMPARISONS.has(node.operatorToken.kind)) {
|
|
32
|
+
return (memberName(node.left) === "status" && isErrorStatus(node.right)) || (memberName(node.right) === "status" && isErrorStatus(node.left));
|
|
33
|
+
}
|
|
34
|
+
if (ts.isCaseClause(node) && isErrorStatus(node.expression) && ts.isCaseBlock(node.parent) && ts.isSwitchStatement(node.parent.parent)) {
|
|
35
|
+
return memberName(node.parent.parent.expression) === "status";
|
|
36
|
+
}
|
|
37
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "includes") {
|
|
38
|
+
const receiver = unwrapped(node.expression.expression);
|
|
39
|
+
return (
|
|
40
|
+
ts.isArrayLiteralExpression(receiver)
|
|
41
|
+
&& receiver.elements.some(isErrorStatus)
|
|
42
|
+
&& node.arguments.some((argument) => memberName(argument) === "status")
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export const httpStatus = defineRule({
|
|
49
|
+
check: ({ sources, options }) =>
|
|
50
|
+
sources
|
|
51
|
+
.filter((file) => !matchesAny(file.path, options.edge))
|
|
52
|
+
.flatMap((file) => syntaxFindings(file, (source) => nodesWhere(source, statusBranch), MESSAGE)),
|
|
53
|
+
description: "HTTP status numbers belong at the repository's HTTP edge; domain code handles typed errors.",
|
|
54
|
+
id: "boundaries/http-status",
|
|
55
|
+
options: Schema.toStandardSchemaV1(HttpStatusOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
56
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { defineRule } from "#rule.ts";
|
|
3
|
+
import { isSpec, memberName, nodesWhere, numericValue, syntaxFindings } from "./syntax.ts";
|
|
4
|
+
|
|
5
|
+
const MESSAGE =
|
|
6
|
+
"Passes a numeric literal to toBe or toHaveLength. Assert the observed items, or give the expected value a domain name before asserting it.";
|
|
7
|
+
|
|
8
|
+
function numericAssertion(node: ts.Node): boolean {
|
|
9
|
+
return (
|
|
10
|
+
ts.isCallExpression(node)
|
|
11
|
+
&& ["toBe", "toHaveLength"].includes(memberName(node.expression) ?? "")
|
|
12
|
+
&& node.arguments.some((argument) => numericValue(argument) !== undefined)
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const namedValues = defineRule({
|
|
17
|
+
check: ({ sources }) =>
|
|
18
|
+
sources.filter((file) => isSpec(file.path)).flatMap((file) => syntaxFindings(file, (source) => nodesWhere(source, numericAssertion), MESSAGE)),
|
|
19
|
+
description: "Stories assert observed items or named values instead of bare counts and numbers.",
|
|
20
|
+
id: "tests/named-values",
|
|
21
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { isTestSupport } from "#naming/testName.ts";
|
|
3
|
+
import { defineRule } from "#rule.ts";
|
|
4
|
+
import { memberName, nodesWhere, syntaxFindings, unwrapped } from "./syntax.ts";
|
|
5
|
+
|
|
6
|
+
const MESSAGE =
|
|
7
|
+
"Calls expect in test-support. Return the engine's observed state from the verb and put the expectation in the spec; throw if an action cannot run.";
|
|
8
|
+
|
|
9
|
+
function assertionReference(expression: ts.Expression, names: ReadonlySet<string>): boolean {
|
|
10
|
+
const node = unwrapped(expression);
|
|
11
|
+
if (ts.isIdentifier(node)) {
|
|
12
|
+
return names.has(node.text);
|
|
13
|
+
}
|
|
14
|
+
return (
|
|
15
|
+
memberName(node) === "expect"
|
|
16
|
+
|| ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && assertionReference(node.expression, names))
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function assertionsIn(source: ts.SourceFile): readonly ts.Node[] {
|
|
21
|
+
const names = new Set(["expect"]);
|
|
22
|
+
for (const statement of source.statements) {
|
|
23
|
+
const bindings = ts.isImportDeclaration(statement) ? statement.importClause?.namedBindings : undefined;
|
|
24
|
+
if (bindings !== undefined && ts.isNamedImports(bindings)) {
|
|
25
|
+
for (const element of bindings.elements) {
|
|
26
|
+
if ((element.propertyName ?? element.name).text === "expect") {
|
|
27
|
+
names.add(element.name.text);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return nodesWhere(source, (node) => ts.isCallExpression(node) && assertionReference(node.expression, names));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const noExpect = defineRule({
|
|
36
|
+
check: ({ sources }) => sources.filter((file) => isTestSupport(file.path)).flatMap((file) => syntaxFindings(file, assertionsIn, MESSAGE)),
|
|
37
|
+
description: "Test-support performs actions and exposes observations; specs own expectations.",
|
|
38
|
+
id: "tests/no-expect-in-support",
|
|
39
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { defineRule } from "#rule.ts";
|
|
3
|
+
import { isSpec, memberName, nodesWhere, syntaxFindings, unwrapped } from "./syntax.ts";
|
|
4
|
+
|
|
5
|
+
const MESSAGE =
|
|
6
|
+
"Imports a MAX_* constant from a limits module. Tell a story about the observed behavior instead of asserting a copy of the implementation's cap.";
|
|
7
|
+
|
|
8
|
+
const LIMITS_MODULE = /(?:^|[/#])limits(?:\.[cm]?[jt]s)?(?:\/|$)/u;
|
|
9
|
+
const MAX_CONSTANT = /^MAX_/u;
|
|
10
|
+
|
|
11
|
+
function literalLimitsImport(expression: ts.Expression): boolean {
|
|
12
|
+
const node = unwrapped(expression);
|
|
13
|
+
if (ts.isAwaitExpression(node)) {
|
|
14
|
+
return literalLimitsImport(node.expression);
|
|
15
|
+
}
|
|
16
|
+
return (
|
|
17
|
+
ts.isCallExpression(node)
|
|
18
|
+
&& node.expression.kind === ts.SyntaxKind.ImportKeyword
|
|
19
|
+
&& node.arguments[0] !== undefined
|
|
20
|
+
&& ts.isStringLiteralLike(node.arguments[0])
|
|
21
|
+
&& LIMITS_MODULE.test(node.arguments[0].text)
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function capBinding(binding: ts.BindingElement): boolean {
|
|
26
|
+
if (binding.dotDotDotToken !== undefined) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
const name = binding.propertyName ?? binding.name;
|
|
30
|
+
if (ts.isComputedPropertyName(name)) {
|
|
31
|
+
return ts.isStringLiteralLike(name.expression) && MAX_CONSTANT.test(name.expression.text);
|
|
32
|
+
}
|
|
33
|
+
return (ts.isIdentifier(name) || ts.isStringLiteralLike(name)) && MAX_CONSTANT.test(name.text);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function capImports(source: ts.SourceFile): readonly ts.Node[] {
|
|
37
|
+
const namespaces = new Set<string>();
|
|
38
|
+
const imports = source.statements.flatMap((statement) => {
|
|
39
|
+
if (
|
|
40
|
+
!(ts.isImportDeclaration(statement) && ts.isStringLiteralLike(statement.moduleSpecifier) && LIMITS_MODULE.test(statement.moduleSpecifier.text))
|
|
41
|
+
) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
const bindings = statement.importClause?.namedBindings;
|
|
45
|
+
if (bindings !== undefined && ts.isNamespaceImport(bindings)) {
|
|
46
|
+
namespaces.add(bindings.name.text);
|
|
47
|
+
}
|
|
48
|
+
return bindings !== undefined && ts.isNamedImports(bindings)
|
|
49
|
+
? bindings.elements.filter((element) => MAX_CONSTANT.test((element.propertyName ?? element.name).text))
|
|
50
|
+
: [];
|
|
51
|
+
});
|
|
52
|
+
const dynamic = nodesWhere(source, ts.isVariableDeclaration).flatMap<ts.Node>((node) => {
|
|
53
|
+
if (!ts.isVariableDeclaration(node) || node.initializer === undefined || !literalLimitsImport(node.initializer)) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
if (ts.isIdentifier(node.name)) {
|
|
57
|
+
namespaces.add(node.name.text);
|
|
58
|
+
return [];
|
|
59
|
+
}
|
|
60
|
+
return ts.isObjectBindingPattern(node.name) ? node.name.elements.filter(capBinding) : [];
|
|
61
|
+
});
|
|
62
|
+
return [
|
|
63
|
+
...imports,
|
|
64
|
+
...dynamic,
|
|
65
|
+
...nodesWhere(
|
|
66
|
+
source,
|
|
67
|
+
(node) =>
|
|
68
|
+
(ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node))
|
|
69
|
+
&& ts.isIdentifier(node.expression)
|
|
70
|
+
&& namespaces.has(node.expression.text)
|
|
71
|
+
&& MAX_CONSTANT.test(memberName(node) ?? ""),
|
|
72
|
+
),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const noLimitImports = defineRule({
|
|
77
|
+
check: ({ sources }) => sources.filter((file) => isSpec(file.path)).flatMap((file) => syntaxFindings(file, capImports, MESSAGE)),
|
|
78
|
+
description: "Stories prove behavior instead of importing implementation caps from limits modules.",
|
|
79
|
+
id: "tests/no-limit-imports",
|
|
80
|
+
});
|