urtext 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 +229 -0
- package/dist/analyze/blast-radius.d.ts +28 -0
- package/dist/analyze/blast-radius.js +163 -0
- package/dist/analyze/canonical.d.ts +27 -0
- package/dist/analyze/canonical.js +74 -0
- package/dist/analyze/citations.d.ts +256 -0
- package/dist/analyze/citations.js +945 -0
- package/dist/analyze/effects.d.ts +15 -0
- package/dist/analyze/effects.js +255 -0
- package/dist/analyze/fact.d.ts +42 -0
- package/dist/analyze/fact.js +46 -0
- package/dist/analyze/guards.d.ts +70 -0
- package/dist/analyze/guards.js +211 -0
- package/dist/analyze/index.d.ts +26 -0
- package/dist/analyze/index.js +52 -0
- package/dist/analyze/program.d.ts +15 -0
- package/dist/analyze/program.js +229 -0
- package/dist/analyze/surface.d.ts +48 -0
- package/dist/analyze/surface.js +396 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +12 -0
- package/dist/cli.d.ts +110 -0
- package/dist/cli.js +502 -0
- package/dist/extract/diff.d.ts +35 -0
- package/dist/extract/diff.js +116 -0
- package/dist/extract/git.d.ts +12 -0
- package/dist/extract/git.js +247 -0
- package/dist/extract/index.d.ts +4 -0
- package/dist/extract/index.js +57 -0
- package/dist/extract/intent.d.ts +64 -0
- package/dist/extract/intent.js +238 -0
- package/dist/extract/scope.d.ts +160 -0
- package/dist/extract/scope.js +284 -0
- package/dist/extract/symbols.d.ts +24 -0
- package/dist/extract/symbols.js +230 -0
- package/dist/interpret/client.d.ts +27 -0
- package/dist/interpret/client.js +80 -0
- package/dist/interpret/index.d.ts +41 -0
- package/dist/interpret/index.js +86 -0
- package/dist/interpret/prompt.d.ts +23 -0
- package/dist/interpret/prompt.js +128 -0
- package/dist/interpret/schema.d.ts +74 -0
- package/dist/interpret/schema.js +103 -0
- package/dist/report/conceal.d.ts +63 -0
- package/dist/report/conceal.js +129 -0
- package/dist/report/coverage.d.ts +43 -0
- package/dist/report/coverage.js +56 -0
- package/dist/report/html.d.ts +4 -0
- package/dist/report/html.js +634 -0
- package/dist/report/markdown.d.ts +2 -0
- package/dist/report/markdown.js +168 -0
- package/dist/report/model.d.ts +303 -0
- package/dist/report/model.js +289 -0
- package/dist/report/pdf.d.ts +2 -0
- package/dist/report/pdf.js +217 -0
- package/dist/report/terminal.d.ts +2 -0
- package/dist/report/terminal.js +206 -0
- package/dist/report/write.d.ts +105 -0
- package/dist/report/write.js +160 -0
- package/dist/score/index.d.ts +94 -0
- package/dist/score/index.js +572 -0
- package/dist/score/reach.d.ts +126 -0
- package/dist/score/reach.js +320 -0
- package/dist/score/reconcile.d.ts +52 -0
- package/dist/score/reconcile.js +208 -0
- package/dist/types.d.ts +221 -0
- package/dist/types.js +10 -0
- package/fonts/DejaVuSans-Bold.ttf +0 -0
- package/fonts/DejaVuSans-Oblique.ttf +0 -0
- package/fonts/DejaVuSans.ttf +0 -0
- package/fonts/DejaVuSansMono.ttf +0 -0
- package/fonts/LICENSE +187 -0
- package/package.json +44 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Noah Ogbi
|
|
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 permit 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,229 @@
|
|
|
1
|
+
# Urtext
|
|
2
|
+
|
|
3
|
+
[](https://github.com/noahogbi/urtext/actions/workflows/ci.yml)
|
|
4
|
+
|
|
5
|
+
**A diff reviewer that shows its evidence.** Point `urtext` at a git range and it reports what
|
|
6
|
+
changed and why it matters — ranked, and with every claim labeled by the kind of evidence behind
|
|
7
|
+
it. A `verified` finding can point at the line of code that proves it. Nothing is asserted on
|
|
8
|
+
the reader's trust alone, and the tool never issues an approve/reject verdict; the judgement
|
|
9
|
+
stays with the human.
|
|
10
|
+
|
|
11
|
+
The name is the thesis: in philology, the *urtext* is the authoritative original from which all
|
|
12
|
+
editions derive. Here the code is the urtext; a review is an edition of it, and an edition that
|
|
13
|
+
cannot cite its source is worthless.
|
|
14
|
+
|
|
15
|
+
## What it does
|
|
16
|
+
|
|
17
|
+
`urtext review` takes a git range, analyses the change, and prints what
|
|
18
|
+
matters — ranked, with every claim labeled by the kind of evidence behind it:
|
|
19
|
+
|
|
20
|
+
- `verified` — proven by static analysis; the report points at the code
|
|
21
|
+
- `inferred` — a model claim that analysis corroborates but does not prove
|
|
22
|
+
- `model` — a model claim nothing mechanical confirms
|
|
23
|
+
|
|
24
|
+
Five analyzers run over the change:
|
|
25
|
+
|
|
26
|
+
- **guards** — conditionals, early returns, and throws removed from code that survived
|
|
27
|
+
- **surface** — exports added, removed, or changed shape
|
|
28
|
+
- **blast radius** — how many places reference a changed export
|
|
29
|
+
- **effects** — network, filesystem, process, env, database, and timing effects appearing or disappearing
|
|
30
|
+
- **citations** — prose that cites code by `path:line` or by a quoted phrase, where the citation resolved
|
|
31
|
+
when its line was last written and no longer resolves now
|
|
32
|
+
|
|
33
|
+
Findings are ranked. A `verified` or `inferred` finding carries the evidence
|
|
34
|
+
behind it — file, line, and the quoted source. A `model` finding carries none
|
|
35
|
+
by construction, and the report says so where it prints one: it is a lead to
|
|
36
|
+
check, not a result.
|
|
37
|
+
|
|
38
|
+
A run that completes writes an HTML report into `.urtext/` at the repository
|
|
39
|
+
root and prints its path; a run broken enough to exit non-zero deliberately
|
|
40
|
+
writes none, so a report on disk never stands in for a review that worked.
|
|
41
|
+
`--export md,pdf` additionally writes the review as GitHub-flavored Markdown
|
|
42
|
+
and as a client-presentable PDF beside the HTML report, sharing its name;
|
|
43
|
+
the same no-report rule applies to every format.
|
|
44
|
+
urtext does not edit the reviewed repository's `.gitignore` — it suggests
|
|
45
|
+
adding `.urtext/` when nothing already ignores it, and leaves the file alone.
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm run review # working tree vs merge-base with the default branch
|
|
49
|
+
npm run review -- HEAD~3 # against a specific revision
|
|
50
|
+
npm run review -- --no-llm # deterministic analysis only; no API key needed
|
|
51
|
+
npm run review -- --json # machine-readable findings
|
|
52
|
+
npm run review -- --open # open the written report
|
|
53
|
+
npm run review -- --export md,pdf # also write Markdown and PDF beside the HTML report
|
|
54
|
+
npm run review -- --model claude-opus-5 # pick the interpretation model
|
|
55
|
+
npm run review -- --help # every flag
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The interpretation stage needs `ANTHROPIC_API_KEY`. Without it, urtext reports
|
|
59
|
+
the analyzers' findings and says in the report that the model was never asked.
|
|
60
|
+
|
|
61
|
+
## Install
|
|
62
|
+
|
|
63
|
+
Not on npm yet. Install straight from GitHub — the build runs automatically
|
|
64
|
+
on install — and `urtext` is on your PATH:
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
npm install -g github:noahogbi/urtext
|
|
68
|
+
urtext review # from any git repository
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Or from a local checkout: `npm install -g path/to/urtext`. The
|
|
72
|
+
`npm run review` form above is the dev loop inside this repository; it runs
|
|
73
|
+
`src/` directly and needs no build.
|
|
74
|
+
|
|
75
|
+
PDF export embeds the bundled DejaVu fonts, which cover Latin, Cyrillic, and
|
|
76
|
+
Greek broadly but not CJK or other scripts — full-Unicode fonts cost tens of
|
|
77
|
+
megabytes.
|
|
78
|
+
|
|
79
|
+
### In CI
|
|
80
|
+
|
|
81
|
+
`action.yml` at this repository's root is a composite GitHub Action that reviews a
|
|
82
|
+
pull request and posts the result as one comment, edited in place on every push:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
name: urtext review
|
|
86
|
+
on: pull_request
|
|
87
|
+
|
|
88
|
+
jobs:
|
|
89
|
+
review:
|
|
90
|
+
runs-on: ubuntu-latest
|
|
91
|
+
permissions:
|
|
92
|
+
contents: read
|
|
93
|
+
pull-requests: write
|
|
94
|
+
steps:
|
|
95
|
+
- uses: actions/checkout@v4
|
|
96
|
+
with:
|
|
97
|
+
fetch-depth: 0 # base...head needs the merge base, which a shallow clone lacks
|
|
98
|
+
- uses: noahogbi/urtext@v1
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`@v1` is a tag, so the action cannot change under you between runs the way `@master`
|
|
102
|
+
can. It was cut on a green cross-platform CI run — the check you can see for yourself
|
|
103
|
+
on this repository.
|
|
104
|
+
|
|
105
|
+
Four of the action's behaviours were verified against live pull requests, and it is
|
|
106
|
+
worth being exact about where that evidence sits: it was recorded during development,
|
|
107
|
+
in a private repository this one does not descend from, so **you cannot follow it from
|
|
108
|
+
here.** What was observed there — the workflow posted one marked comment carrying a
|
|
109
|
+
real review; a re-run edited that same comment rather than adding a second; a
|
|
110
|
+
deliberately failed artifact upload left the job green with the comment intact; and the
|
|
111
|
+
same failure without the upload guard took the job red. Read those as the author's
|
|
112
|
+
report, not as something this repository lets you check.
|
|
113
|
+
|
|
114
|
+
Two behaviours remain unverified anywhere and are not claimed — a pull request from a
|
|
115
|
+
fork, where `GITHUB_TOKEN` is read-only and the post is expected to fail visibly, and
|
|
116
|
+
the `pull_request_target` refusal, which cannot be observed from a branch because that
|
|
117
|
+
trigger reads the base branch's workflow definition.
|
|
118
|
+
|
|
119
|
+
The `permissions:` block sits at the job level, not the workflow level, so adopting
|
|
120
|
+
this does not widen the token for a repository's other jobs. `issues: write` is not
|
|
121
|
+
required — a pull request comment is created through the issue-comments endpoint on a
|
|
122
|
+
pull request, which the `pull-requests` scope governs. The block itself is not
|
|
123
|
+
optional: without `pull-requests: write` the post comes back HTTP 403, which looks
|
|
124
|
+
exactly like the fork case below and is a different problem entirely.
|
|
125
|
+
|
|
126
|
+
By default the review is deterministic: with no key, the action passes `--no-llm`,
|
|
127
|
+
and the comment says so in urtext's own words. One line turns the model on:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
with:
|
|
131
|
+
anthropic-api-key: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
An undefined secret expands to the empty string, so that line degrades to
|
|
135
|
+
deterministic on its own rather than failing.
|
|
136
|
+
|
|
137
|
+
The action never fails a pull request: a review that could not be produced is posted
|
|
138
|
+
as a comment saying so, not as a red check. Every step that can fail captures its own
|
|
139
|
+
status, and the one step that cannot — the artifact upload, which is a `uses:` step
|
|
140
|
+
with no shell to capture in — is marked `continue-on-error`. A failed upload therefore
|
|
141
|
+
costs the comment its `full report` link and empties the `report-artifact-url` output,
|
|
142
|
+
rather than reddening the check. Note what that costs on a large review: the job summary
|
|
143
|
+
is capped by GitHub at 1 MiB and the artifact is the uncapped copy, so past that size a
|
|
144
|
+
failed upload loses the only complete copy of the review.
|
|
145
|
+
`fail-on-error: true` opts into the opposite.
|
|
146
|
+
|
|
147
|
+
One exception remains, deliberately, and `fail-on-error` does not govern it either: the
|
|
148
|
+
`pull_request_target` refusal below fails the job on purpose.
|
|
149
|
+
|
|
150
|
+
**Fork pull requests cannot be commented on.** On a `pull_request` run whose head is
|
|
151
|
+
a fork, `GITHUB_TOKEN` is read-only regardless of the `permissions:` block and
|
|
152
|
+
repository secrets are unavailable, so the post fails with HTTP 403; the action emits
|
|
153
|
+
a warning, sets `posted: none`, leaves the full review in the job summary — and in the
|
|
154
|
+
uploaded artifact when one was uploaded — and stays green. No configuration changes this — a personal access
|
|
155
|
+
token does not help either, because secrets are not exposed to fork-PR runs. The
|
|
156
|
+
known-safe pattern is a two-workflow split: a `pull_request` workflow runs the review
|
|
157
|
+
and uploads the artifact this action already produces, and a `workflow_run` workflow —
|
|
158
|
+
which runs from the base branch with a write token and never checks out head code —
|
|
159
|
+
downloads it and posts. Shipping that second half is a deliberate follow-up.
|
|
160
|
+
|
|
161
|
+
**`pull_request_target` is refused.** The action fails its first step on that trigger,
|
|
162
|
+
on purpose: it grants a write token and secrets to a workflow that then reads the head
|
|
163
|
+
revision, and urtext parses attacker-authored TypeScript.
|
|
164
|
+
|
|
165
|
+
The job summary carries the full, untruncated review. GitHub caps a job summary at
|
|
166
|
+
1 MiB and a larger review is cut by GitHub, not by urtext; the uploaded artifact is
|
|
167
|
+
the uncapped copy. If a *review* comment exceeds `comment-limit`, whole findings are
|
|
168
|
+
removed and the comment says how many and where the rest are.
|
|
169
|
+
|
|
170
|
+
A *failure* comment does not shrink that way. Its headline, reason, closing sentence
|
|
171
|
+
and footer are fixed copy, so a `comment-limit` below their combined length produces a
|
|
172
|
+
comment longer than you asked for — and it is still posted. The action withholds a
|
|
173
|
+
comment only when the body exceeds what the API itself accepts, which lowering
|
|
174
|
+
`comment-limit` cannot cause; that run warns, sets `posted: none`, and leaves the review
|
|
175
|
+
in the job summary — and in the artifact when one was uploaded.
|
|
176
|
+
|
|
177
|
+
Every input is optional:
|
|
178
|
+
|
|
179
|
+
| Input | What it does |
|
|
180
|
+
|---|---|
|
|
181
|
+
| `range` | The git range to review, passed to `urtext review` verbatim. Empty derives `<base sha>...<head sha>` from the pull request payload. |
|
|
182
|
+
| `anthropic-api-key` | Key for the interpretation stage, passed to the CLI as an environment variable and never as an argument. Empty runs `--no-llm`; it never fails. |
|
|
183
|
+
| `model` | Passed as `--model` when non-empty. No effect without `anthropic-api-key`. |
|
|
184
|
+
| `github-token` | The token `gh` authenticates with. Needs `pull-requests: write`. |
|
|
185
|
+
| `comment-marker` | The hidden marker identifying this action's comment. Change it to keep two independent urtext comments on one pull request. |
|
|
186
|
+
| `comment-limit` | Maximum comment body length in characters — the forge's cap carried as data rather than compiled into urtext. |
|
|
187
|
+
| `upload-report` | Upload the run's `.urtext/` directory and the Markdown review as a build artifact, and link it from the comment. |
|
|
188
|
+
| `artifact-name` | The name of that artifact. |
|
|
189
|
+
| `fail-on-error` | Exit non-zero when the review could not be produced or could not be posted. |
|
|
190
|
+
|
|
191
|
+
And the outputs a later step can read:
|
|
192
|
+
|
|
193
|
+
| Output | Value |
|
|
194
|
+
|---|---|
|
|
195
|
+
| `outcome` | `reviewed` when urtext produced a review, `failed` when it did not. |
|
|
196
|
+
| `posted` | `created`, `edited`, or `none`. `none` collapses three causes — a body the API would reject on length, a body that could not be composed, and a post the API refused — and the run's warning names which. |
|
|
197
|
+
| `comment-id` | The created or edited comment's id; empty when `posted` is `none`. |
|
|
198
|
+
| `comment-url` | The comment's `html_url`; empty when `posted` is `none`. |
|
|
199
|
+
| `exit-code` | urtext's own exit code, verbatim. The action interprets it; it never rewrites it. |
|
|
200
|
+
| `omitted-findings` | How many findings the character cap left out. |
|
|
201
|
+
| `report-artifact-url` | The uploaded artifact's URL; empty when `upload-report` is false, and also when the upload failed, which no longer fails the job. |
|
|
202
|
+
|
|
203
|
+
Every default lives in `action.yml` and is deliberately not restated here, so there is
|
|
204
|
+
one place to read it and one place to change it.
|
|
205
|
+
|
|
206
|
+
## Layout
|
|
207
|
+
|
|
208
|
+
- `src/extract/` — git range → changeset (files, hunks, changed symbols)
|
|
209
|
+
- `src/analyze/` — analyzers producing typed facts with source evidence
|
|
210
|
+
- `src/interpret/` — the model stage: facts in, labelled claims out
|
|
211
|
+
- `src/score/` — importance weights, tier assignment, ranking
|
|
212
|
+
- `src/report/` — terminal and HTML renderers, and report writing
|
|
213
|
+
- `src/cli.ts` — entry point
|
|
214
|
+
- `action.yml` — the composite GitHub Action that reviews a pull request
|
|
215
|
+
- `action/` — the action's comment composer, plain ESM run by the runner's `node`
|
|
216
|
+
- `archive/prototype/` — the klar-era IR prototype, kept for provenance
|
|
217
|
+
|
|
218
|
+
Design: `docs/superpowers/specs/2026-08-15-urtext-diff-review-design.md`, and
|
|
219
|
+
`docs/superpowers/README.md` for how to read the rest of them — including which
|
|
220
|
+
citations in them no longer resolve here, and why they were left that way.
|
|
221
|
+
|
|
222
|
+
## Provenance
|
|
223
|
+
|
|
224
|
+
Built ~March 13, 2026 as a standalone prototype under the working name
|
|
225
|
+
**klar** — an AI-native IR the model authored directly. Renamed **urtext**
|
|
226
|
+
and first committed to version control August 15, 2026. In August 2026 it was
|
|
227
|
+
re-aimed at the problem that had become the real bottleneck: reviewing
|
|
228
|
+
AI-written diffs rather than authoring code in an IR. The prototype lives in
|
|
229
|
+
`archive/prototype/`.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import type { Analyzer, EvidenceRef } from "../types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Every identifier in the program that resolves to the named export of the
|
|
5
|
+
* given file, excluding the declaration itself. Every identifier in every
|
|
6
|
+
* file is checked against the resolved symbol — not filtered by spelling
|
|
7
|
+
* first — because a name-matching shortcut is exactly the bug this function
|
|
8
|
+
* exists to avoid: it would both count an unrelated `used` in a different
|
|
9
|
+
* file and miss `import { used as u }` used later as `u(...)`, since `u`
|
|
10
|
+
* never shares text with `used`. Symbol identity, via `getSymbolAtLocation`
|
|
11
|
+
* and `getAliasedSymbol`, is the only thing that decides a match.
|
|
12
|
+
*/
|
|
13
|
+
export declare function countReferences(program: ts.Program, root: string, path: string, name: string): EvidenceRef[];
|
|
14
|
+
/**
|
|
15
|
+
* Reports how widely a changed export is used. Only symbols that actually
|
|
16
|
+
* changed are considered — a busy export nobody touched is not news.
|
|
17
|
+
*
|
|
18
|
+
* This is the first analyzer whose interesting evidence is not co-located
|
|
19
|
+
* with its subject: the fact concerns a changed export in one file, but the
|
|
20
|
+
* evidence a reader wants — the call sites — lives in other files.
|
|
21
|
+
* `evidence[0]` is still the changed declaration itself, matching
|
|
22
|
+
* `Fact.file`/`Fact.line` exactly as every other analyzer in this codebase
|
|
23
|
+
* does; the reference sites follow it in `evidence[1..]` rather than
|
|
24
|
+
* displacing it. `detail.references` counts only the reference sites, not
|
|
25
|
+
* the declaration entry that precedes them — the number in the finding text
|
|
26
|
+
* means "places that use this".
|
|
27
|
+
*/
|
|
28
|
+
export declare const blastRadiusAnalyzer: Analyzer;
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import ts from "typescript";
|
|
3
|
+
import { isTypeScriptFile } from "../extract/symbols.js";
|
|
4
|
+
import { makeFact, MAX_EVIDENCE } from "./fact.js";
|
|
5
|
+
import { relativePathOf } from "./program.js";
|
|
6
|
+
/**
|
|
7
|
+
* Every identifier in the program that resolves to the named export of the
|
|
8
|
+
* given file, excluding the declaration itself. Every identifier in every
|
|
9
|
+
* file is checked against the resolved symbol — not filtered by spelling
|
|
10
|
+
* first — because a name-matching shortcut is exactly the bug this function
|
|
11
|
+
* exists to avoid: it would both count an unrelated `used` in a different
|
|
12
|
+
* file and miss `import { used as u }` used later as `u(...)`, since `u`
|
|
13
|
+
* never shares text with `used`. Symbol identity, via `getSymbolAtLocation`
|
|
14
|
+
* and `getAliasedSymbol`, is the only thing that decides a match.
|
|
15
|
+
*/
|
|
16
|
+
export function countReferences(program, root, path, name) {
|
|
17
|
+
const checker = program.getTypeChecker();
|
|
18
|
+
const declFile = program.getSourceFile(join(root, path));
|
|
19
|
+
if (!declFile)
|
|
20
|
+
return [];
|
|
21
|
+
const moduleSymbol = checker.getSymbolAtLocation(declFile);
|
|
22
|
+
if (!moduleSymbol)
|
|
23
|
+
return [];
|
|
24
|
+
const resolve = (sym) => {
|
|
25
|
+
if (!sym)
|
|
26
|
+
return undefined;
|
|
27
|
+
return sym.flags & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(sym) : sym;
|
|
28
|
+
};
|
|
29
|
+
const exportsOfModule = checker.getExportsOfModule(moduleSymbol);
|
|
30
|
+
// A default export sits in the export table under "default", whatever its
|
|
31
|
+
// declared name is, so the bare-name lookup finds nothing for `export
|
|
32
|
+
// default function main` and its reach silently never attached. When the
|
|
33
|
+
// bare name misses, take the default export — resolved through its alias
|
|
34
|
+
// for the `export default main;` statement form — but only when its own
|
|
35
|
+
// declaration really carries this name: the fallback must not hand an
|
|
36
|
+
// unrelated symbol the default export's reference count.
|
|
37
|
+
const declaresName = (sym) => (sym.declarations ?? []).some((d) => {
|
|
38
|
+
const declName = d.name;
|
|
39
|
+
return !!declName && ts.isIdentifier(declName) && declName.text === name;
|
|
40
|
+
});
|
|
41
|
+
const defaultExport = resolve(exportsOfModule.find((s) => s.getName() === ts.InternalSymbolName.Default));
|
|
42
|
+
const target = exportsOfModule.find((s) => s.getName() === name) ??
|
|
43
|
+
(defaultExport && declaresName(defaultExport) ? defaultExport : undefined);
|
|
44
|
+
if (!target)
|
|
45
|
+
return [];
|
|
46
|
+
// Set<ts.Node>, not Set<ts.Declaration>: it is only ever compared against
|
|
47
|
+
// node.parent below, which is typed as ts.Node.
|
|
48
|
+
const declarations = new Set(target.declarations ?? []);
|
|
49
|
+
const refs = [];
|
|
50
|
+
// A binding position — `import { used } from ...`, `import used from ...`
|
|
51
|
+
// (a default import is the ImportClause's own name, not a specifier),
|
|
52
|
+
// `export { used }`, or the bare identifier of `export default used;` —
|
|
53
|
+
// introduces a local name for the symbol; it does not read the value the
|
|
54
|
+
// symbol holds. Without this exclusion, every importing file counts
|
|
55
|
+
// twice: once for the import binding itself (which resolves through
|
|
56
|
+
// its alias to the same target) and once for each place that actually
|
|
57
|
+
// calls or reads it, so a file imported and used once would inflate the
|
|
58
|
+
// count to two.
|
|
59
|
+
const isBindingIdentifier = (node) => {
|
|
60
|
+
const p = node.parent;
|
|
61
|
+
if (ts.isImportSpecifier(p) || ts.isExportSpecifier(p)) {
|
|
62
|
+
return node === p.name || node === p.propertyName;
|
|
63
|
+
}
|
|
64
|
+
if (ts.isImportClause(p) || ts.isNamespaceImport(p) || ts.isNamespaceExport(p)) {
|
|
65
|
+
return node === p.name;
|
|
66
|
+
}
|
|
67
|
+
if (ts.isExportAssignment(p)) {
|
|
68
|
+
return node === p.expression;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
};
|
|
72
|
+
for (const sf of program.getSourceFiles()) {
|
|
73
|
+
if (sf.isDeclarationFile)
|
|
74
|
+
continue;
|
|
75
|
+
const relPath = relativePathOf(root, sf);
|
|
76
|
+
const lines = sf.text.split("\n");
|
|
77
|
+
const visit = (node) => {
|
|
78
|
+
if (ts.isIdentifier(node)) {
|
|
79
|
+
const inDeclaration = declarations.has(node.parent);
|
|
80
|
+
if (!inDeclaration &&
|
|
81
|
+
!isBindingIdentifier(node) &&
|
|
82
|
+
resolve(checker.getSymbolAtLocation(node)) === target) {
|
|
83
|
+
const line = sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1;
|
|
84
|
+
refs.push({
|
|
85
|
+
file: relPath,
|
|
86
|
+
line,
|
|
87
|
+
excerpt: (lines[line - 1] ?? "").trim(),
|
|
88
|
+
// The program is built at the after revision, so every line
|
|
89
|
+
// number here counts in the working tree the reader is looking
|
|
90
|
+
// at.
|
|
91
|
+
side: "after",
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
ts.forEachChild(node, visit);
|
|
96
|
+
};
|
|
97
|
+
ts.forEachChild(sf, visit);
|
|
98
|
+
}
|
|
99
|
+
return refs;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Reports how widely a changed export is used. Only symbols that actually
|
|
103
|
+
* changed are considered — a busy export nobody touched is not news.
|
|
104
|
+
*
|
|
105
|
+
* This is the first analyzer whose interesting evidence is not co-located
|
|
106
|
+
* with its subject: the fact concerns a changed export in one file, but the
|
|
107
|
+
* evidence a reader wants — the call sites — lives in other files.
|
|
108
|
+
* `evidence[0]` is still the changed declaration itself, matching
|
|
109
|
+
* `Fact.file`/`Fact.line` exactly as every other analyzer in this codebase
|
|
110
|
+
* does; the reference sites follow it in `evidence[1..]` rather than
|
|
111
|
+
* displacing it. `detail.references` counts only the reference sites, not
|
|
112
|
+
* the declaration entry that precedes them — the number in the finding text
|
|
113
|
+
* means "places that use this".
|
|
114
|
+
*/
|
|
115
|
+
export const blastRadiusAnalyzer = async (changeset, ctx) => {
|
|
116
|
+
const relevant = changeset.files.filter((f) => isTypeScriptFile(f.path) &&
|
|
117
|
+
f.status !== "deleted" &&
|
|
118
|
+
f.symbols.some((s) => s.exported && s.change !== "removed"));
|
|
119
|
+
if (relevant.length === 0)
|
|
120
|
+
return [];
|
|
121
|
+
const program = await ctx.programAt(ctx.range.to);
|
|
122
|
+
const facts = [];
|
|
123
|
+
for (const file of relevant) {
|
|
124
|
+
const sf = program.getSourceFile(join(ctx.cwd, file.path));
|
|
125
|
+
const lines = sf ? sf.text.split("\n") : [];
|
|
126
|
+
for (const sym of file.symbols) {
|
|
127
|
+
if (!sym.exported || sym.change === "removed")
|
|
128
|
+
continue;
|
|
129
|
+
const refs = countReferences(program, ctx.cwd, file.path, sym.name);
|
|
130
|
+
// Nothing references it, so there is no blast radius to report — and
|
|
131
|
+
// a fact with only its own declaration as evidence would say nothing
|
|
132
|
+
// a surface_changed fact hasn't already said.
|
|
133
|
+
if (refs.length === 0)
|
|
134
|
+
continue;
|
|
135
|
+
const line = sym.range.startLine || 1;
|
|
136
|
+
const declaration = {
|
|
137
|
+
file: file.path,
|
|
138
|
+
line,
|
|
139
|
+
excerpt: (lines[line - 1] ?? "").trim(),
|
|
140
|
+
side: "after",
|
|
141
|
+
};
|
|
142
|
+
// No text to show for the declaration itself — the file was not in
|
|
143
|
+
// the program, or the line is blank. A fact whose anchor evidence is
|
|
144
|
+
// an empty excerpt asks the reader to take it on faith, which is what
|
|
145
|
+
// the `verified` badge is supposed to replace.
|
|
146
|
+
if (!declaration.excerpt)
|
|
147
|
+
continue;
|
|
148
|
+
facts.push(makeFact({
|
|
149
|
+
id: `blast_radius:${file.path}:${sym.qualifiedName}`,
|
|
150
|
+
kind: "blast_radius",
|
|
151
|
+
// The qualified name, matching the id: `countReferences` above
|
|
152
|
+
// takes the bare `sym.name` because a module's export table is
|
|
153
|
+
// keyed on the exported name, but nothing downstream may be. The
|
|
154
|
+
// two agree for a top-level export and only for that; the fact's
|
|
155
|
+
// identity does not get to depend on which of them was handy.
|
|
156
|
+
qualifiedSymbol: sym.qualifiedName,
|
|
157
|
+
detail: { symbol: sym.qualifiedName, references: refs.length },
|
|
158
|
+
evidence: [declaration, ...refs.slice(0, MAX_EVIDENCE)],
|
|
159
|
+
}));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return facts;
|
|
163
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical form of a printed type, for COMPARISON only — never for display.
|
|
3
|
+
*
|
|
4
|
+
* `checker.typeToString` prints a union's members in type-interning order:
|
|
5
|
+
* the order each member type was first materialized anywhere in that
|
|
6
|
+
* program's checking history. That order is not source order, and it is not
|
|
7
|
+
* stable across two separately-built programs — reviewing a range that
|
|
8
|
+
* merely added a module shifted it, and three byte-identical declarations
|
|
9
|
+
* were reported as `verified` signature changes. `structuralSignature`'s
|
|
10
|
+
* top-level union sort fixed one position; this fixes the class, because a
|
|
11
|
+
* union can sit at ANY depth of a printed type — a property's type, a
|
|
12
|
+
* generic argument, a parameter.
|
|
13
|
+
*
|
|
14
|
+
* The printed text is itself valid TypeScript type syntax, so the real
|
|
15
|
+
* parser does the understanding: parse it, recursively sort every
|
|
16
|
+
* set-semantic construct — union constituents, intersection constituents,
|
|
17
|
+
* type-literal members — by the canonical text of each part, and print the
|
|
18
|
+
* result. A pipe inside a string-literal type is data, not grammar, and the
|
|
19
|
+
* parser knows the difference; pinned by `test/analyze/canonical.test.ts`,
|
|
20
|
+
* "is not fooled by separators inside string-literal types".
|
|
21
|
+
*
|
|
22
|
+
* Text that does not parse cleanly (a signature truncated mid-token by the
|
|
23
|
+
* storage cap) is returned unchanged: comparison then degrades to the raw
|
|
24
|
+
* string equality it was before this module existed, never to anything
|
|
25
|
+
* looser. Two different unparseable texts stay different.
|
|
26
|
+
*/
|
|
27
|
+
export declare function canonicalSignature(text: string): string;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* Canonical form of a printed type, for COMPARISON only — never for display.
|
|
4
|
+
*
|
|
5
|
+
* `checker.typeToString` prints a union's members in type-interning order:
|
|
6
|
+
* the order each member type was first materialized anywhere in that
|
|
7
|
+
* program's checking history. That order is not source order, and it is not
|
|
8
|
+
* stable across two separately-built programs — reviewing a range that
|
|
9
|
+
* merely added a module shifted it, and three byte-identical declarations
|
|
10
|
+
* were reported as `verified` signature changes. `structuralSignature`'s
|
|
11
|
+
* top-level union sort fixed one position; this fixes the class, because a
|
|
12
|
+
* union can sit at ANY depth of a printed type — a property's type, a
|
|
13
|
+
* generic argument, a parameter.
|
|
14
|
+
*
|
|
15
|
+
* The printed text is itself valid TypeScript type syntax, so the real
|
|
16
|
+
* parser does the understanding: parse it, recursively sort every
|
|
17
|
+
* set-semantic construct — union constituents, intersection constituents,
|
|
18
|
+
* type-literal members — by the canonical text of each part, and print the
|
|
19
|
+
* result. A pipe inside a string-literal type is data, not grammar, and the
|
|
20
|
+
* parser knows the difference; pinned by `test/analyze/canonical.test.ts`,
|
|
21
|
+
* "is not fooled by separators inside string-literal types".
|
|
22
|
+
*
|
|
23
|
+
* Text that does not parse cleanly (a signature truncated mid-token by the
|
|
24
|
+
* storage cap) is returned unchanged: comparison then degrades to the raw
|
|
25
|
+
* string equality it was before this module existed, never to anything
|
|
26
|
+
* looser. Two different unparseable texts stay different.
|
|
27
|
+
*/
|
|
28
|
+
export function canonicalSignature(text) {
|
|
29
|
+
const sf = ts.createSourceFile("__sig.ts", `type __T = ${text};`, ts.ScriptTarget.ES2022, true);
|
|
30
|
+
// `parseDiagnostics` is not on the public SourceFile type, but it is the
|
|
31
|
+
// only place the parser records recoverable syntax errors, and a wrapper
|
|
32
|
+
// program just to surface them would type-check the text — far more than
|
|
33
|
+
// this function may assume about it. Worst case if the field ever
|
|
34
|
+
// vanishes: `undefined` here reads as "no diagnostics" and a truncated
|
|
35
|
+
// signature canonicalizes best-effort — still deterministic, since the
|
|
36
|
+
// same cut text always parses to the same tree.
|
|
37
|
+
const diagnostics = sf
|
|
38
|
+
.parseDiagnostics;
|
|
39
|
+
const alias = sf.statements[0];
|
|
40
|
+
if (sf.statements.length !== 1 ||
|
|
41
|
+
!ts.isTypeAliasDeclaration(alias) ||
|
|
42
|
+
(diagnostics?.length ?? 0) > 0) {
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
const printer = ts.createPrinter({ removeComments: true });
|
|
46
|
+
const print = (node) => printer.printNode(ts.EmitHint.Unspecified, node, sf);
|
|
47
|
+
// Plain code-unit order: deterministic on every machine, unlike a locale
|
|
48
|
+
// collation. What the order IS does not matter; that it never varies does.
|
|
49
|
+
const byPrintedText = (nodes) => nodes
|
|
50
|
+
.map((node) => ({ node, key: print(node) }))
|
|
51
|
+
.sort((a, b) => (a.key < b.key ? -1 : a.key > b.key ? 1 : 0))
|
|
52
|
+
.map((entry) => entry.node);
|
|
53
|
+
const transformed = ts.transform(alias.type, [
|
|
54
|
+
(context) => {
|
|
55
|
+
const visit = (node) => {
|
|
56
|
+
// Children first, so a constituent's sort key is already canonical
|
|
57
|
+
// by the time its parent orders it.
|
|
58
|
+
const visited = ts.visitEachChild(node, visit, context);
|
|
59
|
+
if (ts.isUnionTypeNode(visited)) {
|
|
60
|
+
return ts.factory.updateUnionTypeNode(visited, ts.factory.createNodeArray(byPrintedText(visited.types)));
|
|
61
|
+
}
|
|
62
|
+
if (ts.isIntersectionTypeNode(visited)) {
|
|
63
|
+
return ts.factory.updateIntersectionTypeNode(visited, ts.factory.createNodeArray(byPrintedText(visited.types)));
|
|
64
|
+
}
|
|
65
|
+
if (ts.isTypeLiteralNode(visited)) {
|
|
66
|
+
return ts.factory.updateTypeLiteralNode(visited, ts.factory.createNodeArray(byPrintedText(visited.members)));
|
|
67
|
+
}
|
|
68
|
+
return visited;
|
|
69
|
+
};
|
|
70
|
+
return (root) => ts.visitNode(root, visit);
|
|
71
|
+
},
|
|
72
|
+
]);
|
|
73
|
+
return print(transformed.transformed[0]);
|
|
74
|
+
}
|