diff --git a/README.md b/README.md index 579b9ec..123cdb3 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,9 @@ All diagnostics include a stable rule ID and workspace-relative source location. The analyzer distinguishes canonical Askr imports from unrelated same-named functions and only recommends `` for state-backed reactive JSX collections, so static transforms with `.map()` remain valid. +Source discovery honors project and nested `.gitignore` files; use +`askr.analyze.exclude` in the workspace-root manifest for additional +analyzer-only patterns. By default it transactionally applies only mechanical route-parameter and plain-JSON JSX configuration fixes. `--check` is read-only for CI. Semantic diff --git a/docs/analyze.md b/docs/analyze.md index b279775..bd1fc17 100644 --- a/docs/analyze.md +++ b/docs/analyze.md @@ -182,9 +182,11 @@ Configure the analyzer in the workspace root `package.json`: } ``` -Rule values are `error`, `warning`, `info`, or `off`. Exclusions are applied -relative to each workspace. The analyzer always ignores dependency, VCS, -coverage, generated, and common build-output directories by default. +Rule values are `error`, `warning`, `info`, or `off`. Source discovery honors +`.gitignore` files from the project root through each selected workspace, +including nested rules and negation. `askr.analyze.exclude` adds analyzer-only +patterns relative to each workspace. The analyzer also always ignores dependency, +VCS, coverage, generated, and common build-output directories by default. ## CI diff --git a/package-lock.json b/package-lock.json index 499c395..b029f84 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@npmcli/config": "^11.0.1", + "ignore": "^7.0.6", "js-yaml": "^5.4.1", "minimatch": "^10.2.6", "npm-registry-fetch": "^20.0.1", @@ -4096,6 +4097,15 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ignore": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz", + "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/ini": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/ini/-/ini-7.0.0.tgz", diff --git a/package.json b/package.json index 0817c9d..f71a834 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ }, "dependencies": { "@npmcli/config": "^11.0.1", + "ignore": "^7.0.6", "js-yaml": "^5.4.1", "minimatch": "^10.2.6", "npm-registry-fetch": "^20.0.1", diff --git a/src/analyze/project.ts b/src/analyze/project.ts index bd77d12..4efdc17 100644 --- a/src/analyze/project.ts +++ b/src/analyze/project.ts @@ -1,5 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; +import createIgnore from "ignore"; import { minimatch } from "minimatch"; import ts from "typescript"; import type { AnalyzeConfiguration, WorkspaceAnalysisContext } from "./types"; @@ -20,6 +21,13 @@ export const DEFAULT_ANALYZE_EXCLUDES = [ const SOURCE_EXTENSION = /\.[cm]?[jt]sx?$/; +type IgnoreMatcher = ReturnType; + +interface IgnoreScope { + readonly directory: string; + readonly matcher: IgnoreMatcher; +} + function asObject(value: unknown, message: string): Record { if (!value || typeof value !== "object" || Array.isArray(value)) { throw new Error(message); @@ -78,27 +86,112 @@ function isExcluded(root: string, filePath: string, patterns: readonly string[]) ); } +function isWithin(root: string, target: string): boolean { + const relative = path.relative(root, target); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); +} + +function isIgnoredByScopes( + filePath: string, + directory: boolean, + scopes: readonly IgnoreScope[], +): boolean { + let ignored = false; + for (const scope of scopes) { + if (!isWithin(scope.directory, filePath)) continue; + const relative = normalizeRelative(scope.directory, filePath); + if (!relative) continue; + const result = scope.matcher.test(directory ? `${relative}/` : relative); + if (result.ignored) ignored = true; + else if (result.unignored) ignored = false; + } + return ignored; +} + +class GitIgnoreHierarchy { + readonly #root: string; + readonly #scopes = new Map>(); + + constructor(root: string) { + this.#root = path.resolve(root); + } + + scope(directory: string): Promise { + const resolved = path.resolve(directory); + const existing = this.#scopes.get(resolved); + if (existing) return existing; + const pending = fs + .readFile(path.join(resolved, ".gitignore"), "utf8") + .then((patterns) => ({ + directory: resolved, + matcher: createIgnore({ ignorecase: process.platform === "win32" }).add(patterns), + })) + .catch((error: unknown) => { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + }); + this.#scopes.set(resolved, pending); + return pending; + } + + async enter(directory: string): Promise { + const resolved = path.resolve(directory); + if (!isWithin(this.#root, resolved)) return []; + const relative = path.relative(this.#root, resolved); + const parts = relative ? relative.split(path.sep) : []; + const scopes: IgnoreScope[] = []; + let current = this.#root; + const rootScope = await this.scope(current); + if (rootScope) scopes.push(rootScope); + for (const part of parts) { + const child = path.join(current, part); + if (isIgnoredByScopes(child, true, scopes)) return null; + current = child; + const scope = await this.scope(current); + if (scope) scopes.push(scope); + } + return scopes; + } + + async ignores(filePath: string): Promise { + const scopes = await this.enter(path.dirname(filePath)); + return scopes === null || isIgnoredByScopes(filePath, false, scopes); + } +} + async function discoverSourceFiles( directory: string, - root: string, + projectRoot: string, exclusions: readonly string[], -): Promise { +): Promise<{ files: string[]; ignores: (filePath: string) => Promise }> { + const ignoreRoot = isWithin(projectRoot, directory) ? projectRoot : directory; + const hierarchy = new GitIgnoreHierarchy(ignoreRoot); const files: string[] = []; - const visit = async (current: string): Promise => { + const visit = async (current: string, scopes: readonly IgnoreScope[]): Promise => { const entries = await fs.readdir(current, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const child = path.join(current, entry.name); - if (isExcluded(root, child, exclusions)) continue; + if ( + isExcluded(directory, child, exclusions) || + isIgnoredByScopes(child, entry.isDirectory(), scopes) + ) { + continue; + } if (entry.isDirectory()) { - await visit(child); + const childScope = await hierarchy.scope(child); + await visit(child, childScope ? [...scopes, childScope] : scopes); } else if (entry.isFile() && SOURCE_EXTENSION.test(entry.name)) { files.push(child); } } }; - await visit(directory); - return files; + const scopes = await hierarchy.enter(directory); + if (scopes) await visit(directory, scopes); + return { files, ignores: (filePath) => hierarchy.ignores(filePath) }; } function formatConfigDiagnostic(diagnostic: ts.Diagnostic): string { @@ -106,6 +199,7 @@ function formatConfigDiagnostic(diagnostic: ts.Diagnostic): string { } async function compilerInputs( + projectRoot: string, workspace: WorkspaceManifest, configuration: AnalyzeConfiguration, ): Promise<{ @@ -156,12 +250,22 @@ async function compilerInputs( const discovered = await discoverSourceFiles( workspace.directory, - workspace.directory, + projectRoot, configuration.exclude, ); - const rootNames = [...new Set([...configuredFiles, ...discovered])] - .filter((filePath) => !isExcluded(workspace.directory, filePath, configuration.exclude)) - .sort((left, right) => left.localeCompare(right)); + const configuredIncluded = ( + await Promise.all( + configuredFiles.map(async (filePath) => + !isExcluded(workspace.directory, filePath, configuration.exclude) && + !(await discovered.ignores(filePath)) + ? filePath + : null, + ), + ) + ).filter((filePath): filePath is string => filePath !== null); + const rootNames = [...new Set([...configuredIncluded, ...discovered.files])].sort((left, right) => + left.localeCompare(right), + ); return { rootNames, options, tsconfig: hasConfig ? tsconfig : null }; } @@ -170,7 +274,7 @@ export async function createWorkspaceAnalysisContext( workspace: WorkspaceManifest, configuration: AnalyzeConfiguration, ): Promise<{ context: WorkspaceAnalysisContext; tsconfig: string | null }> { - const inputs = await compilerInputs(workspace, configuration); + const inputs = await compilerInputs(root, workspace, configuration); const compilerHost = ts.createCompilerHost(inputs.options, true); const moduleResolutionCache = ts.createModuleResolutionCache( workspace.directory, diff --git a/tests/analyze-rules.test.ts b/tests/analyze-rules.test.ts index b414b2c..085ed96 100644 --- a/tests/analyze-rules.test.ts +++ b/tests/analyze-rules.test.ts @@ -807,6 +807,72 @@ describe("analyzer rules", () => { ).toEqual([expect.objectContaining({ file: "src/theme.ts" })]); }); + it("should honor root and nested gitignore rules including negation", async () => { + const root = await fixture( + { + ".gitignore": ["legacy/*/", "ignored/*.ts", "!ignored/kept.ts", "/ignored-root.ts"].join( + "\n", + ), + "src/page.ts": `export const token = "--ak-color-text";`, + "legacy/framework/src/foreign.ts": `export const token = "--ak-color-surface";`, + "ignored/dropped.ts": `export const token = "--ak-color-border";`, + "ignored/kept.ts": `export const token = "--ak-color-primary";`, + "ignored-root.ts": `export const token = "--ak-color-danger";`, + "nested/.gitignore": ["*.ts", "!kept.ts"].join("\n"), + "nested/dropped.ts": `export const token = "--ak-color-warning";`, + "nested/kept.ts": `export const token = "--ak-color-success";`, + }, + { + tsconfig: { + compilerOptions: { + module: "ESNext", + moduleResolution: "Bundler", + target: "ES2022", + }, + include: ["src", "legacy", "ignored", "nested", "ignored-root.ts"], + }, + }, + ); + + const found = (await diagnostics(root)).filter( + (entry) => entry.ruleId === "askr/no-hardcoded-theme-token", + ); + expect(found.map((entry) => entry.file)).toEqual([ + "ignored/kept.ts", + "nested/kept.ts", + "src/page.ts", + ]); + }); + + it("should apply project-root gitignore rules to nested workspaces", async () => { + const root = await fixture( + { + ".gitignore": "packages/app/ignored.ts\n", + "packages/app/package.json": JSON.stringify({ + name: "@fixture/app", + dependencies: { "@askrjs/askr": "^0.0.70" }, + }), + "packages/app/src/page.ts": `export const token = "--ak-color-text";`, + "packages/app/ignored.ts": `export const token = "--ak-color-surface";`, + }, + { + manifest: { + name: "fixture-root", + private: true, + workspaces: ["packages/*"], + }, + tsconfig: null, + }, + ); + + const found = (await diagnostics(root)).filter( + (entry) => entry.ruleId === "askr/no-hardcoded-theme-token", + ); + expect(found).toEqual([ + expect.objectContaining({ workspace: "@fixture/app", file: "src/page.ts" }), + ]); + }); + it("should honor exclusions and rule severity configuration", async () => { const root = await fixture( { diff --git a/tests/integration/peer-floor.test.ts b/tests/integration/peer-floor.test.ts index b7a5111..f1f7581 100644 --- a/tests/integration/peer-floor.test.ts +++ b/tests/integration/peer-floor.test.ts @@ -9,14 +9,27 @@ import { expect, test } from "vitest"; const execFileAsync = promisify(execFile); const repository = fileURLToPath(new URL("../../", import.meta.url)); -async function run(command: string, args: string[], cwd: string): Promise { - const { stdout, stderr } = await execFileAsync(command, args, { - cwd, - env: { ...process.env, NO_COLOR: "1" }, - maxBuffer: 20 * 1024 * 1024, - }); - if (stderr) process.stderr.write(stderr); - return stdout; +async function run( + command: string, + args: string[], + cwd: string, + expectedExit = 0, +): Promise { + try { + const { stdout, stderr } = await execFileAsync(command, args, { + cwd, + env: { ...process.env, NO_COLOR: "1" }, + maxBuffer: 20 * 1024 * 1024, + }); + if (stderr) process.stderr.write(stderr); + if (expectedExit !== 0) throw new Error(`Expected exit ${expectedExit}, received 0.`); + return stdout; + } catch (error) { + const failure = error as Error & { code?: number; stdout?: string; stderr?: string }; + if (failure.code !== expectedExit) throw error; + if (failure.stderr) process.stderr.write(failure.stderr); + return failure.stdout ?? ""; + } } test("should ensure packed CLI works with the minimum supported Askr peer", async () => { @@ -66,6 +79,25 @@ export const staticConfig = { ); await run("npm", ["install", "--ignore-scripts", "--no-audit", "--no-fund"], root); + await fs.mkdir(path.join(root, "src")); + await fs.writeFile( + path.join(root, "src", "page.ts"), + 'export const token = "--ak-color-text";\n', + ); + await fs.writeFile( + path.join(root, "ignored.ts"), + 'export const token = "--ak-color-surface";\n', + ); + await fs.writeFile(path.join(root, ".gitignore"), "ignored.ts\n"); + const analysis = JSON.parse( + await run(path.join(root, "node_modules", ".bin", "askr"), ["analyze", "--json"], root, 1), + ); + expect( + analysis.diagnostics + .filter((entry: { ruleId: string }) => entry.ruleId === "askr/no-hardcoded-theme-token") + .map((entry: { file: string }) => entry.file), + ).toEqual(["src/page.ts"]); + await run( path.join(root, "node_modules", ".bin", "askr"), ["ssg", "--config", "./ssg.config.ts", "--output", "./dist"], diff --git a/tests/update-cli.test.ts b/tests/update-cli.test.ts index 1b8cf72..d9760fa 100644 --- a/tests/update-cli.test.ts +++ b/tests/update-cli.test.ts @@ -381,6 +381,7 @@ describe("update CLI", () => { expect(manifest.engines.node).toBe(">=24.0.0"); expect(Object.keys(manifest.dependencies).sort()).toEqual([ "@npmcli/config", + "ignore", "js-yaml", "minimatch", "npm-registry-fetch",