@blundergoat/gruff-ts 0.1.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 +16 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/SECURITY.md +45 -0
- package/bin/gruff-ts +25 -0
- package/docs/CONFIGURATION.md +220 -0
- package/docs/RELEASING.md +103 -0
- package/docs/REPORTS_AND_CI.md +156 -0
- package/fixtures/sample.ts +21 -0
- package/package.json +56 -0
- package/scripts/bump-version.sh +145 -0
- package/scripts/check.sh +4 -0
- package/scripts/npm-publish.sh +258 -0
- package/scripts/preflight-checks.sh +357 -0
- package/scripts/start-dev.sh +8 -0
- package/scripts/test-performance.sh +695 -0
- package/src/analyser.ts +461 -0
- package/src/baseline.ts +90 -0
- package/src/blocks.ts +687 -0
- package/src/class-rules.ts +326 -0
- package/src/cli-program.ts +326 -0
- package/src/cli.ts +19 -0
- package/src/comment-rules.ts +605 -0
- package/src/comment-scanner.ts +357 -0
- package/src/config.ts +622 -0
- package/src/constants.ts +4 -0
- package/src/context-doc-rules.ts +241 -0
- package/src/dashboard.ts +114 -0
- package/src/dead-code-rules.ts +183 -0
- package/src/discovery.ts +508 -0
- package/src/doc-rules.ts +368 -0
- package/src/findings-helpers.ts +108 -0
- package/src/findings.ts +45 -0
- package/src/fixture-purpose-rules.ts +334 -0
- package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
- package/src/github-actions-rules.ts +413 -0
- package/src/line-rules.ts +538 -0
- package/src/naming-pushers.ts +191 -0
- package/src/project-config-rules.ts +555 -0
- package/src/project-rules.ts +545 -0
- package/src/report-renderers.ts +691 -0
- package/src/rule-list.ts +179 -0
- package/src/rules.ts +135 -0
- package/src/safety-rules.ts +355 -0
- package/src/scoring.ts +74 -0
- package/src/security-flow-rules.ts +112 -0
- package/src/sensitive-data-rules.ts +288 -0
- package/src/source-text.ts +722 -0
- package/src/test-block-rules.ts +347 -0
- package/src/test-fixtures.ts +621 -0
- package/src/text-scans.ts +193 -0
- package/src/types.ts +113 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
// Cross-file architecture rules (deep imports, cycles, large-module concentration), test-adequacy
|
|
2
|
+
// (missing-nearby-test), and the path-classification helpers (isTestPath, isFixtureLikePath, etc.)
|
|
3
|
+
// every rule pass shares. Pulls the project-index types and the rules that consume them out of cli.ts
|
|
4
|
+
// so the orchestrator stays lean.
|
|
5
|
+
import { basename, dirname as dirnamePath, extname, join } from "node:path";
|
|
6
|
+
import { isString, optionNumber, ruleSeverity, threshold } from "./config.ts";
|
|
7
|
+
import { type SourceFile } from "./discovery.ts";
|
|
8
|
+
import { makeFinding } from "./findings.ts";
|
|
9
|
+
import { fileBaseName } from "./findings-helpers.ts";
|
|
10
|
+
import { byteLine } from "./text-scans.ts";
|
|
11
|
+
import type { Config, Finding, Severity } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
// First exported callable/value in a production file. Missing-nearby-test only needs this compact
|
|
14
|
+
// surface, so the project index does not retain full source bodies after per-file analysis.
|
|
15
|
+
export interface ProjectExportedSurface {
|
|
16
|
+
symbol: string;
|
|
17
|
+
line: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Read-once snapshot of a discovered file. Lines are cached because cross-file project rules
|
|
21
|
+
// scan each source repeatedly - splitting once amortises the cost across rule passes.
|
|
22
|
+
// `templateMaskedLines` mirrors `lines` but blanks out `` ` `` template-literal body characters
|
|
23
|
+
// (single/double-quoted strings stay intact), so syntax-pattern rules can skip fixture
|
|
24
|
+
// template-literal content without losing real `import ... from "..."` detection.
|
|
25
|
+
export interface ProjectSource {
|
|
26
|
+
file: SourceFile;
|
|
27
|
+
lines: string[];
|
|
28
|
+
templateMaskedLines: string[];
|
|
29
|
+
exportedSurface?: ProjectExportedSurface;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Project-wide aggregate built once per scan and reused by every architecture rule (cycle detection,
|
|
33
|
+
// large-module concentration, deep-relative-import). `scriptSources` is a pre-filtered view of
|
|
34
|
+
// `sources` so per-rule code paths don't repeat the script-file check.
|
|
35
|
+
export interface ProjectIndex {
|
|
36
|
+
sources: ProjectSource[];
|
|
37
|
+
scriptSources: ProjectSource[];
|
|
38
|
+
sourcePaths: Set<string>;
|
|
39
|
+
importsByFile: Map<string, ImportEdge[]>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// One import statement in the graph. `parentSegments` counts `../` hops for the deep-relative-import
|
|
43
|
+
// rule; `targetPath` is set only when the specifier resolves to a file gruff has actually discovered.
|
|
44
|
+
interface ImportEdge {
|
|
45
|
+
specifier: string;
|
|
46
|
+
line: number;
|
|
47
|
+
parentSegments: number;
|
|
48
|
+
isTypeOnly: boolean;
|
|
49
|
+
targetPath?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Raw import/export statement span extracted before edge parsing. `line` anchors every edge found
|
|
53
|
+
// inside the statement, even when the specifier appears several physical lines later.
|
|
54
|
+
interface ImportStatement {
|
|
55
|
+
source: string;
|
|
56
|
+
line: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Ordered list of files participating in a cycle. Order is significant - the first edge is the
|
|
60
|
+
// anchor reported in the finding, so rotating the list would shift the finding's source line.
|
|
61
|
+
interface ImportCycle {
|
|
62
|
+
files: string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Resolved thresholds passed into the large-module-concentration check. Holding them together
|
|
66
|
+
// keeps the call surface narrow and prevents accidental field reorders.
|
|
67
|
+
interface LargeModuleThresholds {
|
|
68
|
+
minFiles: number;
|
|
69
|
+
minLines: number;
|
|
70
|
+
maxSharePercent: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// One file's production line count, threaded through the large-module pipeline so the source
|
|
74
|
+
// reference survives sorting and filtering.
|
|
75
|
+
interface ModuleLineCount {
|
|
76
|
+
source: ProjectSource;
|
|
77
|
+
lines: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Single struct holding the largest module + project totals + thresholds so the finding builder
|
|
81
|
+
// has every piece of metadata in one place.
|
|
82
|
+
interface LargeModuleCandidate extends ModuleLineCount {
|
|
83
|
+
totalLines: number;
|
|
84
|
+
sharePercent: number;
|
|
85
|
+
thresholds: LargeModuleThresholds;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Sorts sources by display path so every cross-file rule sees the same order regardless of which
|
|
89
|
+
// filesystem yielded what entries first - the stable input ordering is what keeps reports deterministic.
|
|
90
|
+
export function buildProjectIndex(projectSources: ProjectSource[]): ProjectIndex {
|
|
91
|
+
const sources = [...projectSources].sort((left, right) => left.file.displayPath.localeCompare(right.file.displayPath));
|
|
92
|
+
const scriptSources = sources.filter((source) => source.file.isScript);
|
|
93
|
+
const sourcePaths = new Set(scriptSources.map((source) => source.file.displayPath));
|
|
94
|
+
const importsByFile = new Map<string, ImportEdge[]>();
|
|
95
|
+
for (const source of scriptSources) {
|
|
96
|
+
importsByFile.set(source.file.displayPath, importEdgesForSource(source, sourcePaths));
|
|
97
|
+
}
|
|
98
|
+
return { sources, scriptSources, sourcePaths, importsByFile };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Three architecture rules, evaluated in their stable contract order: deep imports, cycles, then
|
|
102
|
+
// large-module concentration. Reordering shuffles the deterministic fingerprint output.
|
|
103
|
+
export function analyseArchitectureRules(index: ProjectIndex, config: Config, findings: Finding[]): void {
|
|
104
|
+
analyseDeepRelativeImports(index, config, findings);
|
|
105
|
+
analyseCircularImports(index, findings);
|
|
106
|
+
analyseLargeModuleConcentration(index, config, findings);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Container for test-adequacy rules. Just one rule today; existing as a stable shape so additions
|
|
110
|
+
// inherit the same project-index contract without each touching the entry point.
|
|
111
|
+
export function analyseTestAdequacyRules(index: ProjectIndex, findings: Finding[]): void {
|
|
112
|
+
analyseMissingNearbyTests(index, findings);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/*
|
|
116
|
+
* Reports imports that climb more than `maxParentSegments` `../` hops, anchored at the edge line
|
|
117
|
+
* in the importing file. The double loop exists intentionally because `..` and `../foo` look
|
|
118
|
+
* identical to a regex pass, so the parsed edge metadata is the only stable way to count depth
|
|
119
|
+
* without false positives.
|
|
120
|
+
*/
|
|
121
|
+
function analyseDeepRelativeImports(index: ProjectIndex, config: Config, findings: Finding[]): void {
|
|
122
|
+
const maxParentSegments = threshold(config, "design.deep-relative-import", 2);
|
|
123
|
+
const severity = ruleSeverity(config, "design.deep-relative-import", "advisory");
|
|
124
|
+
for (const source of index.scriptSources) {
|
|
125
|
+
const edges = index.importsByFile.get(source.file.displayPath) ?? [];
|
|
126
|
+
for (const edge of edges) {
|
|
127
|
+
if (edge.parentSegments <= maxParentSegments) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
findings.push(
|
|
131
|
+
makeFinding({
|
|
132
|
+
ruleId: "design.deep-relative-import",
|
|
133
|
+
message: `Relative import \`${edge.specifier}\` climbs ${edge.parentSegments} directories.`,
|
|
134
|
+
filePath: source.file.displayPath,
|
|
135
|
+
line: edge.line,
|
|
136
|
+
severity,
|
|
137
|
+
pillar: "design",
|
|
138
|
+
confidence: "medium",
|
|
139
|
+
symbol: edge.specifier,
|
|
140
|
+
remediation: "Move the shared module closer to the caller or introduce a local barrel/module boundary.",
|
|
141
|
+
metadata: { specifier: edge.specifier, parentSegments: edge.parentSegments, maxParentSegments },
|
|
142
|
+
}),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/*
|
|
149
|
+
* Reports one finding per detected cycle. The cycle list comes back already deterministic from
|
|
150
|
+
* `importCycles`, so the resulting fingerprints are reproducible across runs.
|
|
151
|
+
*/
|
|
152
|
+
function analyseCircularImports(index: ProjectIndex, findings: Finding[]): void {
|
|
153
|
+
for (const cycle of importCycles(index)) {
|
|
154
|
+
const finding = circularImportFinding(index, cycle);
|
|
155
|
+
if (finding) {
|
|
156
|
+
findings.push(finding);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/*
|
|
162
|
+
* Anchors the finding at the first file in the cycle so the stable fingerprint and reported line
|
|
163
|
+
* match across reruns. Returns undefined when the anchor file dropped out of the index.
|
|
164
|
+
*/
|
|
165
|
+
function circularImportFinding(index: ProjectIndex, cycle: ImportCycle): Finding | undefined {
|
|
166
|
+
const anchorPath = cycle.files[0] ?? "";
|
|
167
|
+
const anchorSource = index.scriptSources.find((source) => source.file.displayPath === anchorPath);
|
|
168
|
+
if (!anchorSource) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
return makeFinding({
|
|
172
|
+
ruleId: "design.circular-import",
|
|
173
|
+
message: `Import cycle detected among ${cycle.files.join(", ")}.`,
|
|
174
|
+
filePath: anchorSource.file.displayPath,
|
|
175
|
+
line: circularImportLine(index, anchorPath, cycle),
|
|
176
|
+
severity: "warning",
|
|
177
|
+
pillar: "design",
|
|
178
|
+
confidence: "medium",
|
|
179
|
+
symbol: cycle.files.join(" -> "),
|
|
180
|
+
remediation: "Extract the shared contract or move one dependency behind an explicit boundary.",
|
|
181
|
+
metadata: { files: cycle.files },
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Finds the first import edge in the anchor file that points into another cycle member. Line 1
|
|
186
|
+
// fallback keeps finding metadata stable when no edge could be located on the parsed source.
|
|
187
|
+
function circularImportLine(index: ProjectIndex, anchorPath: string, cycle: ImportCycle): number {
|
|
188
|
+
const anchorEdges = index.importsByFile.get(anchorPath) ?? [];
|
|
189
|
+
return anchorEdges.find((edge) => !edge.isTypeOnly && edge.targetPath && cycle.files.includes(edge.targetPath))?.line ?? 1;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/*
|
|
193
|
+
* Reports the largest directory if it crosses the configured share-of-project threshold. Single
|
|
194
|
+
* stable finding (the worst case) rather than one per directory - keeps the rule a noise-tolerant signal.
|
|
195
|
+
*/
|
|
196
|
+
function analyseLargeModuleConcentration(index: ProjectIndex, config: Config, findings: Finding[]): void {
|
|
197
|
+
const candidate = largeModuleCandidate(index, largeModuleThresholds(config));
|
|
198
|
+
if (!candidate) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
findings.push(largeModuleConcentrationFinding(candidate, ruleSeverity(config, "design.large-module-concentration", "advisory")));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Three thresholds drive the rule: minimum file count to consider a project worth checking,
|
|
205
|
+
// minimum line count for the largest directory, and a share-percent cap. All three must hold.
|
|
206
|
+
function largeModuleThresholds(config: Config): LargeModuleThresholds {
|
|
207
|
+
return {
|
|
208
|
+
minFiles: optionNumber(config, "design.large-module-concentration", "minFiles", 4),
|
|
209
|
+
minLines: optionNumber(config, "design.large-module-concentration", "minLines", 80),
|
|
210
|
+
maxSharePercent: threshold(config, "design.large-module-concentration", 55),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Finds the directory with the most production lines and returns it only if it crosses every
|
|
215
|
+
// threshold. Returns undefined when the project is too small or the largest module is below the cap.
|
|
216
|
+
function largeModuleCandidate(index: ProjectIndex, thresholds: LargeModuleThresholds): LargeModuleCandidate | undefined {
|
|
217
|
+
const modules = productionModuleLineCounts(index);
|
|
218
|
+
if (modules.length < thresholds.minFiles) {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
const totalLines = modules.reduce((sum, module) => sum + module.lines, 0);
|
|
222
|
+
const largest = modules[0];
|
|
223
|
+
if (!largest) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
if (totalLines === 0) {
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const sharePercent = Math.round((largest.lines / totalLines) * 1000) / 10;
|
|
230
|
+
if (!exceedsLargeModuleThresholds(largest, sharePercent, thresholds)) {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
return { ...largest, totalLines, sharePercent, thresholds };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Both conditions are required: a small but proportionally dominant module is still suspicious,
|
|
237
|
+
// but a tiny single-file project shouldn't trip the rule just by having one module.
|
|
238
|
+
function exceedsLargeModuleThresholds(largest: ModuleLineCount, sharePercent: number, thresholds: LargeModuleThresholds): boolean {
|
|
239
|
+
return largest.lines >= thresholds.minLines && sharePercent > thresholds.maxSharePercent;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Counts only production sources (tests, fixtures, declarations excluded) sorted by descending
|
|
243
|
+
// line count so the caller can take the head without re-scanning - keeps the rule deterministic and stable.
|
|
244
|
+
function productionModuleLineCounts(index: ProjectIndex): ModuleLineCount[] {
|
|
245
|
+
return index.scriptSources
|
|
246
|
+
.filter((source) => isProductionSourcePath(source.file.displayPath))
|
|
247
|
+
.map((source) => ({ source, lines: source.lines.length }))
|
|
248
|
+
.sort((left, right) => right.lines - left.lines || left.source.file.displayPath.localeCompare(right.source.file.displayPath));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Single makeFinding site for the rule. All threshold values are surfaced in metadata so reviewers
|
|
252
|
+
// can see why the rule fired without re-running with the same config - keeps reports stable for audits.
|
|
253
|
+
function largeModuleConcentrationFinding(candidate: LargeModuleCandidate, severity: Severity): Finding {
|
|
254
|
+
return makeFinding({
|
|
255
|
+
ruleId: "design.large-module-concentration",
|
|
256
|
+
message: `Module \`${candidate.source.file.displayPath}\` contains ${candidate.sharePercent}% of production source lines.`,
|
|
257
|
+
filePath: candidate.source.file.displayPath,
|
|
258
|
+
line: 1,
|
|
259
|
+
severity,
|
|
260
|
+
pillar: "design",
|
|
261
|
+
confidence: "medium",
|
|
262
|
+
symbol: fileBaseName(candidate.source.file.displayPath),
|
|
263
|
+
remediation: "Split unrelated responsibilities into smaller modules once stable seams are visible.",
|
|
264
|
+
metadata: {
|
|
265
|
+
lines: candidate.lines,
|
|
266
|
+
totalLines: candidate.totalLines,
|
|
267
|
+
sharePercent: candidate.sharePercent,
|
|
268
|
+
minFiles: candidate.thresholds.minFiles,
|
|
269
|
+
minLines: candidate.thresholds.minLines,
|
|
270
|
+
maxSharePercent: candidate.thresholds.maxSharePercent,
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Per-file regex pass over the cached source lines. The resulting edges are sorted by (line,
|
|
276
|
+
// specifier) so the import graph and cycle detection both see the same stable, deterministic order.
|
|
277
|
+
function importEdgesForSource(source: ProjectSource, sourcePaths: Set<string>): ImportEdge[] {
|
|
278
|
+
const edges: ImportEdge[] = [];
|
|
279
|
+
for (const statement of importStatements(source.templateMaskedLines)) {
|
|
280
|
+
edges.push(...importEdgesForStatement(source.file.displayPath, statement, sourcePaths));
|
|
281
|
+
}
|
|
282
|
+
return edges.sort((left, right) => left.line - right.line || left.specifier.localeCompare(right.specifier));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Reassembles multiline import/export declarations from template-masked lines. This keeps
|
|
286
|
+
// `import { a,\n b } from "x"` visible to the graph without parsing fixture template bodies.
|
|
287
|
+
function importStatements(lines: string[]): ImportStatement[] {
|
|
288
|
+
const statements: ImportStatement[] = [];
|
|
289
|
+
let current = "";
|
|
290
|
+
let startLine = 1;
|
|
291
|
+
for (const [index, line] of lines.entries()) {
|
|
292
|
+
const trimmed = line.trim();
|
|
293
|
+
if (!current && !/^(?:import|export)\b/.test(trimmed)) {
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (!current) {
|
|
297
|
+
startLine = index + 1;
|
|
298
|
+
}
|
|
299
|
+
current = `${current}\n${line}`;
|
|
300
|
+
if (isImportStatementComplete(current)) {
|
|
301
|
+
statements.push({ source: current, line: startLine });
|
|
302
|
+
current = "";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (isImportStatementComplete(current)) {
|
|
306
|
+
statements.push({ source: current, line: startLine });
|
|
307
|
+
}
|
|
308
|
+
return statements;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// A statement is complete once it reaches a quoted module specifier, either bare side-effect
|
|
312
|
+
// imports (`import "x"`) or `from "x"` forms.
|
|
313
|
+
function isImportStatementComplete(statement: string): boolean {
|
|
314
|
+
return /\b(?:from\s*)?["'][^"']+["']/.test(statement);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// One statement may contain multiple imports (e.g., `import a;export b from 'x'`); the regex
|
|
318
|
+
// captures every `from "specifier"` form. Non-relative specifiers are dropped because the rule
|
|
319
|
+
// only cares about intra-project edges.
|
|
320
|
+
function importEdgesForStatement(importerPath: string, statement: ImportStatement, sourcePaths: Set<string>): ImportEdge[] {
|
|
321
|
+
const edges: ImportEdge[] = [];
|
|
322
|
+
for (const match of statement.source.matchAll(/\b(?:import|export)\b(?:[\s\S]*?\bfrom\s*)?\s*["']([^"']+)["']/g)) {
|
|
323
|
+
const edge = importEdgeForSpecifier(importerPath, match[1] ?? "", statement.line, sourcePaths, isTypeOnlyImportStatement(match[0] ?? ""));
|
|
324
|
+
if (edge) {
|
|
325
|
+
edges.push(edge);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return edges;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Builds an edge with `parentSegments` (counted from `..` hops) and an optional `targetPath` that
|
|
332
|
+
// points to a file gruff has actually discovered. Used by both the cycle detector and the deep-import rule.
|
|
333
|
+
function importEdgeForSpecifier(importerPath: string, specifier: string, line: number, sourcePaths: Set<string>, isTypeOnly: boolean): ImportEdge | undefined {
|
|
334
|
+
if (!specifier.startsWith(".")) {
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
const targetPath = resolveRelativeImport(importerPath, specifier, sourcePaths);
|
|
338
|
+
return {
|
|
339
|
+
specifier,
|
|
340
|
+
line,
|
|
341
|
+
parentSegments: specifier.split("/").filter((segment) => segment === "..").length,
|
|
342
|
+
isTypeOnly,
|
|
343
|
+
...(targetPath ? { targetPath } : {}),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Type-only imports disappear at runtime, so they should not participate in circular-import cycles.
|
|
348
|
+
function isTypeOnlyImportStatement(importStatement: string): boolean {
|
|
349
|
+
return /\b(?:import|export)\s+type\b/.test(importStatement);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Tries every extension / barrel form (see `importPathCandidates`). The first match wins because
|
|
353
|
+
// Node's resolution is deterministic and a stable choice keeps cycle output reproducible.
|
|
354
|
+
function resolveRelativeImport(importerPath: string, specifier: string, sourcePaths: Set<string>): string | undefined {
|
|
355
|
+
const basePath = normalizeDisplayPath(join(dirnamePath(importerPath), specifier));
|
|
356
|
+
for (const candidate of importPathCandidates(basePath)) {
|
|
357
|
+
if (sourcePaths.has(candidate)) {
|
|
358
|
+
return candidate;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Generates every plausible filename for the import: the bare path, each script extension, and
|
|
365
|
+
// `index.<ext>` variants. Set-deduplication keeps the candidate list small for the resolver loop.
|
|
366
|
+
function importPathCandidates(basePath: string): string[] {
|
|
367
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
|
|
368
|
+
const candidates = new Set<string>();
|
|
369
|
+
if (extname(basePath)) {
|
|
370
|
+
candidates.add(basePath);
|
|
371
|
+
const withoutExtension = basePath.slice(0, -extname(basePath).length);
|
|
372
|
+
for (const extension of extensions) {
|
|
373
|
+
candidates.add(`${withoutExtension}${extension}`);
|
|
374
|
+
}
|
|
375
|
+
} else {
|
|
376
|
+
for (const extension of extensions) {
|
|
377
|
+
candidates.add(`${basePath}${extension}`);
|
|
378
|
+
candidates.add(`${basePath}/index${extension}`);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return [...candidates].map(normalizeDisplayPath);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// DFS over the import graph. Path length capped at 12 (see `visitImportCycle`) because beyond that
|
|
385
|
+
// cycle detection becomes a search problem, not a useful signal. Output is sorted so report
|
|
386
|
+
// ordering stays deterministic across runs.
|
|
387
|
+
function importCycles(index: ProjectIndex): ImportCycle[] {
|
|
388
|
+
const cycles = new Map<string, string[]>();
|
|
389
|
+
const paths = [...index.importsByFile.keys()].sort();
|
|
390
|
+
for (const start of paths) {
|
|
391
|
+
visitImportCycle(index, start, start, [start], new Set([start]), cycles);
|
|
392
|
+
}
|
|
393
|
+
return [...cycles.values()]
|
|
394
|
+
.map((files) => ({ files }))
|
|
395
|
+
.sort((left, right) => left.files.join("\0").localeCompare(right.files.join("\0")));
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function visitImportCycle(
|
|
399
|
+
index: ProjectIndex,
|
|
400
|
+
start: string,
|
|
401
|
+
current: string,
|
|
402
|
+
path: string[],
|
|
403
|
+
seen: Set<string>,
|
|
404
|
+
cycles: Map<string, string[]>,
|
|
405
|
+
): void {
|
|
406
|
+
const targets = [...new Set((index.importsByFile.get(current) ?? []).filter((edge) => !edge.isTypeOnly).map((edge) => edge.targetPath).filter(isString))].sort();
|
|
407
|
+
for (const target of targets) {
|
|
408
|
+
if (target === start && path.length > 1) {
|
|
409
|
+
const files = [...path].sort();
|
|
410
|
+
cycles.set(files.join("\0"), files);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (seen.has(target) || path.length >= 12) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
seen.add(target);
|
|
417
|
+
visitImportCycle(index, start, target, [...path, target], seen, cycles);
|
|
418
|
+
seen.delete(target);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Production = not a test, not a `.d.ts`, not a fixture, not under `generated/`. Conservative on
|
|
423
|
+
// purpose - adding a path category here changes the rule surface of every production-only rule.
|
|
424
|
+
export function isProductionSourcePath(path: string): boolean {
|
|
425
|
+
return !isTestPath(path) && !isDeclarationPath(path) && !isFixtureLikePath(path) && !path.split("/").includes("generated");
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/*
|
|
429
|
+
* Reports exported callables whose file has no neighbouring `.test.ts` / `.spec.ts`. The stable
|
|
430
|
+
* neighbour rules (`hasNearbyTest`) define what counts - false positives are likelier than missed
|
|
431
|
+
* cases, so the rule is intentionally conservative.
|
|
432
|
+
*/
|
|
433
|
+
function analyseMissingNearbyTests(index: ProjectIndex, findings: Finding[]): void {
|
|
434
|
+
const testSources = index.scriptSources.filter((source) => isTestPath(source.file.displayPath));
|
|
435
|
+
const testPaths = new Set(testSources.map((source) => source.file.displayPath));
|
|
436
|
+
for (const source of index.scriptSources.filter((candidate) => isProductionSourcePath(candidate.file.displayPath))) {
|
|
437
|
+
const exported = source.exportedSurface;
|
|
438
|
+
if (!exported || hasNearbyTest(source.file.displayPath, testPaths) || hasCentralTestImport(source.file.displayPath, testSources, index.importsByFile)) {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
findings.push(
|
|
442
|
+
makeFinding({
|
|
443
|
+
ruleId: "test-quality.missing-nearby-test",
|
|
444
|
+
message: `Exported source file \`${source.file.displayPath}\` has no nearby test file.`,
|
|
445
|
+
filePath: source.file.displayPath,
|
|
446
|
+
line: exported.line,
|
|
447
|
+
severity: "advisory",
|
|
448
|
+
pillar: "test-quality",
|
|
449
|
+
confidence: "medium",
|
|
450
|
+
symbol: exported.symbol,
|
|
451
|
+
remediation: "Add a focused test beside the source file or under a nearby __tests__/tests directory.",
|
|
452
|
+
metadata: { expectedTestBase: fileBaseName(source.file.displayPath) },
|
|
453
|
+
}),
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Centralized `test/unit` or `test/integration` suites often import the source directly instead
|
|
459
|
+
// of matching filenames. Treat that import edge as nearby enough to avoid layout false positives.
|
|
460
|
+
function hasCentralTestImport(sourcePath: string, testSources: ProjectSource[], importsByFile: Map<string, ImportEdge[]>): boolean {
|
|
461
|
+
return testSources.some((testSource) => (importsByFile.get(testSource.file.displayPath) ?? []).some((edge) => edge.targetPath === sourcePath));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Returns the first exported callable/value seen - one finding per file is sufficient because
|
|
465
|
+
// the rule's signal is "this file ships an API surface", not "every export is untested".
|
|
466
|
+
export function exportedSurface(source: string): ProjectExportedSurface | undefined {
|
|
467
|
+
const match = source.match(/\bexport\s+(?:default\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/);
|
|
468
|
+
if (!match?.[1]) {
|
|
469
|
+
return undefined;
|
|
470
|
+
}
|
|
471
|
+
return { symbol: match[1], line: byteLine(source, match.index ?? 0) };
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// True when a same-name test file exists alongside the source, in a sibling `__tests__`/`tests`
|
|
475
|
+
// directory, or anywhere under a top-level `test`/`tests` tree. Mirrors common project layouts;
|
|
476
|
+
// expanding this list widens what counts as "tested".
|
|
477
|
+
function hasNearbyTest(sourcePath: string, testPaths: Set<string>): boolean {
|
|
478
|
+
const sourceBase = stripSourceExtension(sourcePath);
|
|
479
|
+
const sourceName = basename(sourceBase);
|
|
480
|
+
const sourceDir = displayDir(sourcePath);
|
|
481
|
+
const nearbyDirs = new Set([sourceDir, joinDisplay(sourceDir, "__tests__"), joinDisplay(sourceDir, "tests"), "test", "tests"]);
|
|
482
|
+
for (const testPath of testPaths) {
|
|
483
|
+
const testBase = stripTestMarker(stripSourceExtension(testPath));
|
|
484
|
+
if (basename(testBase) !== sourceName) {
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
if (testBase === sourceBase || nearbyDirs.has(displayDir(testPath)) || isTopLevelTestPath(testPath)) {
|
|
488
|
+
return true;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return false;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Top-level `test/` and `tests/` trees are common central suite layouts. This helper is separate
|
|
495
|
+
// from `isTestPath` because nearby-test matching still requires basename agreement.
|
|
496
|
+
function isTopLevelTestPath(path: string): boolean {
|
|
497
|
+
return path.startsWith("test/") || path.startsWith("tests/");
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Drops the trailing `.ts`/`.tsx`/`.js`/`.jsx`/`.mts`/`.cjs`/`.mjs` extension so source-and-test
|
|
501
|
+
// filename comparison is extension-agnostic. Used together with `stripTestMarker`.
|
|
502
|
+
function stripSourceExtension(path: string): string {
|
|
503
|
+
return path.replace(/\.[cm]?[tj]sx?$/, "");
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Drops the conventional `.test` / `.spec` suffix before comparing a test path to a source path.
|
|
507
|
+
function stripTestMarker(path: string): string {
|
|
508
|
+
return path.replace(/\.(?:test|spec)$/, "");
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Collapses a path's directory portion to the empty string at the project root so
|
|
512
|
+
// `hasNearbyTest`'s nearbyDirs lookup uses one canonical key for root-level files.
|
|
513
|
+
function displayDir(path: string): string {
|
|
514
|
+
const dir = normalizeDisplayPath(dirnamePath(path));
|
|
515
|
+
return dir === "." ? "" : dir;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// POSIX-style join that handles the empty-prefix case so `joinDisplay("", "x")` returns `"x"`,
|
|
519
|
+
// not `"/x"` - needed for paths that live directly at the project root.
|
|
520
|
+
function joinDisplay(left: string, right: string): string {
|
|
521
|
+
return left ? `${left}/${right}` : right;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// `__tests__/` and `tests/` directories, plus `.test.ts` / `.spec.ts` filename suffix. The same
|
|
525
|
+
// patterns drive the production-source filter, so adding a layout here widens every test-aware rule.
|
|
526
|
+
export function isTestPath(path: string): boolean {
|
|
527
|
+
return /(?:^|\/)(?:__tests__|tests?|spec)\//.test(path) || /\.(?:test|spec)\.[cm]?[tj]sx?$/.test(path);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// `.d.ts` family. Declaration files don't carry runtime behaviour, so most rules skip them.
|
|
531
|
+
export function isDeclarationPath(path: string): boolean {
|
|
532
|
+
return /\.d\.[cm]?ts$/.test(path);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Conventional fixture directories. Only `docs.fixture-purpose-missing` opts in to fixture paths;
|
|
536
|
+
// every other rule should treat them as test infrastructure.
|
|
537
|
+
export function isFixtureLikePath(path: string): boolean {
|
|
538
|
+
return /(?:^|\/)(?:__fixtures__|fixtures?|testdata)\//.test(path);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Converts platform-native paths to the POSIX-style report shape used in every Finding. Must be
|
|
542
|
+
// idempotent - repeated normalisation must produce the same string.
|
|
543
|
+
function normalizeDisplayPath(path: string): string {
|
|
544
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
545
|
+
}
|