@spexcode/spec-core 0.6.2 → 0.6.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.
Files changed (56) hide show
  1. package/dist/anchors.d.ts +94 -0
  2. package/dist/anchors.js +730 -0
  3. package/dist/git.d.ts +166 -0
  4. package/dist/git.js +2736 -0
  5. package/dist/graph.d.ts +34 -0
  6. package/dist/graph.js +237 -0
  7. package/dist/graphDelta.d.ts +45 -0
  8. package/dist/graphDelta.js +84 -0
  9. package/dist/harness-identity.d.ts +31 -0
  10. package/dist/harness-identity.js +20 -0
  11. package/dist/identity-presets.d.ts +152 -0
  12. package/dist/identity-presets.js +132 -0
  13. package/dist/index.d.ts +45 -0
  14. package/dist/index.js +19 -0
  15. package/dist/layout.d.ts +183 -0
  16. package/dist/layout.js +548 -0
  17. package/dist/process-identity.d.ts +37 -0
  18. package/dist/process-identity.js +214 -0
  19. package/dist/project-identity.d.ts +12 -0
  20. package/dist/project-identity.js +71 -0
  21. package/dist/project-store.d.ts +3 -0
  22. package/dist/project-store.js +14 -0
  23. package/dist/resilience.d.ts +2 -0
  24. package/dist/resilience.js +40 -0
  25. package/dist/review/index.d.ts +3 -0
  26. package/{src → dist}/review/index.js +3 -3
  27. package/dist/review/reviewFilters.d.ts +77 -0
  28. package/dist/review/reviewFilters.js +308 -0
  29. package/dist/review/reviewQuery.d.ts +66 -0
  30. package/dist/review/reviewQuery.js +180 -0
  31. package/dist/review/session.d.ts +4 -0
  32. package/dist/review/session.js +8 -0
  33. package/dist/reviewSnapshot.d.ts +15 -0
  34. package/dist/reviewSnapshot.js +12 -0
  35. package/dist/root-lru.d.ts +4 -0
  36. package/{src/root-lru.ts → dist/root-lru.js} +26 -30
  37. package/dist/specs.d.ts +117 -0
  38. package/dist/specs.js +489 -0
  39. package/package.json +19 -8
  40. package/src/anchors.ts +0 -728
  41. package/src/git.ts +0 -2556
  42. package/src/graph.ts +0 -251
  43. package/src/harness-identity.ts +0 -26
  44. package/src/identity-presets.d.ts +0 -13
  45. package/src/identity-presets.js +0 -138
  46. package/src/index.ts +0 -20
  47. package/src/layout.ts +0 -637
  48. package/src/process-identity.ts +0 -207
  49. package/src/project-identity.ts +0 -73
  50. package/src/project-store.ts +0 -17
  51. package/src/resilience.ts +0 -41
  52. package/src/review/reviewFilters.js +0 -324
  53. package/src/review/reviewQuery.js +0 -174
  54. package/src/review/session.js +0 -13
  55. package/src/reviewSnapshot.ts +0 -28
  56. package/src/specs.ts +0 -498
@@ -1,207 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { execFileSync } from 'node:child_process'
3
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
4
- import { platform } from 'node:os'
5
- import { dirname, join } from 'node:path'
6
-
7
- export type ProcessIdentity = { pid: number; startToken: string }
8
-
9
- export type ProcessAdapter = Readonly<{
10
- platform: NodeJS.Platform
11
- startToken(pid: number): string | null
12
- processGroupId(pid: number): number | null
13
- linuxSessionId(pid: number): number | null
14
- }>
15
-
16
- export type VerifiedDetachedRuntime = Readonly<ProcessIdentity & {
17
- receiptVersion: 4
18
- processGroupId: number
19
- linuxSessionId?: number
20
- }>
21
-
22
- export type DetachedRuntimeVerification =
23
- | { ok: true; identity: VerifiedDetachedRuntime }
24
- | { ok: false; reason: string }
25
-
26
- type DetachedLaunchReceiptV4 = {
27
- version: 4
28
- kind: 'spexcode-detached-runtime'
29
- pid: number
30
- startToken: string
31
- processGroupId: number
32
- linuxSessionId?: number
33
- }
34
-
35
- export function parseProcStat(text: string): { ppid: number; processGroupId: number; sessionId: number; ticks: number; startToken: string; rssPages: number } {
36
- const end = text.lastIndexOf(')')
37
- if (end < 0) throw new Error('malformed proc stat')
38
- const fields = text.slice(end + 2).trim().split(/\s+/)
39
- if (fields.length < 22) throw new Error('short proc stat')
40
- return {
41
- ppid: Number(fields[1]),
42
- processGroupId: Number(fields[2]),
43
- sessionId: Number(fields[3]),
44
- ticks: Number(fields[11]) + Number(fields[12]),
45
- startToken: fields[19],
46
- rssPages: Number(fields[21]),
47
- }
48
- }
49
-
50
- export function processStartToken(pid: number, procRoot = '/proc'): string | null {
51
- if (platform() === 'linux' || procRoot !== '/proc') {
52
- try { return parseProcStat(readFileSync(join(procRoot, String(pid), 'stat'), 'utf8')).startToken }
53
- catch { return null }
54
- }
55
- try {
56
- const started = execFileSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8' }).trim()
57
- return started || null
58
- } catch { return null }
59
- }
60
-
61
- const procField = (pid: number, field: 'processGroupId' | 'sessionId'): number | null => {
62
- try {
63
- const stat = parseProcStat(readFileSync(join('/proc', String(pid), 'stat'), 'utf8'))
64
- return stat[field]
65
- } catch { return null }
66
- }
67
-
68
- const psProcessGroupId = (pid: number): number | null => {
69
- try {
70
- const value = Number(execFileSync('ps', ['-o', 'pgid=', '-p', String(pid)], { encoding: 'utf8' }).trim())
71
- return Number.isInteger(value) && value > 0 ? value : null
72
- } catch { return null }
73
- }
74
-
75
- export const hostProcessAdapter: ProcessAdapter = Object.freeze({
76
- platform: platform(),
77
- startToken: (pid) => processStartToken(pid),
78
- processGroupId: (pid) => platform() === 'linux' ? procField(pid, 'processGroupId') : psProcessGroupId(pid),
79
- linuxSessionId: (pid) => platform() === 'linux' ? procField(pid, 'sessionId') : null,
80
- })
81
-
82
- const exactKeys = (value: Record<string, unknown>, expected: string[]) => {
83
- const actual = Object.keys(value).sort()
84
- return actual.length === expected.length && actual.every((key, index) => key === expected[index])
85
- }
86
-
87
- const parseDetachedLaunchReceipt = (text: string, hostPlatform: NodeJS.Platform): DetachedLaunchReceiptV4 | null => {
88
- let value: unknown
89
- try { value = JSON.parse(text) } catch { return null }
90
- if (!value || typeof value !== 'object' || Array.isArray(value)) return null
91
- const receipt = value as Record<string, unknown>
92
- const keys = ['kind', 'pid', 'processGroupId', 'startToken', 'version', ...(hostPlatform === 'linux' ? ['linuxSessionId'] : [])].sort()
93
- if (!exactKeys(receipt, keys) || receipt.version !== 4 || receipt.kind !== 'spexcode-detached-runtime' ||
94
- !Number.isInteger(receipt.pid) || Number(receipt.pid) <= 0 || typeof receipt.startToken !== 'string' || !receipt.startToken ||
95
- !Number.isInteger(receipt.processGroupId) || Number(receipt.processGroupId) <= 0 ||
96
- (hostPlatform === 'linux' && (!Number.isInteger(receipt.linuxSessionId) || Number(receipt.linuxSessionId) <= 0))) return null
97
- return receipt as DetachedLaunchReceiptV4
98
- }
99
-
100
- const observeDetachedRuntime = (pid: number, adapter: ProcessAdapter): DetachedRuntimeVerification => {
101
- if (adapter.platform === 'win32') return { ok: false, reason: 'detached runtime identity is unsupported on win32' }
102
- const startBefore = adapter.startToken(pid)
103
- if (!startBefore) return { ok: false, reason: `PID ${pid} has no readable process-start identity` }
104
- const processGroupId = adapter.processGroupId(pid)
105
- if (processGroupId !== pid) return { ok: false, reason: `PID ${pid} is not its own process-group leader (pgrp=${processGroupId ?? 'unknown'})` }
106
- let linuxSessionId: number | undefined
107
- if (adapter.platform === 'linux') {
108
- const sessionId = adapter.linuxSessionId(pid)
109
- if (sessionId !== pid) return { ok: false, reason: `Linux PID ${pid} is not its own session leader (session=${sessionId ?? 'unknown'})` }
110
- linuxSessionId = sessionId
111
- }
112
- const startAfter = adapter.startToken(pid)
113
- if (!startAfter || startAfter !== startBefore)
114
- return { ok: false, reason: `PID ${pid} process-start identity changed during detached-boundary verification` }
115
- return {
116
- ok: true,
117
- identity: Object.freeze({
118
- pid,
119
- startToken: startAfter,
120
- receiptVersion: 4,
121
- processGroupId,
122
- ...(linuxSessionId === undefined ? {} : { linuxSessionId }),
123
- }),
124
- }
125
- }
126
-
127
- export function verifyDetachedRuntime(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): DetachedRuntimeVerification {
128
- let text: string
129
- try { text = readFileSync(receiptFile, 'utf8') }
130
- catch { return { ok: false, reason: 'detached launch receipt is missing or unreadable' } }
131
- const receipt = parseDetachedLaunchReceipt(text, adapter.platform)
132
- if (!receipt) return { ok: false, reason: 'detached launch receipt has the wrong version or shape' }
133
- if (receipt.pid !== pid) return { ok: false, reason: `detached launch receipt names PID ${receipt.pid}, not ${pid}` }
134
- if (receipt.processGroupId !== pid)
135
- return { ok: false, reason: `detached launch receipt has wrong process group ${receipt.processGroupId} for PID ${pid}` }
136
- if (adapter.platform === 'linux' && receipt.linuxSessionId !== pid)
137
- return { ok: false, reason: `detached launch receipt has wrong Linux session ${receipt.linuxSessionId ?? 'missing'} for PID ${pid}` }
138
- const live = observeDetachedRuntime(pid, adapter)
139
- if (!live.ok) return live
140
- if (live.identity.startToken !== receipt.startToken)
141
- return { ok: false, reason: `PID ${pid} process-start identity does not match its detached launch receipt` }
142
- return live
143
- }
144
-
145
- // The inverse question of verifyDetachedRuntime: not "is the recorded process running right now" but "is it
146
- // provably GONE". The two are not complements — a missing receipt, an unreadable identity, or a PID we never
147
- // recorded answers neither, and a caller that treats "unproven" as "dead" would retire a live runtime it can
148
- // simply no longer address. Only a recorded start identity that the live PID no longer matches (including a
149
- // PID that is not running at all) is proof of death.
150
- export function detachedRuntimeIsGone(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): boolean {
151
- let receipt: DetachedLaunchReceiptV4 | null
152
- try { receipt = parseDetachedLaunchReceipt(readFileSync(receiptFile, 'utf8'), adapter.platform) }
153
- catch { return false }
154
- if (!receipt || receipt.pid !== pid) return false
155
- return adapter.startToken(pid) !== receipt.startToken
156
- }
157
-
158
- // A v3 scope is only a migration witness when every recorded and live Linux identity agrees. Readers never
159
- // call this: minting a v4 receipt belongs to the write admission path that needs the shared runtime.
160
- export function migrateLegacyDetachedRuntimeReceipt(
161
- pid: number,
162
- legacyScopeFile: string,
163
- receiptFile: string,
164
- adapter: ProcessAdapter = hostProcessAdapter,
165
- ): boolean {
166
- if (adapter.platform !== 'linux' || verifyDetachedRuntime(pid, receiptFile, adapter).ok) return false
167
- let fields: string[]
168
- try { fields = readFileSync(legacyScopeFile, 'utf8').trim().split(/\s+/) }
169
- catch { return false }
170
- const observed = observeDetachedRuntime(pid, adapter)
171
- if (!observed.ok || observed.identity.linuxSessionId === undefined) return false
172
- const expected = ['detached-v3', String(pid), observed.identity.startToken, String(observed.identity.processGroupId), String(observed.identity.linuxSessionId)]
173
- if (fields.length !== expected.length || fields.some((field, index) => field !== expected[index])) return false
174
- try { writeDetachedRuntimeReceipt(pid, receiptFile, adapter); return true }
175
- catch { return false }
176
- }
177
-
178
- export function writeDetachedRuntimeReceipt(pid: number, receiptFile: string, adapter: ProcessAdapter = hostProcessAdapter): VerifiedDetachedRuntime {
179
- const observed = observeDetachedRuntime(pid, adapter)
180
- if (!observed.ok) throw new Error(`cannot prove detached shared runtime: ${observed.reason}`)
181
- const receipt: DetachedLaunchReceiptV4 = {
182
- version: 4,
183
- kind: 'spexcode-detached-runtime',
184
- pid,
185
- startToken: observed.identity.startToken,
186
- processGroupId: observed.identity.processGroupId,
187
- ...(observed.identity.linuxSessionId === undefined ? {} : { linuxSessionId: observed.identity.linuxSessionId }),
188
- }
189
- mkdirSync(dirname(receiptFile), { recursive: true })
190
- const tmp = `${receiptFile}.${process.pid}.${randomUUID()}.tmp`
191
- let published = false
192
- try {
193
- writeFileSync(tmp, `${JSON.stringify(receipt)}\n`, { mode: 0o600 })
194
- renameSync(tmp, receiptFile)
195
- published = true
196
- const verified = verifyDetachedRuntime(pid, receiptFile, adapter)
197
- if (!verified.ok) throw new Error(`cannot verify detached shared runtime receipt: ${verified.reason}`)
198
- return verified.identity
199
- } catch (error) {
200
- rmSync(tmp, { force: true })
201
- if (published) rmSync(receiptFile, { force: true })
202
- throw error
203
- }
204
- }
205
-
206
- export const detachedRuntimeGenerationToken = (identity: VerifiedDetachedRuntime) =>
207
- `detached-v4|${identity.pid}|${identity.startToken}|${identity.processGroupId}|${identity.linuxSessionId ?? '-'}`
@@ -1,73 +0,0 @@
1
- import { createHash } from 'node:crypto'
2
- import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
3
- import { basename, dirname, join } from 'node:path'
4
- import { spexcodeHome } from './project-store.js'
5
- import {
6
- resolvedIdentityIcon, DEFAULT_GATEWAY_ICON, DEFAULT_PROJECT_ICON, requireIdentityChoice,
7
- } from './identity-presets.js'
8
-
9
- export type ResolvedIdentity = { title: string; icon: string }
10
- export type GatewayIdentitySource = { identity: ResolvedIdentity; revision: string }
11
-
12
- const revisionOf = (raw: string | null): string =>
13
- createHash('sha256').update(raw === null ? 'missing' : `present\0${raw}`).digest('hex')
14
-
15
- function readObject(file: string): { value: Record<string, any>; raw: string | null } {
16
- if (!existsSync(file)) return { value: {}, raw: null }
17
- const raw = readFileSync(file, 'utf8')
18
- let value: unknown
19
- try { value = JSON.parse(raw) }
20
- catch (e) { throw new Error(`malformed ${file}: ${(e as Error).message}`) }
21
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`${file} must contain one top-level JSON object`)
22
- return { value: value as Record<string, any>, raw }
23
- }
24
-
25
- export function resolveProjectIdentity(configRoot: string, canonicalRoot = configRoot): ResolvedIdentity {
26
- const { value } = readObject(join(configRoot, 'spexcode.json'))
27
- const dashboard = value.dashboard && typeof value.dashboard === 'object' && !Array.isArray(value.dashboard)
28
- ? value.dashboard as Record<string, unknown>
29
- : {}
30
- const configuredTitle = typeof dashboard.title === 'string' ? dashboard.title.trim() : ''
31
- return {
32
- title: configuredTitle || basename(canonicalRoot),
33
- icon: resolvedIdentityIcon(dashboard.icon, DEFAULT_PROJECT_ICON),
34
- }
35
- }
36
-
37
- export const hostConfigPath = (): string => join(spexcodeHome(), 'config.json')
38
-
39
- export function readGatewayIdentity(): GatewayIdentitySource {
40
- const { value, raw } = readObject(hostConfigPath())
41
- const gateway = value.gateway && typeof value.gateway === 'object' && !Array.isArray(value.gateway)
42
- ? value.gateway as Record<string, unknown>
43
- : {}
44
- return {
45
- identity: { title: 'Projects', icon: resolvedIdentityIcon(gateway.icon, DEFAULT_GATEWAY_ICON) },
46
- revision: revisionOf(raw),
47
- }
48
- }
49
-
50
- export function writeGatewayIcon(icon: unknown, revision: string): GatewayIdentitySource {
51
- const canonical = requireIdentityChoice(icon)
52
- const file = hostConfigPath()
53
- const current = readObject(file)
54
- if (revision !== revisionOf(current.raw)) {
55
- const error = new Error('host config changed on disk — reload before saving') as Error & { status?: number }
56
- error.status = 409
57
- throw error
58
- }
59
- const gateway = current.value.gateway && typeof current.value.gateway === 'object' && !Array.isArray(current.value.gateway)
60
- ? current.value.gateway as Record<string, unknown>
61
- : {}
62
- const next = { ...current.value, gateway: { ...gateway, icon: canonical } }
63
- const raw = `${JSON.stringify(next, null, 2)}\n`
64
- mkdirSync(dirname(file), { recursive: true, mode: 0o700 })
65
- const tmp = `${file}.${process.pid}.tmp`
66
- try {
67
- writeFileSync(tmp, raw, { mode: 0o600 })
68
- renameSync(tmp, file)
69
- } finally {
70
- try { rmSync(tmp) } catch { /* rename consumed it / write never created it */ }
71
- }
72
- return { identity: { title: 'Projects', icon: canonical }, revision: revisionOf(raw) }
73
- }
@@ -1,17 +0,0 @@
1
- import { homedir } from 'node:os'
2
- import { dirname, join } from 'node:path'
3
-
4
- // One pure identity seam for every project-scoped runtime consumer. Git discovery stays with callers;
5
- // once they have the shared common dir, sessions, materialize, history indexes, backends, and uninstall
6
- // all resolve the same top-level store.
7
- export function spexcodeHome(): string {
8
- return process.env.SPEXCODE_HOME || join(homedir(), '.spexcode')
9
- }
10
-
11
- export function encodeProject(root: string): string {
12
- return root.replace(/[/.]/g, '-')
13
- }
14
-
15
- export function projectRuntimeRoot(commonDir: string): string {
16
- return join(spexcodeHome(), 'projects', encodeProject(dirname(commonDir)))
17
- }
package/src/resilience.ts DELETED
@@ -1,41 +0,0 @@
1
- import { existsSync } from 'node:fs'
2
-
3
- function describe(e: unknown): string {
4
- return e instanceof Error ? e.message : String(e)
5
- }
6
-
7
- // run a per-worktree DETAIL read; on a throw, branch on whether the worktree DIRECTORY still exists.
8
- // dir gone → the worktree was genuinely removed mid-read → return null so the caller omits it.
9
- // dir present → a flaky detail read (ENOENT race on a sibling file, or a git index/ref lock under a
10
- // concurrent merge) → return the caller's `degraded` row so an EXISTING worktree is NEVER
11
- // dropped from the board. read-failure != non-existence.
12
- // `dir` is the worktree path (the existence fact). Accepts a sync OR async fn — resolveLayout's overlay
13
- // read is async, listSessions' row read is sync; `degraded` is a sync raw-facts fallback.
14
- export async function guardWorktree<T>(dir: string, fn: () => T | Promise<T>, degraded: () => T): Promise<T | null> {
15
- try {
16
- return await fn()
17
- } catch (e) {
18
- if (!existsSync(dir)) {
19
- console.warn(`spec-cli: worktree ${dir} gone from disk (removed mid-read), omitting: ${describe(e)}`)
20
- return null
21
- }
22
- console.warn(`spec-cli: worktree ${dir} detail-read failed (transient/lock); serving degraded row: ${describe(e)}`)
23
- return degraded()
24
- }
25
- }
26
-
27
- // the last-resort process net: log an otherwise-fatal async throw and KEEP SERVING. Registering these
28
- // handlers is itself what overrides Node's default "print the stack and exit" — so the backend rides out
29
- // a transient race it didn't anticipate instead of dropping the public port. Idempotent (guarded) so a
30
- // double-install in one process can't stack duplicate handlers.
31
- let guardsInstalled = false
32
- export function installProcessGuards(): void {
33
- if (guardsInstalled) return
34
- guardsInstalled = true
35
- process.on('unhandledRejection', (reason) => {
36
- console.error(`spec-cli: unhandledRejection kept the server alive (investigate): ${reason instanceof Error ? reason.stack : describe(reason)}`)
37
- })
38
- process.on('uncaughtException', (err) => {
39
- console.error(`spec-cli: uncaughtException kept the server alive (investigate): ${err?.stack || describe(err)}`)
40
- })
41
- }
@@ -1,324 +0,0 @@
1
- import { sessionHeadline, sessionPresent } from './session.js'
2
- import { effectiveTokens, tokenize } from './reviewQuery.js'
3
-
4
- // [[review-filters]]: one pure filter engine — the single home of Issues/Evals FIELD SEMANTICS. Domain
5
- // adapters below provide field values; surfaces provide only the state home and presentation: the
6
- // canonical pages parse their ONE visible token text ([[review-query]]) and bridge it here, the Spec
7
- // Information panes keep plain local state. Neither duplicates matching.
8
-
9
- const text = (value) => String(value ?? '').trim().toLocaleLowerCase()
10
- const values = (value) => (Array.isArray(value) ? value : value == null || value === '' ? [] : [value])
11
- const unique = (items) => [...new Set(items.flatMap(values).filter((value) => value != null && value !== '').map(String))]
12
-
13
- export const EVAL_FILTER_KIND = Object.freeze({
14
- RESULT: 'result',
15
- BLIND: 'blind',
16
- UNMEASURED: 'unmeasured',
17
- DANGLING: 'dangling',
18
- })
19
-
20
- export const evidenceList = (reading) =>
21
- reading?.evidence?.length ? reading.evidence
22
- : reading?.blob != null ? [{ hash: reading.blob, kind: reading.blobKind || 'image', state: reading.blobState || 'present' }]
23
- : []
24
-
25
- export const kindsOf = (reading) => {
26
- const evidence = evidenceList(reading)
27
- if (!evidence.length) return ['note']
28
- return ['video', 'image', 'transcript', 'data'].filter((kind) => evidence.some((item) => item.kind === kind))
29
- }
30
-
31
- const optionLabel = (t, key, fallback) => t ? t(key) : fallback
32
- const allOption = (t) => ({ value: '', label: optionLabel(t, 'reviewList.all', 'All') })
33
- const optionsFor = (items, facet, state, context, t) => {
34
- const active = state[facet.key] != null && String(state[facet.key]) !== ''
35
- // a fixed-value ENUM facet keeps its ACTIVE value as a real (checked) row even when no data carries
36
- // it — an active facet must never hide its own off-switch; data-valued facets stay data-derived.
37
- const found = facet.fixedValues
38
- ? facet.fixedValues.filter((value) => facet.enumerateFixed
39
- || (active && String(state[facet.key]) === String(value))
40
- || items.some((item) => values(facet.values(item, context)).map(String).includes(String(value))))
41
- : unique(items.map((item) => facet.values(item, context)))
42
- const available = facet.available
43
- ? facet.available(found, items, state, context)
44
- : found.length >= (facet.minValues ?? 2)
45
- if (!available && !active) return []
46
- return [allOption(t), ...found.map((value) => ({
47
- value,
48
- label: facet.labelValue ? facet.labelValue(value, context) : value,
49
- }))]
50
- }
51
-
52
- // A SPLIT section reports its count as two named buckets instead of one number. The predicate is BINARY
53
- // and applied to the section's own matched rows, so the buckets always re-add to that section's whole
54
- // population — a surface can lead with one half without the count quietly shrinking. The adapter alone
55
- // decides which sections split and on what axis; the engine invents no bucket.
56
- const splitCount = (matched, split, context) => {
57
- const first = matched.filter((item) => split.is(item, context)).length
58
- return { [split.keys[0]]: first, [split.keys[1]]: matched.length - first }
59
- }
60
-
61
- // the one reading rule for a section count, whichever shape the adapter declared.
62
- export const sectionTotal = (count) => (typeof count === 'number'
63
- ? count
64
- : Object.values(count ?? {}).reduce((sum, value) => sum + value, 0))
65
-
66
- export function filterReviewItems(items, state, config, context = {}) {
67
- // `q` is one substring or an ARRAY of substrings (the token text's bare words/phrases), conjunctive;
68
- // an `impossible` state (an unknown qualifier in the canonical text) honestly matches NOTHING.
69
- const qs = values(state.q).map(text).filter(Boolean)
70
- const faceted = state.impossible ? [] : items.filter((item) => (
71
- qs.every((q) => config.search(item, context).some((value) => text(value).includes(q)))
72
- && config.facets.every((facet) => {
73
- const selected = state[facet.key]
74
- return selected == null || selected === ''
75
- || (facet.matches
76
- ? facet.matches(item, selected, context)
77
- : values(facet.values(item, context)).map(String).includes(String(selected)))
78
- })
79
- ))
80
- const sectionValue = config.section && state[config.section.key]
81
- const sectionMatch = (item, selected) => (config.section.matches
82
- ? config.section.matches(item, selected, context)
83
- : String(config.section.value(item, context)) === String(selected))
84
- const shown = sectionValue == null || sectionValue === ''
85
- ? faceted
86
- : faceted.filter((item) => sectionMatch(item, sectionValue))
87
- const sections = config.section
88
- ? Object.fromEntries(config.section.options.map((option) => {
89
- const matched = faceted.filter((item) => sectionMatch(item, option.value))
90
- return [option.value, option.split && config.section.split
91
- ? splitCount(matched, config.section.split, context)
92
- : matched.length]
93
- }))
94
- : {}
95
- const facets = Object.fromEntries(config.facets.map((facet) => [facet.key, {
96
- key: facet.key,
97
- label: optionLabel(context.t, facet.label, facet.key),
98
- value: state[facet.key] || '',
99
- options: optionsFor(items, facet, state, context, context.t),
100
- }]))
101
- return { state, faceted, shown, sections, facets }
102
- }
103
-
104
- export const reviewActorName = (actor) => String(actor || '').length > 22 ? `${String(actor).slice(0, 8)}…` : actor
105
- // the source-session PRESENCE join ([[live-session-filter]]): originator or any reply author still
106
- // resolves on the board — membership, never liveness.
107
- const issuePresent = (issue, sessions) => !!sessionPresent(sessions, issue.by)
108
- || (Array.isArray(issue.replies) && issue.replies.some((reply) => sessionPresent(sessions, reply.by)))
109
- const presenceFacet = (valuesOf) => ({
110
- key: 'session', label: 'reviewList.facetSession', fixedValues: ['present', 'missing'],
111
- values: valuesOf,
112
- labelValue: (value, { t }) => optionLabel(t, value === 'present' ? 'reviewList.sessionPresent' : 'reviewList.sessionMissing', value),
113
- })
114
-
115
- export function issueFilterState(raw = {}, { defaultSection = '' } = {}) {
116
- const state = raw.state === 'closed' || raw.concluded === '1'
117
- ? 'closed'
118
- : raw.state || defaultSection
119
- return {
120
- q: raw.q || '', state, impossible: raw.impossible === true,
121
- author: raw.author || '', store: raw.store || '', node: raw.node || '', label: raw.label || '',
122
- session: raw.session || '',
123
- }
124
- }
125
-
126
- const ISSUE_CONFIG = {
127
- search: (issue) => [issue.id, issue.concern, issue.by, ...(issue.nodes || [])],
128
- section: {
129
- key: 'state',
130
- value: (issue) => issue.status === 'open' ? 'open' : 'closed',
131
- // open|closed are the lifecycle halves; a concrete concluded spelling (landed) matches that status
132
- // honestly instead of pretending the enum is binary.
133
- matches: (issue, selected) => (selected === 'open' ? issue.status === 'open'
134
- : selected === 'closed' ? issue.status !== 'open' : issue.status === selected),
135
- options: [{ value: 'open', label: 'reviewList.open' }, { value: 'closed', label: 'reviewList.closed' }],
136
- },
137
- facets: [
138
- { key: 'author', label: 'reviewList.facetAuthor', values: (issue) => issue.by, labelValue: reviewActorName },
139
- { key: 'store', label: 'reviewList.facetStore', values: (issue) => issue.store },
140
- { key: 'node', label: 'reviewList.facetNode', values: (issue) => issue.nodes || [] },
141
- { key: 'label', label: 'reviewList.facetLabel', values: (issue) => (issue.labels || []).map((label) => typeof label === 'string' ? label : label?.name), minValues: 1 },
142
- presenceFacet((issue, { sessions }) => issuePresent(issue, sessions) ? 'present' : 'missing'),
143
- ],
144
- }
145
-
146
- export function issueFilterModel(items, raw = {}, context = {}) {
147
- const state = issueFilterState(raw, { defaultSection: context.defaultSection ?? '' })
148
- const model = filterReviewItems(items, state, ISSUE_CONFIG, context)
149
- model.section = {
150
- key: 'state', label: optionLabel(context.t, 'reviewList.facetState', 'State'), value: state.state,
151
- meaningful: Object.values(model.sections).filter((count) => sectionTotal(count) > 0).length > 1 || !!state.state,
152
- options: [allOption(context.t), ...ISSUE_CONFIG.section.options.map((option) => ({
153
- value: option.value,
154
- label: optionLabel(context.t, option.label, option.value),
155
- count: sectionTotal(model.sections[option.value]),
156
- }))].filter((option, index, all) => index === 0 || option.count > 0 || option.value === state.state),
157
- }
158
- return model
159
- }
160
-
161
- const evalIsResult = (entry) => entry.filterKind === EVAL_FILTER_KIND.RESULT
162
- const verdictOf = (entry) => evalIsResult(entry)
163
- ? (entry.verdict?.status || 'unscored')
164
- : entry.filterKind === EVAL_FILTER_KIND.BLIND || entry.filterKind === EVAL_FILTER_KIND.UNMEASURED
165
- ? 'unmeasured'
166
- : 'unscored'
167
- const reviewStateOf = (entry) => (evalIsResult(entry) && entry.fresh && entry.humanOk ? 'reviewed' : 'current')
168
- // the ONE freshness axis of the Eval adapter — the facet's option values and the verdict sections' split
169
- // read it, so a chip and its Freshness menu can never disagree about what "stale" counts.
170
- const evalFresh = (entry) => entry.fresh === true
171
- const freshnessOf = (entry) => (evalIsResult(entry) ? (evalFresh(entry) ? 'fresh' : 'stale') : null)
172
- export const evalReviewState = (reading) => {
173
- // an order-only row never had its freshness computed, so it has no verdict to report. Refuse it here
174
- // rather than let its conservative placeholder be published as a measured `stalePass`/`staleFail`: the
175
- // whole point of the deferred pass is that these rows exist only to establish sequence.
176
- if (reading?.freshnessDeferred)
177
- throw new Error(`eval row ${reading.scenario ?? '?'} has deferred freshness — it can order a list, never state a verdict`)
178
- const status = reading?.verdict?.status
179
- if (status !== 'pass' && status !== 'fail') return 'empty'
180
- if (reading.fresh) return status
181
- return status === 'pass' ? 'stalePass' : 'staleFail'
182
- }
183
- const shortSession = (value, sessions) => {
184
- const session = sessions.find((item) => item.id === value)
185
- if (session) return sessionHeadline(session)
186
- return String(value || '').length > 22 ? `${String(value).slice(0, 8)}…` : value
187
- }
188
-
189
- export function evalFilterState(raw = {}, { defaultKind = 'all', defaultSection = '' } = {}) {
190
- const legacyReview = raw.ok === '1' ? 'reviewed' : raw.ok
191
- return {
192
- q: raw.q || '', kind: raw.kind || defaultKind, impossible: raw.impossible === true,
193
- verdict: raw.verdict || '', freshness: raw.freshness || '', node: raw.node || '', filer: raw.filer || '',
194
- session: raw.session || '', review: raw.review || legacyReview || defaultSection,
195
- }
196
- }
197
-
198
- const EVAL_CONFIG = {
199
- search: (entry) => [entry.scenario, entry.node, entry.by, entry.evaluator],
200
- section: {
201
- key: 'verdict', value: verdictOf,
202
- // A MEASURED verdict carries a reading, so it also carries freshness: its count splits into the fresh
203
- // half and the stale half that still owes a re-measurement. The two halves re-add to the verdict's
204
- // whole population — splitting reports the remeasurement debt, it never hides a row. `unmeasured` owns
205
- // no reading and therefore no freshness axis, so it stays one honest number.
206
- split: { keys: ['fresh', 'stale'], is: evalFresh },
207
- options: [
208
- { value: 'fail', label: 'reviewList.verdict.fail', split: true },
209
- { value: 'pass', label: 'reviewList.verdict.pass', split: true },
210
- { value: 'unmeasured', label: 'reviewList.verdict.unmeasured' },
211
- ],
212
- },
213
- facets: [
214
- {
215
- key: 'review', label: 'reviewList.facetReview', fixedValues: ['current', 'reviewed'], enumerateFixed: true,
216
- values: reviewStateOf,
217
- labelValue: (value, { t }) => optionLabel(t, value === 'reviewed' ? 'reviewList.reviewed' : 'reviewList.needsReview', value),
218
- },
219
- {
220
- key: 'freshness', label: 'reviewList.facetFreshness', minValues: 2,
221
- values: (entry) => freshnessOf(entry) ?? [],
222
- labelValue: (value, { t }) => optionLabel(t, `reviewList.freshness.${value}`, value),
223
- },
224
- {
225
- key: 'kind', label: 'reviewList.facetKind', fixedValues: ['video', 'image'], minValues: 1,
226
- values: (entry) => evalIsResult(entry) ? kindsOf(entry).filter((kind) => kind === 'video' || kind === 'image') : [],
227
- matches: (entry, selected) => selected === 'all' || (evalIsResult(entry) && kindsOf(entry).includes(selected)),
228
- labelValue: (value, { t }) => optionLabel(t, `evalsFeed.kind.${value}`, value),
229
- available: (found, _items, state) => found.length > 0 || state.kind !== 'all',
230
- },
231
- { key: 'node', label: 'reviewList.facetNode', values: (entry) => entry.node },
232
- {
233
- key: 'filer', label: 'reviewList.facetFiler', values: (entry) => evalIsResult(entry) ? entry.by : [],
234
- labelValue: (value, { sessions = [] }) => shortSession(value, sessions),
235
- },
236
- presenceFacet((entry, { sessions }) => (evalIsResult(entry)
237
- ? (sessionPresent(sessions, entry.by) ? 'present' : 'missing')
238
- : [])),
239
- ],
240
- }
241
-
242
- export function evalFilterModel(items, raw = {}, context = {}) {
243
- const state = evalFilterState(raw, {
244
- defaultKind: context.defaultKind ?? 'all',
245
- defaultSection: context.defaultSection ?? '',
246
- })
247
- const model = filterReviewItems(items, state, EVAL_CONFIG, context)
248
- model.section = {
249
- key: 'verdict', label: optionLabel(context.t, 'reviewList.facetVerdict', 'Verdict'), value: state.verdict,
250
- meaningful: Object.values(model.sections).some((count) => sectionTotal(count) > 0) || !!state.verdict,
251
- // the menu/popup face of the same sections keeps the verdict's WHOLE count: a compact radio row has no
252
- // room for the split, and it must still agree with the list it filters.
253
- options: [allOption(context.t), ...EVAL_CONFIG.section.options.map((option) => ({
254
- value: option.value,
255
- label: optionLabel(context.t, option.label, option.value),
256
- count: sectionTotal(model.sections[option.value]),
257
- }))].filter((option, index) => index === 0 || option.count > 0 || option.value === state.verdict),
258
- }
259
- const kindFacet = model.facets.kind
260
- if (kindFacet.options.length) {
261
- kindFacet.options = [
262
- { value: 'all', label: optionLabel(context.t, 'evalsFeed.kind.all', 'All') },
263
- ...kindFacet.options.filter((option) => option.value !== '').map((option) => ({ ...option })),
264
- ]
265
- kindFacet.value = state.kind
266
- }
267
- return model
268
- }
269
-
270
- export function filterMenuGroups(model, onChange, keys) {
271
- return keys.map((key) => key === 'section' ? model.section : model.facets[key]).filter((facet) => (
272
- facet?.meaningful !== false
273
- && (facet?.options?.length > 1 || (facet?.value != null && facet.value !== '' && facet.value !== 'all'))
274
- )).map((facet) => ({
275
- key: facet.key,
276
- label: facet.label,
277
- value: facet.value,
278
- active: facet.value != null && facet.value !== '' && facet.value !== 'all',
279
- options: facet.options,
280
- clearLabel: facet.value === 'all' ? null : undefined,
281
- onChange: (value) => onChange({ [facet.key]: value || null }),
282
- }))
283
- }
284
-
285
- // the CANONICAL pages' bridge ([[review-query]] → this engine): parse the ONE visible token text into
286
- // engine state. Bare words/phrases become conjunctive q substrings; duplicate qualifiers are last-wins;
287
- // a qualifier outside the page's map (or a wrong is: identity) marks the state IMPOSSIBLE — the token
288
- // stays verbatim in the text and the list honestly shows nothing. `scope:` maps to no filter: it picks
289
- // the DATA SOURCE upstream ([[evals-view]]), never a per-row predicate.
290
- const TOKEN_MAPS = {
291
- issue: {
292
- is: (v) => (v === 'issue' ? {} : null),
293
- state: (v) => ({ state: v }),
294
- store: (v) => ({ store: v }),
295
- author: (v) => ({ author: v }),
296
- node: (v) => ({ node: v }),
297
- label: (v) => ({ label: v }),
298
- session: (v) => ({ session: v }),
299
- },
300
- eval: {
301
- is: (v) => (v === 'eval' ? {} : null),
302
- state: (v) => ({ review: v }),
303
- verdict: (v) => ({ verdict: v }),
304
- freshness: (v) => ({ freshness: v }),
305
- evidence: (v) => ({ kind: v }),
306
- node: (v) => ({ node: v }),
307
- filer: (v) => ({ filer: v }),
308
- session: (v) => ({ session: v }),
309
- scope: () => ({}),
310
- },
311
- }
312
-
313
- export function tokenFilterState(text, domain) {
314
- const map = TOKEN_MAPS[domain]
315
- const state = { q: [] }
316
- for (const token of effectiveTokens(tokenize(text))) {
317
- if (token.key == null) { state.q.push(token.value); continue }
318
- const toState = map[token.key]
319
- const mapped = toState ? toState(token.value) : null
320
- if (mapped == null) return { impossible: true, q: [] }
321
- Object.assign(state, mapped)
322
- }
323
- return state
324
- }