spexcode 0.1.6 → 0.2.1
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 +99 -35
- package/README.zh-CN.md +135 -0
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +184 -122
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +64 -24
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +186 -19
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +362 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +279 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +301 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +700 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/gateway.ts
CHANGED
|
@@ -8,11 +8,13 @@ import https from 'node:https'
|
|
|
8
8
|
import net from 'node:net'
|
|
9
9
|
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
10
10
|
import { execFileSync, spawnSync } from 'node:child_process'
|
|
11
|
-
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
11
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs'
|
|
12
|
+
import { gzipSync, createGzip } from 'node:zlib'
|
|
12
13
|
import { join, normalize, extname } from 'node:path'
|
|
13
14
|
import { fileURLToPath } from 'node:url'
|
|
14
15
|
import { homedir } from 'node:os'
|
|
15
16
|
import { loginPage } from './login-page.js'
|
|
17
|
+
import { listenOrExit } from './listen.js'
|
|
16
18
|
|
|
17
19
|
// @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
|
|
18
20
|
// chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
|
|
@@ -117,7 +119,7 @@ export function resolveDistDir(): string {
|
|
|
117
119
|
return join(pkgRoot, '..', 'spec-dashboard', 'dist')
|
|
118
120
|
}
|
|
119
121
|
|
|
120
|
-
export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string }
|
|
122
|
+
export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string; onBindFail?: () => void }
|
|
121
123
|
|
|
122
124
|
export function startGateway(opts: GatewayOpts): void {
|
|
123
125
|
// gated ONLY when a password is set; otherwise the login layer doesn't exist and the dashboard is served open.
|
|
@@ -144,10 +146,18 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
144
146
|
}
|
|
145
147
|
}
|
|
146
148
|
if (url.startsWith('/api')) return proxyHttp(req, res, opts.upstreamPort)
|
|
147
|
-
return serveStatic(res, opts.distDir, url)
|
|
149
|
+
return serveStatic(req, res, opts.distDir, url)
|
|
148
150
|
}
|
|
149
151
|
|
|
150
|
-
|
|
152
|
+
// server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
|
|
153
|
+
// public server in public mode, so it carries the SAME reaping as the child: an abandoned/slow connection
|
|
154
|
+
// dies here instead of piling up. Idle keep-alive / slow-header / never-completing request only; the gated
|
|
155
|
+
// WS upgrade (handled below) is an active connection, not reaped by these. Set AT CONSTRUCTION so the
|
|
156
|
+
// connection-checking sweep is armed with our cadence (a post-hoc set leaves it under-effective).
|
|
157
|
+
const reap = { keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000 }
|
|
158
|
+
const server = secure
|
|
159
|
+
? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key, ...reap }, handler)
|
|
160
|
+
: http.createServer(reap, handler)
|
|
151
161
|
|
|
152
162
|
// @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
|
|
153
163
|
// it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
|
|
@@ -163,18 +173,21 @@ export function startGateway(opts: GatewayOpts): void {
|
|
|
163
173
|
socket.on('error', bail); up.on('error', bail)
|
|
164
174
|
})
|
|
165
175
|
|
|
166
|
-
// `spex dashboard` passes an explicit loopback host; `--public` passes
|
|
167
|
-
// original behaviour, IPv4+IPv6), so adding the local path never
|
|
168
|
-
|
|
176
|
+
// `spex dashboard` passes an explicit host (loopback by default, --host to widen); `--public` passes
|
|
177
|
+
// none → bind ALL interfaces (the original behaviour, IPv4+IPv6), so adding the local path never
|
|
178
|
+
// narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
|
|
179
|
+
// an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
|
|
180
|
+
const isLoopback = opts.host === '127.0.0.1' || opts.host === 'localhost' || opts.host === '::1'
|
|
169
181
|
const onListen = () => {
|
|
170
182
|
const scheme = secure ? 'https' : 'http'
|
|
171
183
|
const label = opts.label ?? 'public mode'
|
|
172
|
-
const gate =
|
|
173
|
-
console.log(`[gateway] ${label} on ${scheme}://${
|
|
174
|
-
if (!secure && !
|
|
184
|
+
const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`
|
|
185
|
+
console.log(`[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`)
|
|
186
|
+
if (!secure && !isLoopback && !opts.host) console.log('[gateway] (TLS off — --http)')
|
|
175
187
|
}
|
|
176
|
-
|
|
177
|
-
|
|
188
|
+
// a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
|
|
189
|
+
// (see [[spec-cli]] / listen.ts), so `spex serve` and `spex dashboard` fail a port clash identically.
|
|
190
|
+
listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen })
|
|
178
191
|
}
|
|
179
192
|
|
|
180
193
|
function rawHeaders(req: http.IncomingMessage): string {
|
|
@@ -194,26 +207,51 @@ function doLogin(req: http.IncomingMessage, res: http.ServerResponse, password:
|
|
|
194
207
|
})
|
|
195
208
|
}
|
|
196
209
|
|
|
197
|
-
//
|
|
210
|
+
// @@@ gzip at the gateway - compression is TRANSPORT, so it lives here, once, for every deployment — the
|
|
211
|
+
// loopback upstream and the product semantics never know it exists. Text-ish payloads only; three
|
|
212
|
+
// structural exclusions, each load-bearing: an SSE stream must not sit in a zlib buffer (event latency),
|
|
213
|
+
// an already-encoded response is not re-encoded, and binary media (video/image evidence) gains nothing
|
|
214
|
+
// and would fight Range requests.
|
|
215
|
+
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/
|
|
216
|
+
const wantsGzip = (req: http.IncomingMessage) => /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))
|
|
217
|
+
|
|
218
|
+
// reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
|
|
219
|
+
// stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
|
|
198
220
|
function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number) {
|
|
199
221
|
const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: req.url, method: req.method, headers: req.headers }, (upRes) => {
|
|
200
|
-
|
|
201
|
-
upRes.
|
|
222
|
+
const type = String(upRes.headers['content-type'] || '')
|
|
223
|
+
const skip = !wantsGzip(req) || upRes.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream')
|
|
224
|
+
if (skip) { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); return }
|
|
225
|
+
const headers = { ...upRes.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }
|
|
226
|
+
delete headers['content-length'] // streamed; the encoded length isn't knowable up front
|
|
227
|
+
res.writeHead(upRes.statusCode || 502, headers)
|
|
228
|
+
upRes.pipe(createGzip()).pipe(res)
|
|
202
229
|
})
|
|
203
230
|
up.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('upstream unreachable') })
|
|
204
231
|
req.pipe(up)
|
|
205
232
|
}
|
|
206
233
|
|
|
207
234
|
// serve the built dashboard (vite dist). Unknown non-file paths fall back to index.html (SPA). Path
|
|
208
|
-
// traversal is blocked by normalising and confining to distDir.
|
|
209
|
-
|
|
235
|
+
// traversal is blocked by normalising and confining to distDir. Compressible files ship gzipped, memoized
|
|
236
|
+
// per (path, mtime) — a dist file is immutable per build, so each is compressed once, not per request.
|
|
237
|
+
const gzMemo = new Map<string, { mtime: number; gz: Buffer }>()
|
|
238
|
+
function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, distDir: string, urlPath: string) {
|
|
210
239
|
const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '')
|
|
211
240
|
let file = join(distDir, rel)
|
|
212
241
|
if (!file.startsWith(distDir)) file = join(distDir, 'index.html')
|
|
213
242
|
if (urlPath === '/' || !existsSync(file)) file = join(distDir, 'index.html')
|
|
214
243
|
if (!existsSync(file)) { res.writeHead(503); return res.end('dashboard build missing') }
|
|
215
|
-
|
|
216
|
-
|
|
244
|
+
const type = MIME[extname(file)] || 'application/octet-stream'
|
|
245
|
+
const raw = readFileSync(file)
|
|
246
|
+
if (wantsGzip(req) && COMPRESSIBLE.test(type)) {
|
|
247
|
+
const mtime = statSync(file).mtimeMs
|
|
248
|
+
let hit = gzMemo.get(file)
|
|
249
|
+
if (!hit || hit.mtime !== mtime) { hit = { mtime, gz: gzipSync(raw) }; gzMemo.set(file, hit) }
|
|
250
|
+
res.writeHead(200, { 'Content-Type': type, 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' })
|
|
251
|
+
return res.end(hit.gz)
|
|
252
|
+
}
|
|
253
|
+
res.writeHead(200, { 'Content-Type': type })
|
|
254
|
+
res.end(raw)
|
|
217
255
|
}
|
|
218
256
|
|
|
219
257
|
function sendHtml(res: http.ServerResponse, status: number, html: string) {
|
|
@@ -234,13 +272,15 @@ export function ensureDashboardBuilt(repoRoot: string, distDir: string): void {
|
|
|
234
272
|
}
|
|
235
273
|
|
|
236
274
|
// @@@ serveDashboardLocal - the engine behind `spex dashboard`: the SAME gateway as public mode, bound to
|
|
237
|
-
// loopback with no TLS and no password
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
275
|
+
// loopback by default with no TLS and no password — `--host` widens the bind to a chosen interface
|
|
276
|
+
// (LAN/tailnet viewing) while staying plain HTTP; the internet face remains `spex serve --public`.
|
|
277
|
+
// It serves the bundled dist and proxies /api + the terminal socket to a separately-run `spex serve`.
|
|
278
|
+
// This is the post-install replacement for the dogfood-only `npm run web` (a vite dev server an
|
|
279
|
+
// installed user has no source tree for). See [[packaging]].
|
|
280
|
+
export function serveDashboardLocal(opts: { port: number; apiPort: number; host?: string }): void {
|
|
241
281
|
const pkgRoot = fileURLToPath(new URL('..', import.meta.url))
|
|
242
282
|
const distDir = resolveDistDir()
|
|
243
283
|
ensureDashboardBuilt(join(pkgRoot, '..'), distDir) // bundled dist already has index.html → returns at once
|
|
244
284
|
console.log(`[dashboard] serving ${distDir.endsWith('dashboard-dist') ? 'bundled' : 'monorepo'} build, /api → backend :${opts.apiPort}`)
|
|
245
|
-
startGateway({ host: '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard' })
|
|
285
|
+
startGateway({ host: opts.host ?? '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard' })
|
|
246
286
|
}
|
package/spec-cli/src/git.ts
CHANGED
|
@@ -113,41 +113,6 @@ export function worktreeSpecSig(wtPath: string): string {
|
|
|
113
113
|
export type Version = { hash: string; date: string; reason: string; session: string | null }
|
|
114
114
|
export type DiffStat = { additions: number; deletions: number; files: number }
|
|
115
115
|
|
|
116
|
-
// per-commit numstat for one file, rename-followed; a pure rename is 0/0 so callers tell "moved" from
|
|
117
|
-
// "changed". The per-file path for files outside `.spec` (`.spec` goes through the bulk index below).
|
|
118
|
-
function fileStatsFollow(root: string, relPath: string): Map<string, DiffStat> {
|
|
119
|
-
const m = new Map<string, DiffStat>()
|
|
120
|
-
let out = ''
|
|
121
|
-
try {
|
|
122
|
-
out = git(['-C', root, 'log', '--follow', '--format=%H', '--numstat', '--', relPath])
|
|
123
|
-
} catch {
|
|
124
|
-
return m
|
|
125
|
-
}
|
|
126
|
-
let cur = ''
|
|
127
|
-
for (const line of out.split('\n')) {
|
|
128
|
-
const t = line.trim()
|
|
129
|
-
if (/^[0-9a-f]{7,40}$/.test(t)) { cur = t; if (!m.has(cur)) m.set(cur, { additions: 0, deletions: 0, files: 0 }); continue }
|
|
130
|
-
const n = line.match(/^(\d+|-)\t(\d+|-)\t/)
|
|
131
|
-
if (n && cur) { const s = m.get(cur)!; s.files++; s.additions += n[1] === '-' ? 0 : +n[1]; s.deletions += n[2] === '-' ? 0 : +n[2] }
|
|
132
|
-
}
|
|
133
|
-
return m
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function historyFollow(root: string, relPath: string): Version[] {
|
|
137
|
-
let out = ''
|
|
138
|
-
try {
|
|
139
|
-
out = git(['-C', root, 'log', `--format=%H${US}%aI${US}%s${US}%b${RS}`, '--follow', '--', relPath])
|
|
140
|
-
} catch {
|
|
141
|
-
return []
|
|
142
|
-
}
|
|
143
|
-
const stats = fileStatsFollow(root, relPath)
|
|
144
|
-
return out.split(RS).map((r) => r.trim()).filter(Boolean).map((rec) => {
|
|
145
|
-
const [hash, date, reason, body = ''] = rec.split(US)
|
|
146
|
-
const m = body.match(/Session:\s*(\S+)/)
|
|
147
|
-
return { hash, date, reason, session: m ? m[1] : null }
|
|
148
|
-
}).filter((v) => { const s = stats.get(v.hash); return s != null && s.additions + s.deletions > 0 })
|
|
149
|
-
}
|
|
150
|
-
|
|
151
116
|
// ---- bulk spec history index ----
|
|
152
117
|
|
|
153
118
|
export type HistoryIndex = {
|
|
@@ -174,14 +139,35 @@ function parseStatPath(token: string): { from: string; to: string } {
|
|
|
174
139
|
return { from: token, to: token }
|
|
175
140
|
}
|
|
176
141
|
|
|
177
|
-
|
|
142
|
+
// Both bulk indices are pure functions of a checkout's HEAD, and they are read for SEVERAL roots at
|
|
143
|
+
// once — the backend checkout (board, loadSpecs) plus every session worktree ([[review-proof]]'s eval
|
|
144
|
+
// tab roots its readings at the session's branch). A single-slot cache thrashes between those roots:
|
|
145
|
+
// each eval-tab request evicts the board's entry and vice versa, so every request re-runs a full-history
|
|
146
|
+
// `git log` and re-parses it on the event loop — which is what starves every other request (the board,
|
|
147
|
+
// remark posts) under load. So the cache is a small LRU keyed by HEAD (same head ⇒ same index, whatever
|
|
148
|
+
// the root), holding the in-flight PROMISE so concurrent requests for one head share a single build.
|
|
149
|
+
const INDEX_SLOTS = 16
|
|
150
|
+
function lruGet<V>(m: Map<string, V>, k: string): V | undefined {
|
|
151
|
+
const v = m.get(k)
|
|
152
|
+
if (v !== undefined) { m.delete(k); m.set(k, v) } // refresh recency
|
|
153
|
+
return v
|
|
154
|
+
}
|
|
155
|
+
function lruPut<V>(m: Map<string, V>, k: string, v: V): void {
|
|
156
|
+
m.set(k, v)
|
|
157
|
+
while (m.size > INDEX_SLOTS) m.delete(m.keys().next().value!)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const indexCache = new Map<string, Promise<HistoryIndex>>()
|
|
178
161
|
|
|
179
|
-
export
|
|
162
|
+
export function historyIndex(root: string): Promise<HistoryIndex> {
|
|
180
163
|
const head = headOrEmpty(root)
|
|
181
|
-
if (
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
|
|
164
|
+
if (!head) return buildIndex(root)
|
|
165
|
+
const hit = lruGet(indexCache, head)
|
|
166
|
+
if (hit) return hit
|
|
167
|
+
const p = buildIndex(root)
|
|
168
|
+
p.catch(() => { indexCache.delete(head) }) // don't pin a failed build
|
|
169
|
+
lruPut(indexCache, head, p)
|
|
170
|
+
return p
|
|
185
171
|
}
|
|
186
172
|
|
|
187
173
|
// resolve HEAD for cache-keying, '' if unreadable (fails the cache test → recompute); warns once.
|
|
@@ -232,9 +218,6 @@ async function buildIndex(root: string): Promise<HistoryIndex> {
|
|
|
232
218
|
return { versions, stats }
|
|
233
219
|
}
|
|
234
220
|
|
|
235
|
-
// reset the cache when a process knows HEAD will have moved out from under it (tests, hooks).
|
|
236
|
-
export function resetHistoryCache(): void { indexCache = null }
|
|
237
|
-
|
|
238
221
|
// pure lookups over a prebuilt index (no git). rowsFor drops pure-rename rows (0/0) so a move isn't a version.
|
|
239
222
|
export function rowsFor(idx: HistoryIndex, relPath: string): Version[] {
|
|
240
223
|
const rows = idx.versions.get(relPath) ?? []
|
|
@@ -266,18 +249,6 @@ export async function pathsStats(root: string, paths: string[]): Promise<Map<str
|
|
|
266
249
|
return m
|
|
267
250
|
}
|
|
268
251
|
|
|
269
|
-
// a file's version timeline: `.spec` from the bulk index, anything else (governed code) via per-file --follow.
|
|
270
|
-
export async function history(root: string, relPath: string): Promise<Version[]> {
|
|
271
|
-
if (relPath.startsWith('.spec/')) return rowsFor(await historyIndex(root), relPath)
|
|
272
|
-
return historyFollow(root, relPath)
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// per-commit stat for this node's spec.md (rename-followed), summed with governed-code stats by specHistory.
|
|
276
|
-
export async function specStats(root: string, relPath: string): Promise<Map<string, DiffStat>> {
|
|
277
|
-
if (relPath.startsWith('.spec/')) return statsFor(await historyIndex(root), relPath)
|
|
278
|
-
return fileStatsFollow(root, relPath)
|
|
279
|
-
}
|
|
280
|
-
|
|
281
252
|
// the patch a spec.md got in one commit (vs parent); resolve its path AT that commit (reparents move it)
|
|
282
253
|
// via the stable leaf dir `…/<id>/spec.md`, then `git show` that path. `-M` keeps a rename+edit's body. '' on error.
|
|
283
254
|
export async function fileDiffAt(root: string, relPath: string, hash: string): Promise<string> {
|
|
@@ -288,35 +259,44 @@ export async function fileDiffAt(root: string, relPath: string, hash: string): P
|
|
|
288
259
|
return gitA(['-C', root, '-c', 'core.quotePath=false', 'show', '-M', '--format=', hash, '--', at])
|
|
289
260
|
}
|
|
290
261
|
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
262
|
+
// A cached `git log` over HEAD (HEAD-keyed like historyIndex), enriched with parent edges so "newer than
|
|
263
|
+
// the spec" is answered by true DAG reachability, never by a log-position/date compare (a linear
|
|
264
|
+
// order can't encode a branching history's partial order and silently under-reports — back-dated
|
|
265
|
+
// branches, adoption). driftFor()/ancestorsOf() are then pure in-memory lookups. `acks`/`specNodes`
|
|
266
|
+
// carry the Spec-OK convention (see driftFor): acks[hash] = node ids declared still-valid via
|
|
267
|
+
// `Spec-OK:` trailers; specNodes[hash] = node ids whose spec.md it touched.
|
|
295
268
|
export type DriftIndex = {
|
|
296
|
-
|
|
269
|
+
ord: Map<string, number> // hash -> dense id from the walk: a bitset slot, NEVER an order to compare
|
|
270
|
+
parents: Map<string, string[]> // hash -> parent hashes (the DAG edges, from the same walk)
|
|
297
271
|
fileCommits: Map<string, string[]>
|
|
298
272
|
acks: Map<string, Set<string>> // commit hash -> node ids acknowledged via `Spec-OK:` trailers
|
|
299
273
|
specNodes: Map<string, Set<string>> // commit hash -> node ids whose spec.md it touched (its versions)
|
|
274
|
+
anc: Map<string, Uint8Array> // memoized reachability bitsets, lazily built per queried sha
|
|
300
275
|
}
|
|
301
|
-
|
|
276
|
+
const driftIdxCache = new Map<string, Promise<DriftIndex>>() // HEAD-keyed LRU, same shape as indexCache above
|
|
302
277
|
|
|
303
278
|
async function buildDriftIndex(root: string): Promise<DriftIndex> {
|
|
304
|
-
const
|
|
279
|
+
const ord = new Map<string, number>(), parents = new Map<string, string[]>()
|
|
280
|
+
const fileCommits = new Map<string, string[]>()
|
|
305
281
|
const acks = new Map<string, Set<string>>(), specNodes = new Map<string, Set<string>>()
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
// file
|
|
282
|
+
const idx: DriftIndex = { ord, parents, fileCommits, acks, specNodes, anc: new Map() }
|
|
283
|
+
// RS-delimited records: `<hash>US<parents>US<comma-joined Spec-OK values>` on line 1, then the
|
|
284
|
+
// --name-only file list. `valueonly,separator` collapses the trailer block to one line so it never
|
|
285
|
+
// collides with the file names below it (a raw `%b` body would interleave and be unparseable).
|
|
309
286
|
const out = await gitA(['-C', root, '-c', 'core.quotePath=false', 'log', '--name-only',
|
|
310
|
-
`--format=${RS}%H${US}%(trailers:key=Spec-OK,valueonly,separator=%x2C)`, 'HEAD'])
|
|
311
|
-
if (!out) return
|
|
287
|
+
`--format=${RS}%H${US}%P${US}%(trailers:key=Spec-OK,valueonly,separator=%x2C)`, 'HEAD'])
|
|
288
|
+
if (!out) return idx
|
|
312
289
|
let i = 0
|
|
313
290
|
for (const rec of out.split(RS)) {
|
|
314
291
|
const r = rec.replace(/^\n/, '')
|
|
315
292
|
if (!r) continue
|
|
316
293
|
const lines = r.split('\n')
|
|
317
|
-
const [hash, ackStr = ''] = lines[0].split(US)
|
|
294
|
+
const [hash, parentStr = '', ackStr = ''] = lines[0].split(US)
|
|
318
295
|
if (!hash) continue
|
|
319
|
-
if (!
|
|
296
|
+
if (!ord.has(hash)) {
|
|
297
|
+
ord.set(hash, i++)
|
|
298
|
+
parents.set(hash, parentStr.split(' ').filter(Boolean))
|
|
299
|
+
}
|
|
320
300
|
const ackSet = new Set(ackStr.split(',').map((s) => s.trim()).filter(Boolean))
|
|
321
301
|
if (ackSet.size) acks.set(hash, ackSet)
|
|
322
302
|
for (const line of lines.slice(1)) {
|
|
@@ -329,42 +309,75 @@ async function buildDriftIndex(root: string): Promise<DriftIndex> {
|
|
|
329
309
|
}
|
|
330
310
|
}
|
|
331
311
|
}
|
|
332
|
-
return
|
|
312
|
+
return idx
|
|
333
313
|
}
|
|
334
|
-
export
|
|
314
|
+
export function driftIndex(root: string): Promise<DriftIndex> {
|
|
335
315
|
const head = headOrEmpty(root) // filesystem HEAD, no subprocess — see historyIndex
|
|
336
|
-
if (
|
|
337
|
-
const
|
|
338
|
-
if (
|
|
339
|
-
|
|
316
|
+
if (!head) return buildDriftIndex(root)
|
|
317
|
+
const hit = lruGet(driftIdxCache, head)
|
|
318
|
+
if (hit) return hit
|
|
319
|
+
const p = buildDriftIndex(root)
|
|
320
|
+
p.catch(() => { driftIdxCache.delete(head) })
|
|
321
|
+
lruPut(driftIdxCache, head, p)
|
|
322
|
+
return p
|
|
323
|
+
}
|
|
324
|
+
// the reachability set of `sha` — itself plus every ancestor — as a bitset over the walk's dense ids.
|
|
325
|
+
// Built once per queried sha by following parent edges in memory (no git fork), memoized on the index;
|
|
326
|
+
// a bitset costs history-length BITS, so hundreds of cached shas stay cheap on the board hot path.
|
|
327
|
+
// undefined when `sha` is not reachable from HEAD (rebased away, an unmerged branch, or never on any
|
|
328
|
+
// ref) — callers apply their own conservative rule to that "can't prove" case.
|
|
329
|
+
export function ancestorsOf(idx: DriftIndex, sha: string): Uint8Array | undefined {
|
|
330
|
+
const hit = idx.anc.get(sha)
|
|
331
|
+
if (hit) return hit
|
|
332
|
+
const start = idx.ord.get(sha)
|
|
333
|
+
if (start === undefined) return undefined
|
|
334
|
+
const bits = new Uint8Array((idx.ord.size + 7) >> 3)
|
|
335
|
+
bits[start >> 3] |= 1 << (start & 7)
|
|
336
|
+
const stack = [sha]
|
|
337
|
+
while (stack.length) {
|
|
338
|
+
for (const p of idx.parents.get(stack.pop()!) ?? []) {
|
|
339
|
+
const o = idx.ord.get(p)
|
|
340
|
+
if (o === undefined) continue // shallow-clone boundary: an unwalked parent ends the chain
|
|
341
|
+
const m = 1 << (o & 7)
|
|
342
|
+
if (bits[o >> 3] & m) continue
|
|
343
|
+
bits[o >> 3] |= m
|
|
344
|
+
stack.push(p)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
idx.anc.set(sha, bits)
|
|
348
|
+
return bits
|
|
340
349
|
}
|
|
341
|
-
|
|
342
|
-
|
|
350
|
+
export function inAncestors(idx: DriftIndex, bits: Uint8Array, sha: string): boolean {
|
|
351
|
+
const o = idx.ord.get(sha)
|
|
352
|
+
return o !== undefined && (bits[o >> 3] & (1 << (o & 7))) !== 0
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// pure lookup, no git: a commit to `path` is drift iff it is NOT an ancestor of `sinceHash` — it lies
|
|
356
|
+
// in `sinceHash..HEAD` by true DAG reachability, wherever a date-ordered log happens to place it.
|
|
343
357
|
//
|
|
344
358
|
// `sinceHash` is the node's OWN latest version commit, so the node(s) it's a version of
|
|
345
|
-
// (specNodes[sinceHash]) name the node being measured; an ack counts only if its `Spec-OK:` set names
|
|
346
|
-
// of those — `Spec-OK: A` quiets A's drift, never B's.
|
|
359
|
+
// (specNodes[sinceHash]) name the node being measured; an ack counts only if its `Spec-OK:` set names
|
|
360
|
+
// one of those — `Spec-OK: A` quiets A's drift, never B's. An ack that is itself an ancestor of the
|
|
361
|
+
// version can't speak for it (a re-version invalidates older acks); a valid ack quiets exactly the
|
|
362
|
+
// commits reachable from it. An off-history `sinceHash` → 0: no basis on HEAD to measure from.
|
|
347
363
|
export function driftFor(idx: DriftIndex, sinceHash: string, path: string): number {
|
|
348
364
|
if (!sinceHash) return 0
|
|
349
|
-
const
|
|
350
|
-
if (
|
|
365
|
+
const base = ancestorsOf(idx, sinceHash)
|
|
366
|
+
if (!base) return 0
|
|
351
367
|
const targets = idx.specNodes.get(sinceHash)
|
|
352
|
-
|
|
353
|
-
// drift is acknowledged. No target / no matching ack → Infinity, so nothing is quieted. An ack at or
|
|
354
|
-
// before the version (p >= sp) can't speak for it — a re-version invalidates older acks.
|
|
355
|
-
let ackPos = Infinity
|
|
368
|
+
const ackCover: Uint8Array[] = []
|
|
356
369
|
if (targets) {
|
|
357
370
|
for (const [h, ackSet] of idx.acks) {
|
|
358
|
-
|
|
359
|
-
if (
|
|
360
|
-
|
|
371
|
+
if (inAncestors(idx, base, h)) continue
|
|
372
|
+
if (![...targets].some((t) => ackSet.has(t))) continue
|
|
373
|
+
const a = ancestorsOf(idx, h)
|
|
374
|
+
if (a) ackCover.push(a)
|
|
361
375
|
}
|
|
362
376
|
}
|
|
363
377
|
let n = 0
|
|
364
378
|
for (const h of idx.fileCommits.get(path) ?? []) {
|
|
365
|
-
|
|
366
|
-
if (
|
|
367
|
-
if (p >= ackPos) continue // at or below the newest covering ack → acknowledged
|
|
379
|
+
if (inAncestors(idx, base, h)) continue // reachable from the version → not drift
|
|
380
|
+
if (ackCover.some((a) => inAncestors(idx, a, h))) continue // covered by an ack → acknowledged
|
|
368
381
|
n++
|
|
369
382
|
}
|
|
370
383
|
return n
|