@shivaedev/quality 0.27.1 → 0.28.1
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 +12 -0
- package/README.md +22 -2
- package/biome/declarations.json +5 -0
- package/biome/preset.json +3 -0
- package/dist/biome/exceptionConfig.js +1 -1
- package/dist/biome/exceptionConfig.js.map +1 -1
- package/dist/biome/report.js +1 -1
- package/dist/biome/report.js.map +1 -1
- package/dist/inventory/walk.js +1 -1
- package/dist/inventory/walk.js.map +1 -1
- 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/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/dist/testProjects.js +1 -1
- package/dist/testProjects.js.map +1 -1
- package/package.json +1 -1
- package/src/biome/exceptionConfig.ts +1 -1
- package/src/biome/report.ts +1 -1
- package/src/inventory/walk.ts +1 -1
- package/src/rules/builtIn.ts +16 -0
- 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/testProjects.ts +1 -1
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|
package/src/testProjects.ts
CHANGED
|
@@ -12,7 +12,7 @@ function named(environment?: Environment): string {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
function excluding(...patterns: readonly string[]): string[] {
|
|
15
|
-
return [...defaultExclude, named("typecheck"), TYPECHECK_ALONE, ...patterns];
|
|
15
|
+
return [...defaultExclude, "**/.worktrees/**", named("typecheck"), TYPECHECK_ALONE, ...patterns];
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export interface TestProjectsOptions {
|