@shivaedev/quality 0.27.1 → 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.
Files changed (63) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +20 -0
  3. package/dist/rules/builtIn.d.ts +7 -1
  4. package/dist/rules/builtIn.d.ts.map +1 -1
  5. package/dist/rules/builtIn.js +16 -0
  6. package/dist/rules/builtIn.js.map +1 -1
  7. package/dist/rules/stories/authSql.d.ts +4 -0
  8. package/dist/rules/stories/authSql.d.ts.map +1 -0
  9. package/dist/rules/stories/authSql.js +102 -0
  10. package/dist/rules/stories/authSql.js.map +1 -0
  11. package/dist/rules/stories/httpStatus.d.ts +4 -0
  12. package/dist/rules/stories/httpStatus.d.ts.map +1 -0
  13. package/dist/rules/stories/httpStatus.js +47 -0
  14. package/dist/rules/stories/httpStatus.js.map +1 -0
  15. package/dist/rules/stories/matchesAny.d.ts +2 -0
  16. package/dist/rules/stories/matchesAny.d.ts.map +1 -0
  17. package/dist/rules/stories/matchesAny.js +5 -0
  18. package/dist/rules/stories/matchesAny.js.map +1 -0
  19. package/dist/rules/stories/namedValues.d.ts +2 -0
  20. package/dist/rules/stories/namedValues.d.ts.map +1 -0
  21. package/dist/rules/stories/namedValues.js +15 -0
  22. package/dist/rules/stories/namedValues.js.map +1 -0
  23. package/dist/rules/stories/noExpect.d.ts +2 -0
  24. package/dist/rules/stories/noExpect.d.ts.map +1 -0
  25. package/dist/rules/stories/noExpect.js +33 -0
  26. package/dist/rules/stories/noExpect.js.map +1 -0
  27. package/dist/rules/stories/noLimitImports.d.ts +2 -0
  28. package/dist/rules/stories/noLimitImports.d.ts.map +1 -0
  29. package/dist/rules/stories/noLimitImports.js +66 -0
  30. package/dist/rules/stories/noLimitImports.js.map +1 -0
  31. package/dist/rules/stories/queueSql.d.ts +2 -0
  32. package/dist/rules/stories/queueSql.d.ts.map +1 -0
  33. package/dist/rules/stories/queueSql.js +15 -0
  34. package/dist/rules/stories/queueSql.js.map +1 -0
  35. package/dist/rules/stories/specHelpers.d.ts +2 -0
  36. package/dist/rules/stories/specHelpers.d.ts.map +1 -0
  37. package/dist/rules/stories/specHelpers.js +9 -0
  38. package/dist/rules/stories/specHelpers.js.map +1 -0
  39. package/dist/rules/stories/sqlText.d.ts +8 -0
  40. package/dist/rules/stories/sqlText.d.ts.map +1 -0
  41. package/dist/rules/stories/sqlText.js +86 -0
  42. package/dist/rules/stories/sqlText.js.map +1 -0
  43. package/dist/rules/stories/supportImports.d.ts +4 -0
  44. package/dist/rules/stories/supportImports.d.ts.map +1 -0
  45. package/dist/rules/stories/supportImports.js +28 -0
  46. package/dist/rules/stories/supportImports.js.map +1 -0
  47. package/dist/rules/stories/syntax.d.ts +11 -0
  48. package/dist/rules/stories/syntax.d.ts.map +1 -0
  49. package/dist/rules/stories/syntax.js +71 -0
  50. package/dist/rules/stories/syntax.js.map +1 -0
  51. package/package.json +1 -1
  52. package/src/rules/builtIn.ts +16 -0
  53. package/src/rules/stories/authSql.ts +117 -0
  54. package/src/rules/stories/httpStatus.ts +56 -0
  55. package/src/rules/stories/matchesAny.ts +5 -0
  56. package/src/rules/stories/namedValues.ts +21 -0
  57. package/src/rules/stories/noExpect.ts +39 -0
  58. package/src/rules/stories/noLimitImports.ts +80 -0
  59. package/src/rules/stories/queueSql.ts +18 -0
  60. package/src/rules/stories/specHelpers.ts +10 -0
  61. package/src/rules/stories/sqlText.ts +100 -0
  62. package/src/rules/stories/supportImports.ts +31 -0
  63. package/src/rules/stories/syntax.ts +80 -0
@@ -0,0 +1,86 @@
1
+ import ts from "typescript";
2
+ import { parse } from "#rules/syntax.ts";
3
+ import { unwrapped } from "./syntax.js";
4
+ const SQL_START = /^\s*(?:(?:--[^\n]*\n|\/\*[\s\S]*?\*\/)\s*)*(?:select|with|insert|update|delete|truncate|alter|drop|create|grant|revoke|comment)\b/iu;
5
+ const SQL_CALLEE = /^(?:sql|query|execute|exec|raw)(?:\.|$)|\.(?:sql|unsafe|query|execute|exec|raw)$/iu;
6
+ function interpolation(node) {
7
+ if (ts.isStringLiteralLike(node)) {
8
+ return node.text;
9
+ }
10
+ return ts.isCallExpression(node) && node.arguments.every(ts.isStringLiteralLike)
11
+ ? node.arguments
12
+ .filter(ts.isStringLiteralLike)
13
+ .map((argument) => argument.text)
14
+ .join(".")
15
+ : " ? ";
16
+ }
17
+ function templateText(node) {
18
+ return ts.isNoSubstitutionTemplateLiteral(node)
19
+ ? node.text
20
+ : node.head.text + node.templateSpans.map((span) => interpolation(span.expression) + span.literal.text).join("");
21
+ }
22
+ function textOf(expression) {
23
+ const node = unwrapped(expression);
24
+ if (ts.isStringLiteralLike(node)) {
25
+ return node.text;
26
+ }
27
+ if (ts.isTemplateExpression(node)) {
28
+ return templateText(node);
29
+ }
30
+ if (ts.isObjectLiteralExpression(node)) {
31
+ const query = node.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText() === "text");
32
+ return query !== undefined && ts.isPropertyAssignment(query) ? textOf(query.initializer) : undefined;
33
+ }
34
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
35
+ return `${textOf(node.left) ?? " ? "}${textOf(node.right) ?? " ? "}`;
36
+ }
37
+ return undefined;
38
+ }
39
+ function queryAt(node, source) {
40
+ let text;
41
+ let explicit = false;
42
+ if (ts.isTaggedTemplateExpression(node)) {
43
+ text = templateText(node.template);
44
+ explicit = SQL_CALLEE.test(node.tag.getText(source));
45
+ }
46
+ else if (ts.isCallExpression(node)) {
47
+ text = node.arguments[0] === undefined ? undefined : textOf(node.arguments[0]);
48
+ explicit = SQL_CALLEE.test(node.expression.getText(source));
49
+ }
50
+ else if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
51
+ text = textOf(node.initializer);
52
+ }
53
+ return text !== undefined && (explicit || SQL_START.test(text)) ? text : undefined;
54
+ }
55
+ function queriesIn(file, source) {
56
+ const queries = [];
57
+ function visit(node) {
58
+ const text = queryAt(node, source);
59
+ if (text !== undefined) {
60
+ queries.push({ file, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, text });
61
+ return;
62
+ }
63
+ if (!ts.isTaggedTemplateExpression(node)) {
64
+ ts.forEachChild(node, visit);
65
+ }
66
+ }
67
+ visit(source);
68
+ return queries;
69
+ }
70
+ export async function sqlTexts(inputs) {
71
+ const code = inputs.sources.flatMap((file) => {
72
+ const source = parse(file);
73
+ return source === undefined ? [] : queriesIn(file.path, source);
74
+ });
75
+ const sql = await Promise.all(inputs.files
76
+ .filter((file) => file.endsWith(".sql"))
77
+ .map(async (file) => {
78
+ const text = inputs.sources.find((source) => source.path === file)?.text ?? (await inputs.readText(file));
79
+ if (text === undefined) {
80
+ throw new Error(`Cannot read SQL file ${file}. Restore the file or repair its read permissions.`);
81
+ }
82
+ return { file, line: 1, text };
83
+ }));
84
+ return [...code, ...sql];
85
+ }
86
+ //# sourceMappingURL=sqlText.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlText.js","sourceRoot":"","sources":["../../../src/rules/stories/sqlText.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,4 @@
1
+ export declare const supportImports: import("#rule.ts").Rule<"tests/support-imports", {
2
+ readonly modules?: readonly string[];
3
+ }>;
4
+ //# sourceMappingURL=supportImports.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supportImports.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/supportImports.ts"],"names":[],"mappings":"AAUA,eAAO,MAAM,cAAc;;EAoBzB,CAAC"}
@@ -0,0 +1,28 @@
1
+ import { Effect, Schema } from "effect";
2
+ import { importGraph } from "#imports/graph.ts";
3
+ import { isTestSupport } from "#naming/testName.ts";
4
+ import { defineRule } from "#rule.ts";
5
+ import { matchesAny } from "./matchesAny.js";
6
+ const SupportImportsOptions = Schema.Struct({
7
+ modules: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
8
+ });
9
+ export const supportImports = defineRule({
10
+ check: async ({ options, ...inputs }) => {
11
+ if (!inputs.sources.some((file) => isTestSupport(file.path))) {
12
+ return [];
13
+ }
14
+ const graph = await importGraph(inputs);
15
+ return graph.edges
16
+ .filter((edge) => isTestSupport(edge.from) && edge.to.kind === "file" && !isTestSupport(edge.to.path) && !matchesAny(edge.to.path, options.modules))
17
+ .map((edge) => ({
18
+ file: edge.from,
19
+ line: edge.line,
20
+ message: `Test-support imports "${edge.specifier}" outside its allowed public surface. Drive the app through its host, schemas or queue definitions; list those public module paths in tests/support-imports.options.modules in the repository's quality config.`,
21
+ subject: edge.specifier,
22
+ }));
23
+ },
24
+ description: "Test-support reaches repository modules only through the public surface named by the repository's module allowlist.",
25
+ id: "tests/support-imports",
26
+ options: Schema.toStandardSchemaV1(SupportImportsOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
27
+ });
28
+ //# sourceMappingURL=supportImports.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"supportImports.js","sourceRoot":"","sources":["../../../src/rules/stories/supportImports.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAC;AACxC,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,MAAM,qBAAqB,CAAC;AACpD,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AACtC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,qBAAqB,GAAG,MAAM,CAAC,MAAM,CAAC;IAC3C,OAAO,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,MAAM,CAAC,sBAAsB,CAAC,MAAM,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC,CAAC;CAC5F,CAAC,CAAC;AAEH,MAAM,CAAC,MAAM,cAAc,GAAG,UAAU,CAAC;IACxC,KAAK,EAAE,KAAK,EAAE,EAAE,OAAO,EAAE,GAAG,MAAM,EAAE,EAAE,EAAE;QACvC,IAAI,CAAC,MAAM,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,EAAE,CAAC;YAC9D,OAAO,EAAE,CAAC;QACX,CAAC;QACD,MAAM,KAAK,GAAG,MAAM,WAAW,CAAC,MAAM,CAAC,CAAC;QACxC,OAAO,KAAK,CAAC,KAAK;aAChB,MAAM,CACN,CAAC,IAAI,EAAE,EAAE,CAAC,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,IAAI,CAAC,EAAE,CAAC,IAAI,KAAK,MAAM,IAAI,CAAC,aAAa,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,OAAO,CAAC,CAC3I;aACA,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE,CAAC,CAAC;YACf,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,IAAI,EAAE,IAAI,CAAC,IAAI;YACf,OAAO,EAAE,yBAAyB,IAAI,CAAC,SAAS,iNAAiN;YACjQ,OAAO,EAAE,IAAI,CAAC,SAAS;SACvB,CAAC,CAAC,CAAC;IACN,CAAC;IACD,WAAW,EAAE,qHAAqH;IAClI,EAAE,EAAE,uBAAuB;IAC3B,OAAO,EAAE,MAAM,CAAC,kBAAkB,CAAC,qBAAqB,EAAE,EAAE,YAAY,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,gBAAgB,EAAE,OAAO,EAAE,EAAE,CAAC;CACzH,CAAC,CAAC"}
@@ -0,0 +1,11 @@
1
+ import ts from "typescript";
2
+ import type { Finding, SourceFile } from "#rule.ts";
3
+ export declare function isSpec(path: string): boolean;
4
+ export declare function unwrapped(node: ts.Expression): ts.Expression;
5
+ export declare function memberName(node: ts.Expression): string | undefined;
6
+ export declare function numericValue(node: ts.Expression): number | undefined;
7
+ export declare function findingAt(source: ts.SourceFile, node: ts.Node, message: string): Finding;
8
+ export declare function nodesWhere(source: ts.SourceFile, accepts: (node: ts.Node) => boolean): readonly ts.Node[];
9
+ export declare function topLevelHelpers(source: ts.SourceFile): readonly ts.Node[];
10
+ export declare function syntaxFindings(file: SourceFile, select: (source: ts.SourceFile) => readonly ts.Node[], message: string): readonly Finding[];
11
+ //# sourceMappingURL=syntax.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"syntax.d.ts","sourceRoot":"","sources":["../../../src/rules/stories/syntax.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,YAAY,CAAC;AAE5B,OAAO,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,UAAU,CAAC;AAGpD,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAE5C;AAED,wBAAgB,SAAS,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,GAAG,EAAE,CAAC,UAAU,CAI5D;AAED,wBAAgB,UAAU,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,GAAG,MAAM,GAAG,SAAS,CAMlE;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,EAAE,CAAC,UAAU,GAAG,MAAM,GAAG,SAAS,CAUpE;AAED,wBAAgB,SAAS,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAExF;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,EAAE,OAAO,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC,IAAI,KAAK,OAAO,GAAG,SAAS,EAAE,CAAC,IAAI,EAAE,CAUzG;AAED,wBAAgB,eAAe,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,GAAG,SAAS,EAAE,CAAC,IAAI,EAAE,CAYzE;AAaD,wBAAgB,cAAc,CAAC,IAAI,EAAE,UAAU,EAAE,MAAM,EAAE,CAAC,MAAM,EAAE,EAAE,CAAC,UAAU,KAAK,SAAS,EAAE,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,SAAS,OAAO,EAAE,CAG3I"}
@@ -0,0 +1,71 @@
1
+ import ts from "typescript";
2
+ import { isTestSupport, testName } from "#naming/testName.ts";
3
+ import { parse } from "#rules/syntax.ts";
4
+ export function isSpec(path) {
5
+ return testName(path)?.kind === "spec" && !isTestSupport(path);
6
+ }
7
+ export function unwrapped(node) {
8
+ return ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node)
9
+ ? unwrapped(node.expression)
10
+ : node;
11
+ }
12
+ export function memberName(node) {
13
+ const value = unwrapped(node);
14
+ if (ts.isPropertyAccessExpression(value)) {
15
+ return value.name.text;
16
+ }
17
+ return ts.isElementAccessExpression(value) && ts.isStringLiteralLike(value.argumentExpression) ? value.argumentExpression.text : undefined;
18
+ }
19
+ export function numericValue(node) {
20
+ const value = unwrapped(node);
21
+ if (ts.isNumericLiteral(value)) {
22
+ return Number(value.text);
23
+ }
24
+ if (ts.isPrefixUnaryExpression(value) && (value.operator === ts.SyntaxKind.MinusToken || value.operator === ts.SyntaxKind.PlusToken)) {
25
+ const operand = numericValue(value.operand);
26
+ return operand === undefined ? undefined : operand * (value.operator === ts.SyntaxKind.MinusToken ? -1 : 1);
27
+ }
28
+ return undefined;
29
+ }
30
+ export function findingAt(source, node, message) {
31
+ return { file: source.fileName, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, message };
32
+ }
33
+ export function nodesWhere(source, accepts) {
34
+ const found = [];
35
+ function visit(node) {
36
+ if (accepts(node)) {
37
+ found.push(node);
38
+ }
39
+ ts.forEachChild(node, visit);
40
+ }
41
+ visit(source);
42
+ return found;
43
+ }
44
+ export function topLevelHelpers(source) {
45
+ return source.statements.flatMap((statement) => {
46
+ if (ts.isFunctionDeclaration(statement)) {
47
+ return statement.body === undefined ? [] : [statement.name ?? statement];
48
+ }
49
+ return ts.isVariableStatement(statement)
50
+ ? statement.declarationList.declarations.flatMap((declaration) => {
51
+ const value = declaration.initializer === undefined ? undefined : unwrapped(declaration.initializer);
52
+ return value !== undefined && functionValue(value) ? [declaration.name] : [];
53
+ })
54
+ : [];
55
+ });
56
+ }
57
+ function functionValue(value) {
58
+ const node = unwrapped(value);
59
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
60
+ return true;
61
+ }
62
+ if (!ts.isCallExpression(node)) {
63
+ return false;
64
+ }
65
+ return ["fn", "fnUntraced"].includes(memberName(node.expression) ?? "") || (ts.isCallExpression(node.expression) && functionValue(node.expression));
66
+ }
67
+ export function syntaxFindings(file, select, message) {
68
+ const source = parse(file);
69
+ return source === undefined ? [] : select(source).map((node) => findingAt(source, node, message));
70
+ }
71
+ //# sourceMappingURL=syntax.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"syntax.js","sourceRoot":"","sources":["../../../src/rules/stories/syntax.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shivaedev/quality",
3
- "version": "0.27.1",
3
+ "version": "0.28.0",
4
4
  "description": "Repository quality gate with typed rules, a baseline of existing debt and a registry of reasoned exceptions",
5
5
  "homepage": "https://github.com/ShivaeDev/platform/tree/main/packages/quality#readme",
6
6
  "bugs": {
@@ -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;
@@ -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,5 @@
1
+ import { matchesGlob } from "node:path";
2
+
3
+ export function matchesAny(path: string, patterns: readonly string[]): boolean {
4
+ return patterns.some((pattern) => matchesGlob(path, pattern));
5
+ }
@@ -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
+ });
@@ -0,0 +1,18 @@
1
+ import { defineRule } from "#rule.ts";
2
+ import { sqlTexts } from "./sqlText.ts";
3
+
4
+ const QUEUE_SCHEMA = /\bpgboss\b"?\s*\./iu;
5
+
6
+ export const queueSql = defineRule({
7
+ check: async (inputs) =>
8
+ (await sqlTexts(inputs))
9
+ .filter((query) => QUEUE_SCHEMA.test(query.text))
10
+ .map((query) => ({
11
+ file: query.file,
12
+ line: query.line,
13
+ message:
14
+ "SQL mentions pgboss. Use the app's public queue definitions and let workers process jobs; apps never edit or purge queue tables, and there is no queue-purge API by design.",
15
+ })),
16
+ description: "Apps never read, edit or purge pg-boss's private queue schema.",
17
+ id: "boundaries/queue-sql",
18
+ });
@@ -0,0 +1,10 @@
1
+ import { defineRule } from "#rule.ts";
2
+ import { isSpec, syntaxFindings, topLevelHelpers } from "./syntax.ts";
3
+
4
+ const MESSAGE = "Declares a top-level helper in a spec. Move the action into the story kit's verbs or the setup into its traits in test-support/.";
5
+
6
+ export const specHelpers = defineRule({
7
+ check: ({ sources }) => sources.filter((file) => isSpec(file.path)).flatMap((file) => syntaxFindings(file, topLevelHelpers, MESSAGE)),
8
+ description: "Specs state expectations; story-kit traits and verbs in test-support own helpers.",
9
+ id: "tests/spec-helpers",
10
+ });