@spexcode/spec-core 0.6.2

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.
@@ -0,0 +1,174 @@
1
+ // The ONE token-query engine ([[review-query]]) — pure JS, no React/DOM. Both review ListViews, the
2
+ // route layer's legacy replay, and any consumer minting canonical eval/issue addresses import from HERE:
3
+ // the visible query TEXT is the single source of truth, and every tab/menu/autocomplete is only a
4
+ // BUILDER that rewrites tokens in it (GitHub-measured semantics).
5
+
6
+ export const ISSUE_QUERY_DEFAULT = 'is:issue state:open'
7
+ export const EVAL_QUERY_DEFAULT = 'is:eval'
8
+ // the session doors' scoped-list address: the default view, scoped — the text shows exactly that.
9
+ export const scopedEvalQuery = (sessionId) => setToken(EVAL_QUERY_DEFAULT, 'scope', sessionId)
10
+ // the aggregate score/count doors' address ([[eval-score-badge]]): the default view, node-filtered.
11
+ export const nodeEvalQuery = (nodeId) => setToken(EVAL_QUERY_DEFAULT, 'node', nodeId)
12
+
13
+ const KEY_RE = /^([A-Za-z][A-Za-z0-9-]*):(.*)$/s
14
+ const unquote = (v) => (v.length >= 2 && v.startsWith('"') && v.endsWith('"') ? v.slice(1, -1) : v)
15
+ export const quoteValue = (v) => (/\s/.test(String(v)) ? `"${v}"` : String(v))
16
+
17
+ // segment scan preserving EVERY character — whitespace runs and tokens (a `"` swallows spaces until it
18
+ // closes), each with [start,end) offsets — so the aria-hidden highlight overlay mirrors the input
19
+ // glyph-for-glyph and the autocomplete can find the token under the caret.
20
+ export function scanQuery(text) {
21
+ const s = String(text ?? '')
22
+ const out = []
23
+ let i = 0
24
+ while (i < s.length) {
25
+ let j = i
26
+ if (/\s/.test(s[i])) {
27
+ while (j < s.length && /\s/.test(s[j])) j++
28
+ out.push({ ws: true, raw: s.slice(i, j), start: i, end: j })
29
+ } else {
30
+ while (j < s.length && !/\s/.test(s[j])) {
31
+ if (s[j] === '"') {
32
+ j++
33
+ while (j < s.length && s[j] !== '"') j++
34
+ if (j < s.length) j++
35
+ } else j++
36
+ }
37
+ const raw = s.slice(i, j)
38
+ const m = KEY_RE.exec(raw)
39
+ out.push(m
40
+ ? { ws: false, raw, start: i, end: j, key: m[1].toLowerCase(), value: unquote(m[2]) }
41
+ : { ws: false, raw, start: i, end: j, key: null, value: unquote(raw) })
42
+ }
43
+ i = j
44
+ }
45
+ return out
46
+ }
47
+
48
+ export const tokenize = (text) => scanQuery(text).filter((seg) => !seg.ws)
49
+ export const serialize = (tokens) => tokens.map((t) => t.raw).join(' ')
50
+ export const normalizeQuery = (text) => serialize(tokenize(text))
51
+ export const sameQuery = (a, b) => normalizeQuery(a) === normalizeQuery(b)
52
+
53
+ // duplicate qualifiers: the LAST occurrence wins; bare words all apply.
54
+ export const effectiveTokens = (tokens) => {
55
+ const last = new Map()
56
+ for (const t of tokens) if (t.key != null) last.set(t.key, t)
57
+ return tokens.filter((t) => t.key == null || last.get(t.key) === t)
58
+ }
59
+
60
+ export const readToken = (text, key) => {
61
+ const tokens = tokenize(text)
62
+ for (let i = tokens.length - 1; i >= 0; i--) if (tokens[i].key === key) return tokens[i].value
63
+ return ''
64
+ }
65
+
66
+ // token SURGERY: rewrite the key at its first position, drop later duplicates, append when absent,
67
+ // remove the key entirely on an empty value. Every other token — known or not — survives verbatim.
68
+ export function setToken(text, key, value) {
69
+ const next = value == null || String(value) === ''
70
+ ? null
71
+ : { ws: false, raw: `${key}:${quoteValue(value)}`, key, value: String(value) }
72
+ const out = []
73
+ let placed = false
74
+ for (const t of tokenize(text)) {
75
+ if (t.key === key) {
76
+ if (next && !placed) { out.push(next); placed = true }
77
+ continue
78
+ }
79
+ out.push(t)
80
+ }
81
+ if (next && !placed) out.push(next)
82
+ return serialize(out)
83
+ }
84
+
85
+ // MATCHING is deliberately NOT here: this module owns text — scan/serialize/surgery/suggestions and the
86
+ // canonical-address discipline — while the conjunctive field matching (including the unknown-qualifier
87
+ // IMPOSSIBLE state) lives in the ONE [[review-filters]] engine, reached through its tokenFilterState
88
+ // bridge. A second predicate here would be the exact fork the fusion removed.
89
+
90
+ // canonical address discipline: the default view is the BARE page address; any other state is exactly
91
+ // ?q=<raw text>. An emptied submit falls back to the default (→ bare).
92
+ export const queryParam = (text, defaultText) => {
93
+ const trimmed = String(text ?? '').trim()
94
+ if (!trimmed || sameQuery(trimmed, defaultText)) return null
95
+ return { q: trimmed }
96
+ }
97
+
98
+ // A query/filter action resets by omitting page; a PAGINATION action passes a page (including 1) and
99
+ // therefore records GitHub's explicit page=1 history form. The distinction is the action, not equivalence.
100
+ export const reviewRouteQuery = (text, defaultText, page = null) => {
101
+ const query = queryParam(text, defaultText) || {}
102
+ if (page != null) query.page = String(page)
103
+ return Object.keys(query).length ? query : null
104
+ }
105
+
106
+ // LEGACY structured params → token pairs. `kind=all` maps to nothing: the evidence default IS all.
107
+ const LEGACY_PARAMS = {
108
+ state: (v) => ['state', v],
109
+ concluded: (v) => (v === '1' ? ['state', 'closed'] : null),
110
+ ok: (v) => (v === '1' ? ['state', 'reviewed'] : null),
111
+ verdict: (v) => ['verdict', v],
112
+ freshness: (v) => ['freshness', v],
113
+ kind: (v) => (v === 'all' ? null : ['evidence', v]),
114
+ store: (v) => ['store', v],
115
+ author: (v) => ['author', v],
116
+ node: (v) => ['node', v],
117
+ filer: (v) => ['filer', v],
118
+ live: (v) => (v === '1' ? ['session', 'present'] : null),
119
+ session: (v) => ['scope', v],
120
+ }
121
+
122
+ export const hasLegacyParams = (query) =>
123
+ Object.keys(LEGACY_PARAMS).some((k) => query?.[k] != null && query[k] !== '')
124
+
125
+ // the legacy free q was ONE substring search — it must replay as ONE text token. Quote it whenever the
126
+ // tokenizer would read it as anything else: spaces (several words), a colon (q=drift:check would become
127
+ // an unknown qualifier and match zero), or a stray quote (which would swallow neighbours).
128
+ const freeTextToken = (v) => (/[\s:"]/.test(v) ? `"${v.replace(/"/g, '')}"` : v)
129
+
130
+ // a legacy LIST address replays as the FULL visible state: the page's default tokens with each legacy
131
+ // param surgically applied (live=1→session:present, session=<id>→scope:<id>, ok=1→state:reviewed,
132
+ // kind→evidence:), the free-text q appended as ONE bare/phrase token preserving the old
133
+ // single-substring search. Returns null when nothing legacy is present.
134
+ export function legacyQueryText(defaultText, query) {
135
+ if (!hasLegacyParams(query)) return null
136
+ let text = defaultText
137
+ for (const [param, toPair] of Object.entries(LEGACY_PARAMS)) {
138
+ const v = query[param]
139
+ if (v == null || v === '') continue
140
+ const pair = toPair(String(v))
141
+ if (pair) text = setToken(text, pair[0], pair[1])
142
+ }
143
+ const free = String(query.q ?? '').trim()
144
+ if (free) text = `${text} ${freeTextToken(free)}`.trim()
145
+ return text
146
+ }
147
+
148
+ // inline autocomplete at the caret — client-side and BOUNDED. A bare prefix completes qualifier KEYS
149
+ // (insert `key:`, keep typing); a `key:prefix` completes VALUES from the page-supplied candidate list
150
+ // only (data-derived; scope = sessions on the current board), capped at 8. Everything else stays
151
+ // hand-typable and submits verbatim.
152
+ export function suggestAt(text, caret, keys = [], values = {}) {
153
+ const s = String(text ?? '')
154
+ const at = Math.max(0, Math.min(caret ?? s.length, s.length))
155
+ const seg = scanQuery(s).find((g) => !g.ws && g.start < at && at <= g.end)
156
+ if (!seg) return { start: at, end: at, items: [] }
157
+ const typed = s.slice(seg.start, at)
158
+ const m = KEY_RE.exec(typed)
159
+ if (!m) {
160
+ const w = typed.toLowerCase()
161
+ if (!w || w.includes('"')) return { start: seg.start, end: seg.end, items: [] }
162
+ const items = keys.filter((k) => k.startsWith(w)).slice(0, 8)
163
+ .map((k) => ({ type: 'key', key: k, insert: `${k}:` }))
164
+ return { start: seg.start, end: seg.end, items }
165
+ }
166
+ const key = m[1].toLowerCase()
167
+ const prefix = m[2].replace(/^"/, '').replace(/"$/, '').toLowerCase()
168
+ const pool = values[key] || []
169
+ const items = pool
170
+ .filter((c) => String(c.value).toLowerCase().startsWith(prefix) && String(c.value).toLowerCase() !== prefix)
171
+ .slice(0, 8)
172
+ .map((c) => ({ type: 'value', key, value: String(c.value), label: c.label || null, insert: `${key}:${quoteValue(c.value)} ` }))
173
+ return { start: seg.start, end: seg.end, items }
174
+ }
@@ -0,0 +1,13 @@
1
+ export const sessionPresent = (sessions, id) => {
2
+ const session = id ? (sessions || []).find((item) => item.id === id) : null
3
+ return session || null
4
+ }
5
+
6
+ export const sessionHeadline = (session) =>
7
+ session?.title || session?.headline || session?.name || session?.activity || session?.note
8
+ || session?.promptPreview || session?.node || session?.raw?.title || session?.branch || session?.id
9
+
10
+ export const sessionHandle = (session) =>
11
+ session?.label || session?.name || session?.node || session?.title || session?.branch || session?.id
12
+
13
+ export const sessionTitle = sessionHeadline
@@ -0,0 +1,28 @@
1
+ export type ReviewEvalNode = {
2
+ id: string
3
+ hue?: number
4
+ scenarios: any[]
5
+ evals: any[]
6
+ readings: any[]
7
+ }
8
+
9
+ export type ReviewSnapshot = {
10
+ issues: any[]
11
+ evalNodes: ReviewEvalNode[]
12
+ forgeRevision: number
13
+ }
14
+
15
+ let current: ReviewSnapshot | null = null
16
+
17
+ export function publishReviewSnapshot(snapshot: ReviewSnapshot): void {
18
+ current = snapshot
19
+ }
20
+
21
+ export function readReviewSnapshot(): ReviewSnapshot {
22
+ if (!current) throw new Error('review snapshot is unavailable before the first successful graph build')
23
+ return current
24
+ }
25
+
26
+ export function hasReviewSnapshot(): boolean {
27
+ return current !== null
28
+ }
@@ -0,0 +1,54 @@
1
+ // @@@ root-lru - ONE bounded root→key cache policy, for every layer that keeps immutable per-HEAD work warm.
2
+ // A leaf module on purpose: it imports nothing from the spec graph, the eval sidecar, or git, so both layers
3
+ // can depend on it without either owning the other ([[source-of-truth]]).
4
+ //
5
+ // The policy is reference-counted, not plain LRU, and the distinction is the whole point. Entries are keyed by
6
+ // something IMMUTABLE (a HEAD, or a ledger path + HEAD), so two checkouts sitting on the same commit must share
7
+ // one entry rather than build it twice. A root moving A→B therefore drops A only when NO other root still
8
+ // points at it; otherwise a sequence of successful rebuilds retains one whole index per commit until the slot
9
+ // bound finally evicts them. Bumping an unchanged root is a pure recency touch (delete + reinsert), which is
10
+ // what makes `roots` insertion-ordered enough for the eviction loop to mean "oldest root".
11
+ //
12
+ // This existed twice, verbatim in logic and even in name — `touchRoot` in git.ts (index/drift) and again in
13
+ // spec-eval's scenariofresh.ts (scenario chains), whose comment said it was "mirroring historyIndex/driftIndex
14
+ // in git.ts". Both authors knew; neither had anywhere to put it. Now they do.
15
+
16
+ // slot bound for one cache family. Every caller names its own env knob and default so operators can tune the
17
+ // families independently, but nobody gets to invent a different FLOOR or a bare literal — a cache whose bound
18
+ // is a magic number cannot be tuned in the field at all (scenariofresh's was a hardcoded 16).
19
+ // @@@ unparseable is not zero - the shape both copies used, `Math.max(4, Number(env || fallback))`, returns
20
+ // NaN for a mistyped value, and `size > NaN` is always false: one typo in an env var silently turned the
21
+ // bound OFF and let the cache grow without limit. A bound that fails open is worse than no bound, because
22
+ // nothing reports it. Anything that does not parse to a positive number falls back to the caller's default.
23
+ export const rootSlots = (env: string | undefined, fallback: number): number => {
24
+ const asked = Number(env)
25
+ return Math.max(4, Number.isFinite(asked) && asked > 0 ? asked : fallback)
26
+ }
27
+
28
+ // Record that `root` now wants `key`, evicting what no root wants any more and keeping `roots` within `slots`.
29
+ // `cache` is the caller's own keyed store; this owns only the ROOT→key bookkeeping and the eviction decision.
30
+ export function touchRoot(
31
+ roots: Map<string, string>,
32
+ cache: Map<string, unknown> & { delete(key: string): boolean },
33
+ root: string,
34
+ key: string,
35
+ slots: number,
36
+ ): void {
37
+ const previous = roots.get(root)
38
+ if (previous !== key) {
39
+ roots.set(root, key)
40
+ // the old key survives only while some OTHER root still names it — immutable entries are shared, so
41
+ // dropping one root's view must not throw away a sibling checkout's warm work.
42
+ if (previous && ![...roots.values()].includes(previous)) cache.delete(previous)
43
+ } else {
44
+ roots.delete(root)
45
+ roots.set(root, key) // recency bump: reinsertion is what makes the eviction loop below pick the oldest
46
+ }
47
+ while (roots.size > slots) {
48
+ const oldest = roots.keys().next().value as string | undefined
49
+ if (oldest === undefined) break
50
+ const oldKey = roots.get(oldest)
51
+ roots.delete(oldest)
52
+ if (oldKey && ![...roots.values()].includes(oldKey)) cache.delete(oldKey)
53
+ }
54
+ }