@avi2dg/checks 0.27.0 → 0.29.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/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.29.0
6
+
7
+ Released 2026-09-28.
8
+
9
+ ### Breaking changes
10
+
11
+ - **docs:** hold agent files to a 3,000-character router where every entry points [#101](https://github.com/avi2d/checks/pull/101)
12
+
13
+ ### Fixes
14
+
15
+ - **testing:** run checks-test with CI=true so focused tests fail locally [#100](https://github.com/avi2d/checks/pull/100)
16
+
17
+ ## 0.28.0
18
+
19
+ Released 2026-09-28.
20
+
21
+ ### Features
22
+
23
+ - **dependencies:** add checks-advisories gate for advisories a bun.lock change adds [#98](https://github.com/avi2d/checks/pull/98)
24
+
5
25
  ## 0.27.0
6
26
 
7
27
  Released 2026-09-27.
package/README.md CHANGED
@@ -124,9 +124,10 @@ The table groups the gates by vector, the part of a repository each one judges.
124
124
  | quality | [`checks-comment-gate`](docs/gates/checks-comment-gate.md) | the range | every repository |
125
125
  | testing | [`checks-test-layout`](docs/gates/checks-test-layout.md) | the working tree | a repository tracking `*.ts` or `*.tsx` |
126
126
  | testing | [`checks-quarantine-clock`](docs/gates/checks-quarantine-clock.md) | the range | every repository |
127
- | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range | every repository |
127
+ | docs | [`checks-docs`](docs/gates/checks-docs.md) | the range, and every agent file at the head commit | every repository |
128
128
  | delivery | [`checks-commit-identity`](docs/gates/checks-commit-identity.md) | the range | every repository |
129
129
  | delivery | [`checks-ci-wiring`](docs/gates/checks-ci-wiring.md) | the working tree | every repository |
130
+ | dependencies | [`checks-advisories`](docs/gates/checks-advisories.md) | the range | a repository tracking `bun.lock` |
130
131
 
131
132
  <!-- end generated gates -->
132
133
 
@@ -7,11 +7,3 @@
7
7
  <Leave this section out when the lead holds every constraint.>
8
8
 
9
9
  - <A constraint an agent cannot infer from the code, and the file that holds its detail.>
10
-
11
- ## Maintaining this file
12
-
13
- Keep this file for knowledge useful to almost every future agent session in this project.
14
- Do not repeat what the codebase already shows.
15
- Point to the authoritative file or command instead.
16
- Prefer rewriting or pruning existing entries over appending new ones.
17
- When updating this file, preserve this bar for all agents and keep entries concise.
package/docs/design.md CHANGED
@@ -86,6 +86,7 @@ npm adds `package.json`, `README.md` and `LICENSE` whatever `files` says.
86
86
  `bun pm pack` builds the same tarball the registry serves, and the consumer e2e test installs that tarball.
87
87
 
88
88
  Each oxlint plugin ships compiled under `dist/`, because Node refuses to strip types from a `.ts` file under `node_modules`.
89
+ `@oxlint/plugins` ships no RuleTester, so each `effect-channel`, `readability` and `data-shape` rule is proven red and green against an installed consumer in `tests/e2e/consumer.test.ts`.
89
90
  `dist/` is committed, with the doc templates in `dist/templates/`, and so is `CHANGELOG.md`, which the same build writes.
90
91
  No `prepack` or `prepublishOnly` script rebuilds them, so a publish ships the committed files.
91
92
  CI runs `git diff --exit-code` over the whole tree after `bun run build`.
@@ -140,7 +141,8 @@ That flag reports a repeated block as new once its text changes, so a change tha
140
141
  ## checks-test runs the suite itself
141
142
 
142
143
  `checks-test` runs bun itself rather than reading a report that another run left.
143
- A skip taken only on CI shows only in CI's own run, and an earlier run's report may be stale or narrowed.
144
+ A `"ci"` skip gated on a daemon or tool that CI lacks shows in CI's own run, and an earlier run's report may be stale or narrowed.
145
+ When the same resource is also absent locally, the test skips there too, and its `"ci"` declaration leaves that skip undeclared, so the local run fails.
144
146
  It reads the JUnit report bun writes to a temporary directory, because bun has no other per-test output meant for a program.
145
147
 
146
148
  ## Quarantine has one limit
@@ -175,7 +177,8 @@ A repository adopts the templates as its files change, and an untouched file is
175
177
  The prose rules judge only the lines a change adds or edits.
176
178
  A report about the past is refused the way a promise about the future is, because history on a living page reads as current fact.
177
179
  Text nobody touched never breaks the templates or the prose rules, and a record keeps the words it was written in.
178
- A repository needs no cleanup pass before the gate runs.
180
+ A repository needs no cleanup pass before the gate runs, except on its agent files.
181
+ The ceiling, the rule against a `## Maintaining this file` section and the entry rule judge every agent file at the head commit, because an agent reads the whole file every session, touched or not.
179
182
  Review, not the check, keeps a task heading verb first.
180
183
  No word list tells `Test layout` from `Test the layout`, and a check that passes the noun would be worse than none.
181
184
 
@@ -213,6 +216,28 @@ So `checks-vendor` strips an owner write bit and checks the tree against its rec
213
216
  A write it finds there still fails the run.
214
217
  A group or other write bit fails the run, because another user could have edited `.git/config` through it before `git status` reads it.
215
218
 
219
+ ## Advisories fail only the range that adds them
220
+
221
+ `checks-advisories` compares the advisories at both ends of a range instead of failing on every advisory at the head.
222
+ An advisory published against a package that landed a month earlier would otherwise fail every open pull request, including one that touches only docs.
223
+ The scheduled `--all` run finds those advisories, and `advisory-acks.json` carries the ones a repository accepts for a while.
224
+
225
+ The gate runs OSV-Scanner rather than Trivy, Grype or `bun audit`.
226
+ Trivy 0.74.0 reads a nested `bun.lock` entry such as `mkdirp/minimist` under a name no advisory carries, so it misses every package bun nests.
227
+ Grype 0.119.0 drops the dev dependencies of `bun.lock` with no setting that keeps them.
228
+ `bun audit` asks the npm registry on every run and has no offline mode.
229
+ OSV.dev's npm export also holds OpenSSF's reports of malicious packages, which GitHub's reviewed advisories leave out.
230
+
231
+ The kit pins the scanner by version and by the SHA-256 of each build, because a scanner release is code that runs on every runner.
232
+ Trivy's own advisory GHSA-69fq-xp46-6x23 records a malicious release published with stolen credentials.
233
+ The scan runs offline against a database refreshed once a day, so most runs need no network.
234
+
235
+ The acknowledgement file belongs to the kit rather than to `osv-scanner.toml`.
236
+ OSV-Scanner's `ignoreUntil` accepts any day, such as 2099-01-01, and measures it against the clock of the machine that runs the scan.
237
+ The kit caps each entry at 30 days and measures a range from the head's dates, as `checks-quarantine-clock` does, so a commit gets the same verdict on every run.
238
+ `--all` measures from the current time instead, because a head's dates never move in a repository that takes no commit, and an entry measured from them would never expire.
239
+ The file is JSON because `Bun.TOML` cannot parse a TOML date.
240
+
216
241
  ## Related topics
217
242
 
218
243
  - [checks](../README.md)
@@ -0,0 +1,128 @@
1
+ ---
2
+ kind: reference
3
+ audience: consumers
4
+ ---
5
+ # checks-advisories
6
+
7
+ `checks-advisories` is the gate that fails a range whose `bun.lock` change adds a package version with a known security advisory.
8
+
9
+ ## What it checks
10
+
11
+ It scans `bun.lock` at both ends of the range with OSV-Scanner and fails on each advisory the head's lockfile has and the base's lockfile lacks.
12
+ It matches an advisory across the range by package name and the id OSV-Scanner reports, so a range that moves a package between two affected versions adds nothing.
13
+ It ignores aliases, because two live advisories can list each other as aliases.
14
+ An advisory published against a package the base already held shows at both ends, so it fails no range, and `--all` reports it instead.
15
+ Each failure names the package, its version, the advisory id, its severity and its summary.
16
+ A range that leaves `bun.lock` unchanged runs no scan.
17
+ It also holds `advisory-acks.json` to the rules in [The acknowledgement file](#the-acknowledgement-file) on every run.
18
+
19
+ ## What it reads
20
+
21
+ It reads `bun.lock` at the base and at the head of the range from git, not from the working tree.
22
+ It scans copies of both in a scratch directory with an empty config, so a repository's own `osv-scanner.toml` takes no part.
23
+ It reads the advisories OSV.dev exports for npm, which hold GitHub's reviewed advisories and OpenSSF's reports of malicious packages.
24
+
25
+ It runs OSV-Scanner 2.6.0, pinned by the SHA-256 of each platform's build.
26
+ On first use it downloads the build from the scanner's GitHub release into `~/.cache/avi2dg-checks/osv-scanner/2.6.0/`.
27
+ It checks the SHA-256 again on every run and exits 2 on a copy that differs.
28
+ Builds are pinned for macOS and Linux, each on x64 and arm64.
29
+ On any other platform it exits 2, and no setting runs a scanner other than the pinned build.
30
+
31
+ It scans offline against OSV-Scanner's npm database in `~/.cache/avi2dg-checks/osv-scanner/db/`.
32
+ When the last refresh is more than 24 hours old, the scan asks for the database again, and OSV-Scanner downloads it only when the copy differs.
33
+ A last refresh dated ahead of the clock counts as no refresh.
34
+ When that download fails, it scans the cached copy and says so, as long as that copy was refreshed within 7 days.
35
+ With no copy refreshed within 7 days it exits 2.
36
+ A cold cache downloads about 55 MB of scanner and 217 MB of database.
37
+
38
+ `--all` appends its report to the file `GITHUB_STEP_SUMMARY` names, which is the job summary.
39
+
40
+ ## The acknowledgement file
41
+
42
+ `advisory-acks.json` at the repository root lists the advisories the repository accepts for a while, such as a false positive or a fix that waits on an upstream release:
43
+
44
+ ```json
45
+ [
46
+ {
47
+ "package": "minimist",
48
+ "id": "GHSA-xvch-5gv4-984h",
49
+ "until": "2026-10-20",
50
+ "reason": "mkdirp 0.5.1 never parses untrusted argv here, and leaves with the next test runner"
51
+ }
52
+ ]
53
+ ```
54
+
55
+ It reads the file at the head of the range.
56
+ Each entry names the package, the advisory's id as OSV-Scanner reports it, the day the entry stops holding, and why the repository accepts the advisory.
57
+ An entry covers only the advisory with that id, so one naming an alias covers nothing.
58
+ An entry holds until its `until` day begins in UTC.
59
+ A range measures from the later of the head's author and committer dates, so a commit gets the same verdict on every run.
60
+ `--all` measures from the current time, so an entry expires in a repository that takes no commit.
61
+ An entry whose `until` falls more than 30 days after that moment fails the run and covers nothing, and no setting raises the limit.
62
+ Once that moment passes `until`, the entry fails every run until it is deleted or renewed with a later `until`, whether or not the range touches `bun.lock`.
63
+ Delete it once the package is upgraded.
64
+ When a scan runs, an entry that matches no advisory at the head fails, so the file holds only live entries.
65
+ A file that does not decode as that list exits 2.
66
+
67
+ ## Arguments
68
+
69
+ ```sh
70
+ checks-advisories <base-ref> <head-ref>
71
+ checks-advisories <ref>
72
+ checks-advisories --all
73
+ ```
74
+
75
+ With two arguments it judges the range from their merge base to the head.
76
+ With one it judges that commit against its parent, or against an empty tree for a repository's first commit.
77
+ With `--all` it fails on every advisory in `bun.lock` at `HEAD` that no entry covers.
78
+
79
+ ## Exit codes
80
+
81
+ | Code | When |
82
+ | --- | --- |
83
+ | 0 | the range adds no advisory to `bun.lock`, and every acknowledgement holds |
84
+ | 1 | the range adds an advisory no acknowledgement covers, or an acknowledgement does not hold |
85
+ | 2 | a ref does not resolve, `advisory-acks.json` does not decode, or no verified scanner or usable database is at hand |
86
+
87
+ ## Sample output
88
+
89
+ ```
90
+ advisories: the range adds 2 advisory(ies) to bun.lock (0 at the head predate the range, 0 acknowledged); upgrade each package, or acknowledge its advisory in advisory-acks.json:
91
+ lodash@4.17.20 GHSA-35jh-r3h4-6jhm high: Command Injection in lodash
92
+ minimist@0.0.8 GHSA-xvch-5gv4-984h critical: Prototype Pollution in minimist
93
+ advisories: 1 acknowledgement(s) in advisory-acks.json do not hold:
94
+ qs GHSA-4mjr-xmp4-gh2g expired on 2026-10-20; upgrade the package and delete the entry, or renew it with a later day
95
+ ```
96
+
97
+ ## When it runs
98
+
99
+ `checks-lint` runs it over each pull request's range in a repository that tracks `bun.lock`, as [checks-lint](checks-lint.md) says.
100
+ A range that leaves `bun.lock` unchanged runs in under a second, and one that changes it scans for about 10 seconds on a warm cache.
101
+
102
+ ## Running it on a schedule
103
+
104
+ A range never fails on an advisory published after its package landed, so a scheduled `--all` run finds those:
105
+
106
+ ```yaml
107
+ on:
108
+ schedule:
109
+ - cron: "41 4 * * *"
110
+ workflow_dispatch:
111
+ jobs:
112
+ advisories:
113
+ runs-on: self-hosted
114
+ timeout-minutes: 10
115
+ steps:
116
+ - uses: actions/checkout@v5
117
+ - uses: oven-sh/setup-bun@v2
118
+ - run: bun install --frozen-lockfile
119
+ - run: ./node_modules/.bin/checks-advisories --all
120
+ ```
121
+
122
+ A self-hosted runner keeps `~/.cache/avi2dg-checks/` between runs, so it downloads the scanner once per pinned version and the database about once a day.
123
+ A hosted runner starts each run with an empty cache, so each run downloads both.
124
+
125
+ ## Related topics
126
+
127
+ - [checks-lint](checks-lint.md)
128
+ - [Why it is shaped this way](../design.md)
@@ -13,6 +13,8 @@ It holds each doc file a change touches to the template for its kind.
13
13
  It lists every other doc file that does not match its template yet, and does not fail on it.
14
14
  It holds each line a change adds or edits in a living doc or an agent file to the prose rules, as [The prose rules](#the-prose-rules) says.
15
15
  It fails when a living doc or an agent file names a path, link or command that does not resolve, and the range added or broke it.
16
+ It fails when an agent file holds more than 3,000 characters or a `## Maintaining this file` section, whatever the range touches, as [Agent files](#agent-files) says.
17
+ It fails when an entry in an agent file names no tracked path, link or `bun run` command, whatever the range touches.
16
18
  It fails when a living doc or an agent file names a code span the range removed from every file outside the docs, on any line.
17
19
  [Paths, links and commands](#paths-links-and-commands) says how each reference resolves.
18
20
  The package ships one template per kind under `dist/templates/`, and a repository starts a new doc file by copying one:
@@ -142,6 +144,24 @@ audience: consumers
142
144
  ---
143
145
  ```
144
146
 
147
+ ## Agent files
148
+
149
+ An agent file holds the router its template sketches, and the rules below hold its shape whatever the range touches.
150
+ A file over 3,000 characters fails.
151
+ Move each part's notes into the people doc that covers that part, and delete what a check or the code already holds.
152
+ A file that holds a `## Maintaining this file` section fails, because this gate holds the shape the section asked for.
153
+ Each entry names at least one of these, or it fails:
154
+
155
+ - A path in inline code that git tracks at the head commit, a file or a directory, such as `package.json`, `LICENSE` or `.gitignore`.
156
+ It resolves from the root or from the file's directory, `./` and `../` included, and a path that ends in `/` or `/.` names a directory, never a file.
157
+ - A Markdown link written `[text](target)` with a destination and a closing parenthesis, and not an image.
158
+ - A `bun run` command.
159
+
160
+ Whether the link or the command resolves is the reference rule's call, as [Paths, links and commands](#paths-links-and-commands) says, and it fails when the range adds or breaks one.
161
+ An entry is any list item a reader sees, the items above the first section included.
162
+ A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
163
+ A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
164
+
145
165
  ## What it reads
146
166
 
147
167
  It reads each Markdown file at the head commit, and uses its path or front matter to choose its kind.
@@ -150,6 +170,7 @@ It reads the lines the range adds or edits from the diff, with renames detected,
150
170
  It reads the files tracked at both ends of the range, and the `scripts` of each `package.json` a living doc or an agent file sits under.
151
171
  It compares each code span a living doc or an agent file names with the text git tracks outside the docs at both ends of the range.
152
172
  It reads the repository's own name and its direct dependencies from the root `package.json` at the head commit.
173
+ It reads each agent file at the head commit for the ceiling, its sections and its entries, whatever the range touches.
153
174
  A name that an installed direct dependency still holds counts as present.
154
175
  From the working tree it reads the ignore files git reads, `node_modules/.bin`, and the directory of each direct dependency under `node_modules`.
155
176
 
@@ -167,8 +188,8 @@ With one it is that commit against its parent, or against the empty tree for a r
167
188
 
168
189
  | Code | When |
169
190
  | --- | --- |
170
- | 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, it adds or breaks no reference that does not resolve, and no code span a living doc or an agent file names vanished from every file outside the docs |
171
- | 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, the range adds or breaks a reference that does not resolve, or the range removes a name a living doc or an agent file still carries |
191
+ | 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, it adds or breaks no reference that does not resolve, every agent file holds to the ceiling and holds no `## Maintaining this file`, every entry in an agent file names a tracked path, a link or a command, and no code span a living doc or an agent file names vanished from every file outside the docs |
192
+ | 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, the range adds or breaks a reference that does not resolve, an agent file is over the ceiling or holds `## Maintaining this file`, an entry in an agent file names no tracked path, link or command, or the range removes a name a living doc or an agent file still carries |
172
193
  | 2 | a `package.json` does not decode, a ref does not resolve, or `grep` cannot read an installed direct dependency |
173
194
 
174
195
  ## Sample output
@@ -191,6 +212,7 @@ docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files
191
212
 
192
213
  `checks-lint` runs it over each pull request's range in every repository, as [checks-lint](checks-lint.md) says.
193
214
  A repository adopts the templates as its files change, and the prose rules as its lines change, because an untouched file or line never fails those checks.
215
+ It reshapes its agent files when it adopts the gate, because [Agent files](#agent-files) judges each one whatever the range touches.
194
216
  A name the range removes fails wherever a doc still carries it, because the removal is what turned the line stale.
195
217
 
196
218
  ## Related topics
@@ -10,6 +10,7 @@ audience: consumers
10
10
 
11
11
  It runs the gates under [What runs](../../README.md#what-runs), each in its own process.
12
12
  Gates requiring tracked TypeScript files begin running when the repository tracks TypeScript.
13
+ `checks-advisories` begins running when the repository tracks `bun.lock`.
13
14
  All other gates run for every repository.
14
15
 
15
16
  ## What it reads
@@ -44,7 +45,7 @@ With two arguments, the base and head override range discovery.
44
45
 
45
46
  ```
46
47
  checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
47
- checks-lint: 1 of 11 gate(s) failed: checks-comment-gate
48
+ checks-lint: 1 of 12 gate(s) failed: checks-comment-gate
48
49
  ```
49
50
 
50
51
  <!-- end generated lint-sample -->
@@ -12,6 +12,7 @@ The gate runs jscpd at 50 tokens and 5 lines against the base and head revisions
12
12
  It compares repeated lines for each file, so a decrease in another file never offsets a rise.
13
13
  It follows an edited rename back to the original file.
14
14
  A file that repeats lines without a rise is advisory.
15
+ A block two bins need goes into a module both import, as `rangeFromArgs` and `checkoutFiles` in `src/core/git.ts` show.
15
16
 
16
17
  ## What it reads
17
18
 
@@ -9,6 +9,7 @@ audience: consumers
9
9
  ## What it checks
10
10
 
11
11
  It runs the default suite with `bun test --randomize` and reads Bun's JUnit report from that run.
12
+ It runs Bun with `CI=true`, so `test.only` fails the run in every environment.
12
13
  Bun exits zero when tests skip, so `checks-test` checks every skipped test against its source declaration.
13
14
  A test that `test.skip`, `test.skipIf`, `test.if`, `test.todo` or an enclosing `describe.skip` skips fails unless the test declares its reason.
14
15
 
@@ -33,6 +34,8 @@ A skip is declared on the test itself and only there.
33
34
  Add `"ci"` or `"local"` as the third `skipReason` argument when a declaration applies to one environment.
34
35
  Omit the third argument when it applies in both environments.
35
36
  A declaration for the other environment is not judged in the current run.
37
+ Bun sees `CI` set in every `checks-test` run, so a `"ci"` skip whose condition reads `process.env.CI` also skips in a local run and fails there as undeclared.
38
+ Gate a `"ci"` skip on what CI lacks, such as a daemon or a tool, and never on `process.env.CI`.
36
39
 
37
40
  A declaration whose test passes or does not register fails a CI run.
38
41
  A local run warns about the same declaration because a condition can depend on the machine.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.27.0",
3
+ "version": "0.29.0",
4
4
  "description": "Deterministic checks shared across a set of TypeScript repositories",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,6 +53,11 @@
53
53
  "src/docs/docs.ts",
54
54
  "src/dependencies/vendor.ts",
55
55
  "src/dependencies/vendor-args.ts",
56
+ "src/dependencies/cache-root.ts",
57
+ "src/dependencies/advisories.ts",
58
+ "src/dependencies/advisory-rules.ts",
59
+ "src/dependencies/osv-scanner.ts",
60
+ "src/docs/doc-agents.ts",
56
61
  "src/docs/doc-outline.ts",
57
62
  "src/docs/doc-rules.ts",
58
63
  "src/docs/doc-references.ts",
@@ -100,7 +105,8 @@
100
105
  "checks-exports": "src/complexity/exports.ts",
101
106
  "checks-quarantine-clock": "src/testing/quarantine-clock.ts",
102
107
  "checks-docs": "src/docs/docs.ts",
103
- "checks-vendor": "src/dependencies/vendor.ts"
108
+ "checks-vendor": "src/dependencies/vendor.ts",
109
+ "checks-advisories": "src/dependencies/advisories.ts"
104
110
  },
105
111
  "scripts": {
106
112
  "prepare": "bun src/dependencies/vendor.ts --library effect --package effect --repository https://github.com/Effect-TS/effect.git --tag 'effect@{version}' --path packages/effect/package.json",
@@ -27,7 +27,7 @@ const hasKnipConfig = Effect.fn("hasKnipConfig")(function* (root: string) {
27
27
  const manifest: unknown = yield* fs.readFileString(path.join(root, "package.json")).pipe(
28
28
  Effect.flatMap((text) =>
29
29
  Effect.try({
30
- try: () => JSON.parse(text),
30
+ try: (): unknown => JSON.parse(text),
31
31
  catch: () => new KnipError({ message: "package.json does not parse as JSON" }),
32
32
  })
33
33
  ),
package/src/core/gates.ts CHANGED
@@ -19,6 +19,7 @@ export type KitGate = {
19
19
  readonly vector: Vector;
20
20
  readonly file: string;
21
21
  readonly reads: "tree" | "range";
22
+ readonly alsoReads?: string;
22
23
  readonly args?: readonly string[];
23
24
  readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
24
25
  };
@@ -31,6 +32,8 @@ export const DEFAULT_BRANCH = "main";
31
32
 
32
33
  const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
33
34
 
35
+ const BUN_LOCKFILE: TrackedContent = { pathspecs: ["bun.lock"], content: "a bun lockfile" };
36
+
34
37
  export const KIT_GATES = [
35
38
  { bin: "checks-lint-coverage", vector: "quality", file: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
36
39
  { bin: "checks-test-layout", vector: "testing", file: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
@@ -38,9 +41,10 @@ export const KIT_GATES = [
38
41
  { bin: "checks-comment-gate", vector: "quality", file: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
39
42
  { bin: "checks-suppressions-ratchet", vector: "complexity", file: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
40
43
  { bin: "checks-ci-wiring", vector: "delivery", file: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
41
- { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
44
+ { bin: "checks-docs", vector: "docs", file: "docs.ts", reads: "range", alsoReads: "every agent file at the head commit", appliesTo: EVERY_REPOSITORY },
42
45
  { bin: "checks-repetition", vector: "complexity", file: "repetition.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
43
46
  { bin: "checks-unused", vector: "complexity", file: "unused.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
44
47
  { bin: "checks-exports", vector: "complexity", file: "exports.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
45
48
  { bin: "checks-quarantine-clock", vector: "testing", file: "quarantine-clock.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
49
+ { bin: "checks-advisories", vector: "dependencies", file: "advisories.ts", reads: "range", appliesTo: BUN_LOCKFILE },
46
50
  ] as const satisfies readonly KitGate[];
package/src/core/git.ts CHANGED
@@ -205,6 +205,16 @@ export const commitOf = Effect.fn("commitOf")(function* (rev: string, cwd?: stri
205
205
  return (yield* git(["rev-parse", "--verify", `${rev}^{commit}`], cwd)).trim();
206
206
  });
207
207
 
208
+ // The later of the author and committer dates, in seconds, so a rebase neither restarts nor stops a clock read from it.
209
+ export const writtenAt = Effect.fn("writtenAt")(function* (rev: string, cwd?: string) {
210
+ const shown = yield* git(["show", "-s", "--format=%at %ct", rev], cwd);
211
+ const dates = shown.trim().split(" ").map(Number);
212
+ if (dates.length !== 2 || !dates.every(Number.isInteger)) {
213
+ return yield* new GitFailure({ message: `cannot read when ${rev} was written` });
214
+ }
215
+ return Math.max(...dates);
216
+ });
217
+
208
218
  // A scratch index leaves the repository's own index and working tree untouched.
209
219
  export const checkoutFiles = Effect.fn("checkoutFiles")(function* (rev: string, files: readonly string[], scratch: string, cwd?: string) {
210
220
  const path = yield* Path.Path;
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env bun
2
+ import { Clock, Config, Console, Effect, FileSystem, type Layer, Option, Path, Schema } from "effect";
3
+ import { changedPaths, commitOf, git, pathsAt, rangeFromArgs, writtenAt } from "../core/git.ts";
4
+ import type { BunServices } from "@effect/platform-bun";
5
+ import { runMain, Usage } from "../core/main.ts";
6
+ import {
7
+ ACKNOWLEDGEMENTS,
8
+ clockOf,
9
+ decodeAcknowledgements,
10
+ decodeOsvReport,
11
+ findingsIn,
12
+ judge,
13
+ LOCKFILE,
14
+ passes,
15
+ report,
16
+ summaryOf,
17
+ type AcknowledgementClock,
18
+ type Outcome,
19
+ } from "./advisory-rules.ts";
20
+ import { cacheRoot } from "./cache-root.ts";
21
+ import { scanLockfiles, Scanner } from "./osv-scanner.ts";
22
+
23
+ const NAME = "advisories";
24
+ const ALL = "--all";
25
+ const USAGE = `usage: advisories.ts <ref> | <base-ref> <head-ref> | ${ALL}`;
26
+ const SIDES = ["base", "head"] as const;
27
+
28
+ class AdvisoriesError extends Schema.TaggedError<AdvisoriesError>()("AdvisoriesError", {
29
+ message: Schema.String,
30
+ }) {}
31
+
32
+ type Request = { readonly kind: "range"; readonly base: string; readonly head: string } | { readonly kind: "head"; readonly head: string };
33
+
34
+ const requestOf = Effect.fn("requestOf")(function* (args: readonly string[]) {
35
+ if (args[0] === ALL) {
36
+ if (args.length > 1) return yield* new Usage({ message: USAGE });
37
+ return { kind: "head", head: yield* commitOf("HEAD") } satisfies Request;
38
+ }
39
+ const { base, head } = yield* rangeFromArgs(args, USAGE);
40
+ return { kind: "range", base, head: yield* commitOf(head) } satisfies Request;
41
+ });
42
+
43
+ const fileAt = Effect.fn("fileAt")(function* (rev: string, file: string, root: string) {
44
+ if (!(yield* pathsAt(rev, [file], root)).includes(file)) return Option.none<string>();
45
+ return Option.some(yield* git(["show", `${rev}:${file}`], root));
46
+ });
47
+
48
+ const acknowledgementsAt = Effect.fn("acknowledgementsAt")(function* (head: string, root: string) {
49
+ const text = yield* fileAt(head, ACKNOWLEDGEMENTS, root);
50
+ if (Option.isNone(text)) return [];
51
+ return yield* decodeAcknowledgements(text.value).pipe(
52
+ Effect.mapError((cause) => new AdvisoriesError({ message: `${ACKNOWLEDGEMENTS} at ${head} does not decode: ${cause.message}` })),
53
+ );
54
+ });
55
+
56
+ type Lockfiles = Readonly<Record<(typeof SIDES)[number], Option.Option<string>>>;
57
+
58
+ // Scanning copies keeps the working tree and a repository's own osv-scanner.toml out of the verdict.
59
+ const findingsOf = Effect.fn("findingsOf")(function* (lockfiles: Lockfiles) {
60
+ const fs = yield* FileSystem.FileSystem;
61
+ const path = yield* Path.Path;
62
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-advisories-" });
63
+ const config = path.join(dir, "empty.toml");
64
+ yield* fs.writeFileString(config, "");
65
+ const written: string[] = [];
66
+ for (const side of SIDES) {
67
+ const text = lockfiles[side];
68
+ if (Option.isNone(text)) continue;
69
+ yield* fs.makeDirectory(path.join(dir, side));
70
+ yield* fs.writeFileString(path.join(dir, side, LOCKFILE), text.value);
71
+ written.push(path.join(dir, side, LOCKFILE));
72
+ }
73
+ const cache = yield* cacheRoot();
74
+ const { stdout, note } = yield* scanLockfiles(yield* (yield* Scanner).binary(cache), cache, config, written);
75
+ if (Option.isSome(note)) yield* Console.error(`${NAME}: ${note.value}`);
76
+ const scanned = yield* decodeOsvReport(stdout).pipe(
77
+ Effect.mapError((cause) => new AdvisoriesError({ message: `cannot read the report OSV-Scanner wrote: ${cause.message}` })),
78
+ );
79
+ const at = (side: string) => (source: string) => source.endsWith(`${path.sep}${side}${path.sep}${LOCKFILE}`);
80
+ return { base: findingsIn(scanned, at("base")), head: findingsIn(scanned, at("head")) };
81
+ }, Effect.scoped);
82
+
83
+ const outcomeOf = Effect.fn("outcomeOf")(function* (request: Request, root: string, clock: AcknowledgementClock) {
84
+ if (request.kind === "range" && (yield* changedPaths(request.base, request.head, [LOCKFILE], root)).length === 0) {
85
+ return { kind: "unchanged", problems: clock.problems } satisfies Outcome;
86
+ }
87
+ const head = yield* fileAt(request.head, LOCKFILE, root);
88
+ const base = request.kind === "range" ? yield* fileAt(request.base, LOCKFILE, root) : Option.none<string>();
89
+ const findings = Option.isNone(head) ? { base: [], head: [] } : yield* findingsOf({ base, head });
90
+ return { kind: "scanned", scope: { kind: request.kind }, judged: judge(findings.base, findings.head, clock) } satisfies Outcome;
91
+ });
92
+
93
+ const toStepSummary = Effect.fn("toStepSummary")(function* (text: string) {
94
+ const stepSummary = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY"));
95
+ if (Option.isNone(stepSummary)) return;
96
+ yield* (yield* FileSystem.FileSystem).writeFileString(stepSummary.value, `${summaryOf(text)}\n`, { flag: "a" });
97
+ });
98
+
99
+ const advisories = Effect.gen(function* () {
100
+ const request = yield* requestOf(process.argv.slice(2));
101
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
102
+ const dates = { head: (yield* writtenAt(request.head, root)) * 1000, now: yield* Clock.currentTimeMillis };
103
+ const clock = clockOf(yield* acknowledgementsAt(request.head, root), { kind: request.kind }, dates);
104
+ const outcome = yield* outcomeOf(request, root, clock);
105
+ const text = report(outcome);
106
+ yield* Console.log(text);
107
+ if (request.kind === "head") yield* toStepSummary(text);
108
+ return passes(outcome);
109
+ });
110
+
111
+ export function main(scanner: Layer.Layer<Scanner, never, BunServices.BunServices>): void {
112
+ runMain(NAME, advisories.pipe(Effect.provide(scanner)));
113
+ }
114
+
115
+ if (import.meta.main) main(Scanner.pinned);
@@ -0,0 +1,206 @@
1
+ import { Schema } from "effect";
2
+
3
+ export const LOCKFILE = "bun.lock";
4
+ export const ACKNOWLEDGEMENTS = "advisory-acks.json";
5
+ const ACKNOWLEDGEMENT_DAYS = 30;
6
+ const DAY_MS = 86_400_000;
7
+ const UNRATED = "unrated";
8
+
9
+ // Date.parse rolls a day past the month's end, such as 2026-02-30, into the next month, so the round trip catches it.
10
+ function isCalendarDay(day: string): boolean {
11
+ const ms = Date.parse(`${day}T00:00:00Z`);
12
+ return !Number.isNaN(ms) && new Date(ms).toISOString().startsWith(day);
13
+ }
14
+
15
+ const Day = Schema.String.check(
16
+ Schema.isPattern(/^\d{4}-\d{2}-\d{2}$/, { message: "is not a day such as 2026-10-20" }),
17
+ Schema.makeFilter((day: string) => isCalendarDay(day) || "is not a day of the calendar"),
18
+ );
19
+
20
+ const Acknowledgement = Schema.Struct({
21
+ package: Schema.NonEmptyString,
22
+ id: Schema.NonEmptyString,
23
+ until: Day,
24
+ reason: Schema.NonEmptyString,
25
+ });
26
+
27
+ export type Acknowledgement = typeof Acknowledgement.Type;
28
+
29
+ export const decodeAcknowledgements = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Array(Acknowledgement)));
30
+
31
+ const Vulnerability = Schema.Struct({
32
+ id: Schema.String,
33
+ summary: Schema.optionalKey(Schema.String),
34
+ database_specific: Schema.optionalKey(Schema.Struct({ severity: Schema.optionalKey(Schema.String) })),
35
+ });
36
+
37
+ const OsvReport = Schema.Struct({
38
+ results: Schema.Array(
39
+ Schema.Struct({
40
+ source: Schema.Struct({ path: Schema.String }),
41
+ packages: Schema.Array(
42
+ Schema.Struct({
43
+ package: Schema.Struct({ name: Schema.String, version: Schema.String }),
44
+ vulnerabilities: Schema.optionalKey(Schema.Array(Vulnerability)),
45
+ }),
46
+ ),
47
+ }),
48
+ ),
49
+ });
50
+
51
+ type OsvReport = typeof OsvReport.Type;
52
+
53
+ export const decodeOsvReport = Schema.decodeUnknownEffect(Schema.fromJsonString(OsvReport));
54
+
55
+ export type Finding = {
56
+ readonly name: string;
57
+ readonly version: string;
58
+ readonly id: string;
59
+ readonly severity: string;
60
+ readonly summary: string;
61
+ };
62
+
63
+ // OSV-Scanner leaves a lockfile with no advisory out of its results, so an absent lockfile reads as clean.
64
+ export function findingsIn(scanned: OsvReport, lockfile: (path: string) => boolean): readonly Finding[] {
65
+ return scanned.results
66
+ .filter(({ source }) => lockfile(source.path))
67
+ .flatMap(({ packages }) => packages)
68
+ .flatMap(({ package: { name, version }, vulnerabilities = [] }) =>
69
+ vulnerabilities.map((vulnerability) => ({
70
+ name,
71
+ version,
72
+ id: vulnerability.id,
73
+ severity: vulnerability.database_specific?.severity?.toLowerCase() ?? UNRATED,
74
+ summary: vulnerability.summary ?? "",
75
+ })),
76
+ );
77
+ }
78
+
79
+ // Two live records can list each other as aliases, so only the id the scan reports names an advisory.
80
+ function keyOf({ name, id }: { readonly name: string; readonly id: string }): string {
81
+ return `${name} ${id}`;
82
+ }
83
+
84
+ // Keyed by name and id rather than version, so moving between two affected versions adds nothing.
85
+ export function introducedBy(base: readonly Finding[], head: readonly Finding[]): readonly Finding[] {
86
+ const known = new Set(base.map(keyOf));
87
+ return head.filter((finding) => !known.has(keyOf(finding)));
88
+ }
89
+
90
+ type AcknowledgementProblem =
91
+ | { readonly kind: "expired"; readonly acknowledgement: Acknowledgement }
92
+ | { readonly kind: "too-far"; readonly acknowledgement: Acknowledgement; readonly latest: string }
93
+ | { readonly kind: "unmatched"; readonly acknowledgement: Acknowledgement };
94
+
95
+ export type AcknowledgementClock = {
96
+ readonly live: readonly Acknowledgement[];
97
+ readonly problems: readonly AcknowledgementProblem[];
98
+ };
99
+
100
+ type Scope = { readonly kind: "range" } | { readonly kind: "head" };
101
+
102
+ export type Dates = { readonly head: number; readonly now: number };
103
+
104
+ // A range reads the head's date so a commit gets one verdict, and --all reads today so an entry expires in an idle repository.
105
+ function measuredFrom(scope: Scope, { head, now }: Dates): number {
106
+ return scope.kind === "head" ? now : head;
107
+ }
108
+
109
+ function dayOf(ms: number): string {
110
+ return new Date(ms).toISOString().slice(0, "YYYY-MM-DD".length);
111
+ }
112
+
113
+ export function clockOf(acknowledgements: readonly Acknowledgement[], scope: Scope, dates: Dates): AcknowledgementClock {
114
+ const from = measuredFrom(scope, dates);
115
+ const limit = from + ACKNOWLEDGEMENT_DAYS * DAY_MS;
116
+ const live: Acknowledgement[] = [];
117
+ const problems: AcknowledgementProblem[] = [];
118
+ for (const acknowledgement of acknowledgements) {
119
+ const until = Date.parse(`${acknowledgement.until}T00:00:00Z`);
120
+ if (until <= from) problems.push({ kind: "expired", acknowledgement });
121
+ else if (until > limit) problems.push({ kind: "too-far", acknowledgement, latest: dayOf(limit) });
122
+ else live.push(acknowledgement);
123
+ }
124
+ return { live, problems };
125
+ }
126
+
127
+ function covers(acknowledgement: Acknowledgement, finding: Finding): boolean {
128
+ return acknowledgement.package === finding.name && acknowledgement.id === finding.id;
129
+ }
130
+
131
+ type Judged = {
132
+ readonly failing: readonly Finding[];
133
+ readonly acknowledged: number;
134
+ readonly predating: number;
135
+ readonly problems: readonly AcknowledgementProblem[];
136
+ };
137
+
138
+ export function judge(base: readonly Finding[], head: readonly Finding[], clock: AcknowledgementClock): Judged {
139
+ const added = introducedBy(base, head);
140
+ const failing = added.filter((finding) => !clock.live.some((acknowledgement) => covers(acknowledgement, finding)));
141
+ const unmatched = clock.live
142
+ .filter((acknowledgement) => !head.some((finding) => covers(acknowledgement, finding)))
143
+ .map((acknowledgement): AcknowledgementProblem => ({ kind: "unmatched", acknowledgement }));
144
+ return {
145
+ failing,
146
+ acknowledged: added.length - failing.length,
147
+ predating: head.length - added.length,
148
+ problems: [...clock.problems, ...unmatched],
149
+ };
150
+ }
151
+
152
+ export type Outcome =
153
+ | { readonly kind: "unchanged"; readonly problems: readonly AcknowledgementProblem[] }
154
+ | { readonly kind: "scanned"; readonly scope: Scope; readonly judged: Judged };
155
+
156
+ export function passes(outcome: Outcome): boolean {
157
+ if (outcome.kind === "unchanged") return outcome.problems.length === 0;
158
+ return outcome.judged.failing.length === 0 && outcome.judged.problems.length === 0;
159
+ }
160
+
161
+ function problemLine(problem: AcknowledgementProblem): string {
162
+ const { package: name, id, until } = problem.acknowledgement;
163
+ switch (problem.kind) {
164
+ case "expired":
165
+ return ` ${name} ${id} expired on ${until}; upgrade the package and delete the entry, or renew it with a later day`;
166
+ case "too-far":
167
+ return ` ${name} ${id} runs until ${until}, more than ${ACKNOWLEDGEMENT_DAYS} days out; name a day no later than ${problem.latest}`;
168
+ case "unmatched":
169
+ return ` ${name} ${id} matches nothing in ${LOCKFILE} at the head; delete it`;
170
+ }
171
+ }
172
+
173
+ function problemLines(problems: readonly AcknowledgementProblem[]): readonly string[] {
174
+ if (problems.length === 0) return [];
175
+ return [`advisories: ${problems.length} acknowledgement(s) in ${ACKNOWLEDGEMENTS} do not hold:`, ...problems.map(problemLine)];
176
+ }
177
+
178
+ function findingLine({ name, version, id, severity, summary }: Finding): string {
179
+ return ` ${name}@${version} ${id} ${severity}${summary === "" ? "" : `: ${summary}`}`;
180
+ }
181
+
182
+ function scannedLines(scope: Scope, { failing, acknowledged, predating }: Judged): readonly string[] {
183
+ const fix = `upgrade each package, or acknowledge its advisory in ${ACKNOWLEDGEMENTS}:`;
184
+ if (scope.kind === "head") {
185
+ if (failing.length === 0) return [`advisories: ${LOCKFILE} at the head holds no unacknowledged advisory (${acknowledged} acknowledged)`];
186
+ return [`advisories: ${LOCKFILE} at the head holds ${failing.length} unacknowledged advisory(ies); ${fix}`, ...failing.map(findingLine)];
187
+ }
188
+ const counts = `${predating} at the head predate the range, ${acknowledged} acknowledged`;
189
+ if (failing.length === 0) return [`advisories: the range adds no advisory to ${LOCKFILE} (${counts})`];
190
+ return [`advisories: the range adds ${failing.length} advisory(ies) to ${LOCKFILE} (${counts}); ${fix}`, ...failing.map(findingLine)];
191
+ }
192
+
193
+ export function report(outcome: Outcome): string {
194
+ if (outcome.kind === "unchanged") {
195
+ return [`advisories: ${LOCKFILE} is unchanged in the range, so nothing was scanned`, ...problemLines(outcome.problems)].join("\n");
196
+ }
197
+ return [...scannedLines(outcome.scope, outcome.judged), ...problemLines(outcome.judged.problems)].join("\n");
198
+ }
199
+
200
+ // The job summary is Markdown, where an indented line would merge into the paragraph above it.
201
+ export function summaryOf(text: string): string {
202
+ return text
203
+ .split("\n")
204
+ .map((line) => (line.startsWith(" ") ? `- ${line.trim()}` : line))
205
+ .join("\n");
206
+ }
@@ -0,0 +1,15 @@
1
+ import { Config, Effect, Path, Schema } from "effect";
2
+
3
+ const CACHE_HOME = ".cache/avi2dg-checks";
4
+
5
+ class CacheUnrooted extends Schema.TaggedError<CacheUnrooted>()("CacheUnrooted", {
6
+ message: Schema.String,
7
+ }) {}
8
+
9
+ export const cacheRoot = Effect.fn("cacheRoot")(function* () {
10
+ const path = yield* Path.Path;
11
+ const home = yield* Config.NonEmptyString("HOME").pipe(
12
+ Effect.mapError(() => new CacheUnrooted({ message: "HOME is missing, so the shared cache has no root" })),
13
+ );
14
+ return path.join(home, CACHE_HOME);
15
+ });
@@ -0,0 +1,176 @@
1
+ import { Clock, Context, Crypto, Effect, Encoding, FileSystem, Layer, Option, Path, Schema } from "effect";
2
+ import { HttpClient, HttpClientResponse } from "effect/unstable/http";
3
+ import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
4
+ import { collect } from "../core/git.ts";
5
+
6
+ export const OSV_SCANNER_VERSION = "2.6.0";
7
+ const RELEASES = `https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}`;
8
+ const EXECUTABLE_MODE = 0o755;
9
+ const HOUR_MS = 3_600_000;
10
+ const DAY_MS = 24 * HOUR_MS;
11
+ export const REFRESH_HOURS = 24;
12
+ export const USABLE_DAYS = 7;
13
+ const FOUND_NOTHING = 0;
14
+ const FOUND_ADVISORIES = 1;
15
+ const FOUND_NO_PACKAGE = 128;
16
+ const NO_RESULTS = JSON.stringify({ results: [] });
17
+
18
+ type Build = { readonly asset: string; readonly sha256: string };
19
+
20
+ // The SHA-256 of each asset as osv-scanner_SHA256SUMS of the release lists it.
21
+ const BUILDS: Readonly<Record<string, Build>> = {
22
+ "darwin-arm64": { asset: "osv-scanner_darwin_arm64", sha256: "98c460dcd37de25819babd757d04542045b6243113e209edcd4d89fedb0256b4" },
23
+ "darwin-x64": { asset: "osv-scanner_darwin_amd64", sha256: "60c5296637e977b28eeda5c7f13573e447659a632922737f94d11fa7e30ad6ca" },
24
+ "linux-arm64": { asset: "osv-scanner_linux_arm64", sha256: "2c71403eb443d05891c4f268c3ad771cf4f16e5443463fd7851ef8f454d3c7e4" },
25
+ "linux-x64": { asset: "osv-scanner_linux_amd64", sha256: "ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108" },
26
+ };
27
+
28
+ class OsvScannerError extends Schema.TaggedError<OsvScannerError>()("OsvScannerError", {
29
+ message: Schema.String,
30
+ }) {}
31
+
32
+ export function buildFor(platform: string, arch: string): Option.Option<Build> {
33
+ return Option.fromNullishOr(BUILDS[`${platform}-${arch}`]);
34
+ }
35
+
36
+ const sha256Of = Effect.fn("sha256Of")(function* (bytes: Uint8Array) {
37
+ const crypto = yield* Crypto.Crypto;
38
+ return Encoding.encodeHex(yield* crypto.digest("SHA-256", bytes));
39
+ });
40
+
41
+ const verified = Effect.fn("verified")(function* (binary: string, sha256: string) {
42
+ const fs = yield* FileSystem.FileSystem;
43
+ const found = yield* sha256Of(yield* fs.readFile(binary));
44
+ if (found !== sha256) {
45
+ return yield* new OsvScannerError({ message: `${binary} has SHA-256 ${found}, not the pinned ${sha256}; delete it and rerun` });
46
+ }
47
+ return binary;
48
+ });
49
+
50
+ const download = Effect.fn("download")(function* (url: string) {
51
+ const response = yield* HttpClient.get(url).pipe(Effect.flatMap(HttpClientResponse.filterStatusOk));
52
+ return new Uint8Array(yield* response.arrayBuffer);
53
+ }, Effect.provide(FetchHttpClient.layer));
54
+
55
+ // The binary lands through a rename in its own directory, so a concurrent run sees it whole or not at all.
56
+ export const installPinned = Effect.fn("installPinned")(function* (url: string, sha256: string, binary: string) {
57
+ const fs = yield* FileSystem.FileSystem;
58
+ const path = yield* Path.Path;
59
+ if (yield* fs.exists(binary)) return yield* verified(binary, sha256);
60
+ const bytes = yield* download(url).pipe(
61
+ Effect.mapError((cause) => new OsvScannerError({ message: `cannot download ${url}: ${cause.message}` })),
62
+ );
63
+ const found = yield* sha256Of(bytes);
64
+ if (found !== sha256) {
65
+ return yield* new OsvScannerError({ message: `${url} has SHA-256 ${found}, not the pinned ${sha256}, so nothing was installed` });
66
+ }
67
+ yield* fs.makeDirectory(path.dirname(binary), { recursive: true });
68
+ const staging = yield* fs.makeTempDirectoryScoped({ directory: path.dirname(binary), prefix: `.${path.basename(binary)}-` });
69
+ const staged = path.join(staging, path.basename(binary));
70
+ yield* fs.writeFile(staged, bytes);
71
+ yield* fs.chmod(staged, EXECUTABLE_MODE);
72
+ yield* fs.rename(staged, binary);
73
+ return binary;
74
+ }, Effect.scoped);
75
+
76
+ const pinnedBinary = Effect.fn("pinnedBinary")(function* (cache: string) {
77
+ const build = buildFor(process.platform, process.arch);
78
+ if (Option.isNone(build)) {
79
+ return yield* new OsvScannerError({ message: `no pinned OSV-Scanner build runs on ${process.platform}-${process.arch}` });
80
+ }
81
+ const path = yield* Path.Path;
82
+ const { asset, sha256 } = build.value;
83
+ return yield* installPinned(`${RELEASES}/${asset}`, sha256, path.join(cache, "osv-scanner", OSV_SCANNER_VERSION, asset));
84
+ });
85
+
86
+ type PinnedBinary = ReturnType<typeof pinnedBinary>;
87
+
88
+ export class Scanner extends Context.Service<
89
+ Scanner,
90
+ { readonly binary: (cache: string) => Effect.Effect<string, Effect.Error<PinnedBinary>> }
91
+ >()("@avi2dg/checks/dependencies/Scanner") {
92
+ static readonly pinned = Layer.effect(
93
+ Scanner,
94
+ Effect.gen(function* () {
95
+ const services = yield* Effect.context<Effect.Services<PinnedBinary>>();
96
+ return Scanner.of({ binary: (cache) => pinnedBinary(cache).pipe(Effect.provideContext(services)) });
97
+ }),
98
+ );
99
+ }
100
+
101
+ type RefreshPlan = "refresh" | "offline";
102
+
103
+ export function refreshPlan(refreshedAgo: Option.Option<number>): RefreshPlan {
104
+ return Option.isSome(refreshedAgo) && refreshedAgo.value <= REFRESH_HOURS * HOUR_MS ? "offline" : "refresh";
105
+ }
106
+
107
+ export function usableWithoutRefresh(refreshedAgo: Option.Option<number>): boolean {
108
+ return Option.isSome(refreshedAgo) && refreshedAgo.value <= USABLE_DAYS * DAY_MS;
109
+ }
110
+
111
+ type Database = { readonly dir: string; readonly marker: string };
112
+
113
+ // An unparsable marker gives NaN and one dated ahead of the clock a negative age, and neither says how old the database is.
114
+ export function refreshAge(recorded: Option.Option<string>, now: number): Option.Option<number> {
115
+ return Option.filter(Option.map(recorded, (text) => now - Date.parse(text.trim())), (ago) => ago >= 0);
116
+ }
117
+
118
+ const refreshedAgo = Effect.fn("refreshedAgo")(function* ({ marker }: Database) {
119
+ const fs = yield* FileSystem.FileSystem;
120
+ const recorded = yield* fs.readFileString(marker).pipe(Effect.option);
121
+ return refreshAge(recorded, yield* Clock.currentTimeMillis);
122
+ });
123
+
124
+ const FLAGS: Readonly<Record<RefreshPlan, readonly string[]>> = {
125
+ refresh: ["--offline-vulnerabilities", "--download-offline-databases", "--no-resolve"],
126
+ offline: ["--offline"],
127
+ };
128
+
129
+ type Scanned =
130
+ | { readonly kind: "scanned"; readonly stdout: string }
131
+ | { readonly kind: "no-package" }
132
+ | { readonly kind: "failed"; readonly reason: string };
133
+
134
+ const scanOnce = Effect.fn("scanOnce")(function* (binary: string, database: Database, config: string, lockfiles: readonly string[], plan: RefreshPlan) {
135
+ const args = ["scan", "source", ...FLAGS[plan], "--format", "json", "--config", config, ...lockfiles.flatMap((file) => ["-L", file])];
136
+ const { stdout, stderr, exitCode } = yield* collect(binary, args, undefined, { env: { OSV_SCANNER_LOCAL_DB_CACHE_DIRECTORY: database.dir } }).pipe(
137
+ Effect.mapError((cause) => new OsvScannerError({ message: `cannot run ${binary}: ${cause.message}` })),
138
+ );
139
+ if (exitCode === FOUND_NOTHING || exitCode === FOUND_ADVISORIES) return { kind: "scanned", stdout } satisfies Scanned;
140
+ if (exitCode === FOUND_NO_PACKAGE) return { kind: "no-package" } satisfies Scanned;
141
+ const reason = stderr.trim().split("\n").at(-1) ?? "";
142
+ return { kind: "failed", reason: `${binary} exited ${exitCode}: ${reason}` } satisfies Scanned;
143
+ });
144
+
145
+ type ScanResult = { readonly stdout: string; readonly note: Option.Option<string> };
146
+
147
+ // A failed refresh leaves the cached database in place, which still serves until USABLE_DAYS pass without a refresh.
148
+ export const scanLockfiles = Effect.fn("scanLockfiles")(function* (binary: string, cache: string, config: string, lockfiles: readonly string[]) {
149
+ const fs = yield* FileSystem.FileSystem;
150
+ const path = yield* Path.Path;
151
+ const dir = path.join(cache, "osv-scanner", "db");
152
+ const database = { dir, marker: path.join(dir, "refreshed") };
153
+ const ago = yield* refreshedAgo(database);
154
+ const plan = refreshPlan(ago);
155
+ const first = yield* scanOnce(binary, database, config, lockfiles, plan);
156
+ // A lockfile with no package stops the scanner before it downloads anything, so no refresh is recorded.
157
+ if (first.kind === "no-package") return { stdout: NO_RESULTS, note: Option.none() } satisfies ScanResult;
158
+ if (first.kind === "scanned") {
159
+ if (plan === "refresh") {
160
+ yield* fs.makeDirectory(dir, { recursive: true });
161
+ yield* fs.writeFileString(database.marker, `${new Date(yield* Clock.currentTimeMillis).toISOString()}\n`);
162
+ }
163
+ return { stdout: first.stdout, note: Option.none() } satisfies ScanResult;
164
+ }
165
+ if (plan === "offline") return yield* new OsvScannerError({ message: first.reason });
166
+ if (Option.isNone(ago) || !usableWithoutRefresh(ago)) {
167
+ return yield* new OsvScannerError({
168
+ message: `could not refresh the OSV database, and no copy was refreshed in the last ${USABLE_DAYS} days: ${first.reason}`,
169
+ });
170
+ }
171
+ const fallback = yield* scanOnce(binary, database, config, lockfiles, "offline");
172
+ if (fallback.kind === "failed") return yield* new OsvScannerError({ message: fallback.reason });
173
+ if (fallback.kind === "no-package") return { stdout: NO_RESULTS, note: Option.none() } satisfies ScanResult;
174
+ const days = Math.floor(ago.value / DAY_MS);
175
+ return { stdout: fallback.stdout, note: Option.some(`could not refresh the OSV database, so the scan read the copy refreshed ${days} day(s) ago: ${first.reason}`) } satisfies ScanResult;
176
+ });
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
2
+ import { Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
3
  import { git } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
+ import { cacheRoot } from "./cache-root.ts";
5
6
  import { librariesFrom, NAME, OPENER, type Library } from "./vendor-args.ts";
6
7
 
7
- const CACHE_HOME = ".cache/avi2dg-checks";
8
8
  const RECORD_SUFFIX = ".commit";
9
9
  const LINKS = "repos";
10
10
  const VERSION_TOKEN = "{version}";
@@ -303,14 +303,6 @@ const vend = Effect.fn("vend")(function* (root: string, cache: string, library:
303
303
  yield* ensureLink(root, library, dir);
304
304
  });
305
305
 
306
- const cacheRoot = Effect.fn("cacheRoot")(function* () {
307
- const path = yield* Path.Path;
308
- const home = yield* Config.NonEmptyString("HOME").pipe(
309
- Effect.mapError(() => new VendorError({ message: "HOME is missing, so the shared cache has no root" })),
310
- );
311
- return path.join(home, CACHE_HOME);
312
- });
313
-
314
306
  const main = Effect.gen(function* () {
315
307
  const libraries = yield* librariesFrom(process.argv.slice(2));
316
308
  if (libraries.length === 0) {
@@ -0,0 +1,106 @@
1
+ import { commandNames, type Snapshot } from "./doc-references.ts";
2
+ import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
3
+
4
+ export const AGENT_CEILING = 3000;
5
+
6
+ const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
7
+ const INDENT = /^[ \t]*/;
8
+ const CODE_INDENT = 4;
9
+ const INLINE_LINK =
10
+ /(?<![!\\])\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\(\s*(?:<[^<>\n]+>|[^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
11
+ const MAINTAINING = /^\s{0,3}#{1,6}\s+Maintaining this file(?:\s+#+)?\s*$/;
12
+
13
+ export type AgentFinding = {
14
+ readonly line: number | undefined;
15
+ readonly message: string;
16
+ };
17
+
18
+ export function isAgentFile(repositoryPath: string): boolean {
19
+ return AGENT_NAMES.includes(repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1));
20
+ }
21
+
22
+ export function ceilingFinding(text: string): AgentFinding | undefined {
23
+ if (text.length <= AGENT_CEILING) return undefined;
24
+ return {
25
+ line: undefined,
26
+ message: `is ${text.length} characters, over the 3,000-character ceiling for agent files. Keep what nearly every session needs plus one pointer per part, move each part's notes into the people doc that covers that part, and delete what a check already holds`,
27
+ };
28
+ }
29
+
30
+ export function maintainingFinding(text: string): AgentFinding | undefined {
31
+ const heading = scanMarkdown(text).find(({ kind, raw }) => kind === "heading" && MAINTAINING.test(raw));
32
+ if (heading === undefined) return undefined;
33
+ return {
34
+ line: heading.line,
35
+ message: "holds `## Maintaining this file`, which a router leaves out. Delete the section, since checks-docs holds the file's shape",
36
+ };
37
+ }
38
+
39
+ function indentOf(raw: string): number {
40
+ return (INDENT.exec(raw)?.[0] ?? "").replaceAll("\t", " ").length;
41
+ }
42
+
43
+ export function entries(text: string): readonly MarkdownLine[] {
44
+ let inList = false;
45
+ let afterBlank = true;
46
+ return scanMarkdown(text).filter((line) => {
47
+ const blank = line.raw.trim() === "";
48
+ const indented = !blank && indentOf(line.raw) >= CODE_INDENT && !line.raw.trimStart().startsWith(">");
49
+ const listItem = line.kind === "prose" && LIST_ITEM.test(line.prose) && (inList || !indented);
50
+ if (listItem) inList = true;
51
+ else if (!blank && !indented && (afterBlank || line.kind !== "prose")) inList = false;
52
+ afterBlank = blank;
53
+ return listItem;
54
+ });
55
+ }
56
+
57
+ type Tracked = Pick<Snapshot, "files" | "directories">;
58
+
59
+ const STAYS = new Set(["", "."]);
60
+
61
+ function joined(directory: string, segment: string): string {
62
+ return directory === "" ? segment : `${directory}/${segment}`;
63
+ }
64
+
65
+ function enter(directory: string, segment: string, tracked: Tracked): string | undefined {
66
+ if (STAYS.has(segment)) return directory;
67
+ if (segment === "..") return directory === "" ? undefined : directory.slice(0, Math.max(directory.lastIndexOf("/"), 0));
68
+ const next = joined(directory, segment);
69
+ return tracked.directories.has(next) ? next : undefined;
70
+ }
71
+
72
+ function resolvesFrom(directory: string, span: string, tracked: Tracked): boolean {
73
+ const segments = span.split("/");
74
+ const last = segments.pop() ?? "";
75
+ const walked = segments.reduce<string | undefined>((at, segment) => (at === undefined ? undefined : enter(at, segment, tracked)), directory);
76
+ if (walked === undefined) return false;
77
+ if (!STAYS.has(last) && last !== ".." && tracked.files.has(joined(walked, last))) return true;
78
+ const end = enter(walked, last, tracked);
79
+ return end !== undefined && end !== "" && end !== directory;
80
+ }
81
+
82
+ function namesTrackedPath(agentFile: string, span: string, tracked: Tracked): boolean {
83
+ const directory = agentFile.includes("/") ? agentFile.slice(0, agentFile.lastIndexOf("/")) : "";
84
+ const fromFile = resolvesFrom(directory, span, tracked);
85
+ return fromFile || (!span.startsWith("./") && !span.startsWith("../") && resolvesFrom("", span, tracked));
86
+ }
87
+
88
+ function namesLink({ raw, prose }: MarkdownLine): boolean {
89
+ return [...raw.matchAll(INLINE_LINK)].some(({ index }) => prose.charAt(index) === "[");
90
+ }
91
+
92
+ function points(agentFile: string, line: MarkdownLine, tracked: Tracked): boolean {
93
+ return line.code.some((span) => namesTrackedPath(agentFile, span, tracked)) || namesLink(line) || commandNames(line).length > 0;
94
+ }
95
+
96
+ export function entryFindings(agentFile: string, text: string, tracked: Tracked): readonly AgentFinding[] {
97
+ return entries(text).flatMap((line) => {
98
+ if (points(agentFile, line, tracked)) return [];
99
+ return [
100
+ {
101
+ line: line.line,
102
+ message: "names no tracked path, link or `bun run` command. Name the file, link or command that holds the detail",
103
+ },
104
+ ];
105
+ });
106
+ }
@@ -131,6 +131,10 @@ function commandsOn({ kind, raw, code }: MarkdownLine): readonly string[] {
131
131
  return texts.flatMap((text) => [...text.matchAll(RUN)].map(([, name = ""]) => name.replace(/[),.;:]+$/, "")));
132
132
  }
133
133
 
134
+ export function commandNames(line: MarkdownLine): readonly string[] {
135
+ return commandsOn(line).filter((name) => !NOT_A_NAME.test(name));
136
+ }
137
+
134
138
  export type Judging = { readonly commands: boolean };
135
139
 
136
140
  export function unresolvedIn(doc: string, text: string, snapshot: Snapshot, { commands }: Judging): readonly Unresolved[] {
@@ -57,14 +57,6 @@ const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequis
57
57
 
58
58
  const STEPS = ["To <do the task>:", "", "1. <step>", "1. <step>"];
59
59
 
60
- const MAINTAINING = [
61
- "Keep this file for knowledge useful to almost every future agent session in this project.",
62
- "Do not repeat what the codebase already shows.",
63
- "Point to the authoritative file or command instead.",
64
- "Prefer rewriting or pruning existing entries over appending new ones.",
65
- "When updating this file, preserve this bar for all agents and keep entries concise.",
66
- ];
67
-
68
60
  export const CHANGE_GROUPS = ["Breaking changes", "Features", "Fixes", "Performance", "Reverts"] as const;
69
61
  export type ChangeGroup = (typeof CHANGE_GROUPS)[number];
70
62
 
@@ -122,7 +114,6 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
122
114
  open("<A topic an agent needs>", "any", optional("the lead holds every constraint"), [
123
115
  "- <A constraint an agent cannot infer from the code, and the file that holds its detail.>",
124
116
  ]),
125
- fixed("Maintaining this file", REQUIRED, MAINTAINING),
126
117
  ],
127
118
  },
128
119
  claude: {
package/src/docs/docs.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect } from "effect";
3
- import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
3
+ import { ceilingFinding, entryFindings, isAgentFile, maintainingFinding } from "./doc-agents.ts";
4
+ import { rootsOf, snapshotOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
4
5
  import { ADR_DIRECTORY, judge, placementOf, placementProblem, speaksToConsumers, type Placement } from "./doc-rules.ts";
5
6
  import { vanishedNames } from "./doc-names.ts";
6
7
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
@@ -18,6 +19,7 @@ type Judged = {
18
19
  readonly held: readonly string[];
19
20
  readonly edited: { readonly docs: number; readonly lines: number };
20
21
  readonly named: number;
22
+ readonly agents: number;
21
23
  readonly findings: readonly Finding[];
22
24
  readonly advisory: ReadonlyMap<string, number>;
23
25
  readonly brokenBefore: readonly Finding[];
@@ -109,13 +111,27 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
109
111
  const referenced = new Map(proseDocs.map(({ path }) => [path, text(path)]));
110
112
  const references = yield* referenceFindings({ root, base, head, roots, changed, renamedFrom }, referenced, judging);
111
113
  const vanished = yield* vanishedNames(root, base, head, referenced, references.failed);
114
+ const agents = proseDocs.filter(({ path }) => isAgentFile(path));
115
+ const tracked = snapshotOf(yield* pathsAt(head, [], root), new Map(), new Map());
116
+ const shapes = agents.flatMap(({ path }) =>
117
+ [ceilingFinding(text(path)), maintainingFinding(text(path))].flatMap((finding) => (finding === undefined ? [] : [{ path, ...finding }])),
118
+ );
119
+ const entries = agents.flatMap(({ path }) => entryFindings(path, text(path), tracked).map((finding) => ({ path, ...finding })));
112
120
  const advisory = new Map<string, number>();
113
121
  for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
114
122
  return {
115
123
  held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
116
124
  edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
117
125
  named: referenced.size,
118
- findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing, ...vanished].toSorted(inPathOrder),
126
+ agents: agents.length,
127
+ findings: [
128
+ ...templated.filter((finding) => touched.has(finding.path)),
129
+ ...prose,
130
+ ...references.failing,
131
+ ...vanished,
132
+ ...shapes,
133
+ ...entries,
134
+ ].toSorted(inPathOrder),
119
135
  advisory,
120
136
  brokenBefore: references.brokenBefore.toSorted(inPathOrder),
121
137
  } satisfies Judged;
@@ -125,13 +141,14 @@ function describe({ path, line, message }: Finding): string {
125
141
  return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
126
142
  }
127
143
 
128
- export function report({ held, edited, named, findings, advisory, brokenBefore }: Judged): string {
144
+ export function report({ held, edited, named, agents, findings, advisory, brokenBefore }: Judged): string {
129
145
  const verdict =
130
146
  findings.length === 0
131
147
  ? [
132
148
  `${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
133
149
  `${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
134
150
  `${NAME}: the range breaks no path, link or command the ${named} living doc(s) or agent file(s) name`,
151
+ `${NAME}: the ${agents} agent file(s) hold to the ceiling, and every entry names a tracked path, a link or a command`,
135
152
  ]
136
153
  : [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
137
154
  const unconformed =
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { commitOf, git, isShallowBoundary, refArgs } from "../core/git.ts";
3
+ import { commitOf, git, isShallowBoundary, refArgs, writtenAt } from "../core/git.ts";
4
4
  import { runMain } from "../core/main.ts";
5
5
  import { TEST_FILE } from "./test-layout.ts";
6
6
 
@@ -125,19 +125,10 @@ const entryAt = Effect.fn("entryAt")(function* (root: string, head: string, file
125
125
  return { file, at: entry.at, day: entry.day };
126
126
  });
127
127
 
128
- const headAt = Effect.fn("headAt")(function* (root: string, head: string) {
129
- const shown = yield* git(["show", "-s", "--format=%at %ct", head], root);
130
- const dates = shown.trim().split(" ").map(Number);
131
- if (dates.length !== 2 || !dates.every(Number.isInteger)) {
132
- return yield* new QuarantineError({ message: `cannot read when ${head} was written` });
133
- }
134
- return Math.max(...dates);
135
- });
136
-
137
128
  export const runHead = Effect.fn("runHead")(function* (root: string, head: string) {
138
129
  const files = yield* filesAt(root, head);
139
130
  const entries = yield* Effect.forEach(files, (file) => entryAt(root, head, file));
140
- return { checked: files.length, overdue: overdueOf(entries, yield* headAt(root, head)) } satisfies ClockResult;
131
+ return { checked: files.length, overdue: overdueOf(entries, yield* writtenAt(head, root)) } satisfies ClockResult;
141
132
  });
142
133
 
143
134
  const clock = Effect.gen(function* () {
@@ -118,7 +118,13 @@ const runSuite = Effect.fn("runSuite")(function* (outfile: string, tier: TestTie
118
118
  const tierArgs = tier === undefined ? [] : ["--path-ignore-patterns", "", `./tests/${tier}`];
119
119
  const args = ["test", "--randomize", ...tierArgs, ...reporterArgs(outfile)];
120
120
  return yield* spawner.exitCode(
121
- ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }),
121
+ ChildProcess.make(process.execPath, args, {
122
+ stdin: "ignore",
123
+ stdout: "inherit",
124
+ stderr: "inherit",
125
+ env: { CI: "true" },
126
+ extendEnv: true,
127
+ }),
122
128
  );
123
129
  });
124
130