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
5 changes: 5 additions & 0 deletions .changeset/conditional-lint-generator-inventory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@stll/oxlint-config": patch
---

Track the working directory through bounded conditional Bun generator preparation and inventory the generator's possible nested lint scopes without executing shell conditions.
2 changes: 1 addition & 1 deletion packages/oxlint-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,6 @@ Repositories with a tested staged-path selector can declare its contract explici

The script must be tracked, regular and readable without symlinks. The pre-commit command must use exactly `files: bun scripts/precommit-paths.ts` with `run: bun fmt --staged --ignore-unknown {files}` (the `--ignore-unknown` flag is optional), without an additional `glob`, `exclude`, `skip` or `root`. The declaration records repository ownership of the selector's semantics; static inspection does not prove that its output includes every staged path. Keep behavioral tests with that owner. CI still requires a whole-repository `bun fmt --check` without path operands.

Lint inventory follows literal `cd <workspace> && ...` changes only within declared workspace directories. Literal `bun --cwd=<workspace> run <script>` preparation expands the target workspace's script and its Bun lifecycle, retaining any nested lint scopes. Dynamic paths, directory escapes, symlinks, cycles and ambiguous directory control flow remain rejected.
Lint inventory follows literal `cd <workspace> && ...` changes only within declared workspace directories. Literal `bun --cwd=<workspace> run <script>` preparation expands the target workspace's script and its Bun lifecycle, retaining any nested lint scopes. The conditional preparation form `([ -n "$VARIABLE" ] || bun run <script>)` includes the script's possible lint scopes at the current directory even when the environment may skip generation. Its condition accepts only a quoted variable name, without substitutions, and its body accepts only one literal Bun script. Dynamic paths, directory escapes, symlinks, cycles and ambiguous directory control flow remain rejected.

A repository-owned CI pool can declare `formatterCheckSource` with `source`, `workflow`, `job`, `runner`, `argument` and `reason`. For example, a tracked `.github/checks/policy.yml` containing `run: bun fmt --check` is connected to a tracked `scripts/check-pool.ts` by the named workflow job's literal `bun scripts/check-pool.ts run policy` invocation (`runner` is that script and `argument` is `policy`). All three files must be tracked regular files without symlinks. Assignment prefixes and `exec` are accepted; documentation strings, different jobs or arguments, missing sources and narrowed format commands are rejected. The declaration records the repository's tested runner-to-source execution contract; it does not execute or prove the custom runner's implementation.
52 changes: 52 additions & 0 deletions packages/oxlint-config/src/toolchain-lint-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,3 +715,55 @@ test("literal successful directory changes preserve the inventory working direct
.length,
).toBeGreaterThan(0);
});

test("conditional Bun generators inherit the successful outer directory without hiding their script", () => {
for (const variable of ["TURBO_HASH", "_CACHE_2", "GENERATOR_READY"]) {
const invoked: string[] = [];
const inventory = lintInventoryCommands(
manifest({
lint: `cd ../.. && ([ -n "$${variable}" ] || bun run generate) && bun --bun oxlint -c oxlint.config.ts --type-aware apps/web`,
}),
true,
{
directory: "apps/web",
assertDirectory: (directory) => {
if (directory !== ".") throw new Error("unexpected workspace");
},
invokeBunScript: (directory, script) =>
invoked.push(`${directory}:${script}`),
},
);
expect(inventory.errors).toEqual([]);
expect(invoked).toEqual([".:generate"]);
expect(inventory.commands).toEqual([
{
runtime: "bun",
args: ["-c", "oxlint.config.ts", "apps/web"],
directory: ".",
},
]);
}
});

test("conditional generator inventory rejects substitutions, directory mutations, and ambiguous shell flow", () => {
for (const command of [
'cd ../.. && ([ -n "$(touch marker)" ] || bun run generate) && oxlint apps/web',
'cd ../.. && ([ -n "$TURBO_HASH" ] || cd apps/api) && oxlint apps/web',
'cd ../.. && ([ -n "$TURBO_HASH" ] || bun run "$SCRIPT") && oxlint apps/web',
'cd ../.. && ([ -n "$TURBO_HASH" ] || bun run generate extra) && oxlint apps/web',
'cd ../.. && ([ -n "$TURBO_HASH" ] || bun run generate; cd apps/api) && oxlint apps/web',
'cd ../.. || ([ -n "$TURBO_HASH" ] || bun run generate) && oxlint apps/web',
"bash -c 'cd ../.. && ([ -n \"$TURBO_HASH\" ] || bun run generate) && oxlint apps/web'",
]) {
const inventory = lintInventoryCommands(
manifest({ lint: command, generate: "echo generated" }),
true,
{
directory: "apps/web",
assertDirectory: () => {},
invokeBunScript: () => {},
},
);
expect(inventory.errors.length).toBeGreaterThan(0);
}
});
56 changes: 54 additions & 2 deletions packages/oxlint-config/src/toolchain-lint-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,57 @@ const literalWord = (raw: string) => {
return quote === undefined;
};

// The environment guard may skip its generator, but inventory must include
// that script's possible lint scopes. This bounded group cannot change the
// parent's directory or evaluate command substitutions in its condition.
const includeConditionalBunScripts = (source: string) => {
const tokens = shellTokens(source);
let result = "";
let consumed = 0;
for (let index = 0; index < tokens.length; index++) {
const group = tokens.slice(index, index + 10);
const [
open,
test,
flag,
variable,
closeTest,
otherwise,
bun,
run,
script,
close,
] = group;
if (
open?.kind !== "boundary" ||
open.value !== "(" ||
test?.kind !== "word" ||
test.value !== "[" ||
flag?.kind !== "word" ||
flag.value !== "-n" ||
variable?.kind !== "word" ||
!/^"\$[A-Za-z_][A-Za-z0-9_]*"$/u.test(variable.value) ||
closeTest?.kind !== "word" ||
closeTest.value !== "]" ||
otherwise?.kind !== "boundary" ||
otherwise.value !== "||" ||
bun?.kind !== "word" ||
bun.value !== "bun" ||
run?.kind !== "word" ||
run.value !== "run" ||
script?.kind !== "word" ||
!literalWord(script.value) ||
close?.kind !== "boundary" ||
close.value !== ")"
)
continue;
result += source.slice(consumed, open.index) + `bun run ${script.value}`;
consumed = close.index + 1;
index += group.length - 1;
}
return result + source.slice(consumed);
};

const inventoryArguments = (raw: readonly string[]) => {
const args: string[] = [];
let threadsSeen = false;
Expand Down Expand Up @@ -207,10 +258,11 @@ export const lintInventoryCommands = (
}
active.add(name);
let directory = context.directory ?? ".";
const segments = shellCommandSegments(source).filter(
const inventorySource = includeConditionalBunScripts(source);
const segments = shellCommandSegments(inventorySource).filter(
(segment) => segment.length > 0,
);
const tokens = shellTokens(source);
const tokens = shellTokens(inventorySource);
// Flattened nested shell bodies do not share their parent's working directory.
// Directory tracking is deliberately limited to a literal top-level && chain.
const tracksDirectory =
Expand Down
108 changes: 58 additions & 50 deletions packages/oxlint-config/src/toolchain-node-projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1111,58 +1111,66 @@ test("declared inventory runtimes load real configs independently of the checker
}, 15_000);

test("cross-workspace Bun scripts retain their own lint scopes and cd scopes resolve from repository root", () => {
withInventoryFixture((root, trackedFiles) => {
const manifest = {
...fixtureManifest,
packageManager: "bun@1.4.4",
workspaces: ["packages/*"],
scripts: { lint: "turbo run lint" },
};
const write = (file: string, contents: string) => {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), contents);
trackedFiles.add(file);
};
write("package.json", JSON.stringify(manifest));
write(
"packages/api/package.json",
JSON.stringify({
name: "@fixture/api",
for (const preparation of [
"bun --cwd=../generator run generate && cd ../..",
'cd ../.. && ([ -n "$TURBO_HASH" ] || bun run generate)',
])
withInventoryFixture((root, trackedFiles) => {
const manifest = {
...fixtureManifest,
packageManager: "bun@1.4.4",
workspaces: ["packages/*"],
scripts: {
lint: "bun --cwd=../generator run generate && cd ../.. && bun --bun oxlint -c .oxlintrc.json packages/api",
lint: "turbo run lint",
generate: "bun --cwd=packages/generator run generate",
},
}),
);
write(
"packages/generator/package.json",
JSON.stringify({
name: "@fixture/generator",
scripts: { generate: "oxlint -c ../../.oxlintrc.json ." },
}),
);
for (const directory of ["packages/api", "packages/generator"]) {
write(`${directory}/orphan.ts`, "export const value = 1;\n");
write(`${directory}/tsconfig.json`, '{"files":[]}');
}
trackedFiles.add(".oxlintrc.json");
const diagnostics = checkNodeProjects({ root, manifest, trackedFiles });
expect(diagnostics.map(({ path: file }) => file).sort()).toEqual([
"packages/api/orphan.ts",
"packages/generator/orphan.ts",
]);
write(
"packages/generator/package.json",
JSON.stringify({
name: "@fixture/generator",
scripts: { generate: "bun --cwd=../api run lint" },
}),
);
expect(
checkNodeProjects({ root, manifest, trackedFiles }).some(({ message }) =>
message.includes("Cyclic workspace script delegation"),
),
).toBe(true);
});
};
const write = (file: string, contents: string) => {
mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
writeFileSync(path.join(root, file), contents);
trackedFiles.add(file);
};
write("package.json", JSON.stringify(manifest));
write(
"packages/api/package.json",
JSON.stringify({
name: "@fixture/api",
scripts: {
lint: `${preparation} && bun --bun oxlint -c .oxlintrc.json packages/api`,
},
}),
);
write(
"packages/generator/package.json",
JSON.stringify({
name: "@fixture/generator",
scripts: { generate: "oxlint -c ../../.oxlintrc.json ." },
}),
);
for (const directory of ["packages/api", "packages/generator"]) {
write(`${directory}/orphan.ts`, "export const value = 1;\n");
write(`${directory}/tsconfig.json`, '{"files":[]}');
}
trackedFiles.add(".oxlintrc.json");
const diagnostics = checkNodeProjects({ root, manifest, trackedFiles });
expect(diagnostics.map(({ path: file }) => file).sort()).toEqual([
"packages/api/orphan.ts",
"packages/generator/orphan.ts",
]);
write(
"packages/generator/package.json",
JSON.stringify({
name: "@fixture/generator",
scripts: { generate: "bun --cwd=../api run lint" },
}),
);
expect(
checkNodeProjects({ root, manifest, trackedFiles }).some(
({ message }) =>
message.includes("Cyclic workspace script delegation"),
),
).toBe(true);
});
}, 15_000);

test("changed-directory file-shaped aliases resolve from the target manifest", () => {
Expand Down
Loading