@vladpazych/dexter 0.1.0

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 (68) hide show
  1. package/bin/dexter +6 -0
  2. package/package.json +43 -0
  3. package/src/claude/index.ts +6 -0
  4. package/src/cli.ts +39 -0
  5. package/src/env/define.ts +190 -0
  6. package/src/env/index.ts +10 -0
  7. package/src/env/loader.ts +61 -0
  8. package/src/env/print.ts +98 -0
  9. package/src/env/validate.ts +46 -0
  10. package/src/index.ts +16 -0
  11. package/src/meta/adapters/fs.ts +22 -0
  12. package/src/meta/adapters/git.ts +29 -0
  13. package/src/meta/adapters/glob.ts +14 -0
  14. package/src/meta/adapters/index.ts +24 -0
  15. package/src/meta/adapters/process.ts +40 -0
  16. package/src/meta/cli.ts +340 -0
  17. package/src/meta/domain/bisect.ts +126 -0
  18. package/src/meta/domain/blame.ts +136 -0
  19. package/src/meta/domain/commit.ts +135 -0
  20. package/src/meta/domain/commits.ts +23 -0
  21. package/src/meta/domain/constraints/registry.ts +49 -0
  22. package/src/meta/domain/constraints/types.ts +30 -0
  23. package/src/meta/domain/diff.ts +34 -0
  24. package/src/meta/domain/eval.ts +57 -0
  25. package/src/meta/domain/format.ts +34 -0
  26. package/src/meta/domain/lint.ts +88 -0
  27. package/src/meta/domain/pickaxe.ts +99 -0
  28. package/src/meta/domain/quality.ts +145 -0
  29. package/src/meta/domain/rules.ts +21 -0
  30. package/src/meta/domain/scope-context.ts +63 -0
  31. package/src/meta/domain/service.ts +68 -0
  32. package/src/meta/domain/setup.ts +34 -0
  33. package/src/meta/domain/test.ts +72 -0
  34. package/src/meta/domain/transcripts.ts +88 -0
  35. package/src/meta/domain/typecheck.ts +41 -0
  36. package/src/meta/domain/workspace.ts +78 -0
  37. package/src/meta/errors.ts +19 -0
  38. package/src/meta/hooks/on-post-read.ts +61 -0
  39. package/src/meta/hooks/on-post-write.ts +65 -0
  40. package/src/meta/hooks/on-pre-bash.ts +69 -0
  41. package/src/meta/hooks/stubs.ts +51 -0
  42. package/src/meta/index.ts +36 -0
  43. package/src/meta/lib/actor.ts +53 -0
  44. package/src/meta/lib/eslint.ts +58 -0
  45. package/src/meta/lib/format.ts +55 -0
  46. package/src/meta/lib/paths.ts +36 -0
  47. package/src/meta/lib/present.ts +231 -0
  48. package/src/meta/lib/spec-links.ts +83 -0
  49. package/src/meta/lib/stdin.ts +56 -0
  50. package/src/meta/ports.ts +50 -0
  51. package/src/meta/types.ts +113 -0
  52. package/src/output/build.ts +56 -0
  53. package/src/output/index.ts +24 -0
  54. package/src/output/output.test.ts +374 -0
  55. package/src/output/render-cli.ts +55 -0
  56. package/src/output/render-json.ts +80 -0
  57. package/src/output/render-md.ts +43 -0
  58. package/src/output/render-xml.ts +55 -0
  59. package/src/output/render.ts +23 -0
  60. package/src/output/types.ts +44 -0
  61. package/src/pipe/format.ts +167 -0
  62. package/src/pipe/index.ts +4 -0
  63. package/src/pipe/parse.ts +131 -0
  64. package/src/pipe/spawn.ts +205 -0
  65. package/src/pipe/types.ts +27 -0
  66. package/src/terminal/colors.ts +95 -0
  67. package/src/terminal/index.ts +16 -0
  68. package/src/version.ts +1 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Quality gates — auto-fix then gate before commit.
3
+ *
4
+ * Prettier and ESLint run in fix mode (deterministic, always safe).
5
+ * Typecheck is gate-only (no auto-fix possible).
6
+ */
7
+
8
+ import { join, relative } from "node:path"
9
+
10
+ import type { ControlPorts } from "../ports.ts"
11
+ import type { Package, QualityCheck, QualityResult } from "../types.ts"
12
+
13
+ import { discoverPackages, filterByFiles } from "./workspace.ts"
14
+
15
+ const SKIP_PATTERNS = ["node_modules/", "dist/", ".next/", "coverage/", ".d.ts", "routeTree.gen.ts"]
16
+
17
+ const LINT_EXTS = [".ts", ".tsx"]
18
+ const FORMAT_EXTS = [".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".css", ".html", ".yaml", ".yml"]
19
+ const TYPECHECK_EXTS = [".ts", ".tsx"]
20
+
21
+ function hasExt(file: string, exts: string[]): boolean {
22
+ return exts.some((ext) => file.endsWith(ext))
23
+ }
24
+
25
+ function isSkipped(file: string): boolean {
26
+ return SKIP_PATTERNS.some((p) => file.includes(p))
27
+ }
28
+
29
+ export function isLintable(file: string): boolean {
30
+ return hasExt(file, LINT_EXTS) && !isSkipped(file)
31
+ }
32
+
33
+ export function isFormattable(file: string): boolean {
34
+ return hasExt(file, FORMAT_EXTS) && !isSkipped(file)
35
+ }
36
+
37
+ export function isTypecheckable(file: string): boolean {
38
+ return hasExt(file, TYPECHECK_EXTS) && !isSkipped(file)
39
+ }
40
+
41
+ function collectOutput(ports: ControlPorts, cmd: string, args: string[], cwd: string): Promise<QualityCheck> {
42
+ return new Promise((resolve) => {
43
+ const lines: string[] = []
44
+ const handle = ports.process.spawn({ cmd, args, cwd })
45
+
46
+ handle.onLine("stdout", (line) => lines.push(line))
47
+ handle.onLine("stderr", (line) => lines.push(line))
48
+
49
+ handle.wait().then((code) => {
50
+ resolve({
51
+ name: `${cmd} ${args[0] ?? ""}`.trim(),
52
+ status: code === 0 ? "pass" : "fail",
53
+ output: lines.join("\n"),
54
+ })
55
+ })
56
+ })
57
+ }
58
+
59
+ function scopedTypecheck(ports: ControlPorts, pkg: Package, committedFiles: string[]): Promise<QualityCheck> {
60
+ return new Promise((resolve) => {
61
+ const lines: string[] = []
62
+ const handle = ports.process.spawn({ cmd: "bun", args: ["run", "typecheck"], cwd: pkg.dir })
63
+
64
+ handle.onLine("stdout", (line) => lines.push(line))
65
+ handle.onLine("stderr", (line) => lines.push(line))
66
+
67
+ handle.wait().then((code) => {
68
+ const name = `typecheck (${pkg.shortName})`
69
+
70
+ if (code === 0) {
71
+ resolve({ name, status: "pass", output: "" })
72
+ return
73
+ }
74
+
75
+ // Filter to errors in committed files only
76
+ const relFiles = committedFiles
77
+ .filter((f) => f.startsWith(pkg.relDir + "/"))
78
+ .map((f) => f.slice(pkg.relDir.length + 1))
79
+
80
+ const relevant = lines.filter((line) => relFiles.some((f) => line.startsWith(f + "(")))
81
+
82
+ resolve({
83
+ name,
84
+ status: relevant.length > 0 ? "fail" : "pass",
85
+ output: relevant.join("\n"),
86
+ })
87
+ })
88
+ })
89
+ }
90
+
91
+ export async function checkQuality(ports: ControlPorts, files: string[]): Promise<QualityResult> {
92
+ const checks: Promise<QualityCheck>[] = []
93
+
94
+ const existing = files.filter((f) => ports.fs.exists(join(ports.root, f)))
95
+ const lintFiles = existing.filter(isLintable)
96
+ const formatFiles = existing.filter(isFormattable)
97
+
98
+ const packages = discoverPackages(ports)
99
+
100
+ // ESLint on lintable files — package-aware routing
101
+ if (lintFiles.length > 0) {
102
+ const lintAffected = filterByFiles(packages, lintFiles, ports.root)
103
+ const pkgWithConfig = lintAffected.filter((pkg) => ports.fs.exists(join(pkg.dir, "eslint.config.js")))
104
+ const pkgFiles = new Set<string>()
105
+
106
+ for (const pkg of pkgWithConfig) {
107
+ const pkgSpecific = lintFiles.filter((f) => f.startsWith(pkg.relDir + "/"))
108
+ for (const f of pkgSpecific) pkgFiles.add(f)
109
+ if (pkgSpecific.length > 0) {
110
+ const relFiles = pkgSpecific.map((f) => relative(pkg.relDir, f))
111
+ checks.push(collectOutput(ports, "bunx", ["eslint", "--fix", "--no-warn-ignored", ...relFiles], pkg.dir))
112
+ }
113
+ }
114
+
115
+ const rootLintFiles = lintFiles.filter((f) => !pkgFiles.has(f))
116
+ if (rootLintFiles.length > 0) {
117
+ checks.push(collectOutput(ports, "bunx", ["eslint", "--fix", "--no-warn-ignored", ...rootLintFiles], ports.root))
118
+ }
119
+ }
120
+
121
+ // Prettier on all formattable files
122
+ if (formatFiles.length > 0) {
123
+ checks.push(collectOutput(ports, "bunx", ["prettier", "--write", ...formatFiles], ports.root))
124
+ }
125
+
126
+ // Typecheck per affected package — scoped to committed files only
127
+ const tsFiles = existing.filter(isTypecheckable)
128
+ if (tsFiles.length > 0) {
129
+ const affected = filterByFiles(packages, tsFiles, ports.root)
130
+ for (const pkg of affected) {
131
+ if ("typecheck" in pkg.scripts) {
132
+ checks.push(scopedTypecheck(ports, pkg, tsFiles))
133
+ }
134
+ }
135
+ }
136
+
137
+ if (checks.length === 0) {
138
+ return { passed: true, checks: [] }
139
+ }
140
+
141
+ const results = await Promise.all(checks)
142
+ const passed = results.every((c) => c.status === "pass")
143
+
144
+ return { passed, checks: results }
145
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Query: rules — governing CLAUDE.md cascade for given scopes.
3
+ */
4
+
5
+ import type { ControlPorts } from "../ports.ts"
6
+ import type { QueryResult, RulesScope } from "../types.ts"
7
+ import { resolveCascade, scopeToDir } from "./scope-context.ts"
8
+
9
+ export function rules(ports: ControlPorts, scopes: string[]): QueryResult {
10
+ const data: RulesScope[] = []
11
+
12
+ for (const scope of scopes) {
13
+ const dir = scopeToDir(scope)
14
+ const cascade = resolveCascade(ports, dir)
15
+ if (cascade.length > 0) {
16
+ data.push({ path: scope, cascade })
17
+ }
18
+ }
19
+
20
+ return { what: "rules", scopes, data }
21
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Scope utilities — resolve CLAUDE.md cascade from scope strings.
3
+ */
4
+
5
+ import { join } from "node:path"
6
+
7
+ import type { ControlPorts } from "../ports.ts"
8
+
9
+ /**
10
+ * Extract the directory prefix from a scope string.
11
+ *
12
+ * - File path: strip filename → directory
13
+ * - Glob: extract non-glob prefix
14
+ * - Directory: as-is (strip trailing slash)
15
+ */
16
+ export function scopeToDir(scope: string): string {
17
+ // Strip trailing slash for consistency
18
+ const clean = scope.replace(/\/+$/, "")
19
+
20
+ // Glob: take directory prefix before first glob character
21
+ if (/[*?{]/.test(clean)) {
22
+ const idx = clean.search(/[*?{]/)
23
+ const prefix = clean.slice(0, idx).replace(/\/+$/, "")
24
+ return prefix || "."
25
+ }
26
+
27
+ // File: if last segment has a dot, treat as file → strip to parent
28
+ const lastSlash = clean.lastIndexOf("/")
29
+ const lastSegment = lastSlash >= 0 ? clean.slice(lastSlash + 1) : clean
30
+ if (lastSegment.includes(".")) {
31
+ return lastSlash >= 0 ? clean.slice(0, lastSlash) : "."
32
+ }
33
+
34
+ return clean || "."
35
+ }
36
+
37
+ /**
38
+ * Walk from root to target directory, collecting CLAUDE.md paths that exist.
39
+ * Returns @-prefixed references in cascade order: root first, most specific last.
40
+ */
41
+ export function resolveCascade(ports: ControlPorts, dir: string): string[] {
42
+ const refs: string[] = []
43
+
44
+ // Always check root CLAUDE.md
45
+ if (ports.fs.exists(join(ports.root, "CLAUDE.md"))) {
46
+ refs.push("@CLAUDE.md")
47
+ }
48
+
49
+ // Walk each segment of the directory path
50
+ if (dir !== ".") {
51
+ const segments = dir.split("/").filter(Boolean)
52
+ let current = ""
53
+
54
+ for (const segment of segments) {
55
+ current = current ? `${current}/${segment}` : segment
56
+ if (ports.fs.exists(join(ports.root, current, "CLAUDE.md"))) {
57
+ refs.push(`@${current}/CLAUDE.md`)
58
+ }
59
+ }
60
+ }
61
+
62
+ return refs
63
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Control service factory — wires domain functions to ports.
3
+ */
4
+
5
+ import type { ControlPorts } from "../ports.ts"
6
+ import type {
7
+ CommitParams,
8
+ CommitResult,
9
+ QueryResult,
10
+ GitResult,
11
+ SetupResult,
12
+ EvalParams,
13
+ EvalResult,
14
+ Package,
15
+ TranscriptsParams,
16
+ TranscriptsResult,
17
+ } from "../types.ts"
18
+
19
+ import { commit } from "./commit.ts"
20
+ import { rules } from "./rules.ts"
21
+ import { diff } from "./diff.ts"
22
+ import { commits } from "./commits.ts"
23
+ import { lint } from "./lint.ts"
24
+ import { typecheck } from "./typecheck.ts"
25
+ import { test } from "./test.ts"
26
+ import { blame } from "./blame.ts"
27
+ import { pickaxe } from "./pickaxe.ts"
28
+ import { bisect } from "./bisect.ts"
29
+ import { setup } from "./setup.ts"
30
+ import { evaluate } from "./eval.ts"
31
+ import { transcripts } from "./transcripts.ts"
32
+ import { discoverPackages } from "./workspace.ts"
33
+
34
+ export type ControlService = {
35
+ commit(params: CommitParams): Promise<CommitResult>
36
+ rules(scopes: string[]): QueryResult
37
+ diff(scopes: string[]): QueryResult
38
+ commits(scopes: string[]): QueryResult
39
+ lint(scopes: string[], opts?: { changed?: boolean }): Promise<QueryResult>
40
+ typecheck(scopes: string[]): Promise<QueryResult>
41
+ test(scopes: string[]): Promise<QueryResult>
42
+ blame(file: string, lines?: [number, number]): GitResult
43
+ pickaxe(pattern: string, opts?: { regex?: boolean; scopes?: string[] }): GitResult
44
+ bisect(test: string, good: string, bad?: string, timeout?: number): Promise<GitResult>
45
+ setup(): SetupResult
46
+ eval(params: EvalParams): Promise<EvalResult>
47
+ transcripts(params: TranscriptsParams): TranscriptsResult
48
+ discoverPackages(): Package[]
49
+ }
50
+
51
+ export function createControlService(ports: ControlPorts): ControlService {
52
+ return {
53
+ commit: (params) => commit(ports, params),
54
+ rules: (scopes) => rules(ports, scopes),
55
+ diff: (scopes) => diff(ports, scopes),
56
+ commits: (scopes) => commits(ports, scopes),
57
+ lint: (scopes, opts) => lint(ports, scopes, opts),
58
+ typecheck: (scopes) => typecheck(ports, scopes),
59
+ test: (scopes) => test(ports, scopes),
60
+ blame: (file, lines) => blame(ports, file, lines),
61
+ pickaxe: (pattern, opts) => pickaxe(ports, pattern, opts),
62
+ bisect: (testCmd, good, bad, timeout) => bisect(ports, testCmd, good, bad, timeout),
63
+ setup: () => setup(ports),
64
+ eval: (params) => evaluate(ports, params),
65
+ transcripts: (params) => transcripts(ports, params),
66
+ discoverPackages: () => discoverPackages(ports),
67
+ }
68
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Setup domain logic — configures Claude Code local settings.
3
+ */
4
+
5
+ import { join } from "node:path"
6
+
7
+ import type { ControlPorts } from "../ports.ts"
8
+ import type { SetupResult } from "../types.ts"
9
+
10
+ type LocalSettings = {
11
+ env?: Record<string, string>
12
+ [key: string]: unknown
13
+ }
14
+
15
+ export function setup(ports: ControlPorts): SetupResult {
16
+ const settingsPath = join(ports.root, ".claude/settings.local.json")
17
+ const binPath = join(ports.root, ".claude/bin")
18
+
19
+ let settings: LocalSettings = {}
20
+ if (ports.fs.exists(settingsPath)) {
21
+ try {
22
+ settings = JSON.parse(ports.fs.readFile(settingsPath)) as LocalSettings
23
+ } catch {
24
+ // Invalid JSON, start fresh
25
+ }
26
+ }
27
+
28
+ settings.env = settings.env ?? {}
29
+ settings.env.PATH = `${binPath}:$PATH`
30
+
31
+ ports.fs.writeFile(settingsPath, JSON.stringify(settings, null, 2) + "\n")
32
+
33
+ return { settingsPath, binPath }
34
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Query: test — run bun test with explicit test file paths.
3
+ */
4
+
5
+ import type { ControlPorts } from "../ports.ts"
6
+ import type { QueryResult, Package } from "../types.ts"
7
+ import { discoverPackages, filterByFiles, filterByScript } from "./workspace.ts"
8
+ import { extractErrors } from "./format.ts"
9
+
10
+ export async function test(ports: ControlPorts, scopes: string[]): Promise<QueryResult> {
11
+ const packages = discoverPackages(ports)
12
+
13
+ const { FORCE_COLOR: _, ...parentEnv } = process.env as Record<string, string>
14
+ const env: Record<string, string> = { ...parentEnv, NO_COLOR: "1" }
15
+
16
+ if (scopes.length === 0) {
17
+ const withTest = filterByScript(packages, "test")
18
+ if (withTest.length === 0) {
19
+ return { what: "test", scopes, data: { errorCount: 0, errors: [], raw: "" } }
20
+ }
21
+ const results = await Promise.all(withTest.map((pkg) => runTestScript(ports, pkg, env)))
22
+ const raw = results.filter(Boolean).join("\n")
23
+ return { what: "test", scopes, data: extractErrors(raw) }
24
+ }
25
+
26
+ const affected = filterByFiles(packages, scopes, ports.root)
27
+
28
+ if (affected.length === 0) {
29
+ return { what: "test", scopes, data: { errorCount: 0, errors: [], raw: "" } }
30
+ }
31
+
32
+ const results = await Promise.all(
33
+ affected.map((pkg) => {
34
+ const testFiles = scopes.filter((s) => s.startsWith(pkg.relDir + "/"))
35
+ return runTests(ports, pkg, testFiles, env)
36
+ }),
37
+ )
38
+
39
+ const raw = results.filter(Boolean).join("\n")
40
+ return { what: "test", scopes, data: extractErrors(raw) }
41
+ }
42
+
43
+ function runTestScript(ports: ControlPorts, pkg: Package, env: Record<string, string>): Promise<string> {
44
+ return new Promise((resolve) => {
45
+ const lines: string[] = []
46
+ const handle = ports.process.spawn({ cmd: "bun", args: ["run", "test"], cwd: pkg.dir, env })
47
+ handle.onLine("stdout", (line) => lines.push(line))
48
+ handle.onLine("stderr", (line) => lines.push(line))
49
+ handle.wait().then(() => resolve(lines.join("\n")))
50
+ })
51
+ }
52
+
53
+ function runTests(
54
+ ports: ControlPorts,
55
+ pkg: Package,
56
+ testFiles: string[],
57
+ env: Record<string, string>,
58
+ ): Promise<string> {
59
+ return new Promise((resolve) => {
60
+ const lines: string[] = []
61
+ const relFiles = testFiles.map((f) => f.slice(pkg.relDir.length + 1))
62
+ const handle = ports.process.spawn({
63
+ cmd: "bun",
64
+ args: ["test", ...relFiles],
65
+ cwd: pkg.dir,
66
+ env,
67
+ })
68
+ handle.onLine("stdout", (line) => lines.push(line))
69
+ handle.onLine("stderr", (line) => lines.push(line))
70
+ handle.wait().then(() => resolve(lines.join("\n")))
71
+ })
72
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Transcripts — find and list Claude Code subagent transcripts.
3
+ */
4
+
5
+ import { join } from "node:path"
6
+
7
+ import type { ControlPorts } from "../ports.ts"
8
+ import type { TranscriptEntry, TranscriptsParams, TranscriptsResult } from "../types.ts"
9
+
10
+ const DEFAULT_MINUTES = 120
11
+ const SKILL_DIR_PATTERN = /Base directory for this skill:.*\/\.claude\/skills\/([^/\n]+)/
12
+
13
+ /** Derive Claude Code project slug from absolute repo root path. */
14
+ function projectSlug(root: string): string {
15
+ return "-" + root.replace(/\//g, "-").replace(/^-/, "")
16
+ }
17
+
18
+ /** Extract skill name from first JSONL line's message content. */
19
+ function extractSkill(firstLine: string): string {
20
+ try {
21
+ const data = JSON.parse(firstLine)
22
+ const content = data?.message?.content
23
+ const text = typeof content === "string" ? content : Array.isArray(content) ? (content[0]?.text ?? "") : ""
24
+ const match = SKILL_DIR_PATTERN.exec(text)
25
+ return match?.[1] ?? "task"
26
+ } catch {
27
+ return "unknown"
28
+ }
29
+ }
30
+
31
+ export function transcripts(ports: ControlPorts, params: TranscriptsParams): TranscriptsResult {
32
+ const { skill, minutes = DEFAULT_MINUTES } = params
33
+ const slug = projectSlug(ports.root)
34
+ const basePath = join(ports.homedir(), ".claude", "projects", slug)
35
+
36
+ if (!ports.fs.exists(basePath)) {
37
+ return { projectSlug: slug, entries: [] }
38
+ }
39
+
40
+ const cutoff = new Date(Date.now() - minutes * 60 * 1000)
41
+ const entries: TranscriptEntry[] = []
42
+
43
+ const sessions = ports.fs.readDir(basePath)
44
+ for (const session of sessions) {
45
+ if (!session.isDirectory) continue
46
+
47
+ const subagentsPath = join(basePath, session.name, "subagents")
48
+ if (!ports.fs.exists(subagentsPath)) continue
49
+
50
+ const files = ports.fs.readDir(subagentsPath)
51
+ for (const file of files) {
52
+ if (file.isDirectory || !file.name.endsWith(".jsonl")) continue
53
+
54
+ const filePath = join(subagentsPath, file.name)
55
+ const content = ports.fs.readFile(filePath)
56
+ const firstLine = content.slice(0, content.indexOf("\n"))
57
+ if (!firstLine) continue
58
+
59
+ let timestamp: string
60
+ let agentId: string
61
+ try {
62
+ const meta = JSON.parse(firstLine)
63
+ timestamp = meta.timestamp ?? ""
64
+ agentId = meta.agentId ?? file.name.replace("agent-", "").replace(".jsonl", "")
65
+ } catch {
66
+ continue
67
+ }
68
+
69
+ if (timestamp && new Date(timestamp) < cutoff) continue
70
+
71
+ const skillName = extractSkill(firstLine)
72
+ if (skill && skillName !== skill) continue
73
+
74
+ entries.push({
75
+ path: filePath,
76
+ agentId,
77
+ sessionId: session.name,
78
+ skill: skillName,
79
+ timestamp,
80
+ size: content.length,
81
+ })
82
+ }
83
+ }
84
+
85
+ entries.sort((a, b) => (b.timestamp > a.timestamp ? 1 : b.timestamp < a.timestamp ? -1 : 0))
86
+
87
+ return { projectSlug: slug, entries }
88
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Query: typecheck — run tsc across workspace packages matching scope.
3
+ */
4
+
5
+ import type { ControlPorts } from "../ports.ts"
6
+ import type { QueryResult } from "../types.ts"
7
+ import { discoverPackages, filterByScript } from "./workspace.ts"
8
+ import { extractErrors } from "./format.ts"
9
+
10
+ export async function typecheck(ports: ControlPorts, scopes: string[]): Promise<QueryResult> {
11
+ const packages = discoverPackages(ports)
12
+
13
+ const matching =
14
+ scopes.length === 0
15
+ ? packages
16
+ : packages.filter((pkg) => scopes.some((s) => pkg.relDir.startsWith(s) || s.startsWith(pkg.relDir + "/")))
17
+ const withScript = filterByScript(matching, "typecheck")
18
+
19
+ if (withScript.length === 0) {
20
+ return { what: "typecheck", scopes, data: { errorCount: 0, errors: [], raw: "" } }
21
+ }
22
+
23
+ const { FORCE_COLOR: _, ...parentEnv } = process.env as Record<string, string>
24
+ const env: Record<string, string> = { ...parentEnv, NO_COLOR: "1" }
25
+
26
+ const results = await Promise.all(
27
+ withScript.map(
28
+ (pkg) =>
29
+ new Promise<string>((resolve) => {
30
+ const lines: string[] = []
31
+ const handle = ports.process.spawn({ cmd: "bun", args: ["run", "typecheck"], cwd: pkg.dir, env })
32
+ handle.onLine("stdout", (line) => lines.push(line))
33
+ handle.onLine("stderr", (line) => lines.push(line))
34
+ handle.wait().then(() => resolve(lines.join("\n")))
35
+ }),
36
+ ),
37
+ )
38
+
39
+ const raw = results.filter(Boolean).join("\n")
40
+ return { what: "typecheck", scopes, data: extractErrors(raw) }
41
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Workspace discovery — reads package.json workspaces globs and builds Package list.
3
+ */
4
+
5
+ import { basename, join, relative } from "node:path"
6
+
7
+ import type { ControlPorts } from "../ports.ts"
8
+ import type { Package } from "../types.ts"
9
+
10
+ /**
11
+ * Discover all workspace packages from root package.json.
12
+ * Supports `prefix/*` globs (e.g. "apps/*", "lib/*") and bare directories (e.g. "meta").
13
+ */
14
+ export function discoverPackages(ports: ControlPorts): Package[] {
15
+ const rootPkg = JSON.parse(ports.fs.readFile(join(ports.root, "package.json"))) as {
16
+ workspaces?: string[]
17
+ }
18
+
19
+ const globs = rootPkg.workspaces ?? []
20
+ const packages: Package[] = []
21
+
22
+ for (const glob of globs) {
23
+ if (glob.endsWith("/*")) {
24
+ // Glob: enumerate subdirectories
25
+ const prefix = glob.slice(0, -2)
26
+ const prefixDir = join(ports.root, prefix)
27
+
28
+ let entries: Array<{ name: string; isDirectory: boolean }>
29
+ try {
30
+ entries = ports.fs.readDir(prefixDir).filter((d) => d.isDirectory)
31
+ } catch {
32
+ continue
33
+ }
34
+
35
+ for (const entry of entries.map((d) => d.name).sort()) {
36
+ const pkg = readPackage(ports, join(prefixDir, entry))
37
+ if (pkg) packages.push(pkg)
38
+ }
39
+ } else if (!glob.includes("*")) {
40
+ // Bare directory: single package (skip unsupported globs like "packages/**")
41
+ const pkg = readPackage(ports, join(ports.root, glob))
42
+ if (pkg) packages.push(pkg)
43
+ }
44
+ }
45
+
46
+ return packages.sort((a, b) => a.relDir.localeCompare(b.relDir))
47
+ }
48
+
49
+ function readPackage(ports: ControlPorts, dir: string): Package | null {
50
+ try {
51
+ const pkg = JSON.parse(ports.fs.readFile(join(dir, "package.json"))) as {
52
+ name?: string
53
+ scripts?: Record<string, string>
54
+ }
55
+ return {
56
+ name: pkg.name ?? basename(dir),
57
+ shortName: basename(dir),
58
+ dir,
59
+ relDir: relative(ports.root, dir),
60
+ scripts: pkg.scripts ?? {},
61
+ }
62
+ } catch {
63
+ return null
64
+ }
65
+ }
66
+
67
+ export function filterByScope(packages: Package[], scope: string): Package[] {
68
+ return packages.filter((p) => p.relDir.startsWith(scope))
69
+ }
70
+
71
+ export function filterByScript(packages: Package[], script: string): Package[] {
72
+ return packages.filter((p) => script in p.scripts)
73
+ }
74
+
75
+ export function filterByFiles(packages: Package[], files: string[], root: string): Package[] {
76
+ const relFiles = files.map((f) => (f.startsWith("/") ? relative(root, f) : f))
77
+ return packages.filter((pkg) => relFiles.some((f) => f.startsWith(pkg.relDir + "/")))
78
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Domain error — typed error with machine-readable code and human-readable hints.
3
+ */
4
+
5
+ export class ControlError extends Error {
6
+ readonly code: string
7
+ readonly hints: string[]
8
+
9
+ constructor(code: string, message: string, hints: string[] = []) {
10
+ super(message)
11
+ this.name = "ControlError"
12
+ this.code = code
13
+ this.hints = hints
14
+ }
15
+ }
16
+
17
+ export function isControlError(err: unknown): err is ControlError {
18
+ return err instanceof ControlError
19
+ }