@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTRIBUTING.md +87 -0
  3. package/LICENSE +21 -0
  4. package/README.md +303 -0
  5. package/SECURITY.md +45 -0
  6. package/bin/gruff-ts +25 -0
  7. package/docs/CONFIGURATION.md +220 -0
  8. package/docs/RELEASING.md +103 -0
  9. package/docs/REPORTS_AND_CI.md +156 -0
  10. package/fixtures/sample.ts +21 -0
  11. package/package.json +56 -0
  12. package/scripts/bump-version.sh +145 -0
  13. package/scripts/check.sh +4 -0
  14. package/scripts/npm-publish.sh +258 -0
  15. package/scripts/preflight-checks.sh +357 -0
  16. package/scripts/start-dev.sh +8 -0
  17. package/scripts/test-performance.sh +695 -0
  18. package/src/analyser.ts +461 -0
  19. package/src/baseline.ts +90 -0
  20. package/src/blocks.ts +687 -0
  21. package/src/class-rules.ts +326 -0
  22. package/src/cli-program.ts +326 -0
  23. package/src/cli.ts +19 -0
  24. package/src/comment-rules.ts +605 -0
  25. package/src/comment-scanner.ts +357 -0
  26. package/src/config.ts +622 -0
  27. package/src/constants.ts +4 -0
  28. package/src/context-doc-rules.ts +241 -0
  29. package/src/dashboard.ts +114 -0
  30. package/src/dead-code-rules.ts +183 -0
  31. package/src/discovery.ts +508 -0
  32. package/src/doc-rules.ts +368 -0
  33. package/src/findings-helpers.ts +108 -0
  34. package/src/findings.ts +45 -0
  35. package/src/fixture-purpose-rules.ts +334 -0
  36. package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
  37. package/src/github-actions-rules.ts +413 -0
  38. package/src/line-rules.ts +538 -0
  39. package/src/naming-pushers.ts +191 -0
  40. package/src/project-config-rules.ts +555 -0
  41. package/src/project-rules.ts +545 -0
  42. package/src/report-renderers.ts +691 -0
  43. package/src/rule-list.ts +179 -0
  44. package/src/rules.ts +135 -0
  45. package/src/safety-rules.ts +355 -0
  46. package/src/scoring.ts +74 -0
  47. package/src/security-flow-rules.ts +112 -0
  48. package/src/sensitive-data-rules.ts +288 -0
  49. package/src/source-text.ts +722 -0
  50. package/src/test-block-rules.ts +347 -0
  51. package/src/test-fixtures.ts +621 -0
  52. package/src/text-scans.ts +193 -0
  53. package/src/types.ts +113 -0
  54. 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
+ }