diff --git a/package.json b/package.json index 52d7489..7c1606d 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "sdk:smoke": "bun examples/sdk-usage.ts", "analyze": "bun scripts/design-check.ts", "debt": "bun scripts/ponytail-debt.ts", - "test": "bun scripts/design-check.test.ts && bun scripts/ponytail-debt.test.ts && bun scripts/orch-core.test.ts && bun scripts/orch.test.ts && bun scripts/orch-cost.test.ts && bun scripts/orch-retry.test.ts && bun scripts/orch-route.test.ts && bun scripts/orch-tree-render.test.ts && bun scripts/workflow-timeout.test.ts && bun scripts/session-leak.test.ts && bun scripts/rlm-contract.test.ts && bun scripts/turn-stall.test.ts && bun scripts/toolui-diff.test.ts && bun scripts/turn-memo.test.ts && bun scripts/tui/mock.test.ts", + "test": "bun scripts/design-check.test.ts && bun scripts/ponytail-debt.test.ts && bun scripts/orch-core.test.ts && bun scripts/orch.test.ts && bun scripts/orch-cost.test.ts && bun scripts/orch-retry.test.ts && bun scripts/orch-route.test.ts && bun scripts/orch-tree-render.test.ts && bun scripts/workflow-timeout.test.ts && bun scripts/session-leak.test.ts && bun scripts/rlm-contract.test.ts && bun scripts/turn-stall.test.ts && bun scripts/toolui-diff.test.ts && bun scripts/turn-memo.test.ts && RLM_MOCK=1 bun scripts/tui/mock.test.ts", "test:tui": "bun scripts/tui/mock.test.ts && bun scripts/tui/smoke.test.ts && bun scripts/tui/ui-atoms.test.ts && bun scripts/tui/focus.test.ts && bun scripts/tui/ime.test.ts && bun scripts/tui/layout.test.ts && bun scripts/tui/node-tree.test.ts && bun scripts/tui/node-tree-inline.test.ts && bun scripts/tui/icons.test.ts && bun scripts/tui/thinking-streaming.test.ts && bun scripts/tui/tool-grouping.test.ts && bun scripts/tui/tool-grouping-steps.test.ts && bun scripts/tui/transcript.test.ts && bun scripts/tui/static-commit.test.ts && bun scripts/tui/header.test.ts && bun scripts/tui/diff-viewer.test.ts && bun scripts/tui/diff-syntax.test.ts && bun scripts/tui/queued.test.ts && bun scripts/tui/rate-limit.test.ts && bun scripts/tui/theme.test.ts && bun scripts/tui/messages.test.ts && bun scripts/tui/composer.test.ts && bun scripts/tui/palette.test.ts && bun scripts/tui/dialog-select.test.ts && bun scripts/tui/dialogs.test.ts && bun scripts/tui/which-key.test.ts && bun scripts/tui/autocomplete.test.ts && bun scripts/tui/wire-autocomplete.test.ts && bun scripts/tui/keys.test.ts && bun scripts/tui/polish.test.ts", "live": "RLM_LIVE=1 bun --env-file=.env scripts/workflow-live.test.ts", "live:focus": "RLM_FOCUS_LIVE=1 bun scripts/focus-live.test.ts", diff --git a/scripts/design-check.test.ts b/scripts/design-check.test.ts index 638d784..9601bf8 100644 --- a/scripts/design-check.test.ts +++ b/scripts/design-check.test.ts @@ -2,7 +2,7 @@ // Runnable check for the design gate (ponytail: non-trivial logic leaves a // check behind). Plain asserts, no framework. One fixture per finding tag: // if the walker/heuristics drift, this fails instead of silently passing. -import { analyze, buildAnalyzer, type Finding, pkgRoot, unusedDeps } from "./design-check.ts" +import { analyze, buildAnalyzer, coreBarrelFromPkg, type Finding, pkgRoot, unusedDeps } from "./design-check.ts" let failed = 0 const assert = (cond: boolean, msg: string) => { @@ -95,6 +95,43 @@ assert(u.some((x) => x.msg.includes("leftover-pkg")), "unused dep not flagged") assert(!u.some((x) => x.msg.includes('"effect"')), "used dep false-flagged") assert(!u.some((x) => x.msg.includes("@otel/peer")), "allow-listed dep flagged") +// reachability: a dead CLUSTER (a<->b, nothing reaches it) is flagged unreachable +// even though each side keeps the other's per-symbol refcount > 0 — the precise +// win over a refcount-only dead-export check. +const reachFiles = [ + { path: "src/entry.ts", source: 'import { live } from "./live.ts"\nexport const e = live' }, + { path: "src/live.ts", source: "export const live = 1" }, + { path: "src/deadA.ts", source: 'import { b } from "./deadB.ts"\nexport const a = b' }, + { path: "src/deadB.ts", source: 'import { a } from "./deadA.ts"\nexport const b = a' }, +] +const reach = analyze(buildAnalyzer(reachFiles), { roots: new Set(["src/entry.ts"]) }) +assert(has(reach, "delete", "src/deadA.ts: module unreachable"), "dead-cluster member A not flagged unreachable") +assert(has(reach, "delete", "src/deadB.ts: module unreachable"), "dead-cluster member B not flagged unreachable") +assert(!reach.some((f) => f.msg.startsWith("src/live.ts") && f.msg.includes("unreachable")), "reachable module false-flagged unreachable") +assert(!reach.some((f) => f.msg.startsWith("src/entry.ts") && f.msg.includes("unreachable")), "root false-flagged unreachable") + +// reachability is OFF without roots (fixture default) — no "unreachable" findings, +// so the per-tag unit fixtures above drive analyze() exactly as before. +assert(!an(reachFiles).some((f) => f.msg.includes("unreachable")), "reachability ran without roots") + +// a NON-LINTED consumer (test/example/script) reference makes a module reachable: +// a seam used only by tests is live with NO hand-maintained keep-alive entry, and the +// consumer file is itself neither linted nor reported (incl. its own link errors). +const consumerFiles = [ + { path: "src/entry.ts", source: "export const e = 1" }, + { path: "src/seam.ts", source: "export const seam = 1" }, + { path: "scripts/x.test.ts", source: 'import { seam } from "../src/seam.ts"\nimport { gone } from "../src/entry.ts"\nexport const t = [seam, gone]' }, +] +const consumed = analyze(buildAnalyzer(consumerFiles), { roots: new Set(["src/entry.ts"]), isLinted: (p) => p.startsWith("src/") }) +assert(!consumed.some((f) => f.msg.includes("src/seam.ts") && f.msg.includes("unreachable")), "consumer-only module false-flagged unreachable") +assert(!consumed.some((f) => f.msg.startsWith("scripts/")), "non-linted consumer was linted/reported") + +// coreBarrelFromPkg: the cross-core seam is read from package.json "exports" "." +// (string OR conditional), falling back to the default so it can't silently drift. +assert(coreBarrelFromPkg({ exports: { ".": "./src/core/sdk.ts" } }) === "src/core/sdk.ts", "barrel from string export") +assert(coreBarrelFromPkg({ exports: { ".": { import: "./src/core/sdk.ts" } } }) === "src/core/sdk.ts", "barrel from import condition") +assert(coreBarrelFromPkg({}) === "src/core/sdk.ts", "barrel falls back to default") + // pkgRoot assert(pkgRoot("@scope/pkg/sub") === "@scope/pkg", "scoped pkgRoot") assert(pkgRoot("pkg/sub") === "pkg", "unscoped pkgRoot") diff --git a/scripts/design-check.ts b/scripts/design-check.ts index 638d542..2cfb921 100644 --- a/scripts/design-check.ts +++ b/scripts/design-check.ts @@ -2,7 +2,7 @@ // Real semantic design analysis on yuku-analyzer (not comment-grep). One // cross-file pass over src/, reporting design smells that tsc doesn't: // broken: unresolvable / ambiguous import or re-export (link diagnostic) -// delete: dead export / unused import / unused dependency +// delete: dead export / unused import / unused dependency / UNREACHABLE module // native: a dependency that duplicates a platform/runtime native // cycle: import cycle between modules // shrink: cyclomatic complexity / nesting depth over budget @@ -10,6 +10,12 @@ // mutate: exported mutable binding reassigned / local written but never read // capture: closure writes a shared module-level binding // +// Dead code is REACHABILITY-based, not a per-symbol reference count: the run +// pulls the test/example/script consumers into the graph and marks live +// everything reachable from the real entrypoints (package.json "exports" + the +// app entry). A module no root reaches is dead — which a refcount misses for a +// mutually-referencing dead CLUSTER (each side keeps the other's count > 0). +// // Core logic is exported (buildAnalyzer/analyze/unusedDeps) so scripts/ // design-check.test.ts can assert it on fixtures — ponytail: non-trivial // logic leaves a runnable check. @@ -17,14 +23,14 @@ import { Analyzer, SymbolFlags } from "yuku-analyzer" export type Finding = { tag: "broken" | "crosscore" | "delete" | "native" | "cycle" | "shrink" | "yagni" | "mutate" | "capture"; msg: string } -// CROSS-CORE BOUNDARY: src/core/* is the engine; its ONLY public seam is the src/core/sdk.ts -// barrel (package.json "exports" points there). A file OUTSIDE the trusted layers below that -// deep-imports any core module other than the barrel has reached past the SDK seam — flagged. -// Trusted layers (may import core internals): src/core/ itself, and src/app/ (the app composition -// layer that wires the default agent over a concrete AxAIService). Pure presentation (src/tui/*) -// and any external consumer must go through src/core/sdk.ts. +// CROSS-CORE BOUNDARY: src/core/* is the engine; its ONLY public seam is the core barrel +// (package.json "exports" "." points there — derived at run time, see coreBarrelFromPkg). A file +// OUTSIDE the trusted layers below that deep-imports any core module other than the barrel has +// reached past the SDK seam — flagged. Trusted layers (may import core internals): src/core/ itself, +// and src/app/ (the app composition layer that wires the default agent over a concrete AxAIService). +// Pure presentation (src/tui/*) and any external consumer must go through the barrel. const CORE_DIR = "src/core/" -const CORE_BARREL = "src/core/sdk.ts" +const DEFAULT_CORE_BARREL = "src/core/sdk.ts" const isTrustedCoreImporter = (path: string): boolean => path.startsWith(CORE_DIR) || path.startsWith("src/app/") // Resolve an import specifier to a src/-relative module path (for the crosscore check). Prefer the @@ -43,32 +49,11 @@ const resolveCoreTarget = (importerPath: string, specifier: string, resolved?: s return p.startsWith(CORE_DIR) ? p : null } -// Reachability roots: their exports are public API / entrypoints, not dead. -// chat.tsx = app entry; orch.ts = orchestration-core library surface; orch-recipes.ts -// = userland recipe surface (runNode() by turn(); judge/loopUntilDry/adversarialVerify -// by orch-run.orchestrate()) — kept a root so the recipe library surface isn't pruned -// to only its current callers. -// sdk.ts = the public SDK re-export seam (the external entrypoint, consumed by -// examples/sdk-usage.ts, outside the src/-only scan) — a root so its public-API -// re-exports aren't flagged dead. -// src/mock.ts + src/mock-ai.ts = the NARROW test-only mock seam (the deterministic AI + -// canned node feed). Consumed by scripts/tui/*.test.ts and the agent.ts RLM_MOCK runtime -// swap — the tests OUTSIDE the src/-only scan — so they're roots, like sdk.ts, lest their -// seam surface (MOCK_NODES / makeMockAI / MOCK_FIXTURE) be pruned to its in-src callers. -// src/tui/ui/* = the lifted termcast UI atoms (Spinner / Row / useEvent / useAnimationTick) — -// a presentation foundation landed AHEAD of its chat.tsx wiring and exercised by the frame -// gate fixture scripts/tui/ui-atoms-demo.tsx (mounted by ui-atoms.test.ts, OUTSIDE the -// src/-only scan). Roots like sdk.ts/mock.ts so the atom surface isn't pruned to its (empty, -// pending integration) in-src callers before the sequential chat.tsx re-skin consumes it. -// src/tui/autocomplete.tsx = the @-mention + /slash popup (Autocomplete + useAutocomplete) — -// same case: a presentation foundation landed AHEAD of its composer wiring (the SEPARATE -// wire-autocomplete step) and exercised by the frame gate fixture scripts/tui/autocomplete-demo.tsx -// (mounted by autocomplete.test.ts, OUTSIDE the src/-only scan). A root so its popup surface isn't -// pruned before the composer consumes it. -// src/tui/dialog-select.tsx is deliberately NOT a root: the palette refactor wired it in (palette.tsx -// wraps DialogSelect + chat.tsx drives useDialogSelect), so it's reachable from chat.tsx and gets the -// normal dead-export check like any other module — no root exemption needed. -const ENTRY = new Set(["src/tui/chat.tsx", "src/core/orch.ts", "src/core/orch-recipes.ts", "src/core/sdk.ts", "src/core/run.ts", "src/core/mock.ts", "src/core/mock-ai.ts", "src/tui/ui/spinner.tsx", "src/tui/ui/row.tsx", "src/tui/ui/hooks.tsx", "src/tui/ui/animation-tick.tsx", "src/tui/ui/panel.tsx", "src/tui/autocomplete.tsx"]) +// Runtime entrypoints that are NOT expressible in package.json "exports": the TUI app boots by +// `bun src/tui/chat.tsx`, so it (and what it imports) is reachable even though it is not a library +// export. Everything else that must stay live is reached either from here or from a real consumer +// (tests/examples) now that those are in the graph — so there is no hand-maintained keep-alive list. +const APP_ENTRYPOINTS = ["src/tui/chat.tsx"] const CC_BUDGET = 20 // cyclomatic complexity per function (UI render fns with several display states idiomatically reach ~19; >20 = real tangle) const NEST_BUDGET = 8 // block nesting depth per function const PARAM_BUDGET = 6 // parameters per function @@ -133,11 +118,16 @@ export const buildAnalyzer = (files: { path: string; source: string }[]): Analyz return a } -export const importedRoots = (a: Analyzer): Set => { +// Package roots imported by modules the predicate accepts (default: every module). The run +// narrows this to src/ so a dependency used ONLY by tests/scripts still reads as unused-in-prod. +export const importedRoots = (a: Analyzer, isLinted: (path: string) => boolean = () => true): Set => { const roots = new Set() - for (const m of a.modules.values()) for (const imp of m.imports) { - const root = pkgRoot(imp.specifier) - if (root) roots.add(root) + for (const m of a.modules.values()) { + if (!isLinted(m.path)) continue + for (const imp of m.imports) { + const root = pkgRoot(imp.specifier) + if (root) roots.add(root) + } } return roots } @@ -157,9 +147,27 @@ const lineOf = (m: any, node: any): number => { const countLines = (source: string): number => source.split(/\r?\n/).length -export const analyze = (a: Analyzer): Finding[] => { +// Options for a real run. Defaults keep the function pure + fixture-friendly: with no roots the +// reachability pass is OFF and every module is linted, so the unit tests drive analyze() exactly +// as before. +export type AnalyzeOptions = { + // Public/entry module paths whose exports are the API surface (never "dead"); also the seeds of + // the reachability sweep. Empty → reachability pass disabled (fixture mode). + roots?: Set + // Which modules to actually LINT (per-file smells + dead-export). Non-linted modules + // (tests/examples/scripts) still populate the reference + import graph. Default: lint everything. + isLinted?: (path: string) => boolean + // The core public barrel for the cross-core boundary. Default src/core/sdk.ts. + coreBarrel?: string +} + +export const analyze = (a: Analyzer, opts: AnalyzeOptions = {}): Finding[] => { + const roots = opts.roots ?? new Set() + const isLinted = opts.isLinted ?? (() => true) + const coreBarrel = opts.coreBarrel ?? DEFAULT_CORE_BARREL const out: Finding[] = [] for (const m of a.modules.values()) { + if (!isLinted(m.path)) continue // consumers (tests/examples) inform the graph but are not linted // file-size budget: CONDITIONED on role — 300 for a public-index/barrel, 500 for an // internal impl file — with an allow-list for existing oversized files so the rule // only blocks new growth. @@ -171,8 +179,10 @@ export const analyze = (a: Analyzer): Finding[] => { out.push({ tag: "shrink", msg: `${m.path}: ${lines} lines (${role} budget ${budget}). Split the file.` }) } } - // dead exports (cross-file). Skip entry roots + type-only (referencesOf undercounts type uses). - if (!ENTRY.has(m.path)) { + // dead exports (cross-file). Skip roots (their exports are the public surface) + type-only + // (referencesOf undercounts type uses). A reference from a consumer (test/example) counts, so + // a seam used only by tests is live without a hand-maintained keep-alive entry. + if (!roots.has(m.path)) { for (const s of m.symbols) { if (!s.has(SymbolFlags.Exported)) continue if (s.has(SymbolFlags.TypeSpace) && !s.has(SymbolFlags.ValueSpace)) continue @@ -184,12 +194,12 @@ export const analyze = (a: Analyzer): Finding[] => { const root = pkgRoot(imp.specifier) if (root && NATIVE_DUPES[root]) out.push({ tag: "native", msg: `${m.path}: "${root}" duplicates a native — use ${NATIVE_DUPES[root]}.` }) // CROSS-CORE: an importer outside the trusted layers reaching into a core module other than - // the sdk.ts barrel has gone past the public seam. (type-only imports count too — even a type + // the barrel has gone past the public seam. (type-only imports count too — even a type // dependency on a core internal couples the consumer to the engine's private shapes.) if (!isTrustedCoreImporter(m.path)) { const target = resolveCoreTarget(m.path, imp.specifier, imp.resolvedModule?.path) - if (target !== null && target !== CORE_BARREL) - out.push({ tag: "crosscore", msg: `${m.path}: deep import of core module "${target}" (via "${imp.specifier}"). Cross-core deep import — go through the ${CORE_BARREL} barrel.` }) + if (target !== null && target !== coreBarrel) + out.push({ tag: "crosscore", msg: `${m.path}: deep import of core module "${target}" (via "${imp.specifier}"). Cross-core deep import — go through the ${coreBarrel} barrel.` }) } if (imp.isSideEffect || imp.typeOnly || !imp.local) continue if (imp.local.references.length === 0) out.push({ tag: "delete", msg: `${m.path}: unused import "${imp.local.name}". Remove it.` }) @@ -255,14 +265,43 @@ export const analyze = (a: Analyzer): Finding[] => { // tsc reports these too, but surfacing them here keeps the one gate authoritative. for (const d of a.diagnostics) { if (d.severity !== "error" && d.severity !== "warning") continue + if (!isLinted(d.module)) continue // a consumer (test/example) seeds the graph but its own link errors are out of scope out.push({ tag: "broken", msg: `${d.module}: ${d.message}` }) } - // circular dependencies (DFS over the resolved import graph). + // resolved import edges among LINTED modules (drives both reachability and cycles). const edges = new Map() for (const m of a.modules.values()) { - edges.set(m.path, m.imports.map((i) => i.resolvedModule?.path).filter((p): p is string => Boolean(p))) + if (!isLinted(m.path)) continue + edges.set(m.path, m.imports.map((i) => i.resolvedModule?.path).filter((p): p is string => Boolean(p) && isLinted(p!))) } + + // UNREACHABLE modules (dead files / dead clusters). Reachability replaces a hand-maintained + // keep-alive allowlist: seed from the real roots PLUS any linted module a consumer (test/example/ + // script) imports, then sweep the import graph. A linted module no seed reaches is dead — caught + // even when it is part of a mutually-referencing cluster a per-symbol refcount would keep alive. + if (roots.size > 0) { + const seeds = new Set(roots) + for (const m of a.modules.values()) { + if (isLinted(m.path)) continue + for (const imp of m.imports) { + const p = imp.resolvedModule?.path + if (p && isLinted(p)) seeds.add(p) + } + } + const reached = new Set() + const stack = [...seeds] + while (stack.length) { + const n = stack.pop()! + if (reached.has(n)) continue + reached.add(n) + for (const d of edges.get(n) ?? []) if (!reached.has(d)) stack.push(d) + } + for (const path of edges.keys()) + if (!reached.has(path)) out.push({ tag: "delete", msg: `${path}: module unreachable from any entrypoint — dead file. Drop it or wire it in.` }) + } + + // circular dependencies (DFS over the resolved import graph). const seen = new Set() const path: string[] = [] const onPath = new Set() @@ -306,6 +345,14 @@ export const unusedDeps = (imported: Set, deps: string[], allow = RUNTIM // committer actually staged — so one agent's WIP can't fail another's commit. export const findingFiles = (msg: string): string[] => msg.match(/(?:src|scripts)\/[^\s:→]+|package\.json/g) ?? [] +// The library public barrel, read from package.json "exports" "." (falls back to the default). +// Keeps the cross-core seam from drifting away from what the package actually publishes. +export const coreBarrelFromPkg = (pkg: any): string => { + const dot = pkg?.exports?.["."] + const target = typeof dot === "string" ? dot : typeof dot?.import === "string" ? dot.import : typeof dot?.default === "string" ? dot.default : null + return target ? target.replace(/^\.\//, "") : DEFAULT_CORE_BARREL +} + const stagedSet = (): Set => { const r = Bun.spawnSync(["git", "diff", "--cached", "--name-only"]) if (!r.success) return new Set() @@ -314,11 +361,21 @@ const stagedSet = (): Set => { if (import.meta.main) { const staged = process.argv.includes("--staged") + const isLinted = (p: string) => p.startsWith("src/") + // src/ is LINTED; scripts/ + examples/ are CONSUMERS — they join the reference + import graph so + // a seam used only by a test/example reads as live, but they are not themselves linted. const files: { path: string; source: string }[] = [] - for await (const p of new Bun.Glob("src/**/*.{ts,tsx}").scan(".")) files.push({ path: p, source: await Bun.file(p).text() }) + for (const glob of ["src/**/*.{ts,tsx}", "scripts/**/*.{ts,tsx}", "examples/**/*.{ts,tsx}"]) + for await (const p of new Bun.Glob(glob).scan(".")) files.push({ path: p, source: await Bun.file(p).text() }) const a = buildAnalyzer(files) const pkg = await Bun.file("package.json").json() - const out = [...analyze(a), ...unusedDeps(importedRoots(a), Object.keys(pkg.dependencies ?? {}))] + // roots = the published library surface (package.json "exports") + runtime entrypoints. + const coreBarrel = coreBarrelFromPkg(pkg) + const roots = new Set([coreBarrel, ...APP_ENTRYPOINTS].filter((p) => a.modules.has(p))) + const out = [ + ...analyze(a, { roots, isLinted, coreBarrel }), + ...unusedDeps(importedRoots(a, isLinted), Object.keys(pkg.dependencies ?? {})), + ] // In --staged mode, a finding blocks only if it touches a staged file. const stage = staged ? stagedSet() : null diff --git a/scripts/ponytail-debt.test.ts b/scripts/ponytail-debt.test.ts index 0f50647..ee3070e 100644 --- a/scripts/ponytail-debt.test.ts +++ b/scripts/ponytail-debt.test.ts @@ -48,6 +48,19 @@ assert(orph.markers.some((m) => m.includes("[orphan]")), "orphan marker not tagg const live = harvest("l.ts", "// ponytail: shortcut. Upgrade: later.\nexport const x = 1") assert(live.orphan === 0, `live marker wrongly flagged orphan: ${live.orphan}`) +// TWO IDENTICAL marker texts must each be harvested as a DISTINCT marker against +// its OWN host — not collapsed onto the first host (the old text-keyed map mapped +// both to host #1, so one occurrence inherited the other's orphan/live verdict). +const dup = harvest("dup.ts", [ + "// ponytail: same note. Upgrade: x.", + "export const a = 1", + "// ponytail: same note. Upgrade: x.", + "export const b = 2", +].join("\n")) +assert(dup.markers.length === 2, `both identical markers should harvest: got ${dup.markers.length}`) +assert(dup.orphan === 0, `identical live markers wrongly orphaned: ${dup.orphan}`) +assert(dup.markers.filter((m) => m.includes("dup.ts:")).length === 2, "identical markers collapsed to one span") + if (failed > 0) { console.error(`ponytail-debt.test: ${failed} failure(s).`) process.exit(1) diff --git a/scripts/ponytail-debt.ts b/scripts/ponytail-debt.ts index 4174a7a..94ddfc6 100644 --- a/scripts/ponytail-debt.ts +++ b/scripts/ponytail-debt.ts @@ -22,14 +22,23 @@ export type DebtResult = { markers: string[]; noTrigger: number; orphan: number; const MARKER = /^[\s*]*ponytail:\s*(.+)/is const PLACEHOLDER = /| => { - const map = new Map() +// Map every attached comment back to its host node. AttachedComment carries no +// offset (only value/type/position), so text is the only join key — but two +// identical `// ponytail:` lines must NOT collapse to the first host (that +// misclassifies one as the other's orphan/live state). So key text -> hosts in +// WALK ORDER, and the harvest consumes the Nth occurrence against the Nth host +// (m.comments is source order, the walk is pre-order: same order, so the Nth +// duplicate lines up). A marker whose host is the Program root guards no code — +// it's an orphan. +const hostsByComment = (m: Module): Map => { + const map = new Map() m.walk({ enter: (node) => { - for (const c of node.comments ?? []) if (!map.has(c.value)) map.set(c.value, node) + for (const c of node.comments ?? []) { + const arr = map.get(c.value) + if (arr) arr.push(node) + else map.set(c.value, [node]) + } }, }) return map @@ -38,12 +47,14 @@ const hostByComment = (m: Module): Map => { export const harvest = (path: string, text: string): DebtResult => { const a = new Analyzer() const m = a.addFile(path, text, { attachComments: true }) - const hosts = hostByComment(m) + const hosts = hostsByComment(m) const comments = m.comments const markers: string[] = [] let noTrigger = 0 let orphan = 0 + // per-text cursor: the Nth comment of a given text matches the Nth host. + const hostCursor = new Map() for (let i = 0; i < comments.length; i++) { const c = comments[i]! const match = c.value.match(MARKER) @@ -64,7 +75,9 @@ export const harvest = (path: string, text: string): DebtResult => { } const hasTrigger = /upgrade:/i.test(body) - const host = hosts.get(c.value) + const seen = hostCursor.get(c.value) ?? 0 + hostCursor.set(c.value, seen + 1) + const host = hosts.get(c.value)?.[seen] const isOrphan = !host || host.type === "Program" if (!hasTrigger) noTrigger++ if (isOrphan) orphan++ diff --git a/src/core/orch-recipes.ts b/src/core/orch-recipes.ts index bdd11ce..befdda4 100644 --- a/src/core/orch-recipes.ts +++ b/src/core/orch-recipes.ts @@ -245,24 +245,6 @@ export const judge = async ( toInput: (candidates: ReadonlyArray) => I, ): Promise => node(judgeGen, judgeOpts)(ai, toInput(candidates)) -// loopUntilDry — run body repeatedly until isDry(prev,next) says it converged (or max -// hit). Returns the last (accumulated) value. Body owns its own accumulation. -// Adopted by orch-run.orchestrate() (re-runs the candidate fan-out until the -// surviving-count converges). -export const loopUntilDry = async ( - body: () => Promise, - isDry: (prev: T, next: T) => boolean, - max = 8, -): Promise => { - let prev = await body() - for (let i = 1; i < max; i++) { - const next = await body() - if (isDry(prev, next)) return next - prev = next - } - return prev -} - // adversarialVerify — produce once, then fan the skeptics out via parallelLimit() (failed // skeptic → null, dropped), and let `accept` tally the boolean votes. // Adopted by orch-run.orchestrate() (skeptics vote on the judged answer). diff --git a/src/core/orch.ts b/src/core/orch.ts index dad23a9..0cb928f 100644 --- a/src/core/orch.ts +++ b/src/core/orch.ts @@ -1,7 +1,7 @@ // Orchestration core: a faithful PORT of the Workflow engine onto @ax-llm/ax. // EXACTLY 5 orthogonal primitives, nothing else is engine. Promise-native at this // level — Effect stays at the session boundary (turn() in agent.ts) and in otel.ts, -// NOT inside the combinators. runNode()/judge/loopUntilDry/workflow() are userland +// NOT inside the combinators. runNode()/judge/workflow() are userland // recipes (each <15 lines in these 5 prims), DELIBERATELY not reified here. // // UNIFIED VOCABULARY — ONE WORD: the orchestration unit is a NODE. The core prim that diff --git a/src/tui/ui/hooks.tsx b/src/tui/ui/hooks.tsx deleted file mode 100644 index 35ee43a..0000000 --- a/src/tui/ui/hooks.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// LIFTED verbatim from termcast/src/hooks.tsx — a stable callback that always invokes the -// LATEST handler. Lets an event handler be passed into an effect with an empty dep array (or -// to a memoized child) without re-running the effect or going stale on props/state. -import { useCallback, useLayoutEffect, useRef } from "react" - -export const useEvent = unknown>(handler: T): T => { - const handlerRef = useRef(handler) - - useLayoutEffect(() => { - handlerRef.current = handler - }) - - return useCallback((...args: Parameters) => { - const fn = handlerRef.current - return fn(...args) - }, []) as T -}