spexcode 0.1.1 → 0.1.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.
Files changed (75) hide show
  1. package/package.json +17 -10
  2. package/{bin → spec-cli/bin}/spex.mjs +6 -2
  3. package/spec-cli/hooks/dispatch.sh +74 -0
  4. package/spec-cli/hooks/harness.sh +178 -0
  5. package/{src → spec-cli/src}/cli.ts +14 -0
  6. package/{src → spec-cli/src}/harness.ts +8 -18
  7. package/{src → spec-cli/src}/layout.ts +6 -23
  8. package/{src → spec-cli/src}/materialize.ts +2 -1
  9. package/spec-cli/src/relay.ts +28 -0
  10. package/{src → spec-cli/src}/supervise.ts +2 -1
  11. package/spec-cli/src/tsx-bin.ts +11 -0
  12. package/{dashboard-dist/assets/index-B60MILFg.js → spec-dashboard/dist/assets/index-Bk4E1EQy.js} +29 -29
  13. package/{dashboard-dist → spec-dashboard/dist}/index.html +1 -1
  14. package/spec-forge/src/__fixtures__/github-forge.json +63 -0
  15. package/spec-forge/src/cache.ts +28 -0
  16. package/spec-forge/src/cli.ts +110 -0
  17. package/spec-forge/src/drivers/github.ts +73 -0
  18. package/spec-forge/src/links.ts +76 -0
  19. package/spec-forge/src/needs-yatsu-eval.ts +30 -0
  20. package/spec-forge/src/port.ts +23 -0
  21. package/spec-forge/src/resident.ts +23 -0
  22. package/spec-yatsu/src/cache.ts +68 -0
  23. package/spec-yatsu/src/cli.ts +296 -0
  24. package/spec-yatsu/src/evaltab.ts +112 -0
  25. package/spec-yatsu/src/evaluator.ts +24 -0
  26. package/spec-yatsu/src/freshness.ts +51 -0
  27. package/spec-yatsu/src/proof.ts +491 -0
  28. package/spec-yatsu/src/sidecar.ts +44 -0
  29. package/spec-yatsu/src/yatsu.ts +181 -0
  30. /package/{src → spec-cli/src}/board.ts +0 -0
  31. /package/{src → spec-cli/src}/client.ts +0 -0
  32. /package/{src → spec-cli/src}/gateway.ts +0 -0
  33. /package/{src → spec-cli/src}/git.ts +0 -0
  34. /package/{src → spec-cli/src}/guide.ts +0 -0
  35. /package/{src → spec-cli/src}/hooks.ts +0 -0
  36. /package/{src → spec-cli/src}/index.ts +0 -0
  37. /package/{src → spec-cli/src}/init.ts +0 -0
  38. /package/{src → spec-cli/src}/lint.ts +0 -0
  39. /package/{src → spec-cli/src}/login-page.ts +0 -0
  40. /package/{src → spec-cli/src}/pty-bridge.ts +0 -0
  41. /package/{src → spec-cli/src}/ranker.ts +0 -0
  42. /package/{src → spec-cli/src}/resilience.ts +0 -0
  43. /package/{src → spec-cli/src}/search.bench.mjs +0 -0
  44. /package/{src → spec-cli/src}/search.ts +0 -0
  45. /package/{src → spec-cli/src}/self.ts +0 -0
  46. /package/{src → spec-cli/src}/sessions.ts +0 -0
  47. /package/{src → spec-cli/src}/slash-commands.ts +0 -0
  48. /package/{src → spec-cli/src}/specs.ts +0 -0
  49. /package/{src → spec-cli/src}/uploads.ts +0 -0
  50. /package/{templates → spec-cli/templates}/hooks/pre-commit +0 -0
  51. /package/{templates → spec-cli/templates}/hooks/prepare-commit-msg +0 -0
  52. /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/idle.sh +0 -0
  53. /package/{templates → spec-cli/templates}/spec/project/.config/core/idle/spec.md +0 -0
  54. /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/mark-active.sh +0 -0
  55. /package/{templates → spec-cli/templates}/spec/project/.config/core/mark-active/spec.md +0 -0
  56. /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/fail.sh +0 -0
  57. /package/{templates → spec-cli/templates}/spec/project/.config/core/session-fail/spec.md +0 -0
  58. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec-first.sh +0 -0
  59. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-first/spec.md +0 -0
  60. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec-of-file.sh +0 -0
  61. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec-of-file/spec.md +0 -0
  62. /package/{templates → spec-cli/templates}/spec/project/.config/core/spec.md +0 -0
  63. /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/spec.md +0 -0
  64. /package/{templates → spec-cli/templates}/spec/project/.config/core/stop-gate/stop-gate.sh +0 -0
  65. /package/{templates → spec-cli/templates}/spec/project/.config/extract/spec.md +0 -0
  66. /package/{templates → spec-cli/templates}/spec/project/.config/forge-link/spec.md +0 -0
  67. /package/{templates → spec-cli/templates}/spec/project/.config/memory-hygiene/spec.md +0 -0
  68. /package/{templates → spec-cli/templates}/spec/project/.config/regroup/spec.md +0 -0
  69. /package/{templates → spec-cli/templates}/spec/project/.config/scenario/spec.md +0 -0
  70. /package/{templates → spec-cli/templates}/spec/project/.config/spec.md +0 -0
  71. /package/{templates → spec-cli/templates}/spec/project/.config/supervisor/spec.md +0 -0
  72. /package/{templates → spec-cli/templates}/spec/project/.config/tidy/spec.md +0 -0
  73. /package/{templates → spec-cli/templates}/spec/project/spec.md +0 -0
  74. /package/{templates → spec-cli/templates}/spexcode.json +0 -0
  75. /package/{dashboard-dist → spec-dashboard/dist}/assets/index-Cq7hwngj.css +0 -0
@@ -7,7 +7,7 @@
7
7
  <!-- tab-icon: pre-load default favicon (🔭, on brand with the watch glyph); a configured dashboard.icon
8
8
  replaces it at runtime once the board loads. Inline emoji SVG — no asset to ship. -->
9
9
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%94%AD%3C/text%3E%3C/svg%3E" />
10
- <script type="module" crossorigin src="/assets/index-B60MILFg.js"></script>
10
+ <script type="module" crossorigin src="/assets/index-Bk4E1EQy.js"></script>
11
11
  <link rel="stylesheet" crossorigin href="/assets/index-Cq7hwngj.css">
12
12
  </head>
13
13
  <body>
@@ -0,0 +1,63 @@
1
+ {
2
+ "_note": "Fixture mirroring githubDriver output (the vendor-neutral ForgeIssue[]/ForgePR[] a `gh issue/pr list` collapses to) for a sample repo. Drives the needs-yatsu-eval resolver test with no network.",
3
+ "issues": [
4
+ {
5
+ "number": 1,
6
+ "title": "login redirect is broken on /logout",
7
+ "body": "Steps to repro: click logout, land on a 404.\n\nSpec: sessions\nneeds-yatsu-eval\n",
8
+ "url": "https://github.com/acme/repo/issues/1",
9
+ "state": "open",
10
+ "labels": []
11
+ },
12
+ {
13
+ "number": 2,
14
+ "title": "board badge count is off by one",
15
+ "body": "The open-issue badge over-counts.\n\nSpec: dashboard-issues",
16
+ "url": "https://github.com/acme/repo/issues/2",
17
+ "state": "open",
18
+ "labels": ["bug", "needs-yatsu-eval"]
19
+ },
20
+ {
21
+ "number": 3,
22
+ "title": "tracer mislinks a node/<id> branch with a dash",
23
+ "body": "branchToNode picks the short id.\n\nSpec: links",
24
+ "url": "https://github.com/acme/repo/issues/3",
25
+ "state": "open",
26
+ "labels": ["bug"]
27
+ },
28
+ {
29
+ "number": 4,
30
+ "title": "old freshness drift already verified",
31
+ "body": "Spec: freshness\nNeeds-Yatsu-Eval\n",
32
+ "url": "https://github.com/acme/repo/issues/4",
33
+ "state": "closed",
34
+ "labels": []
35
+ },
36
+ {
37
+ "number": 5,
38
+ "title": "flagged but names no node",
39
+ "body": "This one is flagged but carries no Spec: marker and no closing PR.\n\nneeds-yatsu-eval\n",
40
+ "url": "https://github.com/acme/repo/issues/5",
41
+ "state": "open",
42
+ "labels": ["needs-yatsu-eval"]
43
+ },
44
+ {
45
+ "number": 6,
46
+ "title": "fixed via an open PR, no marker on the issue",
47
+ "body": "A worked bug; the node comes transitively from the closing PR.\n\nneeds-yatsu-eval: ignore-the-trailing-text",
48
+ "url": "https://github.com/acme/repo/issues/6",
49
+ "state": "open",
50
+ "labels": ["needs-yatsu-eval"]
51
+ }
52
+ ],
53
+ "prs": [
54
+ {
55
+ "number": 20,
56
+ "title": "fix logout redirect",
57
+ "url": "https://github.com/acme/repo/pull/20",
58
+ "state": "open",
59
+ "headRefName": "node/port-7f3a1c2",
60
+ "closesIssues": [6]
61
+ }
62
+ ]
63
+ }
@@ -0,0 +1,28 @@
1
+ import type { ForgeDriver, ForgeIssue, ForgePR } from './port.js'
2
+ import { resolveLinks, type NodeLinks } from './links.js'
3
+
4
+ export type ForgeDelta =
5
+ | { kind: 'issue'; issue: ForgeIssue }
6
+ | { kind: 'pr'; pr: ForgePR }
7
+ | { kind: 'remove'; target: 'issue' | 'pr'; number: number }
8
+
9
+ export class ForgeCache {
10
+ private issues = new Map<number, ForgeIssue>()
11
+ private prs = new Map<number, ForgePR>()
12
+
13
+ apply(delta: ForgeDelta): void {
14
+ if (delta.kind === 'issue') this.issues.set(delta.issue.number, delta.issue)
15
+ else if (delta.kind === 'pr') this.prs.set(delta.pr.number, delta.pr)
16
+ else (delta.target === 'issue' ? this.issues : this.prs).delete(delta.number)
17
+ }
18
+
19
+ async reconcile(driver: ForgeDriver): Promise<void> {
20
+ const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
21
+ this.issues = new Map(issues.map((i) => [i.number, i]))
22
+ this.prs = new Map(prs.map((p) => [p.number, p]))
23
+ }
24
+
25
+ view(nodeIds: string[]): NodeLinks[] {
26
+ return resolveLinks([...this.issues.values()], [...this.prs.values()], nodeIds)
27
+ }
28
+ }
@@ -0,0 +1,110 @@
1
+ import { loadSpecs } from '../../spec-cli/src/specs.js'
2
+ import type { ForgeDriver, ForgeIssue, ForgePR } from './port.js'
3
+ import { githubDriver } from './drivers/github.js'
4
+ import { resolveLinks, type NodeLinks } from './links.js'
5
+ import { resolveEvalPending, type NodeEvalPending } from './needs-yatsu-eval.js'
6
+
7
+ const DRIVERS: ForgeDriver[] = [githubDriver]
8
+ const DEFAULT_HOST = 'github'
9
+ function driverFor(host: string): ForgeDriver | undefined {
10
+ return DRIVERS.find((d) => d.host === host)
11
+ }
12
+
13
+ // tiny flag reader over this command's own arg slice (everything after `forge`), so cli.ts stays routing-only.
14
+ function flag(args: string[], name: string): string | undefined {
15
+ const i = args.indexOf(`--${name}`)
16
+ return i >= 0 ? args[i + 1] : undefined
17
+ }
18
+ const has = (args: string[], name: string) => args.includes(`--${name}`)
19
+
20
+ async function readForge(
21
+ args: string[],
22
+ ): Promise<{ driver: ForgeDriver; nodeIds: string[]; issues: ForgeIssue[]; prs: ForgePR[] } | null> {
23
+ const host = flag(args, 'host') ?? DEFAULT_HOST
24
+ const driver = driverFor(host)
25
+ if (!driver) {
26
+ console.error(`forge: unknown host '${host}' (known: ${DRIVERS.map((d) => d.host).join(', ')})`)
27
+ return null
28
+ }
29
+ const nodeIds = (await loadSpecs()).map((s) => s.id)
30
+ const [issues, prs] = await Promise.all([driver.listIssues(), driver.listPRs()])
31
+ return { driver, nodeIds, issues, prs }
32
+ }
33
+
34
+ function render(links: NodeLinks[]): string {
35
+ const out: string[] = []
36
+ for (const n of links) {
37
+ out.push(`\n${n.node}`)
38
+ if (n.issues.length) {
39
+ out.push(' issues:')
40
+ for (const i of n.issues) out.push(` #${i.number} ${i.state} ${i.title} (via ${i.via}) ${i.url}`)
41
+ }
42
+ if (n.prs.length) {
43
+ out.push(' prs:')
44
+ for (const p of n.prs) out.push(` #${p.number} ${p.state} ${p.title} ${p.headRefName} ${p.url}`)
45
+ }
46
+ }
47
+ return out.join('\n')
48
+ }
49
+
50
+ async function links(args: string[]): Promise<number> {
51
+ const forge = await readForge(args)
52
+ if (!forge) return 2
53
+ const { driver, nodeIds, issues, prs } = forge
54
+ let resolved = resolveLinks(issues, prs, nodeIds)
55
+
56
+ const only = flag(args, 'node')
57
+ if (only) {
58
+ if (!nodeIds.includes(only)) { console.error(`forge: no such node '${only}'`); return 1 }
59
+ resolved = resolved.filter((n) => n.node === only)
60
+ }
61
+
62
+ if (has(args, 'json')) { console.log(JSON.stringify(resolved, null, 2)); return 0 }
63
+ const nIssues = resolved.reduce((a, n) => a + n.issues.length, 0)
64
+ const nPRs = resolved.reduce((a, n) => a + n.prs.length, 0)
65
+ console.log(
66
+ `spec-forge · ${driver.host} · ${resolved.length} linked node(s) · ${nIssues} issue(s), ${nPRs} pr(s)` +
67
+ ` · scanned ${issues.length} issue(s), ${prs.length} pr(s)`,
68
+ )
69
+ if (resolved.length) console.log(render(resolved))
70
+ return 0
71
+ }
72
+
73
+ function renderPending(pending: NodeEvalPending[]): string {
74
+ const out: string[] = []
75
+ for (const n of pending) {
76
+ out.push(`\n${n.node}`)
77
+ for (const i of n.pending) out.push(` #${i.number} ${i.state} ${i.title} (via ${i.via}) ${i.url}`)
78
+ }
79
+ return out.join('\n')
80
+ }
81
+
82
+ async function evalPending(args: string[]): Promise<number> {
83
+ const forge = await readForge(args)
84
+ if (!forge) return 2
85
+ const { driver, nodeIds, issues, prs } = forge
86
+ let resolved = resolveEvalPending(issues, prs, nodeIds)
87
+
88
+ const only = flag(args, 'node')
89
+ if (only) {
90
+ if (!nodeIds.includes(only)) { console.error(`forge: no such node '${only}'`); return 1 }
91
+ resolved = resolved.filter((n) => n.node === only)
92
+ }
93
+
94
+ if (has(args, 'json')) { console.log(JSON.stringify(resolved, null, 2)); return 0 }
95
+ const nPending = resolved.reduce((a, n) => a + n.pending.length, 0)
96
+ console.log(
97
+ `spec-forge · ${driver.host} · ${resolved.length} node(s) with eval pending · ${nPending} issue(s)` +
98
+ ` · scanned ${issues.length} issue(s), ${prs.length} pr(s)`,
99
+ )
100
+ if (resolved.length) console.log(renderPending(resolved))
101
+ return 0
102
+ }
103
+
104
+ export async function runForge(args: string[]): Promise<number> {
105
+ const sub = args[0]
106
+ if (sub === 'links') return links(args.slice(1))
107
+ if (sub === 'eval-pending') return evalPending(args.slice(1))
108
+ console.error('spex forge: links [--host github] [--node <id>] [--json] | eval-pending [--host github] [--node <id>] [--json]')
109
+ return 2
110
+ }
@@ -0,0 +1,73 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import type { ForgeDriver, ForgeIssue, ForgePR } from '../port.js'
4
+
5
+ const run = promisify(execFile)
6
+
7
+ // maxBuffer is raised above the 1MB default: a busy repo's issue/PR JSON can exceed it
8
+ async function gh<T>(args: string[]): Promise<T> {
9
+ const { stdout } = await run('gh', args, { maxBuffer: 16 * 1024 * 1024 })
10
+ return JSON.parse(stdout) as T
11
+ }
12
+
13
+ export const githubDriver: ForgeDriver = {
14
+ host: 'github',
15
+
16
+ // fetch open and closed in separate `--limit 200` windows and merge, so a flood of closed issues can't crowd the open set out of one shared `--state all` limit
17
+ async listIssues(): Promise<ForgeIssue[]> {
18
+ const list = (state: string) =>
19
+ gh<{ number: number; title: string; body: string; url: string; state: string; labels: { name: string }[] }[]>(
20
+ ['issue', 'list', '--state', state, '--limit', '200', '--json', 'number,title,body,url,state,labels'],
21
+ )
22
+ const [open, closed] = await Promise.all([list('open'), list('closed')])
23
+ return [...open, ...closed].map((r) => ({
24
+ number: r.number,
25
+ title: r.title,
26
+ body: r.body ?? '',
27
+ url: r.url,
28
+ state: r.state,
29
+ labels: (r.labels ?? []).map((l) => l.name),
30
+ }))
31
+ },
32
+
33
+ async listPRs(): Promise<ForgePR[]> {
34
+ type Row = {
35
+ number: number; title: string; url: string; state: string; headRefName: string
36
+ closingIssuesReferences?: { number: number }[]
37
+ }
38
+ const base = ['pr', 'list', '--state', 'open', '--limit', '200', '--json']
39
+ const fields = 'number,title,url,state,headRefName'
40
+ let rows: Row[]
41
+ try {
42
+ rows = await gh<Row[]>([...base, `${fields},closingIssuesReferences`])
43
+ } catch (err) {
44
+ if (!isUnknownFieldError(err)) throw err
45
+ warnNoTransitiveOnce()
46
+ rows = await gh<Row[]>([...base, fields])
47
+ }
48
+ return rows.map((r) => ({
49
+ number: r.number,
50
+ title: r.title,
51
+ url: r.url,
52
+ state: r.state,
53
+ headRefName: r.headRefName,
54
+ closesIssues: (r.closingIssuesReferences ?? []).map((c) => c.number),
55
+ }))
56
+ },
57
+ }
58
+
59
+ function isUnknownFieldError(err: unknown): boolean {
60
+ const e = err as { stderr?: string; message?: string }
61
+ const text = `${e?.stderr ?? ''}\n${e?.message ?? ''}`
62
+ return /unknown json field/i.test(text)
63
+ }
64
+
65
+ let warnedNoTransitive = false
66
+ function warnNoTransitiveOnce(): void {
67
+ if (warnedNoTransitive) return
68
+ warnedNoTransitive = true
69
+ console.warn(
70
+ 'spec-forge: this `gh` is too old for `closingIssuesReferences` — transitive issue↔PR links are ' +
71
+ 'disabled (branch + `Spec:` marker links still work). Upgrade `gh` to restore them.',
72
+ )
73
+ }
@@ -0,0 +1,76 @@
1
+ import type { ForgeIssue, ForgePR } from './port.js'
2
+
3
+ export type LinkedIssue = ForgeIssue & { via: 'marker' | 'pr' }
4
+ export type LinkedPR = ForgePR & { via: 'branch' }
5
+ export type NodeLinks = { node: string; issues: LinkedIssue[]; prs: LinkedPR[] }
6
+
7
+ export function parseSpecMarkers(body: string): string[] {
8
+ const ids: string[] = []
9
+ for (const m of (body || '').matchAll(/^\s*spec:\s*(.+)$/gim)) {
10
+ for (const part of m[1].split(',')) {
11
+ const id = part.trim()
12
+ if (id) ids.push(id)
13
+ }
14
+ }
15
+ return ids
16
+ }
17
+
18
+ export function branchToNode(branch: string, nodeIds: string[]): string | null {
19
+ if (!branch?.startsWith('node/')) return null
20
+ const rest = branch.slice('node/'.length)
21
+ let best: string | null = null
22
+ for (const id of nodeIds) {
23
+ if (rest === id || rest.startsWith(id + '-')) {
24
+ if (!best || id.length > best.length) best = id
25
+ }
26
+ }
27
+ return best
28
+ }
29
+
30
+ export function resolveLinks(
31
+ issues: ForgeIssue[],
32
+ prs: ForgePR[],
33
+ nodeIds: string[],
34
+ ): NodeLinks[] {
35
+ const known = new Set(nodeIds)
36
+ const byNode = new Map<string, { issues: Map<number, LinkedIssue>; prs: Map<number, LinkedPR> }>()
37
+ const slot = (node: string) => {
38
+ let s = byNode.get(node)
39
+ if (!s) { s = { issues: new Map(), prs: new Map() }; byNode.set(node, s) }
40
+ return s
41
+ }
42
+
43
+ // PRs: branch → node (free structural link), and remember which node each PR belongs to.
44
+ const prNode = new Map<number, string>()
45
+ for (const pr of prs) {
46
+ const node = branchToNode(pr.headRefName, nodeIds)
47
+ if (!node) continue
48
+ prNode.set(pr.number, node)
49
+ slot(node).prs.set(pr.number, { ...pr, via: 'branch' })
50
+ }
51
+
52
+ // Issues by marker — the explicit convention. Unknown ids are dropped (no inventing nodes).
53
+ const issueByNumber = new Map(issues.map((i) => [i.number, i]))
54
+ for (const issue of issues) {
55
+ for (const id of parseSpecMarkers(issue.body)) {
56
+ if (known.has(id)) slot(id).issues.set(issue.number, { ...issue, via: 'marker' })
57
+ }
58
+ }
59
+
60
+ // Transitive: a PR on a node branch that closes an issue links that issue to the node — unless a marker
61
+ // already linked it (marker wins, so we don't overwrite the explicit source with the inferred one).
62
+ for (const pr of prs) {
63
+ const node = prNode.get(pr.number)
64
+ if (!node) continue
65
+ for (const num of pr.closesIssues) {
66
+ const issue = issueByNumber.get(num)
67
+ if (issue && !slot(node).issues.has(num)) {
68
+ slot(node).issues.set(num, { ...issue, via: 'pr' })
69
+ }
70
+ }
71
+ }
72
+
73
+ return [...byNode.entries()]
74
+ .map(([node, s]) => ({ node, issues: [...s.issues.values()], prs: [...s.prs.values()] }))
75
+ .sort((a, b) => a.node.localeCompare(b.node))
76
+ }
@@ -0,0 +1,30 @@
1
+ import type { ForgeIssue, ForgePR } from './port.js'
2
+ import { resolveLinks, type LinkedIssue } from './links.js'
3
+
4
+ export const NEEDS_YATSU_EVAL = 'needs-yatsu-eval'
5
+
6
+ // a bare-marker body line: the name alone, case-insensitive, any indent, optional trailing colon — content after it (`needs-yatsu-eval: foo`) is NOT a match
7
+ const BODY_MARKER = new RegExp(`^\\s*${NEEDS_YATSU_EVAL}\\s*:?\\s*$`, 'im')
8
+
9
+ export function isNeedsYatsuEval(issue: ForgeIssue): boolean {
10
+ if (issue.labels.some((l) => l.trim().toLowerCase() === NEEDS_YATSU_EVAL)) return true
11
+ return BODY_MARKER.test(issue.body || '')
12
+ }
13
+
14
+ export type EvalPending = LinkedIssue
15
+ export type NodeEvalPending = { node: string; pending: EvalPending[] }
16
+
17
+ export function resolveEvalPending(
18
+ issues: ForgeIssue[],
19
+ prs: ForgePR[],
20
+ nodeIds: string[],
21
+ ): NodeEvalPending[] {
22
+ const flagged = new Set(issues.filter(isNeedsYatsuEval).map((i) => i.number))
23
+ if (!flagged.size) return []
24
+ const out: NodeEvalPending[] = []
25
+ for (const { node, issues: linked } of resolveLinks(issues, prs, nodeIds)) {
26
+ const pending = linked.filter((i) => i.state === 'open' && flagged.has(i.number))
27
+ if (pending.length) out.push({ node, pending })
28
+ }
29
+ return out
30
+ }
@@ -0,0 +1,23 @@
1
+ export type ForgeIssue = {
2
+ number: number
3
+ title: string
4
+ body: string
5
+ url: string
6
+ state: string
7
+ labels: string[]
8
+ }
9
+
10
+ export type ForgePR = {
11
+ number: number
12
+ title: string
13
+ url: string
14
+ state: string
15
+ headRefName: string
16
+ closesIssues: number[]
17
+ }
18
+
19
+ export interface ForgeDriver {
20
+ readonly host: string
21
+ listIssues(): Promise<ForgeIssue[]>
22
+ listPRs(): Promise<ForgePR[]>
23
+ }
@@ -0,0 +1,23 @@
1
+ import { ForgeCache } from './cache.js'
2
+ import { githubDriver } from './drivers/github.js'
3
+ import type { NodeLinks } from './links.js'
4
+
5
+ const cache = new ForgeCache()
6
+ let inFlight: Promise<void> | null = null
7
+ // track last ATTEMPT, not last success, so the TTL also backs off failures — else a forge-less repo respawns `gh` every poll
8
+ let lastAttempt = 0
9
+ const TTL_MS = 60_000
10
+
11
+ function refreshIfStale(now: number): void {
12
+ if (inFlight || (lastAttempt && now - lastAttempt < TTL_MS)) return
13
+ lastAttempt = now
14
+ inFlight = cache
15
+ .reconcile(githubDriver)
16
+ .catch(() => {})
17
+ .finally(() => { inFlight = null })
18
+ }
19
+
20
+ export function residentForgeView(nodeIds: string[]): NodeLinks[] {
21
+ refreshIfStale(Date.now())
22
+ return cache.view(nodeIds)
23
+ }
@@ -0,0 +1,68 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync, rmSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { gitCommonDir } from '../../spec-cli/src/layout.js'
5
+
6
+ export const MISS_BLOB = 'miss original file'
7
+
8
+ // every cache fn takes an optional `dir` (defaulting to the live cache dir) so the logic is testable
9
+ // against a temp dir without a git repo.
10
+ export function cacheDir(): string {
11
+ return join(gitCommonDir(), 'spexcode', 'yatsu-blobs')
12
+ }
13
+
14
+ // a content-addressed blob name = the sha256 of its bytes (64 hex). The backstop recognises a stray one.
15
+ const BLOB_NAME = /^[0-9a-f]{64}$/
16
+
17
+ // store bytes, return the content hash (the value recorded as a reading's `blob`). Idempotent: identical
18
+ // content maps to the same name, written once.
19
+ export function putBlob(bytes: Buffer, dir = cacheDir()): string {
20
+ const sha = createHash('sha256').update(bytes).digest('hex')
21
+ mkdirSync(dir, { recursive: true })
22
+ const p = join(dir, sha)
23
+ if (!existsSync(p)) writeFileSync(p, bytes)
24
+ return sha
25
+ }
26
+
27
+ export function blobPath(sha: string, dir = cacheDir()): string {
28
+ return join(dir, sha)
29
+ }
30
+
31
+ export function hasBlob(sha: string | null, dir = cacheDir()): boolean {
32
+ return !!sha && existsSync(blobPath(sha, dir))
33
+ }
34
+
35
+ // render a reading's blob reference: the cache path when present, the MISS sentinel when the record
36
+ // outlived its bytes, '' when the reading had no image at all (a pixel-less observation).
37
+ export function resolveBlob(sha: string | null, dir = cacheDir()): string {
38
+ if (!sha) return ''
39
+ return hasBlob(sha, dir) ? blobPath(sha, dir) : MISS_BLOB
40
+ }
41
+
42
+ // every content-addressed blob currently in the cache.
43
+ export function listBlobs(dir = cacheDir()): string[] {
44
+ if (!existsSync(dir)) return []
45
+ return readdirSync(dir).filter((n) => BLOB_NAME.test(n)).sort()
46
+ }
47
+
48
+ export function gc(keep: Set<string>, dir = cacheDir()): string[] {
49
+ const removed: string[] = []
50
+ for (const name of listBlobs(dir)) {
51
+ if (keep.has(name)) continue
52
+ rmSync(blobPath(name, dir))
53
+ removed.push(name)
54
+ }
55
+ return removed
56
+ }
57
+
58
+ // read a blob's bytes (used by callers that render/serve a reading's pixels); null when absent.
59
+ export function getBlob(sha: string | null, dir = cacheDir()): Buffer | null {
60
+ return hasBlob(sha, dir) ? readFileSync(blobPath(sha!, dir)) : null
61
+ }
62
+
63
+ // is a staged repo path a stray content-addressed blob? (a 64-hex basename, or anything under a
64
+ // yatsu-blobs dir). The pre-commit backstop rejects these so pixels never leak into git history.
65
+ export function isStrayBlob(path: string): boolean {
66
+ const base = path.slice(path.lastIndexOf('/') + 1)
67
+ return BLOB_NAME.test(base) || path.includes('/yatsu-blobs/')
68
+ }