@drkmttr/adhere 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +177 -41
- package/package.json +7 -7
- package/presets/effect/basics/gen-for-sequencing.md +1 -1
- package/presets/effect/data/brand-meaningful-primitives.md +1 -1
- package/presets/effect/data/variants-are-tagged-unions.md +1 -1
- package/presets/effect/services/dependencies-through-layers.md +1 -1
- package/presets/react/effects/clean-up-what-they-start.md +23 -0
- package/presets/react/effects/event-logic-in-handlers.md +29 -0
- package/presets/react/effects/external-stores-use-sync-external-store.md +38 -0
- package/presets/react/effects/fetches-ignore-stale-responses.md +26 -0
- package/presets/react/effects/notify-parents-in-handlers.md +29 -0
- package/presets/react/server/no-private-data-to-client.md +23 -0
- package/presets/react/server/server-functions-authorize-and-validate.md +29 -0
- package/presets/react/state/no-contradictory-state.md +17 -0
- package/presets/react/state/no-props-copied-into-state.md +23 -0
- package/presets/react/state/store-ids-not-copies.md +20 -0
- package/presets/security/constant-time-secret-compare.md +22 -0
- package/presets/security/no-secrets-in-output.md +19 -0
- package/presets/security/no-untrusted-input-in-commands-or-paths.md +18 -0
- package/presets/security/parameterized-queries.md +16 -0
- package/presets/typescript/async/concurrent-independent-work.md +23 -0
- package/presets/typescript/async/network-calls-have-timeouts.md +19 -0
- package/presets/typescript/design/no-argument-mutation.md +20 -0
- package/presets/typescript/errors/no-swallowed-errors.md +29 -0
- package/presets/typescript/errors/release-on-every-path.md +29 -0
- package/presets/typescript/testing/assert-outcomes.md +22 -0
- package/presets/typescript/testing/independent-tests.md +41 -0
- package/presets/typescript/types/assertions-only-when-proven.md +20 -0
- package/presets/typescript/types/derive-dont-restate.md +24 -0
- package/presets/typescript/types/unrepresentable-invalid-states.md +23 -0
- package/presets/typescript/types/validate-external-data.md +21 -0
- package/skills/adhere/SKILL.md +4 -3
- package/skills/adhere-fix/SKILL.md +10 -4
- package/src/cli.ts +9 -1
- package/src/config.ts +29 -1
- package/src/excerpt.ts +72 -62
- package/src/highlight.ts +2 -0
- package/src/markdown.ts +6 -0
- package/src/presets.ts +6 -0
- package/src/rules.ts +1 -1
- package/src/services/AuditCache.ts +31 -5
- package/src/services/Jev.http.ts +76 -11
- package/src/services/Jev.ts +191 -73
- package/src/services/SourceWalker.ts +23 -7
- package/src/suppress.ts +13 -7
- package/src/version.ts +25 -1
- package/src/workflows/audit.ts +96 -26
- package/src/workflows/format.ts +51 -11
- package/presets/alchemy/data/parameterized-sql.md +0 -17
- package/presets/alchemy/secrets/never-logged-returned-or-output.md +0 -19
- package/presets/effect/basics/external-calls-are-resilient.md +0 -21
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A component must read a prop, or compute from it, during render, never copy it into state, where the copy stops following the prop when the parent passes a new value.
|
|
3
|
+
appliesTo: ["a React component or hook"]
|
|
4
|
+
excludeIf: ["a prop meant only as the starting value and named for that, such as initialColor or defaultValue"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Must
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
function Message({ messageColor }: { messageColor: string }) {
|
|
11
|
+
const color = messageColor;
|
|
12
|
+
return <p style={{ color }}>Hello</p>;
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Never
|
|
17
|
+
|
|
18
|
+
```tsx
|
|
19
|
+
function Message({ messageColor }: { messageColor: string }) {
|
|
20
|
+
const [color, setColor] = useState(messageColor);
|
|
21
|
+
return <p style={{ color }}>Hello</p>;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: State that says which item of a list is selected, or otherwise points into data held elsewhere, should hold the item's id and look the item up during render, and should not hold a copy of the item, which goes stale when the list changes.
|
|
3
|
+
appliesTo: ["a React component or hook"]
|
|
4
|
+
level: warning
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Should
|
|
8
|
+
|
|
9
|
+
```tsx
|
|
10
|
+
const [items, setItems] = useState(initialItems);
|
|
11
|
+
const [selectedId, setSelectedId] = useState(initialItems[0].id);
|
|
12
|
+
const selectedItem = items.find((item) => item.id === selectedId);
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Should not
|
|
16
|
+
|
|
17
|
+
```tsx
|
|
18
|
+
const [items, setItems] = useState(initialItems);
|
|
19
|
+
const [selectedItem, setSelectedItem] = useState(initialItems[0]);
|
|
20
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Code that checks a secret it received, such as a token, an API key, or an HMAC signature, must compare it in constant time, as `crypto.timingSafeEqual` or a library's verify function does, never with `===`, `!==`, or a string method.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const expected = createHmac("sha256", WEBHOOK_SECRET).update(body).digest();
|
|
9
|
+
const received = Buffer.from(request.headers.get("x-signature") ?? "", "hex");
|
|
10
|
+
if (received.length !== expected.length || !timingSafeEqual(received, expected)) {
|
|
11
|
+
return new Response("Invalid signature", { status: 401 });
|
|
12
|
+
}
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Never
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const expected = createHmac("sha256", WEBHOOK_SECRET).update(body).digest("hex");
|
|
19
|
+
if (request.headers.get("x-signature") !== expected) {
|
|
20
|
+
return new Response("Invalid signature", { status: 401 });
|
|
21
|
+
}
|
|
22
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A secret, such as a password, an API key, a token, a private key, a session id, or a connection string with credentials, must never be written to a log, put in an error message, or returned in an HTTP response; code must refer to a secret only by its name, its presence, or a non-secret id.
|
|
3
|
+
appliesTo: ["code that writes a log, builds an error message, or builds an HTTP response"]
|
|
4
|
+
excludeIf: ["a masked secret that shows at most a few of its characters"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Must
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
logger.info("calling billing", { keyId: apiKey.id });
|
|
11
|
+
throw new Error(`Could not connect to the database at ${dbHost}`);
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Never
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
logger.info(`calling billing with key ${apiKey.secret}`);
|
|
18
|
+
throw new Error(`Could not connect to ${process.env.DATABASE_URL}`);
|
|
19
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Input from outside the program, such as a request's parameters or body, must reach a shell command only as a separate argument, and a file path only after the resolved path is checked to be inside the directory meant for it; it must never be interpolated into a command string, passed to `eval` or `new Function`, or joined into a path unchecked.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const file = resolve(UPLOADS, request.params.name);
|
|
9
|
+
if (!file.startsWith(UPLOADS + sep)) return new Response("Not found", { status: 404 });
|
|
10
|
+
const { stdout } = await execFile("ffprobe", ["-show_format", file]);
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Never
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const file = join(UPLOADS, request.params.name);
|
|
17
|
+
const { stdout } = await exec(`ffprobe -show_format ${file}`);
|
|
18
|
+
```
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A SQL query that includes a value that comes from outside the code, such as user input or a function's argument, must pass it as a bound parameter, or through a query builder or tagged template that binds it, never concatenate or interpolate the value into the query string.
|
|
3
|
+
excludeIf: ["an identifier, such as a table or column name, the code chose from a fixed list"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const { rows } = await db.query("SELECT * FROM users WHERE email = $1", [email]);
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Never
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
const { rows } = await db.query(`SELECT * FROM users WHERE email = '${email}'`);
|
|
16
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Awaited calls that do not depend on each other's results should run concurrently, with `Promise.all` or `Promise.allSettled`, and should not be awaited one after another or one per loop iteration.
|
|
3
|
+
excludeIf: ["calls that must run in order, such as writes whose order matters", "calls spaced out on purpose, such as to respect a rate limit"]
|
|
4
|
+
level: warning
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Should
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const [user, orders, preferences] = await Promise.all([
|
|
11
|
+
getUser(userId),
|
|
12
|
+
getOrders(userId),
|
|
13
|
+
getPreferences(userId),
|
|
14
|
+
]);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Should not
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
const user = await getUser(userId);
|
|
21
|
+
const orders = await getOrders(userId);
|
|
22
|
+
const preferences = await getPreferences(userId);
|
|
23
|
+
```
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: An HTTP request, such as a `fetch` or a call through an HTTP client, should carry a timeout or an AbortSignal that ends it, and should not wait without limit.
|
|
3
|
+
excludeIf: ["a call through a client configured with a default timeout", "a call whose signal the caller passes in", "a request from browser code to the app's own API"]
|
|
4
|
+
level: warning
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Should
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const response = await fetch(`${BILLING_URL}/invoices/${id}`, {
|
|
11
|
+
signal: AbortSignal.timeout(5_000),
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Should not
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const response = await fetch(`${BILLING_URL}/invoices/${id}`);
|
|
19
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A function must return a changed copy of an object or array it received as an argument, never mutate the argument, such as by sorting it in place, pushing to it, deleting a key, or assigning to a property, where its caller does not expect the change.
|
|
3
|
+
excludeIf: ["a function whose name says it changes its argument, such as sortInPlace", "an accumulator the function builds, such as a reduce callback's"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const byNewest = (posts: ReadonlyArray<Post>) =>
|
|
10
|
+
posts.toSorted((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Never
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
const byNewest = (posts: Post[]) => {
|
|
17
|
+
posts.sort((a, b) => b.publishedAt.getTime() - a.publishedAt.getTime());
|
|
18
|
+
return posts;
|
|
19
|
+
};
|
|
20
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A caught error must be handled, rethrown, or returned as a failure the caller can tell apart from success, never discarded or turned into a value that reads as a successful result, such as `null`, an empty array, or a default. Logging an error and carrying on as if nothing failed discards it. Recovering from one specific, expected error, such as a missing file that has a default, handles it.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
async function loadSettings(path: string): Promise<Settings> {
|
|
9
|
+
try {
|
|
10
|
+
return parseSettings(await readFile(path, "utf8"));
|
|
11
|
+
} catch (error) {
|
|
12
|
+
if (isNotFound(error)) return DEFAULT_SETTINGS;
|
|
13
|
+
throw new Error(`Could not load settings from ${path}`, { cause: error });
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Never
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
async function loadInvoices(customerId: string): Promise<Invoice[]> {
|
|
22
|
+
try {
|
|
23
|
+
return await api.invoices.list({ customerId });
|
|
24
|
+
} catch (error) {
|
|
25
|
+
console.error(error);
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A resource the code acquires and must give back, such as a file handle, a lock, a database connection or transaction, a temporary file, or a child process, must be released on every path, including when an error is thrown between acquiring and releasing it, with `finally` or `using`; it must never be released only after the work succeeds. A resource whose owner releases it, such as a framework or a pool's query method, is out of scope.
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
## Must
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const client = await pool.connect();
|
|
9
|
+
try {
|
|
10
|
+
await client.query("BEGIN");
|
|
11
|
+
await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, from]);
|
|
12
|
+
await client.query("COMMIT");
|
|
13
|
+
} catch (error) {
|
|
14
|
+
await client.query("ROLLBACK");
|
|
15
|
+
throw error;
|
|
16
|
+
} finally {
|
|
17
|
+
client.release();
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Never
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
const client = await pool.connect();
|
|
25
|
+
await client.query("BEGIN");
|
|
26
|
+
await client.query("UPDATE accounts SET balance = balance - $1 WHERE id = $2", [amount, from]);
|
|
27
|
+
await client.query("COMMIT");
|
|
28
|
+
client.release();
|
|
29
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A test must assert what the code under test returned or did, such as its result, what it rendered, or the state it left, never only that it ran without throwing or returned something defined.
|
|
3
|
+
tests: only
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
it("applies the discount to each line", () => {
|
|
10
|
+
const total = checkout([{ price: 100, quantity: 2 }], { discount: 0.1 });
|
|
11
|
+
expect(total).toBe(180);
|
|
12
|
+
});
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Never
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
it("applies the discount to each line", () => {
|
|
19
|
+
const total = checkout([{ price: 100, quantity: 2 }], { discount: 0.1 });
|
|
20
|
+
expect(total).toBeDefined();
|
|
21
|
+
});
|
|
22
|
+
```
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A test must set up the state it needs itself, or in a `beforeEach`, and must never rely on state another test left behind, such as a module-level variable, a record another test inserted, or a mock another test configured.
|
|
3
|
+
excludeIf: ["state a suite sets up once for all its tests, in beforeAll or its body, that no test changes"]
|
|
4
|
+
tests: only
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Must
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
let cart: Cart;
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
cart = new Cart();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("adds an item", () => {
|
|
16
|
+
cart.add(book);
|
|
17
|
+
expect(cart.items).toEqual([book]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("totals its items", () => {
|
|
21
|
+
cart.add(book);
|
|
22
|
+
cart.add(pen);
|
|
23
|
+
expect(cart.total()).toBe(book.price + pen.price);
|
|
24
|
+
});
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Never
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const cart = new Cart();
|
|
31
|
+
|
|
32
|
+
it("adds an item", () => {
|
|
33
|
+
cart.add(book);
|
|
34
|
+
expect(cart.items).toEqual([book]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("totals its items", () => {
|
|
38
|
+
cart.add(pen);
|
|
39
|
+
expect(cart.total()).toBe(book.price + pen.price);
|
|
40
|
+
});
|
|
41
|
+
```
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A type assertion with `as`, or a non-null assertion with `!`, should claim only what the code around it has already established, and should not be used to make a type error go away. `as const` claims nothing and is out of scope.
|
|
3
|
+
excludeIf: ["data read from outside the program, such as a parsed body or a fetch response"]
|
|
4
|
+
level: warning
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Should
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const input = document.querySelector("#email");
|
|
11
|
+
if (!(input instanceof HTMLInputElement)) throw new Error("#email is not an input");
|
|
12
|
+
input.value = email;
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Should not
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const input = document.querySelector("#email") as HTMLInputElement;
|
|
19
|
+
input.value = email;
|
|
20
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A type that describes the same data as another type, a schema, a function, or a constant should be derived from it, with a utility type such as `Pick`, with `typeof` or `keyof`, or as a schema's inferred type, and should not restate its fields or values by hand.
|
|
3
|
+
level: warning
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Should
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
const ROLES = ["admin", "member", "guest"] as const;
|
|
10
|
+
type Role = (typeof ROLES)[number];
|
|
11
|
+
|
|
12
|
+
const User = z.object({ id: z.string(), email: z.string(), role: z.enum(ROLES) });
|
|
13
|
+
type User = z.infer<typeof User>;
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Should not
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const ROLES = ["admin", "member", "guest"];
|
|
20
|
+
type Role = "admin" | "member" | "guest";
|
|
21
|
+
|
|
22
|
+
const User = z.object({ id: z.string(), email: z.string(), role: z.enum(ROLES) });
|
|
23
|
+
type User = { id: string; email: string; role: Role };
|
|
24
|
+
```
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: A type whose fields are valid only in some combinations must be a union with one member per state, each carrying only the fields that state has, never one object type of optional fields and flags whose valid combinations only the code that reads them knows. A union of Schema.TaggedClass members is such a union.
|
|
3
|
+
appliesTo: ["a declared type, interface, or schema"]
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Must
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
type Request<T> =
|
|
10
|
+
| { status: "loading" }
|
|
11
|
+
| { status: "ok"; data: T }
|
|
12
|
+
| { status: "error"; error: Error };
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Never
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
interface Request<T> {
|
|
19
|
+
loading: boolean;
|
|
20
|
+
data?: T;
|
|
21
|
+
error?: Error;
|
|
22
|
+
}
|
|
23
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Data from outside the program, such as a parsed request body, a fetch response, a file's contents, a message, or an environment variable, must be checked at runtime before code gives it a type, never cast with `as` or assigned from `any` to a declared type. A schema's parse or decode, such as Zod's or Effect Schema's, or a type guard that checks each field it claims, is a runtime check.
|
|
3
|
+
appliesTo: ["code that reads data from outside the program"]
|
|
4
|
+
excludeIf: ["a value read through a typed client of the program's own database"]
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Must
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
const User = z.object({ id: z.string(), email: z.string().email() });
|
|
11
|
+
|
|
12
|
+
const response = await fetch(`/api/users/${id}`);
|
|
13
|
+
const user = User.parse(await response.json());
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Never
|
|
17
|
+
|
|
18
|
+
```ts
|
|
19
|
+
const response = await fetch(`/api/users/${id}`);
|
|
20
|
+
const user = (await response.json()) as User;
|
|
21
|
+
```
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -94,9 +94,10 @@ Rules for the rule:
|
|
|
94
94
|
- Jev reads code without its comments, except comments that say `@adhere`. A
|
|
95
95
|
rule about comments, such as doc comments on exports, needs
|
|
96
96
|
`includeComments: true` in the config.
|
|
97
|
-
- Rules skip tests: `.test
|
|
98
|
-
`
|
|
99
|
-
|
|
97
|
+
- Rules skip tests: `.test` and `.spec` files, such as `a.test.ts` and
|
|
98
|
+
`Button.spec.tsx`, and files under `test/`, `tests/`, `__tests__/`, or
|
|
99
|
+
`fixtures/`. A rule about tests says `tests: only` in its front matter, and
|
|
100
|
+
one that holds in tests as well `tests: include`.
|
|
100
101
|
- A nit, or a rule that tends to flag code wrongly, says `level: warning` in
|
|
101
102
|
its front matter: its findings show in amber and do not fail CI.
|
|
102
103
|
- `threshold` is optional. Start without it; set it in step 5 if needed.
|
|
@@ -7,8 +7,8 @@ description: Verify and fix the findings `adhere lint` reports. Decide whether e
|
|
|
7
7
|
|
|
8
8
|
adhere is a linter for rules a normal linter cannot check. Jev, TypeSafe AI's
|
|
9
9
|
model, reads each file against each rule and answers with the probability that
|
|
10
|
-
the file breaks it; adhere reports each rule above a threshold,
|
|
11
|
-
points to. A finding is a judgment, not a proof. On one rule adhere's authors
|
|
10
|
+
the file breaks it; adhere reports each rule above a threshold, in the section
|
|
11
|
+
of the file Jev points to. A finding is a judgment, not a proof. On one rule adhere's authors
|
|
12
12
|
checked against the live API, only 1 of its 20 findings was a real violation.
|
|
13
13
|
Verify every finding before you change code for it: fix the real ones, and
|
|
14
14
|
report the false ones with the evidence.
|
|
@@ -34,7 +34,13 @@ Each finding shows:
|
|
|
34
34
|
- the rule id, a preset's with its preset first, as in
|
|
35
35
|
`alchemy/providers/idempotent-delete`, Jev's probability, and the rule's
|
|
36
36
|
description;
|
|
37
|
-
- the
|
|
37
|
+
- the section of the file Jev points at, a run of whole statements of about 30
|
|
38
|
+
lines, with the lines it names underlined, or marked with a bar when they
|
|
39
|
+
are several: usually where the violation starts, which may run on past them;
|
|
40
|
+
- sometimes `warning: this file may not show enough to check this rule`: the
|
|
41
|
+
rule may turn on another file, a library, or a service. Most of these are
|
|
42
|
+
false; read what the code relies on before fixing it. Its `help:` suggests
|
|
43
|
+
an `@adhere` note, which follows the same rule as a suppression (see below);
|
|
38
44
|
- `hint:`, the code the rule wants, or `never:` for a rule that only shows code
|
|
39
45
|
to avoid.
|
|
40
46
|
|
|
@@ -57,7 +63,7 @@ the code under `## Must` and `## Never`, and anything it puts out of scope.
|
|
|
57
63
|
|
|
58
64
|
Read the code as a reviewer would, not only the excerpt:
|
|
59
65
|
|
|
60
|
-
- **The whole unit.** Read the function, handler, or class the
|
|
66
|
+
- **The whole unit.** Read the function, handler, or class the section holds,
|
|
61
67
|
and what calls it.
|
|
62
68
|
- **What it calls.** Follow helpers into other files. A helper can already do
|
|
63
69
|
what the rule asks, such as a delete that looks unguarded calling a client
|
package/src/cli.ts
CHANGED
|
@@ -99,6 +99,13 @@ const threshold = Flag.float("threshold").pipe(
|
|
|
99
99
|
),
|
|
100
100
|
);
|
|
101
101
|
|
|
102
|
+
const sufficiencyThreshold = Flag.float("sufficiency-threshold").pipe(
|
|
103
|
+
Flag.optional,
|
|
104
|
+
Flag.withDescription(
|
|
105
|
+
"Warn on a finding when Jev's probability that the file shows enough to decide it is below this value, 0 to 1. Replaces the config's sufficiencyThreshold; the default is 0.6.",
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
|
|
102
109
|
const limit = Flag.integer("limit").pipe(
|
|
103
110
|
Flag.filter(
|
|
104
111
|
(checks) => checks >= 0,
|
|
@@ -219,7 +226,7 @@ export const auditLayer = (flags: Flags, filter: ReadonlyArray<string> = []) =>
|
|
|
219
226
|
/** `adhere lint`: the audit. */
|
|
220
227
|
export const lintCommand = Command.make(
|
|
221
228
|
"lint",
|
|
222
|
-
{ preset, threshold, yes, limit, rpm, filter, denyWarnings },
|
|
229
|
+
{ preset, threshold, sufficiencyThreshold, yes, limit, rpm, filter, denyWarnings },
|
|
223
230
|
(input) =>
|
|
224
231
|
Effect.gen(function* () {
|
|
225
232
|
const stdio = yield* Stdio.Stdio;
|
|
@@ -264,6 +271,7 @@ export const lintCommand = Command.make(
|
|
|
264
271
|
{
|
|
265
272
|
presets: input.preset,
|
|
266
273
|
threshold: Option.getOrUndefined(input.threshold),
|
|
274
|
+
sufficiencyThreshold: Option.getOrUndefined(input.sufficiencyThreshold),
|
|
267
275
|
rpm: Option.getOrUndefined(input.rpm),
|
|
268
276
|
},
|
|
269
277
|
input.filter,
|
package/src/config.ts
CHANGED
|
@@ -28,6 +28,17 @@ const RuleFields = Schema.Struct({
|
|
|
28
28
|
* findings, which does not.
|
|
29
29
|
*/
|
|
30
30
|
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
31
|
+
/**
|
|
32
|
+
* Where the rule applies, each a description of code that breaks it: Jev is
|
|
33
|
+
* asked of each whether any code that breaks the rule is as it describes,
|
|
34
|
+
* and a finding stands only when it says yes to every one.
|
|
35
|
+
*/
|
|
36
|
+
appliesTo: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
37
|
+
/**
|
|
38
|
+
* Where the rule does not apply: Jev is asked of each whether all the code
|
|
39
|
+
* that breaks the rule is as it describes, and a yes to any drops the finding.
|
|
40
|
+
*/
|
|
41
|
+
excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
31
42
|
});
|
|
32
43
|
|
|
33
44
|
/** Before 0.7, a rule's examples were `reference` and `avoid`: read as `must` and `never`. */
|
|
@@ -112,6 +123,12 @@ export const AdhereConfig = Schema.Struct({
|
|
|
112
123
|
* The minimum confidence threshold for a positive match. Defaults to 0.8
|
|
113
124
|
*/
|
|
114
125
|
threshold: Schema.optionalKey(Schema.Finite),
|
|
126
|
+
/**
|
|
127
|
+
* Below this, Jev's probability that a file shows enough to decide a
|
|
128
|
+
* finding's rule adds a warning to the finding: check what the code relies
|
|
129
|
+
* on outside the file. Defaults to 0.6
|
|
130
|
+
*/
|
|
131
|
+
sufficiencyThreshold: Schema.optionalKey(Schema.Finite),
|
|
115
132
|
/**
|
|
116
133
|
* The presets to use for rule matching. Defaults to an empty array. Current presets
|
|
117
134
|
* include "effect" and "alchemy". Subcategories can be expanded by appending `/<subcategory>`.
|
|
@@ -175,13 +192,17 @@ export const SKIPPED_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
|
175
192
|
/**
|
|
176
193
|
* Directories whose files are tests, matched as whole path segments below the
|
|
177
194
|
* working directory, as skipped directories are. Only a rule that says `tests`
|
|
178
|
-
* judges them, or a `.test
|
|
195
|
+
* judges them, or a `.test` or `.spec` file, such as `a.test.tsx`, anywhere.
|
|
179
196
|
*/
|
|
180
197
|
export const TEST_DIRECTORIES: ReadonlySet<string> = new Set([
|
|
181
198
|
"test",
|
|
182
199
|
"tests",
|
|
183
200
|
"__tests__",
|
|
201
|
+
"testing",
|
|
184
202
|
"fixtures",
|
|
203
|
+
"fixture",
|
|
204
|
+
"__fixtures__",
|
|
205
|
+
"__mocks__",
|
|
185
206
|
]);
|
|
186
207
|
|
|
187
208
|
/** The shape a config file default-exports: the schema's input, where `presets` is optional. */
|
|
@@ -212,11 +233,14 @@ export type Loaded<T extends { readonly rules?: unknown }> = Omit<T, "rules"> &
|
|
|
212
233
|
|
|
213
234
|
export const DEFAULT_MODEL = "jev-latest";
|
|
214
235
|
export const DEFAULT_THRESHOLD = 0.8;
|
|
236
|
+
export const DEFAULT_SUFFICIENCY_THRESHOLD = 0.6;
|
|
215
237
|
|
|
216
238
|
/** A config with its presets folded in and every default applied. */
|
|
217
239
|
export interface ResolvedConfig {
|
|
218
240
|
readonly model: string;
|
|
219
241
|
readonly threshold: number;
|
|
242
|
+
/** Below this, a finding's sufficiency adds a warning to it. */
|
|
243
|
+
readonly sufficiencyThreshold: number;
|
|
220
244
|
readonly rules: Rules;
|
|
221
245
|
readonly scopedRules?: RuleSet;
|
|
222
246
|
/** Requests to Jev a minute, at most. Unset, requests go out as fast as they are made. */
|
|
@@ -232,6 +256,8 @@ export interface Flags {
|
|
|
232
256
|
readonly presets?: ReadonlyArray<PresetName>;
|
|
233
257
|
/** Replaces the config's global threshold. Per-rule thresholds still win. */
|
|
234
258
|
readonly threshold?: number | undefined;
|
|
259
|
+
/** Replaces the config's sufficiency threshold. */
|
|
260
|
+
readonly sufficiencyThreshold?: number | undefined;
|
|
235
261
|
/** Throttles requests to Jev to at most this many a minute. */
|
|
236
262
|
readonly rpm?: number | undefined;
|
|
237
263
|
}
|
|
@@ -271,6 +297,8 @@ export const resolveConfig = (
|
|
|
271
297
|
return {
|
|
272
298
|
model: config.model ?? last("model") ?? DEFAULT_MODEL,
|
|
273
299
|
threshold: flags.threshold ?? config.threshold ?? last("threshold") ?? DEFAULT_THRESHOLD,
|
|
300
|
+
sufficiencyThreshold:
|
|
301
|
+
flags.sufficiencyThreshold ?? config.sufficiencyThreshold ?? DEFAULT_SUFFICIENCY_THRESHOLD,
|
|
274
302
|
rules: Object.assign({}, ...applied.map((preset) => preset.rules), config.rules),
|
|
275
303
|
...(flags.rpm === undefined ? {} : { rpm: flags.rpm }),
|
|
276
304
|
...(config.exclude === undefined ? {} : { exclude: config.exclude }),
|