Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>` 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
Expand Down
8 changes: 5 additions & 3 deletions docs/analyze.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
128 changes: 116 additions & 12 deletions src/analyze/project.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -20,6 +21,13 @@ export const DEFAULT_ANALYZE_EXCLUDES = [

const SOURCE_EXTENSION = /\.[cm]?[jt]sx?$/;

type IgnoreMatcher = ReturnType<typeof createIgnore>;

interface IgnoreScope {
readonly directory: string;
readonly matcher: IgnoreMatcher;
}

function asObject(value: unknown, message: string): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new Error(message);
Expand Down Expand Up @@ -78,34 +86,120 @@ 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<string, Promise<IgnoreScope | null>>();

constructor(root: string) {
this.#root = path.resolve(root);
}

scope(directory: string): Promise<IgnoreScope | null> {
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<IgnoreScope[] | null> {
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<boolean> {
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<string[]> {
): Promise<{ files: string[]; ignores: (filePath: string) => Promise<boolean> }> {
const ignoreRoot = isWithin(projectRoot, directory) ? projectRoot : directory;
const hierarchy = new GitIgnoreHierarchy(ignoreRoot);
const files: string[] = [];
const visit = async (current: string): Promise<void> => {
const visit = async (current: string, scopes: readonly IgnoreScope[]): Promise<void> => {
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 {
return ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n");
}

async function compilerInputs(
projectRoot: string,
workspace: WorkspaceManifest,
configuration: AnalyzeConfiguration,
): Promise<{
Expand Down Expand Up @@ -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 };
}

Expand All @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/analyze-rules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
48 changes: 40 additions & 8 deletions tests/integration/peer-floor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
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<string> {
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 () => {
Expand Down Expand Up @@ -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"],
Expand Down
1 change: 1 addition & 0 deletions tests/update-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down