@keithadler/frostjs 0.1.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/LICENSE +21 -0
- package/README.md +568 -0
- package/dist/baseline.d.ts +18 -0
- package/dist/baseline.d.ts.map +1 -0
- package/dist/baseline.js +56 -0
- package/dist/baseline.js.map +1 -0
- package/dist/changed.d.ts +15 -0
- package/dist/changed.d.ts.map +1 -0
- package/dist/changed.js +93 -0
- package/dist/changed.js.map +1 -0
- package/dist/cli/args.d.ts +37 -0
- package/dist/cli/args.d.ts.map +1 -0
- package/dist/cli/args.js +200 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/main.d.ts +3 -0
- package/dist/cli/main.d.ts.map +1 -0
- package/dist/cli/main.js +8 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/run.d.ts +9 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +404 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/discover/index.d.ts +15 -0
- package/dist/discover/index.d.ts.map +1 -0
- package/dist/discover/index.js +70 -0
- package/dist/discover/index.js.map +1 -0
- package/dist/eslint.d.ts +103 -0
- package/dist/eslint.d.ts.map +1 -0
- package/dist/eslint.js +111 -0
- package/dist/eslint.js.map +1 -0
- package/dist/extract/annotations.d.ts +5 -0
- package/dist/extract/annotations.d.ts.map +1 -0
- package/dist/extract/annotations.js +5 -0
- package/dist/extract/annotations.js.map +1 -0
- package/dist/extract/ast.d.ts +45 -0
- package/dist/extract/ast.d.ts.map +1 -0
- package/dist/extract/ast.js +60 -0
- package/dist/extract/ast.js.map +1 -0
- package/dist/extract/capability.d.ts +22 -0
- package/dist/extract/capability.d.ts.map +1 -0
- package/dist/extract/capability.js +2 -0
- package/dist/extract/capability.js.map +1 -0
- package/dist/extract/html.d.ts +15 -0
- package/dist/extract/html.d.ts.map +1 -0
- package/dist/extract/html.js +69 -0
- package/dist/extract/html.js.map +1 -0
- package/dist/extract/index.d.ts +9 -0
- package/dist/extract/index.d.ts.map +1 -0
- package/dist/extract/index.js +95 -0
- package/dist/extract/index.js.map +1 -0
- package/dist/extract/recognizers/codegen.d.ts +10 -0
- package/dist/extract/recognizers/codegen.d.ts.map +1 -0
- package/dist/extract/recognizers/codegen.js +39 -0
- package/dist/extract/recognizers/codegen.js.map +1 -0
- package/dist/extract/recognizers/dom-escape.d.ts +11 -0
- package/dist/extract/recognizers/dom-escape.d.ts.map +1 -0
- package/dist/extract/recognizers/dom-escape.js +58 -0
- package/dist/extract/recognizers/dom-escape.js.map +1 -0
- package/dist/extract/recognizers/globals.d.ts +9 -0
- package/dist/extract/recognizers/globals.d.ts.map +1 -0
- package/dist/extract/recognizers/globals.js +121 -0
- package/dist/extract/recognizers/globals.js.map +1 -0
- package/dist/extract/recognizers/identity.d.ts +10 -0
- package/dist/extract/recognizers/identity.d.ts.map +1 -0
- package/dist/extract/recognizers/identity.js +79 -0
- package/dist/extract/recognizers/identity.js.map +1 -0
- package/dist/extract/recognizers/navigation.d.ts +12 -0
- package/dist/extract/recognizers/navigation.d.ts.map +1 -0
- package/dist/extract/recognizers/navigation.js +110 -0
- package/dist/extract/recognizers/navigation.js.map +1 -0
- package/dist/extract/recognizers/network.d.ts +11 -0
- package/dist/extract/recognizers/network.d.ts.map +1 -0
- package/dist/extract/recognizers/network.js +41 -0
- package/dist/extract/recognizers/network.js.map +1 -0
- package/dist/extract/recognizers/resolve.d.ts +37 -0
- package/dist/extract/recognizers/resolve.d.ts.map +1 -0
- package/dist/extract/recognizers/resolve.js +72 -0
- package/dist/extract/recognizers/resolve.js.map +1 -0
- package/dist/extract/recognizers/storage.d.ts +9 -0
- package/dist/extract/recognizers/storage.d.ts.map +1 -0
- package/dist/extract/recognizers/storage.js +37 -0
- package/dist/extract/recognizers/storage.js.map +1 -0
- package/dist/extract/recognizers/types.d.ts +38 -0
- package/dist/extract/recognizers/types.d.ts.map +1 -0
- package/dist/extract/recognizers/types.js +25 -0
- package/dist/extract/recognizers/types.js.map +1 -0
- package/dist/extract/recognizers/worker.d.ts +11 -0
- package/dist/extract/recognizers/worker.d.ts.map +1 -0
- package/dist/extract/recognizers/worker.js +41 -0
- package/dist/extract/recognizers/worker.js.map +1 -0
- package/dist/extract/scope.d.ts +23 -0
- package/dist/extract/scope.d.ts.map +1 -0
- package/dist/extract/scope.js +299 -0
- package/dist/extract/scope.js.map +1 -0
- package/dist/extract/suppress.d.ts +21 -0
- package/dist/extract/suppress.d.ts.map +1 -0
- package/dist/extract/suppress.js +42 -0
- package/dist/extract/suppress.js.map +1 -0
- package/dist/extract/target.d.ts +34 -0
- package/dist/extract/target.d.ts.map +1 -0
- package/dist/extract/target.js +81 -0
- package/dist/extract/target.js.map +1 -0
- package/dist/extract/typescript.d.ts +12 -0
- package/dist/extract/typescript.d.ts.map +1 -0
- package/dist/extract/typescript.js +56 -0
- package/dist/extract/typescript.js.map +1 -0
- package/dist/extract/walk.d.ts +14 -0
- package/dist/extract/walk.d.ts.map +1 -0
- package/dist/extract/walk.js +70 -0
- package/dist/extract/walk.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -0
- package/dist/init.d.ts +10 -0
- package/dist/init.d.ts.map +1 -0
- package/dist/init.js +79 -0
- package/dist/init.js.map +1 -0
- package/dist/policy/compile.d.ts +56 -0
- package/dist/policy/compile.d.ts.map +1 -0
- package/dist/policy/compile.js +78 -0
- package/dist/policy/compile.js.map +1 -0
- package/dist/policy/config.d.ts +18 -0
- package/dist/policy/config.d.ts.map +1 -0
- package/dist/policy/config.js +70 -0
- package/dist/policy/config.js.map +1 -0
- package/dist/policy/csp.d.ts +19 -0
- package/dist/policy/csp.d.ts.map +1 -0
- package/dist/policy/csp.js +47 -0
- package/dist/policy/csp.js.map +1 -0
- package/dist/policy/glob.d.ts +12 -0
- package/dist/policy/glob.d.ts.map +1 -0
- package/dist/policy/glob.js +63 -0
- package/dist/policy/glob.js.map +1 -0
- package/dist/policy/index.d.ts +47 -0
- package/dist/policy/index.d.ts.map +1 -0
- package/dist/policy/index.js +34 -0
- package/dist/policy/index.js.map +1 -0
- package/dist/policy/parse.d.ts +51 -0
- package/dist/policy/parse.d.ts.map +1 -0
- package/dist/policy/parse.js +352 -0
- package/dist/policy/parse.js.map +1 -0
- package/dist/policy/vocabulary.d.ts +15 -0
- package/dist/policy/vocabulary.d.ts.map +1 -0
- package/dist/policy/vocabulary.js +99 -0
- package/dist/policy/vocabulary.js.map +1 -0
- package/dist/registry.d.ts +45 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +85 -0
- package/dist/registry.js.map +1 -0
- package/dist/report/github.d.ts +10 -0
- package/dist/report/github.d.ts.map +1 -0
- package/dist/report/github.js +31 -0
- package/dist/report/github.js.map +1 -0
- package/dist/report/json.d.ts +44 -0
- package/dist/report/json.d.ts.map +1 -0
- package/dist/report/json.js +40 -0
- package/dist/report/json.js.map +1 -0
- package/dist/report/sarif.d.ts +10 -0
- package/dist/report/sarif.d.ts.map +1 -0
- package/dist/report/sarif.js +64 -0
- package/dist/report/sarif.js.map +1 -0
- package/dist/report/summary.d.ts +9 -0
- package/dist/report/summary.d.ts.map +1 -0
- package/dist/report/summary.js +67 -0
- package/dist/report/summary.js.map +1 -0
- package/dist/report/text.d.ts +37 -0
- package/dist/report/text.d.ts.map +1 -0
- package/dist/report/text.js +81 -0
- package/dist/report/text.js.map +1 -0
- package/dist/sync.d.ts +19 -0
- package/dist/sync.d.ts.map +1 -0
- package/dist/sync.js +131 -0
- package/dist/sync.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +6 -0
- package/dist/version.js.map +1 -0
- package/package.json +88 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Keith Adler
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to frostjs persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
1
|
+
# frostjs
|
|
2
|
+
|
|
3
|
+
**The model wrote it. Did anyone decide it could do that?**
|
|
4
|
+
|
|
5
|
+
frostjs is a deny-by-default capability gate for JavaScript. You write a
|
|
6
|
+
policy that fits on one screen, in plain words:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
may reach "api.example.com"
|
|
10
|
+
may use session storage
|
|
11
|
+
forbid cookies -- consent banner owns these
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
and the build fails on anything the code reaches for that the policy does
|
|
15
|
+
not grant: reading storage, setting cookies, calling `eval`, injecting a
|
|
16
|
+
`<script>`, opening a WebSocket to a host you have never heard of. The
|
|
17
|
+
report names the file, the line, the expression, and the policy line that
|
|
18
|
+
said no.
|
|
19
|
+
|
|
20
|
+
## Why every AI-assisted JavaScript project needs this in the pipeline
|
|
21
|
+
|
|
22
|
+
Code review was built for code written by a colleague at human speed. An
|
|
23
|
+
assistant writes a hundred lines in the time it takes to read ten, and
|
|
24
|
+
none of those lines arrive with an intent attached. A prompt says "cache
|
|
25
|
+
the results"; the model reaches for `localStorage`. A prompt says "load the
|
|
26
|
+
physics engine"; the model writes `import("https://cdn.skypack.dev/...")`.
|
|
27
|
+
A prompt says "make the markdown render"; the model assigns `innerHTML`.
|
|
28
|
+
Each is a reasonable reading of the words, each is a capability your
|
|
29
|
+
application now has, and nobody decided it.
|
|
30
|
+
|
|
31
|
+
A test suite does not catch this: the code works. A linter does not catch
|
|
32
|
+
this: the code is well formed. A human reviewer skims it, because the
|
|
33
|
+
diff is long and the code looks fine, which it is. The only thing that
|
|
34
|
+
catches it is a rule that says what this project may do, written down
|
|
35
|
+
before the code was, and a build that enforces it. That is the whole
|
|
36
|
+
tool.
|
|
37
|
+
|
|
38
|
+
frostjs gives you:
|
|
39
|
+
|
|
40
|
+
- **Deny by default.** Everything is off until the policy turns it on. A
|
|
41
|
+
new capability cannot arrive unnoticed, whoever or whatever wrote it.
|
|
42
|
+
- **A policy a non-engineer can read and sign off.** `frostjs summary`
|
|
43
|
+
prints it in English. `frostjs csp` turns the same file into your
|
|
44
|
+
`Content-Security-Policy` header, so the build-time gate and the runtime
|
|
45
|
+
backstop cannot disagree.
|
|
46
|
+
- **Zero false positives as the product.** Real scope analysis, so a local
|
|
47
|
+
named `fetch` is not a network call. Every engine change runs against 21
|
|
48
|
+
MB of real, pinned, hash-verified JavaScript and the finding count must
|
|
49
|
+
not move.
|
|
50
|
+
- **Adoption in one command.** `frostjs init src` writes a policy that
|
|
51
|
+
grants exactly what the code does today, with a note on each line saying
|
|
52
|
+
where. The first run passes. Then you delete what should not be allowed.
|
|
53
|
+
- **Dependencies by fingerprint.** Vendored files are admitted by hash with
|
|
54
|
+
the capabilities someone reviewed; a version bump that gains a network
|
|
55
|
+
destination is refused and shown as a diff.
|
|
56
|
+
- **Exceptions that expire.** `may use local storage in "src/legacy/*"
|
|
57
|
+
until 2026-12-01` warns for two weeks, then fails. Drift has a deadline.
|
|
58
|
+
|
|
59
|
+
CLI, GitHub Action, pre-commit hook, ESLint plugin. One runtime
|
|
60
|
+
dependency. MIT.
|
|
61
|
+
|
|
62
|
+
## Two minutes to a gated pipeline
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npm install -D @keithadler/frostjs
|
|
66
|
+
npx frostjs init src # writes frostjs.policy from what the code does today
|
|
67
|
+
git add frostjs.policy # commit it; it is the contract
|
|
68
|
+
npx frostjs src # passes, because init granted what exists
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The package is scoped (`@keithadler/frostjs`) because npm's typosquat
|
|
72
|
+
rules reserve the bare name; the command it installs is `frostjs`.
|
|
73
|
+
|
|
74
|
+
Then add the check to CI:
|
|
75
|
+
|
|
76
|
+
```yaml
|
|
77
|
+
# .github/workflows/frostjs.yml
|
|
78
|
+
on: [pull_request]
|
|
79
|
+
jobs:
|
|
80
|
+
frostjs:
|
|
81
|
+
runs-on: ubuntu-latest
|
|
82
|
+
steps:
|
|
83
|
+
- uses: actions/checkout@v4
|
|
84
|
+
with: { fetch-depth: 0 }
|
|
85
|
+
- uses: keithadler/frostjs@main
|
|
86
|
+
with:
|
|
87
|
+
paths: src
|
|
88
|
+
args: --changed-since origin/main
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
From here on, a pull request that reaches for a capability the policy does
|
|
92
|
+
not grant fails with an inline annotation on the offending line. To allow
|
|
93
|
+
it, someone edits `frostjs.policy` in the same PR, and the reviewer sees
|
|
94
|
+
both changes side by side. That is the decision being made visible.
|
|
95
|
+
|
|
96
|
+
## For AI coding agents
|
|
97
|
+
|
|
98
|
+
If an assistant writes code in your project, tell it the gate exists.
|
|
99
|
+
Paste this into `CLAUDE.md`, `AGENTS.md`, `.cursorrules`, or whatever your
|
|
100
|
+
tool reads:
|
|
101
|
+
|
|
102
|
+
```markdown
|
|
103
|
+
## frostjs
|
|
104
|
+
|
|
105
|
+
This project is gated by frostjs (https://github.com/keithadler/frostjs,
|
|
106
|
+
npm package @keithadler/frostjs, installed as a dev dependency).
|
|
107
|
+
`frostjs.policy` at the repository root says which browser capabilities
|
|
108
|
+
the code may use: storage, network destinations, code generation, HTML
|
|
109
|
+
injection, identity, navigation, globals, workers. Everything else is
|
|
110
|
+
denied.
|
|
111
|
+
|
|
112
|
+
- Before finishing any change to JavaScript or TypeScript, run
|
|
113
|
+
`npx frostjs <paths you changed>` and make it pass.
|
|
114
|
+
- Read a denial as a question, not an obstacle. If the task genuinely needs
|
|
115
|
+
the capability, add the narrowest grant that covers it to
|
|
116
|
+
`frostjs.policy` (scope it with `in "<file>"`, name hosts with
|
|
117
|
+
`may reach "<host>"`, add `until <date>` if it is temporary) and say in
|
|
118
|
+
your summary that you widened the policy and why. If the task does not
|
|
119
|
+
need it, change the code instead.
|
|
120
|
+
- Never add `may use everything`, never add `// frostjs: ignore` to make a
|
|
121
|
+
build pass, and never delete or loosen an existing `forbid` line. Those
|
|
122
|
+
are a person's decisions.
|
|
123
|
+
- `npx frostjs summary` prints the policy in plain English if you need to
|
|
124
|
+
explain it.
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The instructions above are also in [`AGENTS.md`](AGENTS.md) in this
|
|
128
|
+
repository, which applies them to frostjs's own code.
|
|
129
|
+
|
|
130
|
+
## Where it has already found something
|
|
131
|
+
|
|
132
|
+
three.js 0.160.0 ships `examples/jsm/libs/ecsy.module.js`, which, if a page
|
|
133
|
+
that imports it is opened with `?enable-remote-devtools` in the URL, loads a
|
|
134
|
+
script from a third-party CDN, connects to a third-party relay, and `eval`s
|
|
135
|
+
whatever the relay sends. frostjs reports it as
|
|
136
|
+
`codegen.eval denied by default (no rule grants it): eval(data.script)` under any policy
|
|
137
|
+
an application would plausibly write. The same run names a runtime
|
|
138
|
+
`import()` of physics engine code from `cdn.skypack.dev`. The full story, the
|
|
139
|
+
policy, the CSP it emits and the honest count of what else the policy
|
|
140
|
+
flags are in [SHOWCASE.md](SHOWCASE.md).
|
|
141
|
+
|
|
142
|
+
## Status
|
|
143
|
+
|
|
144
|
+
Pre-alpha, feature complete against [REQUIREMENTS.md](REQUIREMENTS.md): all
|
|
145
|
+
eight capability families, frost-dialect policies, scope analysis,
|
|
146
|
+
baselines, changed-lines mode, json/sarif/github output, a GitHub Action,
|
|
147
|
+
an ESLint plugin, a fingerprint registry for vendored code with SRI output,
|
|
148
|
+
TypeScript, JSX and inline HTML. Run on Excalidraw (656 files, a
|
|
149
|
+
TypeScript and React monorepo) it finishes in under a second and
|
|
150
|
+
`frostjs init` writes an 18-line policy; every finding was checked by hand.
|
|
151
|
+
Not yet published to npm.
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
$ cat frostjs.policy
|
|
155
|
+
policy "checkout-widget"
|
|
156
|
+
may use session storage
|
|
157
|
+
may use local storage in "src/legacy/*" -- old code, rewrite by Q4
|
|
158
|
+
forbid cookies -- consent banner owns these
|
|
159
|
+
may use the cache until 2026-08-30 -- service worker experiment
|
|
160
|
+
|
|
161
|
+
$ frostjs src
|
|
162
|
+
src/app.js:2:1: storage.local denied by default (no rule grants it): localStorage.setItem("not-here", 1)
|
|
163
|
+
src/legacy/old.js:2:1: storage.cookie denied by "forbid cookies" (line 4): consent banner owns these: document.cookie
|
|
164
|
+
|
|
165
|
+
warning: frostjs.policy line 5: "may use the cache until 2026-08-30" expires in 7 days
|
|
166
|
+
|
|
167
|
+
3 files, 2 denied, 0 unknown
|
|
168
|
+
$ echo $?
|
|
169
|
+
1
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Starting out
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
npm install -D @keithadler/frostjs
|
|
176
|
+
npx frostjs init src
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
writes a `frostjs.policy` in the current directory that grants exactly
|
|
180
|
+
what the code under `src` does today, one line per capability, scoped to
|
|
181
|
+
the files that use it when there are only a few, each with a note saying
|
|
182
|
+
where. The first check passes. Then read the file and delete what should
|
|
183
|
+
not be allowed; the build starts refusing it. A network destination the
|
|
184
|
+
code builds at runtime is called out in a hint rather than quietly widened
|
|
185
|
+
to `may use the network`. For a large codebase with debt you would rather
|
|
186
|
+
pay down than grant, use `--baseline` instead (below).
|
|
187
|
+
|
|
188
|
+
## Policy files
|
|
189
|
+
|
|
190
|
+
A policy is a `frostjs.policy` file in frost's policy dialect: one rule per
|
|
191
|
+
line, `--` or `#` comments, case-insensitive keywords. Deny-by-default, so
|
|
192
|
+
the file only ever grants. A trailing comment on a rule is its *hint*, and is
|
|
193
|
+
printed whenever that rule refuses something.
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
policy "<name>" optional, once
|
|
197
|
+
ignore "<glob>", ... files not analyzed at all
|
|
198
|
+
vendored "<glob>", ... third-party files, checked by fingerprint
|
|
199
|
+
may use <capability> [in "<glob>", ...] [until YYYY-MM-DD]
|
|
200
|
+
may reach "<host>", ... [in "<glob>", ...] [until YYYY-MM-DD]
|
|
201
|
+
forbid [using] <capability> [in "<glob>", ...]
|
|
202
|
+
forbid reaching "<host>", ... [in "<glob>", ...]
|
|
203
|
+
forbid everything else optional, readability only
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
`<capability>` is a phrase or a code. A family name grants the whole family.
|
|
207
|
+
|
|
208
|
+
| phrase | code |
|
|
209
|
+
| --- | --- |
|
|
210
|
+
| `storage` | `storage` (every member below) |
|
|
211
|
+
| `local storage` | `storage.local` |
|
|
212
|
+
| `session storage` | `storage.session` |
|
|
213
|
+
| `cookies` | `storage.cookie` |
|
|
214
|
+
| `indexeddb` | `storage.indexeddb` |
|
|
215
|
+
| `the cache`, `caches` | `storage.cache` |
|
|
216
|
+
| `navigator storage` | `storage.navigator` |
|
|
217
|
+
| `the network` | `network` (any destination) |
|
|
218
|
+
| `code generation`, `eval` | `codegen` |
|
|
219
|
+
| `html injection` | `dom-escape` |
|
|
220
|
+
| `identity`, `fingerprinting` | `identity` |
|
|
221
|
+
| `navigation` | `navigation` |
|
|
222
|
+
| `globals` | `globals` |
|
|
223
|
+
| `workers`, `service workers` | `worker` |
|
|
224
|
+
| `everything` | `*` |
|
|
225
|
+
|
|
226
|
+
Rules:
|
|
227
|
+
|
|
228
|
+
- `forbid` always wins over `may`, so `may use storage` + `forbid cookies`
|
|
229
|
+
grants everything in storage except cookies.
|
|
230
|
+
- `in` scopes a rule to path globs (`*` within a segment, `**` across
|
|
231
|
+
segments, a bare name matches at any depth, a plain directory matches
|
|
232
|
+
everything beneath it). Globs are relative to the policy file's directory.
|
|
233
|
+
- `ignore` skips files entirely: generated bundles, test fixtures, anything
|
|
234
|
+
that is not your code to police. It lives in the policy so the exception
|
|
235
|
+
is visible in review, where `--exclude` on the command line is not.
|
|
236
|
+
- `may reach` grants the network family only to the named hosts. `*` in a
|
|
237
|
+
host spans any characters (`*.internal`). `"same-origin"` names relative
|
|
238
|
+
URLs. A destination that cannot be read from the code is **not** allowed by
|
|
239
|
+
a host list: cannot be shown to be allowed is not allowed. Grant
|
|
240
|
+
`may use the network` if you really mean any destination.
|
|
241
|
+
- `until` puts an expiry on a grant. Inside the last 14 days the build warns;
|
|
242
|
+
after the date the grant denies with its own message. This is how drift is
|
|
243
|
+
fought: an exception has to be renewed on purpose.
|
|
244
|
+
|
|
245
|
+
`frostjs.policy` is searched for in the directory shared by all the given
|
|
246
|
+
paths, then upward; the nearest one wins, so a monorepo can keep one per
|
|
247
|
+
tenant directory. `--policy <file>` overrides the search. With no policy at
|
|
248
|
+
all, every capability is denied and a note says so.
|
|
249
|
+
|
|
250
|
+
## Usage
|
|
251
|
+
|
|
252
|
+
```
|
|
253
|
+
frostjs init [paths] write a starter frostjs.policy granting what the code does today
|
|
254
|
+
frostjs <paths...> discover and analyze .js/.mjs/.cjs/.jsx/.ts/.tsx/.mts/.cts and inline <script> in .html under paths
|
|
255
|
+
frostjs csp print the Content-Security-Policy header the policy implies
|
|
256
|
+
frostjs summary print a plain-English reading of the policy
|
|
257
|
+
frostjs vendor add <files> fingerprint third-party files and record their capabilities
|
|
258
|
+
frostjs registry sync re-admit bumped dependencies whose capabilities did not change
|
|
259
|
+
frostjs sri [paths] print Subresource Integrity values for registered vendored files
|
|
260
|
+
frostjs --exclude <name> skip directories with this name (repeatable)
|
|
261
|
+
frostjs --exit-zero report findings but always exit 0
|
|
262
|
+
frostjs --policy <file> use this policy instead of searching for frostjs.policy
|
|
263
|
+
frostjs --today <date> treat YYYY-MM-DD as today when checking expiry
|
|
264
|
+
frostjs --min-confidence <c> lowest confidence that fails: certain, probable (default), possible
|
|
265
|
+
frostjs --baseline <file> denials recorded in this file do not fail the build
|
|
266
|
+
frostjs --update-baseline write every current denial into the baseline and exit 0
|
|
267
|
+
frostjs --changed-since <ref> fail only on uses in lines changed since the git ref
|
|
268
|
+
frostjs --format <f> text (default), json, sarif, or github
|
|
269
|
+
frostjs --version print the version and exit
|
|
270
|
+
frostjs --help show usage
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
`node_modules`, `dist`, `build`, `coverage` and `.git` are always skipped
|
|
274
|
+
(unless a `vendored` glob reaches into them). `.d.ts` files are skipped:
|
|
275
|
+
they describe globals and contain no code. A path that names a file
|
|
276
|
+
directly is always analyzed.
|
|
277
|
+
|
|
278
|
+
TypeScript type positions are never references (`let f: typeof fetch` is
|
|
279
|
+
quiet), `declare` statements neither use nor shadow the globals they
|
|
280
|
+
describe, and `as` / `!` / `satisfies` are looked through. In JSX,
|
|
281
|
+
`dangerouslySetInnerHTML={...}` and `srcdoc={...}` are html injection and
|
|
282
|
+
intrinsic `<script>` / `<iframe>` elements count like `createElement`;
|
|
283
|
+
component names and ordinary attributes are quiet.
|
|
284
|
+
|
|
285
|
+
Inline `<script>` blocks in `.html` and `.htm` files are analyzed in place:
|
|
286
|
+
positions refer to the HTML file, `type="module"` blocks parse as modules,
|
|
287
|
+
and blocks with a `src` or a non-JavaScript `type` (JSON, import maps,
|
|
288
|
+
templates) are data, not code. Script elements are found with a regular
|
|
289
|
+
expression, which is right for markup people write and wrong only for
|
|
290
|
+
markup written to confuse it, which the threat model already excludes.
|
|
291
|
+
|
|
292
|
+
Exit codes: `0` clean, `1` policy violations, `2` usage or input error
|
|
293
|
+
(bad flag, missing path, syntax error).
|
|
294
|
+
|
|
295
|
+
Uses with `possible` confidence are listed under "unknown" and never fail the
|
|
296
|
+
build; `certain` and `probable` uses do.
|
|
297
|
+
|
|
298
|
+
## GitHub Action
|
|
299
|
+
|
|
300
|
+
```yaml
|
|
301
|
+
- uses: keithadler/frostjs@main
|
|
302
|
+
with:
|
|
303
|
+
paths: src
|
|
304
|
+
args: --baseline .frostjs-baseline.json --changed-since origin/main
|
|
305
|
+
fail-on-findings: "true"
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
Inputs: `paths` (default `.`), `format` (default `github`, which annotates
|
|
309
|
+
the pull request inline), `args` (extra flags), `fail-on-findings` (set
|
|
310
|
+
`"false"` for an informational run). Inputs reach the script through the
|
|
311
|
+
environment only, never spliced into the script body, so a hostile input is
|
|
312
|
+
an argument and not a command.
|
|
313
|
+
|
|
314
|
+
## ESLint plugin
|
|
315
|
+
|
|
316
|
+
The same engine as an ESLint rule, so denials show up in the editor and on
|
|
317
|
+
`eslint` runs, with the same policy discovery (nearest `frostjs.policy`
|
|
318
|
+
above the file) and the same `frostjs: ignore` comments. `eslint-disable`
|
|
319
|
+
works too.
|
|
320
|
+
|
|
321
|
+
```js
|
|
322
|
+
// eslint.config.js
|
|
323
|
+
import frostjs from "@keithadler/frostjs/eslint";
|
|
324
|
+
export default [frostjs.configs.recommended];
|
|
325
|
+
// or: [{ plugins: { frostjs }, rules: { "frostjs/capability": ["error", { reportUnknown: true }] } }]
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Options: `policy` (explicit file), `minConfidence`, `reportUnknown`
|
|
329
|
+
(also report uses the CLI would list as unknown), `today`.
|
|
330
|
+
|
|
331
|
+
## pre-commit
|
|
332
|
+
|
|
333
|
+
```yaml
|
|
334
|
+
repos:
|
|
335
|
+
- repo: https://github.com/keithadler/frostjs
|
|
336
|
+
rev: main
|
|
337
|
+
hooks:
|
|
338
|
+
- id: frostjs
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
## Third-party code
|
|
342
|
+
|
|
343
|
+
Dependencies are not analyzed line by line; that is a year-long project
|
|
344
|
+
that ends in noise. Instead the policy names which files are vendored:
|
|
345
|
+
|
|
346
|
+
```
|
|
347
|
+
vendored "vendor/**", "static/lib/*.min.js"
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
A vendored file is hashed (SHA-384, the same value SRI uses) and looked up
|
|
351
|
+
in `.frostjs/registry.json` beside the policy. A known hash contributes the
|
|
352
|
+
capability set somebody recorded for it, checked against the policy like
|
|
353
|
+
any first-party use. An unknown hash fails the build:
|
|
354
|
+
|
|
355
|
+
```
|
|
356
|
+
vendor/widget.min.js:1:1: vendored file is not in the registry; review it with: frostjs vendor add vendor/widget.min.js
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
`frostjs vendor add` analyzes the file once, prints what it found so a
|
|
360
|
+
person can look at it, and records the entry. A patch release changes the
|
|
361
|
+
hash, so the review happens again; that is the point. To keep that from
|
|
362
|
+
being a chore, `frostjs registry sync` walks the vendored paths after a
|
|
363
|
+
dependency bump: a new version that uses exactly the capabilities the old
|
|
364
|
+
one did is re-admitted automatically and noted; one that gained a
|
|
365
|
+
capability or a new destination is refused with the difference printed,
|
|
366
|
+
which is the "dependency bump silently introduces a new network
|
|
367
|
+
destination" case from the threat model. Entries whose file is gone are
|
|
368
|
+
pruned, and the lockfile's hash is recorded so the next run can say
|
|
369
|
+
whether anything moved. A vendored glob may reach into `node_modules`;
|
|
370
|
+
the walk follows it there.
|
|
371
|
+
|
|
372
|
+
`frostjs sri` prints the same SHA-384 values as `integrity` attributes
|
|
373
|
+
(`--format html` for ready-made script tags, `--format json` for a build
|
|
374
|
+
step), so the browser refuses at load time exactly what the registry never
|
|
375
|
+
reviewed. A vendored file that is not in the registry is refused here too.
|
|
376
|
+
|
|
377
|
+
## One policy, three artifacts
|
|
378
|
+
|
|
379
|
+
The same `frostjs.policy` drives the linter ruleset, a CSP header, and a
|
|
380
|
+
reviewer's summary, so they cannot drift apart.
|
|
381
|
+
|
|
382
|
+
`frostjs csp` prints the header and nothing else, for nginx or the CDN
|
|
383
|
+
config. Only directives the policy determines are emitted: `connect-src`
|
|
384
|
+
from `may reach` hosts (`'none'` when nothing is granted, `*` for
|
|
385
|
+
`may use the network`), `script-src 'self'` plus `'unsafe-eval'` when code
|
|
386
|
+
generation is granted and the reach hosts when dynamic import is, and
|
|
387
|
+
`worker-src` when workers are. Expired grants do not widen it; path-scoped
|
|
388
|
+
grants do, because a header covers the whole page.
|
|
389
|
+
|
|
390
|
+
`frostjs summary` prints what the code may do, what it may not, and spells
|
|
391
|
+
out the implicit deny:
|
|
392
|
+
|
|
393
|
+
```
|
|
394
|
+
Policy "proj" (frostjs.policy)
|
|
395
|
+
|
|
396
|
+
This code may:
|
|
397
|
+
- use session storage (line 2)
|
|
398
|
+
- use local storage, only in src/legacy/* (line 3) - old code, rewrite by Q4
|
|
399
|
+
- use the cache, until 2026-08-30 (line 5) - service worker experiment
|
|
400
|
+
|
|
401
|
+
It may not, even where a broader grant would allow it:
|
|
402
|
+
- use cookies (line 4) - consent banner owns these
|
|
403
|
+
|
|
404
|
+
Everything else is denied. In particular this code may not use: the network, code generation, html injection, identity, navigation, globals, workers.
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
## Output formats
|
|
408
|
+
|
|
409
|
+
- `text` (default): one line per denial with the policy line that denied
|
|
410
|
+
it, unknowns in their own section, warnings, then a summary.
|
|
411
|
+
- `json`: a versioned document (`schema: 1`) with every decision, the
|
|
412
|
+
policy used, a summary by verdict, and expiry warnings.
|
|
413
|
+
- `sarif`: SARIF 2.1.0 for code scanning. One rule per capability code;
|
|
414
|
+
denied uses are errors, unknown uses warnings, baselined uses carry
|
|
415
|
+
`baselineState: "unchanged"`.
|
|
416
|
+
- `github`: GitHub Actions workflow commands (`::error file=...`) so each
|
|
417
|
+
denial shows up inline on the pull request, followed by the text report.
|
|
418
|
+
|
|
419
|
+
## Adopting frostjs on an existing codebase
|
|
420
|
+
|
|
421
|
+
```bash
|
|
422
|
+
frostjs --baseline .frostjs-baseline.json --update-baseline src
|
|
423
|
+
```
|
|
424
|
+
|
|
425
|
+
That records every current denial, keyed on file, capability and expression
|
|
426
|
+
text (never line numbers), and exits 0. From then on
|
|
427
|
+
`frostjs --baseline .frostjs-baseline.json src` fails only on *new* uses;
|
|
428
|
+
existing ones are counted as "baselined". Commit the file; shrink it as the
|
|
429
|
+
debt is paid down. Paths inside it are relative to the file's directory.
|
|
430
|
+
|
|
431
|
+
For pull-request checks, `frostjs --changed-since origin/main src` fails only
|
|
432
|
+
on uses that sit in lines the branch added or modified; the rest are counted
|
|
433
|
+
as "unchanged". Untracked files count as entirely changed.
|
|
434
|
+
|
|
435
|
+
## Suppressing a single use
|
|
436
|
+
|
|
437
|
+
```js
|
|
438
|
+
// frostjs: ignore[storage.local]
|
|
439
|
+
localStorage.setItem("draft", text);
|
|
440
|
+
|
|
441
|
+
fetch(url); // frostjs: ignore
|
|
442
|
+
```
|
|
443
|
+
|
|
444
|
+
A bare `frostjs: ignore` suppresses every capability on that line; a
|
|
445
|
+
bracketed list suppresses only those codes or families. The comment applies
|
|
446
|
+
to its own line or, when it stands alone, to the line after it. Suppressed
|
|
447
|
+
uses are counted in the summary and never fail the build. Prefer a scoped
|
|
448
|
+
`may ... in "file"` line in the policy when the exception should be visible
|
|
449
|
+
to a reviewer; suppression is for the one-off.
|
|
450
|
+
|
|
451
|
+
## Capabilities recognized so far
|
|
452
|
+
|
|
453
|
+
| code | what |
|
|
454
|
+
| --- | --- |
|
|
455
|
+
| `storage.local` | `localStorage` |
|
|
456
|
+
| `storage.session` | `sessionStorage` |
|
|
457
|
+
| `storage.indexeddb` | `indexedDB` |
|
|
458
|
+
| `storage.cache` | `caches` |
|
|
459
|
+
| `storage.cookie` | `document.cookie` |
|
|
460
|
+
| `storage.navigator` | `navigator.storage` |
|
|
461
|
+
| `network.fetch` | `fetch` |
|
|
462
|
+
| `network.xhr` | `XMLHttpRequest` |
|
|
463
|
+
| `network.websocket` | `WebSocket` |
|
|
464
|
+
| `network.eventsource` | `EventSource` |
|
|
465
|
+
| `network.beacon` | `navigator.sendBeacon` |
|
|
466
|
+
| `network.import` | dynamic `import()` of an absolute URL or an expression |
|
|
467
|
+
| `codegen.eval` | `eval` |
|
|
468
|
+
| `codegen.function` | `Function(...)`, `new Function(...)` |
|
|
469
|
+
| `codegen.timer` | `setTimeout` / `setInterval` with string code |
|
|
470
|
+
| `codegen.write` | `document.write`, `document.writeln` |
|
|
471
|
+
| `dom-escape.html` | assignment to `innerHTML` / `outerHTML` / `srcdoc`, `insertAdjacentHTML`, `createContextualFragment`, JSX `dangerouslySetInnerHTML` / `srcdoc` |
|
|
472
|
+
| `dom-escape.script` | `document.createElement("script")`, JSX `<script>` |
|
|
473
|
+
| `dom-escape.iframe` | `document.createElement("iframe")`, JSX `<iframe>` |
|
|
474
|
+
| `identity.device` | `navigator.userAgent`, `platform`, `vendor`, `plugins`, `hardwareConcurrency`, `deviceMemory`... |
|
|
475
|
+
| `identity.geolocation` | `navigator.geolocation` |
|
|
476
|
+
| `identity.media` | `navigator.mediaDevices`, `getUserMedia` |
|
|
477
|
+
| `identity.clipboard` | `navigator.clipboard`, `document.execCommand("copy" / "paste")` |
|
|
478
|
+
| `identity.credentials` | `navigator.credentials` |
|
|
479
|
+
| `identity.permissions` | `navigator.permissions` |
|
|
480
|
+
| `navigation.location` | assignment to `location` / `location.href` etc., `location.assign` / `replace` / `reload` |
|
|
481
|
+
| `navigation.open` | `window.open` |
|
|
482
|
+
| `navigation.history` | `history.pushState` / `replaceState` / `back` / `forward` / `go` |
|
|
483
|
+
| `navigation.postmessage` | `postMessage` to `parent` / `top` / `opener` / `contentWindow`, or with a string origin |
|
|
484
|
+
| `globals.window` | assignment to `window.*` / `globalThis.*`, `Object.defineProperty(window, ...)` |
|
|
485
|
+
| `globals.prototype` | assignment to a built-in or its prototype (`Array.prototype.x = `, `Error.prepareStackTrace = `), or `Object.defineProperty` / `assign` on one |
|
|
486
|
+
| `worker.dedicated` | `new Worker(url)` |
|
|
487
|
+
| `worker.shared` | `new SharedWorker(url)` |
|
|
488
|
+
| `worker.service` | `navigator.serviceWorker.register(url)` |
|
|
489
|
+
| `worker.worklet` | `CSS.paintWorklet.addModule(url)`, `audioWorklet.addModule(url)`... |
|
|
490
|
+
|
|
491
|
+
Each is recognized bare, via `window` / `globalThis` / `self`, and via a
|
|
492
|
+
computed member whose name is a string literal (`window["localStorage"]`),
|
|
493
|
+
a concatenation of literals, or a `const` the scope analysis can fold
|
|
494
|
+
(`const k = "localStorage"; window[k]`, reported as `probable`).
|
|
495
|
+
|
|
496
|
+
Scope analysis is real, with hoisting: a local named `fetch` or `window` is
|
|
497
|
+
not the global, so a use through it is not reported at all, while the same
|
|
498
|
+
name declared in a sibling function does not hide anything. Only inside a
|
|
499
|
+
`with` block, where nothing can be resolved, is a use reported as `possible`.
|
|
500
|
+
Uses via `self` are `probable` rather than `certain`, since `self` is often
|
|
501
|
+
a local alias for `this` in older code.
|
|
502
|
+
|
|
503
|
+
Canvas and audio fingerprinting are deliberately **not** recognized: every
|
|
504
|
+
charting and 3D library draws to canvases, and no static signature separates
|
|
505
|
+
that from fingerprinting without false positives. CSP and the network family
|
|
506
|
+
are the backstop for where such a fingerprint would be sent.
|
|
507
|
+
|
|
508
|
+
Network uses carry a **target** when it can be fixed statically. Frost's
|
|
509
|
+
rule applies: a literal that closes the authority fixes the host, and nothing
|
|
510
|
+
after the slash can move it. `fetch("https://api.example.com/items/" + id)`
|
|
511
|
+
reaches `api.example.com`; `fetch("https://" + host)` reaches nobody we can
|
|
512
|
+
name. Relative URLs are `same-origin`. Dynamic `import()` of a relative path
|
|
513
|
+
or a bare package name goes through the bundler, not the network, and is not
|
|
514
|
+
reported.
|
|
515
|
+
|
|
516
|
+
## What this is not
|
|
517
|
+
|
|
518
|
+
- **Not a runtime sandbox.** No membrane, no proxied globals. A determined
|
|
519
|
+
attacker with code execution defeats any wrapper; that fight is not worth having.
|
|
520
|
+
- **Not a replacement for CSP.** It emits CSP, and CSP remains the runtime
|
|
521
|
+
backstop. This tool is the build-time gate.
|
|
522
|
+
- **Not a universal npm scanner.** Dependencies are admitted by fingerprint
|
|
523
|
+
against a registry, not analyzed line by line.
|
|
524
|
+
|
|
525
|
+
## Threat model, honestly
|
|
526
|
+
|
|
527
|
+
`frostjs` catches careless or accidental use of forbidden APIs in first-party,
|
|
528
|
+
tenant, or model-generated code, and it catches drift over time. It does **not**
|
|
529
|
+
catch deliberately obfuscated code, runtime-constructed access beyond a shallow
|
|
530
|
+
constant fold, or anything injected after the build. The value is a high floor,
|
|
531
|
+
not a ceiling.
|
|
532
|
+
|
|
533
|
+
## Development
|
|
534
|
+
|
|
535
|
+
```
|
|
536
|
+
npm install
|
|
537
|
+
npm test vitest (npm run test:watch to keep it running)
|
|
538
|
+
npm run lint prettier --check, then typecheck src, test and scripts
|
|
539
|
+
npm run format prettier --write
|
|
540
|
+
npm run build tsc to dist/
|
|
541
|
+
npm run corpus scan the pinned corpus; fails if findings changed
|
|
542
|
+
npm run showcase reproduce SHOWCASE.md
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Zero false positives is the product. `npm run corpus` runs the extractor over
|
|
546
|
+
six pinned, hash-verified npm packages (about 21 MB of real JavaScript) and
|
|
547
|
+
diffs the findings against `corpus/expected.txt`. Any change to `src/extract/`
|
|
548
|
+
must leave that diff empty, or update the file deliberately with
|
|
549
|
+
`npm run corpus -- --update` and explain why in the commit.
|
|
550
|
+
|
|
551
|
+
- [ARCHITECTURE.md](ARCHITECTURE.md): how the code is laid out and the
|
|
552
|
+
contract a recognizer signs.
|
|
553
|
+
- [CONTRIBUTING.md](CONTRIBUTING.md): how to add a recognizer or a policy
|
|
554
|
+
form, and the conventions.
|
|
555
|
+
- [SECURITY.md](SECURITY.md): what a green run does and does not promise,
|
|
556
|
+
and how to report a bypass.
|
|
557
|
+
- [CHANGELOG.md](CHANGELOG.md).
|
|
558
|
+
- [REQUIREMENTS.md](REQUIREMENTS.md): the original plan, with every step
|
|
559
|
+
marked done and every decision recorded.
|
|
560
|
+
|
|
561
|
+
## License
|
|
562
|
+
|
|
563
|
+
[MIT](LICENSE), the same license as [frost](https://github.com/keithadler/frost)
|
|
564
|
+
and [exact](https://github.com/keithadler/magic-float-linter). A build-time
|
|
565
|
+
linter wants the widest possible adoption and gives nobody a reason to
|
|
566
|
+
hesitate: no copyleft, no patent clause to have reviewed, nothing to
|
|
567
|
+
attribute beyond the notice. Contributions are accepted under the same
|
|
568
|
+
license.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface BaselineEntry {
|
|
2
|
+
file: string;
|
|
3
|
+
capability: string;
|
|
4
|
+
expression: string;
|
|
5
|
+
}
|
|
6
|
+
export interface Baseline {
|
|
7
|
+
version: 1;
|
|
8
|
+
entries: BaselineEntry[];
|
|
9
|
+
}
|
|
10
|
+
/** The key an entry is matched on: posix path, capability, normalized expression. Never a line number. */
|
|
11
|
+
export declare function baselineKey(file: string, capability: string, expression: string): string;
|
|
12
|
+
/** Read a baseline. A missing file is an empty baseline; malformed JSON or an unknown version throws. */
|
|
13
|
+
export declare function readBaseline(file: string): Baseline;
|
|
14
|
+
/** Write entries, deduplicated and sorted, and return how many were written. */
|
|
15
|
+
export declare function writeBaseline(file: string, entries: readonly BaselineEntry[]): number;
|
|
16
|
+
/** The set of keys a baseline covers. */
|
|
17
|
+
export declare function baselineKeys(b: Baseline): Set<string>;
|
|
18
|
+
//# sourceMappingURL=baseline.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"baseline.d.ts","sourceRoot":"","sources":["../src/baseline.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,QAAQ;IACvB,OAAO,EAAE,CAAC,CAAC;IACX,OAAO,EAAE,aAAa,EAAE,CAAC;CAC1B;AAOD,0GAA0G;AAC1G,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAExF;AAED,yGAAyG;AACzG,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,GAAG,QAAQ,CAWnD;AAED,gFAAgF;AAChF,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,SAAS,aAAa,EAAE,GAAG,MAAM,CAIrF;AAiBD,yCAAyC;AACzC,wBAAgB,YAAY,CAAC,CAAC,EAAE,QAAQ,GAAG,GAAG,CAAC,MAAM,CAAC,CAErD"}
|
package/dist/baseline.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline snapshots. A baseline freezes the violations
|
|
3
|
+
* a codebase already has so that only new ones fail the build. Entries are
|
|
4
|
+
* keyed on (file, capability, expression text), never on line numbers, so
|
|
5
|
+
* unrelated edits do not invalidate them. Paths are relative to the
|
|
6
|
+
* baseline file's directory.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
/** Collapse whitespace so a reformatted expression keeps its baseline entry. */
|
|
11
|
+
function normalizeExpression(expression) {
|
|
12
|
+
return expression.replace(/\s+/g, " ").trim();
|
|
13
|
+
}
|
|
14
|
+
/** The key an entry is matched on: posix path, capability, normalized expression. Never a line number. */
|
|
15
|
+
export function baselineKey(file, capability, expression) {
|
|
16
|
+
return `${file.split(path.sep).join("/")} ${capability} ${normalizeExpression(expression)}`;
|
|
17
|
+
}
|
|
18
|
+
/** Read a baseline. A missing file is an empty baseline; malformed JSON or an unknown version throws. */
|
|
19
|
+
export function readBaseline(file) {
|
|
20
|
+
if (!fs.existsSync(file))
|
|
21
|
+
return { version: 1, entries: [] };
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
throw new Error(`baseline ${file} is not valid JSON: ${e.message}`);
|
|
28
|
+
}
|
|
29
|
+
const b = raw;
|
|
30
|
+
if (b.version !== 1 || !Array.isArray(b.entries))
|
|
31
|
+
throw new Error(`baseline ${file} has an unknown format`);
|
|
32
|
+
return { version: 1, entries: dedupe(b.entries) };
|
|
33
|
+
}
|
|
34
|
+
/** Write entries, deduplicated and sorted, and return how many were written. */
|
|
35
|
+
export function writeBaseline(file, entries) {
|
|
36
|
+
const out = { version: 1, entries: dedupe(entries) };
|
|
37
|
+
fs.writeFileSync(file, JSON.stringify(out, null, 2) + "\n");
|
|
38
|
+
return out.entries.length;
|
|
39
|
+
}
|
|
40
|
+
function dedupe(entries) {
|
|
41
|
+
const seen = new Map();
|
|
42
|
+
for (const e of entries) {
|
|
43
|
+
const n = { file: e.file, capability: e.capability, expression: normalizeExpression(e.expression) };
|
|
44
|
+
seen.set(baselineKey(n.file, n.capability, n.expression), n);
|
|
45
|
+
}
|
|
46
|
+
return [...seen.values()].sort((a, b) => a.file === b.file
|
|
47
|
+
? a.capability === b.capability
|
|
48
|
+
? a.expression.localeCompare(b.expression)
|
|
49
|
+
: a.capability.localeCompare(b.capability)
|
|
50
|
+
: a.file.localeCompare(b.file));
|
|
51
|
+
}
|
|
52
|
+
/** The set of keys a baseline covers. */
|
|
53
|
+
export function baselineKeys(b) {
|
|
54
|
+
return new Set(b.entries.map((e) => baselineKey(e.file, e.capability, e.expression)));
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=baseline.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"baseline.js","sourceRoot":"","sources":["../src/baseline.ts"],"names":[],"mappings":"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"}
|