@drkmttr/adhere 0.13.0 → 0.14.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 +1150 -824
- package/package.json +6 -6
- package/skills/adhere/SKILL.md +14 -1
- package/skills/adhere-setup/SKILL.md +21 -5
- package/src/config.ts +28 -0
- package/src/init.ts +5 -6
- package/src/markdown.ts +25 -3
- package/src/services/AdhereConfig.ts +13 -1
- package/src/services/Jev.http.ts +13 -5
- package/src/services/Jev.ts +16 -10
- package/src/workflows/audit.ts +14 -4
- package/src/workspaces.ts +141 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@drkmttr/adhere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Lints a repository against the rules in .adhere/, judged by Jev.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -68,10 +68,10 @@
|
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"optionalDependencies": {
|
|
71
|
-
"@drkmttr/adhere-darwin-arm64": "0.
|
|
72
|
-
"@drkmttr/adhere-darwin-x64": "0.
|
|
73
|
-
"@drkmttr/adhere-linux-arm64": "0.
|
|
74
|
-
"@drkmttr/adhere-linux-x64": "0.
|
|
75
|
-
"@drkmttr/adhere-win32-x64": "0.
|
|
71
|
+
"@drkmttr/adhere-darwin-arm64": "0.14.0",
|
|
72
|
+
"@drkmttr/adhere-darwin-x64": "0.14.0",
|
|
73
|
+
"@drkmttr/adhere-linux-arm64": "0.14.0",
|
|
74
|
+
"@drkmttr/adhere-linux-x64": "0.14.0",
|
|
75
|
+
"@drkmttr/adhere-win32-x64": "0.14.0"
|
|
76
76
|
}
|
|
77
77
|
}
|
package/skills/adhere/SKILL.md
CHANGED
|
@@ -53,7 +53,8 @@ config in `.adhere/` are not rules.
|
|
|
53
53
|
description: A port must be a branded, range-checked integer, never a bare number.
|
|
54
54
|
---
|
|
55
55
|
|
|
56
|
-
Prose here
|
|
56
|
+
Prose here is the rule's details: Jev reads it after the description. Say why
|
|
57
|
+
the rule holds, or where it does not apply.
|
|
57
58
|
|
|
58
59
|
## Must
|
|
59
60
|
|
|
@@ -88,6 +89,9 @@ How to word a rule, from adhere's evals:
|
|
|
88
89
|
- One pattern per rule. Two in one block blur the probability.
|
|
89
90
|
- A heading names its code only when it is the word alone: `## Never`, not
|
|
90
91
|
`## Never do this`. A fence tagged `ts never` names its code itself.
|
|
92
|
+
- Prose outside the code is sent to Jev with the description, so keep it about
|
|
93
|
+
the rule: why it holds, or where it does not apply. A note for the people who
|
|
94
|
+
maintain the rule, rather than for judging code, belongs elsewhere.
|
|
91
95
|
|
|
92
96
|
Front matter besides `description`, all optional:
|
|
93
97
|
|
|
@@ -102,6 +106,15 @@ Front matter besides `description`, all optional:
|
|
|
102
106
|
one line, scope a rule without widening its description: a finding stands
|
|
103
107
|
only when Jev says the code that breaks the rule is as every `appliesTo`
|
|
104
108
|
describes and as no `excludeIf` does.
|
|
109
|
+
- `reads: ["workspacePackages"]`, a JSON array on one line, names what Jev
|
|
110
|
+
reads for the rule beside the code, each under that name. There is one so
|
|
111
|
+
far, `workspacePackages`: each of the repository's own packages, by name,
|
|
112
|
+
with its directory, read from the root `package.json`'s `workspaces` or
|
|
113
|
+
`pnpm-workspace.yaml`. Use it for a rule that turns on whether an import is
|
|
114
|
+
of the repository's own package or an installed one, and name
|
|
115
|
+
`workspacePackages` in the description. A name adhere does not have refuses
|
|
116
|
+
the run. The rule costs a request of its own per file, and a changed
|
|
117
|
+
workspace judges it again.
|
|
105
118
|
|
|
106
119
|
A rule's directory can hold a `RULE.ts` instead, which default-exports
|
|
107
120
|
`defineRule({...})` from `@drkmttr/adhere`, with the fields a config's inline
|
|
@@ -38,8 +38,15 @@ wherever this skill says `adhere`, run it the manager's way, as in
|
|
|
38
38
|
With pnpm, at a workspace root, add `--ignore-workspace-root-check`.
|
|
39
39
|
|
|
40
40
|
In a repo without a `package.json`, use the `adhere` on PATH (`adhere
|
|
41
|
-
--version`), or **Ask** before installing
|
|
42
|
-
|
|
41
|
+
--version`), or **Ask** before installing the executable from the latest
|
|
42
|
+
release (`adhere-Windows-x86_64.exe` on Windows), which needs no Node or
|
|
43
|
+
package manager:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
curl -fsSL --create-dirs -o ~/.local/bin/adhere \
|
|
47
|
+
https://github.com/darkmatter/adhere/releases/latest/download/adhere-$(uname -s)-$(uname -m)
|
|
48
|
+
chmod +x ~/.local/bin/adhere
|
|
49
|
+
```
|
|
43
50
|
|
|
44
51
|
Validate and lint need a TypeSafe AI API key: `TYPESAFE_API_KEY`, or one saved
|
|
45
52
|
by `adhere login`, which the user runs; never ask for the key in chat or print
|
|
@@ -205,9 +212,18 @@ jobs:
|
|
|
205
212
|
|
|
206
213
|
Pin the versions the repo already uses: bun's `bun-version`, Node's
|
|
207
214
|
`node-version` from `.nvmrc` or `engines`, and pnpm's from `packageManager`,
|
|
208
|
-
which `pnpm/action-setup` reads. In a repo without a `package.json`,
|
|
209
|
-
|
|
210
|
-
|
|
215
|
+
which `pnpm/action-setup` reads. In a repo without a `package.json`, skip the
|
|
216
|
+
setup and install, and fetch the executable at the version the user ran
|
|
217
|
+
locally:
|
|
218
|
+
|
|
219
|
+
```yaml
|
|
220
|
+
- run: |
|
|
221
|
+
curl -fsSL -o "$RUNNER_TEMP/adhere" https://github.com/darkmatter/adhere/releases/download/v0.14.0/adhere-$(uname -s)-$(uname -m)
|
|
222
|
+
chmod +x "$RUNNER_TEMP/adhere"
|
|
223
|
+
"$RUNNER_TEMP/adhere" lint --yes
|
|
224
|
+
env:
|
|
225
|
+
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
|
|
226
|
+
```
|
|
211
227
|
|
|
212
228
|
Add `--deny-warnings` if the user wants warnings to fail the job too. Match
|
|
213
229
|
the branch to the repo's default branch. For another CI system, write the
|
package/src/config.ts
CHANGED
|
@@ -32,6 +32,15 @@ export type AppendState = (
|
|
|
32
32
|
bun: BunApi,
|
|
33
33
|
) => Readonly<Record<string, unknown>> | Promise<Readonly<Record<string, unknown>>>;
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* What a rule can have Jev read beside the code, each by the name of the key
|
|
37
|
+
* the state carries it under, which the rule's description can name in
|
|
38
|
+
* backticks. `workspacePackages` is the workspace's packages: each one's name
|
|
39
|
+
* with its directory, so a rule can tell an import of the repository's own
|
|
40
|
+
* package from an installed one's.
|
|
41
|
+
*/
|
|
42
|
+
export const Reads = Schema.Array(Schema.Literals(["workspacePackages"]));
|
|
43
|
+
|
|
35
44
|
/**
|
|
36
45
|
* A rule's examples, named in RFC 2119's words, which Jev reads as written. A
|
|
37
46
|
* requirement shows code that `must` be written and code that must `never`
|
|
@@ -40,6 +49,13 @@ export type AppendState = (
|
|
|
40
49
|
*/
|
|
41
50
|
const RuleFields = Schema.Struct({
|
|
42
51
|
description: Schema.String,
|
|
52
|
+
/**
|
|
53
|
+
* What the description leaves out, such as why the rule holds or where it
|
|
54
|
+
* does not: in a `RULE.md`, the prose around its code. Jev reads it after
|
|
55
|
+
* the description, as part of the rule; the report shows the description
|
|
56
|
+
* alone.
|
|
57
|
+
*/
|
|
58
|
+
details: Schema.optionalKey(Schema.String),
|
|
43
59
|
must: Schema.optionalKey(Schema.String),
|
|
44
60
|
never: Schema.optionalKey(Schema.String),
|
|
45
61
|
should: Schema.optionalKey(Schema.String),
|
|
@@ -67,6 +83,12 @@ const RuleFields = Schema.Struct({
|
|
|
67
83
|
* that breaks the rule is as it describes, and a yes to any drops the finding.
|
|
68
84
|
*/
|
|
69
85
|
excludeIf: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
86
|
+
/**
|
|
87
|
+
* What Jev reads for the rule beside the code, by name (see `Reads`). The
|
|
88
|
+
* rule's questions go in requests of their own, so only they read it, and
|
|
89
|
+
* a change to what is read judges the rule again.
|
|
90
|
+
*/
|
|
91
|
+
reads: Schema.optionalKey(Reads),
|
|
70
92
|
/**
|
|
71
93
|
* A hook on the state Jev reads for the rule, in a rule written in
|
|
72
94
|
* TypeScript (see `AppendState`). The rule's questions go in requests of
|
|
@@ -296,6 +318,12 @@ export interface ResolvedConfig {
|
|
|
296
318
|
readonly exclude?: ReadonlyArray<string>;
|
|
297
319
|
/** Whether Jev reads every comment; unset, only the `@adhere` notes. */
|
|
298
320
|
readonly includeComments?: boolean;
|
|
321
|
+
/**
|
|
322
|
+
* The workspace's packages, each name with its directory from the working
|
|
323
|
+
* directory, read from its manifests on every run, when a rule's `reads`
|
|
324
|
+
* names them.
|
|
325
|
+
*/
|
|
326
|
+
readonly workspacePackages?: Readonly<Record<string, string>>;
|
|
299
327
|
}
|
|
300
328
|
|
|
301
329
|
/** Command-line values that apply on top of the config file. */
|
package/src/init.ts
CHANGED
|
@@ -68,9 +68,8 @@ description: A file should be small and focused, with one primary responsibility
|
|
|
68
68
|
threshold: 0.8
|
|
69
69
|
---
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
Use this rule as a template for repository-specific conventions.
|
|
71
|
+
Why: a file that does one thing is easier to find, read, and change. A helper
|
|
72
|
+
that serves only that thing belongs in the same file.
|
|
74
73
|
|
|
75
74
|
## Should
|
|
76
75
|
|
|
@@ -93,6 +92,9 @@ description: A function must be named after the domain action it performs, never
|
|
|
93
92
|
threshold: 0.8
|
|
94
93
|
---
|
|
95
94
|
|
|
95
|
+
Why: a name like \`handle\` or \`process\` says nothing about what the function
|
|
96
|
+
does, so every caller has to open it to find out.
|
|
97
|
+
|
|
96
98
|
## Must
|
|
97
99
|
|
|
98
100
|
\`\`\`ts
|
|
@@ -104,9 +106,6 @@ export const loadCustomerProfile = (customerId: CustomerId) =>
|
|
|
104
106
|
|
|
105
107
|
## Never
|
|
106
108
|
|
|
107
|
-
The code under Never shows what a violation looks like. It is optional, and a
|
|
108
|
-
rule can have it without the code under Must.
|
|
109
|
-
|
|
110
109
|
\`\`\`ts
|
|
111
110
|
export const handle = (id: string) =>
|
|
112
111
|
Effect.gen(function* () {
|
package/src/markdown.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigUnavailable, type Example, type Rule } from "#config.ts";
|
|
1
|
+
import { ConfigUnavailable, type Example, Reads, type Rule } from "#config.ts";
|
|
2
2
|
import { Effect, Schema } from "effect";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -8,7 +8,7 @@ import { Effect, Schema } from "effect";
|
|
|
8
8
|
* description: One sentence saying what code must be, or never be.
|
|
9
9
|
* threshold: 0.8
|
|
10
10
|
* ---
|
|
11
|
-
* Any prose,
|
|
11
|
+
* Any prose, which Jev reads after the description, as the rule's details.
|
|
12
12
|
*
|
|
13
13
|
* ## Must
|
|
14
14
|
*
|
|
@@ -34,6 +34,8 @@ import { Effect, Schema } from "effect";
|
|
|
34
34
|
* `warning` reports the rule's findings as warnings, which do not fail a run.
|
|
35
35
|
* `appliesTo` and `excludeIf`, the rule's scope, are JSON arrays of strings on
|
|
36
36
|
* one line, as in `excludeIf: ["a type that mirrors a third-party format"]`.
|
|
37
|
+
* So is `reads`, of the names of what Jev reads for the rule beside the code,
|
|
38
|
+
* as in `reads: ["workspacePackages"]`.
|
|
37
39
|
*/
|
|
38
40
|
const Matchers = Schema.fromJsonString(Schema.Array(Schema.String));
|
|
39
41
|
|
|
@@ -44,6 +46,7 @@ const FrontMatter = Schema.Struct({
|
|
|
44
46
|
level: Schema.optionalKey(Schema.Literals(["error", "warning"])),
|
|
45
47
|
appliesTo: Schema.optionalKey(Matchers),
|
|
46
48
|
excludeIf: Schema.optionalKey(Matchers),
|
|
49
|
+
reads: Schema.optionalKey(Schema.fromJsonString(Reads)),
|
|
47
50
|
});
|
|
48
51
|
|
|
49
52
|
const FRONT_MATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
@@ -148,6 +151,24 @@ const fencesOf = (body: string): ReadonlyArray<Fence> => {
|
|
|
148
151
|
const nonEmpty = (code: string | undefined): string | undefined =>
|
|
149
152
|
code === undefined || code.length === 0 ? undefined : code;
|
|
150
153
|
|
|
154
|
+
/**
|
|
155
|
+
* The body's prose: what is left outside its fences and the headings that
|
|
156
|
+
* name their code, which Jev reads after the description. A body without a
|
|
157
|
+
* fence is all code, so it has none.
|
|
158
|
+
*/
|
|
159
|
+
const detailsOf = (body: string): string | undefined =>
|
|
160
|
+
fencesOf(body).length === 0
|
|
161
|
+
? undefined
|
|
162
|
+
: nonEmpty(
|
|
163
|
+
body
|
|
164
|
+
.replace(FENCE, "")
|
|
165
|
+
.replace(HEADING, (line, _level, text: string | undefined) =>
|
|
166
|
+
headingWordOf(text ?? "") === undefined ? line : "",
|
|
167
|
+
)
|
|
168
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
169
|
+
.trim(),
|
|
170
|
+
);
|
|
171
|
+
|
|
151
172
|
type Code = Pick<Rule, "must" | "never" | "should" | "shouldNot">;
|
|
152
173
|
|
|
153
174
|
/**
|
|
@@ -206,5 +227,6 @@ export const parseRuleMarkdown = (
|
|
|
206
227
|
message: `${file}: the body needs code, in a fence under a Must, Never, Should, or Should not heading, or untagged`,
|
|
207
228
|
});
|
|
208
229
|
}
|
|
209
|
-
|
|
230
|
+
const details = detailsOf(match[2]);
|
|
231
|
+
return { ...front, ...(details === undefined ? {} : { details }), ...code };
|
|
210
232
|
});
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
shownId,
|
|
22
22
|
withOverrides,
|
|
23
23
|
} from "#rules.ts";
|
|
24
|
+
import { workspacePackagesOf } from "#workspaces.ts";
|
|
24
25
|
import { Context, Effect, FileSystem, Layer, Path, Record } from "effect";
|
|
25
26
|
|
|
26
27
|
export class AdhereConfig extends Context.Service<AdhereConfig, ResolvedConfig>()(
|
|
@@ -129,6 +130,17 @@ export const AdhereConfigLive = (flags: Flags) =>
|
|
|
129
130
|
});
|
|
130
131
|
}
|
|
131
132
|
}
|
|
132
|
-
|
|
133
|
+
const scopedRules = withOverrides(entries, overrides);
|
|
134
|
+
// The manifests are read on every run, so a changed workspace is seen, and only when a rule reads them.
|
|
135
|
+
const workspacePackages = scopedRules.some(
|
|
136
|
+
(entry) => entry.rule.reads?.includes("workspacePackages") === true,
|
|
137
|
+
)
|
|
138
|
+
? yield* workspacePackagesOf(cwd)
|
|
139
|
+
: undefined;
|
|
140
|
+
return AdhereConfig.of({
|
|
141
|
+
...resolved,
|
|
142
|
+
scopedRules,
|
|
143
|
+
...(workspacePackages === undefined ? {} : { workspacePackages }),
|
|
144
|
+
});
|
|
133
145
|
}),
|
|
134
146
|
);
|
package/src/services/Jev.http.ts
CHANGED
|
@@ -68,14 +68,21 @@ const refused = (message: string) => JevUnavailable.make({ message });
|
|
|
68
68
|
const bun = (globalThis as { readonly Bun?: unknown }).Bun as Parameters<AppendState>[2];
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* A body as it goes out: when its rule
|
|
72
|
-
*
|
|
71
|
+
* A body as it goes out: when its rule's `reads` names the workspace's
|
|
72
|
+
* packages, with them in its state, and when it has `appendState`, with what
|
|
73
|
+
* the hook returns spread over that. A hook that fails refuses the run.
|
|
73
74
|
*/
|
|
74
75
|
const hooked = (
|
|
75
|
-
|
|
76
|
+
sent: Body & { readonly state: JevState },
|
|
76
77
|
group: Rules,
|
|
77
78
|
file: JudgedFile,
|
|
79
|
+
workspacePackages: Readonly<Record<string, string>>,
|
|
78
80
|
): Effect.Effect<Body, JevUnavailable> => {
|
|
81
|
+
const body = Object.values(group).some(
|
|
82
|
+
(rule) => rule.reads?.includes("workspacePackages") === true,
|
|
83
|
+
)
|
|
84
|
+
? { ...sent, state: { ...sent.state, workspacePackages } }
|
|
85
|
+
: sent;
|
|
79
86
|
const [hook] = Object.entries(group).flatMap(([id, rule]) =>
|
|
80
87
|
rule.appendState === undefined ? [] : [[id, rule.appendState] as const],
|
|
81
88
|
);
|
|
@@ -273,8 +280,9 @@ export const JevLive = Layer.effect(Jev)(
|
|
|
273
280
|
) =>
|
|
274
281
|
Effect.map(
|
|
275
282
|
Effect.forEach(requestGroups(rules), (group) =>
|
|
276
|
-
Effect.flatMap(
|
|
277
|
-
|
|
283
|
+
Effect.flatMap(
|
|
284
|
+
hooked(bodyOf(group), group, file, config.workspacePackages ?? {}),
|
|
285
|
+
(body) => answersTo(kind, body, Answers),
|
|
278
286
|
),
|
|
279
287
|
),
|
|
280
288
|
(groups) =>
|
package/src/services/Jev.ts
CHANGED
|
@@ -160,11 +160,15 @@ const exampleFields = (rule: Rule): Record<string, string> => {
|
|
|
160
160
|
};
|
|
161
161
|
};
|
|
162
162
|
|
|
163
|
-
/** The rule as
|
|
164
|
-
const
|
|
163
|
+
/** The rule in words, as Jev reads it: its description, then its details when it has any. */
|
|
164
|
+
const ruleText = (rule: Rule): string =>
|
|
165
|
+
rule.details === undefined ? rule.description : `${rule.description}\n\n${rule.details}`;
|
|
166
|
+
|
|
167
|
+
/** The rule as its questions carry it: its text, and its examples under their words. */
|
|
168
|
+
const ruleFields = (rule: Rule) => ({ rule: ruleText(rule), ...exampleFields(rule) });
|
|
165
169
|
|
|
166
170
|
/** A rule's text as a comparison of rules reads it. */
|
|
167
|
-
const ruleState = (rule: Rule) => ({ description: rule
|
|
171
|
+
const ruleState = (rule: Rule) => ({ description: ruleText(rule), ...exampleFields(rule) });
|
|
168
172
|
|
|
169
173
|
/**
|
|
170
174
|
* The compared rules a question mentions, keyed by index: ids alone would
|
|
@@ -477,18 +481,20 @@ export const requestsOf = (body: Body): ReadonlyArray<Body> => {
|
|
|
477
481
|
return requests.map(({ questions }) => ({ ...body, questions }));
|
|
478
482
|
};
|
|
479
483
|
|
|
484
|
+
/** Whether a rule reads state of its own: what its `appendState` adds, or what its `reads` names. */
|
|
485
|
+
const readsOwnState = (rule: Rule): boolean =>
|
|
486
|
+
rule.appendState !== undefined || (rule.reads ?? []).length > 0;
|
|
487
|
+
|
|
480
488
|
/**
|
|
481
|
-
* The rules as requests carry them: every rule
|
|
482
|
-
* body, and each rule with
|
|
483
|
-
*
|
|
489
|
+
* The rules as requests carry them: every rule that reads only the code in
|
|
490
|
+
* one body, and each rule with state of its own in a body of its own, so
|
|
491
|
+
* that what is added to the state for it only its own questions read.
|
|
484
492
|
*/
|
|
485
493
|
export const requestGroups = (rules: Rules): ReadonlyArray<Rules> => {
|
|
486
|
-
const shared = Record.filter(rules, (rule) => rule
|
|
494
|
+
const shared = Record.filter(rules, (rule) => !readsOwnState(rule));
|
|
487
495
|
return [
|
|
488
496
|
...(Record.isEmptyRecord(shared) ? [] : [shared]),
|
|
489
|
-
...Object.entries(rules).flatMap(([id, rule]) =>
|
|
490
|
-
rule.appendState === undefined ? [] : [{ [id]: rule }],
|
|
491
|
-
),
|
|
497
|
+
...Object.entries(rules).flatMap(([id, rule]) => (readsOwnState(rule) ? [{ [id]: rule }] : [])),
|
|
492
498
|
];
|
|
493
499
|
};
|
|
494
500
|
|
package/src/workflows/audit.ts
CHANGED
|
@@ -112,14 +112,24 @@ const LAYOUT = "sections";
|
|
|
112
112
|
* judge question, and its matchers' when it has any. An edited rule or
|
|
113
113
|
* matcher re-judges the rule, and a question asked differently re-judges
|
|
114
114
|
* every rule. So does an edited `appendState`, by its source; what the hook
|
|
115
|
-
* reads outside the file is not known until it runs, so a change there does
|
|
115
|
+
* reads outside the file is not known until it runs, so a change there does
|
|
116
|
+
* not. A rule that reads the workspace's packages depends on them too, so a
|
|
117
|
+
* package added, renamed, or moved re-judges that rule and no other.
|
|
116
118
|
*/
|
|
117
|
-
const fingerprintOf = (
|
|
119
|
+
const fingerprintOf = (
|
|
120
|
+
model: string,
|
|
121
|
+
rule: Rule,
|
|
122
|
+
workspacePackages: Readonly<Record<string, string>> = {},
|
|
123
|
+
) => {
|
|
118
124
|
const matchers = matcherQuestions("", rule).map(([, question]) => question);
|
|
119
125
|
const scope = matchers.length === 0 ? "" : `\u0000${JSON.stringify(matchers)}`;
|
|
120
126
|
const hook = rule.appendState === undefined ? "" : `\u0000${rule.appendState.toString()}`;
|
|
127
|
+
const packages =
|
|
128
|
+
rule.reads?.includes("workspacePackages") === true
|
|
129
|
+
? `\u0000${JSON.stringify(workspacePackages)}`
|
|
130
|
+
: "";
|
|
121
131
|
return sha256(
|
|
122
|
-
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}`,
|
|
132
|
+
`${model}\u0000${LAYOUT}\u0000${JSON.stringify(judgeQuestion(rule))}${scope}${hook}${packages}`,
|
|
123
133
|
);
|
|
124
134
|
};
|
|
125
135
|
|
|
@@ -314,7 +324,7 @@ export const planAudit = (
|
|
|
314
324
|
const prepared: Record<RuleId, PreparedRule> = yield* Effect.forEach(
|
|
315
325
|
Object.entries(rules),
|
|
316
326
|
([id, rule]) =>
|
|
317
|
-
Effect.map(fingerprintOf(config.model, rule), (fingerprint) => {
|
|
327
|
+
Effect.map(fingerprintOf(config.model, rule, config.workspacePackages), (fingerprint) => {
|
|
318
328
|
const preset = presetOfRule.get(rule);
|
|
319
329
|
return [
|
|
320
330
|
id,
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path } from "effect";
|
|
2
|
+
|
|
3
|
+
/** A workspace's packages: each one's name, with its directory from the root. */
|
|
4
|
+
export type WorkspacePackages = Readonly<Record<string, string>>;
|
|
5
|
+
|
|
6
|
+
/** What JSON text holds, or nothing when it is not JSON. */
|
|
7
|
+
const parsed = (text: string): unknown => {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(text);
|
|
10
|
+
} catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** The strings of a value that is an array of them, and none of any other value. */
|
|
16
|
+
const strings = (value: unknown): ReadonlyArray<string> =>
|
|
17
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A root package.json's workspace patterns: `workspaces` as an array, which
|
|
21
|
+
* npm, Bun, and Yarn read, or as Yarn's `{ packages: [...] }`.
|
|
22
|
+
*/
|
|
23
|
+
const manifestPatterns = (manifest: unknown): ReadonlyArray<string> => {
|
|
24
|
+
const workspaces = (manifest as { readonly workspaces?: unknown } | undefined)?.workspaces;
|
|
25
|
+
return Array.isArray(workspaces)
|
|
26
|
+
? strings(workspaces)
|
|
27
|
+
: strings((workspaces as { readonly packages?: unknown } | undefined)?.packages);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The patterns under `packages:` in a pnpm-workspace.yaml: the list items
|
|
32
|
+
* that follow it, up to the next key. Read line by line, since that list is
|
|
33
|
+
* all of the file adhere needs and the executable carries no YAML parser.
|
|
34
|
+
*/
|
|
35
|
+
const pnpmPatterns = (yaml: string): ReadonlyArray<string> => {
|
|
36
|
+
const lines = yaml.split(/\r?\n/);
|
|
37
|
+
const start = lines.findIndex((line) => /^packages:\s*$/.test(line));
|
|
38
|
+
if (start < 0) return [];
|
|
39
|
+
const rest = lines.slice(start + 1);
|
|
40
|
+
const end = rest.findIndex((line) => /^\S/.test(line) && !line.startsWith("-"));
|
|
41
|
+
return (end < 0 ? rest : rest.slice(0, end)).flatMap((line) => {
|
|
42
|
+
const [, item] = /^\s*-\s*(.+?)\s*$/.exec(line) ?? [];
|
|
43
|
+
return item === undefined ? [] : [item.replace(/^["']|["']$/g, "")];
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Whether a directory's name matches a pattern segment, where `*` stands for any run of characters. */
|
|
48
|
+
const matches = (segment: string, name: string): boolean =>
|
|
49
|
+
new RegExp(
|
|
50
|
+
`^${segment
|
|
51
|
+
.split("*")
|
|
52
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
53
|
+
.join(".*")}$`,
|
|
54
|
+
).test(name);
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The packages of the workspace whose root is `root`, by name, sorted, so
|
|
58
|
+
* the same workspace always reads the same: from the root package.json's
|
|
59
|
+
* `workspaces` and from pnpm-workspace.yaml's `packages`. A pattern's `*` is
|
|
60
|
+
* any directory and `**` any depth of them, outside `node_modules` and dot
|
|
61
|
+
* directories, and one that starts with `!` leaves out what it matches. A
|
|
62
|
+
* root that has neither file, or names no workspaces, has no packages.
|
|
63
|
+
*/
|
|
64
|
+
export const workspacePackagesOf = Effect.fn("workspacePackagesOf")(function* (root: string) {
|
|
65
|
+
const fs = yield* FileSystem.FileSystem;
|
|
66
|
+
const path = yield* Path.Path;
|
|
67
|
+
const read = (file: string) =>
|
|
68
|
+
fs.readFileString(path.join(root, file)).pipe(Effect.orElseSucceed(() => ""));
|
|
69
|
+
const children = (directory: string) =>
|
|
70
|
+
fs.readDirectory(path.join(root, directory)).pipe(
|
|
71
|
+
Effect.orElseSucceed((): ReadonlyArray<string> => []),
|
|
72
|
+
Effect.map((names) =>
|
|
73
|
+
names.filter((name) => name !== "node_modules" && !name.startsWith(".")),
|
|
74
|
+
),
|
|
75
|
+
Effect.flatMap((names) =>
|
|
76
|
+
Effect.filter(names, (name) =>
|
|
77
|
+
fs.stat(path.join(root, directory, name)).pipe(
|
|
78
|
+
Effect.map((info) => info.type === "Directory"),
|
|
79
|
+
Effect.orElseSucceed(() => false),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
),
|
|
83
|
+
Effect.map((names) =>
|
|
84
|
+
names.map((name) => (directory === "" ? name : `${directory}/${name}`)),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
/** The directories under `directory` that the pattern's remaining segments lead to. */
|
|
88
|
+
const expand = (
|
|
89
|
+
directory: string,
|
|
90
|
+
segments: ReadonlyArray<string>,
|
|
91
|
+
): Effect.Effect<ReadonlyArray<string>> => {
|
|
92
|
+
const [segment, ...rest] = segments;
|
|
93
|
+
if (segment === undefined) return Effect.succeed([directory]);
|
|
94
|
+
if (segment === "**") {
|
|
95
|
+
return Effect.gen(function* () {
|
|
96
|
+
const below = yield* Effect.forEach(yield* children(directory), (child) =>
|
|
97
|
+
expand(child, segments),
|
|
98
|
+
);
|
|
99
|
+
return [...(yield* expand(directory, rest)), ...below.flat()];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (!segment.includes("*")) {
|
|
103
|
+
return expand(directory === "" ? segment : `${directory}/${segment}`, rest);
|
|
104
|
+
}
|
|
105
|
+
return Effect.flatMap(children(directory), (names) =>
|
|
106
|
+
Effect.map(
|
|
107
|
+
Effect.forEach(
|
|
108
|
+
names.filter((name) => matches(segment, name.slice(name.lastIndexOf("/") + 1))),
|
|
109
|
+
(child) => expand(child, rest),
|
|
110
|
+
),
|
|
111
|
+
(found) => found.flat(),
|
|
112
|
+
),
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
const segmentsOf = (pattern: string) =>
|
|
116
|
+
pattern.split("/").filter((segment) => segment !== "" && segment !== ".");
|
|
117
|
+
const patterns = [
|
|
118
|
+
...manifestPatterns(parsed(yield* read("package.json"))),
|
|
119
|
+
...pnpmPatterns(yield* read("pnpm-workspace.yaml")),
|
|
120
|
+
];
|
|
121
|
+
const directoriesOf = (chosen: ReadonlyArray<string>) =>
|
|
122
|
+
Effect.map(
|
|
123
|
+
Effect.forEach(chosen, (pattern) => expand("", segmentsOf(pattern))),
|
|
124
|
+
(found) => new Set(found.flat()),
|
|
125
|
+
);
|
|
126
|
+
const included = yield* directoriesOf(patterns.filter((pattern) => !pattern.startsWith("!")));
|
|
127
|
+
const excluded = yield* directoriesOf(
|
|
128
|
+
patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)),
|
|
129
|
+
);
|
|
130
|
+
const named = yield* Effect.forEach(
|
|
131
|
+
[...included].filter((directory) => !excluded.has(directory)),
|
|
132
|
+
(directory) =>
|
|
133
|
+
Effect.map(read(`${directory}/package.json`), (text) => {
|
|
134
|
+
const name = (parsed(text) as { readonly name?: unknown } | undefined)?.name;
|
|
135
|
+
return typeof name === "string" ? [[name, directory] as const] : [];
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
return Object.fromEntries(
|
|
139
|
+
named.flat().sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
140
|
+
) as WorkspacePackages;
|
|
141
|
+
});
|