@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.
- package/dist/anchors.d.ts +94 -0
- package/dist/anchors.js +730 -0
- package/dist/git.d.ts +166 -0
- package/dist/git.js +2736 -0
- package/dist/graph.d.ts +34 -0
- package/dist/graph.js +237 -0
- package/dist/graphDelta.d.ts +45 -0
- package/dist/graphDelta.js +84 -0
- package/dist/harness-identity.d.ts +31 -0
- package/dist/harness-identity.js +20 -0
- package/dist/identity-presets.d.ts +152 -0
- package/dist/identity-presets.js +132 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +19 -0
- package/dist/layout.d.ts +183 -0
- package/dist/layout.js +548 -0
- package/dist/process-identity.d.ts +37 -0
- package/dist/process-identity.js +214 -0
- package/dist/project-identity.d.ts +12 -0
- package/dist/project-identity.js +71 -0
- package/dist/project-store.d.ts +3 -0
- package/dist/project-store.js +14 -0
- package/dist/resilience.d.ts +2 -0
- package/dist/resilience.js +40 -0
- package/dist/review/index.d.ts +3 -0
- package/{src → dist}/review/index.js +3 -3
- package/dist/review/reviewFilters.d.ts +77 -0
- package/dist/review/reviewFilters.js +308 -0
- package/dist/review/reviewQuery.d.ts +66 -0
- package/dist/review/reviewQuery.js +180 -0
- package/dist/review/session.d.ts +4 -0
- package/dist/review/session.js +8 -0
- package/dist/reviewSnapshot.d.ts +15 -0
- package/dist/reviewSnapshot.js +12 -0
- package/dist/root-lru.d.ts +4 -0
- package/{src/root-lru.ts → dist/root-lru.js} +26 -30
- package/dist/specs.d.ts +117 -0
- package/dist/specs.js +489 -0
- package/package.json +19 -8
- package/src/anchors.ts +0 -728
- package/src/git.ts +0 -2556
- package/src/graph.ts +0 -251
- package/src/harness-identity.ts +0 -26
- package/src/identity-presets.d.ts +0 -13
- package/src/identity-presets.js +0 -138
- package/src/index.ts +0 -20
- package/src/layout.ts +0 -637
- package/src/process-identity.ts +0 -207
- package/src/project-identity.ts +0 -73
- package/src/project-store.ts +0 -17
- package/src/resilience.ts +0 -41
- package/src/review/reviewFilters.js +0 -324
- package/src/review/reviewQuery.js +0 -174
- package/src/review/session.js +0 -13
- package/src/reviewSnapshot.ts +0 -28
- package/src/specs.ts +0 -498
package/src/process-identity.ts
DELETED
|
@@ -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 ?? '-'}`
|
package/src/project-identity.ts
DELETED
|
@@ -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
|
-
}
|
package/src/project-store.ts
DELETED
|
@@ -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
|
-
}
|