@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
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { RuleInputs } from "#rule.ts";
|
|
3
|
+
import { parse } from "#rules/syntax.ts";
|
|
4
|
+
import { unwrapped } from "./syntax.ts";
|
|
5
|
+
|
|
6
|
+
export interface SqlText {
|
|
7
|
+
readonly file: string;
|
|
8
|
+
readonly line: number;
|
|
9
|
+
readonly text: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const SQL_START =
|
|
13
|
+
/^\s*(?:(?:--[^\n]*\n|\/\*[\s\S]*?\*\/)\s*)*(?:select|with|insert|update|delete|truncate|alter|drop|create|grant|revoke|comment)\b/iu;
|
|
14
|
+
const SQL_CALLEE = /^(?:sql|query|execute|exec|raw)(?:\.|$)|\.(?:sql|unsafe|query|execute|exec|raw)$/iu;
|
|
15
|
+
|
|
16
|
+
function interpolation(node: ts.Expression): string {
|
|
17
|
+
if (ts.isStringLiteralLike(node)) {
|
|
18
|
+
return node.text;
|
|
19
|
+
}
|
|
20
|
+
return ts.isCallExpression(node) && node.arguments.every(ts.isStringLiteralLike)
|
|
21
|
+
? node.arguments
|
|
22
|
+
.filter(ts.isStringLiteralLike)
|
|
23
|
+
.map((argument) => argument.text)
|
|
24
|
+
.join(".")
|
|
25
|
+
: " ? ";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function templateText(node: ts.TemplateLiteral): string {
|
|
29
|
+
return ts.isNoSubstitutionTemplateLiteral(node)
|
|
30
|
+
? node.text
|
|
31
|
+
: node.head.text + node.templateSpans.map((span) => interpolation(span.expression) + span.literal.text).join("");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function textOf(expression: ts.Expression): string | undefined {
|
|
35
|
+
const node = unwrapped(expression);
|
|
36
|
+
if (ts.isStringLiteralLike(node)) {
|
|
37
|
+
return node.text;
|
|
38
|
+
}
|
|
39
|
+
if (ts.isTemplateExpression(node)) {
|
|
40
|
+
return templateText(node);
|
|
41
|
+
}
|
|
42
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
43
|
+
const query = node.properties.find((property) => ts.isPropertyAssignment(property) && property.name.getText() === "text");
|
|
44
|
+
return query !== undefined && ts.isPropertyAssignment(query) ? textOf(query.initializer) : undefined;
|
|
45
|
+
}
|
|
46
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
|
|
47
|
+
return `${textOf(node.left) ?? " ? "}${textOf(node.right) ?? " ? "}`;
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function queryAt(node: ts.Node, source: ts.SourceFile): string | undefined {
|
|
53
|
+
let text: string | undefined;
|
|
54
|
+
let explicit = false;
|
|
55
|
+
if (ts.isTaggedTemplateExpression(node)) {
|
|
56
|
+
text = templateText(node.template);
|
|
57
|
+
explicit = SQL_CALLEE.test(node.tag.getText(source));
|
|
58
|
+
} else if (ts.isCallExpression(node)) {
|
|
59
|
+
text = node.arguments[0] === undefined ? undefined : textOf(node.arguments[0]);
|
|
60
|
+
explicit = SQL_CALLEE.test(node.expression.getText(source));
|
|
61
|
+
} else if (ts.isVariableDeclaration(node) && node.initializer !== undefined) {
|
|
62
|
+
text = textOf(node.initializer);
|
|
63
|
+
}
|
|
64
|
+
return text !== undefined && (explicit || SQL_START.test(text)) ? text : undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function queriesIn(file: string, source: ts.SourceFile): readonly SqlText[] {
|
|
68
|
+
const queries: SqlText[] = [];
|
|
69
|
+
function visit(node: ts.Node): void {
|
|
70
|
+
const text = queryAt(node, source);
|
|
71
|
+
if (text !== undefined) {
|
|
72
|
+
queries.push({ file, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, text });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (!ts.isTaggedTemplateExpression(node)) {
|
|
76
|
+
ts.forEachChild(node, visit);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
visit(source);
|
|
80
|
+
return queries;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export async function sqlTexts(inputs: RuleInputs): Promise<readonly SqlText[]> {
|
|
84
|
+
const code = inputs.sources.flatMap((file) => {
|
|
85
|
+
const source = parse(file);
|
|
86
|
+
return source === undefined ? [] : queriesIn(file.path, source);
|
|
87
|
+
});
|
|
88
|
+
const sql = await Promise.all(
|
|
89
|
+
inputs.files
|
|
90
|
+
.filter((file) => file.endsWith(".sql"))
|
|
91
|
+
.map(async (file) => {
|
|
92
|
+
const text = inputs.sources.find((source) => source.path === file)?.text ?? (await inputs.readText(file));
|
|
93
|
+
if (text === undefined) {
|
|
94
|
+
throw new Error(`Cannot read SQL file ${file}. Restore the file or repair its read permissions.`);
|
|
95
|
+
}
|
|
96
|
+
return { file, line: 1, text };
|
|
97
|
+
}),
|
|
98
|
+
);
|
|
99
|
+
return [...code, ...sql];
|
|
100
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
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.ts";
|
|
6
|
+
|
|
7
|
+
const SupportImportsOptions = Schema.Struct({
|
|
8
|
+
modules: Schema.Array(Schema.String).pipe(Schema.withDecodingDefaultKey(Effect.succeed([]))),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const supportImports = defineRule({
|
|
12
|
+
check: async ({ options, ...inputs }) => {
|
|
13
|
+
if (!inputs.sources.some((file) => isTestSupport(file.path))) {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
const graph = await importGraph(inputs);
|
|
17
|
+
return graph.edges
|
|
18
|
+
.filter(
|
|
19
|
+
(edge) => isTestSupport(edge.from) && edge.to.kind === "file" && !isTestSupport(edge.to.path) && !matchesAny(edge.to.path, options.modules),
|
|
20
|
+
)
|
|
21
|
+
.map((edge) => ({
|
|
22
|
+
file: edge.from,
|
|
23
|
+
line: edge.line,
|
|
24
|
+
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.`,
|
|
25
|
+
subject: edge.specifier,
|
|
26
|
+
}));
|
|
27
|
+
},
|
|
28
|
+
description: "Test-support reaches repository modules only through the public surface named by the repository's module allowlist.",
|
|
29
|
+
id: "tests/support-imports",
|
|
30
|
+
options: Schema.toStandardSchemaV1(SupportImportsOptions, { parseOptions: { errors: "all", onExcessProperty: "error" } }),
|
|
31
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { isTestSupport, testName } from "#naming/testName.ts";
|
|
3
|
+
import type { Finding, SourceFile } from "#rule.ts";
|
|
4
|
+
import { parse } from "#rules/syntax.ts";
|
|
5
|
+
|
|
6
|
+
export function isSpec(path: string): boolean {
|
|
7
|
+
return testName(path)?.kind === "spec" && !isTestSupport(path);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function unwrapped(node: ts.Expression): ts.Expression {
|
|
11
|
+
return ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isSatisfiesExpression(node) || ts.isNonNullExpression(node)
|
|
12
|
+
? unwrapped(node.expression)
|
|
13
|
+
: node;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function memberName(node: ts.Expression): string | undefined {
|
|
17
|
+
const value = unwrapped(node);
|
|
18
|
+
if (ts.isPropertyAccessExpression(value)) {
|
|
19
|
+
return value.name.text;
|
|
20
|
+
}
|
|
21
|
+
return ts.isElementAccessExpression(value) && ts.isStringLiteralLike(value.argumentExpression) ? value.argumentExpression.text : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function numericValue(node: ts.Expression): number | undefined {
|
|
25
|
+
const value = unwrapped(node);
|
|
26
|
+
if (ts.isNumericLiteral(value)) {
|
|
27
|
+
return Number(value.text);
|
|
28
|
+
}
|
|
29
|
+
if (ts.isPrefixUnaryExpression(value) && (value.operator === ts.SyntaxKind.MinusToken || value.operator === ts.SyntaxKind.PlusToken)) {
|
|
30
|
+
const operand = numericValue(value.operand);
|
|
31
|
+
return operand === undefined ? undefined : operand * (value.operator === ts.SyntaxKind.MinusToken ? -1 : 1);
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function findingAt(source: ts.SourceFile, node: ts.Node, message: string): Finding {
|
|
37
|
+
return { file: source.fileName, line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1, message };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function nodesWhere(source: ts.SourceFile, accepts: (node: ts.Node) => boolean): readonly ts.Node[] {
|
|
41
|
+
const found: ts.Node[] = [];
|
|
42
|
+
function visit(node: ts.Node): void {
|
|
43
|
+
if (accepts(node)) {
|
|
44
|
+
found.push(node);
|
|
45
|
+
}
|
|
46
|
+
ts.forEachChild(node, visit);
|
|
47
|
+
}
|
|
48
|
+
visit(source);
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function topLevelHelpers(source: ts.SourceFile): readonly ts.Node[] {
|
|
53
|
+
return source.statements.flatMap<ts.Node>((statement) => {
|
|
54
|
+
if (ts.isFunctionDeclaration(statement)) {
|
|
55
|
+
return statement.body === undefined ? [] : [statement.name ?? statement];
|
|
56
|
+
}
|
|
57
|
+
return ts.isVariableStatement(statement)
|
|
58
|
+
? statement.declarationList.declarations.flatMap((declaration) => {
|
|
59
|
+
const value = declaration.initializer === undefined ? undefined : unwrapped(declaration.initializer);
|
|
60
|
+
return value !== undefined && functionValue(value) ? [declaration.name] : [];
|
|
61
|
+
})
|
|
62
|
+
: [];
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function functionValue(value: ts.Expression): boolean {
|
|
67
|
+
const node = unwrapped(value);
|
|
68
|
+
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
if (!ts.isCallExpression(node)) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
return ["fn", "fnUntraced"].includes(memberName(node.expression) ?? "") || (ts.isCallExpression(node.expression) && functionValue(node.expression));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function syntaxFindings(file: SourceFile, select: (source: ts.SourceFile) => readonly ts.Node[], message: string): readonly Finding[] {
|
|
78
|
+
const source = parse(file);
|
|
79
|
+
return source === undefined ? [] : select(source).map((node) => findingAt(source, node, message));
|
|
80
|
+
}
|