spexcode 0.5.2 → 0.5.3
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 +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +13 -3
- package/spec-cli/src/harness.ts +946 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +706 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +49 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +99 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1249 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +26 -6
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
|
@@ -1,14 +1,45 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
|
-
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
3
4
|
import { dirname, join, relative } from 'node:path'
|
|
4
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
batchBlobTexts,
|
|
7
|
+
batchRevisionOids,
|
|
8
|
+
git,
|
|
9
|
+
gitA,
|
|
10
|
+
gitTry,
|
|
11
|
+
pathRangeEvents,
|
|
12
|
+
repoRoot,
|
|
13
|
+
driftIndex,
|
|
14
|
+
historyIndex,
|
|
15
|
+
treeTextFiles,
|
|
16
|
+
type DriftIndex,
|
|
17
|
+
type DriftPathEvent,
|
|
18
|
+
type ReviewDiffFile,
|
|
19
|
+
} from '../../spec-cli/src/git.js'
|
|
5
20
|
import { loadSpecs } from '../../spec-cli/src/specs.js'
|
|
6
21
|
import { mainBranch } from '../../spec-cli/src/layout.js'
|
|
7
22
|
import { reviewPayload } from '../../spec-cli/src/sessions.js'
|
|
8
23
|
import { loadEvalRemarkTracks } from '../../spec-cli/src/issues.js'
|
|
24
|
+
import {
|
|
25
|
+
anchorHitCommits,
|
|
26
|
+
diffHunkRanges,
|
|
27
|
+
extOf,
|
|
28
|
+
extractorFor,
|
|
29
|
+
extractors,
|
|
30
|
+
parseRelation,
|
|
31
|
+
relationClaimsPath,
|
|
32
|
+
resolveAnchor,
|
|
33
|
+
selectorsHitRanges,
|
|
34
|
+
type AnchorHit,
|
|
35
|
+
type Extractor,
|
|
36
|
+
type RelationEntry,
|
|
37
|
+
type Unit,
|
|
38
|
+
} from '../../spec-cli/src/anchors.js'
|
|
9
39
|
import { evalTimeline, evalContext, readBlobByHash, type EvalEntry, type EvalTimeline, type ScenarioInfo } from './evaltab.js'
|
|
10
40
|
import { isUiPath } from './cli.js'
|
|
11
|
-
import {
|
|
41
|
+
import { readReadings } from './sidecar.js'
|
|
42
|
+
import { parseScenarios, scenarioCodeAxis, scenarioHash, type Scenario } from './scenarios.js'
|
|
12
43
|
|
|
13
44
|
// ---- the model ----
|
|
14
45
|
|
|
@@ -17,76 +48,640 @@ type ScoreState = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty' | null
|
|
|
17
48
|
export type ScenarioImpactReason = 'code' | 'contract' | 'measurement'
|
|
18
49
|
export type SessionScenarioInfo = ScenarioInfo & { impact: ScenarioImpactReason[] }
|
|
19
50
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
51
|
+
export type SessionImpactSelectorHit = AnchorHit & { path: string }
|
|
52
|
+
export type SessionImpactScenarioDelta = {
|
|
53
|
+
kind: 'added' | 'removed' | 'modified' | 'unchanged'
|
|
54
|
+
semantic: boolean
|
|
55
|
+
metadata: boolean
|
|
56
|
+
metadataOnly: boolean
|
|
57
|
+
}
|
|
58
|
+
export type SessionImpactScenario = {
|
|
59
|
+
name: string
|
|
60
|
+
state: 'added' | 'removed' | 'present'
|
|
61
|
+
impact: ScenarioImpactReason[]
|
|
62
|
+
baseEffectiveCode: RelationEntry[]
|
|
63
|
+
headEffectiveCode: RelationEntry[]
|
|
64
|
+
selectorHits: SessionImpactSelectorHit[]
|
|
65
|
+
baseScenarioHash: string | null
|
|
66
|
+
headScenarioHash: string | null
|
|
67
|
+
delta: SessionImpactScenarioDelta
|
|
68
|
+
}
|
|
69
|
+
export type SessionImpactNodeCause = {
|
|
70
|
+
kind: 'spec' | 'eval' | 'code' | 'related' | 'contract' | 'measurement'
|
|
71
|
+
paths: string[]
|
|
72
|
+
scenarios: string[]
|
|
73
|
+
selectorHits: SessionImpactSelectorHit[]
|
|
74
|
+
}
|
|
75
|
+
export type SessionImpactNode = {
|
|
76
|
+
id: string
|
|
77
|
+
causes: SessionImpactNodeCause[]
|
|
78
|
+
scenarios: SessionImpactScenario[]
|
|
79
|
+
}
|
|
80
|
+
export type SessionImpactProjection = {
|
|
81
|
+
base: string
|
|
82
|
+
head: string
|
|
83
|
+
revision: string
|
|
84
|
+
nodes: SessionImpactNode[]
|
|
85
|
+
}
|
|
86
|
+
export type SessionImpactOptions = {
|
|
87
|
+
base: string
|
|
88
|
+
head: string
|
|
89
|
+
measurements?: Readonly<Record<string, readonly string[]>>
|
|
90
|
+
overlay?: SessionImpactOverlay
|
|
41
91
|
}
|
|
42
92
|
|
|
43
|
-
export
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
93
|
+
export type SessionImpactOverlay = {
|
|
94
|
+
revision: string
|
|
95
|
+
paths: string[]
|
|
96
|
+
baseRanges: Record<string, [number, number][]>
|
|
97
|
+
ranges: Record<string, [number, number][]>
|
|
98
|
+
specs: SessionImpactSpecSnapshot[]
|
|
99
|
+
evalSources: Record<string, string | null>
|
|
100
|
+
files: Record<string, string | null>
|
|
51
101
|
}
|
|
52
102
|
|
|
53
|
-
export
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
103
|
+
export type SessionImpactSpecSnapshot = Pick<LoadedSpec,
|
|
104
|
+
'id' | 'path' | 'code' | 'codeScoped' | 'related' | 'relatedScoped' | 'relationProblems'>
|
|
105
|
+
|
|
106
|
+
export class SessionEvalUnavailableError extends Error {
|
|
107
|
+
override name = 'SessionEvalUnavailableError'
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export class SessionImpactUnavailableError extends SessionEvalUnavailableError {
|
|
111
|
+
override name = 'SessionImpactUnavailableError'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class SessionImpactRevisionMovedError extends SessionImpactUnavailableError {
|
|
115
|
+
override name = 'SessionImpactRevisionMovedError'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type LoadedSpec = Awaited<ReturnType<typeof loadSpecs>>[number]
|
|
119
|
+
|
|
120
|
+
async function impactGit(root: string, args: string[], operation: string): Promise<string> {
|
|
121
|
+
const result = await gitTry(['-C', root, ...args])
|
|
122
|
+
if (!result.ok) {
|
|
123
|
+
const detail = result.stderr.trim() || `${result.failure ?? 'git'} failure`
|
|
124
|
+
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
|
|
125
|
+
}
|
|
126
|
+
return result.stdout
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function impactIndexGit(root: string, indexFile: string, args: string[], operation: string): Promise<string> {
|
|
130
|
+
const result = await gitTry(['-C', root, ...args], { indexFile })
|
|
131
|
+
if (!result.ok) {
|
|
132
|
+
const detail = result.stderr.trim() || `${result.failure ?? 'git'} failure`
|
|
133
|
+
throw new SessionImpactUnavailableError(`session impact cannot ${operation}: ${detail}`)
|
|
134
|
+
}
|
|
135
|
+
return result.stdout
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function impactCommit(root: string, selector: string): Promise<string> {
|
|
139
|
+
const out = await impactGit(root, ['rev-parse', '--verify', `${selector}^{commit}`], `resolve revision '${selector}'`)
|
|
140
|
+
const oid = out.trim()
|
|
141
|
+
if (!oid) throw new SessionImpactUnavailableError(`session impact revision '${selector}' resolved to no commit`)
|
|
142
|
+
return oid
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
type NameStatusChange = { status: string; before: string; after: string }
|
|
146
|
+
function parseNameStatusChanges(out: string): NameStatusChange[] {
|
|
147
|
+
const records = out.split('\0')
|
|
148
|
+
const changes: NameStatusChange[] = []
|
|
149
|
+
for (let index = 0; index < records.length;) {
|
|
150
|
+
const status = records[index++]
|
|
151
|
+
if (!status) continue
|
|
152
|
+
const first = records[index++]
|
|
153
|
+
if (!first) throw new SessionImpactUnavailableError(`session impact diff emitted '${status}' without a path`)
|
|
154
|
+
if (/^[RC]/.test(status)) {
|
|
155
|
+
const second = records[index++]
|
|
156
|
+
if (!second) throw new SessionImpactUnavailableError(`session impact diff emitted '${status}' without both rename paths`)
|
|
157
|
+
changes.push({ status, before: first, after: second })
|
|
158
|
+
} else changes.push({ status, before: first, after: first })
|
|
159
|
+
}
|
|
160
|
+
return changes
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function revisionFile(root: string, revision: string, path: string): Promise<string | null> {
|
|
164
|
+
const listed = await impactGit(root, ['-c', 'core.quotePath=false', 'ls-tree', '-z', '--name-only', revision, '--', path], `read ${revision}:${path}`)
|
|
165
|
+
if (!listed.split('\0').includes(path)) return null
|
|
166
|
+
return impactGit(root, ['show', `${revision}:${path}`], `read ${revision}:${path}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function loadedRelationRows(spec: SessionImpactSpecSnapshot | undefined, relation: 'code' | 'related'): string[] {
|
|
170
|
+
if (!spec) return []
|
|
171
|
+
const paths = relation === 'code' ? spec.code : spec.related
|
|
172
|
+
const scoped = relation === 'code' ? spec.codeScoped : spec.relatedScoped
|
|
173
|
+
const selectors = new Map(scoped.map((entry) => [entry.path, entry.selectors]))
|
|
174
|
+
return paths.flatMap((path) => selectors.get(path)?.map((selector) => `${path}#${selector}`) ?? [path])
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function scenarioMetadata(scenario: Scenario, effectiveCode: readonly RelationEntry[]): string {
|
|
178
|
+
return JSON.stringify({
|
|
179
|
+
tags: scenario.tags ?? [],
|
|
180
|
+
test: scenario.test ?? null,
|
|
181
|
+
effectiveCode,
|
|
182
|
+
related: scenario.related ?? [],
|
|
65
183
|
})
|
|
66
184
|
}
|
|
67
185
|
|
|
186
|
+
type ImpactReadContext = {
|
|
187
|
+
root: string
|
|
188
|
+
base: string
|
|
189
|
+
head: string
|
|
190
|
+
committedPaths: ReadonlySet<string>
|
|
191
|
+
changedPaths: ReadonlySet<string>
|
|
192
|
+
overlay?: SessionImpactOverlay
|
|
193
|
+
regs: ReturnType<typeof extractors>
|
|
194
|
+
didx: DriftIndex | null
|
|
195
|
+
entryImpacts: Map<string, Promise<{ paths: string[]; selectorHits: SessionImpactSelectorHit[] }>>
|
|
196
|
+
windows: Map<string, DriftPathEvent[]>
|
|
197
|
+
sources: Map<string, Promise<string | null>>
|
|
198
|
+
units: Map<string, Promise<{ extractor: Extractor; units: Unit[] }>>
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function selectorSource(
|
|
202
|
+
context: ImpactReadContext,
|
|
203
|
+
revision: string,
|
|
204
|
+
path: string,
|
|
205
|
+
sourceView: 'base' | 'head',
|
|
206
|
+
): Promise<string | null> {
|
|
207
|
+
if (sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path)) return context.overlay.files[path]
|
|
208
|
+
const key = `${revision}\0${path}`
|
|
209
|
+
let pending = context.sources.get(key)
|
|
210
|
+
if (!pending) {
|
|
211
|
+
pending = revisionFile(context.root, revision, path)
|
|
212
|
+
context.sources.set(key, pending)
|
|
213
|
+
}
|
|
214
|
+
return pending
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function validateSelectorEntry(
|
|
218
|
+
context: ImpactReadContext,
|
|
219
|
+
revision: string,
|
|
220
|
+
entry: RelationEntry,
|
|
221
|
+
sourceView: 'base' | 'head',
|
|
222
|
+
): Promise<{ extractor: Extractor; units: Unit[] }> {
|
|
223
|
+
const x = extractorFor(context.regs, extOf(entry.path))
|
|
224
|
+
if (!x) {
|
|
225
|
+
throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors[0]}' is unavailable: no designated extractor for '.${extOf(entry.path)}'`)
|
|
226
|
+
}
|
|
227
|
+
const ready = x.ready()
|
|
228
|
+
if (ready !== true) {
|
|
229
|
+
throw new SessionImpactUnavailableError(`session impact selectors on '${entry.path}' are unavailable: ${ready}`)
|
|
230
|
+
}
|
|
231
|
+
const sourceIdentity = sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, entry.path)
|
|
232
|
+
? `overlay:${context.overlay.revision}`
|
|
233
|
+
: 'exact'
|
|
234
|
+
const key = `${revision}\0${sourceIdentity}\0${entry.path}\0${x.id}\0${x.memoKey(entry.path)}`
|
|
235
|
+
let extracted = context.units.get(key)
|
|
236
|
+
if (!extracted) {
|
|
237
|
+
extracted = (async () => {
|
|
238
|
+
const source = await selectorSource(context, revision, entry.path, sourceView)
|
|
239
|
+
if (source === null) {
|
|
240
|
+
throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors[0]}' is dead at ${revision.slice(0, 8)}: the file does not exist`)
|
|
241
|
+
}
|
|
242
|
+
try { return { extractor: x, units: x.extract(source, entry.path) } }
|
|
243
|
+
catch (error: any) {
|
|
244
|
+
throw new SessionImpactUnavailableError(`session impact selectors on '${entry.path}' are unextractable at ${revision.slice(0, 8)}: ${error?.message ?? String(error)}`)
|
|
245
|
+
}
|
|
246
|
+
})()
|
|
247
|
+
context.units.set(key, extracted)
|
|
248
|
+
}
|
|
249
|
+
const { extractor, units } = await extracted
|
|
250
|
+
for (const selector of entry.selectors) {
|
|
251
|
+
const resolved = resolveAnchor(units, selector)
|
|
252
|
+
if ('dead' in resolved) {
|
|
253
|
+
throw new SessionImpactUnavailableError(`dead session impact selector '${entry.path}#${selector}' at ${revision.slice(0, 8)}: follow the rename or repair the code relation`)
|
|
254
|
+
}
|
|
255
|
+
if ('ambiguous' in resolved) {
|
|
256
|
+
throw new SessionImpactUnavailableError(`ambiguous session impact selector '${entry.path}#${selector}' at ${revision.slice(0, 8)}: ${resolved.ambiguous} units share that name`)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return { extractor, units }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function selectorEntriesForSnapshot(
|
|
263
|
+
specs: readonly SessionImpactSpecSnapshot[],
|
|
264
|
+
scenariosById: ReadonlyMap<string, readonly Scenario[]>,
|
|
265
|
+
): RelationEntry[] {
|
|
266
|
+
const entries: RelationEntry[] = []
|
|
267
|
+
for (const spec of specs) {
|
|
268
|
+
entries.push(...parsedRelation(loadedRelationRows(spec, 'code'), 'code', `node '${spec.id}'`))
|
|
269
|
+
entries.push(...parsedRelation(loadedRelationRows(spec, 'related'), 'related', `node '${spec.id}'`))
|
|
270
|
+
for (const scenario of scenariosById.get(spec.id) ?? []) {
|
|
271
|
+
entries.push(...parsedRelation(scenario.code ?? [], 'code', `scenario '${spec.id} · ${scenario.name}'`))
|
|
272
|
+
entries.push(...parsedRelation(scenario.related ?? [], 'related', `scenario '${spec.id} · ${scenario.name}'`))
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const unique = new Map<string, RelationEntry>()
|
|
276
|
+
for (const entry of entries) if (entry.selectors.length) unique.set(`${entry.path}\0${entry.selectors.join('\x1e')}`, entry)
|
|
277
|
+
return [...unique.values()]
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function primeSelectorSources(
|
|
281
|
+
context: ImpactReadContext,
|
|
282
|
+
revision: string,
|
|
283
|
+
paths: readonly string[],
|
|
284
|
+
sourceView: 'base' | 'head',
|
|
285
|
+
): void {
|
|
286
|
+
const exactPaths = [...new Set(paths)].filter((path) => (
|
|
287
|
+
!context.sources.has(`${revision}\0${path}`)
|
|
288
|
+
&& !(sourceView === 'head' && context.overlay && Object.hasOwn(context.overlay.files, path))
|
|
289
|
+
)).sort()
|
|
290
|
+
if (!exactPaths.length) return
|
|
291
|
+
try {
|
|
292
|
+
const oids = batchRevisionOids(context.root, exactPaths.map((path) => `${revision}:${path}`))
|
|
293
|
+
const blobs = batchBlobTexts(context.root, oids.filter((oid): oid is string => !!oid))
|
|
294
|
+
for (let index = 0; index < exactPaths.length; index++) {
|
|
295
|
+
const path = exactPaths[index]
|
|
296
|
+
const oid = oids[index]
|
|
297
|
+
if (oid && !blobs.has(oid)) throw new Error(`cat-file returned no bytes for ${revision.slice(0, 8)}:${path}`)
|
|
298
|
+
context.sources.set(`${revision}\0${path}`, Promise.resolve(oid ? blobs.get(oid)! : null))
|
|
299
|
+
}
|
|
300
|
+
} catch (error: any) {
|
|
301
|
+
throw new SessionImpactUnavailableError(`session impact cannot batch-read selector sources at ${revision.slice(0, 8)}: ${error?.message ?? String(error)}`)
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
async function impactForEntry(
|
|
306
|
+
context: ImpactReadContext,
|
|
307
|
+
entry: RelationEntry,
|
|
308
|
+
options: { validateUnchanged?: boolean; validationRevision?: string; validationSide?: 'base' | 'head' } = {},
|
|
309
|
+
): Promise<{ paths: string[]; selectorHits: SessionImpactSelectorHit[] }> {
|
|
310
|
+
const revision = options.validationRevision ?? context.head
|
|
311
|
+
const sourceView = options.validationSide ?? 'head'
|
|
312
|
+
const key = `${context.base}\0${context.head}\0${context.overlay?.revision ?? ''}\0${sourceView}\0${revision}\0${entry.path}\0${entry.selectors.join('\x1e')}\0${options.validateUnchanged ? 1 : 0}`
|
|
313
|
+
let pending = context.entryImpacts.get(key)
|
|
314
|
+
if (pending) return pending
|
|
315
|
+
pending = (async () => {
|
|
316
|
+
// Reuse sessioneval's existing relation claim matcher over the ONE exact base..head changed set. The
|
|
317
|
+
// projector adds no impact-local glob/path grammar; selector entries then narrow that file result.
|
|
318
|
+
const changed = [...context.changedPaths].filter((path) => codeClaims([entry.path], path))
|
|
319
|
+
if (!entry.selectors.length) return { paths: changed.sort(), selectorHits: [] }
|
|
320
|
+
// Resolution is a validity claim about the selected HEAD, independent of whether the file moved.
|
|
321
|
+
// Validating first prevents a dead selector from becoming a vacuous no-hit/fresh answer.
|
|
322
|
+
if (!changed.length && !options.validateUnchanged) return { paths: [], selectorHits: [] }
|
|
323
|
+
const { units } = await validateSelectorEntry(context, revision, entry, sourceView)
|
|
324
|
+
if (!changed.length) return { paths: [], selectorHits: [] }
|
|
325
|
+
const selectorHits: SessionImpactSelectorHit[] = []
|
|
326
|
+
const committed = [...context.committedPaths].some((path) => codeClaims([entry.path], path))
|
|
327
|
+
if (committed) {
|
|
328
|
+
if (!context.didx) throw new SessionImpactUnavailableError('session impact has committed paths but no exact event index')
|
|
329
|
+
const windowKey = `${revision}\0${entry.path}`
|
|
330
|
+
let window = context.windows.get(windowKey)
|
|
331
|
+
if (!window) {
|
|
332
|
+
const projected = pathRangeEvents(context.didx, context.base, entry.path, revision)
|
|
333
|
+
if (!projected) throw new SessionImpactUnavailableError(`session impact cannot place base ${context.base.slice(0, 8)} in the exact event topology`)
|
|
334
|
+
window = projected
|
|
335
|
+
context.windows.set(windowKey, window)
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
const hits = await anchorHitCommits(context.root, window, [...entry.selectors], context.regs)
|
|
339
|
+
for (const hit of hits) selectorHits.push({ path: entry.path, ...hit })
|
|
340
|
+
} catch (error: any) {
|
|
341
|
+
throw new SessionImpactUnavailableError(`session impact selector '${entry.path}#${entry.selectors.join(',#')}' is unavailable: ${error?.message ?? String(error)}`)
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (context.overlay && context.overlay.paths.some((path) => codeClaims([entry.path], path))) {
|
|
345
|
+
const ranges = sourceView === 'base' ? context.overlay.baseRanges : context.overlay.ranges
|
|
346
|
+
const touched = selectorsHitRanges(units, entry.selectors, ranges[entry.path] ?? [])
|
|
347
|
+
if (touched.length) selectorHits.push({
|
|
348
|
+
path: entry.path,
|
|
349
|
+
commit: `worktree:${context.overlay.revision.slice(0, 12)}`,
|
|
350
|
+
selectors: touched,
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
return { paths: selectorHits.length ? changed.sort() : [], selectorHits }
|
|
354
|
+
})()
|
|
355
|
+
context.entryImpacts.set(key, pending)
|
|
356
|
+
return pending
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function impactForEntries(
|
|
360
|
+
context: ImpactReadContext,
|
|
361
|
+
entries: readonly RelationEntry[],
|
|
362
|
+
options: { validateUnchanged?: boolean; validationRevision?: string; validationSide?: 'base' | 'head' } = {},
|
|
363
|
+
): Promise<{ paths: string[]; selectorHits: SessionImpactSelectorHit[] }> {
|
|
364
|
+
const results = await Promise.all(entries.map((entry) => impactForEntry(context, entry, options)))
|
|
365
|
+
return {
|
|
366
|
+
paths: [...new Set(results.flatMap((result) => result.paths))].sort(),
|
|
367
|
+
selectorHits: results.flatMap((result) => result.selectorHits),
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function pushNodeCause(
|
|
372
|
+
causes: SessionImpactNodeCause[],
|
|
373
|
+
kind: SessionImpactNodeCause['kind'],
|
|
374
|
+
paths: readonly string[] = [],
|
|
375
|
+
scenarios: readonly string[] = [],
|
|
376
|
+
selectorHits: readonly SessionImpactSelectorHit[] = [],
|
|
377
|
+
): void {
|
|
378
|
+
let cause = causes.find((candidate) => candidate.kind === kind)
|
|
379
|
+
if (!cause) {
|
|
380
|
+
cause = { kind, paths: [], scenarios: [], selectorHits: [] }
|
|
381
|
+
causes.push(cause)
|
|
382
|
+
}
|
|
383
|
+
cause.paths = [...new Set([...cause.paths, ...paths])].sort()
|
|
384
|
+
cause.scenarios = [...new Set([...cause.scenarios, ...scenarios])].sort()
|
|
385
|
+
const seen = new Set(cause.selectorHits.map((hit) => JSON.stringify(hit)))
|
|
386
|
+
for (const hit of selectorHits) {
|
|
387
|
+
const key = JSON.stringify(hit)
|
|
388
|
+
if (!seen.has(key)) { seen.add(key); cause.selectorHits.push(hit) }
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function parsedRelation(raw: readonly string[], relation: 'code' | 'related', owner: string): RelationEntry[] {
|
|
393
|
+
const parsed = parseRelation([...raw], relation)
|
|
394
|
+
if (parsed.problems.length) {
|
|
395
|
+
throw new SessionImpactUnavailableError(`session impact ${owner} has invalid ${relation}: ${parsed.problems.join('; ')}`)
|
|
396
|
+
}
|
|
397
|
+
return parsed.entries
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Public, exact-revision impact projection. It reads both spec trees and all scenario declarations from
|
|
401
|
+
// immutable Git objects, reuses the canonical relation/anchor engine, then re-resolves the caller's selectors
|
|
402
|
+
// before publication. Callers may pass branch names, but never receive a projection spanning two ref states.
|
|
403
|
+
export async function projectSessionImpact(root: string, options: SessionImpactOptions): Promise<SessionImpactProjection> {
|
|
404
|
+
const [base, head] = await Promise.all([
|
|
405
|
+
impactCommit(root, options.base),
|
|
406
|
+
impactCommit(root, options.head),
|
|
407
|
+
])
|
|
408
|
+
const ancestry = await gitTry(['-C', root, 'merge-base', '--is-ancestor', base, head])
|
|
409
|
+
if (!ancestry.ok) {
|
|
410
|
+
if (ancestry.failure === 'exit') {
|
|
411
|
+
throw new SessionImpactUnavailableError(`session impact base ${base.slice(0, 8)} is not an ancestor of head ${head.slice(0, 8)}; use the session merge-base as base`)
|
|
412
|
+
}
|
|
413
|
+
throw new SessionImpactUnavailableError(`session impact cannot verify base/head ancestry: ${ancestry.stderr.trim() || ancestry.failure}`)
|
|
414
|
+
}
|
|
415
|
+
// One immutable .spec tree read per distinct revision supplies BOTH spec relations and eval declarations.
|
|
416
|
+
// This is deliberately build-local: exact projections do not need another resident cache or generation.
|
|
417
|
+
const treeByRevision = new Map<string, ReadonlyMap<string, string>>()
|
|
418
|
+
const readTree = (revision: string): ReadonlyMap<string, string> => {
|
|
419
|
+
const cached = treeByRevision.get(revision)
|
|
420
|
+
if (cached) return cached
|
|
421
|
+
try {
|
|
422
|
+
const tree = treeTextFiles(root, revision, '.spec')
|
|
423
|
+
treeByRevision.set(revision, tree)
|
|
424
|
+
return tree
|
|
425
|
+
} catch (error: any) {
|
|
426
|
+
throw new SessionImpactUnavailableError(`session impact cannot read ${revision.slice(0, 8)} declarations: ${error?.message ?? String(error)}`)
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
const baseTree = readTree(base)
|
|
430
|
+
const headTree = options.overlay ? null : readTree(head)
|
|
431
|
+
const [baseSpecs, headSpecs] = await Promise.all([
|
|
432
|
+
loadSpecs(root, { tip: base, history: null, drift: null, snapshot: { tip: base, files: baseTree } }),
|
|
433
|
+
options.overlay
|
|
434
|
+
? Promise.resolve(options.overlay.specs)
|
|
435
|
+
: loadSpecs(root, { tip: head, history: null, drift: null, snapshot: { tip: head, files: headTree! } }),
|
|
436
|
+
])
|
|
437
|
+
const committedChanges = parseNameStatusChanges(await impactGit(
|
|
438
|
+
root,
|
|
439
|
+
['-c', 'core.quotePath=false', 'diff', '--name-status', '-z', '-M', base, head],
|
|
440
|
+
`diff ${base.slice(0, 8)}..${head.slice(0, 8)}`,
|
|
441
|
+
))
|
|
442
|
+
const committedPaths = new Set(committedChanges.flatMap((change) => [change.before, change.after]))
|
|
443
|
+
let didx: DriftIndex | null = null
|
|
444
|
+
if (committedPaths.size) {
|
|
445
|
+
try { didx = await driftIndex(root, head) }
|
|
446
|
+
catch (error: any) {
|
|
447
|
+
throw new SessionImpactUnavailableError(`session impact cannot derive the exact ${base.slice(0, 8)}..${head.slice(0, 8)} event window: ${error?.message ?? String(error)}`)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const changedPaths = new Set([...committedPaths, ...(options.overlay?.paths ?? [])])
|
|
451
|
+
const context: ImpactReadContext = {
|
|
452
|
+
root,
|
|
453
|
+
base,
|
|
454
|
+
head,
|
|
455
|
+
committedPaths,
|
|
456
|
+
changedPaths,
|
|
457
|
+
overlay: options.overlay,
|
|
458
|
+
regs: extractors(root),
|
|
459
|
+
didx,
|
|
460
|
+
entryImpacts: new Map(),
|
|
461
|
+
windows: new Map(),
|
|
462
|
+
sources: new Map(),
|
|
463
|
+
units: new Map(),
|
|
464
|
+
}
|
|
465
|
+
const baseById = new Map<string, SessionImpactSpecSnapshot>(baseSpecs.map((spec) => [spec.id, spec]))
|
|
466
|
+
const headById = new Map<string, SessionImpactSpecSnapshot>(headSpecs.map((spec) => [spec.id, spec]))
|
|
467
|
+
const nodeIds = [...new Set([...baseById.keys(), ...headById.keys()])].sort()
|
|
468
|
+
if (options.overlay) {
|
|
469
|
+
for (const path of options.overlay.paths) {
|
|
470
|
+
if (!Object.hasOwn(options.overlay.files, path)
|
|
471
|
+
|| !Object.hasOwn(options.overlay.baseRanges, path)
|
|
472
|
+
|| !Object.hasOwn(options.overlay.ranges, path)) {
|
|
473
|
+
throw new SessionImpactUnavailableError(`session impact overlay '${options.overlay.revision}' is incomplete for '${path}'`)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const scenariosAt = (
|
|
478
|
+
side: 'base' | 'head',
|
|
479
|
+
spec: SessionImpactSpecSnapshot,
|
|
480
|
+
): Scenario[] => {
|
|
481
|
+
const evalPath = join(dirname(spec.path), 'eval.md')
|
|
482
|
+
let source: string | null
|
|
483
|
+
if (side === 'base') source = baseTree.get(evalPath) ?? null
|
|
484
|
+
else if (options.overlay) {
|
|
485
|
+
if (!Object.hasOwn(options.overlay.evalSources, evalPath)) {
|
|
486
|
+
throw new SessionImpactUnavailableError(`session impact overlay '${options.overlay.revision}' has no declaration snapshot for '${evalPath}'`)
|
|
487
|
+
}
|
|
488
|
+
source = options.overlay.evalSources[evalPath]
|
|
489
|
+
} else source = headTree!.get(evalPath) ?? null
|
|
490
|
+
if (source === null) return []
|
|
491
|
+
try { return parseScenarios(source) }
|
|
492
|
+
catch (error: any) {
|
|
493
|
+
throw new SessionImpactUnavailableError(`session impact cannot parse ${side} declarations '${evalPath}': ${error?.message ?? String(error)}`)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const baseScenariosById = new Map(baseSpecs.map((spec) => [spec.id, scenariosAt('base', spec)]))
|
|
497
|
+
const headScenariosById = new Map(headSpecs.map((spec) => [spec.id, scenariosAt('head', spec)]))
|
|
498
|
+
const baseSelectorEntries = selectorEntriesForSnapshot(baseSpecs, baseScenariosById)
|
|
499
|
+
const headSelectorEntries = selectorEntriesForSnapshot(headSpecs, headScenariosById)
|
|
500
|
+
primeSelectorSources(context, base, baseSelectorEntries.map((entry) => entry.path), 'base')
|
|
501
|
+
primeSelectorSources(context, head, headSelectorEntries.map((entry) => entry.path), 'head')
|
|
502
|
+
await Promise.all([
|
|
503
|
+
...baseSelectorEntries.map((entry) => validateSelectorEntry(context, base, entry, 'base')),
|
|
504
|
+
...headSelectorEntries.map((entry) => validateSelectorEntry(context, head, entry, 'head')),
|
|
505
|
+
])
|
|
506
|
+
const nodes: SessionImpactNode[] = []
|
|
507
|
+
|
|
508
|
+
for (const id of nodeIds) {
|
|
509
|
+
const baseSpec = baseById.get(id)
|
|
510
|
+
const headSpec = headById.get(id)
|
|
511
|
+
const causes: SessionImpactNodeCause[] = []
|
|
512
|
+
const baseSpecPath = baseSpec?.path ?? ''
|
|
513
|
+
const headSpecPath = headSpec?.path ?? ''
|
|
514
|
+
const specPaths = [...new Set([baseSpecPath, headSpecPath].filter(Boolean))]
|
|
515
|
+
const specChanged = [...changedPaths].filter((path) => specPaths.some((claim) => codeClaims([claim], path)))
|
|
516
|
+
if (specChanged.length) pushNodeCause(causes, 'spec', specChanged)
|
|
517
|
+
|
|
518
|
+
if (baseSpec?.relationProblems.length || headSpec?.relationProblems.length) {
|
|
519
|
+
throw new SessionImpactUnavailableError(`session impact node '${id}' has invalid relations: ${[
|
|
520
|
+
...(baseSpec?.relationProblems ?? []), ...(headSpec?.relationProblems ?? []),
|
|
521
|
+
].join('; ')}`)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const baseEvalPath = baseSpec ? join(dirname(baseSpec.path), 'eval.md') : ''
|
|
525
|
+
const headEvalPath = headSpec ? join(dirname(headSpec.path), 'eval.md') : ''
|
|
526
|
+
const baseScenarios = baseScenariosById.get(id) ?? []
|
|
527
|
+
const headScenarios = headScenariosById.get(id) ?? []
|
|
528
|
+
const evalPaths = [...new Set([baseEvalPath, headEvalPath].filter(Boolean))]
|
|
529
|
+
const evalChanged = [...changedPaths].filter((path) => evalPaths.some((claim) => codeClaims([claim], path)))
|
|
530
|
+
if (evalChanged.length) pushNodeCause(causes, 'eval', evalChanged)
|
|
531
|
+
|
|
532
|
+
const baseNodeCodeRows = loadedRelationRows(baseSpec, 'code')
|
|
533
|
+
const headNodeCodeRows = loadedRelationRows(headSpec, 'code')
|
|
534
|
+
const baseNodeCode = scenarioCodeAxis(undefined, baseNodeCodeRows)
|
|
535
|
+
const headNodeCode = scenarioCodeAxis(undefined, headNodeCodeRows)
|
|
536
|
+
if (baseNodeCode.problems.length || headNodeCode.problems.length) {
|
|
537
|
+
throw new SessionImpactUnavailableError(`session impact node '${id}' has invalid code: ${[
|
|
538
|
+
...baseNodeCode.problems, ...headNodeCode.problems,
|
|
539
|
+
].join('; ')}`)
|
|
540
|
+
}
|
|
541
|
+
const nodeCodeMoved = !!baseSpec && !!headSpec
|
|
542
|
+
&& JSON.stringify(baseNodeCode.entries) !== JSON.stringify(headNodeCode.entries)
|
|
543
|
+
const nodeCodeReads = nodeCodeMoved
|
|
544
|
+
? await Promise.all([
|
|
545
|
+
impactForEntries(context, baseNodeCode.entries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
|
|
546
|
+
impactForEntries(context, headNodeCode.entries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
|
|
547
|
+
])
|
|
548
|
+
: [await impactForEntries(context, (headSpec ? headNodeCode : baseNodeCode).entries, {
|
|
549
|
+
validateUnchanged: !baseSpec || !headSpec,
|
|
550
|
+
validationRevision: headSpec ? head : base,
|
|
551
|
+
validationSide: headSpec ? 'head' : 'base',
|
|
552
|
+
})]
|
|
553
|
+
const nodeCodeImpact = {
|
|
554
|
+
paths: [...new Set(nodeCodeReads.flatMap((read) => read.paths))].sort(),
|
|
555
|
+
selectorHits: nodeCodeReads.flatMap((read) => read.selectorHits),
|
|
556
|
+
}
|
|
557
|
+
if (nodeCodeImpact.paths.length) pushNodeCause(causes, 'code', nodeCodeImpact.paths, [], nodeCodeImpact.selectorHits)
|
|
558
|
+
|
|
559
|
+
// Related declarations belong to their own owners. Combine their parsed DATA entries after validation;
|
|
560
|
+
// parsing one synthetic concatenated relation would turn two legitimate owners naming the same path into
|
|
561
|
+
// a fake duplicate/mixed-form schema error.
|
|
562
|
+
const relatedFor = (spec: SessionImpactSpecSnapshot | undefined, scenarios: readonly Scenario[]) => [
|
|
563
|
+
...parsedRelation(loadedRelationRows(spec, 'related'), 'related', `node '${id}'`),
|
|
564
|
+
...scenarios.flatMap((scenario) => (
|
|
565
|
+
parsedRelation(scenario.related ?? [], 'related', `scenario '${id} · ${scenario.name}'`)
|
|
566
|
+
)),
|
|
567
|
+
]
|
|
568
|
+
const baseRelatedEntries = relatedFor(baseSpec, baseScenarios)
|
|
569
|
+
const headRelatedEntries = relatedFor(headSpec, headScenarios)
|
|
570
|
+
const relatedMoved = !!baseSpec && !!headSpec
|
|
571
|
+
&& JSON.stringify(baseRelatedEntries) !== JSON.stringify(headRelatedEntries)
|
|
572
|
+
const relatedReads = relatedMoved
|
|
573
|
+
? await Promise.all([
|
|
574
|
+
impactForEntries(context, baseRelatedEntries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
|
|
575
|
+
impactForEntries(context, headRelatedEntries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
|
|
576
|
+
])
|
|
577
|
+
: [await impactForEntries(context, headSpec ? headRelatedEntries : baseRelatedEntries, {
|
|
578
|
+
validateUnchanged: !baseSpec || !headSpec,
|
|
579
|
+
validationRevision: headSpec ? head : base,
|
|
580
|
+
validationSide: headSpec ? 'head' : 'base',
|
|
581
|
+
})]
|
|
582
|
+
const relatedImpact = {
|
|
583
|
+
paths: [...new Set(relatedReads.flatMap((read) => read.paths))].sort(),
|
|
584
|
+
selectorHits: relatedReads.flatMap((read) => read.selectorHits),
|
|
585
|
+
}
|
|
586
|
+
if (relatedImpact.paths.length) pushNodeCause(causes, 'related', relatedImpact.paths, [], relatedImpact.selectorHits)
|
|
587
|
+
|
|
588
|
+
const baseByName = new Map(baseScenarios.map((scenario) => [scenario.name, scenario]))
|
|
589
|
+
const headByName = new Map(headScenarios.map((scenario) => [scenario.name, scenario]))
|
|
590
|
+
const scenarioNames = [...new Set([...baseByName.keys(), ...headByName.keys()])].sort()
|
|
591
|
+
const measured = new Set(options.measurements?.[id] ?? [])
|
|
592
|
+
const scenarios: SessionImpactScenario[] = []
|
|
593
|
+
for (const name of scenarioNames) {
|
|
594
|
+
const before = baseByName.get(name)
|
|
595
|
+
const after = headByName.get(name)
|
|
596
|
+
const state = !before ? 'added' : !after ? 'removed' : 'present'
|
|
597
|
+
const baseScenarioHash = before ? scenarioHash(before) : null
|
|
598
|
+
const headScenarioHash = after ? scenarioHash(after) : null
|
|
599
|
+
const semantic = !before || !after || baseScenarioHash !== headScenarioHash
|
|
600
|
+
const baseAxis = before ? scenarioCodeAxis(before.code, loadedRelationRows(baseSpec, 'code')) : { entries: [], paths: [], problems: [] }
|
|
601
|
+
const headAxis = after ? scenarioCodeAxis(after.code, loadedRelationRows(headSpec, 'code')) : { entries: [], paths: [], problems: [] }
|
|
602
|
+
if (baseAxis.problems.length || headAxis.problems.length) {
|
|
603
|
+
throw new SessionImpactUnavailableError(`session impact scenario '${id} · ${name}' has invalid code: ${[
|
|
604
|
+
...baseAxis.problems, ...headAxis.problems,
|
|
605
|
+
].join('; ')}`)
|
|
606
|
+
}
|
|
607
|
+
// Metadata is the RESOLVED measurement pointer, not only bytes authored in this scenario block. A
|
|
608
|
+
// declaration without own code inherits the node relation at each revision, so node #alpha -> #beta is
|
|
609
|
+
// a metadata move for that scenario even though its eval.md block did not change.
|
|
610
|
+
const metadata = !!before && !!after
|
|
611
|
+
&& scenarioMetadata(before, baseAxis.entries) !== scenarioMetadata(after, headAxis.entries)
|
|
612
|
+
const effectiveCodeMoved = JSON.stringify(baseAxis.entries) !== JSON.stringify(headAxis.entries)
|
|
613
|
+
const kind: SessionImpactScenarioDelta['kind'] = !before ? 'added' : !after ? 'removed'
|
|
614
|
+
: semantic || metadata ? 'modified' : 'unchanged'
|
|
615
|
+
const codeReads = before && after && effectiveCodeMoved
|
|
616
|
+
? await Promise.all([
|
|
617
|
+
impactForEntries(context, baseAxis.entries, { validateUnchanged: true, validationRevision: base, validationSide: 'base' }),
|
|
618
|
+
impactForEntries(context, headAxis.entries, { validateUnchanged: true, validationRevision: head, validationSide: 'head' }),
|
|
619
|
+
])
|
|
620
|
+
: [await impactForEntries(context, (after ? headAxis : baseAxis).entries, {
|
|
621
|
+
validateUnchanged: kind === 'added' || kind === 'removed',
|
|
622
|
+
validationRevision: after ? head : base,
|
|
623
|
+
validationSide: after ? 'head' : 'base',
|
|
624
|
+
})]
|
|
625
|
+
const code = {
|
|
626
|
+
paths: [...new Set(codeReads.flatMap((read) => read.paths))].sort(),
|
|
627
|
+
selectorHits: codeReads.flatMap((read) => read.selectorHits),
|
|
628
|
+
}
|
|
629
|
+
const impact: ScenarioImpactReason[] = []
|
|
630
|
+
if (code.paths.length) impact.push('code')
|
|
631
|
+
if (semantic) impact.push('contract')
|
|
632
|
+
if (measured.has(name)) impact.push('measurement')
|
|
633
|
+
if (code.paths.length) pushNodeCause(causes, 'code', code.paths, [name], code.selectorHits)
|
|
634
|
+
if (semantic) pushNodeCause(causes, 'contract', evalChanged, [name])
|
|
635
|
+
if (measured.has(name)) pushNodeCause(causes, 'measurement', [], [name])
|
|
636
|
+
scenarios.push({
|
|
637
|
+
name,
|
|
638
|
+
state,
|
|
639
|
+
impact,
|
|
640
|
+
baseEffectiveCode: baseAxis.entries,
|
|
641
|
+
headEffectiveCode: headAxis.entries,
|
|
642
|
+
selectorHits: code.selectorHits,
|
|
643
|
+
baseScenarioHash,
|
|
644
|
+
headScenarioHash,
|
|
645
|
+
delta: { kind, semantic, metadata, metadataOnly: metadata && !semantic },
|
|
646
|
+
})
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (causes.length || scenarios.some((scenario) => scenario.delta.kind !== 'unchanged' || scenario.impact.length)) {
|
|
650
|
+
nodes.push({ id, causes, scenarios })
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
const [baseAfter, headAfter] = await Promise.all([
|
|
655
|
+
impactCommit(root, options.base),
|
|
656
|
+
impactCommit(root, options.head),
|
|
657
|
+
])
|
|
658
|
+
if (baseAfter !== base || headAfter !== head) {
|
|
659
|
+
throw new SessionImpactRevisionMovedError(`session impact revisions moved while projecting: base ${base.slice(0, 8)} -> ${baseAfter.slice(0, 8)}, head ${head.slice(0, 8)} -> ${headAfter.slice(0, 8)}; retry the projection`)
|
|
660
|
+
}
|
|
661
|
+
const revision = createHash('sha256').update(JSON.stringify({ base, head, overlay: options.overlay?.revision ?? null, nodes })).digest('hex')
|
|
662
|
+
return { base, head, revision, nodes }
|
|
663
|
+
}
|
|
664
|
+
|
|
68
665
|
type SessionEvalReading = EvalEntry & { inSession: boolean }
|
|
69
666
|
|
|
70
667
|
export function scopeSessionScenarioRows(
|
|
71
668
|
current: Scenario[],
|
|
72
|
-
base: Scenario[],
|
|
73
669
|
scenarioInfo: ScenarioInfo[],
|
|
74
|
-
nodeCode: string[],
|
|
75
|
-
changedPaths: ReadonlySet<string>,
|
|
76
|
-
evalFileChanged: boolean,
|
|
77
670
|
evals: SessionEvalReading[],
|
|
671
|
+
projected: readonly SessionImpactScenario[],
|
|
78
672
|
): { scenarios: SessionScenarioInfo[]; evals: SessionEvalReading[] } {
|
|
79
|
-
const
|
|
80
|
-
|
|
673
|
+
const impactByName = new Map(projected
|
|
674
|
+
.filter((scenario) => scenario.state !== 'removed' && scenario.impact.length)
|
|
675
|
+
.map((scenario) => [scenario.name, scenario.impact]))
|
|
81
676
|
const infoByName = new Map(scenarioInfo.map((scenario) => [scenario.name, scenario]))
|
|
82
|
-
const scenarios: SessionScenarioInfo[] =
|
|
677
|
+
const scenarios: SessionScenarioInfo[] = current.filter((scenario) => impactByName.has(scenario.name)).map((scenario) => ({
|
|
83
678
|
...(infoByName.get(scenario.name) ?? {
|
|
84
679
|
name: scenario.name, expected: scenario.expected,
|
|
85
680
|
...(scenario.tags?.length ? { tags: scenario.tags } : {}),
|
|
86
681
|
...(scenario.test ? { test: scenario.test } : {}),
|
|
87
682
|
...(scenario.code?.length ? { code: scenario.code } : {}),
|
|
88
683
|
}),
|
|
89
|
-
impact
|
|
684
|
+
impact: impactByName.get(scenario.name)!,
|
|
90
685
|
}))
|
|
91
686
|
const names = new Set(scenarios.map((scenario) => scenario.name))
|
|
92
687
|
return { scenarios, evals: evals.filter((reading) => names.has(reading.scenario)) }
|
|
@@ -99,10 +694,6 @@ export function completeExportNodeIds(
|
|
|
99
694
|
return [...new Set([...changedNodeIds, ...scopedNodeIds])]
|
|
100
695
|
}
|
|
101
696
|
|
|
102
|
-
export function mergeBasePath(path: string, oldPaths: ReadonlyMap<string, string>): string {
|
|
103
|
-
return oldPaths.get(path) ?? path
|
|
104
|
-
}
|
|
105
|
-
|
|
106
697
|
// one eval reading rendered for the export: the latest measurement of one scenario, with its evidence
|
|
107
698
|
// resolved to inline bytes (an image data-URI, or transcript text) so the document is self-contained.
|
|
108
699
|
export type ExportReading = {
|
|
@@ -170,6 +761,7 @@ export type ExportModel = {
|
|
|
170
761
|
dirtyNonRuntime: number
|
|
171
762
|
gates: ExportGate[]
|
|
172
763
|
score: { passed: number; total: number; fresh: number } // the eval summary across all nodes
|
|
764
|
+
impact: SessionImpactProjection
|
|
173
765
|
nodes: ExportNode[]
|
|
174
766
|
otherFiles: ExportFile[] // changed files no spec node claims
|
|
175
767
|
}
|
|
@@ -189,23 +781,13 @@ export async function buildExportModel(id: string): Promise<ExportModel | null>
|
|
|
189
781
|
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
190
782
|
const ctx = await evalContext(ctxRoot, specs, didx, hidx)
|
|
191
783
|
|
|
192
|
-
const changedPaths = new Set(payload.diff.map((file) => file.path))
|
|
193
|
-
const oldPaths = new Map(payload.diff.flatMap((file) => file.oldPath ? [[file.path, file.oldPath] as const] : []))
|
|
194
|
-
|
|
195
784
|
// enrich each changed file with its unified diff + full before/after content (derived from the session
|
|
196
785
|
// worktree at the merge-base ↔ HEAD), so the proof can drill summary → diff → whole-file comparison with no
|
|
197
786
|
// extra fetch. Capped at MAX_ENRICHED_FILES so a huge changeset can't bloat the page; the rest keep their
|
|
198
787
|
// row but say so (omitted), never silently blank.
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
worktreeDirtyState(wtPath),
|
|
203
|
-
]) : ['', '', { paths: new Set<string>(), oldPaths: new Map<string, string>() }] as const
|
|
204
|
-
const dirtyPaths = dirtyState.paths
|
|
205
|
-
for (const path of dirtyPaths) changedPaths.add(path)
|
|
206
|
-
for (const [path, oldPath] of dirtyState.oldPaths) if (!oldPaths.has(path)) oldPaths.set(path, oldPath)
|
|
207
|
-
const shas = new Set(shaRows.split('\n').filter(Boolean))
|
|
208
|
-
const scopedNodes = await sessionScopeNodes(id, ctx, changedPaths, dirtyPaths, oldPaths, base, shas)
|
|
788
|
+
const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
|
|
789
|
+
const base = impact.base
|
|
790
|
+
const scopedNodes = await sessionScopeNodes(id, ctx, impact, shas)
|
|
209
791
|
const enriched = new Map<string, ExportFile>()
|
|
210
792
|
let budget = MAX_ENRICHED_FILES
|
|
211
793
|
for (const f of payload.diff) {
|
|
@@ -277,6 +859,7 @@ export async function buildExportModel(id: string): Promise<ExportModel | null>
|
|
|
277
859
|
dirtyNonRuntime: payload.dirtyNonRuntime,
|
|
278
860
|
gates: gateRows(payload),
|
|
279
861
|
score: { passed, total, fresh },
|
|
862
|
+
impact,
|
|
280
863
|
nodes,
|
|
281
864
|
otherFiles,
|
|
282
865
|
}
|
|
@@ -420,13 +1003,7 @@ function nodeForFile(file: string, specs: Awaited<ReturnType<typeof loadSpecs>>,
|
|
|
420
1003
|
}
|
|
421
1004
|
|
|
422
1005
|
function codeClaims(code: string[], file: string): boolean {
|
|
423
|
-
return code.some((
|
|
424
|
-
if (cf === file) return true
|
|
425
|
-
const dir = cf.replace(/\/+$/, '') + '/'
|
|
426
|
-
if (file.startsWith(dir)) return true
|
|
427
|
-
if (cf.includes('*')) return new RegExp('^' + cf.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$').test(file)
|
|
428
|
-
return false
|
|
429
|
-
})
|
|
1006
|
+
return code.some((claim) => relationClaimsPath(claim, file))
|
|
430
1007
|
}
|
|
431
1008
|
|
|
432
1009
|
// ---- worktree resolution (no sessions.ts edit: read git's own worktree list) ----
|
|
@@ -460,22 +1037,94 @@ export function parsePorcelainPaths(out: string): Set<string> {
|
|
|
460
1037
|
return paths
|
|
461
1038
|
}
|
|
462
1039
|
|
|
463
|
-
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
const path = record.slice(3)
|
|
471
|
-
if ((status.includes('R') || status.includes('C')) && records[i + 1]) renames.set(path, records[++i])
|
|
472
|
-
}
|
|
473
|
-
return renames
|
|
1040
|
+
async function worktreeDirtyState(wtPath: string): Promise<{ paths: Set<string>; status: string }> {
|
|
1041
|
+
const out = await impactGit(
|
|
1042
|
+
wtPath,
|
|
1043
|
+
['-c', 'core.quotePath=false', 'status', '--porcelain=v1', '-z', '--untracked-files=all'],
|
|
1044
|
+
'snapshot worktree status',
|
|
1045
|
+
)
|
|
1046
|
+
return { paths: parsePorcelainPaths(out), status: out }
|
|
474
1047
|
}
|
|
475
1048
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
1049
|
+
// Capture one immutable description of the live worktree overlay. One rename-aware change set supplies both
|
|
1050
|
+
// path images; untracked files are wholly new result ranges. The canonical anchor parser
|
|
1051
|
+
// turns those patches into ranges, and projectSessionImpact consumes the snapshot beside base..HEAD. The
|
|
1052
|
+
// outer session content double-read fences concurrent edits before this result can publish.
|
|
1053
|
+
export async function sessionImpactOverlay(
|
|
1054
|
+
wtPath: string,
|
|
1055
|
+
dirty: { paths: ReadonlySet<string>; status: string },
|
|
1056
|
+
): Promise<SessionImpactOverlay | undefined> {
|
|
1057
|
+
if (!dirty.paths.size) return undefined
|
|
1058
|
+
const untracked = new Set(untrackedPaths(dirty.status))
|
|
1059
|
+
const paths = [...dirty.paths].sort()
|
|
1060
|
+
const baseRanges: Record<string, [number, number][]> = {}
|
|
1061
|
+
const ranges: Record<string, [number, number][]> = {}
|
|
1062
|
+
const files: Record<string, string | null> = {}
|
|
1063
|
+
const fingerprints: string[] = []
|
|
1064
|
+
const bytesByPath = new Map<string, Buffer | null>()
|
|
1065
|
+
for (const path of paths) {
|
|
1066
|
+
let bytes: Buffer | null = null
|
|
1067
|
+
try { bytes = await readFile(join(wtPath, path)) }
|
|
1068
|
+
catch (error: any) {
|
|
1069
|
+
if (error?.code !== 'ENOENT') throw new SessionImpactUnavailableError(`session impact cannot snapshot '${path}': ${error?.message ?? String(error)}`)
|
|
1070
|
+
}
|
|
1071
|
+
bytesByPath.set(path, bytes)
|
|
1072
|
+
files[path] = bytes?.toString('utf8') ?? null
|
|
1073
|
+
baseRanges[path] = []
|
|
1074
|
+
ranges[path] = []
|
|
1075
|
+
fingerprints.push(`${path}\0${bytes ? createHash('sha256').update(bytes).digest('hex') : '<gone>'}`)
|
|
1076
|
+
}
|
|
1077
|
+
const indexDir = await mkdtemp(join(tmpdir(), 'spex-impact-index-'))
|
|
1078
|
+
const indexFile = join(indexDir, 'index')
|
|
1079
|
+
try {
|
|
1080
|
+
await impactIndexGit(wtPath, indexFile, ['read-tree', 'HEAD'], 'seed the isolated worktree index')
|
|
1081
|
+
await impactIndexGit(wtPath, indexFile, ['add', '-A', '--', ...paths], 'snapshot dirty paths in the isolated worktree index')
|
|
1082
|
+
const trackedChanges = parseNameStatusChanges(await impactIndexGit(
|
|
1083
|
+
wtPath,
|
|
1084
|
+
indexFile,
|
|
1085
|
+
['-c', 'core.quotePath=false', 'diff', '--cached', 'HEAD', '--name-status', '-z', '-M'],
|
|
1086
|
+
'snapshot worktree change identities',
|
|
1087
|
+
))
|
|
1088
|
+
for (const change of trackedChanges) {
|
|
1089
|
+
if (!Object.hasOwn(baseRanges, change.before) || !Object.hasOwn(ranges, change.after))
|
|
1090
|
+
throw new SessionImpactUnavailableError(`session impact dirty snapshot omitted '${change.before}' -> '${change.after}'`)
|
|
1091
|
+
const patch = await impactIndexGit(
|
|
1092
|
+
wtPath,
|
|
1093
|
+
indexFile,
|
|
1094
|
+
['-c', 'core.quotePath=false', 'diff', '--cached', 'HEAD', '--unified=0', '--no-ext-diff', '-M', '--', ...new Set([change.before, change.after])],
|
|
1095
|
+
`snapshot worktree patch '${change.before}' -> '${change.after}'`,
|
|
1096
|
+
)
|
|
1097
|
+
baseRanges[change.before] = diffHunkRanges(patch, 'old')
|
|
1098
|
+
ranges[change.after] = diffHunkRanges(patch, 'new')
|
|
1099
|
+
fingerprints.push(`${change.status}\0${change.before}\0${change.after}\0${createHash('sha256').update(patch).digest('hex')}`)
|
|
1100
|
+
}
|
|
1101
|
+
} finally {
|
|
1102
|
+
await rm(indexDir, { recursive: true, force: true })
|
|
1103
|
+
}
|
|
1104
|
+
for (const path of untracked) fingerprints.push(`untracked\0${path}\0${bytesByPath.get(path)?.length ?? 0}`)
|
|
1105
|
+
const loaded = await loadSpecs(wtPath, { history: null, drift: null })
|
|
1106
|
+
const specs: SessionImpactSpecSnapshot[] = loaded.map((spec) => ({
|
|
1107
|
+
id: spec.id,
|
|
1108
|
+
path: spec.path,
|
|
1109
|
+
code: spec.code,
|
|
1110
|
+
codeScoped: spec.codeScoped,
|
|
1111
|
+
related: spec.related,
|
|
1112
|
+
relatedScoped: spec.relatedScoped,
|
|
1113
|
+
relationProblems: spec.relationProblems,
|
|
1114
|
+
}))
|
|
1115
|
+
const evalSources: Record<string, string | null> = {}
|
|
1116
|
+
for (const spec of specs) {
|
|
1117
|
+
const evalPath = join(dirname(spec.path), 'eval.md')
|
|
1118
|
+
try { evalSources[evalPath] = await readFile(join(wtPath, evalPath), 'utf8') }
|
|
1119
|
+
catch (error: any) {
|
|
1120
|
+
if (error?.code === 'ENOENT') evalSources[evalPath] = null
|
|
1121
|
+
else throw new SessionImpactUnavailableError(`session impact cannot snapshot '${evalPath}': ${error?.message ?? String(error)}`)
|
|
1122
|
+
}
|
|
1123
|
+
}
|
|
1124
|
+
const revision = createHash('sha256')
|
|
1125
|
+
.update([dirty.status, ...fingerprints, JSON.stringify(specs), JSON.stringify(evalSources)].join('\0'))
|
|
1126
|
+
.digest('hex')
|
|
1127
|
+
return { revision, paths, baseRanges, ranges, specs, evalSources, files }
|
|
479
1128
|
}
|
|
480
1129
|
|
|
481
1130
|
// ---- the renderer ----
|
|
@@ -585,7 +1234,7 @@ export function renderExportHtml(m: ExportModel): string {
|
|
|
585
1234
|
const idShort = m.id.slice(0, 8)
|
|
586
1235
|
const ribbon = [
|
|
587
1236
|
...m.gates.map((g) => `<span class="chip ${g.ok ? 'ok' : 'bad'}" title="${esc(g.detail)}">${g.ok ? '✓' : '✗'} ${esc(g.label)}</span>`),
|
|
588
|
-
m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="affected scenarios fresh-passing; ${m.score.fresh}/${m.score.total} have a fresh
|
|
1237
|
+
m.score.total ? `<span class="chip ${m.score.passed === m.score.total ? 'ok' : 'warn'}" title="affected scenarios fresh-passing; ${m.score.fresh}/${m.score.total} have a fresh measurement">★ ${m.score.passed}/${m.score.total} passing</span>` : `<span class="chip warn" title="no scenario is affected by this worktree">★ no affected scenarios</span>`,
|
|
589
1238
|
].join('')
|
|
590
1239
|
const gates = m.gates.map((g) => `<li class="${g.ok ? 'ok' : 'bad'}"><span class="gmark">${g.ok ? '✓' : '✗'}</span><span class="glabel">${esc(g.label)}</span><span class="gdetail">${esc(g.detail)}</span></li>`).join('')
|
|
591
1240
|
const otherBlock = m.otherFiles.length
|
|
@@ -720,8 +1369,11 @@ export type SessionEvalNode = {
|
|
|
720
1369
|
// Changed frontend code that no declared scenario covers. This is node-level UNKNOWN coverage, never a
|
|
721
1370
|
// synthetic scenario: it stays outside scenario totals and filters while remaining visible to consumers.
|
|
722
1371
|
unknownCoverage: string[]
|
|
723
|
-
//
|
|
724
|
-
//
|
|
1372
|
+
// Node-level review context from the SAME exact impact projection. Related movement lives here and never
|
|
1373
|
+
// becomes a synthetic scenario reason.
|
|
1374
|
+
causes: SessionImpactNodeCause[]
|
|
1375
|
+
// Already scoped by the canonical exact impact projection. Consumers must not infer impact from node
|
|
1376
|
+
// membership, freshness, or the full eval.md again.
|
|
725
1377
|
scenarios: SessionScenarioInfo[]
|
|
726
1378
|
// each reading carries the trunk eval-concern thread for its (node, scenario) ([[remark-teeth]] / directive
|
|
727
1379
|
// 3): the server-side join (attached by evalTimeline as `EvalEntry.thread`), so the session tab's event
|
|
@@ -738,6 +1390,7 @@ export type SessionEvals = {
|
|
|
738
1390
|
dirtyNonRuntime: number
|
|
739
1391
|
gates: ExportGate[]
|
|
740
1392
|
nodes: SessionEvalNode[]
|
|
1393
|
+
impact: SessionImpactProjection
|
|
741
1394
|
summary: SessionEvalSummary
|
|
742
1395
|
evalRevision: SessionEvalRevision
|
|
743
1396
|
}
|
|
@@ -767,10 +1420,6 @@ export type SessionEvalProjection = {
|
|
|
767
1420
|
lastKnown?: { generation: number; revision: string; value: SessionEvalSummary }
|
|
768
1421
|
}
|
|
769
1422
|
|
|
770
|
-
export class SessionEvalUnavailableError extends Error {
|
|
771
|
-
override name = 'SessionEvalUnavailableError'
|
|
772
|
-
}
|
|
773
|
-
|
|
774
1423
|
// The one count projection over the already-affected rows. This is the backend source both the graph
|
|
775
1424
|
// glance and the demand full model carry; consumers never repeat impact selection or score classification.
|
|
776
1425
|
export function sessionEvalSummary(nodes: SessionEvalNode[]): SessionEvalSummary {
|
|
@@ -801,61 +1450,55 @@ export function sessionEvalSummary(nodes: SessionEvalNode[]): SessionEvalSummary
|
|
|
801
1450
|
async function sessionScopeNodes(
|
|
802
1451
|
id: string,
|
|
803
1452
|
ctx: Awaited<ReturnType<typeof evalContext>>,
|
|
804
|
-
|
|
805
|
-
dirtyPaths: ReadonlySet<string>,
|
|
806
|
-
oldPaths: ReadonlyMap<string, string>,
|
|
807
|
-
base: string,
|
|
1453
|
+
impact: SessionImpactProjection,
|
|
808
1454
|
shas: ReadonlySet<string>,
|
|
809
1455
|
latestOnly = false,
|
|
810
1456
|
): Promise<SessionEvalNode[]> {
|
|
811
1457
|
const evalById = new Map(ctx.ynodes.map((node) => [node.id, node]))
|
|
1458
|
+
const specById = new Map(ctx.specs.map((spec) => [spec.id, spec]))
|
|
812
1459
|
const nodes: SessionEvalNode[] = []
|
|
813
1460
|
|
|
814
|
-
for (const
|
|
1461
|
+
for (const projected of impact.nodes) {
|
|
1462
|
+
const spec = specById.get(projected.id)
|
|
1463
|
+
if (!spec) continue // removed nodes remain fully explained by impact.nodes; they have no live eval rows.
|
|
815
1464
|
const evalNode = evalById.get(spec.id)
|
|
816
1465
|
const current = evalNode?.scenarios ?? []
|
|
817
1466
|
// Unknown means the node has no measurement contract at all. A node that has eval.md is known even
|
|
818
1467
|
// when individual scenarios narrow their code axes; partial scenario ownership is not a synthetic gap.
|
|
819
|
-
const unknownCoverage = evalNode ? [] :
|
|
1468
|
+
const unknownCoverage = evalNode ? [] : [...new Set(projected.causes
|
|
1469
|
+
.filter((cause) => cause.kind === 'code')
|
|
1470
|
+
.flatMap((cause) => cause.paths)
|
|
1471
|
+
.filter(isUiPath))]
|
|
820
1472
|
|
|
821
1473
|
if (!evalNode) {
|
|
822
1474
|
if (unknownCoverage.length) {
|
|
823
1475
|
nodes.push({
|
|
824
1476
|
id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
|
|
825
1477
|
hasEvalFile: false, uncoveredFrontend: true, unknownCoverage,
|
|
1478
|
+
causes: projected.causes,
|
|
826
1479
|
scenarios: [], evals: [],
|
|
827
1480
|
})
|
|
828
1481
|
}
|
|
829
1482
|
continue
|
|
830
1483
|
}
|
|
831
1484
|
|
|
832
|
-
const evalFileChanged = changedPaths.has(evalNode.evalPath)
|
|
833
|
-
const sidecarPath = relative(ctx.root, evalNode.sidecarPath)
|
|
834
|
-
if (!sessionEvalNodeCandidate(current, spec.code, evalNode.evalPath, sidecarPath, changedPaths, dirtyPaths)) continue
|
|
835
|
-
|
|
836
1485
|
const timeline = await evalTimeline(spec.id, ctx)
|
|
837
1486
|
// A reading is this session's own when the session filed it OR its anchor is a branch commit. This is
|
|
838
1487
|
// the same marker the UI and CLI render; measurement impact consumes that marker instead of inventing
|
|
839
1488
|
// another attribution rule.
|
|
840
1489
|
const evals = timeline.readings.map((reading) => ({
|
|
841
1490
|
...reading,
|
|
842
|
-
inSession: reading
|
|
1491
|
+
inSession: readingInSession(reading, id, shas),
|
|
843
1492
|
}))
|
|
844
|
-
const
|
|
845
|
-
|
|
846
|
-
? (await gitA(['-C', ctx.root, 'ls-tree', '--name-only', base, '--', baseEvalPath])).trim() !== ''
|
|
847
|
-
: false
|
|
848
|
-
const baseScenarios = baseHasEval
|
|
849
|
-
? parseScenarios(await gitA(['-C', ctx.root, 'show', `${base}:${baseEvalPath}`]))
|
|
850
|
-
: []
|
|
851
|
-
const scoped = scopeSessionScenarioRows(current, baseScenarios, timeline.scenarios, spec.code, changedPaths, evalFileChanged, evals)
|
|
852
|
-
if (!scoped.scenarios.length && !unknownCoverage.length) continue
|
|
1493
|
+
const scoped = scopeSessionScenarioRows(current, timeline.scenarios, evals, projected.scenarios)
|
|
1494
|
+
if (!scoped.scenarios.length && !unknownCoverage.length && !projected.causes.length) continue
|
|
853
1495
|
|
|
854
1496
|
nodes.push({
|
|
855
1497
|
id: spec.id, title: spec.title, hue: spec.hue, desc: spec.desc,
|
|
856
1498
|
hasEvalFile: timeline.hasEvalFile,
|
|
857
1499
|
uncoveredFrontend: !timeline.hasEvalFile && unknownCoverage.length > 0,
|
|
858
1500
|
unknownCoverage,
|
|
1501
|
+
causes: projected.causes,
|
|
859
1502
|
scenarios: scoped.scenarios,
|
|
860
1503
|
// Preserve the whole A/B history for selected scenarios. Fresh, stale, legacy and missing remain
|
|
861
1504
|
// honest downstream states; impact selection never removes a row because its reading is stale.
|
|
@@ -866,6 +1509,57 @@ async function sessionScopeNodes(
|
|
|
866
1509
|
return nodes
|
|
867
1510
|
}
|
|
868
1511
|
|
|
1512
|
+
function readingInSession(reading: { by?: string; codeSha: string }, id: string, shas: ReadonlySet<string>): boolean {
|
|
1513
|
+
return reading.by === id || shas.has(reading.codeSha)
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function sessionMeasurements(
|
|
1517
|
+
id: string,
|
|
1518
|
+
ctx: Awaited<ReturnType<typeof evalContext>>,
|
|
1519
|
+
shas: ReadonlySet<string>,
|
|
1520
|
+
): Record<string, string[]> {
|
|
1521
|
+
const measurements: Record<string, string[]> = {}
|
|
1522
|
+
for (const node of ctx.ynodes) {
|
|
1523
|
+
const names = [...new Set(readReadings(node.sidecarPath)
|
|
1524
|
+
.filter((reading) => readingInSession(reading, id, shas))
|
|
1525
|
+
.map((reading) => reading.scenario))]
|
|
1526
|
+
if (names.length) measurements[node.id] = names
|
|
1527
|
+
}
|
|
1528
|
+
return measurements
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
async function sessionImpactForContext(
|
|
1532
|
+
id: string,
|
|
1533
|
+
ctx: Awaited<ReturnType<typeof evalContext>>,
|
|
1534
|
+
wtPath: string | null,
|
|
1535
|
+
): Promise<{ impact: SessionImpactProjection; shas: Set<string> }> {
|
|
1536
|
+
const root = wtPath ?? ctx.root
|
|
1537
|
+
if (!wtPath) {
|
|
1538
|
+
const head = await impactCommit(root, 'HEAD')
|
|
1539
|
+
const impact = await projectSessionImpact(root, {
|
|
1540
|
+
base: head,
|
|
1541
|
+
head,
|
|
1542
|
+
measurements: sessionMeasurements(id, ctx, new Set()),
|
|
1543
|
+
})
|
|
1544
|
+
return { impact, shas: new Set() }
|
|
1545
|
+
}
|
|
1546
|
+
const [base, head, shaRows, dirty] = await Promise.all([
|
|
1547
|
+
impactGit(wtPath, ['merge-base', mainBranch(), 'HEAD'], 'resolve session merge-base').then((out) => out.trim()),
|
|
1548
|
+
impactCommit(wtPath, 'HEAD'),
|
|
1549
|
+
impactGit(wtPath, ['rev-list', `${mainBranch()}..HEAD`], 'read session commits'),
|
|
1550
|
+
worktreeDirtyState(wtPath),
|
|
1551
|
+
])
|
|
1552
|
+
const shas = new Set(shaRows.split('\n').filter(Boolean))
|
|
1553
|
+
const overlay = await sessionImpactOverlay(wtPath, dirty)
|
|
1554
|
+
const impact = await projectSessionImpact(wtPath, {
|
|
1555
|
+
base,
|
|
1556
|
+
head,
|
|
1557
|
+
overlay,
|
|
1558
|
+
measurements: sessionMeasurements(id, ctx, shas),
|
|
1559
|
+
})
|
|
1560
|
+
return { impact, shas }
|
|
1561
|
+
}
|
|
1562
|
+
|
|
869
1563
|
type ReviewPayloadValue = NonNullable<Awaited<ReturnType<typeof reviewPayload>>>
|
|
870
1564
|
type SessionEvalModel = Omit<SessionEvals, 'summary' | 'evalRevision'>
|
|
871
1565
|
|
|
@@ -882,21 +1576,8 @@ async function buildSessionEvalModel(
|
|
|
882
1576
|
const specById = new Map(specs.map((s) => [s.id, s]))
|
|
883
1577
|
const [didx, hidx] = await Promise.all([driftIndex(ctxRoot), historyIndex(ctxRoot)])
|
|
884
1578
|
const ctx = await evalContext(ctxRoot, specs, didx, hidx)
|
|
885
|
-
const
|
|
886
|
-
const
|
|
887
|
-
const [base, shaRows, dirtyState] = wtPath ? await Promise.all([
|
|
888
|
-
gitA(['-C', wtPath, 'merge-base', mainBranch(), 'HEAD']).then((out) => out.trim()),
|
|
889
|
-
gitA(['-C', wtPath, 'rev-list', `${mainBranch()}..HEAD`]),
|
|
890
|
-
worktreeDirtyState(wtPath),
|
|
891
|
-
]) : ['', '', { paths: new Set<string>(), oldPaths: new Map<string, string>() }] as const
|
|
892
|
-
const dirtyPaths = dirtyState.paths
|
|
893
|
-
// A session evaluation is the proposal as it exists now, not only its committed slice. A dirty source,
|
|
894
|
-
// staged rename, draft eval.md, or uncommitted sidecar therefore enters the SAME affected selector.
|
|
895
|
-
for (const path of dirtyPaths) changedPaths.add(path)
|
|
896
|
-
for (const [path, oldPath] of dirtyState.oldPaths) if (!oldPaths.has(path)) oldPaths.set(path, oldPath)
|
|
897
|
-
// this session's own commits — the membership test behind `inSession` and measurement impact
|
|
898
|
-
const shas = new Set(shaRows.split('\n').filter(Boolean))
|
|
899
|
-
const nodes = await sessionScopeNodes(id, ctx, changedPaths, dirtyPaths, oldPaths, base, shas, latestOnly)
|
|
1579
|
+
const { impact, shas } = await sessionImpactForContext(id, ctx, wtPath)
|
|
1580
|
+
const nodes = await sessionScopeNodes(id, ctx, impact, shas, latestOnly)
|
|
900
1581
|
// nodes with in-session measurements lead, then the most-measured — the session's own evidence first.
|
|
901
1582
|
nodes.sort((a, b) => (b.evals.filter((e) => e.inSession).length - a.evals.filter((e) => e.inSession).length)
|
|
902
1583
|
|| (b.scenarios.length - a.scenarios.length) || (b.unknownCoverage.length - a.unknownCoverage.length))
|
|
@@ -911,6 +1592,7 @@ async function buildSessionEvalModel(
|
|
|
911
1592
|
dirtyNonRuntime: payload.dirtyNonRuntime,
|
|
912
1593
|
gates: gateRows(payload),
|
|
913
1594
|
nodes,
|
|
1595
|
+
impact,
|
|
914
1596
|
}
|
|
915
1597
|
}
|
|
916
1598
|
|
|
@@ -965,9 +1647,17 @@ type SummaryBuildResult =
|
|
|
965
1647
|
|
|
966
1648
|
export type SessionEvalSummaryBuilder = (id: string, path: string) => Promise<SummaryBuildResult>
|
|
967
1649
|
|
|
1650
|
+
type DemandJob<T> = {
|
|
1651
|
+
id: string
|
|
1652
|
+
run: () => Promise<T>
|
|
1653
|
+
resolve: (value: T) => void
|
|
1654
|
+
reject: (error: unknown) => void
|
|
1655
|
+
}
|
|
1656
|
+
|
|
968
1657
|
type ProjectionEntry = {
|
|
969
1658
|
id: string
|
|
970
1659
|
path: string
|
|
1660
|
+
liveness: string
|
|
971
1661
|
generation: number
|
|
972
1662
|
phase: SessionEvalProjection['phase']
|
|
973
1663
|
current?: { generation: number; revision: string; value: SessionEvalSummary }
|
|
@@ -978,6 +1668,15 @@ type ProjectionEntry = {
|
|
|
978
1668
|
|
|
979
1669
|
type ProjectionTarget = 'all' | { id?: string; path?: string }
|
|
980
1670
|
|
|
1671
|
+
// Summary builds touch a session worktree's diff, history, and eval sidecars. Running one job per row
|
|
1672
|
+
// multiplies those git children and their parsed indexes by the retained session count, so the shared
|
|
1673
|
+
// projection queue has one bounded capacity for every project. A deployment can tune the capacity, but the
|
|
1674
|
+
// default is deliberately serial: board assembly remains responsive and memory has a natural settle point.
|
|
1675
|
+
const configuredProjectionConcurrency = Number(process.env.SPEXCODE_SESSION_EVAL_CONCURRENCY || 1)
|
|
1676
|
+
const PROJECTION_CONCURRENCY = Number.isFinite(configuredProjectionConcurrency)
|
|
1677
|
+
? Math.max(1, Math.floor(configuredProjectionConcurrency))
|
|
1678
|
+
: 1
|
|
1679
|
+
|
|
981
1680
|
// Pure generation coordinator around an injected stable builder. Snapshot construction only serializes
|
|
982
1681
|
// entries and authorizes the newest dirty generations; the async batch runs after that snapshot has captured
|
|
983
1682
|
// `updating(lastKnown)`, then emits one completion nudge for all stable/error results in the batch.
|
|
@@ -988,15 +1687,66 @@ export class SessionEvalProjectionCache {
|
|
|
988
1687
|
private readonly observerWaiters = new Set<() => void>()
|
|
989
1688
|
private batch: Promise<void> | null = null
|
|
990
1689
|
private notify: () => void
|
|
991
|
-
|
|
992
|
-
|
|
1690
|
+
private precompute: boolean
|
|
1691
|
+
private readonly demandQueue: DemandJob<any>[] = []
|
|
1692
|
+
private readonly demands = new Map<string, Promise<unknown>>()
|
|
1693
|
+
|
|
1694
|
+
constructor(
|
|
1695
|
+
private readonly build: SessionEvalSummaryBuilder,
|
|
1696
|
+
notify: () => void = () => {},
|
|
1697
|
+
epoch: string = randomUUID(),
|
|
1698
|
+
precompute = true,
|
|
1699
|
+
) {
|
|
993
1700
|
this.notify = notify
|
|
994
1701
|
this.epoch = epoch
|
|
1702
|
+
this.precompute = precompute
|
|
995
1703
|
}
|
|
996
1704
|
|
|
997
1705
|
setNotify(notify: () => void): void { this.notify = notify }
|
|
998
1706
|
|
|
999
|
-
|
|
1707
|
+
setPrecompute(enabled: boolean): void {
|
|
1708
|
+
if (this.precompute === enabled) return
|
|
1709
|
+
this.precompute = enabled
|
|
1710
|
+
for (const entry of this.entries.values()) {
|
|
1711
|
+
if (!enabled) {
|
|
1712
|
+
// A running build is allowed to settle; dropping only queued work prevents a last subscriber's
|
|
1713
|
+
// departure from starting another historical walk after the era has ended.
|
|
1714
|
+
if (entry.running == null) entry.scheduled = null
|
|
1715
|
+
continue
|
|
1716
|
+
}
|
|
1717
|
+
this.authorize(entry)
|
|
1718
|
+
}
|
|
1719
|
+
if (enabled) queueMicrotask(() => this.startBatch())
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
private authorize(entry: ProjectionEntry): void {
|
|
1723
|
+
if (!this.precompute || entry.liveness === 'offline' || entry.observerHolds.size) return
|
|
1724
|
+
if ((entry.phase === 'loading' || entry.phase === 'updating')
|
|
1725
|
+
&& entry.running !== entry.generation && entry.scheduled !== entry.generation)
|
|
1726
|
+
entry.scheduled = entry.generation
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
private ensureEntry(id: string, path: string): ProjectionEntry {
|
|
1730
|
+
let entry = this.entries.get(id)
|
|
1731
|
+
if (entry) {
|
|
1732
|
+
entry.path = path || entry.path
|
|
1733
|
+
return entry
|
|
1734
|
+
}
|
|
1735
|
+
entry = {
|
|
1736
|
+
id,
|
|
1737
|
+
path,
|
|
1738
|
+
liveness: 'offline',
|
|
1739
|
+
generation: 0,
|
|
1740
|
+
phase: 'loading',
|
|
1741
|
+
scheduled: null,
|
|
1742
|
+
running: null,
|
|
1743
|
+
observerHolds: new Set(),
|
|
1744
|
+
}
|
|
1745
|
+
this.entries.set(id, entry)
|
|
1746
|
+
return entry
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
snapshot(sessions: { id: string; path: string; liveness?: string }[]): Map<string, SessionEvalProjection> {
|
|
1000
1750
|
const live = new Set(sessions.map((session) => session.id))
|
|
1001
1751
|
for (const id of this.entries.keys()) if (!live.has(id)) this.entries.delete(id)
|
|
1002
1752
|
const out = new Map<string, SessionEvalProjection>()
|
|
@@ -1006,6 +1756,8 @@ export class SessionEvalProjectionCache {
|
|
|
1006
1756
|
entry = {
|
|
1007
1757
|
id: session.id,
|
|
1008
1758
|
path: session.path,
|
|
1759
|
+
// Unit tests and direct callers omit liveness; those callers retain the historical eager default.
|
|
1760
|
+
liveness: session.liveness ?? 'online',
|
|
1009
1761
|
generation: 0,
|
|
1010
1762
|
phase: 'loading',
|
|
1011
1763
|
scheduled: null,
|
|
@@ -1013,14 +1765,19 @@ export class SessionEvalProjectionCache {
|
|
|
1013
1765
|
observerHolds: new Set(),
|
|
1014
1766
|
}
|
|
1015
1767
|
this.entries.set(session.id, entry)
|
|
1016
|
-
} else
|
|
1768
|
+
} else {
|
|
1769
|
+
entry.path = session.path
|
|
1770
|
+
entry.liveness = session.liveness ?? 'online'
|
|
1771
|
+
}
|
|
1017
1772
|
entry.observerHolds = new Set([...this.observerHolds]
|
|
1018
1773
|
.filter(([, target]) => this.matches(entry!, target))
|
|
1019
1774
|
.map(([observer]) => observer))
|
|
1020
1775
|
if (entry.observerHolds.size) entry.phase = 'updating'
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1776
|
+
// Dormant history is demand-only. The graph still exposes a loading/last-known projection for an
|
|
1777
|
+
// offline row, but does not fan out a summary build for every retained session. Selecting that row's
|
|
1778
|
+
// eval calls buildSessionEvals directly; live rows are eager only during a delta stream era.
|
|
1779
|
+
if (session.liveness === 'offline' && entry.running == null) entry.scheduled = null
|
|
1780
|
+
this.authorize(entry)
|
|
1024
1781
|
out.set(session.id, this.project(entry))
|
|
1025
1782
|
}
|
|
1026
1783
|
queueMicrotask(() => this.startBatch())
|
|
@@ -1094,6 +1851,25 @@ export class SessionEvalProjectionCache {
|
|
|
1094
1851
|
return entry ? this.project(entry) : null
|
|
1095
1852
|
}
|
|
1096
1853
|
|
|
1854
|
+
async demand<T>(id: string, path: string, run: () => Promise<T>): Promise<T> {
|
|
1855
|
+
const existing = this.demands.get(id)
|
|
1856
|
+
if (existing) return existing as Promise<T>
|
|
1857
|
+
const entry = this.ensureEntry(id, path)
|
|
1858
|
+
// A queued summary for this same generation is superseded by the full demand build. A running
|
|
1859
|
+
// summary is left alone; the priority job waits for it to settle before taking the slot.
|
|
1860
|
+
if (entry.running == null) entry.scheduled = null
|
|
1861
|
+
let resolve!: (value: T) => void
|
|
1862
|
+
let reject!: (error: unknown) => void
|
|
1863
|
+
const promise = new Promise<T>((res, rej) => { resolve = res; reject = rej })
|
|
1864
|
+
this.demands.set(id, promise)
|
|
1865
|
+
this.demandQueue.push({ id, run, resolve, reject })
|
|
1866
|
+
promise.finally(() => {
|
|
1867
|
+
if (this.demands.get(id) === promise) this.demands.delete(id)
|
|
1868
|
+
}).catch(() => {})
|
|
1869
|
+
queueMicrotask(() => this.startBatch())
|
|
1870
|
+
return promise
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1097
1873
|
async idle(): Promise<void> {
|
|
1098
1874
|
await Promise.resolve()
|
|
1099
1875
|
while (this.batch) await this.batch
|
|
@@ -1128,19 +1904,28 @@ export class SessionEvalProjectionCache {
|
|
|
1128
1904
|
|
|
1129
1905
|
private startBatch(): void {
|
|
1130
1906
|
if (this.batch) return
|
|
1131
|
-
const
|
|
1132
|
-
|
|
1907
|
+
const hasPending = () => this.demandQueue.length > 0
|
|
1908
|
+
|| [...this.entries.values()].some((entry) => entry.scheduled != null && entry.running == null)
|
|
1909
|
+
if (!hasPending()) return
|
|
1133
1910
|
this.batch = (async () => {
|
|
1134
1911
|
let publish = false
|
|
1135
|
-
while (
|
|
1136
|
-
const
|
|
1912
|
+
while (hasPending()) {
|
|
1913
|
+
const demand = this.demandQueue.shift()
|
|
1914
|
+
if (demand) {
|
|
1915
|
+
try { demand.resolve(await demand.run()) }
|
|
1916
|
+
catch (error) { demand.reject(error) }
|
|
1917
|
+
continue
|
|
1918
|
+
}
|
|
1919
|
+
const jobs = [...this.entries.values()]
|
|
1920
|
+
.filter((entry) => entry.scheduled != null && entry.running == null)
|
|
1921
|
+
.slice(0, PROJECTION_CONCURRENCY)
|
|
1137
1922
|
const changed = await Promise.all(jobs.map((entry) => this.runEntry(entry)))
|
|
1138
1923
|
publish = publish || changed.some(Boolean)
|
|
1139
1924
|
}
|
|
1140
1925
|
if (publish) this.notify()
|
|
1141
1926
|
})().finally(() => {
|
|
1142
1927
|
this.batch = null
|
|
1143
|
-
if (
|
|
1928
|
+
if (hasPending()) this.startBatch()
|
|
1144
1929
|
})
|
|
1145
1930
|
}
|
|
1146
1931
|
|
|
@@ -1198,7 +1983,7 @@ async function buildSummaryAttempt(id: string, _path: string): Promise<SummaryBu
|
|
|
1198
1983
|
}
|
|
1199
1984
|
|
|
1200
1985
|
const summaryByContent = new Map<string, SessionEvalSummary>()
|
|
1201
|
-
const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt)
|
|
1986
|
+
const projectionCache = new SessionEvalProjectionCache(buildSummaryAttempt, () => {}, randomUUID(), false)
|
|
1202
1987
|
const OBSERVER_RECOVERY_TIMEOUT_MS = 10_000
|
|
1203
1988
|
|
|
1204
1989
|
async function awaitObservableInputs(id: string, path: string): Promise<void> {
|
|
@@ -1207,9 +1992,16 @@ async function awaitObservableInputs(id: string, path: string): Promise<void> {
|
|
|
1207
1992
|
}
|
|
1208
1993
|
|
|
1209
1994
|
export function setSessionEvalProjectionNotify(notify: () => void): void { projectionCache.setNotify(notify) }
|
|
1210
|
-
export function
|
|
1995
|
+
export function setSessionEvalProjectionWarmup(enabled: boolean): void { projectionCache.setPrecompute(enabled) }
|
|
1996
|
+
export function sessionEvalProjections(sessions: { id: string; path: string; liveness?: string }[]): Map<string, SessionEvalProjection> {
|
|
1211
1997
|
return projectionCache.snapshot(sessions)
|
|
1212
1998
|
}
|
|
1999
|
+
// The single-session READ of the same projection: no entry is minted, no build is scheduled, no generation
|
|
2000
|
+
// moves. `null` means this cache has never held that session — a caller that must distinguish "not computed"
|
|
2001
|
+
// from "computed and empty" gets to; one that builds instead goes through buildSessionEvals.
|
|
2002
|
+
export function sessionEvalProjection(id: string): SessionEvalProjection | null {
|
|
2003
|
+
return projectionCache.get(id)
|
|
2004
|
+
}
|
|
1213
2005
|
export function invalidateSessionEvalProjections(target: 'all' | { id?: string; path?: string } = 'all'): number {
|
|
1214
2006
|
return projectionCache.invalidate(target)
|
|
1215
2007
|
}
|
|
@@ -1225,35 +2017,39 @@ export function releaseSessionEvalProjectionObserver(observer: string): boolean
|
|
|
1225
2017
|
export async function awaitSessionEvalProjectionIdle(): Promise<void> { await projectionCache.idle() }
|
|
1226
2018
|
|
|
1227
2019
|
export async function buildSessionEvals(id: string): Promise<SessionEvals | null> {
|
|
1228
|
-
// A full model is demand-only
|
|
1229
|
-
//
|
|
2020
|
+
// A full model is demand-only, but it still runs through the projection queue. This gives a selected session
|
|
2021
|
+
// priority over unrelated queued summaries without opening a second git/build lane.
|
|
1230
2022
|
for (;;) {
|
|
1231
|
-
await projectionCache.
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
2023
|
+
const attempt = await projectionCache.demand(id, '', async () => {
|
|
2024
|
+
const payload = await reviewPayload(id)
|
|
2025
|
+
if (!payload) return { kind: 'missing' as const }
|
|
2026
|
+
const wtPath = worktreePathForBranch(payload.branch)
|
|
2027
|
+
const ctxPath = wtPath ?? repoRoot()
|
|
2028
|
+
const known = projectionCache.get(id)
|
|
2029
|
+
const generation = known?.generation ?? 0
|
|
2030
|
+
await awaitObservableInputs(id, ctxPath)
|
|
2031
|
+
const before = await sessionEvalContentRevision(ctxPath)
|
|
2032
|
+
const model = await buildSessionEvalModel(id, payload, wtPath, false)
|
|
2033
|
+
const after = await sessionEvalContentRevision(ctxPath)
|
|
2034
|
+
const current = projectionCache.get(id)
|
|
2035
|
+
if (before !== after || projectionCache.isObserverHeld(id, ctxPath)
|
|
2036
|
+
|| (current && current.generation !== generation)) {
|
|
2037
|
+
if (before !== after) projectionCache.invalidate({ id })
|
|
2038
|
+
return { kind: 'retry' as const }
|
|
2039
|
+
}
|
|
2040
|
+
const summary = sessionEvalSummary(model.nodes)
|
|
2041
|
+
const cacheKey = `${id}\0${after}`
|
|
2042
|
+
for (const key of summaryByContent.keys()) if (key.startsWith(`${id}\0`)) summaryByContent.delete(key)
|
|
2043
|
+
summaryByContent.set(cacheKey, summary)
|
|
2044
|
+
if (current) projectionCache.accept(id, generation, after, summary)
|
|
2045
|
+
return { kind: 'ready' as const, model, summary, generation, revision: after }
|
|
2046
|
+
})
|
|
2047
|
+
if (attempt.kind === 'missing') return null
|
|
2048
|
+
if (attempt.kind === 'retry') continue
|
|
1253
2049
|
return {
|
|
1254
|
-
...model,
|
|
1255
|
-
summary,
|
|
1256
|
-
evalRevision: { epoch: projectionCache.epoch, generation, content:
|
|
2050
|
+
...attempt.model,
|
|
2051
|
+
summary: attempt.summary,
|
|
2052
|
+
evalRevision: { epoch: projectionCache.epoch, generation: attempt.generation, content: attempt.revision },
|
|
1257
2053
|
}
|
|
1258
2054
|
}
|
|
1259
2055
|
}
|