chore(lint): vendor anti-slop oxlint plugin and add batched cleanup pipeline
Vendor the anti-slop Oxlint plugin at tools/oxlint/anti-slop and register it in oxlint.config.ts, with Oxlint's own rule categories disabled so ESLint stays the general-purpose linter. Add scripts/anti-slop.mjs (bun run deslop) mirroring the React Doctor batch interface: next-batch, check-batch, active, release, top, file. Batch handoff directories now double as file claims shared across clones via ~/.openchamber/maintenance-claims, so concurrent maintenance batches from either pipeline never select the same file. Harden both scheduled maintenance flows: stop on a dirty worktree, stop on NO BATCH AVAILABLE, validate per package instead of workspace-wide, and pin react-doctor to 0.9.12. The anti-slop task command documents concrete good and bad fixes and forbids laundering types to satisfy a rule.
This commit is contained in:
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
description: Create an anti-slop lint cleanup PR from the next generated batch
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
|
||||||
|
You are working in the OpenChamber repository.
|
||||||
|
|
||||||
|
Goal: reduce anti-slop Oxlint findings in a small, reviewable maintenance PR.
|
||||||
|
|
||||||
|
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||||
|
|
||||||
|
First, verify the worktree is safe to use:
|
||||||
|
|
||||||
|
`git status --porcelain`
|
||||||
|
|
||||||
|
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR.
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
|
`bun run deslop -- next-batch --min-issues 25 --max-issues 60`
|
||||||
|
|
||||||
|
Use the command output as the source of truth for this task scope.
|
||||||
|
|
||||||
|
If the output contains `NO BATCH AVAILABLE`, stop immediately and report the printed reason. Do not create a branch, do not create a pull request, and do not look for other work. Concurrency is already handled: the command excludes files claimed by other active batches and refuses to exceed the active-batch limit.
|
||||||
|
|
||||||
|
Background: anti-slop is a vendored Oxlint plugin at `tools/oxlint/anti-slop/`, configured in `oxlint.config.ts`. It rejects low-evidence typing: unjustified type assertions, `unknown`/`object`/`Record<string, unknown>` contracts, ad hoc `typeof` narrowing, conditional `{}` spreads, and module mocking. Fixing a finding means giving the code real type evidence, never hiding the symptom.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
- Before generating the batch, switch to `main` and pull the latest remote changes.
|
||||||
|
- Read the `next-batch` output carefully.
|
||||||
|
- Use the exact `Run ID`, `Batch name`, `Branch name`, and `PR title` printed by the command.
|
||||||
|
- Create the branch using the printed `Branch name`.
|
||||||
|
- Work only on the selected files listed in the batch output.
|
||||||
|
- Treat the selected files as complete-file scope. Do not cherry-pick only the first N findings.
|
||||||
|
- Read each selected file fully before editing it. These findings sit on type contracts, so a local edit can change behavior at a distant call site.
|
||||||
|
- Fix as many findings as practical in the selected files. Your default should be to fix selected findings, not to skip them.
|
||||||
|
|
||||||
|
## What a good fix looks like
|
||||||
|
|
||||||
|
Every finding is the same underlying complaint: the code claims less about a value than it actually knows. A good fix restores the missing knowledge. A bad fix hides the complaint while the knowledge stays missing. The rule cannot tell the difference, so you must.
|
||||||
|
|
||||||
|
Before editing, answer one question for the value in question: where does it actually come from? There are only three answers, and each has one correct fix.
|
||||||
|
|
||||||
|
1. It comes from code in this repository. The real type already exists somewhere upstream. Find it and use it. No parsing, no assertion.
|
||||||
|
2. It crosses an I/O boundary: HTTP response, `postMessage`, file contents, `localStorage`, a child process, the OpenCode SDK edge. Parse it once at that boundary, then let the parsed type flow onward untouched.
|
||||||
|
|
||||||
|
On parsing style, follow local precedent and do not introduce a new one. `zod` is declared as a dependency but is not currently used in the source, so a maintenance PR is the wrong place to start spreading it. Unless the file or package you are editing already parses with a schema library, write a small local parse function that takes the raw input, returns the domain type or `undefined`, and lives next to the boundary it guards. If you believe a schema library is genuinely warranted, skip the finding and say so in the PR body instead of introducing the pattern yourself.
|
||||||
|
3. It is genuinely dynamic, such as a plugin registry keyed by arbitrary strings. Then keep the open key but make the value type precise, and say so in the contract's name.
|
||||||
|
|
||||||
|
### `no-unsafe-dictionary-type`
|
||||||
|
|
||||||
|
Bad, and the most common lazy fix. The shape is known; the annotation throws it away.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type QuotaSnapshot = Record<string, unknown>;
|
||||||
|
|
||||||
|
function readLimit(snapshot: QuotaSnapshot) {
|
||||||
|
return snapshot.limit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Good. Name the contract and state the fields the code actually reads.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type QuotaSnapshot = {
|
||||||
|
limit: number;
|
||||||
|
used: number;
|
||||||
|
resetsAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function readLimit(snapshot: QuotaSnapshot) {
|
||||||
|
return snapshot.limit;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Also good, when keys really are open but values are not.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ProviderQuotas = Record<string, QuotaSnapshot>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Still bad, and does not count as a fix:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type QuotaSnapshot = Record<string, any>;
|
||||||
|
type QuotaSnapshot = { [key: string]: object };
|
||||||
|
type QuotaSnapshot = Record<string, string | number | boolean | null>;
|
||||||
|
```
|
||||||
|
|
||||||
|
The third one is the sneaky one. Widening to a union of primitives satisfies the rule without describing anything. If you cannot name the fields, that is a signal the value is unparsed I/O; go to the boundary and parse it.
|
||||||
|
|
||||||
|
### `no-unknown-parameters`, `no-unknown-returns`, `no-unknown-type-aliases`
|
||||||
|
|
||||||
|
Bad. The function accepts anything and immediately guesses.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function applyThemeMessage(message: unknown) {
|
||||||
|
const theme = message as { themeId: string };
|
||||||
|
setTheme(theme.themeId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Good. Parse at the boundary; the domain function receives a real type.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ThemeMessage = { themeId: string };
|
||||||
|
|
||||||
|
function parseThemeMessage(data: MessageEvent["data"]): ThemeMessage | undefined {
|
||||||
|
if (data === null || typeof data !== "object") return undefined;
|
||||||
|
const themeId = Reflect.get(data, "themeId");
|
||||||
|
return typeof themeId === "string" ? { themeId } : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyThemeMessage(message: ThemeMessage) {
|
||||||
|
setTheme(message.themeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("message", (event) => {
|
||||||
|
const message = parseThemeMessage(event.data);
|
||||||
|
if (message === undefined) return;
|
||||||
|
applyThemeMessage(message);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
The parse function itself will still report `no-runtime-typeof` and `no-reflect-get`, because it is doing exactly what those rules describe. That is expected and acceptable: the checks are now concentrated in one named boundary function instead of scattered through domain logic, and the domain function above is genuinely typed. Report these remaining findings in the PR body rather than hiding them. Do not silence them with inline suppressions.
|
||||||
|
|
||||||
|
Note what changed at runtime: a malformed message is now ignored instead of silently producing `undefined` deeper in the call stack. That is a deliberate behavior decision and it belongs in the PR body. Never introduce a throw on a path that previously degraded quietly.
|
||||||
|
|
||||||
|
The `cause` convention is the single allowed exception: `unknown` is correct for an error cause.
|
||||||
|
|
||||||
|
### `no-known-value-widening`
|
||||||
|
|
||||||
|
Bad. The annotation erases the known keys, so callers lose autocomplete and typo safety.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const settingsBySlug: Record<string, SettingsSection> = {
|
||||||
|
appearance: appearanceSection,
|
||||||
|
keybindings: keybindingsSection,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Good. Keep inference and validate the shape.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const settingsBySlug = {
|
||||||
|
appearance: appearanceSection,
|
||||||
|
keybindings: keybindingsSection,
|
||||||
|
} satisfies Record<string, SettingsSection>;
|
||||||
|
```
|
||||||
|
|
||||||
|
`satisfies` checks every value against the contract while preserving the literal keys. Reach for it before anything else here.
|
||||||
|
|
||||||
|
### `no-chained-type-assertions` and `no-widen-then-assert`
|
||||||
|
|
||||||
|
Bad. The precise type existed and was thrown away, then guessed back.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const raw = loadSession() as unknown as SessionSnapshot;
|
||||||
|
```
|
||||||
|
|
||||||
|
Good. Fix the upstream contract so the round trip is unnecessary.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const snapshot = loadSession();
|
||||||
|
```
|
||||||
|
|
||||||
|
If `loadSession` genuinely returns something imprecise, that function is the real defect. Fix it there when it is inside the batch scope; if it is outside, make the minimal supporting change and say so in the PR body.
|
||||||
|
|
||||||
|
### `require-safety-comment-for-type-assertion`
|
||||||
|
|
||||||
|
The first move is always to delete the assertion, not to document it. Only a small minority of these findings deserve a comment.
|
||||||
|
|
||||||
|
Bad, and an automatic rejection at review:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// SAFETY: this is safe.
|
||||||
|
const session = value as Session;
|
||||||
|
|
||||||
|
// SAFETY: value is a Session.
|
||||||
|
const session = value as Session;
|
||||||
|
|
||||||
|
// SAFETY: required by TypeScript.
|
||||||
|
const session = value as Session;
|
||||||
|
```
|
||||||
|
|
||||||
|
These say nothing. A valid comment names the check that already ran and the line or function that ran it, so a reviewer can verify the claim without trusting you.
|
||||||
|
|
||||||
|
Good:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const parsed = sessionSchema.safeParse(payload);
|
||||||
|
if (!parsed.success) return undefined;
|
||||||
|
// SAFETY: sessionSchema.safeParse above confirmed every field of Session.
|
||||||
|
const session = parsed.data as Session;
|
||||||
|
```
|
||||||
|
|
||||||
|
If you cannot write such a sentence truthfully, you do not have an assertion problem, you have a missing check. Add the check.
|
||||||
|
|
||||||
|
### `no-conditional-empty-object-spread`
|
||||||
|
|
||||||
|
This one changes behavior more often than it looks, so read the consumer before editing.
|
||||||
|
|
||||||
|
Bad:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const body = {
|
||||||
|
sessionId,
|
||||||
|
...(title !== undefined ? { title } : {}),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Good, when the consumer distinguishes a missing key from an explicit `undefined`, which is true for anything serialized to JSON or merged over defaults:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const body: CreateSessionBody = { sessionId };
|
||||||
|
if (title !== undefined) body.title = title;
|
||||||
|
```
|
||||||
|
|
||||||
|
Good, when the consumer treats both the same:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const body = { sessionId, title };
|
||||||
|
```
|
||||||
|
|
||||||
|
Choosing wrongly here sends `"title": null` or drops a field on a real API call. If you cannot determine which behavior the consumer needs by reading it, skip the finding and say why.
|
||||||
|
|
||||||
|
### `no-runtime-typeof`
|
||||||
|
|
||||||
|
Bad. An ad hoc check in the middle of domain logic.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function resolveHost(stored: unknown) {
|
||||||
|
if (typeof stored === "string") return stored;
|
||||||
|
return DEFAULT_HOST;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Good. Read and validate where the value enters the program, then branch on real domain values.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function readStoredHost(): string {
|
||||||
|
const stored = localStorage.getItem(STORED_HOST_KEY);
|
||||||
|
return stored !== null && stored.length > 0 ? stored : DEFAULT_HOST;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Here the fix removed the check entirely, because `localStorage.getItem` already has a precise contract: `string | null`. The original `unknown` was self-inflicted. Look for this case first; it is more common than it seems.
|
||||||
|
|
||||||
|
When a real check is unavoidable, keep it inside one named boundary function as shown above, and accept that the boundary function keeps its finding. What is not acceptable is spreading the same check across domain code, or renaming it into a type predicate so it reads as intentional while nothing was actually established.
|
||||||
|
|
||||||
|
### `no-module-mocking`
|
||||||
|
|
||||||
|
Bad. The test mocks a module and therefore tests the mock.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
mock.module("../lib/runtimeFetch", () => ({ runtimeFetch: async () => ({ ok: true }) }));
|
||||||
|
```
|
||||||
|
|
||||||
|
Good. Pass the dependency in, and let the test supply a real function.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
async function loadStatus(fetchStatus: () => Promise<StatusResponse>) {
|
||||||
|
return fetchStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
test("returns the fetched status", async () => {
|
||||||
|
const status = await loadStatus(async () => ({ ok: true }));
|
||||||
|
expect(status.ok).toBe(true);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If introducing the seam would restructure production code well beyond the batch, skip the finding and say so. Do not fake a seam you do not believe in.
|
||||||
|
|
||||||
|
## How to know your fix is real
|
||||||
|
|
||||||
|
Before moving to the next finding, check all four:
|
||||||
|
|
||||||
|
- The code now knows something it did not know before. If you only rearranged syntax, it is not a fix.
|
||||||
|
- No new `any`, no new assertion, no new broad union invented to satisfy the checker.
|
||||||
|
- If you added parsing, you decided explicitly what happens on invalid input, and that decision is written in the PR body.
|
||||||
|
- If you changed a type used elsewhere, you searched for its call sites and updated them, rather than casting at the call site.
|
||||||
|
|
||||||
|
Handle findings deliberately instead of skipping them: for parsing work, add the smallest schema that covers the fields actually used; for contract changes, follow call sites with search and update them; for tests, prefer real seams over widened fixtures.
|
||||||
|
|
||||||
|
Skip a finding only when the fix would require broad architectural changes, unclear behavior changes, or changes outside the selected batch scope. If skipped, mention it in the PR body.
|
||||||
|
|
||||||
|
Hard prohibitions. Each of these makes the lint output greener while making the code worse, and each is grounds for rejecting the whole PR:
|
||||||
|
- Do not disable, downgrade, or ignore anti-slop rules, in configuration or with inline comments.
|
||||||
|
- Do not add `any`, widen a type, or add an assertion in order to satisfy a rule.
|
||||||
|
- Do not write a generic or placeholder `// SAFETY:` comment. A comment that does not name a real, already-performed check is worse than the original finding.
|
||||||
|
- Do not invent a union of primitives to escape a dictionary rule.
|
||||||
|
- Do not move a rejected `typeof` check into a hand-written type predicate to get it out of the linter's way.
|
||||||
|
- Do not delete code, tests, or fields to make a finding disappear.
|
||||||
|
- Do not rename a symbol solely to dodge `no-shape-in-symbol-names`; rename it to what it actually is.
|
||||||
|
- Do not introduce a throw where the previous code degraded quietly. A parse failure on a path that used to fall back must keep falling back.
|
||||||
|
- Do not introduce a schema library, a new utility module, or a new architectural pattern as part of a lint cleanup.
|
||||||
|
- Do not edit `oxlint.config.ts` or `tools/oxlint/anti-slop/`.
|
||||||
|
- Do not edit `CHANGELOG.md`, package versions, or release metadata. This is internal maintenance with no user-facing change.
|
||||||
|
- Do not fix findings outside the selected files.
|
||||||
|
|
||||||
|
After edits, run:
|
||||||
|
|
||||||
|
`bun run deslop -- check-batch --run <run-id>`
|
||||||
|
|
||||||
|
Then validate the packages you actually touched, not the whole workspace. For each affected package run its own checks, for example:
|
||||||
|
|
||||||
|
`bun run --cwd packages/ui type-check`
|
||||||
|
|
||||||
|
`bun run --cwd packages/ui lint`
|
||||||
|
|
||||||
|
`bun run --cwd packages/ui test`
|
||||||
|
|
||||||
|
Workspace-wide `bun run type-check` and `bun run lint` are CI's job. Run them locally only when a change crosses package boundaries or touches shared contracts.
|
||||||
|
|
||||||
|
For files that TypeScript does not cover, such as server or CLI JavaScript, run the focused tests for that surface instead, for example `bun run --cwd packages/web test`.
|
||||||
|
|
||||||
|
Validation and delivery:
|
||||||
|
- Confirm selected files have fewer findings than before.
|
||||||
|
- Confirm `Findings outside selected files delta` is not positive. If it is, you introduced new findings elsewhere; fix them before continuing.
|
||||||
|
- If validation fails, fix failures only if the fixes stay within the task scope. Otherwise stop and report the blocker.
|
||||||
|
- Commit the changes with a concise message.
|
||||||
|
- Push the branch.
|
||||||
|
- Create exactly one PR with `gh pr create` using the exact printed `PR title`.
|
||||||
|
- After the PR is created, switch back to `main` and pull the latest remote changes again.
|
||||||
|
|
||||||
|
PR requirements:
|
||||||
|
- Use the exact printed `PR title`.
|
||||||
|
- Include the `Run ID`, `Batch name`, and `Branch name`.
|
||||||
|
- Include selected files.
|
||||||
|
- Include findings fixed according to `check-batch`.
|
||||||
|
- Include remaining findings in selected files.
|
||||||
|
- Include validation results for `check-batch` and every package-scoped type-check, lint, and test command you ran, naming the packages.
|
||||||
|
- Include a `Manual testing recommendations` section with focused checks for the changed behavior, based on the selected files and actual edits. Type-contract changes can alter runtime behavior at call sites, so name the affected surfaces concretely.
|
||||||
|
- Include any skipped findings and why.
|
||||||
|
- Include any `// SAFETY:` comment you added, with the invariant it documents.
|
||||||
|
- Include every parsing decision you introduced: what schema was added, and what now happens when input fails to parse. Reviewers must be able to see where behavior changed without reading the whole diff.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Keep the PR small and reviewable.
|
||||||
|
- Do not auto-merge.
|
||||||
|
- Do not modify unrelated files except minimal supporting changes required by selected-file fixes.
|
||||||
|
- Do not run broad formatting.
|
||||||
|
- Leave the batch's run directory intact after creating the PR. `next-batch` prints its location. That directory is both the handoff for the review follow-up task and the claim that stops another batch, including the React Doctor pipeline, from touching the same files. Deleting it early lets a parallel batch collide with this PR. Never delete it by hand; use `bun run deslop -- release --run <run-id>`.
|
||||||
|
- If you stop before creating a PR for any reason, release the claim with `bun run deslop -- release --run <run-id>` so the files return to the pool.
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
---
|
||||||
|
description: Follow up on an anti-slop PR by addressing review feedback
|
||||||
|
agent: build
|
||||||
|
---
|
||||||
|
|
||||||
|
You are working in the OpenChamber repository.
|
||||||
|
|
||||||
|
Goal: follow up on an existing anti-slop maintenance PR, address Greptile/review bot feedback, and clean up the local batch handoff files when done.
|
||||||
|
|
||||||
|
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||||
|
|
||||||
|
First, verify the worktree is safe to use:
|
||||||
|
|
||||||
|
`git status --porcelain`
|
||||||
|
|
||||||
|
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches.
|
||||||
|
|
||||||
|
List the active batches:
|
||||||
|
|
||||||
|
`bun run deslop -- active`
|
||||||
|
|
||||||
|
The listing may include batches owned by the React Doctor pipeline; those are shown as `[pipeline rd]`. Never touch them.
|
||||||
|
|
||||||
|
Workflow:
|
||||||
|
- If there are no active batches, stop and report that there is nothing to follow up.
|
||||||
|
- Each active batch corresponds to one open PR. Read its `batch.json` for `runId`, `branchName`, `batchName`, `prTitle`, and selected files.
|
||||||
|
- Use `gh` to find the open PR for each batch branch.
|
||||||
|
- Work on the oldest batch that has an open PR with unaddressed feedback. If several qualify, handle exactly one and leave the rest.
|
||||||
|
- If a batch's PR was already merged or closed, do not treat it as follow-up work. Release its claim with `bun run deslop -- release --run <run-id>` so its files return to the pool, then continue looking.
|
||||||
|
- If no batch has an open PR with actionable feedback, stop and report that.
|
||||||
|
- Switch to the batch branch using the exact `branchName`.
|
||||||
|
- Pull or update the branch from remote if needed.
|
||||||
|
- Use `gh` to inspect PR review comments, PR issue comments, review threads if available, and check run summaries if relevant.
|
||||||
|
- Focus specifically on Greptile/review bot feedback and actionable reviewer comments.
|
||||||
|
- Pay particular attention to comments questioning whether a type contract is now wrong, whether a `// SAFETY:` comment is accurate, or whether a call site was missed. These are the likely real defects in this kind of PR.
|
||||||
|
- Address actionable comments with minimal follow-up fixes.
|
||||||
|
- Keep changes within the original selected files whenever possible.
|
||||||
|
- If a review comment requires changes outside the selected files, make only the minimal required supporting change.
|
||||||
|
- Do not perform unrelated cleanup.
|
||||||
|
- Do not rewrite the original PR.
|
||||||
|
- Do not force-push.
|
||||||
|
- Do not disable, downgrade, or ignore anti-slop rules, and do not add `any`, widen a type, or add an assertion to satisfy a reviewer comment.
|
||||||
|
- Follow the same fix standards as the original batch task, described in `.opencode/commands/as-fixes.md` under "What a good fix looks like" and "Hard prohibitions". Read that section before editing. Review pressure is exactly when a laundered fix is most tempting.
|
||||||
|
|
||||||
|
After fixes, run:
|
||||||
|
|
||||||
|
`bun run deslop -- check-batch --run <run-id>`
|
||||||
|
|
||||||
|
Then re-run the package-scoped checks for the packages you touched, for example `bun run --cwd packages/ui type-check`, `bun run --cwd packages/ui lint`, and `bun run --cwd packages/ui test`. Workspace-wide checks are CI's job.
|
||||||
|
|
||||||
|
Delivery:
|
||||||
|
- Commit follow-up fixes with a concise message.
|
||||||
|
- Push the branch.
|
||||||
|
- Reply to addressed review comments using `gh`.
|
||||||
|
- For each specific review comment you addressed, reply with what was changed and the follow-up commit hash.
|
||||||
|
- If the feedback was a general PR comment, add one general PR comment summarizing what was addressed, commit hashes, and validation results.
|
||||||
|
- If a comment is intentionally not addressed, reply with a concise reason.
|
||||||
|
- Do not release the batch while its PR is still open and awaiting review. The claim is what keeps parallel batches off these files.
|
||||||
|
- Release the batch only once its PR has been merged or closed: `bun run deslop -- release --run <run-id>`.
|
||||||
|
- After the follow-up is complete, switch back to `main` and pull the latest remote changes.
|
||||||
|
|
||||||
|
Constraints:
|
||||||
|
- Work on exactly one anti-slop batch PR.
|
||||||
|
- Prefer the oldest batch with an open PR.
|
||||||
|
- Do not auto-merge.
|
||||||
|
- Do not close the PR.
|
||||||
|
- Do not edit `CHANGELOG.md`, package versions, or release metadata.
|
||||||
|
- Do not release or delete handoff directories for batches you did not handle.
|
||||||
|
- If validation fails and cannot be fixed safely within scope, leave the batch claimed and report the blocker.
|
||||||
@@ -7,12 +7,22 @@ You are working in the OpenChamber repository.
|
|||||||
|
|
||||||
Goal: reduce React Doctor diagnostics in a small, reviewable maintenance PR.
|
Goal: reduce React Doctor diagnostics in a small, reviewable maintenance PR.
|
||||||
|
|
||||||
Start by running:
|
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||||
|
|
||||||
|
First, verify the worktree is safe to use:
|
||||||
|
|
||||||
|
`git status --porcelain`
|
||||||
|
|
||||||
|
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, commit, or switch branches. Local work in progress must never end up in a maintenance PR.
|
||||||
|
|
||||||
|
Then run:
|
||||||
|
|
||||||
`bun run doctor -- next-batch --min-issues 75 --max-issues 120`
|
`bun run doctor -- next-batch --min-issues 75 --max-issues 120`
|
||||||
|
|
||||||
Use the command output as the source of truth for this task scope.
|
Use the command output as the source of truth for this task scope.
|
||||||
|
|
||||||
|
If the output contains `NO BATCH AVAILABLE`, stop immediately and report the printed reason. Do not create a branch, do not create a pull request, and do not look for other work. Concurrency is already handled: the command excludes files claimed by other active batches and refuses to exceed the active-batch limit.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
- Before generating the batch, switch to `main` and pull the latest remote changes.
|
- Before generating the batch, switch to `main` and pull the latest remote changes.
|
||||||
- Read the `next-batch` output carefully.
|
- Read the `next-batch` output carefully.
|
||||||
@@ -31,11 +41,15 @@ After edits, run:
|
|||||||
|
|
||||||
`bun run doctor -- check-batch --run <run-id>`
|
`bun run doctor -- check-batch --run <run-id>`
|
||||||
|
|
||||||
Then run:
|
Then validate the packages you actually touched, not the whole workspace. For each affected package run its own checks, for example:
|
||||||
|
|
||||||
`bun run type-check`
|
`bun run --cwd packages/ui type-check`
|
||||||
|
|
||||||
`bun run lint`
|
`bun run --cwd packages/ui lint`
|
||||||
|
|
||||||
|
`bun run --cwd packages/ui test`
|
||||||
|
|
||||||
|
Workspace-wide `bun run type-check` and `bun run lint` are CI's job. Run them locally only when a change crosses package boundaries or touches shared contracts. For files that TypeScript does not cover, such as server or CLI JavaScript, run the focused tests for that surface instead.
|
||||||
|
|
||||||
Validation and delivery:
|
Validation and delivery:
|
||||||
- Confirm selected files have fewer diagnostics than before.
|
- Confirm selected files have fewer diagnostics than before.
|
||||||
@@ -51,7 +65,7 @@ PR requirements:
|
|||||||
- Include selected files.
|
- Include selected files.
|
||||||
- Include diagnostics fixed according to `check-batch`.
|
- Include diagnostics fixed according to `check-batch`.
|
||||||
- Include remaining diagnostics in selected files.
|
- Include remaining diagnostics in selected files.
|
||||||
- Include validation results for `bun run type-check` and `bun run lint`.
|
- Include validation results for every package-scoped type-check, lint, and test command you ran, naming the packages.
|
||||||
- Include a `Manual testing recommendations` section with focused checks for the changed behavior. Base it on the selected files and actual edits, for example checking affected dropdowns, keyboard navigation, model/agent selection, settings controls, or mobile/desktop variants.
|
- Include a `Manual testing recommendations` section with focused checks for the changed behavior. Base it on the selected files and actual edits, for example checking affected dropdowns, keyboard navigation, model/agent selection, settings controls, or mobile/desktop variants.
|
||||||
- Include any skipped diagnostics and why.
|
- Include any skipped diagnostics and why.
|
||||||
|
|
||||||
@@ -61,4 +75,6 @@ Constraints:
|
|||||||
- Do not modify unrelated files except minimal supporting changes required by selected-file fixes.
|
- Do not modify unrelated files except minimal supporting changes required by selected-file fixes.
|
||||||
- Do not run broad formatting.
|
- Do not run broad formatting.
|
||||||
- Do not fix diagnostics outside the selected files.
|
- Do not fix diagnostics outside the selected files.
|
||||||
- Leave `.tmp/react-doctor/runs/<run-id>/` intact after creating the PR. These files are the handoff for the review follow-up task.
|
- Do not edit `CHANGELOG.md`, package versions, or release metadata. This is internal maintenance with no user-facing change.
|
||||||
|
- Leave the batch's run directory intact after creating the PR. `next-batch` prints its location. That directory is both the handoff for the review follow-up task and the claim that stops another batch, including the anti-slop pipeline, from touching the same files. Deleting it early lets a parallel batch collide with this PR. Never delete it by hand; use `bun run doctor -- release --run <run-id>`.
|
||||||
|
- If you stop before creating a PR for any reason, release the claim with `bun run doctor -- release --run <run-id>` so the files return to the pool.
|
||||||
|
|||||||
@@ -7,16 +7,27 @@ You are working in the OpenChamber repository.
|
|||||||
|
|
||||||
Goal: follow up on an existing React Doctor maintenance PR, address Greptile/review bot feedback, and clean up the local batch handoff files when done.
|
Goal: follow up on an existing React Doctor maintenance PR, address Greptile/review bot feedback, and clean up the local batch handoff files when done.
|
||||||
|
|
||||||
Inspect local React Doctor batch handoff files:
|
This task can run unattended on a schedule, so it must be safe to start at any moment and must stop cleanly when there is nothing to do.
|
||||||
|
|
||||||
`find .tmp/react-doctor/runs -maxdepth 2 -name batch.json -print 2>/dev/null || true`
|
First, verify the worktree is safe to use:
|
||||||
|
|
||||||
|
`git status --porcelain`
|
||||||
|
|
||||||
|
If the output is not empty, stop immediately and report that the worktree has uncommitted changes. Do not stash, reset, discard, or switch branches.
|
||||||
|
|
||||||
|
List the active batches:
|
||||||
|
|
||||||
|
`bun run doctor -- active`
|
||||||
|
|
||||||
|
The listing may include batches owned by the anti-slop pipeline; those are shown as `[pipeline as]`. Never touch them.
|
||||||
|
|
||||||
Workflow:
|
Workflow:
|
||||||
- Read the available `.tmp/react-doctor/runs/*/batch.json` files.
|
- If there are no active batches, stop and report that there is nothing to follow up.
|
||||||
- Find the most recent batch that has `branchName`, `batchName`, and `prTitle`.
|
- Each active batch corresponds to one open PR. Read its `batch.json` for `runId`, `branchName`, `batchName`, `prTitle`, and selected files.
|
||||||
- Read its `Run ID`, `Batch name`, `Branch name`, `PR title`, and selected files.
|
- Use `gh` to find the open PR for each batch branch.
|
||||||
- Use `gh` to find the open PR for that branch or title.
|
- Work on the oldest batch that has an open PR with unaddressed feedback. If several qualify, handle exactly one and leave the rest.
|
||||||
- If no open PR exists for the batch, stop and report that there is no PR to follow up.
|
- If a batch's PR was already merged or closed, do not treat it as follow-up work. Release its claim with `bun run doctor -- release --run <run-id>` so its files return to the pool, then continue looking.
|
||||||
|
- If no batch has an open PR with actionable feedback, stop and report that.
|
||||||
- Switch to the batch branch using the exact `branchName`.
|
- Switch to the batch branch using the exact `branchName`.
|
||||||
- Pull or update the branch from remote if needed.
|
- Pull or update the branch from remote if needed.
|
||||||
- Use `gh` to inspect PR review comments, PR issue comments, review threads if available, and check run summaries if relevant.
|
- Use `gh` to inspect PR review comments, PR issue comments, review threads if available, and check run summaries if relevant.
|
||||||
@@ -32,9 +43,7 @@ After fixes, run:
|
|||||||
|
|
||||||
`bun run doctor -- check-batch --run <run-id>`
|
`bun run doctor -- check-batch --run <run-id>`
|
||||||
|
|
||||||
`bun run type-check`
|
Then re-run the package-scoped checks for the packages you touched, for example `bun run --cwd packages/ui type-check`, `bun run --cwd packages/ui lint`, and `bun run --cwd packages/ui test`. Workspace-wide checks are CI's job.
|
||||||
|
|
||||||
`bun run lint`
|
|
||||||
|
|
||||||
Delivery:
|
Delivery:
|
||||||
- Commit follow-up fixes with a concise message.
|
- Commit follow-up fixes with a concise message.
|
||||||
@@ -43,14 +52,15 @@ Delivery:
|
|||||||
- For each specific review comment you addressed, reply with what was changed and the follow-up commit hash.
|
- For each specific review comment you addressed, reply with what was changed and the follow-up commit hash.
|
||||||
- If the feedback was a general PR comment, add one general PR comment summarizing what was addressed, commit hashes, and validation results.
|
- If the feedback was a general PR comment, add one general PR comment summarizing what was addressed, commit hashes, and validation results.
|
||||||
- If a comment is intentionally not addressed, reply with a concise reason.
|
- If a comment is intentionally not addressed, reply with a concise reason.
|
||||||
- After successful push and replies, delete only the completed batch handoff directory: `.tmp/react-doctor/runs/<run-id>/`.
|
- Do not release the batch while its PR is still open and awaiting review. The claim is what keeps parallel batches off these files.
|
||||||
|
- Release the batch only once its PR has been merged or closed: `bun run doctor -- release --run <run-id>`.
|
||||||
- After the follow-up is complete, switch back to `main` and pull the latest remote changes.
|
- After the follow-up is complete, switch back to `main` and pull the latest remote changes.
|
||||||
|
|
||||||
Constraints:
|
Constraints:
|
||||||
- Work on exactly one React Doctor batch PR.
|
- Work on exactly one React Doctor batch PR.
|
||||||
- Prefer the most recent batch with an open PR.
|
- Prefer the oldest batch with an open PR.
|
||||||
- Do not auto-merge.
|
- Do not auto-merge.
|
||||||
- Do not close the PR.
|
- Do not close the PR.
|
||||||
- Do not delete handoff files until comments are addressed, validation passes, and follow-up commits are pushed.
|
- Do not edit `CHANGELOG.md`, package versions, or release metadata.
|
||||||
- Do not delete unrelated `.tmp/react-doctor/runs/*` directories.
|
- Do not release or delete handoff directories for batches you did not handle.
|
||||||
- If validation fails and cannot be fixed safely within scope, do not delete the handoff directory.
|
- If validation fails and cannot be fixed safely within scope, leave the batch claimed and report the blocker.
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ Before adding guidance to a skill, identify its canonical owner. If another skil
|
|||||||
- Prefer focused tests and package-scoped type-check/lint for executable source changes.
|
- Prefer focused tests and package-scoped type-check/lint for executable source changes.
|
||||||
- Use workspace-wide checks for cross-workspace contracts, root tooling, dependencies, or shared generated assets.
|
- Use workspace-wide checks for cross-workspace contracts, root tooling, dependencies, or shared generated assets.
|
||||||
- Run `bun run dead-code` when source files are added/deleted/renamed or exports, types, entrypoints, or import shape change; inspect its report because it is non-blocking.
|
- Run `bun run dead-code` when source files are added/deleted/renamed or exports, types, entrypoints, or import shape change; inspect its report because it is non-blocking.
|
||||||
|
- Run `bunx oxlint <changed-paths>` on TypeScript/JavaScript files you created or substantially rewrote. This runs the vendored `anti-slop` plugin, which rejects low-evidence typing: unjustified type assertions, `unknown`/`object`/`Record<string, unknown>` contracts, ad hoc `typeof` narrowing, and module mocking. Fix findings in code you authored. Pre-existing findings elsewhere are a known backlog: do not mass-fix them, and never silence a rule, weaken severity, or launder types to make the check pass.
|
||||||
- Do not assume TypeScript/lint covers server JS, CLI JS, Electron helpers, or native behavior; run focused tests, syntax checks, builds, or runtime validation for the touched surface.
|
- Do not assume TypeScript/lint covers server JS, CLI JS, Electron helpers, or native behavior; run focused tests, syntax checks, builds, or runtime validation for the touched surface.
|
||||||
- For docs-only or isolated config changes, run the narrowest relevant validation.
|
- For docs-only or isolated config changes, run the narrowest relevant validation.
|
||||||
- Report exactly what was and was not validated. Static checks alone do not prove runtime, relay, performance, or platform correctness.
|
- Report exactly what was and was not validated. Static checks alone do not prove runtime, relay, performance, or platform correctness.
|
||||||
|
|||||||
@@ -65,6 +65,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clack/prompts": "^1.1.0",
|
"@clack/prompts": "^1.1.0",
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@oxlint/plugins": "1.78.0",
|
||||||
"@remixicon/react": "^4.7.0",
|
"@remixicon/react": "^4.7.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/dom-speech-recognition": "^0.0.12",
|
"@types/dom-speech-recognition": "^0.0.12",
|
||||||
@@ -83,6 +84,7 @@
|
|||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"node-addon-api": "7.1.1",
|
"node-addon-api": "7.1.1",
|
||||||
"nodemon": "^3.1.7",
|
"nodemon": "^3.1.7",
|
||||||
|
"oxlint": "1.78.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"sharp": "^0.35.0",
|
"sharp": "^0.35.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron": {
|
"packages/electron": {
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.18.2",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/web": "workspace:*",
|
"@openchamber/web": "workspace:*",
|
||||||
"electron-context-menu": "^4.1.2",
|
"electron-context-menu": "^4.1.2",
|
||||||
@@ -132,7 +134,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.18.2",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
"@aparajita/capacitor-secure-storage": "^8.0.0",
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
@@ -236,7 +238,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.18.2",
|
"version": "1.18.4",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "1.18.18",
|
"@opencode-ai/sdk": "1.18.18",
|
||||||
@@ -259,7 +261,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.18.2",
|
"version": "1.18.4",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -995,6 +997,46 @@
|
|||||||
|
|
||||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="],
|
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="],
|
||||||
|
|
||||||
|
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="],
|
||||||
|
|
||||||
|
"@oxlint/plugins": ["@oxlint/plugins@1.78.0", "", {}, "sha512-Ypt8KeRYw+4jUtlPirfcHWMrn5ms12VrrFPD+Mds477/7tJxG1Kcz2Yrg2nVcTQEUx/GdlhS+BUg1kmxNm04Ug=="],
|
||||||
|
|
||||||
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
"@paralleldrive/cuid2": ["@paralleldrive/cuid2@2.3.1", "", { "dependencies": { "@noble/hashes": "^1.1.5" } }, "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw=="],
|
||||||
|
|
||||||
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
|
"@peculiar/asn1-android": ["@peculiar/asn1-android@2.6.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.6.0", "asn1js": "^3.0.6", "tslib": "^2.8.1" } }, "sha512-cBRCKtYPF7vJGN76/yG8VbxRcHLPF3HnkoHhKOZeHpoVtbMYfY9ROKtH3DtYUY9m8uI1Mh47PRhHf2hSK3xcSQ=="],
|
||||||
@@ -2663,6 +2705,8 @@
|
|||||||
|
|
||||||
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
"own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="],
|
||||||
|
|
||||||
|
"oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="],
|
||||||
|
|
||||||
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
"p-cancelable": ["p-cancelable@2.1.1", "", {}, "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="],
|
||||||
|
|
||||||
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { defineConfig } from "oxlint";
|
||||||
|
|
||||||
|
// Oxlint here runs only the vendored anti-slop plugin; ESLint remains the
|
||||||
|
// general-purpose linter for this repository.
|
||||||
|
export default defineConfig({
|
||||||
|
categories: {
|
||||||
|
correctness: "off",
|
||||||
|
},
|
||||||
|
ignorePatterns: [
|
||||||
|
"**/node_modules/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/build/**",
|
||||||
|
"**/out/**",
|
||||||
|
"**/.next/**",
|
||||||
|
"**/ios/**",
|
||||||
|
"**/android/**",
|
||||||
|
".agents/**",
|
||||||
|
".claude/**",
|
||||||
|
".conductor/**",
|
||||||
|
".opencode/**",
|
||||||
|
".openchamber/**",
|
||||||
|
".tmp/**",
|
||||||
|
"patches/**",
|
||||||
|
"bun-patches/**",
|
||||||
|
"tools/oxlint/anti-slop/**",
|
||||||
|
],
|
||||||
|
jsPlugins: [
|
||||||
|
{ name: "anti-slop", specifier: "./tools/oxlint/anti-slop/index.ts" },
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
"anti-slop/no-chained-type-assertions": "error",
|
||||||
|
"anti-slop/no-conditional-empty-object-spread": "error",
|
||||||
|
"anti-slop/no-known-value-widening": "error",
|
||||||
|
"anti-slop/no-module-mocking": "error",
|
||||||
|
"anti-slop/no-object-parameters": "error",
|
||||||
|
"anti-slop/no-reflect-apply": "error",
|
||||||
|
"anti-slop/no-reflect-get": "error",
|
||||||
|
"anti-slop/no-runtime-typeof": "error",
|
||||||
|
"anti-slop/no-shape-in-symbol-names": "error",
|
||||||
|
"anti-slop/no-unknown-parameters": "error",
|
||||||
|
"anti-slop/no-unknown-returns": "error",
|
||||||
|
"anti-slop/no-unknown-type-aliases": "error",
|
||||||
|
"anti-slop/no-unsafe-dictionary-type": "error",
|
||||||
|
"anti-slop/no-widen-then-assert": "error",
|
||||||
|
"anti-slop/require-safety-comment-for-type-assertion": "error",
|
||||||
|
},
|
||||||
|
});
|
||||||
+5
-1
@@ -38,6 +38,7 @@
|
|||||||
"lint:ui": "bun run --cwd packages/ui lint",
|
"lint:ui": "bun run --cwd packages/ui lint",
|
||||||
"lint:electron": "bun run --cwd packages/electron lint",
|
"lint:electron": "bun run --cwd packages/electron lint",
|
||||||
"lint:mobile": "bun run --cwd packages/mobile lint",
|
"lint:mobile": "bun run --cwd packages/mobile lint",
|
||||||
|
"lint:anti-slop": "oxlint",
|
||||||
"test": "node scripts/run-isolated-tests.mjs scripts && bun run --cwd packages/ui test && bun run --cwd packages/vscode test && bun run --cwd packages/electron test && bun run --cwd packages/web test",
|
"test": "node scripts/run-isolated-tests.mjs scripts && bun run --cwd packages/ui test && bun run --cwd packages/vscode test && bun run --cwd packages/electron test && bun run --cwd packages/web test",
|
||||||
"clean": "bun run --filter '*' clean",
|
"clean": "bun run --filter '*' clean",
|
||||||
"changelog-card": "node scripts/changelog-card/generate.mjs",
|
"changelog-card": "node scripts/changelog-card/generate.mjs",
|
||||||
@@ -74,6 +75,7 @@
|
|||||||
"docs:validate": "node scripts/docs/validate-docs.mjs",
|
"docs:validate": "node scripts/docs/validate-docs.mjs",
|
||||||
"dead-code": "bunx knip@5.80.0 --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,duplicates",
|
"dead-code": "bunx knip@5.80.0 --no-exit-code --include files,exports,nsExports,types,nsTypes,enumMembers,duplicates",
|
||||||
"doctor": "node scripts/react-doctor.mjs",
|
"doctor": "node scripts/react-doctor.mjs",
|
||||||
|
"deslop": "node scripts/anti-slop.mjs",
|
||||||
"profile:browser": "node scripts/profile-browser.mjs",
|
"profile:browser": "node scripts/profile-browser.mjs",
|
||||||
"icons:sprite": "node scripts/generate-file-type-sprite.mjs",
|
"icons:sprite": "node scripts/generate-file-type-sprite.mjs",
|
||||||
"icons:generate": "bun run scripts/generate-icon-sprite.mjs",
|
"icons:generate": "bun run scripts/generate-icon-sprite.mjs",
|
||||||
@@ -152,6 +154,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@clack/prompts": "^1.1.0",
|
"@clack/prompts": "^1.1.0",
|
||||||
"@eslint/js": "^9.33.0",
|
"@eslint/js": "^9.33.0",
|
||||||
|
"@oxlint/plugins": "1.78.0",
|
||||||
|
"@remixicon/react": "^4.7.0",
|
||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/dom-speech-recognition": "^0.0.12",
|
"@types/dom-speech-recognition": "^0.0.12",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
@@ -169,8 +173,8 @@
|
|||||||
"globals": "^16.3.0",
|
"globals": "^16.3.0",
|
||||||
"node-addon-api": "7.1.1",
|
"node-addon-api": "7.1.1",
|
||||||
"nodemon": "^3.1.7",
|
"nodemon": "^3.1.7",
|
||||||
|
"oxlint": "1.78.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"@remixicon/react": "^4.7.0",
|
|
||||||
"sharp": "^0.35.0",
|
"sharp": "^0.35.0",
|
||||||
"tailwindcss": "^4.0.0",
|
"tailwindcss": "^4.0.0",
|
||||||
"tsx": "^4.20.6",
|
"tsx": "^4.20.6",
|
||||||
|
|||||||
@@ -0,0 +1,540 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
claimedFilePaths,
|
||||||
|
printClaims,
|
||||||
|
readActiveClaims,
|
||||||
|
releaseRun,
|
||||||
|
resolveRunsDir,
|
||||||
|
runDirPath,
|
||||||
|
} from "./lib/batch-claims.mjs";
|
||||||
|
|
||||||
|
const PIPELINE = "as";
|
||||||
|
// Resolved before command dispatch so every command shares one claims location.
|
||||||
|
const { runsDir: RUNS_DIR, shared: SHARED_CLAIMS } = resolveRunsDir(parseArgs(process.argv.slice(2))["claims-dir"]);
|
||||||
|
const DEFAULT_MAX_ACTIVE = 3;
|
||||||
|
const DEFAULT_CLAIM_TTL_DAYS = 3;
|
||||||
|
|
||||||
|
// Rules ordered by how mechanical and behavior-safe their fixes are. Higher
|
||||||
|
// scores are preferred when selecting the next batch.
|
||||||
|
const PRIORITY_RULES = new Map([
|
||||||
|
["no-object-parameters", 100],
|
||||||
|
["no-shape-in-symbol-names", 95],
|
||||||
|
["no-unknown-type-aliases", 90],
|
||||||
|
["no-unknown-returns", 85],
|
||||||
|
["no-unknown-parameters", 80],
|
||||||
|
["no-unsafe-dictionary-type", 75],
|
||||||
|
["no-conditional-empty-object-spread", 70],
|
||||||
|
["no-known-value-widening", 65],
|
||||||
|
["no-chained-type-assertions", 60],
|
||||||
|
["no-widen-then-assert", 55],
|
||||||
|
["no-reflect-get", 50],
|
||||||
|
["no-reflect-apply", 50],
|
||||||
|
["no-module-mocking", 30],
|
||||||
|
["require-safety-comment-for-type-assertion", 20],
|
||||||
|
["no-runtime-typeof", 10],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Excluded by default because they account for most of the existing backlog and
|
||||||
|
// their fixes are the least mechanical. Opt in with --include-noisy.
|
||||||
|
const NOISY_RULES = new Set(["no-runtime-typeof", "require-safety-comment-for-type-assertion"]);
|
||||||
|
|
||||||
|
function usage(exitCode = 0) {
|
||||||
|
const out = exitCode === 0 ? console.log : console.error;
|
||||||
|
out(`Usage:
|
||||||
|
bun run deslop -- next-batch [--min-issues 25] [--max-issues 60] [--max-files 4]
|
||||||
|
[--max-active ${DEFAULT_MAX_ACTIVE}] [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}] [--include-noisy]
|
||||||
|
bun run deslop -- check-batch --run <run-id>
|
||||||
|
bun run deslop -- active [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
|
||||||
|
|
||||||
|
Every command accepts --claims-dir <path> to isolate a working copy.
|
||||||
|
bun run deslop -- release --run <run-id>
|
||||||
|
bun run deslop -- file <path> [--include-noisy]
|
||||||
|
bun run deslop -- top [--limit 10] [--include-noisy]
|
||||||
|
|
||||||
|
Files selected by an active batch are excluded from later batches, so concurrent
|
||||||
|
batches never touch the same file, including batches created by the React Doctor
|
||||||
|
pipeline. Claims are shared across clones by default. A batch stays active until
|
||||||
|
it is released.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bun run deslop -- next-batch --min-issues 25 --max-issues 60
|
||||||
|
bun run deslop -- file packages/ui/src/lib/settings/metadata.ts
|
||||||
|
bun run deslop -- check-batch --run 2026-08-16T10-12-44Z
|
||||||
|
bun run deslop -- release --run 2026-08-16T10-12-44Z`);
|
||||||
|
process.exit(exitCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = { _: [] };
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
args._.push(arg);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = arg.slice(2);
|
||||||
|
const next = argv[i + 1];
|
||||||
|
if (!next || next.startsWith("--")) {
|
||||||
|
args[key] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
args[key] = next;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asPositiveInt(value, fallback, name) {
|
||||||
|
if (value === undefined) return fallback;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isInteger(parsed) || parsed <= 0) {
|
||||||
|
throw new Error(`Invalid --${name}: expected a positive integer.`);
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function runOxlint() {
|
||||||
|
// Oxlint exits non-zero whenever it reports findings, so the report has to be
|
||||||
|
// read from stdout of the failed invocation rather than treated as an error.
|
||||||
|
let output;
|
||||||
|
try {
|
||||||
|
output = execFileSync("bunx", ["oxlint", "--format", "json"], {
|
||||||
|
cwd: process.cwd(),
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 256 * 1024 * 1024,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// The utf8 encoding above makes stdout a string whenever the run produced
|
||||||
|
// a report; an empty stdout means the run itself failed.
|
||||||
|
if (!error.stdout) throw error;
|
||||||
|
output = error.stdout;
|
||||||
|
}
|
||||||
|
const report = JSON.parse(output);
|
||||||
|
return { diagnostics: normalizeDiagnostics(report.diagnostics ?? []) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ruleOf(code) {
|
||||||
|
const match = /^anti-slop\((.+)\)$/.exec(code ?? "");
|
||||||
|
return match ? match[1] : (code ?? "unknown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDiagnostics(rawDiagnostics) {
|
||||||
|
return rawDiagnostics.map((diagnostic) => {
|
||||||
|
const span = diagnostic.labels?.[0]?.span;
|
||||||
|
return {
|
||||||
|
filePath: diagnostic.filename,
|
||||||
|
rule: ruleOf(diagnostic.code),
|
||||||
|
severity: diagnostic.severity ?? "error",
|
||||||
|
message: diagnostic.message,
|
||||||
|
line: span?.line,
|
||||||
|
column: span?.column,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectableDiagnostics(report, includeNoisy) {
|
||||||
|
if (includeNoisy) return report.diagnostics;
|
||||||
|
return report.diagnostics.filter((diagnostic) => !NOISY_RULES.has(diagnostic.rule));
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupByFile(diagnostics) {
|
||||||
|
const byFile = new Map();
|
||||||
|
for (const diagnostic of diagnostics) {
|
||||||
|
const list = byFile.get(diagnostic.filePath) ?? [];
|
||||||
|
list.push(diagnostic);
|
||||||
|
byFile.set(diagnostic.filePath, list);
|
||||||
|
}
|
||||||
|
return byFile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rulePriority(rule) {
|
||||||
|
return PRIORITY_RULES.get(rule) ?? 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filePriority(diagnostics) {
|
||||||
|
const score = diagnostics.reduce((sum, diagnostic) => sum + rulePriority(diagnostic.rule), 0);
|
||||||
|
const mechanicalCount = diagnostics.filter((diagnostic) => rulePriority(diagnostic.rule) >= 75).length;
|
||||||
|
return score + mechanicalCount * 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortedFileEntries(diagnostics) {
|
||||||
|
return [...groupByFile(diagnostics).entries()].sort((a, b) => {
|
||||||
|
const scoreDiff = filePriority(b[1]) - filePriority(a[1]);
|
||||||
|
if (scoreDiff !== 0) return scoreDiff;
|
||||||
|
const countDiff = b[1].length - a[1].length;
|
||||||
|
if (countDiff !== 0) return countDiff;
|
||||||
|
return a[0].localeCompare(b[0]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarizeRules(diagnostics) {
|
||||||
|
const counts = new Map();
|
||||||
|
for (const diagnostic of diagnostics) {
|
||||||
|
counts.set(diagnostic.rule, (counts.get(diagnostic.rule) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRunId() {
|
||||||
|
return new Date().toISOString().replace(/:/g, "-").replace(/\.\d{3}Z$/, "Z");
|
||||||
|
}
|
||||||
|
|
||||||
|
function titleCase(value) {
|
||||||
|
return value
|
||||||
|
.replace(/[-_]+/g, " ")
|
||||||
|
.replace(/\b\w/g, (char) => char.toUpperCase())
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileNameWithoutExtension(filePath) {
|
||||||
|
const fileName = filePath.split("/").at(-1) ?? filePath;
|
||||||
|
return fileName.replace(/\.[^.]+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function slugify(value) {
|
||||||
|
return value
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBatchMetadata(runId, selectedFiles) {
|
||||||
|
const [datePart, timePart = ""] = runId.replace(/Z$/, "").split("T");
|
||||||
|
const timestamp = `${datePart.replace(/-/g, "")}-${timePart.replace(/-/g, "")}`;
|
||||||
|
const stems = selectedFiles.map((file) => fileNameWithoutExtension(file.filePath));
|
||||||
|
const readableArea = stems.length === 1
|
||||||
|
? stems[0]
|
||||||
|
: `${stems.slice(0, 2).join(" and ")}${stems.length > 2 ? ` plus ${stems.length - 2}` : ""}`;
|
||||||
|
const areaSlug = slugify(stems.slice(0, 3).join("-")) || "batch";
|
||||||
|
const batchName = `as-${timestamp}-${areaSlug}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
batchName,
|
||||||
|
branchName: `anti-slop/${batchName}`,
|
||||||
|
prTitle: `Reduce anti-slop findings in ${titleCase(readableArea)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectBatch(entries, minIssues, maxIssues, maxFiles) {
|
||||||
|
if (entries.length === 0) {
|
||||||
|
return { selected: [], oversized: false, belowTarget: false, reason: "No findings available for selection." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstFitting = entries.find(([, diagnostics]) => diagnostics.length >= minIssues && diagnostics.length <= maxIssues);
|
||||||
|
if (firstFitting) {
|
||||||
|
return {
|
||||||
|
selected: [firstFitting],
|
||||||
|
oversized: false,
|
||||||
|
belowTarget: false,
|
||||||
|
reason: "A prioritized file already fits the target window.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const oversized = entries.find(([, diagnostics]) => diagnostics.length > maxIssues);
|
||||||
|
if (oversized) {
|
||||||
|
return {
|
||||||
|
selected: [oversized],
|
||||||
|
oversized: true,
|
||||||
|
belowTarget: false,
|
||||||
|
reason: "A prioritized file exceeds the target window and was selected as a single complete-file batch.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selected = [];
|
||||||
|
let total = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (selected.length >= maxFiles) break;
|
||||||
|
const count = entry[1].length;
|
||||||
|
if (total + count > maxIssues) {
|
||||||
|
if (total >= minIssues) break;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
selected.push(entry);
|
||||||
|
total += count;
|
||||||
|
if (total >= minIssues) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.length > 0) {
|
||||||
|
return {
|
||||||
|
selected,
|
||||||
|
oversized: false,
|
||||||
|
belowTarget: total < minIssues,
|
||||||
|
reason: total >= minIssues
|
||||||
|
? "Added complete files until the batch reached the target window."
|
||||||
|
: "No combination reached the minimum without exceeding the maximum; selected the best smaller complete-file batch.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
selected: [entries[0]],
|
||||||
|
oversized: false,
|
||||||
|
belowTarget: entries[0][1].length < minIssues,
|
||||||
|
reason: "Selected the best available complete file below the target window.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeRun(runId, payload) {
|
||||||
|
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(join(dir, "baseline.json"), `${JSON.stringify(payload.report, null, 2)}\n`);
|
||||||
|
writeFileSync(join(dir, "batch.json"), `${JSON.stringify(payload.batch, null, 2)}\n`);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readRun(runId) {
|
||||||
|
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
|
||||||
|
const baselinePath = join(dir, "baseline.json");
|
||||||
|
const batchPath = join(dir, "batch.json");
|
||||||
|
if (!existsSync(baselinePath) || !existsSync(batchPath)) {
|
||||||
|
throw new Error(`Unknown run: ${runId}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
baseline: JSON.parse(readFileSync(baselinePath, "utf8")),
|
||||||
|
batch: JSON.parse(readFileSync(batchPath, "utf8")),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function printReportHeader(report) {
|
||||||
|
const total = report.diagnostics.length;
|
||||||
|
const affected = groupByFile(report.diagnostics).size;
|
||||||
|
const noisy = report.diagnostics.filter((diagnostic) => NOISY_RULES.has(diagnostic.rule)).length;
|
||||||
|
console.log(`Total findings: ${total} across ${affected} files`);
|
||||||
|
console.log(`Excluded-by-default findings: ${noisy} (${[...NOISY_RULES].join(", ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandNextBatch(args) {
|
||||||
|
const minIssues = asPositiveInt(args["min-issues"], 25, "min-issues");
|
||||||
|
const maxIssues = asPositiveInt(args["max-issues"], 60, "max-issues");
|
||||||
|
const maxFiles = asPositiveInt(args["max-files"], 4, "max-files");
|
||||||
|
if (minIssues > maxIssues) throw new Error("--min-issues cannot be greater than --max-issues.");
|
||||||
|
const maxActive = asPositiveInt(args["max-active"], DEFAULT_MAX_ACTIVE, "max-active");
|
||||||
|
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
|
||||||
|
const includeNoisy = args["include-noisy"] === true;
|
||||||
|
|
||||||
|
const claims = readActiveClaims(RUNS_DIR, claimTtlDays);
|
||||||
|
if (claims.length >= maxActive) {
|
||||||
|
console.log("Anti-Slop Next Batch");
|
||||||
|
console.log("");
|
||||||
|
console.log("NO BATCH AVAILABLE");
|
||||||
|
console.log(`Reason: ${claims.length} active batches already exist and the limit is ${maxActive}.`);
|
||||||
|
console.log("Stop here. Do not create a branch or a pull request.");
|
||||||
|
console.log("");
|
||||||
|
printClaims(claims, PIPELINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const report = runOxlint();
|
||||||
|
const claimedPaths = claimedFilePaths(claims);
|
||||||
|
const candidates = selectableDiagnostics(report, includeNoisy)
|
||||||
|
.filter((diagnostic) => !claimedPaths.has(diagnostic.filePath));
|
||||||
|
const entries = sortedFileEntries(candidates);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log("Anti-Slop Next Batch");
|
||||||
|
console.log("");
|
||||||
|
console.log("NO BATCH AVAILABLE");
|
||||||
|
console.log("Reason: no unclaimed findings remain for the selected rules.");
|
||||||
|
console.log("Stop here. Do not create a branch or a pull request.");
|
||||||
|
console.log("");
|
||||||
|
printClaims(claims, PIPELINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const selection = selectBatch(entries, minIssues, maxIssues, maxFiles);
|
||||||
|
const runId = createRunId();
|
||||||
|
const selectedFiles = selection.selected.map(([filePath, fileDiagnostics]) => ({
|
||||||
|
filePath,
|
||||||
|
diagnosticCount: fileDiagnostics.length,
|
||||||
|
rules: summarizeRules(fileDiagnostics),
|
||||||
|
}));
|
||||||
|
const metadata = createBatchMetadata(runId, selectedFiles);
|
||||||
|
const batch = {
|
||||||
|
runId,
|
||||||
|
...metadata,
|
||||||
|
minIssues,
|
||||||
|
maxIssues,
|
||||||
|
maxFiles,
|
||||||
|
maxActive,
|
||||||
|
includeNoisy,
|
||||||
|
selectedFiles,
|
||||||
|
oversized: selection.oversized,
|
||||||
|
belowTarget: selection.belowTarget,
|
||||||
|
reason: selection.reason,
|
||||||
|
};
|
||||||
|
const runDir = writeRun(runId, { report, batch });
|
||||||
|
|
||||||
|
console.log("Anti-Slop Next Batch");
|
||||||
|
console.log("");
|
||||||
|
console.log(`Run ID: ${runId}`);
|
||||||
|
console.log(`Batch name: ${batch.batchName}`);
|
||||||
|
console.log(`Branch name: ${batch.branchName}`);
|
||||||
|
console.log(`PR title: ${batch.prTitle}`);
|
||||||
|
console.log(`Baseline: ${join(runDir, "baseline.json")}`);
|
||||||
|
console.log(`Batch metadata: ${join(runDir, "batch.json")}`);
|
||||||
|
console.log("");
|
||||||
|
printReportHeader(report);
|
||||||
|
console.log("");
|
||||||
|
console.log(`Batch window: ${minIssues}-${maxIssues} findings`);
|
||||||
|
console.log(`Noisy rules included: ${includeNoisy ? "yes" : "no"}`);
|
||||||
|
console.log(`Active batches before this one: ${claims.length} of ${maxActive}`);
|
||||||
|
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
|
||||||
|
console.log(`Files excluded as claimed by active batches: ${claimedPaths.size}`);
|
||||||
|
console.log(`Selection mode: complete files only`);
|
||||||
|
console.log(`Batch total: ${selectedFiles.reduce((sum, file) => sum + file.diagnosticCount, 0)} findings`);
|
||||||
|
console.log(`Oversized: ${selection.oversized ? "yes" : "no"}`);
|
||||||
|
console.log(`Below target: ${selection.belowTarget ? "yes" : "no"}`);
|
||||||
|
console.log(`Selection reason: ${selection.reason}`);
|
||||||
|
console.log("");
|
||||||
|
console.log("Selected files:");
|
||||||
|
selection.selected.forEach(([filePath, fileDiagnostics], index) => {
|
||||||
|
console.log(`${index + 1}. ${filePath}`);
|
||||||
|
console.log(` Findings: ${fileDiagnostics.length}`);
|
||||||
|
console.log(" Rules:");
|
||||||
|
for (const [rule, count] of summarizeRules(fileDiagnostics)) {
|
||||||
|
console.log(` ${String(count).padStart(3)} ${rule}`);
|
||||||
|
}
|
||||||
|
console.log(" Findings detail:");
|
||||||
|
for (const diagnostic of fileDiagnostics) {
|
||||||
|
console.log(` line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.rule}`);
|
||||||
|
console.log(` ${diagnostic.message}`);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandActive(args) {
|
||||||
|
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
|
||||||
|
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
|
||||||
|
printClaims(readActiveClaims(RUNS_DIR, claimTtlDays), PIPELINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandRelease(args) {
|
||||||
|
const runId = args.run;
|
||||||
|
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
|
||||||
|
const dir = releaseRun(RUNS_DIR, PIPELINE, runId);
|
||||||
|
console.log(`Released batch ${runId}`);
|
||||||
|
console.log(`Removed ${dir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandTop(args) {
|
||||||
|
const limit = asPositiveInt(args.limit, 10, "limit");
|
||||||
|
const includeNoisy = args["include-noisy"] === true;
|
||||||
|
const report = runOxlint();
|
||||||
|
const entries = sortedFileEntries(selectableDiagnostics(report, includeNoisy)).slice(0, limit);
|
||||||
|
console.log(`Top ${limit} files by prioritized anti-slop findings`);
|
||||||
|
console.log("");
|
||||||
|
for (const [filePath, diagnostics] of entries) {
|
||||||
|
console.log(`${String(diagnostics.length).padStart(4)} ${filePath}`);
|
||||||
|
console.log(` ${summarizeRules(diagnostics).map(([rule, count]) => `${rule} ${count}`).join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandFile(args) {
|
||||||
|
const filePath = args._[1];
|
||||||
|
if (!filePath) throw new Error("Missing file path. Usage: bun run deslop -- file <path>");
|
||||||
|
const includeNoisy = args["include-noisy"] === true;
|
||||||
|
const report = runOxlint();
|
||||||
|
const diagnostics = groupByFile(selectableDiagnostics(report, includeNoisy)).get(filePath) ?? [];
|
||||||
|
console.log(filePath);
|
||||||
|
console.log(`${diagnostics.length} findings`);
|
||||||
|
console.log("");
|
||||||
|
if (diagnostics.length === 0) return;
|
||||||
|
console.log("Rules:");
|
||||||
|
for (const [rule, count] of summarizeRules(diagnostics)) {
|
||||||
|
console.log(`${String(count).padStart(4)} ${rule}`);
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
console.log("Findings:");
|
||||||
|
for (const diagnostic of diagnostics) {
|
||||||
|
console.log(`line ${diagnostic.line ?? "?"}:${diagnostic.column ?? "?"} ${diagnostic.severity} ${diagnostic.rule}`);
|
||||||
|
console.log(` ${diagnostic.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandCheckBatch(args) {
|
||||||
|
const runId = args.run;
|
||||||
|
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
|
||||||
|
const { baseline, batch } = readRun(runId);
|
||||||
|
const current = runOxlint();
|
||||||
|
const includeNoisy = batch.includeNoisy === true;
|
||||||
|
const beforeDiagnostics = selectableDiagnostics(baseline, includeNoisy);
|
||||||
|
const afterDiagnostics = selectableDiagnostics(current, includeNoisy);
|
||||||
|
const beforeByFile = groupByFile(beforeDiagnostics);
|
||||||
|
const afterByFile = groupByFile(afterDiagnostics);
|
||||||
|
const selected = batch.selectedFiles ?? [];
|
||||||
|
let beforeTotal = 0;
|
||||||
|
let afterTotal = 0;
|
||||||
|
|
||||||
|
console.log("Anti-Slop Batch Check");
|
||||||
|
console.log("");
|
||||||
|
console.log(`Run ID: ${runId}`);
|
||||||
|
if (batch.batchName) console.log(`Batch name: ${batch.batchName}`);
|
||||||
|
if (batch.branchName) console.log(`Branch name: ${batch.branchName}`);
|
||||||
|
if (batch.prTitle) console.log(`PR title: ${batch.prTitle}`);
|
||||||
|
console.log("");
|
||||||
|
console.log("Selected files:");
|
||||||
|
for (const file of selected) {
|
||||||
|
const before = beforeByFile.get(file.filePath)?.length ?? 0;
|
||||||
|
const after = afterByFile.get(file.filePath)?.length ?? 0;
|
||||||
|
beforeTotal += before;
|
||||||
|
afterTotal += after;
|
||||||
|
console.log(file.filePath);
|
||||||
|
console.log(` Before: ${before}`);
|
||||||
|
console.log(` After: ${after}`);
|
||||||
|
console.log(` Delta: ${after - before}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPaths = new Set(selected.map((file) => file.filePath));
|
||||||
|
const beforeOutside = beforeDiagnostics.filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
|
||||||
|
const afterOutside = afterDiagnostics.filter((diagnostic) => !selectedPaths.has(diagnostic.filePath)).length;
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
console.log("Batch result:");
|
||||||
|
console.log(`Fixed findings in selected files: ${Math.max(0, beforeTotal - afterTotal)}`);
|
||||||
|
console.log(`Remaining findings in selected files: ${afterTotal}`);
|
||||||
|
console.log(`Findings outside selected files delta: ${afterOutside - beforeOutside}`);
|
||||||
|
console.log("");
|
||||||
|
console.log("Current repository summary:");
|
||||||
|
printReportHeader(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const command = args._[0];
|
||||||
|
if (!command || command === "help" || args.help) usage(0);
|
||||||
|
|
||||||
|
switch (command) {
|
||||||
|
case "next-batch":
|
||||||
|
commandNextBatch(args);
|
||||||
|
break;
|
||||||
|
case "top":
|
||||||
|
commandTop(args);
|
||||||
|
break;
|
||||||
|
case "file":
|
||||||
|
commandFile(args);
|
||||||
|
break;
|
||||||
|
case "check-batch":
|
||||||
|
commandCheckBatch(args);
|
||||||
|
break;
|
||||||
|
case "active":
|
||||||
|
commandActive(args);
|
||||||
|
break;
|
||||||
|
case "release":
|
||||||
|
commandRelease(args);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown command: ${command}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { readdirSync, readFileSync, rmSync, existsSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
// Batch handoff directories double as file claims. A run directory exists from
|
||||||
|
// the moment its batch is generated until its follow-up task releases it, so
|
||||||
|
// concurrent maintenance batches can be kept file-disjoint.
|
||||||
|
//
|
||||||
|
// Maintenance pipelines are expected to run from dedicated clones of the same
|
||||||
|
// repository, so claims live outside the working copy by default. Every clone
|
||||||
|
// and every pipeline therefore sees the same claims without any per-scheduler
|
||||||
|
// configuration. Override with --claims-dir or OPENCHAMBER_BATCH_CLAIMS_DIR
|
||||||
|
// only when a working copy must be isolated, for example while experimenting.
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
const SHARED_CLAIMS_ENV = "OPENCHAMBER_BATCH_CLAIMS_DIR";
|
||||||
|
const DEFAULT_CLAIMS_DIR = join(homedir(), ".openchamber", "maintenance-claims");
|
||||||
|
|
||||||
|
function expandHome(path) {
|
||||||
|
if (path === "~") return homedir();
|
||||||
|
if (path.startsWith("~/")) return join(homedir(), path.slice(2));
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRunsDir(claimsDirArgument) {
|
||||||
|
const override = claimsDirArgument ?? process.env[SHARED_CLAIMS_ENV];
|
||||||
|
if (override !== undefined && override !== true) {
|
||||||
|
return { runsDir: join(expandHome(override), "runs"), shared: false };
|
||||||
|
}
|
||||||
|
return { runsDir: join(DEFAULT_CLAIMS_DIR, "runs"), shared: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runDirName(pipeline, runId) {
|
||||||
|
return `${pipeline}-${runId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runDirPath(runsDir, pipeline, runId) {
|
||||||
|
return join(runsDir, runDirName(pipeline, runId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDirName(dirName) {
|
||||||
|
const match = /^([a-z]+)-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}Z?)$/.exec(dirName);
|
||||||
|
if (!match) return undefined;
|
||||||
|
const [, pipeline, stamp] = match;
|
||||||
|
const [date, time] = stamp.replace(/Z$/, "").split("T");
|
||||||
|
const createdAt = Date.parse(`${date}T${time.replace(/-/g, ":")}Z`);
|
||||||
|
return { pipeline, runId: stamp, createdAt: Number.isNaN(createdAt) ? undefined : createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readActiveClaims(runsDir, claimTtlDays) {
|
||||||
|
if (!existsSync(runsDir)) return [];
|
||||||
|
const now = Date.now();
|
||||||
|
const claims = [];
|
||||||
|
|
||||||
|
for (const entry of readdirSync(runsDir, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const parsed = parseDirName(entry.name);
|
||||||
|
if (!parsed) continue;
|
||||||
|
|
||||||
|
const batchPath = join(runsDir, entry.name, "batch.json");
|
||||||
|
if (!existsSync(batchPath)) continue;
|
||||||
|
|
||||||
|
let batch;
|
||||||
|
try {
|
||||||
|
batch = JSON.parse(readFileSync(batchPath, "utf8"));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const expired = parsed.createdAt !== undefined && now - parsed.createdAt > claimTtlDays * DAY_MS;
|
||||||
|
if (expired) continue;
|
||||||
|
|
||||||
|
claims.push({
|
||||||
|
pipeline: parsed.pipeline,
|
||||||
|
runId: batch.runId ?? parsed.runId,
|
||||||
|
branchName: batch.branchName,
|
||||||
|
createdAt: parsed.createdAt,
|
||||||
|
filePaths: (batch.selectedFiles ?? []).map((file) => file.filePath),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function claimedFilePaths(claims) {
|
||||||
|
return new Set(claims.flatMap((claim) => claim.filePaths));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function releaseRun(runsDir, pipeline, runId) {
|
||||||
|
const dir = runDirPath(runsDir, pipeline, runId);
|
||||||
|
if (!existsSync(dir)) throw new Error(`Unknown run: ${runId}`);
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function printClaims(claims, ownPipeline) {
|
||||||
|
if (claims.length === 0) {
|
||||||
|
console.log("Active batches: none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.log(`Active batches: ${claims.length}`);
|
||||||
|
for (const claim of claims) {
|
||||||
|
const owner = claim.pipeline === ownPipeline ? "this pipeline" : `pipeline ${claim.pipeline}`;
|
||||||
|
console.log(` ${claim.runId} ${claim.branchName ?? "(no branch)"} [${owner}]`);
|
||||||
|
for (const filePath of claim.filePaths) console.log(` ${filePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,24 @@ import { execFileSync } from "node:child_process";
|
|||||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import {
|
||||||
|
claimedFilePaths,
|
||||||
|
printClaims,
|
||||||
|
readActiveClaims,
|
||||||
|
releaseRun,
|
||||||
|
resolveRunsDir,
|
||||||
|
runDirPath,
|
||||||
|
} from "./lib/batch-claims.mjs";
|
||||||
|
|
||||||
const PROJECT_NAME = "openchamber-monorepo";
|
const PROJECT_NAME = "openchamber-monorepo";
|
||||||
const RUNS_DIR = join(process.cwd(), ".tmp", "react-doctor", "runs");
|
// Pinned so unattended batch runs cannot change diagnostics or output shape
|
||||||
|
// without an explicit update here.
|
||||||
|
const REACT_DOCTOR_VERSION = "0.9.12";
|
||||||
|
const PIPELINE = "rd";
|
||||||
|
// Resolved before command dispatch so every command shares one claims location.
|
||||||
|
const { runsDir: RUNS_DIR, shared: SHARED_CLAIMS } = resolveRunsDir(parseArgs(process.argv.slice(2))["claims-dir"]);
|
||||||
|
const DEFAULT_MAX_ACTIVE = 3;
|
||||||
|
const DEFAULT_CLAIM_TTL_DAYS = 3;
|
||||||
|
|
||||||
const PRIORITY_RULES = new Map([
|
const PRIORITY_RULES = new Map([
|
||||||
["effect-needs-cleanup", 100],
|
["effect-needs-cleanup", 100],
|
||||||
@@ -88,14 +104,25 @@ function usage(exitCode = 0) {
|
|||||||
const out = exitCode === 0 ? console.log : console.error;
|
const out = exitCode === 0 ? console.log : console.error;
|
||||||
out(`Usage:
|
out(`Usage:
|
||||||
bun run doctor -- next-batch [--min-issues 75] [--max-issues 120] [--max-files 4]
|
bun run doctor -- next-batch [--min-issues 75] [--max-issues 120] [--max-files 4]
|
||||||
|
[--max-active ${DEFAULT_MAX_ACTIVE}] [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
|
||||||
bun run doctor -- check-batch --run <run-id>
|
bun run doctor -- check-batch --run <run-id>
|
||||||
|
bun run doctor -- active [--claim-ttl ${DEFAULT_CLAIM_TTL_DAYS}]
|
||||||
|
|
||||||
|
Every command accepts --claims-dir <path> to isolate a working copy.
|
||||||
|
bun run doctor -- release --run <run-id>
|
||||||
bun run doctor -- file <path>
|
bun run doctor -- file <path>
|
||||||
bun run doctor -- top [--limit 10]
|
bun run doctor -- top [--limit 10]
|
||||||
|
|
||||||
|
Files selected by an active batch are excluded from later batches, so concurrent
|
||||||
|
batches never touch the same file, including batches created by the anti-slop
|
||||||
|
pipeline. Claims are shared across clones by default. A batch stays active until
|
||||||
|
it is released.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bun run doctor -- next-batch --min-issues 75 --max-issues 120
|
bun run doctor -- next-batch --min-issues 75 --max-issues 120
|
||||||
bun run doctor -- file packages/ui/src/components/chat/ChatInput.tsx
|
bun run doctor -- file packages/ui/src/components/chat/ChatInput.tsx
|
||||||
bun run doctor -- check-batch --run 2026-05-14T12-31-44`);
|
bun run doctor -- check-batch --run 2026-05-14T12-31-44Z
|
||||||
|
bun run doctor -- release --run 2026-05-14T12-31-44Z`);
|
||||||
process.exit(exitCode);
|
process.exit(exitCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +159,7 @@ function runReactDoctor() {
|
|||||||
const output = execFileSync(
|
const output = execFileSync(
|
||||||
"npx",
|
"npx",
|
||||||
[
|
[
|
||||||
"react-doctor@latest",
|
`react-doctor@${REACT_DOCTOR_VERSION}`,
|
||||||
"--project",
|
"--project",
|
||||||
PROJECT_NAME,
|
PROJECT_NAME,
|
||||||
"--json",
|
"--json",
|
||||||
@@ -296,7 +323,7 @@ function selectBatch(entries, minIssues, maxIssues, maxFiles) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeRun(runId, payload) {
|
function writeRun(runId, payload) {
|
||||||
const dir = join(RUNS_DIR, runId);
|
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
|
||||||
mkdirSync(dir, { recursive: true });
|
mkdirSync(dir, { recursive: true });
|
||||||
writeFileSync(join(dir, "baseline.json"), `${JSON.stringify(payload.report, null, 2)}\n`);
|
writeFileSync(join(dir, "baseline.json"), `${JSON.stringify(payload.report, null, 2)}\n`);
|
||||||
writeFileSync(join(dir, "batch.json"), `${JSON.stringify(payload.batch, null, 2)}\n`);
|
writeFileSync(join(dir, "batch.json"), `${JSON.stringify(payload.batch, null, 2)}\n`);
|
||||||
@@ -304,7 +331,7 @@ function writeRun(runId, payload) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readRun(runId) {
|
function readRun(runId) {
|
||||||
const dir = join(RUNS_DIR, runId);
|
const dir = runDirPath(RUNS_DIR, PIPELINE, runId);
|
||||||
const baselinePath = join(dir, "baseline.json");
|
const baselinePath = join(dir, "baseline.json");
|
||||||
const batchPath = join(dir, "batch.json");
|
const batchPath = join(dir, "batch.json");
|
||||||
if (!existsSync(baselinePath) || !existsSync(batchPath)) {
|
if (!existsSync(baselinePath) || !existsSync(batchPath)) {
|
||||||
@@ -336,10 +363,36 @@ function commandNextBatch(args) {
|
|||||||
const maxIssues = asPositiveInt(args["max-issues"], 120, "max-issues");
|
const maxIssues = asPositiveInt(args["max-issues"], 120, "max-issues");
|
||||||
const maxFiles = asPositiveInt(args["max-files"], 4, "max-files");
|
const maxFiles = asPositiveInt(args["max-files"], 4, "max-files");
|
||||||
if (minIssues > maxIssues) throw new Error("--min-issues cannot be greater than --max-issues.");
|
if (minIssues > maxIssues) throw new Error("--min-issues cannot be greater than --max-issues.");
|
||||||
|
const maxActive = asPositiveInt(args["max-active"], DEFAULT_MAX_ACTIVE, "max-active");
|
||||||
|
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
|
||||||
|
|
||||||
|
const claims = readActiveClaims(RUNS_DIR, claimTtlDays);
|
||||||
|
if (claims.length >= maxActive) {
|
||||||
|
console.log("React Doctor Next Batch");
|
||||||
|
console.log("");
|
||||||
|
console.log("NO BATCH AVAILABLE");
|
||||||
|
console.log(`Reason: ${claims.length} active batches already exist and the limit is ${maxActive}.`);
|
||||||
|
console.log("Stop here. Do not create a branch or a pull request.");
|
||||||
|
console.log("");
|
||||||
|
printClaims(claims, PIPELINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const report = runReactDoctor();
|
const report = runReactDoctor();
|
||||||
const diagnostics = allDiagnostics(report);
|
const claimedPaths = claimedFilePaths(claims);
|
||||||
|
const diagnostics = allDiagnostics(report).filter((diagnostic) => !claimedPaths.has(diagnostic.filePath));
|
||||||
const entries = sortedFileEntries(diagnostics);
|
const entries = sortedFileEntries(diagnostics);
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
console.log("React Doctor Next Batch");
|
||||||
|
console.log("");
|
||||||
|
console.log("NO BATCH AVAILABLE");
|
||||||
|
console.log("Reason: no unclaimed diagnostics remain.");
|
||||||
|
console.log("Stop here. Do not create a branch or a pull request.");
|
||||||
|
console.log("");
|
||||||
|
printClaims(claims, PIPELINE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const selection = selectBatch(entries, minIssues, maxIssues, maxFiles);
|
const selection = selectBatch(entries, minIssues, maxIssues, maxFiles);
|
||||||
const runId = createRunId();
|
const runId = createRunId();
|
||||||
const selectedFiles = selection.selected.map(([filePath, fileDiagnostics]) => ({
|
const selectedFiles = selection.selected.map(([filePath, fileDiagnostics]) => ({
|
||||||
@@ -348,7 +401,7 @@ function commandNextBatch(args) {
|
|||||||
rules: summarizeRules(fileDiagnostics),
|
rules: summarizeRules(fileDiagnostics),
|
||||||
}));
|
}));
|
||||||
const metadata = createBatchMetadata(runId, selectedFiles);
|
const metadata = createBatchMetadata(runId, selectedFiles);
|
||||||
const batch = { runId, ...metadata, minIssues, maxIssues, maxFiles, selectedFiles, oversized: selection.oversized, belowTarget: selection.belowTarget, reason: selection.reason };
|
const batch = { runId, ...metadata, minIssues, maxIssues, maxFiles, maxActive, selectedFiles, oversized: selection.oversized, belowTarget: selection.belowTarget, reason: selection.reason };
|
||||||
const runDir = writeRun(runId, { report, batch });
|
const runDir = writeRun(runId, { report, batch });
|
||||||
|
|
||||||
console.log("React Doctor Next Batch");
|
console.log("React Doctor Next Batch");
|
||||||
@@ -363,6 +416,9 @@ function commandNextBatch(args) {
|
|||||||
printReportHeader(report);
|
printReportHeader(report);
|
||||||
console.log("");
|
console.log("");
|
||||||
console.log(`Batch window: ${minIssues}-${maxIssues} diagnostics`);
|
console.log(`Batch window: ${minIssues}-${maxIssues} diagnostics`);
|
||||||
|
console.log(`Active batches before this one: ${claims.length} of ${maxActive}`);
|
||||||
|
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
|
||||||
|
console.log(`Files excluded as claimed by active batches: ${claimedPaths.size}`);
|
||||||
console.log(`Selection mode: complete files only`);
|
console.log(`Selection mode: complete files only`);
|
||||||
console.log(`Batch total: ${selectedFiles.reduce((sum, file) => sum + file.diagnosticCount, 0)} diagnostics`);
|
console.log(`Batch total: ${selectedFiles.reduce((sum, file) => sum + file.diagnosticCount, 0)} diagnostics`);
|
||||||
console.log(`Oversized: ${selection.oversized ? "yes" : "no"}`);
|
console.log(`Oversized: ${selection.oversized ? "yes" : "no"}`);
|
||||||
@@ -387,6 +443,20 @@ function commandNextBatch(args) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function commandActive(args) {
|
||||||
|
const claimTtlDays = asPositiveInt(args["claim-ttl"], DEFAULT_CLAIM_TTL_DAYS, "claim-ttl");
|
||||||
|
console.log(`Claims directory: ${RUNS_DIR} (${SHARED_CLAIMS ? "shared default" : "override"})`);
|
||||||
|
printClaims(readActiveClaims(RUNS_DIR, claimTtlDays), PIPELINE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandRelease(args) {
|
||||||
|
const runId = args.run;
|
||||||
|
if (!runId || runId === true) throw new Error("Missing --run <run-id>.");
|
||||||
|
const dir = releaseRun(RUNS_DIR, PIPELINE, runId);
|
||||||
|
console.log(`Released batch ${runId}`);
|
||||||
|
console.log(`Removed ${dir}`);
|
||||||
|
}
|
||||||
|
|
||||||
function commandTop(args) {
|
function commandTop(args) {
|
||||||
const limit = asPositiveInt(args.limit, 10, "limit");
|
const limit = asPositiveInt(args.limit, 10, "limit");
|
||||||
const report = runReactDoctor();
|
const report = runReactDoctor();
|
||||||
@@ -479,6 +549,12 @@ async function main() {
|
|||||||
case "check-batch":
|
case "check-batch":
|
||||||
commandCheckBatch(args);
|
commandCheckBatch(args);
|
||||||
break;
|
break;
|
||||||
|
case "active":
|
||||||
|
commandActive(args);
|
||||||
|
break;
|
||||||
|
case "release":
|
||||||
|
commandRelease(args);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error(`Unknown command: ${command}`);
|
throw new Error(`Unknown command: ${command}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { eslintCompatPlugin } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { noChainedTypeAssertionsRule } from "./rules/no-chained-type-assertions.ts";
|
||||||
|
import { noConditionalEmptyObjectSpreadRule } from "./rules/no-conditional-empty-object-spread.ts";
|
||||||
|
import { noKnownValueWideningRule } from "./rules/no-known-value-widening.ts";
|
||||||
|
import { noModuleMockingRule } from "./rules/no-module-mocking.ts";
|
||||||
|
import { noObjectParametersRule } from "./rules/no-object-parameters.ts";
|
||||||
|
import { noReflectApplyRule } from "./rules/no-reflect-apply.ts";
|
||||||
|
import { noReflectGetRule } from "./rules/no-reflect-get.ts";
|
||||||
|
import { noRuntimeTypeofRule } from "./rules/no-runtime-typeof.ts";
|
||||||
|
import { noForbiddenTermInSymbolNamesRule } from "./rules/no-shape-in-symbol-names.ts";
|
||||||
|
import { noUnknownParametersRule } from "./rules/no-unknown-parameters.ts";
|
||||||
|
import { noUnknownReturnsRule } from "./rules/no-unknown-returns.ts";
|
||||||
|
import { noUnknownTypeAliasesRule } from "./rules/no-unknown-type-aliases.ts";
|
||||||
|
import { noUnsafeDictionaryTypeRule } from "./rules/no-unsafe-dictionary-type.ts";
|
||||||
|
import { noWidenThenAssertRule } from "./rules/no-widen-then-assert.ts";
|
||||||
|
import { requireSafetyCommentForTypeAssertionRule } from "./rules/require-safety-comment-for-type-assertion.ts";
|
||||||
|
|
||||||
|
/** Generic Oxlint rules that reject low-evidence and low-signal implementation patterns. */
|
||||||
|
const antiSlopPlugin = eslintCompatPlugin({
|
||||||
|
meta: { name: "anti-slop" },
|
||||||
|
rules: {
|
||||||
|
"no-chained-type-assertions": noChainedTypeAssertionsRule,
|
||||||
|
"no-conditional-empty-object-spread": noConditionalEmptyObjectSpreadRule,
|
||||||
|
"no-known-value-widening": noKnownValueWideningRule,
|
||||||
|
"no-module-mocking": noModuleMockingRule,
|
||||||
|
"no-object-parameters": noObjectParametersRule,
|
||||||
|
"no-reflect-apply": noReflectApplyRule,
|
||||||
|
"no-reflect-get": noReflectGetRule,
|
||||||
|
"no-runtime-typeof": noRuntimeTypeofRule,
|
||||||
|
"no-unsafe-dictionary-type": noUnsafeDictionaryTypeRule,
|
||||||
|
"no-shape-in-symbol-names": noForbiddenTermInSymbolNamesRule,
|
||||||
|
"no-unknown-parameters": noUnknownParametersRule,
|
||||||
|
"no-unknown-returns": noUnknownReturnsRule,
|
||||||
|
"no-unknown-type-aliases": noUnknownTypeAliasesRule,
|
||||||
|
"no-widen-then-assert": noWidenThenAssertRule,
|
||||||
|
"require-safety-comment-for-type-assertion": requireSafetyCommentForTypeAssertionRule,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default antiSlopPlugin;
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type TypeAssertionExpression = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
||||||
|
|
||||||
|
function isTypeAssertionExpression(node: ESTree.Node): node is TypeAssertionExpression {
|
||||||
|
return node.type === "TSAsExpression" || node.type === "TSTypeAssertion";
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapParenthesizedExpression(expression: ESTree.Expression): ESTree.Expression {
|
||||||
|
let current = expression;
|
||||||
|
while (current.type === "ParenthesizedExpression") {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConstAssertion(node: TypeAssertionExpression): boolean {
|
||||||
|
const { typeAnnotation } = node;
|
||||||
|
return (
|
||||||
|
typeAnnotation.type === "TSTypeReference" &&
|
||||||
|
typeAnnotation.typeName.type === "Identifier" &&
|
||||||
|
typeAnnotation.typeName.name === "const"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOutermostAssertionInChain(node: TypeAssertionExpression): boolean {
|
||||||
|
let current: ESTree.Expression = node;
|
||||||
|
let parent = node.parent;
|
||||||
|
|
||||||
|
while (parent.type === "ParenthesizedExpression" && parent.expression === current) {
|
||||||
|
current = parent;
|
||||||
|
parent = parent.parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !isTypeAssertionExpression(parent) || parent.expression !== current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isForbiddenAssertionChain(node: TypeAssertionExpression): boolean {
|
||||||
|
let assertionCount = 0;
|
||||||
|
let hasNonConstAssertion = false;
|
||||||
|
let current: ESTree.Expression = node;
|
||||||
|
|
||||||
|
while (isTypeAssertionExpression(current)) {
|
||||||
|
assertionCount += 1;
|
||||||
|
hasNonConstAssertion ||= !isConstAssertion(current);
|
||||||
|
current = unwrapParenthesizedExpression(current.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
return assertionCount > 1 && hasNonConstAssertion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disallow nested TypeScript type assertions, while permitting chains made only of const assertions. */
|
||||||
|
export const noChainedTypeAssertionsRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow chained TypeScript as and angle-bracket assertions, including parenthesized chains.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
chained:
|
||||||
|
"This assertion chain discards type evidence. Keep the original precise type, or parse untrusted input at its boundary before narrowing it.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const checkTypeAssertion = (node: TypeAssertionExpression) => {
|
||||||
|
if (!isOutermostAssertionInChain(node) || !isForbiddenAssertionChain(node)) return;
|
||||||
|
context.report({ node, messageId: "chained" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
TSAsExpression: checkTypeAssertion,
|
||||||
|
TSTypeAssertion: checkTypeAssertion,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
function unwrapParentheses(node: ESTree.Expression): ESTree.Expression {
|
||||||
|
let current = node;
|
||||||
|
while (current.type === "ParenthesizedExpression") {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyObjectExpression(node: ESTree.Expression): boolean {
|
||||||
|
return node.type === "ObjectExpression" && node.properties.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConditionalEmptyObjectSpread(node: ESTree.Expression): boolean {
|
||||||
|
const conditional = unwrapParentheses(node);
|
||||||
|
return (
|
||||||
|
conditional.type === "ConditionalExpression" &&
|
||||||
|
(isEmptyObjectExpression(conditional.consequent) ||
|
||||||
|
isEmptyObjectExpression(conditional.alternate))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ban conditional empty-object spreads without changing their omission semantics. */
|
||||||
|
export const noConditionalEmptyObjectSpreadRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "suggestion",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow object spreads that conditionally spread an empty object to omit fields.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
avoid:
|
||||||
|
"This conditional spread hides property omission behind an empty object. Build the object in separate statements and add the property only when present.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
return {
|
||||||
|
SpreadElement(node) {
|
||||||
|
if (node.parent.type !== "ObjectExpression") return;
|
||||||
|
|
||||||
|
if (isConditionalEmptyObjectSpread(node.argument)) {
|
||||||
|
context.report({ node, messageId: "avoid" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyWideningTarget,
|
||||||
|
createTypeEnvironment,
|
||||||
|
isKnownEvidenceExpression,
|
||||||
|
type TypeEnvironment,
|
||||||
|
type WideningTarget,
|
||||||
|
} from "../shared/dictionary-types.ts";
|
||||||
|
|
||||||
|
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type FunctionExpression = ESTree.ArrowFunctionExpression | ESTree.Function;
|
||||||
|
|
||||||
|
function unwrapExpression(expression: ESTree.Expression): ESTree.Expression {
|
||||||
|
let current = expression;
|
||||||
|
while (
|
||||||
|
current.type === "ParenthesizedExpression" ||
|
||||||
|
current.type === "TSAsExpression" ||
|
||||||
|
current.type === "TSSatisfiesExpression" ||
|
||||||
|
current.type === "TSTypeAssertion" ||
|
||||||
|
current.type === "TSNonNullExpression"
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveVariable(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
identifier: ESTree.IdentifierReference,
|
||||||
|
): Variable | null {
|
||||||
|
let scope: Scope | null = sourceCode.getScope(identifier);
|
||||||
|
while (scope !== null) {
|
||||||
|
const variable = scope.set.get(identifier.name);
|
||||||
|
if (variable !== undefined) return variable;
|
||||||
|
scope = scope.upper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
|
||||||
|
if (variable.defs.length !== 1) return null;
|
||||||
|
const [definition] = variable.defs;
|
||||||
|
return definition?.type === "Variable" && definition.node.type === "VariableDeclarator"
|
||||||
|
? definition.node
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isStableConstVariable(variable: Variable, declarator: ESTree.VariableDeclarator): boolean {
|
||||||
|
return (
|
||||||
|
declarator.parent.type === "VariableDeclaration" &&
|
||||||
|
declarator.parent.kind === "const" &&
|
||||||
|
variable.references.every((reference) => reference.init || !reference.isWrite())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasKnownEvidence(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
visitedVariables = new Set<Variable>(),
|
||||||
|
): boolean {
|
||||||
|
if (isKnownEvidenceExpression(expression)) return true;
|
||||||
|
const unwrapped = unwrapExpression(expression);
|
||||||
|
if (unwrapped.type !== "Identifier") return false;
|
||||||
|
const variable = resolveVariable(sourceCode, unwrapped);
|
||||||
|
if (variable === null || visitedVariables.has(variable)) return false;
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (
|
||||||
|
declarator === null ||
|
||||||
|
declarator.init === null ||
|
||||||
|
!isStableConstVariable(variable, declarator)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
visitedVariables.add(variable);
|
||||||
|
return hasKnownEvidence(sourceCode, declarator.init, visitedVariables);
|
||||||
|
}
|
||||||
|
|
||||||
|
function annotationTarget(
|
||||||
|
annotation: ESTree.TSTypeAnnotation | null | undefined,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): WideningTarget | null {
|
||||||
|
return annotation === null || annotation === undefined
|
||||||
|
? null
|
||||||
|
: classifyWideningTarget(annotation.typeAnnotation, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enclosingFunction(node: ESTree.Node): FunctionExpression | null {
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (
|
||||||
|
current.type === "ArrowFunctionExpression" ||
|
||||||
|
current.type === "FunctionDeclaration" ||
|
||||||
|
current.type === "FunctionExpression"
|
||||||
|
) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceKeyName(sourceCode: SourceCode, key: ESTree.PropertyKey): string {
|
||||||
|
if (key.type === "Identifier" || key.type === "PrivateIdentifier") return key.name;
|
||||||
|
if (key.type === "Literal") return String(key.value);
|
||||||
|
return sourceCode.getText(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionName(sourceCode: SourceCode, owner: FunctionExpression | null): string {
|
||||||
|
if (owner === null) return "anonymous function";
|
||||||
|
if (owner.id !== null) return owner.id.name;
|
||||||
|
const parent = owner.parent;
|
||||||
|
if (parent.type === "VariableDeclarator" && parent.id.type === "Identifier")
|
||||||
|
return parent.id.name;
|
||||||
|
if (parent.type === "MethodDefinition") return sourceKeyName(sourceCode, parent.key);
|
||||||
|
return "anonymous function";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEmptyObjectExpression(expression: ESTree.Expression): boolean {
|
||||||
|
const unwrapped = unwrapExpression(expression);
|
||||||
|
return unwrapped.type === "ObjectExpression" && unwrapped.properties.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDictionaryAccumulatorTarget(destination: WideningTarget): boolean {
|
||||||
|
return destination.kind === "open dictionary" || destination.kind === "generic container";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasParentAssertion(node: ESTree.Node): boolean {
|
||||||
|
return node.parent?.type === "TSAsExpression" || node.parent?.type === "TSTypeAssertion";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect sound syntactic cases where a known value is explicitly widened and loses evidence. */
|
||||||
|
export const noKnownValueWideningRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow syntactically established values from flowing into explicitly broad or anonymous target types that discard useful evidence.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
widening:
|
||||||
|
"The explicit {{target}} type on {{subject}} discards known type evidence. Keep inference, validate with `satisfies`, or use a named owner contract.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
let environment: TypeEnvironment | null = null;
|
||||||
|
|
||||||
|
const reportFlow = (
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
destination: WideningTarget | null,
|
||||||
|
subject: string,
|
||||||
|
) => {
|
||||||
|
if (destination === null) return;
|
||||||
|
if (
|
||||||
|
isDictionaryAccumulatorTarget(destination) &&
|
||||||
|
isEmptyObjectExpression(expression)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hasKnownEvidence(context.sourceCode, expression)) return;
|
||||||
|
context.report({
|
||||||
|
node: expression,
|
||||||
|
messageId: "widening",
|
||||||
|
data: { subject, target: destination.kind },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const targetFromAnnotation = (annotation: ESTree.TSTypeAnnotation | null | undefined) =>
|
||||||
|
environment === null ? null : annotationTarget(annotation, environment);
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
environment = createTypeEnvironment(node);
|
||||||
|
},
|
||||||
|
VariableDeclarator(node) {
|
||||||
|
if (node.init === null || node.id.type !== "Identifier") return;
|
||||||
|
reportFlow(
|
||||||
|
node.init,
|
||||||
|
targetFromAnnotation(node.id.typeAnnotation),
|
||||||
|
`binding \`${node.id.name}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
PropertyDefinition(node) {
|
||||||
|
if (node.value === null) return;
|
||||||
|
reportFlow(
|
||||||
|
node.value,
|
||||||
|
targetFromAnnotation(node.typeAnnotation),
|
||||||
|
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
AccessorProperty(node) {
|
||||||
|
if (node.value === null) return;
|
||||||
|
reportFlow(
|
||||||
|
node.value,
|
||||||
|
targetFromAnnotation(node.typeAnnotation),
|
||||||
|
`property \`${sourceKeyName(context.sourceCode, node.key)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
AssignmentExpression(node) {
|
||||||
|
if (node.operator !== "=" || node.left.type !== "Identifier") return;
|
||||||
|
const variable = resolveVariable(context.sourceCode, node.left);
|
||||||
|
if (variable === null) return;
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (declarator === null || declarator.id.type !== "Identifier") return;
|
||||||
|
reportFlow(
|
||||||
|
node.right,
|
||||||
|
targetFromAnnotation(declarator.id.typeAnnotation),
|
||||||
|
`binding \`${declarator.id.name}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ReturnStatement(node) {
|
||||||
|
if (node.argument === null) return;
|
||||||
|
const owner = enclosingFunction(node);
|
||||||
|
reportFlow(
|
||||||
|
node.argument,
|
||||||
|
targetFromAnnotation(owner?.returnType),
|
||||||
|
`return value of \`${functionName(context.sourceCode, owner)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ArrowFunctionExpression(node) {
|
||||||
|
if (node.body.type === "BlockStatement") return;
|
||||||
|
reportFlow(
|
||||||
|
node.body,
|
||||||
|
targetFromAnnotation(node.returnType),
|
||||||
|
`return value of \`${functionName(context.sourceCode, node)}\``,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
TSAsExpression(node) {
|
||||||
|
if (environment === null || hasParentAssertion(node)) return;
|
||||||
|
reportFlow(
|
||||||
|
node.expression,
|
||||||
|
classifyWideningTarget(node.typeAnnotation, environment),
|
||||||
|
"assertion",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
TSTypeAssertion(node) {
|
||||||
|
if (environment === null || hasParentAssertion(node)) return;
|
||||||
|
reportFlow(
|
||||||
|
node.expression,
|
||||||
|
classifyWideningTarget(node.typeAnnotation, environment),
|
||||||
|
"assertion",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
const moduleMockMethods = new Set(["doMock", "mock", "unstable_mockModule"]);
|
||||||
|
|
||||||
|
function resolveVariable(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
identifier: ESTree.IdentifierReference,
|
||||||
|
): Variable | null {
|
||||||
|
let scope: Scope | null = sourceCode.getScope(identifier);
|
||||||
|
while (scope !== null) {
|
||||||
|
const variable = scope.set.get(identifier.name);
|
||||||
|
if (variable !== undefined) return variable;
|
||||||
|
scope = scope.upper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importedName(node: ESTree.Node): string | null {
|
||||||
|
if (node.type !== "ImportSpecifier") return null;
|
||||||
|
return node.imported.type === "Identifier" ? node.imported.name : node.imported.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTestFrameworkObject(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
): expression is ESTree.IdentifierReference {
|
||||||
|
if (expression.type !== "Identifier") return false;
|
||||||
|
if (
|
||||||
|
(expression.name === "vi" || expression.name === "jest") &&
|
||||||
|
sourceCode.isGlobalReference(expression)
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variable = resolveVariable(sourceCode, expression);
|
||||||
|
if (variable === null || variable.defs.length === 0) {
|
||||||
|
return expression.name === "vi" || expression.name === "jest";
|
||||||
|
}
|
||||||
|
return variable.defs.some((definition) => {
|
||||||
|
if (definition.type !== "ImportBinding" || definition.parent?.type !== "ImportDeclaration") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const source = definition.parent.source.value;
|
||||||
|
const name = importedName(definition.node);
|
||||||
|
return (source === "vitest" && name === "vi") || (source === "@jest/globals" && name === "jest");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleMockCall(sourceCode: SourceCode, callee: ESTree.Expression): boolean {
|
||||||
|
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
|
||||||
|
if (!isTestFrameworkObject(sourceCode, callee.object)) return false;
|
||||||
|
const property = callee.property;
|
||||||
|
const method = callee.computed
|
||||||
|
? property.type === "Literal" &&
|
||||||
|
(property.value === "doMock" ||
|
||||||
|
property.value === "mock" ||
|
||||||
|
property.value === "unstable_mockModule")
|
||||||
|
? property.value
|
||||||
|
: null
|
||||||
|
: property.type === "Identifier"
|
||||||
|
? property.name
|
||||||
|
: null;
|
||||||
|
return method !== null && moduleMockMethods.has(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ban test framework module mocking in favor of real dependency seams. */
|
||||||
|
export const noModuleMockingRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow Vitest and Jest module mocking; tests must replace dependencies through real interfaces.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
moduleMock:
|
||||||
|
"Replace module mocking with dependency injection through a real interface, service layer, or faithful test implementation.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
return {
|
||||||
|
CallExpression(node) {
|
||||||
|
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
||||||
|
if (moduleMockCall(context.sourceCode, node.callee)) {
|
||||||
|
context.report({ node, messageId: "moduleMock" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts";
|
||||||
|
|
||||||
|
type Parameter = ESTree.ParamPattern;
|
||||||
|
type ParameterOwner =
|
||||||
|
| ESTree.ArrowFunctionExpression
|
||||||
|
| ESTree.Function
|
||||||
|
| ESTree.TSCallSignatureDeclaration
|
||||||
|
| ESTree.TSConstructSignatureDeclaration
|
||||||
|
| ESTree.TSConstructorType
|
||||||
|
| ESTree.TSFunctionType
|
||||||
|
| ESTree.TSMethodSignature;
|
||||||
|
|
||||||
|
function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined {
|
||||||
|
if (parameter.type === "TSParameterProperty") {
|
||||||
|
return parameterAnnotation(parameter.parameter);
|
||||||
|
}
|
||||||
|
if (parameter.type === "RestElement") {
|
||||||
|
return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
|
||||||
|
}
|
||||||
|
if (parameter.type === "AssignmentPattern") {
|
||||||
|
return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
|
||||||
|
}
|
||||||
|
return parameter.typeAnnotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameterName(parameter: Parameter, sourceCode: SourceCode): string {
|
||||||
|
return parameter.type === "Identifier"
|
||||||
|
? parameter.name
|
||||||
|
: sourceCode.getText(parameter).replace(/\s*:\s*object\s*$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ban the broad object type on function inputs, including local aliases to object. */
|
||||||
|
export const noObjectParametersRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow object function parameters; inputs must use an owner-provided type and be parsed at their boundary.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
objectParameter:
|
||||||
|
"Parameter `{{parameter}}` uses the broad `object` type. Accept a named owner type; parse external input at its boundary before calling this function.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const aliases = new Map<string, ESTree.TSType>();
|
||||||
|
|
||||||
|
const resolvesToObject = (
|
||||||
|
type: ESTree.TSType,
|
||||||
|
shadowedAliases: ReadonlySet<string>,
|
||||||
|
visited = new Set<string>(),
|
||||||
|
): boolean => {
|
||||||
|
if (type.type === "TSObjectKeyword") return true;
|
||||||
|
if (type.type === "TSParenthesizedType")
|
||||||
|
return resolvesToObject(type.typeAnnotation, shadowedAliases, visited);
|
||||||
|
if (type.type === "TSUnionType") {
|
||||||
|
return type.types.some((member) =>
|
||||||
|
resolvesToObject(member, shadowedAliases, visited),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type.type !== "TSTypeReference" ||
|
||||||
|
type.typeName.type !== "Identifier" ||
|
||||||
|
(type.typeArguments !== null &&
|
||||||
|
type.typeArguments !== undefined &&
|
||||||
|
type.typeArguments.params.length > 0) ||
|
||||||
|
visited.has(type.typeName.name) ||
|
||||||
|
shadowedAliases.has(type.typeName.name)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const alias = aliases.get(type.typeName.name);
|
||||||
|
if (alias === undefined) return false;
|
||||||
|
const nextVisited = new Set(visited);
|
||||||
|
nextVisited.add(type.typeName.name);
|
||||||
|
return resolvesToObject(alias, shadowedAliases, nextVisited);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkParameters = (node: ParameterOwner) => {
|
||||||
|
const shadowedAliases = lexicalTypeParameterNames(
|
||||||
|
node,
|
||||||
|
context.sourceCode.visitorKeys,
|
||||||
|
);
|
||||||
|
for (const parameter of node.params) {
|
||||||
|
const annotation = parameterAnnotation(parameter);
|
||||||
|
if (annotation === null || annotation === undefined) continue;
|
||||||
|
if (!resolvesToObject(annotation.typeAnnotation, shadowedAliases)) continue;
|
||||||
|
context.report({
|
||||||
|
node: annotation.typeAnnotation,
|
||||||
|
messageId: "objectParameter",
|
||||||
|
data: { parameter: parameterName(parameter, context.sourceCode) },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
aliases.clear();
|
||||||
|
for (const statement of node.body) {
|
||||||
|
const declaration =
|
||||||
|
statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
||||||
|
if (
|
||||||
|
declaration?.type === "TSTypeAliasDeclaration" &&
|
||||||
|
(declaration.typeParameters === null || declaration.typeParameters === undefined)
|
||||||
|
) {
|
||||||
|
aliases.set(declaration.id.name, declaration.typeAnnotation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ArrowFunctionExpression: checkParameters,
|
||||||
|
FunctionDeclaration: checkParameters,
|
||||||
|
FunctionExpression: checkParameters,
|
||||||
|
TSCallSignatureDeclaration: checkParameters,
|
||||||
|
TSConstructSignatureDeclaration: checkParameters,
|
||||||
|
TSConstructorType: checkParameters,
|
||||||
|
TSDeclareFunction: checkParameters,
|
||||||
|
TSEmptyBodyFunctionExpression: checkParameters,
|
||||||
|
TSFunctionType: checkParameters,
|
||||||
|
TSMethodSignature: checkParameters,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts";
|
||||||
|
|
||||||
|
/** Ban Reflect.apply, which bypasses ordinary typed function calls. */
|
||||||
|
export const noReflectApplyRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow Reflect.apply; call typed functions directly or model dynamic dispatch behind an interface.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
reflectApply:
|
||||||
|
"Replace `Reflect.apply` with a typed function call. Model dynamic dispatch behind a named interface.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
return {
|
||||||
|
CallExpression(node) {
|
||||||
|
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
||||||
|
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "apply")) {
|
||||||
|
context.report({ node, messageId: "reflectApply" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { isGlobalReflectMethodCall } from "../shared/reflect-method.ts";
|
||||||
|
|
||||||
|
/** Ban Reflect.get, which bypasses ordinary property access and useful type evidence. */
|
||||||
|
export const noReflectGetRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow Reflect.get; use typed property access or parse dynamic input into a domain type.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
reflectGet:
|
||||||
|
"Replace `Reflect.get` with typed property access. Parse dynamic input into a named domain type before reading it.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
return {
|
||||||
|
CallExpression(node) {
|
||||||
|
if (node.callee.type === "Super" || node.callee.type === "V8IntrinsicExpression") return;
|
||||||
|
if (isGlobalReflectMethodCall(context.sourceCode, node.callee, "get")) {
|
||||||
|
context.report({ node, messageId: "reflectGet" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type RuntimeFunction = ESTree.ArrowFunctionExpression | ESTree.Function;
|
||||||
|
|
||||||
|
function isRuntimeFunction(node: ESTree.Node): node is RuntimeFunction {
|
||||||
|
return (
|
||||||
|
node.type === "ArrowFunctionExpression" ||
|
||||||
|
node.type === "FunctionDeclaration" ||
|
||||||
|
node.type === "FunctionExpression"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideTypeGuard(node: ESTree.Node): boolean {
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (isRuntimeFunction(current)) {
|
||||||
|
return current.returnType?.typeAnnotation.type === "TSTypePredicate";
|
||||||
|
}
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disallow runtime typeof checks that narrow unparsed values instead of decoding them. */
|
||||||
|
export const noRuntimeTypeofRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow runtime typeof checks; external values must be decoded into meaningful types at their I/O boundary.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
runtimeTypeof:
|
||||||
|
"A `typeof` check narrows a representation without establishing its contract. Parse input at its I/O boundary, then branch on the domain value.",
|
||||||
|
},
|
||||||
|
schema: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
allowInTypeGuards: { type: "boolean" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
defaultOptions: [{ allowInTypeGuards: false }],
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
return {
|
||||||
|
UnaryExpression(node) {
|
||||||
|
const option = context.options?.[0];
|
||||||
|
const allowInTypeGuards =
|
||||||
|
typeof option === "object" &&
|
||||||
|
option !== null &&
|
||||||
|
!Array.isArray(option) &&
|
||||||
|
option.allowInTypeGuards === true;
|
||||||
|
if (
|
||||||
|
node.operator === "typeof" &&
|
||||||
|
(!allowInTypeGuards || !isInsideTypeGuard(node))
|
||||||
|
) {
|
||||||
|
context.report({ node, messageId: "runtimeTypeof" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
const FORBIDDEN_SYMBOL_NAME = "shape";
|
||||||
|
|
||||||
|
function containsForbiddenSymbolName(name: string): boolean {
|
||||||
|
return name.toLowerCase().includes(FORBIDDEN_SYMBOL_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ban the case-insensitive substring "shape" in every JavaScript and TypeScript symbol name. */
|
||||||
|
export const noForbiddenTermInSymbolNamesRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
'Disallow the case-insensitive substring "shape" in JavaScript, TypeScript, private, and JSX symbol names.',
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
forbiddenSymbolName:
|
||||||
|
'Rename symbol "{{name}}" for its domain role; "shape" describes structure rather than ownership.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const reportForbiddenSymbolName = (node: ESTree.Node & { name: string }) => {
|
||||||
|
if (!containsForbiddenSymbolName(node.name)) return;
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "forbiddenSymbolName",
|
||||||
|
data: { name: node.name },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Identifier: reportForbiddenSymbolName,
|
||||||
|
PrivateIdentifier: reportForbiddenSymbolName,
|
||||||
|
JSXIdentifier: reportForbiddenSymbolName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type Parameter = ESTree.ParamPattern;
|
||||||
|
type ParameterOwner =
|
||||||
|
| ESTree.ArrowFunctionExpression
|
||||||
|
| ESTree.Function
|
||||||
|
| ESTree.TSCallSignatureDeclaration
|
||||||
|
| ESTree.TSConstructSignatureDeclaration
|
||||||
|
| ESTree.TSConstructorType
|
||||||
|
| ESTree.TSFunctionType
|
||||||
|
| ESTree.TSMethodSignature;
|
||||||
|
|
||||||
|
function parameterAnnotation(parameter: Parameter): ESTree.TSTypeAnnotation | null | undefined {
|
||||||
|
if (parameter.type === "TSParameterProperty") {
|
||||||
|
return parameterAnnotation(parameter.parameter);
|
||||||
|
}
|
||||||
|
if (parameter.type === "RestElement") {
|
||||||
|
return parameter.typeAnnotation ?? parameterAnnotation(parameter.argument);
|
||||||
|
}
|
||||||
|
if (parameter.type === "AssignmentPattern") {
|
||||||
|
return parameter.typeAnnotation ?? parameter.left.typeAnnotation;
|
||||||
|
}
|
||||||
|
return parameter.typeAnnotation;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parameterName(parameter: Parameter, sourceText: string): string {
|
||||||
|
if (parameter.type === "TSParameterProperty") {
|
||||||
|
return parameterName(parameter.parameter, sourceText);
|
||||||
|
}
|
||||||
|
if (parameter.type === "AssignmentPattern") {
|
||||||
|
return parameterName(parameter.left, sourceText);
|
||||||
|
}
|
||||||
|
if (parameter.type === "RestElement") {
|
||||||
|
return parameterName(parameter.argument, sourceText);
|
||||||
|
}
|
||||||
|
return parameter.type === "Identifier"
|
||||||
|
? parameter.name
|
||||||
|
: sourceText.replace(/\s*:\s*unknown\s*$/u, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disallow unknown inputs except explicitly named error-cause enrichment. */
|
||||||
|
export const noUnknownParametersRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow explicitly unknown function parameters except `cause`; decode unknown input at its I/O boundary instead.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
unknownParameter:
|
||||||
|
"Parameter `{{parameter}}` leaves input unparsed. Accept a named domain type; run the expected schema or parser at the I/O boundary before calling this function.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const checkParameters = (node: ParameterOwner) => {
|
||||||
|
for (const parameter of node.params) {
|
||||||
|
const annotation = parameterAnnotation(parameter);
|
||||||
|
if (annotation?.typeAnnotation.type !== "TSUnknownKeyword") continue;
|
||||||
|
const name = parameterName(parameter, context.sourceCode.getText(parameter));
|
||||||
|
if (name === "cause") continue;
|
||||||
|
context.report({
|
||||||
|
node: annotation.typeAnnotation,
|
||||||
|
messageId: "unknownParameter",
|
||||||
|
data: { parameter: name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
ArrowFunctionExpression: checkParameters,
|
||||||
|
FunctionDeclaration: checkParameters,
|
||||||
|
FunctionExpression: checkParameters,
|
||||||
|
TSCallSignatureDeclaration: checkParameters,
|
||||||
|
TSConstructSignatureDeclaration: checkParameters,
|
||||||
|
TSConstructorType: checkParameters,
|
||||||
|
TSDeclareFunction: checkParameters,
|
||||||
|
TSEmptyBodyFunctionExpression: checkParameters,
|
||||||
|
TSFunctionType: checkParameters,
|
||||||
|
TSMethodSignature: checkParameters,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import { lexicalTypeParameterNames } from "../shared/lexical-type-parameters.ts";
|
||||||
|
|
||||||
|
type FunctionWithReturnType =
|
||||||
|
| ESTree.ArrowFunctionExpression
|
||||||
|
| ESTree.Function
|
||||||
|
| ESTree.TSCallSignatureDeclaration
|
||||||
|
| ESTree.TSConstructSignatureDeclaration
|
||||||
|
| ESTree.TSConstructorType
|
||||||
|
| ESTree.TSFunctionType
|
||||||
|
| ESTree.TSMethodSignature;
|
||||||
|
|
||||||
|
function referencedAliasName(type: ESTree.TSType): string | null {
|
||||||
|
if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation);
|
||||||
|
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
|
||||||
|
return type.typeArguments === null ||
|
||||||
|
type.typeArguments === undefined ||
|
||||||
|
type.typeArguments.params.length === 0
|
||||||
|
? type.typeName.name
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ban function contracts that return unknown instead of a parsed domain type. */
|
||||||
|
export const noUnknownReturnsRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow functions whose explicit return contract is unknown or Promise<unknown>.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
unknownReturn:
|
||||||
|
"This function exposes `unknown` to its caller. Parse the value at its boundary and return a named domain type.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const aliases = new Map<string, ESTree.TSTypeAliasDeclaration>();
|
||||||
|
|
||||||
|
const resolvesToUnknown = (
|
||||||
|
type: ESTree.TSType,
|
||||||
|
shadowedAliases: ReadonlySet<string>,
|
||||||
|
visited = new Set<string>(),
|
||||||
|
): boolean => {
|
||||||
|
if (type.type === "TSUnknownKeyword") return true;
|
||||||
|
if (type.type === "TSParenthesizedType") {
|
||||||
|
return resolvesToUnknown(type.typeAnnotation, shadowedAliases, visited);
|
||||||
|
}
|
||||||
|
if (type.type === "TSUnionType") {
|
||||||
|
return type.types.some((member) =>
|
||||||
|
resolvesToUnknown(member, shadowedAliases, visited),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
type.type === "TSTypeReference" &&
|
||||||
|
type.typeName.type === "Identifier" &&
|
||||||
|
(type.typeName.name === "Promise" || type.typeName.name === "PromiseLike")
|
||||||
|
) {
|
||||||
|
const value = type.typeArguments?.params[0];
|
||||||
|
return value !== undefined && resolvesToUnknown(value, shadowedAliases, visited);
|
||||||
|
}
|
||||||
|
const name = referencedAliasName(type);
|
||||||
|
if (name === null || visited.has(name) || shadowedAliases.has(name)) return false;
|
||||||
|
const alias = aliases.get(name);
|
||||||
|
if (
|
||||||
|
alias === undefined ||
|
||||||
|
(alias.typeParameters !== null && alias.typeParameters !== undefined)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextVisited = new Set(visited);
|
||||||
|
nextVisited.add(name);
|
||||||
|
return resolvesToUnknown(alias.typeAnnotation, shadowedAliases, nextVisited);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkReturnType = (node: FunctionWithReturnType) => {
|
||||||
|
const annotation = node.returnType;
|
||||||
|
if (annotation === null || annotation === undefined) return;
|
||||||
|
if (
|
||||||
|
!resolvesToUnknown(
|
||||||
|
annotation.typeAnnotation,
|
||||||
|
lexicalTypeParameterNames(node, context.sourceCode.visitorKeys),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.report({ node: annotation.typeAnnotation, messageId: "unknownReturn" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
aliases.clear();
|
||||||
|
for (const statement of node.body) {
|
||||||
|
const declaration =
|
||||||
|
statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
||||||
|
if (declaration?.type === "TSTypeAliasDeclaration") {
|
||||||
|
aliases.set(declaration.id.name, declaration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ArrowFunctionExpression: checkReturnType,
|
||||||
|
FunctionDeclaration: checkReturnType,
|
||||||
|
FunctionExpression: checkReturnType,
|
||||||
|
TSCallSignatureDeclaration: checkReturnType,
|
||||||
|
TSConstructSignatureDeclaration: checkReturnType,
|
||||||
|
TSConstructorType: checkReturnType,
|
||||||
|
TSDeclareFunction: checkReturnType,
|
||||||
|
TSEmptyBodyFunctionExpression: checkReturnType,
|
||||||
|
TSFunctionType: checkReturnType,
|
||||||
|
TSMethodSignature: checkReturnType,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
function referencedAliasName(type: ESTree.TSType): string | null {
|
||||||
|
if (type.type === "TSParenthesizedType") return referencedAliasName(type.typeAnnotation);
|
||||||
|
if (type.type !== "TSTypeReference" || type.typeName.type !== "Identifier") return null;
|
||||||
|
return type.typeArguments === null ||
|
||||||
|
type.typeArguments === undefined ||
|
||||||
|
type.typeArguments.params.length === 0
|
||||||
|
? type.typeName.name
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ban named aliases that merely conceal TypeScript's unknown top type. */
|
||||||
|
export const noUnknownTypeAliasesRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow type aliases whose resolved type is unknown; unknown must remain visible at an allowed boundary.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
unknownAlias:
|
||||||
|
"Type alias `{{alias}}` hides `unknown`. Keep `unknown` explicit at the parsing boundary or on an allowed `cause` field; otherwise use the parsed owner type.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const aliases = new Map<string, ESTree.TSTypeAliasDeclaration>();
|
||||||
|
|
||||||
|
const resolvesToUnknown = (type: ESTree.TSType, visited = new Set<string>()): boolean => {
|
||||||
|
if (type.type === "TSUnknownKeyword") return true;
|
||||||
|
if (type.type === "TSParenthesizedType")
|
||||||
|
return resolvesToUnknown(type.typeAnnotation, visited);
|
||||||
|
const name = referencedAliasName(type);
|
||||||
|
if (name === null || visited.has(name)) return false;
|
||||||
|
const alias = aliases.get(name);
|
||||||
|
if (
|
||||||
|
alias === undefined ||
|
||||||
|
(alias.typeParameters !== null && alias.typeParameters !== undefined)
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const nextVisited = new Set(visited);
|
||||||
|
nextVisited.add(name);
|
||||||
|
return resolvesToUnknown(alias.typeAnnotation, nextVisited);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
aliases.clear();
|
||||||
|
for (const statement of node.body) {
|
||||||
|
const declaration =
|
||||||
|
statement.type === "ExportNamedDeclaration" ? statement.declaration : statement;
|
||||||
|
if (declaration?.type === "TSTypeAliasDeclaration") {
|
||||||
|
aliases.set(declaration.id.name, declaration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const alias of aliases.values()) {
|
||||||
|
if (!resolvesToUnknown(alias.typeAnnotation, new Set([alias.id.name]))) continue;
|
||||||
|
context.report({
|
||||||
|
node: alias.id,
|
||||||
|
messageId: "unknownAlias",
|
||||||
|
data: { alias: alias.id.name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyUnsafeDictionary,
|
||||||
|
classifyUnsafeDictionaryValue,
|
||||||
|
createTypeEnvironment,
|
||||||
|
type TypeEnvironment,
|
||||||
|
} from "../shared/dictionary-types.ts";
|
||||||
|
|
||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
const typeNodeKinds: ReadonlySet<string> = new Set([
|
||||||
|
"JSDocNonNullableType",
|
||||||
|
"JSDocNullableType",
|
||||||
|
"JSDocUnknownType",
|
||||||
|
"TSAnyKeyword",
|
||||||
|
"TSArrayType",
|
||||||
|
"TSBigIntKeyword",
|
||||||
|
"TSBooleanKeyword",
|
||||||
|
"TSConditionalType",
|
||||||
|
"TSConstructorType",
|
||||||
|
"TSFunctionType",
|
||||||
|
"TSImportType",
|
||||||
|
"TSIndexedAccessType",
|
||||||
|
"TSInferType",
|
||||||
|
"TSIntersectionType",
|
||||||
|
"TSIntrinsicKeyword",
|
||||||
|
"TSLiteralType",
|
||||||
|
"TSMappedType",
|
||||||
|
"TSNamedTupleMember",
|
||||||
|
"TSNeverKeyword",
|
||||||
|
"TSNullKeyword",
|
||||||
|
"TSNumberKeyword",
|
||||||
|
"TSObjectKeyword",
|
||||||
|
"TSParenthesizedType",
|
||||||
|
"TSStringKeyword",
|
||||||
|
"TSSymbolKeyword",
|
||||||
|
"TSTemplateLiteralType",
|
||||||
|
"TSThisType",
|
||||||
|
"TSTupleType",
|
||||||
|
"TSTypeLiteral",
|
||||||
|
"TSTypeOperator",
|
||||||
|
"TSTypePredicate",
|
||||||
|
"TSTypeQuery",
|
||||||
|
"TSTypeReference",
|
||||||
|
"TSUndefinedKeyword",
|
||||||
|
"TSUnionType",
|
||||||
|
"TSUnknownKeyword",
|
||||||
|
"TSVoidKeyword",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTypeNode(node: ESTree.Node): node is ESTree.TSType {
|
||||||
|
return typeNodeKinds.has(node.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||||
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInsideTypeAliasDeclaration(node: ESTree.Node): boolean {
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (current.type === "TSTypeAliasDeclaration") return true;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainAliasConsumerUse(node: ESTree.TSType, environment: TypeEnvironment): boolean {
|
||||||
|
if (node.type !== "TSTypeReference" || node.typeArguments?.params.length) return false;
|
||||||
|
const name = typeReferenceName(node);
|
||||||
|
return name !== null && environment.aliases.has(name) && !isInsideTypeAliasDeclaration(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldReportType(node: ESTree.TSType, environment: TypeEnvironment): boolean {
|
||||||
|
if (isPlainAliasConsumerUse(node, environment)) return false;
|
||||||
|
if (classifyUnsafeDictionary(node, environment) === null) return false;
|
||||||
|
let current: ESTree.Node | null = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (isTypeNode(current) && classifyUnsafeDictionary(current, environment) !== null)
|
||||||
|
return false;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Disallow object-dictionary contracts whose direct value type is an unsafe escape hatch. */
|
||||||
|
export const noUnsafeDictionaryTypeRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow object-dictionary contracts whose direct value type is unknown, any, object, {}, or a union/alias containing one of those escape hatches.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
unsafeDictionary:
|
||||||
|
"This dictionary's {{value}} value type gives callers no concrete value contract. Use an owner/schema-derived value type; parse external payloads before insertion.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
let environment: TypeEnvironment | null = null;
|
||||||
|
const report = (node: ESTree.Node, value: string) => {
|
||||||
|
context.report({ node, messageId: "unsafeDictionary", data: { value } });
|
||||||
|
};
|
||||||
|
const reportIfUnsafe = (node: ESTree.TSType) => {
|
||||||
|
if (environment === null || !shouldReportType(node, environment)) return;
|
||||||
|
const unsafe = classifyUnsafeDictionary(node, environment);
|
||||||
|
if (unsafe === null) return;
|
||||||
|
report(node, unsafe.unsafeValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program(node) {
|
||||||
|
environment = createTypeEnvironment(node);
|
||||||
|
},
|
||||||
|
TSTypeReference: reportIfUnsafe,
|
||||||
|
TSTypeLiteral: reportIfUnsafe,
|
||||||
|
TSMappedType: reportIfUnsafe,
|
||||||
|
TSIndexSignature(node) {
|
||||||
|
if (
|
||||||
|
environment === null ||
|
||||||
|
node.typeAnnotation === null ||
|
||||||
|
node.parent.type === "TSTypeLiteral"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const unsafe = classifyUnsafeDictionaryValue(
|
||||||
|
node.typeAnnotation.typeAnnotation,
|
||||||
|
environment,
|
||||||
|
);
|
||||||
|
if (unsafe !== null) report(node, unsafe.unsafeValue);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
import type { ESTree, Variable } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type BroadTypeKind = "top" | "object" | "record";
|
||||||
|
|
||||||
|
type KnownValueEvidence = {
|
||||||
|
readonly type: ESTree.TSType | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const functionBoundaryTypes = new Set([
|
||||||
|
"ArrowFunctionExpression",
|
||||||
|
"FunctionDeclaration",
|
||||||
|
"FunctionExpression",
|
||||||
|
"TSDeclareFunction",
|
||||||
|
"TSEmptyBodyFunctionExpression",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function unwrapExpressionParentheses(expression: ESTree.Expression): ESTree.Expression {
|
||||||
|
let current = expression;
|
||||||
|
while (current.type === "ParenthesizedExpression") current = current.expression;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapTypeParentheses(type: ESTree.TSType): ESTree.TSType {
|
||||||
|
let current = type;
|
||||||
|
while (current.type === "TSParenthesizedType") current = current.typeAnnotation;
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||||
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnknownOrAnyType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
return unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadRecordKeyType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
if (
|
||||||
|
unwrapped.type === "TSStringKeyword" ||
|
||||||
|
unwrapped.type === "TSNumberKeyword" ||
|
||||||
|
unwrapped.type === "TSSymbolKeyword"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSUnionType") return unwrapped.types.every(isBroadRecordKeyType);
|
||||||
|
return unwrapped.type === "TSTypeReference" && typeReferenceName(unwrapped) === "PropertyKey";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadRecordType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSTypeReference") {
|
||||||
|
if (typeReferenceName(unwrapped) === "Readonly") {
|
||||||
|
const [inner] = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return inner !== undefined && isBroadRecordType(inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeReferenceName(unwrapped) !== "Record") return false;
|
||||||
|
const parameters = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return (
|
||||||
|
parameters.length === 2 &&
|
||||||
|
parameters[0] !== undefined &&
|
||||||
|
parameters[1] !== undefined &&
|
||||||
|
isBroadRecordKeyType(parameters[0]) &&
|
||||||
|
isUnknownOrAnyType(parameters[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "TSTypeLiteral" || unwrapped.members.length !== 1) return false;
|
||||||
|
const [member] = unwrapped.members;
|
||||||
|
const [parameter] = member?.type === "TSIndexSignature" ? member.parameters : [];
|
||||||
|
return (
|
||||||
|
member?.type === "TSIndexSignature" &&
|
||||||
|
member.parameters.length === 1 &&
|
||||||
|
parameter !== undefined &&
|
||||||
|
isBroadRecordKeyType(parameter.typeAnnotation.typeAnnotation) &&
|
||||||
|
isUnknownOrAnyType(member.typeAnnotation.typeAnnotation)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadTypeKind(type: ESTree.TSType): BroadTypeKind | null {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword" || unwrapped.type === "TSAnyKeyword") return "top";
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return "object";
|
||||||
|
return isBroadRecordType(unwrapped) ? "record" : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertedExpression(
|
||||||
|
node: ESTree.TSAsExpression | ESTree.TSTypeAssertion,
|
||||||
|
): ESTree.Expression {
|
||||||
|
return unwrapExpressionParentheses(node.expression);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertionFromExpression(
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
): ESTree.TSAsExpression | ESTree.TSTypeAssertion | null {
|
||||||
|
const unwrapped = unwrapExpressionParentheses(expression);
|
||||||
|
return unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion"
|
||||||
|
? unwrapped
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedTypeText(sourceText: string, type: ESTree.TSType): string {
|
||||||
|
return sourceText.slice(type.start, type.end).replaceAll(/\s+/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function typesHaveSameSyntax(
|
||||||
|
sourceText: string,
|
||||||
|
left: ESTree.TSType | null,
|
||||||
|
right: ESTree.TSType,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
left !== null &&
|
||||||
|
normalizedTypeText(sourceText, unwrapTypeParentheses(left)) ===
|
||||||
|
normalizedTypeText(sourceText, unwrapTypeParentheses(right))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefinitelyObjectType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
switch (unwrapped.type) {
|
||||||
|
case "TSArrayType":
|
||||||
|
case "TSConstructorType":
|
||||||
|
case "TSFunctionType":
|
||||||
|
case "TSMappedType":
|
||||||
|
case "TSObjectKeyword":
|
||||||
|
case "TSTupleType":
|
||||||
|
return true;
|
||||||
|
case "TSTypeLiteral":
|
||||||
|
return unwrapped.members.length > 0;
|
||||||
|
case "TSIntersectionType":
|
||||||
|
return unwrapped.types.every(isDefinitelyObjectType);
|
||||||
|
case "TSTypeOperator":
|
||||||
|
return unwrapped.operator === "readonly" && isDefinitelyObjectType(unwrapped.typeAnnotation);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDefinitelyNarrowerRecordType(type: ESTree.TSType): boolean {
|
||||||
|
const unwrapped = unwrapTypeParentheses(type);
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.some((member) => member.type !== "TSIndexSignature");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return false;
|
||||||
|
if (typeReferenceName(unwrapped) === "Readonly") {
|
||||||
|
const [inner] = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return inner !== undefined && isDefinitelyNarrowerRecordType(inner);
|
||||||
|
}
|
||||||
|
if (typeReferenceName(unwrapped) !== "Record") return false;
|
||||||
|
|
||||||
|
const parameters = unwrapped.typeArguments?.params ?? [];
|
||||||
|
return (
|
||||||
|
parameters.length === 2 && parameters[1] !== undefined && !isUnknownOrAnyType(parameters[1])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function functionBoundary(node: ESTree.Node): ESTree.Node | null {
|
||||||
|
let current = node.parent;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if (functionBoundaryTypes.has(current.type)) return current;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedVariableForIdentifier(
|
||||||
|
scopes: readonly {
|
||||||
|
readonly references: readonly {
|
||||||
|
readonly identifier: ESTree.Node;
|
||||||
|
readonly resolved: Variable | null;
|
||||||
|
}[];
|
||||||
|
}[],
|
||||||
|
identifier: ESTree.IdentifierReference,
|
||||||
|
): Variable | null {
|
||||||
|
for (const scope of scopes) {
|
||||||
|
const reference = scope.references.find(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.identifier.start === identifier.start &&
|
||||||
|
candidate.identifier.end === identifier.end,
|
||||||
|
);
|
||||||
|
if (reference !== undefined) return reference.resolved;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function variableDeclarator(variable: Variable): ESTree.VariableDeclarator | null {
|
||||||
|
for (const definition of variable.defs) {
|
||||||
|
if (definition.type === "Variable" && definition.node.type === "VariableDeclarator") {
|
||||||
|
return definition.node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function knownValueEvidence(
|
||||||
|
expression: ESTree.Expression,
|
||||||
|
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
||||||
|
boundary: ESTree.Node | null,
|
||||||
|
visitedVariables: ReadonlySet<Variable>,
|
||||||
|
): KnownValueEvidence | null {
|
||||||
|
const unwrapped = unwrapExpressionParentheses(expression);
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSAsExpression" || unwrapped.type === "TSTypeAssertion") {
|
||||||
|
if (broadTypeKind(unwrapped.typeAnnotation) !== null) return null;
|
||||||
|
return { type: unwrapped.typeAnnotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type === "Literal" || unwrapped.type === "TemplateLiteral") {
|
||||||
|
return { type: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
unwrapped.type === "ArrayExpression" ||
|
||||||
|
unwrapped.type === "ArrowFunctionExpression" ||
|
||||||
|
unwrapped.type === "ClassExpression" ||
|
||||||
|
unwrapped.type === "FunctionExpression" ||
|
||||||
|
unwrapped.type === "NewExpression" ||
|
||||||
|
unwrapped.type === "ObjectExpression"
|
||||||
|
) {
|
||||||
|
return { type: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "Identifier") return null;
|
||||||
|
const variable = resolvedVariableForIdentifier(scopes, unwrapped);
|
||||||
|
if (variable === null || visitedVariables.has(variable)) return null;
|
||||||
|
|
||||||
|
const annotatedIdentifier = variable.identifiers.find(
|
||||||
|
(identifier) => identifier.typeAnnotation !== null && identifier.typeAnnotation !== undefined,
|
||||||
|
);
|
||||||
|
const annotation = annotatedIdentifier?.typeAnnotation?.typeAnnotation;
|
||||||
|
if (annotation !== undefined && annotatedIdentifier !== undefined) {
|
||||||
|
if (functionBoundary(annotatedIdentifier) !== boundary || broadTypeKind(annotation) !== null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { type: annotation };
|
||||||
|
}
|
||||||
|
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (
|
||||||
|
declarator === null ||
|
||||||
|
declarator.parent.type !== "VariableDeclaration" ||
|
||||||
|
declarator.parent.kind !== "const" ||
|
||||||
|
declarator.init === null ||
|
||||||
|
variable.references.some((reference) => reference.isWrite() && !reference.init) ||
|
||||||
|
functionBoundary(declarator) !== boundary
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return knownValueEvidence(
|
||||||
|
declarator.init,
|
||||||
|
scopes,
|
||||||
|
boundary,
|
||||||
|
new Set([...visitedVariables, variable]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function widenedBinding(
|
||||||
|
variable: Variable,
|
||||||
|
scopes: Parameters<typeof resolvedVariableForIdentifier>[0],
|
||||||
|
): {
|
||||||
|
readonly broadKind: BroadTypeKind;
|
||||||
|
readonly evidence: KnownValueEvidence;
|
||||||
|
readonly declaredAt: number;
|
||||||
|
readonly boundary: ESTree.Node | null;
|
||||||
|
} | null {
|
||||||
|
const declarator = variableDeclarator(variable);
|
||||||
|
if (
|
||||||
|
declarator === null ||
|
||||||
|
declarator.parent.type !== "VariableDeclaration" ||
|
||||||
|
declarator.parent.kind !== "const" ||
|
||||||
|
declarator.id.type !== "Identifier" ||
|
||||||
|
declarator.init === null ||
|
||||||
|
variable.references.some((reference) => reference.isWrite() && !reference.init)
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const boundary = functionBoundary(declarator);
|
||||||
|
const declaredType = declarator.id.typeAnnotation?.typeAnnotation;
|
||||||
|
const initializerAssertion = assertionFromExpression(declarator.init);
|
||||||
|
const initializerBroadKind =
|
||||||
|
initializerAssertion === null ? null : broadTypeKind(initializerAssertion.typeAnnotation);
|
||||||
|
const declaredBroadKind = declaredType === undefined ? null : broadTypeKind(declaredType);
|
||||||
|
const broadKind = declaredBroadKind ?? initializerBroadKind;
|
||||||
|
if (broadKind === null) return null;
|
||||||
|
|
||||||
|
const originalExpression =
|
||||||
|
initializerAssertion !== null && initializerBroadKind !== null
|
||||||
|
? assertedExpression(initializerAssertion)
|
||||||
|
: declarator.init;
|
||||||
|
const evidence = knownValueEvidence(originalExpression, scopes, boundary, new Set([variable]));
|
||||||
|
return evidence === null ? null : { broadKind, evidence, declaredAt: declarator.end, boundary };
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertionIsNarrower(
|
||||||
|
sourceText: string,
|
||||||
|
broadKind: BroadTypeKind,
|
||||||
|
evidence: KnownValueEvidence,
|
||||||
|
assertedType: ESTree.TSType,
|
||||||
|
): boolean {
|
||||||
|
if (broadTypeKind(assertedType) !== null) return false;
|
||||||
|
if (broadKind === "top") return true;
|
||||||
|
if (typesHaveSameSyntax(sourceText, evidence.type, assertedType)) return true;
|
||||||
|
if (broadKind === "object") return isDefinitelyObjectType(assertedType);
|
||||||
|
return isDefinitelyNarrowerRecordType(assertedType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect immutable local bindings that erase a known type and are later asserted back to a narrower type. */
|
||||||
|
export const noWidenThenAssertRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Disallow local const flows that explicitly widen a known value before asserting the widened binding to a narrower type.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
widenThenAssert:
|
||||||
|
'Binding "{{name}}" discards type evidence and later recreates it with an assertion. Keep the precise type from initialization through use; parse boundary input once.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
let scopes: Parameters<typeof resolvedVariableForIdentifier>[0] = [];
|
||||||
|
|
||||||
|
const checkAssertion = (node: ESTree.TSAsExpression | ESTree.TSTypeAssertion) => {
|
||||||
|
const expression = assertedExpression(node);
|
||||||
|
if (expression.type !== "Identifier") return;
|
||||||
|
|
||||||
|
const variable = resolvedVariableForIdentifier(scopes, expression);
|
||||||
|
if (variable === null) return;
|
||||||
|
const widened = widenedBinding(variable, scopes);
|
||||||
|
if (
|
||||||
|
widened === null ||
|
||||||
|
node.start <= widened.declaredAt ||
|
||||||
|
functionBoundary(node) !== widened.boundary ||
|
||||||
|
!assertionIsNarrower(
|
||||||
|
context.sourceCode.text,
|
||||||
|
widened.broadKind,
|
||||||
|
widened.evidence,
|
||||||
|
node.typeAnnotation,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
messageId: "widenThenAssert",
|
||||||
|
data: { name: expression.name },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
Program() {
|
||||||
|
scopes = context.sourceCode.scopeManager.scopes;
|
||||||
|
},
|
||||||
|
TSAsExpression: checkAssertion,
|
||||||
|
TSTypeAssertion: checkAssertion,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { defineRule } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
import type { ESTree, SourceCode } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type TypeAssertion = ESTree.TSAsExpression | ESTree.TSTypeAssertion;
|
||||||
|
|
||||||
|
const commentOwnerKinds = new Set([
|
||||||
|
"ExpressionStatement",
|
||||||
|
"PropertyDefinition",
|
||||||
|
"ReturnStatement",
|
||||||
|
"ThrowStatement",
|
||||||
|
"VariableDeclaration",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isConstAssertion(node: TypeAssertion): boolean {
|
||||||
|
return (
|
||||||
|
node.typeAnnotation.type === "TSTypeReference" &&
|
||||||
|
node.typeAnnotation.typeName.type === "Identifier" &&
|
||||||
|
node.typeAnnotation.typeName.name === "const"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSafetyComment(sourceCode: SourceCode, node: TypeAssertion): boolean {
|
||||||
|
let current: ESTree.Node = node;
|
||||||
|
while (true) {
|
||||||
|
if (
|
||||||
|
sourceCode
|
||||||
|
.getCommentsBefore(current)
|
||||||
|
.some((comment) => comment.end <= node.start && /\bSAFETY\s*:/u.test(comment.value))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (commentOwnerKinds.has(current.type) || current.parent.type === "Program") return false;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Require every non-const type assertion to state the invariant TypeScript cannot express. */
|
||||||
|
export const requireSafetyCommentForTypeAssertionRule = defineRule({
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
docs: {
|
||||||
|
description:
|
||||||
|
"Require a nearby SAFETY comment for every TypeScript type assertion except const assertions.",
|
||||||
|
},
|
||||||
|
messages: {
|
||||||
|
missingSafetyComment:
|
||||||
|
"This type assertion has no `SAFETY:` justification. State the checked invariant immediately before the assertion or its containing statement.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createOnce(context) {
|
||||||
|
const checkAssertion = (node: TypeAssertion) => {
|
||||||
|
if (isConstAssertion(node) || hasSafetyComment(context.sourceCode, node)) return;
|
||||||
|
context.report({ node, messageId: "missingSafetyComment" });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
TSAsExpression: checkAssertion,
|
||||||
|
TSTypeAssertion: checkAssertion,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,502 @@
|
|||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
const BUILT_INS = new Set([
|
||||||
|
"Record",
|
||||||
|
"Readonly",
|
||||||
|
"Partial",
|
||||||
|
"Required",
|
||||||
|
"Pick",
|
||||||
|
"Omit",
|
||||||
|
"PropertyKey",
|
||||||
|
"NonNullable",
|
||||||
|
]);
|
||||||
|
const TRANSPARENT_WRAPPERS = new Set(["Readonly", "Partial", "Required", "NonNullable"]);
|
||||||
|
|
||||||
|
type TypeAliasEnvironment = ReadonlyMap<string, ESTree.TSType>;
|
||||||
|
|
||||||
|
type ResolvedType = {
|
||||||
|
readonly type: ESTree.TSType;
|
||||||
|
readonly substitutions: TypeAliasEnvironment;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UnsafeDictionary = {
|
||||||
|
readonly kind: "unsafe-dictionary";
|
||||||
|
readonly unsafeValue: "any" | "empty-object" | "object" | "union" | "unknown";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WideningTargetKind =
|
||||||
|
| "anonymous object"
|
||||||
|
| "generic container"
|
||||||
|
| "object"
|
||||||
|
| "open dictionary"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type WideningTarget = {
|
||||||
|
readonly kind: WideningTargetKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TypeEnvironment = {
|
||||||
|
readonly aliases: ReadonlyMap<string, ESTree.TSTypeAliasDeclaration>;
|
||||||
|
readonly interfaces: ReadonlyMap<string, readonly ESTree.TSInterfaceDeclaration[]>;
|
||||||
|
readonly shadowedBuiltIns: ReadonlySet<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function declaredStatement(statement: ESTree.Statement): ESTree.Node | null {
|
||||||
|
return statement.type === "ExportNamedDeclaration" ||
|
||||||
|
statement.type === "ExportDefaultDeclaration"
|
||||||
|
? (statement.declaration ?? null)
|
||||||
|
: statement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTypeEnvironment(program: ESTree.Program): TypeEnvironment {
|
||||||
|
const aliases = new Map<string, ESTree.TSTypeAliasDeclaration>();
|
||||||
|
const interfaces = new Map<string, ESTree.TSInterfaceDeclaration[]>();
|
||||||
|
const shadowedBuiltIns = new Set<string>();
|
||||||
|
|
||||||
|
for (const statement of program.body) {
|
||||||
|
const declaration = declaredStatement(statement);
|
||||||
|
if (declaration?.type === "ImportDeclaration") {
|
||||||
|
for (const specifier of declaration.specifiers) {
|
||||||
|
if (BUILT_INS.has(specifier.local.name)) shadowedBuiltIns.add(specifier.local.name);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaration?.type === "TSTypeAliasDeclaration") {
|
||||||
|
const existing = aliases.get(declaration.id.name);
|
||||||
|
if (existing === undefined) aliases.set(declaration.id.name, declaration);
|
||||||
|
else shadowedBuiltIns.add(declaration.id.name);
|
||||||
|
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaration?.type === "TSInterfaceDeclaration") {
|
||||||
|
const declarations = interfaces.get(declaration.id.name) ?? [];
|
||||||
|
declarations.push(declaration);
|
||||||
|
interfaces.set(declaration.id.name, declarations);
|
||||||
|
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaration?.type === "TSEnumDeclaration") {
|
||||||
|
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(declaration?.type === "ClassDeclaration" ||
|
||||||
|
declaration?.type === "FunctionDeclaration") &&
|
||||||
|
declaration.id !== null
|
||||||
|
) {
|
||||||
|
if (BUILT_INS.has(declaration.id.name)) shadowedBuiltIns.add(declaration.id.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { aliases, interfaces, shadowedBuiltIns };
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeReferenceName(type: ESTree.TSTypeReference): string | null {
|
||||||
|
return type.typeName.type === "Identifier" ? type.typeName.name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBuiltIn(name: string, environment: TypeEnvironment): boolean {
|
||||||
|
return BUILT_INS.has(name) && !environment.shadowedBuiltIns.has(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnappliedReferenceTo(type: ESTree.TSType, name: string): boolean {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
return (
|
||||||
|
unwrapped.type === "TSTypeReference" &&
|
||||||
|
typeReferenceName(unwrapped) === name &&
|
||||||
|
(unwrapped.typeArguments === null ||
|
||||||
|
unwrapped.typeArguments === undefined ||
|
||||||
|
unwrapped.typeArguments.params.length === 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapTransparentType(type: ESTree.TSType): ESTree.TSType {
|
||||||
|
let current = type;
|
||||||
|
while (
|
||||||
|
current.type === "TSParenthesizedType" ||
|
||||||
|
(current.type === "TSTypeOperator" && current.operator === "readonly")
|
||||||
|
) {
|
||||||
|
current = current.typeAnnotation;
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isNeverType(type: ESTree.TSType): boolean {
|
||||||
|
return unwrapTransparentType(type).type === "TSNeverKeyword";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEffectivelyEmptyMember(member: ESTree.TSSignature): boolean {
|
||||||
|
return (
|
||||||
|
member.type === "TSPropertySignature" &&
|
||||||
|
member.optional === true &&
|
||||||
|
member.typeAnnotation !== null &&
|
||||||
|
member.typeAnnotation !== undefined &&
|
||||||
|
isNeverType(member.typeAnnotation.typeAnnotation)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEffectivelyEmptyTypeLiteral(type: ESTree.TSTypeLiteral): boolean {
|
||||||
|
return type.members.length === 0 || type.members.every(isEffectivelyEmptyMember);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEffectivelyEmptyInterface(
|
||||||
|
declarations: readonly ESTree.TSInterfaceDeclaration[],
|
||||||
|
): boolean {
|
||||||
|
if (declarations.length !== 1) return false;
|
||||||
|
const [type] = declarations;
|
||||||
|
return (
|
||||||
|
type !== undefined &&
|
||||||
|
type.extends.length === 0 &&
|
||||||
|
(type.body.body.length === 0 || type.body.body.every(isEffectivelyEmptyMember))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvedSubstitutionArgument(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
base: TypeAliasEnvironment,
|
||||||
|
resolving: ReadonlySet<string> = new Set(),
|
||||||
|
): ESTree.TSType {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return type;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null || resolving.has(name)) return type;
|
||||||
|
const substitution = base.get(name);
|
||||||
|
if (substitution === undefined) return type;
|
||||||
|
const nextResolving = new Set(resolving);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return resolvedSubstitutionArgument(substitution, base, nextResolving);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aliasSubstitution(
|
||||||
|
alias: ESTree.TSTypeAliasDeclaration,
|
||||||
|
type: ESTree.TSTypeReference,
|
||||||
|
base: TypeAliasEnvironment,
|
||||||
|
): TypeAliasEnvironment | null {
|
||||||
|
const parameters = alias.typeParameters?.params ?? [];
|
||||||
|
const arguments_ = type.typeArguments?.params ?? [];
|
||||||
|
const next = new Map(base);
|
||||||
|
for (const [index, parameter] of parameters.entries()) {
|
||||||
|
const argument = arguments_[index] ?? parameter.default;
|
||||||
|
if (argument === null || argument === undefined) return null;
|
||||||
|
next.set(parameter.name.name, resolvedSubstitutionArgument(argument, next));
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unsafeDirectValue(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
resolvingAliases: ReadonlySet<string>,
|
||||||
|
): UnsafeDictionary["unsafeValue"] | null {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword") return "unknown";
|
||||||
|
if (unwrapped.type === "TSAnyKeyword") return "any";
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return "object";
|
||||||
|
if (unwrapped.type === "TSTypeLiteral" && isEffectivelyEmptyTypeLiteral(unwrapped))
|
||||||
|
return "empty-object";
|
||||||
|
if (unwrapped.type === "TSUnionType") {
|
||||||
|
return unwrapped.types.some(
|
||||||
|
(member) => unsafeDirectValue(member, environment, substitutions, resolvingAliases) !== null,
|
||||||
|
)
|
||||||
|
? "union"
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSIntersectionType") {
|
||||||
|
const unsafeMembers = unwrapped.types.map((member) =>
|
||||||
|
unsafeDirectValue(member, environment, substitutions, resolvingAliases),
|
||||||
|
);
|
||||||
|
if (unsafeMembers.includes("any")) return "any";
|
||||||
|
return unsafeMembers.length > 0 && unsafeMembers.every((member) => member !== null)
|
||||||
|
? unsafeMembers[0]
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return null;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return null;
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined
|
||||||
|
? null
|
||||||
|
: unsafeDirectValue(wrapped, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined) {
|
||||||
|
return isUnappliedReferenceTo(substitution, name)
|
||||||
|
? null
|
||||||
|
: unsafeDirectValue(substitution, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
const interfaceDeclarations = environment.interfaces.get(name);
|
||||||
|
if (interfaceDeclarations !== undefined) {
|
||||||
|
return isEffectivelyEmptyInterface(interfaceDeclarations) ? "empty-object" : null;
|
||||||
|
}
|
||||||
|
const alias = environment.aliases.get(name);
|
||||||
|
if (alias === undefined || resolvingAliases.has(name)) return null;
|
||||||
|
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||||
|
if (nextSubstitutions === null) return null;
|
||||||
|
const nextResolving = new Set(resolvingAliases);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return unsafeDirectValue(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dictionaryValueTypes(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
resolvingAliases: ReadonlySet<string>,
|
||||||
|
): readonly ResolvedType[] {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.flatMap((member): readonly ResolvedType[] =>
|
||||||
|
member.type === "TSIndexSignature" && member.typeAnnotation !== null
|
||||||
|
? [{ type: member.typeAnnotation.typeAnnotation, substitutions }]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type === "TSMappedType") {
|
||||||
|
return unwrapped.typeAnnotation === null
|
||||||
|
? []
|
||||||
|
: [{ type: unwrapped.typeAnnotation, substitutions }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return [];
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return [];
|
||||||
|
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined) {
|
||||||
|
return isUnappliedReferenceTo(substitution, name)
|
||||||
|
? []
|
||||||
|
: dictionaryValueTypes(substitution, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined
|
||||||
|
? []
|
||||||
|
: dictionaryValueTypes(wrapped, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "Record" && isBuiltIn(name, environment)) {
|
||||||
|
const value = unwrapped.typeArguments?.params[1] ?? null;
|
||||||
|
return value === null ? [] : [{ type: value, substitutions }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((name === "Pick" || name === "Omit") && isBuiltIn(name, environment)) {
|
||||||
|
const source = unwrapped.typeArguments?.params[0];
|
||||||
|
return source === undefined
|
||||||
|
? []
|
||||||
|
: dictionaryValueTypes(source, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
|
||||||
|
const alias = environment.aliases.get(name);
|
||||||
|
if (alias === undefined || resolvingAliases.has(name)) return [];
|
||||||
|
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||||
|
if (nextSubstitutions === null) return [];
|
||||||
|
const nextResolving = new Set(resolvingAliases);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return dictionaryValueTypes(alias.typeAnnotation, environment, nextSubstitutions, nextResolving);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyUnsafeDictionaryValue(
|
||||||
|
valueType: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): UnsafeDictionary | null {
|
||||||
|
const unsafeValue = unsafeDirectValue(valueType, environment, new Map(), new Set());
|
||||||
|
return unsafeValue === null ? null : { kind: "unsafe-dictionary", unsafeValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyUnsafeDictionary(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): UnsafeDictionary | null {
|
||||||
|
for (const valueType of dictionaryValueTypes(type, environment, new Map(), new Set())) {
|
||||||
|
const unsafeValue = unsafeDirectValue(
|
||||||
|
valueType.type,
|
||||||
|
environment,
|
||||||
|
valueType.substitutions,
|
||||||
|
new Set(),
|
||||||
|
);
|
||||||
|
if (unsafeValue !== null) return { kind: "unsafe-dictionary", unsafeValue };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvesToDictionary(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
resolvingAliases: ReadonlySet<string>,
|
||||||
|
): boolean {
|
||||||
|
return dictionaryValueTypes(type, environment, substitutions, resolvingAliases).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function classifyWideningTarget(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
): WideningTarget | null {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: unwrapped.members.length > 0
|
||||||
|
? { kind: "anonymous object" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSMappedType") return { kind: "open dictionary" };
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return null;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return null;
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined ? null : classifyWideningTarget(wrapped, environment);
|
||||||
|
}
|
||||||
|
if (name === "Record" && isBuiltIn(name, environment)) return { kind: "open dictionary" };
|
||||||
|
const alias = environment.aliases.get(name);
|
||||||
|
if (alias === undefined) return null;
|
||||||
|
if ((alias.typeParameters?.params.length ?? 0) > 0) {
|
||||||
|
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||||
|
return substitutions !== null &&
|
||||||
|
resolvesToDictionary(alias.typeAnnotation, environment, substitutions, new Set([name]))
|
||||||
|
? { kind: "generic container" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
const substitutions = aliasSubstitution(alias, unwrapped, new Map());
|
||||||
|
if (substitutions === null) return null;
|
||||||
|
const resolved = classifyAliasBroadTarget(
|
||||||
|
alias.typeAnnotation,
|
||||||
|
environment,
|
||||||
|
substitutions,
|
||||||
|
new Set([name]),
|
||||||
|
);
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBroadMappedKey(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
): boolean {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (
|
||||||
|
unwrapped.type === "TSStringKeyword" ||
|
||||||
|
unwrapped.type === "TSNumberKeyword" ||
|
||||||
|
unwrapped.type === "TSSymbolKeyword"
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSUnionType") {
|
||||||
|
return unwrapped.types.every((member) =>
|
||||||
|
isBroadMappedKey(member, environment, substitutions),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return false;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return false;
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined && !isUnappliedReferenceTo(substitution, name)) {
|
||||||
|
return isBroadMappedKey(substitution, environment, substitutions);
|
||||||
|
}
|
||||||
|
return name === "PropertyKey" && isBuiltIn(name, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyAliasBroadTarget(
|
||||||
|
type: ESTree.TSType,
|
||||||
|
environment: TypeEnvironment,
|
||||||
|
substitutions: TypeAliasEnvironment,
|
||||||
|
resolvingAliases: ReadonlySet<string>,
|
||||||
|
): WideningTarget | null {
|
||||||
|
const unwrapped = unwrapTransparentType(type);
|
||||||
|
if (unwrapped.type === "TSUnknownKeyword") return { kind: "unknown" };
|
||||||
|
if (unwrapped.type === "TSObjectKeyword") return { kind: "object" };
|
||||||
|
if (unwrapped.type === "TSTypeLiteral") {
|
||||||
|
return unwrapped.members.some((member) => member.type === "TSIndexSignature")
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type === "TSMappedType") {
|
||||||
|
return isBroadMappedKey(unwrapped.constraint, environment, substitutions)
|
||||||
|
? { kind: "open dictionary" }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
if (unwrapped.type !== "TSTypeReference") return null;
|
||||||
|
const name = typeReferenceName(unwrapped);
|
||||||
|
if (name === null) return null;
|
||||||
|
const substitution = substitutions.get(name);
|
||||||
|
if (substitution !== undefined) {
|
||||||
|
return isUnappliedReferenceTo(substitution, name)
|
||||||
|
? null
|
||||||
|
: classifyAliasBroadTarget(
|
||||||
|
substitution,
|
||||||
|
environment,
|
||||||
|
substitutions,
|
||||||
|
resolvingAliases,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (TRANSPARENT_WRAPPERS.has(name) && isBuiltIn(name, environment)) {
|
||||||
|
const wrapped = unwrapped.typeArguments?.params[0];
|
||||||
|
return wrapped === undefined
|
||||||
|
? null
|
||||||
|
: classifyAliasBroadTarget(wrapped, environment, substitutions, resolvingAliases);
|
||||||
|
}
|
||||||
|
if (name === "Record" && isBuiltIn(name, environment)) {
|
||||||
|
return { kind: "open dictionary" };
|
||||||
|
}
|
||||||
|
const alias = environment.aliases.get(name);
|
||||||
|
if (alias === undefined || resolvingAliases.has(name)) return null;
|
||||||
|
const nextSubstitutions = aliasSubstitution(alias, unwrapped, substitutions);
|
||||||
|
if (nextSubstitutions === null) return null;
|
||||||
|
const nextResolving = new Set(resolvingAliases);
|
||||||
|
nextResolving.add(name);
|
||||||
|
return classifyAliasBroadTarget(
|
||||||
|
alias.typeAnnotation,
|
||||||
|
environment,
|
||||||
|
nextSubstitutions,
|
||||||
|
nextResolving,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPopulatedObjectExpression(expression: ESTree.Expression): boolean {
|
||||||
|
let current = expression;
|
||||||
|
while (
|
||||||
|
current.type === "ParenthesizedExpression" ||
|
||||||
|
current.type === "TSAsExpression" ||
|
||||||
|
current.type === "TSTypeAssertion" ||
|
||||||
|
current.type === "TSNonNullExpression"
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
return current.type === "ObjectExpression" && current.properties.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnownEvidenceExpression(expression: ESTree.Expression): boolean {
|
||||||
|
let current = expression;
|
||||||
|
while (
|
||||||
|
current.type === "ParenthesizedExpression" ||
|
||||||
|
current.type === "TSAsExpression" ||
|
||||||
|
current.type === "TSTypeAssertion" ||
|
||||||
|
current.type === "TSNonNullExpression" ||
|
||||||
|
current.type === "TSSatisfiesExpression"
|
||||||
|
) {
|
||||||
|
current = current.expression;
|
||||||
|
}
|
||||||
|
if (current.type === "ObjectExpression") return true;
|
||||||
|
return (
|
||||||
|
current.type === "ArrayExpression" ||
|
||||||
|
current.type === "ArrowFunctionExpression" ||
|
||||||
|
current.type === "ClassExpression" ||
|
||||||
|
current.type === "FunctionExpression" ||
|
||||||
|
current.type === "NewExpression" ||
|
||||||
|
current.type === "Literal" ||
|
||||||
|
current.type === "TemplateLiteral" ||
|
||||||
|
current.type === "UnaryExpression"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import type { ESTree } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
type VisitorKeys = Readonly<Record<string, readonly string[]>>;
|
||||||
|
|
||||||
|
function isNode(value: unknown): value is ESTree.Node {
|
||||||
|
return (
|
||||||
|
typeof value === "object" &&
|
||||||
|
value !== null &&
|
||||||
|
"type" in value &&
|
||||||
|
typeof value.type === "string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectInferTypeParameterNames(
|
||||||
|
node: ESTree.Node,
|
||||||
|
visitorKeys: VisitorKeys,
|
||||||
|
names: Set<string>,
|
||||||
|
): void {
|
||||||
|
if (node.type === "TSInferType") names.add(node.typeParameter.name.name);
|
||||||
|
const record = node as unknown as Readonly<Record<string, unknown>>;
|
||||||
|
for (const key of visitorKeys[node.type] ?? []) {
|
||||||
|
const value = record[key];
|
||||||
|
if (isNode(value)) {
|
||||||
|
collectInferTypeParameterNames(value, visitorKeys, names);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(value)) continue;
|
||||||
|
for (const child of value) {
|
||||||
|
if (isNode(child)) collectInferTypeParameterNames(child, visitorKeys, names);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect type binders that are in scope at a node and can shadow module aliases. */
|
||||||
|
export function lexicalTypeParameterNames(
|
||||||
|
node: ESTree.Node,
|
||||||
|
visitorKeys: VisitorKeys,
|
||||||
|
): ReadonlySet<string> {
|
||||||
|
const names = new Set<string>();
|
||||||
|
let descendant: ESTree.Node = node;
|
||||||
|
let current: ESTree.Node | null = node;
|
||||||
|
while (current !== null && current.type !== "Program") {
|
||||||
|
if ("typeParameters" in current) {
|
||||||
|
for (const parameter of current.typeParameters?.params ?? []) {
|
||||||
|
names.add(parameter.name.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
current.type === "TSMappedType" &&
|
||||||
|
(descendant === current.nameType || descendant === current.typeAnnotation)
|
||||||
|
) {
|
||||||
|
names.add(current.key.name);
|
||||||
|
}
|
||||||
|
if (current.type === "TSConditionalType" && descendant === current.trueType) {
|
||||||
|
collectInferTypeParameterNames(current.extendsType, visitorKeys, names);
|
||||||
|
}
|
||||||
|
descendant = current;
|
||||||
|
current = current.parent;
|
||||||
|
}
|
||||||
|
return names;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { ESTree, Scope, SourceCode, Variable } from "@oxlint/plugins";
|
||||||
|
|
||||||
|
function resolveVariable(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
identifier: ESTree.IdentifierReference,
|
||||||
|
): Variable | null {
|
||||||
|
let scope: Scope | null = sourceCode.getScope(identifier);
|
||||||
|
while (scope !== null) {
|
||||||
|
const variable = scope.set.get(identifier.name);
|
||||||
|
if (variable !== undefined) return variable;
|
||||||
|
scope = scope.upper;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGlobalReflect(sourceCode: SourceCode, expression: ESTree.Expression): boolean {
|
||||||
|
if (expression.type !== "Identifier" || expression.name !== "Reflect") return false;
|
||||||
|
if (sourceCode.isGlobalReference(expression)) return true;
|
||||||
|
const variable = resolveVariable(sourceCode, expression);
|
||||||
|
return variable === null || variable.defs.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reports whether a call target names one method on the global Reflect object. */
|
||||||
|
export function isGlobalReflectMethodCall(
|
||||||
|
sourceCode: SourceCode,
|
||||||
|
callee: ESTree.Expression,
|
||||||
|
methodName: string,
|
||||||
|
): boolean {
|
||||||
|
if (!("property" in callee) || !("object" in callee) || !("computed" in callee)) return false;
|
||||||
|
if (!isGlobalReflect(sourceCode, callee.object)) return false;
|
||||||
|
const property = callee.property;
|
||||||
|
return callee.computed
|
||||||
|
? property.type === "Literal" && property.value === methodName
|
||||||
|
: property.type === "Identifier" && property.name === methodName;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user