@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.
- package/bin/dexter +6 -0
- package/package.json +43 -0
- package/src/claude/index.ts +6 -0
- package/src/cli.ts +39 -0
- package/src/env/define.ts +190 -0
- package/src/env/index.ts +10 -0
- package/src/env/loader.ts +61 -0
- package/src/env/print.ts +98 -0
- package/src/env/validate.ts +46 -0
- package/src/index.ts +16 -0
- package/src/meta/adapters/fs.ts +22 -0
- package/src/meta/adapters/git.ts +29 -0
- package/src/meta/adapters/glob.ts +14 -0
- package/src/meta/adapters/index.ts +24 -0
- package/src/meta/adapters/process.ts +40 -0
- package/src/meta/cli.ts +340 -0
- package/src/meta/domain/bisect.ts +126 -0
- package/src/meta/domain/blame.ts +136 -0
- package/src/meta/domain/commit.ts +135 -0
- package/src/meta/domain/commits.ts +23 -0
- package/src/meta/domain/constraints/registry.ts +49 -0
- package/src/meta/domain/constraints/types.ts +30 -0
- package/src/meta/domain/diff.ts +34 -0
- package/src/meta/domain/eval.ts +57 -0
- package/src/meta/domain/format.ts +34 -0
- package/src/meta/domain/lint.ts +88 -0
- package/src/meta/domain/pickaxe.ts +99 -0
- package/src/meta/domain/quality.ts +145 -0
- package/src/meta/domain/rules.ts +21 -0
- package/src/meta/domain/scope-context.ts +63 -0
- package/src/meta/domain/service.ts +68 -0
- package/src/meta/domain/setup.ts +34 -0
- package/src/meta/domain/test.ts +72 -0
- package/src/meta/domain/transcripts.ts +88 -0
- package/src/meta/domain/typecheck.ts +41 -0
- package/src/meta/domain/workspace.ts +78 -0
- package/src/meta/errors.ts +19 -0
- package/src/meta/hooks/on-post-read.ts +61 -0
- package/src/meta/hooks/on-post-write.ts +65 -0
- package/src/meta/hooks/on-pre-bash.ts +69 -0
- package/src/meta/hooks/stubs.ts +51 -0
- package/src/meta/index.ts +36 -0
- package/src/meta/lib/actor.ts +53 -0
- package/src/meta/lib/eslint.ts +58 -0
- package/src/meta/lib/format.ts +55 -0
- package/src/meta/lib/paths.ts +36 -0
- package/src/meta/lib/present.ts +231 -0
- package/src/meta/lib/spec-links.ts +83 -0
- package/src/meta/lib/stdin.ts +56 -0
- package/src/meta/ports.ts +50 -0
- package/src/meta/types.ts +113 -0
- package/src/output/build.ts +56 -0
- package/src/output/index.ts +24 -0
- package/src/output/output.test.ts +374 -0
- package/src/output/render-cli.ts +55 -0
- package/src/output/render-json.ts +80 -0
- package/src/output/render-md.ts +43 -0
- package/src/output/render-xml.ts +55 -0
- package/src/output/render.ts +23 -0
- package/src/output/types.ts +44 -0
- package/src/pipe/format.ts +167 -0
- package/src/pipe/index.ts +4 -0
- package/src/pipe/parse.ts +131 -0
- package/src/pipe/spawn.ts +205 -0
- package/src/pipe/types.ts +27 -0
- package/src/terminal/colors.ts +95 -0
- package/src/terminal/index.ts +16 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit domain logic — isolated git commit using temporary index.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
import { ControlError } from "../errors.ts"
|
|
8
|
+
import type { ControlPorts } from "../ports.ts"
|
|
9
|
+
import type { CommitParams, CommitResult } from "../types.ts"
|
|
10
|
+
|
|
11
|
+
import { checkQuality } from "./quality.ts"
|
|
12
|
+
|
|
13
|
+
const MAX_MESSAGE_LENGTH = 72
|
|
14
|
+
|
|
15
|
+
function fail(code: string, message: string, hints: string[] = []): never {
|
|
16
|
+
throw new ControlError(code, message, hints)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function commit(ports: ControlPorts, params: CommitParams): Promise<CommitResult> {
|
|
20
|
+
const { message, files } = params
|
|
21
|
+
|
|
22
|
+
if (!ports.git.run(["--version"]).success) {
|
|
23
|
+
fail("git_not_found", "git not installed", ["install git to use this command"])
|
|
24
|
+
}
|
|
25
|
+
if (!ports.git.run(["rev-parse", "--is-inside-work-tree"]).success) {
|
|
26
|
+
fail("not_repo", "not a git repository", ["run from within a git repository, or run 'git init' first"])
|
|
27
|
+
}
|
|
28
|
+
if (!ports.git.run(["rev-parse", "HEAD"]).success) {
|
|
29
|
+
fail("no_commits", "repository has no commits", [
|
|
30
|
+
"create an initial commit first: git commit --allow-empty -m 'initial commit'",
|
|
31
|
+
])
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!message) {
|
|
35
|
+
fail("empty_message", "commit message cannot be empty", ["describe why this change exists"])
|
|
36
|
+
}
|
|
37
|
+
if (message.length > MAX_MESSAGE_LENGTH) {
|
|
38
|
+
fail("message_too_long", `message exceeds ${MAX_MESSAGE_LENGTH} characters (${message.length})`, [
|
|
39
|
+
"keep the commit message concise — one line explaining why",
|
|
40
|
+
])
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (files.length === 0) {
|
|
44
|
+
fail("no_files", "no files to commit", ['./meta/run commit "message" file1 file2 ...'])
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const quality = await checkQuality(ports, files)
|
|
48
|
+
if (!quality.passed) {
|
|
49
|
+
const diagnostics = quality.checks.filter((c) => c.status === "fail").map((c) => `${c.name}: ${c.output}`)
|
|
50
|
+
fail("quality_failed", "quality checks failed", diagnostics)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const tempIndex = join(ports.tmpdir(), `git-index.${process.pid}.${Date.now()}`)
|
|
54
|
+
const indexEnv = { GIT_INDEX_FILE: tempIndex }
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const readTreeResult = ports.git.run(["read-tree", "HEAD"], indexEnv)
|
|
58
|
+
if (!readTreeResult.success) {
|
|
59
|
+
fail("read_tree_failed", "failed to read git tree", [readTreeResult.stderr])
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const headTree = ports.git.run(["ls-tree", "-r", "--name-only", "HEAD"], indexEnv)
|
|
63
|
+
const caseMap = new Map<string, string>()
|
|
64
|
+
if (headTree.success) {
|
|
65
|
+
for (const p of headTree.stdout.split("\n").filter(Boolean)) {
|
|
66
|
+
caseMap.set(p.toLowerCase(), p)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let hasCaseRenames = false
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
const fileExists = ports.fs.exists(file)
|
|
73
|
+
const isTracked = ports.git.run(["ls-tree", "--name-only", "HEAD", "--", file]).stdout !== ""
|
|
74
|
+
|
|
75
|
+
if (!fileExists && !isTracked) {
|
|
76
|
+
const allFiles = ports.git.run(["ls-files"]).stdout
|
|
77
|
+
const basename = file.split("/").pop() ?? file
|
|
78
|
+
const similar = allFiles
|
|
79
|
+
.split("\n")
|
|
80
|
+
.filter((f) => f.toLowerCase().includes(basename.toLowerCase()))
|
|
81
|
+
.slice(0, 3)
|
|
82
|
+
|
|
83
|
+
const hints = ["file may have been moved, renamed, or never created"]
|
|
84
|
+
if (similar.length > 0 && similar[0]) {
|
|
85
|
+
hints.push("similar tracked files:")
|
|
86
|
+
similar.forEach((f) => hints.push(` ${f}`))
|
|
87
|
+
}
|
|
88
|
+
fail("file_not_found", `file not found and not tracked: ${file}`, hints)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (fileExists) {
|
|
92
|
+
if (ports.git.checkIgnore(file)) {
|
|
93
|
+
fail("file_ignored", `file is ignored by .gitignore: ${file}`, ["file matches a pattern in .gitignore"])
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const headPath = caseMap.get(file.toLowerCase())
|
|
98
|
+
if (headPath && headPath !== file) {
|
|
99
|
+
ports.git.run(["rm", "--cached", "--quiet", "--", headPath], indexEnv)
|
|
100
|
+
hasCaseRenames = true
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const addResult = ports.git.run(["add", "--", file], indexEnv)
|
|
104
|
+
if (!addResult.success) {
|
|
105
|
+
fail("stage_failed", `failed to stage file: ${file}`, [addResult.stderr])
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!hasCaseRenames) {
|
|
110
|
+
const diffResult = ports.git.run(["diff", "--cached", "--quiet"], indexEnv)
|
|
111
|
+
if (diffResult.success) {
|
|
112
|
+
fail("no_changes", "no changes to commit in specified files", ["file contents match what is already in HEAD"])
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const commitResult = ports.git.run(["commit", "-m", message, "--quiet"], indexEnv)
|
|
117
|
+
if (!commitResult.success) {
|
|
118
|
+
fail("commit_failed", "commit failed", [commitResult.stderr])
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const hash = ports.git.run(["rev-parse", "--short", "HEAD"], indexEnv).stdout.trim()
|
|
122
|
+
const committedFiles = ports.git
|
|
123
|
+
.run(["diff-tree", "--no-commit-id", "--name-only", "-r", "HEAD"], indexEnv)
|
|
124
|
+
.stdout.split("\n")
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
|
|
127
|
+
ports.git.run(["reset", "--quiet", "HEAD"])
|
|
128
|
+
|
|
129
|
+
return { hash, message, files: committedFiles }
|
|
130
|
+
} finally {
|
|
131
|
+
if (ports.fs.exists(tempIndex)) {
|
|
132
|
+
ports.fs.unlink(tempIndex)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query: commits — recent commits for given scopes + repo-wide style reference.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { CommitsScope, QueryResult } from "../types.ts"
|
|
7
|
+
|
|
8
|
+
export function commits(ports: ControlPorts, scopes: string[]): QueryResult {
|
|
9
|
+
const data: CommitsScope[] = []
|
|
10
|
+
|
|
11
|
+
for (const scope of scopes) {
|
|
12
|
+
const logResult = ports.git.run(["log", "--oneline", "--format=%h %s (%an, %ar)", "-6", "--", scope])
|
|
13
|
+
const lines = logResult.success && logResult.stdout ? logResult.stdout.split("\n").filter(Boolean) : []
|
|
14
|
+
if (lines.length > 0) {
|
|
15
|
+
data.push({ path: scope, log: lines })
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const log = ports.git.run(["log", "--oneline", "-5"])
|
|
20
|
+
const recent = log.success ? log.stdout.split("\n").filter(Boolean) : []
|
|
21
|
+
|
|
22
|
+
return { what: "commits", scopes, data, recent }
|
|
23
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constraint Registry — centralized constraint definitions.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { type ConstraintResult, type PatternConstraint, createPatternConstraint } from "./types.ts"
|
|
6
|
+
|
|
7
|
+
/** Git commands that should use workflow scripts */
|
|
8
|
+
export const GIT_WORKFLOW_CONSTRAINTS: PatternConstraint[] = [
|
|
9
|
+
createPatternConstraint("git-add", /^git\s+(add)(\s|$)/, "Use ./scripts/git/commit"),
|
|
10
|
+
createPatternConstraint("git-commit", /^git\s+(commit)(\s|$)/, "Use ./scripts/git/commit"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
/** Destructive git operations */
|
|
14
|
+
export const GIT_DESTRUCTIVE_CONSTRAINTS: PatternConstraint[] = [
|
|
15
|
+
createPatternConstraint("git-force-push", /^git\s+push\s+.*--force/, "Force push not allowed. Use revert instead."),
|
|
16
|
+
createPatternConstraint("git-hard-reset", /^git\s+reset\s+--hard/, "Hard reset not allowed. Use revert instead."),
|
|
17
|
+
createPatternConstraint(
|
|
18
|
+
"git-clean-force",
|
|
19
|
+
/^git\s+clean\s+-f/,
|
|
20
|
+
"git clean -f not allowed. Remove files explicitly.",
|
|
21
|
+
),
|
|
22
|
+
createPatternConstraint(
|
|
23
|
+
"git-checkout-all",
|
|
24
|
+
/^git\s+checkout\s+\.$/,
|
|
25
|
+
"Discard all changes not allowed. Be explicit about files.",
|
|
26
|
+
),
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
/** All bash command constraints */
|
|
30
|
+
export const BASH_CONSTRAINTS: PatternConstraint[] = [...GIT_WORKFLOW_CONSTRAINTS, ...GIT_DESTRUCTIVE_CONSTRAINTS]
|
|
31
|
+
|
|
32
|
+
/** Check a command against all bash constraints */
|
|
33
|
+
export function checkBashCommand(command: string): ConstraintResult {
|
|
34
|
+
for (const constraint of BASH_CONSTRAINTS) {
|
|
35
|
+
const result = constraint.check(command)
|
|
36
|
+
if (!result.ok) {
|
|
37
|
+
return result
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { ok: true }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Check a command against pattern constraints, returning the first match */
|
|
44
|
+
export function findMatchingConstraint(
|
|
45
|
+
command: string,
|
|
46
|
+
constraints: PatternConstraint[],
|
|
47
|
+
): PatternConstraint | undefined {
|
|
48
|
+
return constraints.find((c) => c.pattern.test(command))
|
|
49
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constraint system types
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export type ConstraintResult = { ok: true } | { ok: false; message: string; hint?: string }
|
|
6
|
+
|
|
7
|
+
export interface Constraint<T = unknown> {
|
|
8
|
+
name: string
|
|
9
|
+
check: (input: T) => ConstraintResult
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface PatternConstraint extends Constraint<string> {
|
|
13
|
+
pattern: RegExp
|
|
14
|
+
hint: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Create a pattern-based constraint */
|
|
18
|
+
export function createPatternConstraint(name: string, pattern: RegExp, hint: string): PatternConstraint {
|
|
19
|
+
return {
|
|
20
|
+
name,
|
|
21
|
+
pattern,
|
|
22
|
+
hint,
|
|
23
|
+
check: (input: string): ConstraintResult => {
|
|
24
|
+
if (pattern.test(input)) {
|
|
25
|
+
return { ok: false, message: `Pattern matched: ${name}`, hint }
|
|
26
|
+
}
|
|
27
|
+
return { ok: true }
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query: diff — git status and diff for given scopes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { DiffScope, QueryResult } from "../types.ts"
|
|
7
|
+
|
|
8
|
+
const DIFF_LINE_LIMIT = 200
|
|
9
|
+
|
|
10
|
+
export function diff(ports: ControlPorts, scopes: string[]): QueryResult {
|
|
11
|
+
const data: DiffScope[] = []
|
|
12
|
+
|
|
13
|
+
for (const scope of scopes) {
|
|
14
|
+
const statusResult = ports.git.run(["status", "--short", "--", scope])
|
|
15
|
+
const status = statusResult.success ? statusResult.stdout.trim() : ""
|
|
16
|
+
|
|
17
|
+
const diffResult = ports.git.run(["diff", "HEAD", "--", scope])
|
|
18
|
+
let diffText = ""
|
|
19
|
+
if (diffResult.success && diffResult.stdout) {
|
|
20
|
+
const lines = diffResult.stdout.split("\n")
|
|
21
|
+
if (lines.length > DIFF_LINE_LIMIT) {
|
|
22
|
+
diffText = lines.slice(0, DIFF_LINE_LIMIT).join("\n") + `\n... (${lines.length - DIFF_LINE_LIMIT} more lines)`
|
|
23
|
+
} else {
|
|
24
|
+
diffText = diffResult.stdout
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (status || diffText.trim()) {
|
|
29
|
+
data.push({ path: scope, status, diff: diffText.trim() })
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { what: "diff", scopes, data }
|
|
34
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval domain logic — execute TypeScript code in a sandboxed subprocess.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join } from "node:path"
|
|
6
|
+
|
|
7
|
+
import { ControlError } from "../errors.ts"
|
|
8
|
+
import type { ControlPorts } from "../ports.ts"
|
|
9
|
+
import type { EvalParams, EvalResult } from "../types.ts"
|
|
10
|
+
|
|
11
|
+
const SCRATCH_DIR = "meta/.scratch"
|
|
12
|
+
const DEFAULT_TIMEOUT = 5000
|
|
13
|
+
|
|
14
|
+
export async function evaluate(ports: ControlPorts, params: EvalParams): Promise<EvalResult> {
|
|
15
|
+
const { code, timeout = DEFAULT_TIMEOUT } = params
|
|
16
|
+
|
|
17
|
+
if (!code.trim()) {
|
|
18
|
+
throw new ControlError("empty_code", "no code to evaluate", ["provide a TypeScript expression or snippet"])
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const scratchPath = join(ports.root, SCRATCH_DIR)
|
|
22
|
+
if (!ports.fs.exists(scratchPath)) {
|
|
23
|
+
ports.fs.mkdir(scratchPath)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const filename = `eval-${process.pid}-${Date.now()}.ts`
|
|
27
|
+
const filepath = join(scratchPath, filename)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
ports.fs.writeFile(filepath, code)
|
|
31
|
+
|
|
32
|
+
const stdout: string[] = []
|
|
33
|
+
const stderr: string[] = []
|
|
34
|
+
|
|
35
|
+
const handle = ports.process.spawn({
|
|
36
|
+
cmd: "bun",
|
|
37
|
+
args: ["run", filepath],
|
|
38
|
+
cwd: ports.root,
|
|
39
|
+
timeout,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
handle.onLine("stdout", (line) => stdout.push(line))
|
|
43
|
+
handle.onLine("stderr", (line) => stderr.push(line))
|
|
44
|
+
|
|
45
|
+
const exitCode = await handle.wait()
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
ok: exitCode === 0,
|
|
49
|
+
stdout: stdout.join("\n"),
|
|
50
|
+
stderr: stderr.join("\n"),
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
if (ports.fs.exists(filepath)) {
|
|
54
|
+
ports.fs.unlink(filepath)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check output parsing — extract structured error data from raw tool output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CheckData, CheckError } from "../types.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Detect lines that represent errors in check output.
|
|
9
|
+
*/
|
|
10
|
+
export function isErrorLine(line: string): boolean {
|
|
11
|
+
if (/[:(]\d+[,):]/.test(line) && /error/i.test(line)) return true
|
|
12
|
+
if (line.includes("\u2717") || line.includes("FAIL")) return true
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract structured error entries from raw check output.
|
|
18
|
+
*/
|
|
19
|
+
export function extractErrors(raw: string): CheckData {
|
|
20
|
+
if (!raw.trim()) {
|
|
21
|
+
return { errorCount: 0, errors: [], raw }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lines = raw.split("\n")
|
|
25
|
+
const errors: CheckError[] = lines
|
|
26
|
+
.map((line, idx) => ({ line: idx + 1, summary: line }))
|
|
27
|
+
.filter(({ summary }) => isErrorLine(summary))
|
|
28
|
+
.map(({ line, summary }) => ({
|
|
29
|
+
line,
|
|
30
|
+
summary: summary.length > 80 ? summary.slice(0, 77) + "..." : summary,
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
return { errorCount: errors.length, errors, raw }
|
|
34
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Query: lint — ESLint results for given scopes.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { join, relative } from "node:path"
|
|
6
|
+
|
|
7
|
+
import type { ControlPorts } from "../ports.ts"
|
|
8
|
+
import type { QueryResult } from "../types.ts"
|
|
9
|
+
import { isLintable } from "./quality.ts"
|
|
10
|
+
import { discoverPackages, filterByFiles } from "./workspace.ts"
|
|
11
|
+
import { extractErrors } from "./format.ts"
|
|
12
|
+
|
|
13
|
+
export async function lint(ports: ControlPorts, scopes: string[], opts?: { changed?: boolean }): Promise<QueryResult> {
|
|
14
|
+
const effectiveScopes = scopes.length === 0 ? ["."] : scopes
|
|
15
|
+
const allFiles = opts?.changed ? getChangedFiles(ports, effectiveScopes) : getTrackedFiles(ports, effectiveScopes)
|
|
16
|
+
|
|
17
|
+
if (allFiles.length === 0) {
|
|
18
|
+
return { what: "lint", scopes, data: { errorCount: 0, errors: [], raw: "" } }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const packages = discoverPackages(ports)
|
|
22
|
+
const affected = filterByFiles(packages, allFiles, ports.root)
|
|
23
|
+
const pkgWithOwnConfig = affected.filter((pkg) => ports.fs.exists(join(pkg.dir, "eslint.config.js")))
|
|
24
|
+
|
|
25
|
+
const pkgFiles = new Set<string>()
|
|
26
|
+
const lintJobs: Promise<string>[] = []
|
|
27
|
+
|
|
28
|
+
for (const pkg of pkgWithOwnConfig) {
|
|
29
|
+
const pkgSpecific = allFiles.filter((f) => f.startsWith(pkg.relDir + "/"))
|
|
30
|
+
for (const f of pkgSpecific) pkgFiles.add(f)
|
|
31
|
+
if (pkgSpecific.length > 0) {
|
|
32
|
+
const relFiles = pkgSpecific.map((f) => relative(pkg.relDir, f))
|
|
33
|
+
lintJobs.push(runEslint(ports, relFiles, pkg.dir))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rootFiles = allFiles.filter((f) => !pkgFiles.has(f))
|
|
38
|
+
if (rootFiles.length > 0) {
|
|
39
|
+
lintJobs.push(runEslint(ports, rootFiles, ports.root))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const outputs = await Promise.all(lintJobs)
|
|
43
|
+
const raw = outputs.filter(Boolean).join("\n")
|
|
44
|
+
|
|
45
|
+
return { what: "lint", scopes, data: extractErrors(raw) }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function getTrackedFiles(ports: ControlPorts, scopes: string[]): string[] {
|
|
49
|
+
const files: string[] = []
|
|
50
|
+
for (const scope of scopes) {
|
|
51
|
+
const lsResult = ports.git.run(["ls-files", scope])
|
|
52
|
+
if (lsResult.success && lsResult.stdout) {
|
|
53
|
+
files.push(
|
|
54
|
+
...lsResult.stdout
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter(Boolean)
|
|
57
|
+
.filter(isLintable)
|
|
58
|
+
.filter((f) => ports.fs.exists(join(ports.root, f))),
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return files
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getChangedFiles(ports: ControlPorts, scopes: string[]): string[] {
|
|
66
|
+
const files: string[] = []
|
|
67
|
+
for (const scope of scopes) {
|
|
68
|
+
const modified = ports.git.run(["diff", "--name-only", "HEAD", "--", scope])
|
|
69
|
+
if (modified.success) files.push(...modified.stdout.split("\n").filter(Boolean))
|
|
70
|
+
const untracked = ports.git.run(["ls-files", "--others", "--exclude-standard", "--", scope])
|
|
71
|
+
if (untracked.success) files.push(...untracked.stdout.split("\n").filter(Boolean))
|
|
72
|
+
}
|
|
73
|
+
return [...new Set(files)].filter(isLintable).filter((f) => ports.fs.exists(join(ports.root, f)))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function runEslint(ports: ControlPorts, files: string[], cwd: string): Promise<string> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const lines: string[] = []
|
|
79
|
+
const handle = ports.process.spawn({
|
|
80
|
+
cmd: "bunx",
|
|
81
|
+
args: ["eslint", "--no-warn-ignored", ...files],
|
|
82
|
+
cwd,
|
|
83
|
+
})
|
|
84
|
+
handle.onLine("stdout", (line) => lines.push(line))
|
|
85
|
+
handle.onLine("stderr", (line) => lines.push(line))
|
|
86
|
+
handle.wait().then(() => resolve(lines.join("\n")))
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Git pickaxe — find commits that added or removed a pattern.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ControlPorts } from "../ports.ts"
|
|
6
|
+
import type { GitResult, PickaxeMatch } from "../types.ts"
|
|
7
|
+
import { ControlError } from "../errors.ts"
|
|
8
|
+
|
|
9
|
+
const MAX_MATCHES = 10
|
|
10
|
+
|
|
11
|
+
/** Parse git log output with custom format + patch into PickaxeMatch[]. */
|
|
12
|
+
function parseLogOutput(raw: string): PickaxeMatch[] {
|
|
13
|
+
if (!raw.trim()) return []
|
|
14
|
+
|
|
15
|
+
const matches: PickaxeMatch[] = []
|
|
16
|
+
const commits = raw.split(/^(?=commit [0-9a-f]{40}$)/m).filter(Boolean)
|
|
17
|
+
|
|
18
|
+
for (const block of commits) {
|
|
19
|
+
const lines = block.split("\n")
|
|
20
|
+
let hash = ""
|
|
21
|
+
let author = ""
|
|
22
|
+
let date = ""
|
|
23
|
+
let message = ""
|
|
24
|
+
const diffLines: string[] = []
|
|
25
|
+
let inDiff = false
|
|
26
|
+
|
|
27
|
+
for (const line of lines) {
|
|
28
|
+
if (line.startsWith("commit ")) {
|
|
29
|
+
hash = line.slice(7, 15)
|
|
30
|
+
} else if (line.startsWith("Author: ")) {
|
|
31
|
+
const authorMatch = line.match(/^Author:\s+(.+?)\s*</)
|
|
32
|
+
author = authorMatch ? authorMatch[1]!.trim() : line.slice(8).trim()
|
|
33
|
+
} else if (line.startsWith("Date: ")) {
|
|
34
|
+
const raw = line.slice(6).trim()
|
|
35
|
+
try {
|
|
36
|
+
date = new Date(raw).toISOString().slice(0, 10)
|
|
37
|
+
} catch {
|
|
38
|
+
date = raw
|
|
39
|
+
}
|
|
40
|
+
} else if (line.startsWith(" ") && !inDiff && !message) {
|
|
41
|
+
message = line.trim()
|
|
42
|
+
} else if (line.startsWith("diff --git") || inDiff) {
|
|
43
|
+
inDiff = true
|
|
44
|
+
diffLines.push(line)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (hash) {
|
|
49
|
+
matches.push({
|
|
50
|
+
hash,
|
|
51
|
+
author,
|
|
52
|
+
date,
|
|
53
|
+
message,
|
|
54
|
+
diff: diffLines.join("\n").trim(),
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return matches
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function pickaxe(
|
|
63
|
+
ports: ControlPorts,
|
|
64
|
+
pattern: string,
|
|
65
|
+
opts?: { regex?: boolean; scopes?: string[] },
|
|
66
|
+
): GitResult {
|
|
67
|
+
if (!pattern) {
|
|
68
|
+
throw new ControlError("empty_pattern", "pickaxe requires a search pattern", [
|
|
69
|
+
"Usage: pickaxe <pattern> [--regex] [scopes...]",
|
|
70
|
+
])
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const args = ["log"]
|
|
74
|
+
|
|
75
|
+
if (opts?.regex) {
|
|
76
|
+
args.push("-G", pattern)
|
|
77
|
+
} else {
|
|
78
|
+
args.push("-S", pattern)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
args.push(`-${MAX_MATCHES}`, "--patch", "--diff-filter=AMD")
|
|
82
|
+
|
|
83
|
+
if (opts?.scopes && opts.scopes.length > 0) {
|
|
84
|
+
args.push("--", ...opts.scopes)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const result = ports.git.run(args)
|
|
88
|
+
if (!result.success) {
|
|
89
|
+
throw new ControlError(
|
|
90
|
+
"pickaxe_failed",
|
|
91
|
+
`git log pickaxe failed: ${result.stderr.trim()}`,
|
|
92
|
+
[opts?.regex ? "Check that the regex pattern is valid" : "", "Ensure the repository has commits"].filter(Boolean),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const matches = parseLogOutput(result.stdout)
|
|
97
|
+
|
|
98
|
+
return { what: "pickaxe", pattern, matches }
|
|
99
|
+
}
|