@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/graph.ts
DELETED
|
@@ -1,251 +0,0 @@
|
|
|
1
|
-
import { deriveStatus } from './specs.js'
|
|
2
|
-
import { resolveProjectIdentity } from './project-identity.js'
|
|
3
|
-
import { publishReviewSnapshot } from './reviewSnapshot.js'
|
|
4
|
-
import { evalReviewState } from './review/reviewFilters.js'
|
|
5
|
-
|
|
6
|
-
// a ghost (added) node's parent: the existing node whose directory is the longest prefix of the new one.
|
|
7
|
-
function resolveParent(path: string, byDir: Record<string, string>): string | null {
|
|
8
|
-
const dir = path.replace(/\/spec\.md$/, '')
|
|
9
|
-
const segs = dir.split('/')
|
|
10
|
-
for (let k = segs.length - 1; k > 0; k--) {
|
|
11
|
-
const anc = segs.slice(0, k).join('/')
|
|
12
|
-
if (byDir[anc]) return byDir[anc]
|
|
13
|
-
}
|
|
14
|
-
return null
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const OVERLAY_TO_PATH = Symbol('overlay-to-path')
|
|
18
|
-
|
|
19
|
-
// The graph owns only the composition of these frozen inputs. Session enumeration and forge/issue reads
|
|
20
|
-
// belong to the CLI/server adapter, so this module has no hidden runtime-system fallback.
|
|
21
|
-
export type BoardSnapshot = {
|
|
22
|
-
root: string
|
|
23
|
-
specs: any[]
|
|
24
|
-
layout: { worktrees: any[] }
|
|
25
|
-
sessions: any[]
|
|
26
|
-
issues: any[]
|
|
27
|
-
issuesStamp: string
|
|
28
|
-
forgeRevision: string
|
|
29
|
-
evalTimelines: Map<string, any>
|
|
30
|
-
sessionEvalProjections: Map<string, any>
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// The server-only review snapshot keeps latest readings verbatim. Graph JSON receives only counts.
|
|
34
|
-
export function latestPerScenario<T extends { scenario: string }>(readings: T[]): T[] {
|
|
35
|
-
const seen = new Set<string>()
|
|
36
|
-
return readings.filter((r) => !seen.has(r.scenario) && (seen.add(r.scenario), true))
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export function nodeEvalSummary(scenarios: { name: string }[], readings: any[]) {
|
|
40
|
-
type State = 'pass' | 'fail' | 'stalePass' | 'staleFail' | 'empty'
|
|
41
|
-
const latest = new Map(latestPerScenario(readings).map((reading) => [reading.scenario, reading]))
|
|
42
|
-
const summary = { total: scenarios.length, pass: 0, fail: 0, stalePass: 0, staleFail: 0, empty: 0 }
|
|
43
|
-
for (const scenario of scenarios) {
|
|
44
|
-
const reading = latest.get(scenario.name)
|
|
45
|
-
const state = (reading ? evalReviewState(reading) : 'empty') as State
|
|
46
|
-
summary[state]++
|
|
47
|
-
}
|
|
48
|
-
return summary
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// @@@ a shelved row carries no delta ([[archive]]) - the board has TWO producers (a full buildBoard and the
|
|
52
|
-
// sessions-only spliceSessions), and shelving is a session-scoped write, so it takes the SPLICE path — which
|
|
53
|
-
// reuses the previous board's ops by path. Skipping the delta only in layout's row builder would therefore be
|
|
54
|
-
// invisible exactly when it fires: the splice would carry the stale ops forward forever. The rule belongs to
|
|
55
|
-
// the row, not to one producer, so both decorate through this one function.
|
|
56
|
-
const rowOps = (s: { path: string; archived?: boolean }, opsByPath: Record<string, any[]>): any[] =>
|
|
57
|
-
(s.archived ? [] : opsByPath[s.path] || [])
|
|
58
|
-
|
|
59
|
-
export async function buildBoard({ root, specs, layout, sessions, issues: merged, issuesStamp, forgeRevision, evalTimelines, sessionEvalProjections }: BoardSnapshot) {
|
|
60
|
-
const worktrees = layout.worktrees.filter((w) => !w.isMain)
|
|
61
|
-
// resolveLayout already zeroed ops for unmanaged worktrees, so this is just "has pending changes".
|
|
62
|
-
const opWts = worktrees.filter((w) => w.ops && w.ops.length)
|
|
63
|
-
|
|
64
|
-
const sessIdByPath: Record<string, string> = {}
|
|
65
|
-
sessions.forEach((s) => { sessIdByPath[s.path] = s.id })
|
|
66
|
-
const seedOf = (path: string): string => sessIdByPath[path] || path
|
|
67
|
-
|
|
68
|
-
const byId: Record<string, any> = Object.fromEntries(specs.map((n) => [n.id, n]))
|
|
69
|
-
const byDir: Record<string, string> = {}
|
|
70
|
-
specs.forEach((n: any) => { if (n.path) byDir[n.path.replace(/\/spec\.md$/, '')] = n.id })
|
|
71
|
-
// register each added node's own dir in byDir first, so resolveParent can chain a new node to its new
|
|
72
|
-
// ghost ancestor (a whole added subtree renders as one tree, not a flat scatter of roots).
|
|
73
|
-
for (const w of opWts) for (const op of w.ops) {
|
|
74
|
-
if (op.op === 'added') byDir[op.path.replace(/\/spec\.md$/, '')] = op.nodeId
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
const overlaysByNode: Record<string, any[]> = {}
|
|
78
|
-
const ghostById: Record<string, any> = {}
|
|
79
|
-
for (const w of opWts) {
|
|
80
|
-
const source = w.path, label = w.node || w.branch || w.path, seed = seedOf(w.path)
|
|
81
|
-
for (const op of w.ops) {
|
|
82
|
-
const ov = {
|
|
83
|
-
op: op.op, source, label, branch: w.branch, seed,
|
|
84
|
-
committed: op.committed, dirty: op.dirty,
|
|
85
|
-
toParent: op.op === 'moved' ? resolveParent(op.toPath || op.path, byDir) : null,
|
|
86
|
-
[OVERLAY_TO_PATH]: op.op === 'moved' ? op.toPath || op.path : null,
|
|
87
|
-
}
|
|
88
|
-
if (op.op === 'added' && !byId[op.nodeId]) {
|
|
89
|
-
if (ghostById[op.nodeId]) { ghostById[op.nodeId].overlays.push(ov); continue }
|
|
90
|
-
// a ghost is a node being ADDED by a worktree but not yet on main -> it has a pending op,
|
|
91
|
-
// so its derived status is `active` (live, in-flight), never `pending`.
|
|
92
|
-
ghostById[op.nodeId] = {
|
|
93
|
-
id: op.nodeId, parent: resolveParent(op.path, byDir), path: op.path,
|
|
94
|
-
title: op.nodeId, status: deriveStatus({ version: 0, drift: 0, hasOverlay: true }),
|
|
95
|
-
version: 0, session: null, fmStatus: null,
|
|
96
|
-
desc: '', code: [], related: [], body: '', drift: 0, driftFiles: [], ghost: true, overlays: [ov],
|
|
97
|
-
}
|
|
98
|
-
} else {
|
|
99
|
-
(overlaysByNode[op.nodeId] ??= []).push(ov)
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// re-derive status WITH the overlay so a node an unmerged worktree is touching reads `active` — the only
|
|
105
|
-
// place in-flight work is known, so the only place `active` is produced.
|
|
106
|
-
const nodes = [
|
|
107
|
-
...specs.map((n: any) => {
|
|
108
|
-
const overlays = overlaysByNode[n.id] || []
|
|
109
|
-
// `body` and its derivation `parts` are DROPPED from the board payload ([[graph-lean]]): together ~56% of
|
|
110
|
-
// the bytes, and detail the graph overview never renders. The detail view fetches them per node from
|
|
111
|
-
// `/api/specs/:id/content` on open, and the search palette fetches the body corpus from `/api/specs/lite`
|
|
112
|
-
// once on open — both off this hot poll. `undefined` makes JSON.stringify omit the keys.
|
|
113
|
-
return { ...n, body: undefined, parts: undefined, overlays, status: deriveStatus({ version: n.version, drift: n.drift, hasOverlay: overlays.length > 0, hasCode: (n.code?.length ?? 0) > 0, fmStatus: n.fmStatus ?? undefined }) }
|
|
114
|
-
}),
|
|
115
|
-
...Object.values(ghostById),
|
|
116
|
-
]
|
|
117
|
-
// The outer adapter reads Issues once. Full rows stay in the server-only review snapshot; graph nodes get
|
|
118
|
-
// counts and open identity only, enough for tile/stat/tree glances without reconstructing the list.
|
|
119
|
-
const isOpen = (i: { status: string }) => i.status === 'open'
|
|
120
|
-
// `issuesStamp` above is that ONE board-level freshness stamp, over EVERY thread — noded or nodeless,
|
|
121
|
-
// both stores, BOTH remark hosts. It is folded from the whole store and NOT from the split `merged`: a
|
|
122
|
-
// scenario-hosted remark lands on an eval track the issue read splits out ([[eval-issue-split]]), so a
|
|
123
|
-
// carrier folded over the issue half alone left an open READING blind to every remark on it — the write
|
|
124
|
-
// moved no board byte, [[graph-delta]] correctly suppressed the no-change broadcast, and the push never
|
|
125
|
-
// fired at all. The per-node fold below stays [[graph-lean]]-slim (no reply payloads).
|
|
126
|
-
const issuesByNode: Record<string, any[]> = {}
|
|
127
|
-
for (const issue of merged)
|
|
128
|
-
for (const nid of issue.nodes) (issuesByNode[nid] ??= []).push(issue)
|
|
129
|
-
for (const n of nodes) {
|
|
130
|
-
const issues = issuesByNode[n.id]
|
|
131
|
-
if (!issues || !issues.length) continue
|
|
132
|
-
const open = issues.filter(isOpen)
|
|
133
|
-
n.reviewSummary = {
|
|
134
|
-
...(n.reviewSummary || {}),
|
|
135
|
-
issues: { open: open.length, closed: issues.length - open.length, openIds: open.map((issue) => issue.id) },
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// The L1 adapter calculates current eval timelines once. Latest rows/declarations stay server-only for
|
|
140
|
-
// paged review; graph nodes receive the explicit per-state count projection and nothing row-shaped.
|
|
141
|
-
const evalReviewNodes = nodes.flatMap((n) => {
|
|
142
|
-
const timeline = evalTimelines.get(n.id)
|
|
143
|
-
if (!timeline?.hasEvalFile) return []
|
|
144
|
-
const latest = latestPerScenario(timeline.readings)
|
|
145
|
-
n.reviewSummary = { ...(n.reviewSummary || {}), evals: nodeEvalSummary(timeline.scenarios, latest) }
|
|
146
|
-
return [{ id: n.id, hue: n.hue, scenarios: timeline.scenarios, evals: latest, readings: timeline.readings }]
|
|
147
|
-
})
|
|
148
|
-
|
|
149
|
-
publishReviewSnapshot({ issues: merged, evalNodes: evalReviewNodes, forgeRevision })
|
|
150
|
-
|
|
151
|
-
const opsByPath: Record<string, any[]> = {}
|
|
152
|
-
opWts.forEach((w) => { opsByPath[w.path] = w.ops })
|
|
153
|
-
const sess = sessions.map((s) => ({
|
|
154
|
-
...s,
|
|
155
|
-
source: s.path,
|
|
156
|
-
ops: rowOps(s, opsByPath),
|
|
157
|
-
evalSummary: sessionEvalProjections.get(s.id),
|
|
158
|
-
}))
|
|
159
|
-
|
|
160
|
-
// One resolved identity projection feeds title, favicon, rail, and catalog compatibility. A worktree
|
|
161
|
-
// backend reads the actual served tree's branch config and identity; endpoint registration uses the
|
|
162
|
-
// same git toplevel, so a linked worktree occupies its own host slot instead of replacing main.
|
|
163
|
-
const identity = resolveProjectIdentity(root, root)
|
|
164
|
-
return { nodes, sessions: sess, identity, issuesStamp }
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
// @@@ spliceSessions — the SESSIONS-ONLY producer ([[graph-cache]]). A session-scoped change (a lifecycle
|
|
168
|
-
// write, a liveness/activity poll flip) reshapes only the board's `sessions` rows — the node/meta units are
|
|
169
|
-
// untouched — so the cache re-derives ONLY the sessions and splices them onto the previous board verbatim,
|
|
170
|
-
// skipping the whole loadSpecs/layout/eval assembly a full buildBoard() pays. The adapter supplies every
|
|
171
|
-
// live input:
|
|
172
|
-
// each row is decorated EXACTLY as buildBoard's sess mapping (`{...s, source: s.path, ops}`), and every
|
|
173
|
-
// path's `ops` is REUSED from the previous board (a path→ops map). A session path absent in `prev` gets []
|
|
174
|
-
// — a brand-new worktree has no pending spec ops yet, and any later ops-CHANGING event (a commit, a
|
|
175
|
-
// worktree `.spec` edit) is refs/worktree-scoped, i.e. a FULL rebuild, never a sessions splice. So the
|
|
176
|
-
// splice is byte-indistinguishable from a full rebuild whenever only session state moved.
|
|
177
|
-
export async function spliceSessions(
|
|
178
|
-
prev: Awaited<ReturnType<typeof buildBoard>>,
|
|
179
|
-
sessions: any[],
|
|
180
|
-
sessionEvalProjections: Map<string, any>,
|
|
181
|
-
): Promise<Awaited<ReturnType<typeof buildBoard>>> {
|
|
182
|
-
const activeSources = new Set(sessions.map((session) => session.path))
|
|
183
|
-
const opsByPath: Record<string, any[]> = {}
|
|
184
|
-
for (const s of prev.sessions) opsByPath[s.source] = s.ops
|
|
185
|
-
const sess = sessions.map((s) => ({
|
|
186
|
-
...s,
|
|
187
|
-
source: s.path,
|
|
188
|
-
ops: rowOps(s, opsByPath),
|
|
189
|
-
evalSummary: sessionEvalProjections.get(s.id),
|
|
190
|
-
}))
|
|
191
|
-
// Archive and close are subtractive topology changes: their worktree leaves the working set, so its
|
|
192
|
-
// overlays must leave in the same cheap publication as its row. Filtering the already-built units is exact
|
|
193
|
-
// and forks nothing. A newly-active source is intentionally absent here; graphCache keeps a full obligation
|
|
194
|
-
// for additions/resume so resolveLayout can discover that worktree's current delta.
|
|
195
|
-
let nodeProjectionMoved = false
|
|
196
|
-
const projectedNodes = prev.nodes.flatMap((node: any) => {
|
|
197
|
-
const overlays = (node.overlays || []).filter((overlay: any) => activeSources.has(overlay.source))
|
|
198
|
-
if (node.ghost && overlays.length === 0) { nodeProjectionMoved = true; return [] }
|
|
199
|
-
if (overlays.length === (node.overlays || []).length) return [node]
|
|
200
|
-
nodeProjectionMoved = true
|
|
201
|
-
if (node.ghost) return [{ ...node, overlays, status: deriveStatus({ version: 0, drift: 0, hasOverlay: true }) }]
|
|
202
|
-
return [{ ...node, overlays, status: deriveStatus({
|
|
203
|
-
version: node.version,
|
|
204
|
-
drift: node.drift,
|
|
205
|
-
hasOverlay: overlays.length > 0,
|
|
206
|
-
hasCode: (node.code?.length ?? 0) > 0,
|
|
207
|
-
fmStatus: node.fmStatus ?? undefined,
|
|
208
|
-
}) }]
|
|
209
|
-
})
|
|
210
|
-
const nodes = nodeProjectionMoved ? projectedNodes : prev.nodes
|
|
211
|
-
if (nodeProjectionMoved) {
|
|
212
|
-
const byDir: Record<string, string> = {}
|
|
213
|
-
for (const node of nodes) if (node.path) byDir[node.path.replace(/\/spec\.md$/, '')] = node.id
|
|
214
|
-
const retainedIds = new Set(nodes.map((node: any) => node.id))
|
|
215
|
-
for (let index = 0; index < nodes.length; index++) {
|
|
216
|
-
const node: any = nodes[index]
|
|
217
|
-
let changed = false
|
|
218
|
-
let parent = node.parent
|
|
219
|
-
if (node.ghost) {
|
|
220
|
-
const next = resolveParent(node.path, byDir)
|
|
221
|
-
if (next !== parent) { parent = next; changed = true }
|
|
222
|
-
}
|
|
223
|
-
const overlays = (node.overlays || []).map((overlay: any) => {
|
|
224
|
-
if (!overlay.toParent || retainedIds.has(overlay.toParent)) return overlay
|
|
225
|
-
const toPath = overlay[OVERLAY_TO_PATH]
|
|
226
|
-
changed = true
|
|
227
|
-
return { ...overlay, toParent: typeof toPath === 'string' ? resolveParent(toPath, byDir) : null }
|
|
228
|
-
})
|
|
229
|
-
if (changed) nodes[index] = { ...node, parent, overlays }
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return { ...prev, nodes, sessions: sess }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// A full producer may finish after the session lane has already shown a newer row. Reuse that published
|
|
236
|
-
// projection on the full topology without another store read: topology owns the current per-path ops, while
|
|
237
|
-
// the published row owns lifecycle/eval fields. This is deliberately synchronous so a full completion never
|
|
238
|
-
// waits for a quiet session store.
|
|
239
|
-
export function rebasePublishedSessions(
|
|
240
|
-
topology: Awaited<ReturnType<typeof buildBoard>>,
|
|
241
|
-
published: Awaited<ReturnType<typeof buildBoard>>,
|
|
242
|
-
): Awaited<ReturnType<typeof buildBoard>> {
|
|
243
|
-
const opsByPath: Record<string, any[]> = {}
|
|
244
|
-
for (const session of topology.sessions) opsByPath[session.source] = session.ops
|
|
245
|
-
const sessions = published.sessions.map((session) => ({
|
|
246
|
-
...session,
|
|
247
|
-
source: session.path,
|
|
248
|
-
ops: rowOps(session, opsByPath),
|
|
249
|
-
}))
|
|
250
|
-
return { ...topology, sessions }
|
|
251
|
-
}
|
package/src/harness-identity.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
type HarnessIdentityRow = { id: string; sessionEnvVar: string }
|
|
2
|
-
|
|
3
|
-
// Adapter-neutral identity facts. Full harness adapters project these rows; consumers that only resolve an
|
|
4
|
-
// environment identity must not load launchers, runtime transport, or materialization code.
|
|
5
|
-
export const HARNESS_IDENTITIES = [
|
|
6
|
-
{ id: 'claude', sessionEnvVar: 'CLAUDE_CODE_SESSION_ID' },
|
|
7
|
-
{ id: 'codex', sessionEnvVar: 'CODEX_THREAD_ID' },
|
|
8
|
-
{ id: 'opencode', sessionEnvVar: 'OPENCODE_SESSION_ID' },
|
|
9
|
-
{ id: 'pi', sessionEnvVar: 'PI_SESSION_ID' },
|
|
10
|
-
{ id: 'zcode', sessionEnvVar: 'ZCODE_SESSION_ID' },
|
|
11
|
-
{ id: 'claude-headless', sessionEnvVar: 'CLAUDE_CODE_SESSION_ID' },
|
|
12
|
-
{ id: 'opencode-headless', sessionEnvVar: 'OPENCODE_SESSION_ID' },
|
|
13
|
-
{ id: 'pi-headless', sessionEnvVar: 'PI_SESSION_ID' },
|
|
14
|
-
{ id: 'codex-headless', sessionEnvVar: 'CODEX_THREAD_ID' },
|
|
15
|
-
] as const satisfies readonly HarnessIdentityRow[]
|
|
16
|
-
|
|
17
|
-
export type HarnessId = typeof HARNESS_IDENTITIES[number]['id']
|
|
18
|
-
export type HarnessIdentity = typeof HARNESS_IDENTITIES[number]
|
|
19
|
-
|
|
20
|
-
const identityById = new Map(HARNESS_IDENTITIES.map((identity) => [identity.id, identity]))
|
|
21
|
-
|
|
22
|
-
export function harnessIdentity(id: HarnessId): HarnessIdentity {
|
|
23
|
-
const identity = identityById.get(id)
|
|
24
|
-
if (!identity) throw new Error(`unknown harness identity '${id}'`)
|
|
25
|
-
return identity
|
|
26
|
-
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
export type IdentityShape = { tag: string; [key: string]: string | number }
|
|
2
|
-
export type IdentityPreset = { id: string; label: string; bg: string; fg: string; shapes: IdentityShape[] }
|
|
3
|
-
|
|
4
|
-
export const DEFAULT_PROJECT_ICON: string
|
|
5
|
-
export const DEFAULT_GATEWAY_ICON: string
|
|
6
|
-
export const IDENTITY_PRESETS: readonly IdentityPreset[]
|
|
7
|
-
export const IDENTITY_PRESET_IDS: readonly string[]
|
|
8
|
-
export function resolvedIdentityIcon(value: unknown, fallback?: string): string
|
|
9
|
-
export function identityPreset(value: unknown): IdentityPreset | null
|
|
10
|
-
export function isIconifyIcon(value: unknown): boolean
|
|
11
|
-
export function requireIdentityChoice(value: unknown): string
|
|
12
|
-
export function identitySvg(value: unknown, fallback?: string): string
|
|
13
|
-
export function identityFaviconHref(value: unknown, fallback?: string): string
|
package/src/identity-presets.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
// One browser-safe identity registry shared by backend validation and every dashboard projection.
|
|
2
|
-
// Geometry is data so the React renderer and favicon serializer cannot drift into separate drawings.
|
|
3
|
-
//
|
|
4
|
-
// @@@ shape keys travel through two renderers - a shape's own attributes override the group's
|
|
5
|
-
// fill/stroke, which is how a multi-colour mark fits a single-fg registry. Both consumers pass the keys
|
|
6
|
-
// through verbatim: the string serializer spreads them as attributes, React spreads them as JSX props.
|
|
7
|
-
// So a key must be valid in BOTH, which rules out hyphenated names (React wants strokeWidth) - hence a
|
|
8
|
-
// painted seam outline rather than a stroked line, since per-shape stroke-width cannot be expressed.
|
|
9
|
-
|
|
10
|
-
export const DEFAULT_PROJECT_ICON = 'spexcode'
|
|
11
|
-
export const DEFAULT_GATEWAY_ICON = 'gateway'
|
|
12
|
-
|
|
13
|
-
export const IDENTITY_PRESETS = Object.freeze([
|
|
14
|
-
{
|
|
15
|
-
// The brand mark: two brackets holding one file, cleft corner to corner and regenerated on the
|
|
16
|
-
// upper-right of the cleft. Painted rather than stroked — see the paint note below. Geometry is
|
|
17
|
-
// docs/brand/mark-dark.svg scaled by 24/512; the two surface gradients flatten to their midpoints
|
|
18
|
-
// because a 24-unit chip has no room for a ramp and the format carries no defs.
|
|
19
|
-
id: 'spexcode', label: 'SpexCode', bg: '#12161C', fg: '#EFE8D8',
|
|
20
|
-
shapes: [
|
|
21
|
-
{ tag: 'path', d: 'M 4.500,4.500 L 9.750,4.500 L 9.750,6.656 L 6.656,6.656 L 6.656,17.344 L 9.750,17.344 L 9.750,19.500 L 4.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
|
|
22
|
-
{ tag: 'path', d: 'M 19.500,4.500 L 14.250,4.500 L 14.250,6.656 L 17.344,6.656 L 17.344,17.344 L 14.250,17.344 L 14.250,19.500 L 19.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
|
|
23
|
-
{ tag: 'path', d: 'M 15.281,15.281 L 11.731,13.057 L 12.269,10.943 L 8.719,8.719 L 8.719,15.281 Z', fill: '#327D95', stroke: 'none' },
|
|
24
|
-
{ tag: 'path', d: 'M 8.719,8.719 L 12.269,10.943 L 11.731,13.057 L 15.281,15.281 L 15.281,12.000 L 12.000,8.719 Z', fill: '#82C3D6', stroke: 'none' },
|
|
25
|
-
{ tag: 'path', d: 'M 8.569,8.957 L 11.946,11.073 L 11.408,13.186 L 15.132,15.520 L 15.431,15.043 L 12.054,12.927 L 12.592,10.814 L 8.868,8.480 Z', fill: '#E4F7FD', stroke: 'none' },
|
|
26
|
-
],
|
|
27
|
-
},
|
|
28
|
-
{
|
|
29
|
-
id: 'gateway', label: 'Gateway', bg: '#155e75', fg: '#ecfeff',
|
|
30
|
-
shapes: [
|
|
31
|
-
{ tag: 'path', d: 'm12 3 8 4.2-8 4.2-8-4.2Z' },
|
|
32
|
-
{ tag: 'path', d: 'm4 11.2 8 4.2 8-4.2' },
|
|
33
|
-
{ tag: 'path', d: 'm4 15.2 8 4.2 8-4.2' },
|
|
34
|
-
],
|
|
35
|
-
},
|
|
36
|
-
{
|
|
37
|
-
id: 'mdi:rocket-launch', label: 'Rocket', bg: '#9f1239', fg: '#fff1f2',
|
|
38
|
-
shapes: [
|
|
39
|
-
{ tag: 'path', d: 'M14.5 5.2c2.2-2.2 4.8-2 5.3-1.8.2.5.4 3.1-1.8 5.3l-5.5 5.5-4.2-4.2Z' },
|
|
40
|
-
{ tag: 'path', d: 'm11.2 6.8-4.1.6-2.5 2.5 4.1.7' },
|
|
41
|
-
{ tag: 'path', d: 'm16.4 12-1 4.7-2.5 2.5-.7-4.1' },
|
|
42
|
-
{ tag: 'circle', cx: 16.1, cy: 7.1, r: 1.2 },
|
|
43
|
-
{ tag: 'path', d: 'M7.6 14.6c-2.2.4-3.4 1.6-3.6 3.8 2.2-.2 3.4-1.4 3.8-3.6' },
|
|
44
|
-
],
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
id: 'compass', label: 'Compass', bg: '#1d4ed8', fg: '#eff6ff',
|
|
48
|
-
shapes: [
|
|
49
|
-
{ tag: 'circle', cx: 12, cy: 12, r: 8.5 },
|
|
50
|
-
{ tag: 'path', d: 'm15.2 8.8-1.8 4.6-4.6 1.8 1.8-4.6Z' },
|
|
51
|
-
],
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
id: 'terminal', label: 'Terminal', bg: '#3f3f46', fg: '#fafafa',
|
|
55
|
-
shapes: [
|
|
56
|
-
{ tag: 'rect', x: 3.5, y: 4.5, width: 17, height: 15, rx: 2 },
|
|
57
|
-
{ tag: 'path', d: 'm7 9 3 3-3 3' },
|
|
58
|
-
{ tag: 'path', d: 'M12.5 15H17' },
|
|
59
|
-
],
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
id: 'package', label: 'Package', bg: '#6d28d9', fg: '#f5f3ff',
|
|
63
|
-
shapes: [
|
|
64
|
-
{ tag: 'path', d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9Z' },
|
|
65
|
-
{ tag: 'path', d: 'm4.3 7.7 7.7 4.4 7.7-4.4' },
|
|
66
|
-
{ tag: 'path', d: 'M12 12.1V21' },
|
|
67
|
-
],
|
|
68
|
-
},
|
|
69
|
-
{
|
|
70
|
-
id: 'database', label: 'Database', bg: '#a16207', fg: '#fefce8',
|
|
71
|
-
shapes: [
|
|
72
|
-
{ tag: 'ellipse', cx: 12, cy: 6, rx: 7.5, ry: 3 },
|
|
73
|
-
{ tag: 'path', d: 'M4.5 6v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3V6' },
|
|
74
|
-
{ tag: 'path', d: 'M4.5 12v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6' },
|
|
75
|
-
],
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
id: 'spark', label: 'Spark', bg: '#c2410c', fg: '#fff7ed',
|
|
79
|
-
shapes: [
|
|
80
|
-
{ tag: 'path', d: 'm12 3 1.5 5.2L19 10l-5.5 1.8L12 17l-1.5-5.2L5 10l5.5-1.8Z' },
|
|
81
|
-
{ tag: 'path', d: 'm18.5 15 .7 2.2 2.3.8-2.3.8-.7 2.2-.7-2.2-2.3-.8 2.3-.8Z' },
|
|
82
|
-
],
|
|
83
|
-
},
|
|
84
|
-
])
|
|
85
|
-
|
|
86
|
-
const BY_ID = new Map(IDENTITY_PRESETS.map((preset) => [preset.id, preset]))
|
|
87
|
-
const ICONIFY_ID = /^[a-z0-9-]+[:/][a-z0-9-]+$/i
|
|
88
|
-
const ALIASES = new Map([
|
|
89
|
-
['rocket', 'mdi:rocket-launch'],
|
|
90
|
-
['mdi/rocket-launch', 'mdi:rocket-launch'],
|
|
91
|
-
['layers', 'gateway'],
|
|
92
|
-
['default', 'spexcode'],
|
|
93
|
-
])
|
|
94
|
-
|
|
95
|
-
export const IDENTITY_PRESET_IDS = Object.freeze(IDENTITY_PRESETS.map((preset) => preset.id))
|
|
96
|
-
|
|
97
|
-
export function resolvedIdentityIcon(value, fallback = DEFAULT_PROJECT_ICON) {
|
|
98
|
-
const raw = typeof value === 'string' ? value.trim() : ''
|
|
99
|
-
const id = ALIASES.get(raw) || raw
|
|
100
|
-
return id || fallback
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
export function identityPreset(value) {
|
|
104
|
-
const raw = typeof value === 'string' ? value.trim() : ''
|
|
105
|
-
return BY_ID.get(ALIASES.get(raw) || raw) || null
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
export function isIconifyIcon(value) {
|
|
109
|
-
return ICONIFY_ID.test(typeof value === 'string' ? value.trim() : '')
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function requireIdentityChoice(value) {
|
|
113
|
-
const raw = typeof value === 'string' ? value.trim() : ''
|
|
114
|
-
const id = ALIASES.get(raw) || raw
|
|
115
|
-
if (BY_ID.has(id)) return id
|
|
116
|
-
if (isIconifyIcon(id)) return id.replace('/', ':')
|
|
117
|
-
throw new Error(`unknown identity icon '${raw}' (choose a preset or Iconify prefix:name)`)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function attrs(shape) {
|
|
121
|
-
return Object.entries(shape).filter(([key]) => key !== 'tag')
|
|
122
|
-
.map(([key, value]) => `${key === 'className' ? 'class' : key}="${String(value).replaceAll('&', '&').replaceAll('"', '"')}"`).join(' ')
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export function identitySvg(value, fallback = DEFAULT_PROJECT_ICON) {
|
|
126
|
-
const preset = identityPreset(value) || identityPreset(fallback)
|
|
127
|
-
const geometry = preset.shapes.map((shape) => `<${shape.tag} ${attrs(shape)}/>`).join('')
|
|
128
|
-
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="5" fill="${preset.bg}"/><g fill="none" stroke="${preset.fg}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${geometry}</g></svg>`
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
export function identityFaviconHref(value, fallback = DEFAULT_PROJECT_ICON) {
|
|
132
|
-
const resolved = resolvedIdentityIcon(value, fallback)
|
|
133
|
-
if (identityPreset(resolved)) return `data:image/svg+xml,${encodeURIComponent(identitySvg(resolved, fallback))}`
|
|
134
|
-
if (/^https?:\/\//.test(resolved)) return resolved
|
|
135
|
-
if (isIconifyIcon(resolved)) return `https://api.iconify.design/${resolved.replace(':', '/')}.svg`
|
|
136
|
-
const glyph = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y=".86em" font-size="82" text-anchor="middle">${resolved.replaceAll('&', '&').replaceAll('<', '<')}</text></svg>`
|
|
137
|
-
return `data:image/svg+xml,${encodeURIComponent(glyph)}`
|
|
138
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { sourceIndexesFull } from './git.js'
|
|
2
|
-
import { loadSpecs } from './specs.js'
|
|
3
|
-
|
|
4
|
-
export async function readSpecs(root: string) {
|
|
5
|
-
const [history, drift] = await sourceIndexesFull(root)
|
|
6
|
-
return loadSpecs(root, { history, drift })
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
export * from './anchors.js'
|
|
10
|
-
export * from './git.js'
|
|
11
|
-
export * from './graph.js'
|
|
12
|
-
export * from './harness-identity.js'
|
|
13
|
-
export * from './layout.js'
|
|
14
|
-
export * from './process-identity.js'
|
|
15
|
-
export * from './project-identity.js'
|
|
16
|
-
export * from './project-store.js'
|
|
17
|
-
export * from './resilience.js'
|
|
18
|
-
export * from './reviewSnapshot.js'
|
|
19
|
-
export * from './root-lru.js'
|
|
20
|
-
export * from './specs.js'
|