@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,508 @@
1
+ // Filesystem discovery, ignore-policy matching, and display-path normalization for deterministic scans.
2
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
3
+ import { basename, extname, isAbsolute, join, relative } from "node:path";
4
+ import type { AnalysisOptions, Config } from "./types.ts";
5
+
6
+ // `absolutePath` is what `node:fs` operates on; `displayPath` is the project-relative POSIX form
7
+ // embedded in findings and baselines. They must stay aligned - diverging them breaks fingerprint stability.
8
+ export interface SourceFile {
9
+ absolutePath: string;
10
+ displayPath: string;
11
+ isScript: boolean;
12
+ }
13
+
14
+ // Mutable accumulator used during the walk. `ignoredPaths` is a Set because the walker may visit
15
+ // the same parent path through multiple roots; finalised into a sorted array on return.
16
+ interface SourceDiscovery {
17
+ files: SourceFile[];
18
+ missingPaths: string[];
19
+ ignoredPaths: Set<string>;
20
+ }
21
+
22
+ // Public discovery result. `files` are sorted by display path and deduped; `ignoredPaths` is sorted
23
+ // so reports stay deterministic even when the underlying filesystem returns directory entries in arbitrary order.
24
+ export interface SourceDiscoveryResult {
25
+ files: SourceFile[];
26
+ missingPaths: string[];
27
+ ignoredPaths: string[];
28
+ }
29
+
30
+ // A single line from a `.gitignore`. The combination of `isAnchored`, `isDirectoryOnly`, and `hasSlash`
31
+ // reproduces git's documented matching semantics; missing any one of them yields wrong matches.
32
+ interface GitIgnoreRule {
33
+ basePath: string;
34
+ pattern: string;
35
+ isNegated: boolean;
36
+ isDirectoryOnly: boolean;
37
+ isAnchored: boolean;
38
+ hasSlash: boolean;
39
+ }
40
+
41
+ // Public entry point. Reads from the filesystem and returns a sorted, deduped, deterministic
42
+ // result so finding ordering and report-path metadata remain stable across runs - this is part of
43
+ // the schema invariant that makes baseline matching reproducible.
44
+ export function discoverSources(projectRoot: string, options: AnalysisOptions, config: Config): SourceDiscoveryResult {
45
+ const discovery: SourceDiscovery = { files: [], missingPaths: [], ignoredPaths: new Set<string>() };
46
+ const inputs = options.paths.length > 0 ? options.paths : ["."];
47
+
48
+ for (const input of inputs) {
49
+ discoverSourceInput(projectRoot, input, options, config, discovery);
50
+ }
51
+
52
+ discovery.files.sort((left, right) => left.displayPath.localeCompare(right.displayPath));
53
+ return { files: uniqueFiles(discovery.files), missingPaths: discovery.missingPaths, ignoredPaths: [...discovery.ignoredPaths].sort() };
54
+ }
55
+
56
+ // Resolves the input against `node:fs`. Missing inputs go into `missingPaths` so the CLI can
57
+ // report them as diagnostics rather than silently producing no findings. CI output depends on that
58
+ // distinction, and .gitignore rules are only read when ignored paths are not requested.
59
+ function discoverSourceInput(projectRoot: string, input: string, options: AnalysisOptions, config: Config, discovery: SourceDiscovery): void {
60
+ const absolute = absolutize(projectRoot, input);
61
+ if (!existsSync(absolute)) {
62
+ discovery.missingPaths.push(input);
63
+ return;
64
+ }
65
+ const stats = statSync(absolute);
66
+ if (stats.isFile()) {
67
+ pushSourceFile(projectRoot, absolute, discovery.files);
68
+ return;
69
+ }
70
+ const gitIgnoreRules = options.shouldIncludeIgnored ? [] : gitIgnoreRulesForDirectory(projectRoot, absolute);
71
+ walk(projectRoot, absolute, options, config, discovery.ignoredPaths, discovery.files, gitIgnoreRules);
72
+ }
73
+
74
+ function walk(
75
+ projectRoot: string,
76
+ directory: string,
77
+ options: AnalysisOptions,
78
+ config: Config,
79
+ ignoredPaths: Set<string>,
80
+ files: SourceFile[],
81
+ gitIgnoreRules: GitIgnoreRule[],
82
+ ): void {
83
+ for (const entry of readdirSync(directory, { withFileTypes: true })) {
84
+ const absolute = join(directory, entry.name);
85
+ const display = displayPath(projectRoot, absolute);
86
+ if (entry.isDirectory() || entry.isFile()) {
87
+ if (isIgnoredDiscoveryPath(display, entry.isDirectory(), options, config, gitIgnoreRules)) {
88
+ ignoredPaths.add(display);
89
+ continue;
90
+ }
91
+ }
92
+ if (entry.isDirectory()) {
93
+ walk(projectRoot, absolute, options, config, ignoredPaths, files, options.shouldIncludeIgnored ? gitIgnoreRules : appendGitIgnoreRules(projectRoot, absolute, gitIgnoreRules));
94
+ } else if (entry.isFile()) {
95
+ pushSourceFile(projectRoot, absolute, files);
96
+ }
97
+ }
98
+ }
99
+
100
+ // Single source of truth for which extensions count as scannable. Adding a new file kind here will
101
+ // expand the rule set's reach across an entire project - coordinate with rule descriptors before changing.
102
+ function pushSourceFile(projectRoot: string, absolutePath: string, files: SourceFile[]): void {
103
+ const extension = extname(absolutePath).slice(1).toLowerCase();
104
+ const name = basename(absolutePath);
105
+ const isScript = ["ts", "tsx", "js", "jsx", "mjs", "cjs"].includes(extension);
106
+ const isText =
107
+ ["conf", "config", "css", "env", "ini", "json", "toml", "xml", "yaml", "yml"].includes(extension) ||
108
+ name.startsWith(".env") ||
109
+ isExactSecretTextFile(name);
110
+ if (isScript || isText) {
111
+ files.push({ absolutePath, displayPath: displayPath(projectRoot, absolutePath), isScript });
112
+ }
113
+ }
114
+
115
+ // Exact extensionless secret files stay scannable without opening the door to every dotfile.
116
+ function isExactSecretTextFile(name: string): boolean {
117
+ return [".npmrc", ".pypirc", ".envrc", ".netrc"].includes(name);
118
+ }
119
+
120
+ // The default-ignore list is part of the documented schema contract: callers can override with
121
+ // `--include-ignored`, but the list itself must not silently change between releases without notice.
122
+ function isDefaultIgnoredDir(path: string): boolean {
123
+ const first = path.split("/")[0] ?? path;
124
+ return [".git", ".hg", ".svn", ".idea", ".vscode", "build", "cache", "coverage", "dist", "generated", "node_modules", "target", "tmp", "vendor"].includes(first);
125
+ }
126
+
127
+ // Three independent ignore sources combined with OR. Order is for short-circuit only - semantically
128
+ // the union of (built-in defaults, parsed .gitignore stack, user config patterns) is what counts.
129
+ function isIgnoredDiscoveryPath(display: string, isDirectory: boolean, options: AnalysisOptions, config: Config, gitIgnoreRules: GitIgnoreRule[]): boolean {
130
+ if (isDefaultIgnoredDiscoveryPath(display, isDirectory, options)) {
131
+ return true;
132
+ }
133
+ if (isGitIgnoredDiscoveryPath(display, isDirectory, options, gitIgnoreRules)) {
134
+ return true;
135
+ }
136
+ return config.ignoredPaths.some((pattern) => pathMatches(pattern, display));
137
+ }
138
+
139
+ // Default directory ignores only apply to directories, never to files - a file named "tmp" should
140
+ // still be scanned. `--include-ignored` opts out entirely.
141
+ function isDefaultIgnoredDiscoveryPath(display: string, isDirectory: boolean, options: AnalysisOptions): boolean {
142
+ return !options.shouldIncludeIgnored && isDirectory && isDefaultIgnoredDir(display);
143
+ }
144
+
145
+ // Mirrors git's own rule evaluation order; bypassed entirely by `--include-ignored` for callers
146
+ // that want to scan the full tree.
147
+ function isGitIgnoredDiscoveryPath(display: string, isDirectory: boolean, options: AnalysisOptions, gitIgnoreRules: GitIgnoreRule[]): boolean {
148
+ return !options.shouldIncludeIgnored && isGitIgnoredPath(gitIgnoreRules, display, isDirectory);
149
+ }
150
+
151
+ // Walks .gitignore files top-down from project root to the target directory so child rules can
152
+ // override parents - this must match git's documented inheritance order or the scan will diverge
153
+ // from `git status` on the same tree, and reads each `.gitignore` it encounters along the way.
154
+ function gitIgnoreRulesForDirectory(projectRoot: string, directory: string): GitIgnoreRule[] {
155
+ if (!isInsideProject(projectRoot, directory)) {
156
+ return [];
157
+ }
158
+
159
+ const relativeDirectory = displayPath(projectRoot, directory);
160
+ const segments = relativeDirectory === "." ? [] : relativeDirectory.split("/");
161
+ let current = projectRoot;
162
+ let rules = appendGitIgnoreRules(projectRoot, current, []);
163
+ for (const segment of segments) {
164
+ current = join(current, segment);
165
+ rules = appendGitIgnoreRules(projectRoot, current, rules);
166
+ }
167
+ return rules;
168
+ }
169
+
170
+ // Reads one `.gitignore` and appends its rules to the inherited stack. Returns the original array
171
+ // untouched when no file exists so the walker can pass results around without spurious allocations.
172
+ function appendGitIgnoreRules(projectRoot: string, directory: string, inheritedRules: GitIgnoreRule[]): GitIgnoreRule[] {
173
+ const ignoreFile = join(directory, ".gitignore");
174
+ if (!existsSync(ignoreFile) || !statSync(ignoreFile).isFile()) {
175
+ return inheritedRules;
176
+ }
177
+
178
+ const basePath = displayPath(projectRoot, directory);
179
+ const parsedRules = parseGitIgnoreRules(readFileSync(ignoreFile, "utf8"), basePath === "." ? "" : basePath);
180
+ return parsedRules.length > 0 ? [...inheritedRules, ...parsedRules] : inheritedRules;
181
+ }
182
+
183
+ // One rule per non-empty, non-comment line. Empty results are dropped here so downstream matchers
184
+ // never need to guard against undefined patterns.
185
+ function parseGitIgnoreRules(source: string, basePath: string): GitIgnoreRule[] {
186
+ const rules: GitIgnoreRule[] = [];
187
+ for (const rawLine of source.replace(/\r\n/g, "\n").split("\n")) {
188
+ const rule = parseGitIgnoreRule(rawLine, basePath);
189
+ if (rule) {
190
+ rules.push(rule);
191
+ }
192
+ }
193
+ return rules;
194
+ }
195
+
196
+ // Extracts the four flags git applies per pattern: negation (`!`), directory-only (trailing `/`),
197
+ // path-scoped (leading `/` or contains `/`), and the cleaned pattern itself. Undefined return means
198
+ // the line was blank or a comment - callers must not treat it as a "match nothing" rule.
199
+ function parseGitIgnoreRule(rawLine: string, basePath: string): GitIgnoreRule | undefined {
200
+ const initial = unescapedGitIgnoreLine(rawLine);
201
+ if (!initial) {
202
+ return undefined;
203
+ }
204
+ const isNegated = initial.startsWith("!");
205
+ const withoutNegation = isNegated ? initial.slice(1) : initial;
206
+ if (withoutNegation.length === 0) {
207
+ return undefined;
208
+ }
209
+ const isAnchored = withoutNegation.startsWith("/");
210
+ const isDirectoryOnly = withoutNegation.endsWith("/");
211
+ const pattern = normalizedGitIgnorePattern(withoutNegation);
212
+ if (pattern.length === 0) {
213
+ return undefined;
214
+ }
215
+ return { basePath, pattern, isNegated, isDirectoryOnly, isAnchored, hasSlash: pattern.includes("/") };
216
+ }
217
+
218
+ // Blank and comment lines return undefined so the caller can skip them cleanly. Trailing spaces are
219
+ // ignored only when they are not escaped, matching gitignore's literal-space escape.
220
+ function unescapedGitIgnoreLine(rawLine: string): string | undefined {
221
+ const line = trimUnescapedTrailingSpaces(rawLine);
222
+ if (line.length === 0 || line.startsWith("#")) {
223
+ return undefined;
224
+ }
225
+ return line;
226
+ }
227
+
228
+ // Gitignore treats trailing spaces as insignificant unless escaped. Walk back from the end so a
229
+ // literal `\ ` stays part of the pattern while editor-added padding disappears.
230
+ function trimUnescapedTrailingSpaces(rawLine: string): string {
231
+ let end = rawLine.length;
232
+ while (end > 0 && rawLine[end - 1] === " " && !isEscapedAt(rawLine, end - 1)) {
233
+ end -= 1;
234
+ }
235
+ return rawLine.slice(0, end);
236
+ }
237
+
238
+ // True when the character at `index` has an odd number of backslashes immediately before it.
239
+ // Even runs cancel out because `\\ ` means a literal slash followed by an unescaped space.
240
+ function isEscapedAt(source: string, index: number): boolean {
241
+ let slashCount = 0;
242
+ for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) {
243
+ slashCount += 1;
244
+ }
245
+ return slashCount % 2 === 1;
246
+ }
247
+
248
+ // Strips leading and trailing slashes (those flags are already captured in `isAnchored` /
249
+ // `isDirectoryOnly`) and collapses any internal `//` runs so the glob matcher sees canonical segments.
250
+ function normalizedGitIgnorePattern(line: string): string {
251
+ return line
252
+ .replace(/^\/+/, "")
253
+ .replace(/\/+$/, "")
254
+ .split("/")
255
+ .filter((segment) => segment.length > 0)
256
+ .join("/");
257
+ }
258
+
259
+ // Sequential evaluation, last match wins. This is the same algorithm git uses and is the reason
260
+ // negations later in a file (or in a child `.gitignore`) can reinstate previously ignored paths.
261
+ function isGitIgnoredPath(rules: GitIgnoreRule[], display: string, isDirectory: boolean): boolean {
262
+ let isIgnored = false;
263
+ for (const rule of rules) {
264
+ if (gitIgnoreRuleMatches(rule, display, isDirectory)) {
265
+ isIgnored = !rule.isNegated;
266
+ }
267
+ }
268
+ return isIgnored;
269
+ }
270
+
271
+ // First rebases the display path against the rule's basePath (rules don't reach outside their
272
+ // owning directory), then dispatches to the directory-only or file matcher.
273
+ function gitIgnoreRuleMatches(rule: GitIgnoreRule, display: string, isDirectory: boolean): boolean {
274
+ const relativePath = pathRelativeToBase(rule.basePath, display);
275
+ if (relativePath === undefined || relativePath.length === 0) {
276
+ return false;
277
+ }
278
+
279
+ if (rule.isDirectoryOnly) {
280
+ return gitIgnoreDirectoryRuleMatches(rule, relativePath, isDirectory);
281
+ }
282
+ return gitIgnoreFileRuleMatches(rule, relativePath, isDirectory);
283
+ }
284
+
285
+ // Path-scoped patterns (with `/` or anchored) match against progressive sub-paths; bare patterns
286
+ // match any path segment. Mirrors git's distinction between `foo` (any segment) and `dir/foo`.
287
+ function gitIgnoreFileRuleMatches(rule: GitIgnoreRule, relativePath: string, isDirectory: boolean): boolean {
288
+ if (isPathScopedGitIgnoreRule(rule)) {
289
+ return gitIgnorePathCandidates(relativePath, isDirectory, true).some((candidate) => gitIgnoreGlobMatches(rule.pattern, candidate));
290
+ }
291
+ return relativePath.split("/").some((segment) => gitIgnoreGlobMatches(rule.pattern, segment));
292
+ }
293
+
294
+ // Like the file matcher but only considers directory segments - `node_modules/` must not match the
295
+ // leaf file name even if a file happened to be called `node_modules`.
296
+ function gitIgnoreDirectoryRuleMatches(rule: GitIgnoreRule, relativePath: string, isDirectory: boolean): boolean {
297
+ if (isPathScopedGitIgnoreRule(rule)) {
298
+ return gitIgnorePathCandidates(relativePath, isDirectory, false).some((candidate) => gitIgnoreGlobMatches(rule.pattern, candidate));
299
+ }
300
+ const segments = relativePath.split("/");
301
+ const directorySegments = isDirectory ? segments : segments.slice(0, -1);
302
+ return directorySegments.some((segment) => gitIgnoreGlobMatches(rule.pattern, segment));
303
+ }
304
+
305
+ // A rule is "path scoped" when it was anchored (leading `/`) or contained a `/` - both signal that
306
+ // the pattern should be matched against multi-segment sub-paths rather than individual names.
307
+ function isPathScopedGitIgnoreRule(rule: GitIgnoreRule): boolean {
308
+ return rule.isAnchored || rule.hasSlash;
309
+ }
310
+
311
+ // Enumerates progressively longer prefixes of `relativePath` so a single-segment pattern can match
312
+ // at any nesting depth. `shouldIncludeFilePath` controls whether the leaf segment is part of the prefix set
313
+ // - directory-only rules omit it because `dir/` must not match a file called `dir`.
314
+ function gitIgnorePathCandidates(relativePath: string, isDirectory: boolean, shouldIncludeFilePath: boolean): string[] {
315
+ const segments = relativePath.split("/");
316
+ const limit = isDirectory || shouldIncludeFilePath ? segments.length : segments.length - 1;
317
+ const candidates: string[] = [];
318
+ for (let index = 1; index <= limit; index += 1) {
319
+ candidates.push(segments.slice(0, index).join("/"));
320
+ }
321
+ return candidates;
322
+ }
323
+
324
+ // Compiles the pattern on demand. Compilation is cheap relative to the surrounding walk so caching
325
+ // across calls is not worth the cache-invalidation footgun.
326
+ function gitIgnoreGlobMatches(pattern: string, candidatePath: string): boolean {
327
+ return gitIgnoreGlobRegex(pattern).test(candidatePath);
328
+ }
329
+
330
+ // Translates a git glob into a JS RegExp. `**`, `*`, and `?` get their git-flavoured semantics:
331
+ // `*` matches a single segment (`[^/]*`), `**` can cross segment boundaries, and `?` matches one
332
+ // non-slash character.
333
+ function gitIgnoreGlobRegex(pattern: string): RegExp {
334
+ let source = "^";
335
+ for (let index = 0; index < pattern.length; index += 1) {
336
+ const fragment = gitIgnoreGlobFragment(pattern, index);
337
+ source += fragment.source;
338
+ index += fragment.skip;
339
+ }
340
+ return new RegExp(`${source}$`);
341
+ }
342
+
343
+ // One pattern character → one regex fragment. `*` dispatches to the star helper because `**` and
344
+ // `**/ ` need special handling; everything else falls through to literal escape via `escapeRegex`.
345
+ function gitIgnoreGlobFragment(pattern: string, index: number): { source: string; skip: number } {
346
+ const character = pattern[index] ?? "";
347
+ if (character === "*") {
348
+ return gitIgnoreStarFragment(pattern, index);
349
+ }
350
+ if (character === "?") {
351
+ return { source: "[^/]", skip: 0 };
352
+ }
353
+ if (character === "[") {
354
+ return gitIgnoreCharacterClassFragment(pattern, index) ?? { source: escapeRegex(character), skip: 0 };
355
+ }
356
+ if (character === "\\") {
357
+ return { source: escapeRegex(pattern[index + 1] ?? character), skip: pattern[index + 1] ? 1 : 0 };
358
+ }
359
+ return { source: escapeRegex(character), skip: 0 };
360
+ }
361
+
362
+ // Three star modes from the git spec: single `*` is segment-local, `**/` matches zero or more
363
+ // segments, and trailing `**` matches anything. The `skip` return lets the caller advance past the
364
+ // extra characters consumed.
365
+ function gitIgnoreStarFragment(pattern: string, index: number): { source: string; skip: number } {
366
+ const next = pattern[index + 1];
367
+ const afterNext = pattern[index + 2];
368
+ if (next !== "*") {
369
+ return { source: "[^/]*", skip: 0 };
370
+ }
371
+ if (afterNext === "/" && (index === 0 || pattern[index - 1] === "/")) {
372
+ return { source: "(?:.*/)?", skip: 2 };
373
+ }
374
+ if (index > 0 && pattern[index - 1] === "/" && index + 2 === pattern.length) {
375
+ return { source: ".*", skip: 1 };
376
+ }
377
+ return { source: "[^/]*", skip: 1 };
378
+ }
379
+
380
+ // Converts `[abc]`, `[!abc]`, and escaped class characters into regex class syntax. Split into
381
+ // header/body helpers because git classes have positional exceptions that are easier to audit separately.
382
+ function gitIgnoreCharacterClassFragment(pattern: string, index: number): { source: string; skip: number } | undefined {
383
+ const parsed = parseGitIgnoreCharacterClass(pattern, index);
384
+ if (!parsed) {
385
+ return undefined;
386
+ }
387
+ return { source: parsed.isNegated ? `[^/${parsed.body}]` : `[${parsed.body}]`, skip: parsed.skip };
388
+ }
389
+
390
+ // Parsed gitignore character-class body before conversion to regex syntax.
391
+ interface GitIgnoreCharacterClass {
392
+ body: string;
393
+ isNegated: boolean;
394
+ skip: number;
395
+ }
396
+
397
+ // Walks a character class until the first valid closing `]`. The leading `]` exception is handled
398
+ // before the loop because git treats `[]a]` as a class containing `]` and `a`.
399
+ function parseGitIgnoreCharacterClass(pattern: string, index: number): GitIgnoreCharacterClass | undefined {
400
+ const header = gitIgnoreCharacterClassHeader(pattern, index);
401
+ let cursor = header.cursor;
402
+ let body = header.body;
403
+ for (; cursor < pattern.length; cursor += 1) {
404
+ const character = pattern[cursor] ?? "";
405
+ if (character === "]" && body.length > 0) {
406
+ return { body, isNegated: header.isNegated, skip: cursor - index };
407
+ }
408
+ const fragment = gitIgnoreClassBodyFragment(pattern, cursor);
409
+ body += fragment.source;
410
+ cursor += fragment.skip;
411
+ }
412
+ return undefined;
413
+ }
414
+
415
+ // Reads negation (`!`/`^`) and the special literal `]` when it appears first in the class body.
416
+ function gitIgnoreCharacterClassHeader(pattern: string, index: number): { cursor: number; body: string; isNegated: boolean } {
417
+ let cursor = index + 1;
418
+ const isNegated = pattern[cursor] === "!" || pattern[cursor] === "^";
419
+ if (isNegated) {
420
+ cursor += 1;
421
+ }
422
+ if (pattern[cursor] === "]") {
423
+ return { cursor: cursor + 1, body: "\\]", isNegated };
424
+ }
425
+ return { cursor, body: "", isNegated };
426
+ }
427
+
428
+ // Returns the regex-ready body fragment for one class character, consuming an escaped literal pair
429
+ // when gitignore used a backslash.
430
+ function gitIgnoreClassBodyFragment(pattern: string, cursor: number): { source: string; skip: number } {
431
+ if (pattern[cursor] === "\\" && pattern[cursor + 1]) {
432
+ return { source: escapeRegexClassCharacter(pattern[cursor + 1] ?? ""), skip: 1 };
433
+ }
434
+ return { source: escapeRegexClassCharacter(pattern[cursor] ?? ""), skip: 0 };
435
+ }
436
+
437
+ // Escapes only characters with special meaning inside a regex character class. Escaping `-` would
438
+ // change valid git ranges like `[a-z]`, so the set is intentionally narrower than normal regex escaping.
439
+ function escapeRegexClassCharacter(character: string): string {
440
+ return character.replace(/[\\\]^]/g, "\\$&");
441
+ }
442
+
443
+ // Strips the basePath prefix so a rule from `subdir/.gitignore` is matched against paths relative
444
+ // to `subdir`. Returns undefined when `display` is outside the base - those rules cannot apply.
445
+ function pathRelativeToBase(basePath: string, display: string): string | undefined {
446
+ if (basePath.length === 0) {
447
+ return display === "." ? "" : display;
448
+ }
449
+ if (display === basePath) {
450
+ return "";
451
+ }
452
+ return display.startsWith(`${basePath}/`) ? display.slice(basePath.length + 1) : undefined;
453
+ }
454
+
455
+ // Guards against `..`-traversal and absolute-path inputs that point outside the requested root.
456
+ // Required so a malformed CLI path cannot drag the .gitignore walker into the user's home directory.
457
+ function isInsideProject(projectRoot: string, path: string): boolean {
458
+ const relativePath = relative(projectRoot, path).replaceAll("\\", "/");
459
+ return relativePath === "" || (!relativePath.startsWith("../") && relativePath !== ".." && !isAbsolute(relativePath));
460
+ }
461
+
462
+ // User config ignore patterns. Simpler than gitignore: literal, `prefix/**`, glob with `*` / `**`,
463
+ // or plain prefix. No negation - config ignores are additive on top of gitignore.
464
+ function pathMatches(pattern: string, path: string): boolean {
465
+ if (pattern === path) {
466
+ return true;
467
+ }
468
+ if (pattern.endsWith("/**")) {
469
+ const prefix = pattern.slice(0, -3);
470
+ return path === prefix || path.startsWith(`${prefix}/`);
471
+ }
472
+ if (pattern.includes("*")) {
473
+ const regex = new RegExp(`^${escapeRegex(pattern).replaceAll("\\*\\*", ".*").replaceAll("\\*", "[^/]*")}$`);
474
+ return regex.test(path);
475
+ }
476
+ const prefix = pattern.replace(/\/$/, "");
477
+ return path === prefix || path.startsWith(`${prefix}/`);
478
+ }
479
+
480
+ // Same absolute path can be reached through multiple CLI inputs. First-seen wins because that
481
+ // preserves the deterministic sort imposed by `discoverSources` and keeps fingerprint stability.
482
+ function uniqueFiles(files: SourceFile[]): SourceFile[] {
483
+ const seen = new Set<string>();
484
+ return files.filter((file) => {
485
+ if (seen.has(file.absolutePath)) {
486
+ return false;
487
+ }
488
+ seen.add(file.absolutePath);
489
+ return true;
490
+ });
491
+ }
492
+
493
+ // Anchors a relative CLI argument against the project root; absolute paths pass through unchanged.
494
+ export function absolutize(projectRoot: string, path: string): string {
495
+ return isAbsolute(path) ? path : join(projectRoot, path);
496
+ }
497
+
498
+ // Project-relative form with forward slashes - the report contract uses POSIX-style display paths
499
+ // on every platform. "" collapses to "." so the root has a stable label in findings.
500
+ export function displayPath(projectRoot: string, path: string): string {
501
+ const relativePath = relative(projectRoot, path).replaceAll("\\", "/");
502
+ return relativePath === "" ? "." : relativePath;
503
+ }
504
+
505
+ // Escapes the standard regex metacharacters so untrusted patterns can be embedded literally.
506
+ function escapeRegex(rawText: string): string {
507
+ return rawText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
508
+ }