@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.
- package/package.json +18 -0
- package/src/anchors.ts +728 -0
- package/src/git.ts +2556 -0
- package/src/graph.ts +251 -0
- package/src/harness-identity.ts +26 -0
- package/src/identity-presets.d.ts +13 -0
- package/src/identity-presets.js +138 -0
- package/src/index.ts +20 -0
- package/src/layout.ts +637 -0
- package/src/process-identity.ts +207 -0
- package/src/project-identity.ts +73 -0
- package/src/project-store.ts +17 -0
- package/src/resilience.ts +41 -0
- package/src/review/index.js +5 -0
- package/src/review/reviewFilters.js +324 -0
- package/src/review/reviewQuery.js +174 -0
- package/src/review/session.js +13 -0
- package/src/reviewSnapshot.ts +28 -0
- package/src/root-lru.ts +54 -0
- package/src/specs.ts +498 -0
- package/templates/spexcode.json +33 -0
|
@@ -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
|
+
}
|
package/src/root-lru.ts
ADDED
|
@@ -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
|
+
}
|