spexcode 0.2.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/anchors.ts +300 -0
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +691 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +23 -15
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +142 -98
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +300 -385
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +144 -103
- package/spec-cli/src/init.ts +14 -13
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +128 -30
- package/spec-cli/src/localIssues.ts +61 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +398 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +12 -12
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +40 -25
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +5 -5
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +20 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/.plugins/spec.md +18 -0
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-CTcH2eW9.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-CVFbBpyW.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-kULjonqj.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-B0ZJju8K.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-BRKJqU2U.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CDhEL7wO.js} +7 -7
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BL6FV_8S.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-DmQsNYKK.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-DulGPk6A.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +212 -96
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +35 -27
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +44 -22
- package/spec-eval/src/humanok.ts +43 -0
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +42 -10
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +0 -11
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/spec.md +0 -15
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins/prompts}/memory-hygiene/spec.md +0 -0
package/README.md
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
<a href="https://spexcode.net"><img alt="docs" src="https://img.shields.io/badge/docs-spexcode.net-8957e5"></a>
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
+
<p>
|
|
13
|
+
<img alt="Linux" src="https://img.shields.io/badge/Linux-supported-success?logo=linux&logoColor=white">
|
|
14
|
+
<img alt="macOS" src="https://img.shields.io/badge/macOS-supported-success?logo=apple&logoColor=white">
|
|
15
|
+
<img alt="Windows: via WSL2" src="https://img.shields.io/badge/Windows-WSL2-success">
|
|
16
|
+
<img alt="database: git" src="https://img.shields.io/badge/database-git-f05032?logo=git&logoColor=white">
|
|
17
|
+
</p>
|
|
18
|
+
|
|
12
19
|
</div>
|
|
13
20
|
|
|
14
21
|
Spec-driven development with AI agents in the loop. SpexCode keeps a versioned tree of specs inside
|
|
@@ -23,16 +30,17 @@ implementation from drifting apart.
|
|
|
23
30
|
English | [中文](./docs/README.zh-CN.md) · Docs: [spexcode.net](https://spexcode.net) · License: MIT
|
|
24
31
|
|
|
25
32
|
Quick links: [the model](#the-model) · [quick start](#quick-start) ·
|
|
26
|
-
[agents](#working-with-agents) · [
|
|
33
|
+
[agents](#working-with-agents) · [eval](#measuring-behavior-eval) · [config](#configuration)
|
|
27
34
|
|
|
28
35
|
## The model
|
|
29
36
|
|
|
30
37
|
<div align="center"><img src="docs/sdd-tuxedo-pooh.png" alt="spec-driven development meme" width="260"></div>
|
|
31
38
|
|
|
32
39
|
A spec node is a directory under `.spec/` containing a `spec.md`: frontmatter (title, status, a
|
|
33
|
-
`code:`
|
|
34
|
-
supposed to do, right now. Nodes nest, so the tree
|
|
35
|
-
|
|
40
|
+
`code:` pointer to the file it governs, a `related:` list for files it references) plus a prose
|
|
41
|
+
body stating what that part of the system is supposed to do, right now. Nodes nest, so the tree
|
|
42
|
+
mirrors how you think about the project rather than the file layout. The body can split into two
|
|
43
|
+
labelled parts. The short **raw source** states the intent; changing it takes explicit
|
|
36
44
|
human approval (an agent can draft it, a human signs off). The **expanded spec** is the agent's
|
|
37
45
|
detailed reading of that intent; it iterates freely but must always match the raw source.
|
|
38
46
|
|
|
@@ -41,7 +49,7 @@ detailed reading of that intent; it iterates freely but must always match the ra
|
|
|
41
49
|
Two rules make this workable:
|
|
42
50
|
|
|
43
51
|
1. **Git is the database.** There is no separate store. A node's version count is the number of
|
|
44
|
-
commits that
|
|
52
|
+
commits that changed its `spec.md`, its history view is `git log` on that file, and each version
|
|
45
53
|
is attributed to an agent session through a `Session:` commit trailer. This is also why a spec
|
|
46
54
|
body always describes present intent and gets rewritten in place: changelog headings inside the
|
|
47
55
|
body are banned (the linter enforces it), because git already keeps the history.
|
|
@@ -49,23 +57,23 @@ Two rules make this workable:
|
|
|
49
57
|
code it justifies. When code moves without its spec, the linter flags it,
|
|
50
58
|
|
|
51
59
|
```
|
|
52
|
-
drift: spec-cli/src/
|
|
60
|
+
drift: spec-cli/src/graph.ts is 1 commit(s) ahead of spec 'graph-lean' (v12) — may be stale
|
|
53
61
|
```
|
|
54
62
|
|
|
55
63
|
and keeps flagging until the spec catches up.
|
|
56
64
|
|
|
57
65
|
## The optimization loop
|
|
58
66
|
|
|
59
|
-
Specs, commits, and
|
|
60
|
-
it's the half a human signs off on. Commits are the optimizer. **
|
|
61
|
-
subsystem,
|
|
67
|
+
Specs, commits, and evals compose into one loop. The spec is the loss function: it states what you
|
|
68
|
+
want, and it's the half a human signs off on. Commits are the optimizer. **eval**, the measurement
|
|
69
|
+
subsystem, scores how far live behavior currently sits from the spec, and the
|
|
62
70
|
score's history lives in git like everything else.
|
|
63
71
|
|
|
64
72
|
<img src="docs/readme-loop.png" alt="the spec/code optimization loop">
|
|
65
73
|
|
|
66
74
|
It also settles where the human stands day to day: nobody reads a neural net by staring at its
|
|
67
75
|
weights, and between merge gates you don't have to stare at agent diffs either. Attention goes to
|
|
68
|
-
the spec and the
|
|
76
|
+
the spec and the evals; the diff gets read once, at merge time.
|
|
69
77
|
|
|
70
78
|
## Quick start
|
|
71
79
|
|
|
@@ -76,20 +84,20 @@ npm i -g spexcode # installs the `spex` command
|
|
|
76
84
|
cd your-repo
|
|
77
85
|
spex init # seeds .spec/, installs git hooks, materializes the agent contract
|
|
78
86
|
spex serve # API backend on :8787
|
|
79
|
-
spex
|
|
87
|
+
spex serve ui # dashboard on :5173, proxying to the backend
|
|
80
88
|
```
|
|
81
89
|
|
|
82
90
|
`spex init` is additive. It works on any existing git repo and never overwrites your files: it
|
|
83
|
-
creates a root `.spec/project/spec.md` and a starter `spexcode.json`, installs the
|
|
84
|
-
|
|
91
|
+
creates a root `.spec/project/spec.md` and a starter `spexcode.json`, installs the git hooks, and
|
|
92
|
+
writes a managed block into `CLAUDE.md`/`AGENTS.md` so any agent working in the repo
|
|
85
93
|
discovers the workflow on its own.
|
|
86
94
|
|
|
87
95
|
Then grow the tree:
|
|
88
96
|
|
|
89
97
|
1. Edit `.spec/project/spec.md` to describe the project.
|
|
90
|
-
2. Add child nodes for the parts you want governed, each with a `code:`
|
|
91
|
-
files.
|
|
92
|
-
3. Run `spex lint`. Coverage warnings list the source files no spec claims yet; that list is your
|
|
98
|
+
2. Add child nodes for the parts you want governed, each with a `code:` entry pointing at an
|
|
99
|
+
existing file (`related:` for the files it touches but doesn't own).
|
|
100
|
+
3. Run `spex spec lint`. Coverage warnings list the source files no spec claims yet; that list is your
|
|
93
101
|
adoption TODO.
|
|
94
102
|
|
|
95
103
|
You are not expected to hand-author all of this. The intended workflow is to have an agent do most
|
|
@@ -97,7 +105,7 @@ of the spec writing; `spex guide spec` prints the exact file format it needs.
|
|
|
97
105
|
[Getting started](https://spexcode.net/getting-started/) on the docs site walks the setup end to
|
|
98
106
|
end.
|
|
99
107
|
|
|
100
|
-
<img src="docs/readme-
|
|
108
|
+
<img src="docs/readme-graph.png" alt="dashboard screenshot">
|
|
101
109
|
|
|
102
110
|
*SpexCode's own repo on its own board; the sessions top-left are agents building the tool.*
|
|
103
111
|
|
|
@@ -107,7 +115,7 @@ This part needs tmux and a logged-in [Claude Code](https://www.anthropic.com/cla
|
|
|
107
115
|
on the machine.
|
|
108
116
|
|
|
109
117
|
```sh
|
|
110
|
-
spex new "make the settings page remember the last tab" --node settings
|
|
118
|
+
spex session new "make the settings page remember the last tab" --node settings
|
|
111
119
|
```
|
|
112
120
|
|
|
113
121
|
launches a worker session in its own worktree on branch `node/settings-…`. The `--node` flag (or a
|
|
@@ -123,9 +131,9 @@ themselves use when they delegate.
|
|
|
123
131
|
You supervise from outside — on the board, or with the same commands your agent uses:
|
|
124
132
|
|
|
125
133
|
```sh
|
|
126
|
-
spex watch # stream session transitions: launched / review / done / needs-input ...
|
|
127
|
-
spex review settings # commits ahead of trunk, merge-base diff,
|
|
128
|
-
spex merge settings # gated merge into the trunk
|
|
134
|
+
spex session watch # stream session transitions: launched / review / done / needs-input ...
|
|
135
|
+
spex session review settings # commits ahead of trunk, merge-base diff, merge-conflict/lint gates
|
|
136
|
+
spex session merge settings # gated merge into the trunk
|
|
129
137
|
spex session close settings
|
|
130
138
|
```
|
|
131
139
|
|
|
@@ -138,28 +146,29 @@ hook stamps the attribution; the materialized contract block carries the rest, s
|
|
|
138
146
|
prompt stays task-only. More on this mode of working:
|
|
139
147
|
[working with agents](https://spexcode.net/working-with-agents/).
|
|
140
148
|
|
|
141
|
-
## Measuring behavior:
|
|
149
|
+
## Measuring behavior: eval
|
|
142
150
|
|
|
143
|
-
|
|
144
|
-
[the loop](#the-optimization-loop)
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
eval is the measuring half of
|
|
152
|
+
[the loop](#the-optimization-loop), built on the YATU discipline (**You As The User**): you measure
|
|
153
|
+
behavior from the product's real surface, the way a real end user would touch it, not through an
|
|
154
|
+
internal helper or shortcut that makes the
|
|
155
|
+
proof easy. A spec says what a part should do; an
|
|
156
|
+
`eval.md` beside it says how to check. Each scenario is a plain description plus an expected
|
|
157
|
+
result. eval itself runs nothing (no DSL, no runner). An agent runs the scenario however it can:
|
|
149
158
|
a test file, a real browser, or just clicking through by hand and screenshotting. It compares
|
|
150
|
-
actual to expected and files the
|
|
159
|
+
actual to expected and files the eval with evidence:
|
|
151
160
|
|
|
152
161
|
```sh
|
|
153
|
-
spex
|
|
162
|
+
spex eval add settings --scenario remembers-tab --pass --image evidence.png
|
|
154
163
|
```
|
|
155
164
|
|
|
156
|
-
|
|
157
|
-
and history as spec versions. Bug fixes are expected to bracket: file a failing
|
|
158
|
-
reproduces the bug, fix, then file a passing
|
|
165
|
+
Evals live in a git-tracked ndjson next to the spec, so measurements get the same attribution
|
|
166
|
+
and history as spec versions. Bug fixes are expected to bracket: file a failing eval that
|
|
167
|
+
reproduces the bug, fix, then file a passing eval on the same scenario.
|
|
159
168
|
|
|
160
169
|
<img src="docs/readme-eval.png" alt="eval view screenshot">
|
|
161
170
|
|
|
162
|
-
*The eval view: scenario
|
|
171
|
+
*The eval view: scenario evals on the left; the selected eval's expected result, staleness,
|
|
163
172
|
and recorded video evidence in the middle.*
|
|
164
173
|
|
|
165
174
|
## What's in the repo
|
|
@@ -168,14 +177,14 @@ and recorded video evidence in the middle.*
|
|
|
168
177
|
|---|---|
|
|
169
178
|
| `spec-cli` | The `spex` CLI and the HTTP backend (Hono, runs via tsx, no build step). Reads `.spec` and git live; owns the session state machine and the linter. |
|
|
170
179
|
| `spec-dashboard` | React board: the node graph, per-node spec/history/issues panes, and a real terminal onto each live agent session. |
|
|
171
|
-
| `spec-
|
|
172
|
-
| `spec-forge` | Read-only tracer that resolves a forge's open issues and PRs to the spec nodes they serve (GitHub today). An issue links itself with a `Spec: <node-id>` line in its body; a PR from a `node/<id>` branch links for free. |
|
|
180
|
+
| `spec-eval` | Scenario definitions, evals, evidence. |
|
|
181
|
+
| `spec-forge` | Read-only tracer that resolves a forge's open issues and PRs to the spec nodes they serve (GitHub and GitLab drivers today). An issue links itself with a `Spec: <node-id>` line in its body; a PR from a `node/<id>` branch links for free. |
|
|
173
182
|
|
|
174
183
|
## The linter
|
|
175
184
|
|
|
176
|
-
`spex lint` checks the spec↔code graph and is the real gate (the git hook is fast local feedback):
|
|
185
|
+
`spex spec lint` checks the spec↔code graph and is the real gate (the git hook is fast local feedback):
|
|
177
186
|
|
|
178
|
-
- **integrity** (error): a `code:` path that doesn't exist
|
|
187
|
+
- **integrity** (error): a `code:` or `related:` path that doesn't exist
|
|
179
188
|
- **living** (error): a changelog heading in a spec body
|
|
180
189
|
- **altitude** (warn): a body that slid from contract prose into an implementation dump. The usual
|
|
181
190
|
smell is a numbered step list or a wall of function names; this rule is why spec bodies stay
|
|
@@ -183,20 +192,23 @@ and recorded video evidence in the middle.*
|
|
|
183
192
|
- **coverage** (warn): unclaimed source files
|
|
184
193
|
- **drift** (warn): governed code changed after its spec's last version, derived live from git
|
|
185
194
|
|
|
195
|
+
plus naming and ownership rules (`one-govern`, `id-format`, `mention` as errors; `breadth`,
|
|
196
|
+
`related-drift`, `owners`, `confusable-id` as warns) — `spex guide spec` lists them all.
|
|
197
|
+
|
|
186
198
|
## Configuration
|
|
187
199
|
|
|
188
200
|
`spexcode.json` (committed, portable: layout, lint budgets, dashboard identity, launcher names) and
|
|
189
|
-
`spexcode.local.json` (gitignored, host-specific: absolute launcher paths,
|
|
190
|
-
|
|
191
|
-
to), and `spex guide
|
|
192
|
-
manuals are `spex guide` (the workflow), `spex guide spec`,
|
|
193
|
-
maps the commands.
|
|
201
|
+
`spexcode.local.json` (gitignored, host-specific: absolute launcher paths, cert paths) cover every
|
|
202
|
+
setting. There is no imperative settings verb: you edit the two files by hand (or ask your agent
|
|
203
|
+
to), and `spex guide settings` documents every field. The other
|
|
204
|
+
manuals are `spex guide` (the workflow), `spex guide spec`, `spex guide eval`, and
|
|
205
|
+
`spex guide footprint`; `spex help` maps the commands.
|
|
194
206
|
|
|
195
207
|
## Contributing
|
|
196
208
|
|
|
197
209
|
[`docs/CONTRIBUTING.md`](docs/CONTRIBUTING.md) gets you from a clone to a first merged change.
|
|
198
210
|
[`docs/AGENT_GUIDE.md`](docs/AGENT_GUIDE.md) has the full mechanics of the node model and the
|
|
199
|
-
reflexive
|
|
211
|
+
reflexive plugin system.
|
|
200
212
|
|
|
201
213
|
## Credit
|
|
202
214
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spexcode",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode — a spec-driven, self-developing dev tool. The `spex` CLI + spec server reads the .spec tree and its git history, and serves the dashboard.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
"spec-cli/bin",
|
|
14
14
|
"spec-cli/templates",
|
|
15
15
|
"spec-cli/hooks",
|
|
16
|
-
"spec-
|
|
17
|
-
"!spec-
|
|
16
|
+
"spec-eval/src",
|
|
17
|
+
"!spec-eval/src/**/*.test.ts",
|
|
18
18
|
"spec-forge/src",
|
|
19
19
|
"!spec-forge/src/**/*.test.ts",
|
|
20
20
|
"spec-dashboard/dist",
|
package/spec-cli/bin/spex.mjs
CHANGED
|
@@ -17,9 +17,9 @@ const cli = join(pkg, 'src', 'cli.ts')
|
|
|
17
17
|
// while a merge conflict is being resolved in the checkout that hosts it, the source holds conflict
|
|
18
18
|
// markers and tsx dies with a raw esbuild stacktrace — on EVERY call, including the Stop hook and an
|
|
19
19
|
// agent's `spex session done`. Catch that one transient state up front: scan the source trees the CLI
|
|
20
|
-
// imports (spec-cli ←→ spec-
|
|
20
|
+
// imports (spec-cli ←→ spec-eval ←→ spec-forge), and if any file carries a marker, print one actionable
|
|
21
21
|
// line and exit 75 (EX_TEMPFAIL: transient, retry) instead of spawning tsx into the stacktrace.
|
|
22
|
-
const srcRoots = [join(pkg, 'src'), join(pkg, '..', 'spec-
|
|
22
|
+
const srcRoots = [join(pkg, 'src'), join(pkg, '..', 'spec-eval', 'src'), join(pkg, '..', 'spec-forge', 'src')]
|
|
23
23
|
const conflicted = srcRoots.flatMap((root) => {
|
|
24
24
|
if (!existsSync(root)) return []
|
|
25
25
|
return readdirSync(root, { recursive: true })
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
#
|
|
14
14
|
# The old (1) GATE — an auto-materialize when the config content-hash moved — is RETIRED ([[commit-surgery]]):
|
|
15
15
|
# a harness event is never a materialize trigger; the materialize anchors are git-native only (spex verbs,
|
|
16
|
-
# session-worktree creation, and the pre-commit/post-checkout/post-merge hooks). .
|
|
16
|
+
# session-worktree creation, and the pre-commit/post-checkout/post-merge hooks). .plugins edits are
|
|
17
17
|
# git-transactional: they take effect at the commit/checkout/merge that carries them, like any other source.
|
|
18
18
|
set -u
|
|
19
19
|
# args: `<harness> <Event>`. A harness id as $1 (claude|codex|plugin) is consumed; otherwise we keep $1 as the
|
|
@@ -59,15 +59,35 @@ hp_field() {
|
|
|
59
59
|
# the session id from a payload (both harnesses use session_id).
|
|
60
60
|
# Codex hooks run inside ONE shared per-project app-server. That process can inherit the FIRST launched
|
|
61
61
|
# session's SPEXCODE_SESSION_ID, so on codex the payload session_id (the acting thread id) must win and then
|
|
62
|
-
# hp_store_dir aliases it to the governed record. Claude's payload id
|
|
63
|
-
#
|
|
62
|
+
# hp_store_dir aliases it to the governed record. Claude's payload id equals its governed record id, so the
|
|
63
|
+
# PAYLOAD is the acting identity; the inherited env is only a fallback for payload-less events. Env-first was
|
|
64
|
+
# a live bug: a nested subagent (Task tool) inherits the parent's SPEXCODE_SESSION_ID, so with env winning,
|
|
65
|
+
# every child tool call fired mark-active against the PARENT's record — the parent read `working` forever and
|
|
66
|
+
# every park/done declaration was clobbered within seconds (measured). Same staleness class the codex branch
|
|
67
|
+
# already guards against (payload-first below); claude now follows the same rule.
|
|
64
68
|
hp_session_id() {
|
|
69
|
+
local pid
|
|
65
70
|
case "$SPEXCODE_HARNESS" in
|
|
66
71
|
codex) hp_field "$1" session_id ;;
|
|
67
|
-
*) printf '%s' "${SPEXCODE_SESSION_ID
|
|
72
|
+
*) pid=$(hp_field "$1" session_id); printf '%s' "${pid:-$SPEXCODE_SESSION_ID}" ;;
|
|
68
73
|
esac
|
|
69
74
|
}
|
|
70
75
|
|
|
76
|
+
# is THIS payload a tool call executed by an IN-PROCESS SUBAGENT (Claude's Task tool — a sub-conversation
|
|
77
|
+
# inside the SAME harness process) rather than the session's own top-level agent? The subagent's payload
|
|
78
|
+
# carries the PARENT's session_id/transcript_path (measured live, claude 2.1.207), so hp_session_id cannot
|
|
79
|
+
# tell them apart — but the harness stamps every subagent-executed call with a top-level `agent_id`
|
|
80
|
+
# (+ `agent_type`) field that a top-level call NEVER carries. The check is structural, never a heuristic:
|
|
81
|
+
# scan ONLY the payload prefix before the first `"tool_input"` (every harness-stamped top-level field
|
|
82
|
+
# precedes tool_input), and require the key shape `"agent_id":`. Inside a JSON string value every quote is
|
|
83
|
+
# escaped (\"), so a bare-quoted `"agent_id"` followed by a colon in that prefix can only be the harness's
|
|
84
|
+
# own stamp — a tool parameter literally NAMED agent_id (an MCP tool) sits inside tool_input, past the
|
|
85
|
+
# truncation, and file/prompt content can never fake an unescaped quote. Codex payloads carry no such field
|
|
86
|
+
# (its verified field set) so this never matches there. Echoes "1" when yes, else nothing.
|
|
87
|
+
hp_is_subagent() {
|
|
88
|
+
printf '%s' "${1%%\"tool_input\"*}" | grep -q '"agent_id"[[:space:]]*:' && printf 1
|
|
89
|
+
}
|
|
90
|
+
|
|
71
91
|
# the per-PROJECT GLOBAL runtime dir (mirrors spec-cli/src/layout.ts `runtimeRoot`): <store>/projects/<enc>,
|
|
72
92
|
# keyed by the project (dirname of the ABSOLUTE git-common-dir, so the answer is identical from main or any
|
|
73
93
|
# worktree). The per-session dirs and the per-tree materialize slots (hp_tree_dir) live under it.
|
|
@@ -114,7 +134,7 @@ hp_store_dir() {
|
|
|
114
134
|
|
|
115
135
|
# the TOOLCHAIN's own version fingerprint — the toolchain side of the content key. The materialized artifacts
|
|
116
136
|
# are a function of (config content, toolchain), so a TOOLCHAIN update must move the key too, or an updated
|
|
117
|
-
# deploy never self-heals its stale contract/shims/manifest until someone happens to edit .
|
|
137
|
+
# deploy never self-heals its stale contract/shims/manifest until someone happens to edit .plugins (the field
|
|
118
138
|
# lesson: a toolchain update does NOT self-heal). A source checkout answers with the git TREE hash of the
|
|
119
139
|
# package dir (moves exactly when the toolchain's content moves, not on every repo commit); an npm install
|
|
120
140
|
# (no .git) answers with the package.json hash (npm bumps the version). env-stripped git — a git hook's
|
|
@@ -127,7 +147,7 @@ hp_toolchain_version() {
|
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
# the deterministic content fingerprint of EVERYTHING the materialize is a function of: the EDITABLE config
|
|
130
|
-
# roots (.
|
|
150
|
+
# roots (.plugins + plugin-system md/sh), the PERSISTED POLICY files (the MAIN checkout's spexcode.json +
|
|
131
151
|
# spexcode.local.json — the `harnesses` set materialize reads via readConfig(mainCheckout)), and the
|
|
132
152
|
# toolchain version above. Since the dispatch-gate retired ([[commit-surgery]] — materialize anchors on
|
|
133
153
|
# git-native events only), this is a FRESHNESS STAMP materialize records after each pass, a diagnostic
|
|
@@ -138,7 +158,7 @@ hp_config_hash() {
|
|
|
138
158
|
gcd=$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --path-format=absolute --git-common-dir 2>/dev/null) \
|
|
139
159
|
|| gcd=$(realpath "$(env -u GIT_DIR -u GIT_INDEX_FILE git rev-parse --git-common-dir 2>/dev/null)" 2>/dev/null)
|
|
140
160
|
{ hp_toolchain_version
|
|
141
|
-
find .spec/*/.
|
|
161
|
+
find .spec/*/.plugins .spec/*/plugin-system \( -name '*.md' -o -name '*.sh' \) -type f -print0 2>/dev/null \
|
|
142
162
|
| sort -z | xargs -0 cat 2>/dev/null
|
|
143
163
|
[ -n "$gcd" ] && cat "$(dirname "$gcd")/spexcode.json" "$(dirname "$gcd")/spexcode.local.json" 2>/dev/null
|
|
144
164
|
} | sha256sum | cut -d' ' -f1
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { join } from 'node:path'
|
|
2
|
+
import { createRequire } from 'node:module'
|
|
3
|
+
import { gitA, type DriftIndex, ancestorsOf, inAncestors, ackCoverFor } from './git.js'
|
|
4
|
+
|
|
5
|
+
// ---- the anchor vocabulary ([[code-anchor]]) ----
|
|
6
|
+
// A spec's `code:` entry may pin ONE named unit: `path#symbol` (`#Class.method` for a class method).
|
|
7
|
+
// Everything below the entry parse splits into two layers:
|
|
8
|
+
// - the LANGUAGE SEAM: pure extractors (content, filename) -> Unit[] — no git, no cache, no fs.
|
|
9
|
+
// Each extension maps to exactly ONE designated extractor; there is NO cross-tier fallback.
|
|
10
|
+
// - the LANGUAGE-AGNOSTIC ENGINE: blob-oid memo, anchor resolution (dead/ambiguous), diff-hunk ∩
|
|
11
|
+
// unit-range intersection over the drift window. It never knows which language it is measuring.
|
|
12
|
+
|
|
13
|
+
export type Unit = { name: string; kind: string; start: number; end: number; typeOnly?: boolean }
|
|
14
|
+
|
|
15
|
+
export type Extractor = {
|
|
16
|
+
id: string
|
|
17
|
+
claims(ext: string): boolean
|
|
18
|
+
// true = usable here. A string is WHY it cannot run — lint turns that into an ERROR with the repair
|
|
19
|
+
// entrypoint (never a silent or degraded pass): the designated extractor either runs or the anchor
|
|
20
|
+
// is unverifiable.
|
|
21
|
+
ready(): true | string
|
|
22
|
+
// PURE function of its arguments (importable by an external benchmark/scorer as-is). Throws when the
|
|
23
|
+
// content cannot be parsed — the caller maps that to a conservative verdict, never a silent skip.
|
|
24
|
+
extract(content: string, filename: string): Unit[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type CodeEntry = { path: string; anchor: string | null }
|
|
28
|
+
export function parseCodeEntry(raw: string): CodeEntry {
|
|
29
|
+
const i = raw.indexOf('#')
|
|
30
|
+
if (i < 0) return { path: raw, anchor: null }
|
|
31
|
+
return { path: raw.slice(0, i), anchor: raw.slice(i + 1).trim() || null }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---- extractor: ts-ast (the designated extractor for the JS family) ----
|
|
35
|
+
// Parse-only via the HOST project's own typescript (resolved from the repo root, walking up like any
|
|
36
|
+
// require) — never a bundled copy, so the parse matches what the project itself compiles with. Not
|
|
37
|
+
// resolvable => ready() returns the repair entrypoint and lint ERRORS (no regex fallback for JS).
|
|
38
|
+
const JS_EXTS = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'mts', 'cts'])
|
|
39
|
+
|
|
40
|
+
export function tsAstExtractor(root: string): Extractor {
|
|
41
|
+
let ts: any | null | undefined // undefined = unprobed; null = unresolvable
|
|
42
|
+
let readiness: true | string | undefined
|
|
43
|
+
const probe = () => {
|
|
44
|
+
if (ts !== undefined) return
|
|
45
|
+
try { ts = createRequire(join(root, 'package.json'))('typescript') } catch { ts = null }
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
id: 'ts-ast',
|
|
49
|
+
claims: (ext) => JS_EXTS.has(ext),
|
|
50
|
+
ready() {
|
|
51
|
+
if (readiness !== undefined) return readiness
|
|
52
|
+
probe()
|
|
53
|
+
if (!ts) return (readiness = `typescript is not resolvable from ${root} — anchors on JS-family files need the host project's typescript: run 'npm i -D typescript', or remove the #anchor`)
|
|
54
|
+
// resolvability is not usability: typescript@7 (the Go rewrite) may resolve yet not expose the JS
|
|
55
|
+
// compiler API this extractor drives. Probe the ACTUAL surface with a tiny parse — an incompatible
|
|
56
|
+
// host typescript is a loud error, never a silent pass or downgrade.
|
|
57
|
+
try {
|
|
58
|
+
const sf = ts.createSourceFile('probe.ts', 'const x = 1', ts.ScriptTarget.Latest, true, ts.ScriptKind.TS)
|
|
59
|
+
if (!sf?.statements?.length || sf.parseDiagnostics?.length) throw new Error('probe parse failed')
|
|
60
|
+
readiness = true
|
|
61
|
+
} catch {
|
|
62
|
+
readiness = `host typescript (v${ts?.version ?? 'unknown'}) resolves but its createSourceFile API is unusable (a TS7/Go build?) — pin 'npm i -D typescript@5', or remove the #anchor`
|
|
63
|
+
}
|
|
64
|
+
return readiness
|
|
65
|
+
},
|
|
66
|
+
extract(content, filename) {
|
|
67
|
+
probe()
|
|
68
|
+
if (!ts) throw new Error('ts-ast extractor is not ready (typescript unresolvable)')
|
|
69
|
+
const kind = /\.(tsx)$/.test(filename) ? ts.ScriptKind.TSX
|
|
70
|
+
: /\.(jsx)$/.test(filename) ? ts.ScriptKind.JSX
|
|
71
|
+
: /\.(ts|mts|cts)$/.test(filename) ? ts.ScriptKind.TS
|
|
72
|
+
: ts.ScriptKind.JS
|
|
73
|
+
const sf = ts.createSourceFile(filename, content, ts.ScriptTarget.Latest, true, kind)
|
|
74
|
+
// parse-only gate: a file that does not parse yields GARBAGE units (a shell script's `x=$(...)`
|
|
75
|
+
// parses as a const) — throw so the caller renders an honest "cannot parse" verdict instead.
|
|
76
|
+
if (sf.parseDiagnostics?.length) throw new Error(`${filename} does not parse as ${ts.ScriptKind[kind]} (${sf.parseDiagnostics.length} syntax error(s))`)
|
|
77
|
+
const line = (pos: number) => sf.getLineAndCharacterOfPosition(pos).line + 1
|
|
78
|
+
const units: Unit[] = []
|
|
79
|
+
const push = (name: string, ukind: string, node: any, typeOnly = false) =>
|
|
80
|
+
units.push({ name, kind: ukind, start: line(node.getStart(sf)), end: line(node.end), ...(typeOnly ? { typeOnly } : {}) })
|
|
81
|
+
for (const st of sf.statements) {
|
|
82
|
+
if (ts.isFunctionDeclaration(st)) push(st.name ? st.name.text : '(default)', 'function', st)
|
|
83
|
+
else if (ts.isClassDeclaration(st)) {
|
|
84
|
+
const cname = st.name ? st.name.text : '(default)'
|
|
85
|
+
push(cname, 'class', st)
|
|
86
|
+
for (const m of st.members) {
|
|
87
|
+
if ((ts.isMethodDeclaration(m) || ts.isConstructorDeclaration(m) || ts.isGetAccessorDeclaration(m) || ts.isSetAccessorDeclaration(m)) && m.body) {
|
|
88
|
+
const mname = ts.isConstructorDeclaration(m) ? 'constructor' : (m.name && ts.isIdentifier(m.name) ? m.name.text : '(computed)')
|
|
89
|
+
push(`${cname}.${mname}`, 'method', m)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
} else if (ts.isVariableStatement(st)) {
|
|
93
|
+
for (const d of st.declarationList.declarations) {
|
|
94
|
+
if (!ts.isIdentifier(d.name)) continue // destructuring — not anchorable by one name
|
|
95
|
+
const fn = d.initializer && (ts.isArrowFunction(d.initializer) || ts.isFunctionExpression(d.initializer))
|
|
96
|
+
// range = the whole statement (multi-declarator lines co-move; each name shares the range)
|
|
97
|
+
units.push({ name: d.name.text, kind: fn ? 'const-fn' : 'const-data', start: line(st.getStart(sf)), end: line(st.end) })
|
|
98
|
+
}
|
|
99
|
+
} else if (ts.isEnumDeclaration(st)) push(st.name.text, 'enum', st)
|
|
100
|
+
else if (ts.isInterfaceDeclaration(st)) push(st.name.text, 'interface', st, true)
|
|
101
|
+
else if (ts.isTypeAliasDeclaration(st)) push(st.name.text, 'type', st, true)
|
|
102
|
+
}
|
|
103
|
+
return units
|
|
104
|
+
},
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---- extractor: heuristic(langSpec) — a generic regex engine fed LANGUAGE DATA, not language branches ----
|
|
109
|
+
// The designated extractor for languages described by a LangSpec row; adding a language = adding a data
|
|
110
|
+
// row + a registry entry (never a new engine). The JS family is deliberately NOT routed here (its
|
|
111
|
+
// designated extractor is ts-ast above); JS_LANG_R5B below exists as the validated reference row for the
|
|
112
|
+
// engine's shape and for the external benchmark to score.
|
|
113
|
+
export type LangSpec = {
|
|
114
|
+
id: string
|
|
115
|
+
extensions: string[]
|
|
116
|
+
// column-0 declaration patterns; capture group 1 = the unit name (or the declarator list when declList)
|
|
117
|
+
decls: { re: RegExp; kind: string; typeOnly?: boolean; classOpener?: boolean; declList?: boolean }[]
|
|
118
|
+
// class-member pattern, active while inside a classOpener's balanced-bracket body (name -> Class.name)
|
|
119
|
+
member?: { re: RegExp; blacklist?: RegExp }
|
|
120
|
+
// a column-0 line matching this ENDS the previous unit (comment-aware so trailing comment blocks
|
|
121
|
+
// attach to the NEXT unit, not the previous one)
|
|
122
|
+
boundary: RegExp
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const balance = (s: string) => { let n = 0; for (const ch of s) { if ('([{'.includes(ch)) n++; else if (')]}'.includes(ch)) n-- } return n }
|
|
126
|
+
// split a declarator-list head on top-level commas: `COLS = 220, ROWS = 50` -> [COLS, ROWS]
|
|
127
|
+
function declNames(head: string): string[] {
|
|
128
|
+
let d = 0, seg = ''
|
|
129
|
+
const segs: string[] = []
|
|
130
|
+
for (const ch of head) {
|
|
131
|
+
if ('([{<'.includes(ch)) d++
|
|
132
|
+
else if (')]}>'.includes(ch)) d--
|
|
133
|
+
if (ch === ',' && d === 0) { segs.push(seg); seg = '' } else seg += ch
|
|
134
|
+
}
|
|
135
|
+
segs.push(seg)
|
|
136
|
+
return segs.map((s) => s.match(/^\s*([A-Za-z_$][\w$]*)\s*(?::|=|$)/)?.[1]).filter((x): x is string => !!x)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function heuristicExtractor(spec: LangSpec): Extractor {
|
|
140
|
+
return {
|
|
141
|
+
id: spec.id,
|
|
142
|
+
claims: (ext) => spec.extensions.includes(ext),
|
|
143
|
+
ready: () => true,
|
|
144
|
+
extract(content) {
|
|
145
|
+
const lines = content.split('\n')
|
|
146
|
+
const units: Unit[] = []
|
|
147
|
+
let cls: string | null = null, depth = 0
|
|
148
|
+
for (let i = 0; i < lines.length; i++) {
|
|
149
|
+
const l = lines[i]
|
|
150
|
+
if (cls) {
|
|
151
|
+
const m = spec.member && l.match(spec.member.re)
|
|
152
|
+
if (m && !spec.member!.blacklist?.test(m[1])) units.push({ name: `${cls}.${m[1]}`, kind: 'method', start: i + 1, end: i + 1 })
|
|
153
|
+
depth += balance(l)
|
|
154
|
+
if (depth <= 0) cls = null
|
|
155
|
+
continue
|
|
156
|
+
}
|
|
157
|
+
for (const d of spec.decls) {
|
|
158
|
+
const m = l.match(d.re)
|
|
159
|
+
if (!m) continue
|
|
160
|
+
for (const name of d.declList ? declNames(m[1]) : [m[1]])
|
|
161
|
+
units.push({ name, kind: d.kind, start: i + 1, end: i + 1, ...(d.typeOnly ? { typeOnly: true } : {}) })
|
|
162
|
+
if (d.classOpener) { cls = m[1]; depth = balance(l) }
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// R5b ranges: a unit ends before the next column-0 boundary line; a method is also capped by the
|
|
167
|
+
// next unit's start (methods sit inside their class's indentation, below boundary's radar).
|
|
168
|
+
const bset: number[] = []
|
|
169
|
+
for (let i = 0; i < lines.length; i++) if (spec.boundary.test(lines[i])) bset.push(i + 1)
|
|
170
|
+
const starts = units.map((u) => u.start).sort((a, b) => a - b)
|
|
171
|
+
for (const u of units) {
|
|
172
|
+
const nb = bset.find((b) => b > u.start)
|
|
173
|
+
let end = nb ?? lines.length + 1
|
|
174
|
+
if (u.kind === 'method') { const ns = starts.find((x) => x > u.start); if (ns && ns < end) end = ns }
|
|
175
|
+
u.end = Math.max(u.start, end - 1)
|
|
176
|
+
}
|
|
177
|
+
return units
|
|
178
|
+
},
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// The validated JS-family reference row (R5b: name precision 99.7% / recall 100% / range 98.9% on the
|
|
183
|
+
// 41-file oracle) — NOT registered for JS (ts-ast is designated); kept as the engine's reference shape
|
|
184
|
+
// and the benchmark's scoring subject.
|
|
185
|
+
export const JS_LANG_R5B: LangSpec = {
|
|
186
|
+
id: 'heuristic-js',
|
|
187
|
+
extensions: [...JS_EXTS],
|
|
188
|
+
decls: [
|
|
189
|
+
{ re: /^(?:export\s+)?(?:default\s+)?(?:async\s+)?function\*?\s+([A-Za-z_$][\w$]*)/, kind: 'function' },
|
|
190
|
+
{ re: /^(?:export\s+)?(?:abstract\s+)?class\s+([A-Za-z_$][\w$]*)/, kind: 'class', classOpener: true },
|
|
191
|
+
{ re: /^(?:export\s+)?(?:declare\s+)?enum\s+([A-Za-z_$][\w$]*)/, kind: 'enum' },
|
|
192
|
+
{ re: /^(?:export\s+)?(?:declare\s+)?interface\s+([A-Za-z_$][\w$]*)/, kind: 'interface', typeOnly: true },
|
|
193
|
+
{ re: /^(?:export\s+)?(?:declare\s+)?type\s+([A-Za-z_$][\w$]*)/, kind: 'type', typeOnly: true },
|
|
194
|
+
{ re: /^(?:export\s+)?(?:const|let|var)\s+(.+)$/, kind: 'const', declList: true },
|
|
195
|
+
],
|
|
196
|
+
member: {
|
|
197
|
+
re: /^\s+(?:(?:public|private|protected|static|readonly|async|get|set)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(/,
|
|
198
|
+
blacklist: /^(if|for|while|switch|return|catch|new|await|typeof|throw|else|do)$/,
|
|
199
|
+
},
|
|
200
|
+
boundary: /^(?:[A-Za-z_$]|\/\/|\/\*)/,
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---- registry: extension -> its ONE designated extractor ----
|
|
204
|
+
// The registry's shape is the Extractor INTERFACE, not any engine: a future language row may be a
|
|
205
|
+
// heuristicExtractor(LangSpec) or a web-tree-sitter extractor carrying its own wasm-grammar/query
|
|
206
|
+
// config — whatever the implementation needs rides inside its own factory, never in the registry.
|
|
207
|
+
export function extractors(root: string): Extractor[] {
|
|
208
|
+
return [tsAstExtractor(root)]
|
|
209
|
+
}
|
|
210
|
+
// first claiming extractor IS the designation (the registry order defines it); null = no anchor support
|
|
211
|
+
// for this language yet (lint ERRORS — the remedy is a LangSpec data row, or dropping the anchor).
|
|
212
|
+
export function extractorFor(regs: Extractor[], ext: string): Extractor | null {
|
|
213
|
+
return regs.find((x) => x.claims(ext)) ?? null
|
|
214
|
+
}
|
|
215
|
+
export const extOf = (path: string): string => {
|
|
216
|
+
const base = path.slice(path.lastIndexOf('/') + 1)
|
|
217
|
+
const dot = base.lastIndexOf('.')
|
|
218
|
+
return dot > 0 ? base.slice(dot + 1) : ''
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---- anchor resolution (language-agnostic) ----
|
|
222
|
+
export type AnchorResolution = { ok: Unit } | { dead: true } | { ambiguous: number }
|
|
223
|
+
export function resolveAnchor(units: Unit[], symbol: string): AnchorResolution {
|
|
224
|
+
const hits = units.filter((u) => u.name === symbol)
|
|
225
|
+
if (!hits.length) return { dead: true }
|
|
226
|
+
if (hits.length > 1) return { ambiguous: hits.length }
|
|
227
|
+
return { ok: hits[0] }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- the historical hit engine (language-agnostic; batch short-lived git, no resident process) ----
|
|
231
|
+
|
|
232
|
+
// units of a file AS OF a commit, memoized by (blob oid, extractor id) — a blob is immutable, so the
|
|
233
|
+
// memo never invalidates; distinct file versions in a window are few. 'absent' = no blob at that commit;
|
|
234
|
+
// 'unparseable' = the extractor rejected that version's content (the caller treats it conservatively).
|
|
235
|
+
type BlobUnits = { units: Unit[] } | { absent: true } | { unparseable: string }
|
|
236
|
+
const unitMemo = new Map<string, BlobUnits>()
|
|
237
|
+
const MEMO_MAX = 4096
|
|
238
|
+
async function unitsAt(root: string, commit: string, path: string, x: Extractor): Promise<BlobUnits> {
|
|
239
|
+
const oid = (await gitA(['-C', root, 'rev-parse', `${commit}:${path}`])).trim()
|
|
240
|
+
if (!oid) return { absent: true }
|
|
241
|
+
const key = `${oid}\0${x.id}`
|
|
242
|
+
const hit = unitMemo.get(key)
|
|
243
|
+
if (hit) return hit
|
|
244
|
+
const text = await gitA(['-C', root, 'cat-file', 'blob', oid])
|
|
245
|
+
let v: BlobUnits
|
|
246
|
+
try { v = { units: x.extract(text, path) } } catch (e: any) { v = { unparseable: e?.message ?? String(e) } }
|
|
247
|
+
if (unitMemo.size >= MEMO_MAX) unitMemo.clear()
|
|
248
|
+
unitMemo.set(key, v)
|
|
249
|
+
return v
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// post-image line ranges of one commit's diff to one file (`@@ -a,b +c,d @@`, --unified=0). d>0 → lines
|
|
253
|
+
// c..c+d-1 changed; d==0 (pure deletion) → the point line after which content vanished. Immutable per
|
|
254
|
+
// (commit, file), memoized.
|
|
255
|
+
const hunkMemo = new Map<string, [number, number][]>()
|
|
256
|
+
async function hunksAt(root: string, commit: string, path: string): Promise<[number, number][]> {
|
|
257
|
+
const key = `${commit}\0${path}`
|
|
258
|
+
const hit = hunkMemo.get(key)
|
|
259
|
+
if (hit) return hit
|
|
260
|
+
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '--unified=0', '--format=', commit, '--', path])
|
|
261
|
+
const ranges: [number, number][] = []
|
|
262
|
+
for (const m of out.matchAll(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/gm)) {
|
|
263
|
+
const c = +m[1], d = m[2] === undefined ? 1 : +m[2]
|
|
264
|
+
ranges.push(d > 0 ? [c, c + d - 1] : [Math.max(1, c), Math.max(1, c)])
|
|
265
|
+
}
|
|
266
|
+
if (hunkMemo.size >= MEMO_MAX) hunkMemo.clear()
|
|
267
|
+
hunkMemo.set(key, ranges)
|
|
268
|
+
return ranges
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// the drift window of an anchored file: every commit to `path` not reachable from the spec's version
|
|
272
|
+
// and not covered by a valid Spec-OK ack — the SAME set driftFor counts, exposed as commits so the
|
|
273
|
+
// anchor engine can probe each one. (fileCommits comes from a --name-only walk, which lists no files
|
|
274
|
+
// for merge commits — the window is non-merge by construction.)
|
|
275
|
+
export function windowCommits(idx: DriftIndex, sinceHash: string, path: string): string[] {
|
|
276
|
+
if (!sinceHash) return []
|
|
277
|
+
const base = ancestorsOf(idx, sinceHash)
|
|
278
|
+
if (!base) return []
|
|
279
|
+
const cover = ackCoverFor(idx, sinceHash)
|
|
280
|
+
return (idx.fileCommits.get(path) ?? []).filter((h) => !inAncestors(idx, base, h) && !cover.some((a) => inAncestors(idx, a, h)))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// which window commits TOUCHED the anchored unit: the commit's --unified=0 hunks intersect the unit's
|
|
284
|
+
// line range extracted from the file AS IT EXISTED AT THAT COMMIT (never from HEAD — units later renamed
|
|
285
|
+
// or moved still attribute correctly). A version whose content the extractor cannot parse is a
|
|
286
|
+
// CONSERVATIVE hit (`unparseable` set) — over-warn, never silently skip.
|
|
287
|
+
export type AnchorHit = { commit: string; unparseable?: string }
|
|
288
|
+
export async function anchorHitCommits(root: string, win: string[], path: string, symbol: string, x: Extractor): Promise<AnchorHit[]> {
|
|
289
|
+
const hits: AnchorHit[] = []
|
|
290
|
+
for (const c of win) {
|
|
291
|
+
const at = await unitsAt(root, c, path, x)
|
|
292
|
+
if ('absent' in at) continue // file not in that commit's tree — nothing of the anchor to touch
|
|
293
|
+
if ('unparseable' in at) { hits.push({ commit: c, unparseable: at.unparseable }); continue }
|
|
294
|
+
const ranges = at.units.filter((u) => u.name === symbol)
|
|
295
|
+
if (!ranges.length) continue // unit didn't exist under this name at that commit
|
|
296
|
+
const hunks = await hunksAt(root, c, path)
|
|
297
|
+
if (hunks.some(([a, b]) => ranges.some((u) => a <= u.end && u.start <= b))) hits.push({ commit: c })
|
|
298
|
+
}
|
|
299
|
+
return hits
|
|
300
|
+
}
|