@gavin-hu/my-pi-agent 0.14.0 → 0.15.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/README.md +55 -33
- package/README.zh-CN.md +44 -27
- package/extensions/bytefly/README.md +193 -140
- package/extensions/bytefly/block/component.ts +7 -15
- package/extensions/bytefly/block/register.ts +6 -1
- package/extensions/bytefly/doctor/register.ts +24 -5
- package/extensions/bytefly/doctor/tools.ts +4 -0
- package/extensions/bytefly/footer/collect/snapshot.ts +6 -1
- package/extensions/bytefly/footer/config.ts +10 -6
- package/extensions/bytefly/footer/price/price.ts +5 -3
- package/extensions/bytefly/footer/price/rates.ts +63 -26
- package/extensions/bytefly/footer/render/status.ts +3 -3
- package/extensions/bytefly/header/component.ts +3 -2
- package/extensions/bytefly/index.ts +3 -3
- package/extensions/bytefly/notice/component.ts +8 -3
- package/extensions/bytefly/notify/config.ts +6 -11
- package/extensions/bytefly/notify/dispatch.ts +13 -5
- package/extensions/bytefly/panel/commands.ts +21 -1
- package/extensions/bytefly/panel/format.ts +5 -2
- package/extensions/bytefly/panel/settings-runtime.ts +26 -6
- package/extensions/bytefly/panel/settings.ts +41 -4
- package/extensions/bytefly/panel/themes.ts +32 -0
- package/extensions/bytefly/panel/tui/editing.ts +77 -0
- package/extensions/bytefly/panel/tui/entries.ts +24 -0
- package/extensions/bytefly/panel/tui/extensions-view.ts +13 -8
- package/extensions/bytefly/panel/tui/frame.ts +121 -4
- package/extensions/bytefly/panel/tui/settings-view.ts +54 -15
- package/extensions/bytefly/panel/tui.ts +94 -152
- package/extensions/bytefly/picker/discovery.ts +1 -0
- package/extensions/bytefly/picker/types.ts +2 -0
- package/extensions/bytefly/separator/component.ts +14 -4
- package/extensions/bytefly/update/cache.ts +58 -19
- package/extensions/bytefly/update/check.ts +35 -0
- package/extensions/bytefly/update/format.ts +76 -35
- package/extensions/bytefly/update/register.ts +144 -55
- package/extensions/bytefly/update/state.ts +4 -2
- package/extensions/bytefly/update/suppress.ts +66 -0
- package/extensions/bytefly/update/types.ts +15 -0
- package/extensions/check/README.md +253 -0
- package/extensions/check/commands.ts +128 -0
- package/extensions/check/config.ts +39 -0
- package/extensions/check/detect.ts +269 -0
- package/extensions/check/failures.ts +230 -0
- package/extensions/check/format.ts +135 -0
- package/extensions/check/index.ts +26 -0
- package/extensions/check/render.ts +185 -0
- package/extensions/check/runner.ts +176 -0
- package/extensions/check/runtime.ts +244 -0
- package/extensions/check/source.ts +80 -0
- package/extensions/check/tools.ts +173 -0
- package/extensions/check/tui.ts +195 -0
- package/extensions/check/types.ts +99 -0
- package/extensions/document/README.md +1 -1
- package/extensions/document/extract/pdf.ts +8 -16
- package/extensions/document/formats.ts +2 -2
- package/extensions/file-browse/README.md +66 -32
- package/extensions/file-browse/client.ts +95 -32
- package/extensions/file-browse/config.ts +9 -9
- package/extensions/file-browse/git.ts +37 -47
- package/extensions/file-browse/html.ts +39 -58
- package/extensions/file-browse/markdown.ts +110 -92
- package/extensions/file-browse/raw-html.ts +478 -0
- package/extensions/file-browse/router.ts +32 -70
- package/extensions/file-browse/server.ts +18 -56
- package/extensions/file-browse/urls.ts +80 -0
- package/extensions/goal/README.md +103 -47
- package/extensions/goal/clarify-parse.ts +4 -9
- package/extensions/goal/clarify.ts +27 -4
- package/extensions/goal/commands.ts +24 -11
- package/extensions/goal/format.ts +41 -10
- package/extensions/goal/index.ts +39 -16
- package/extensions/goal/plan-route.ts +51 -0
- package/extensions/goal/schema.ts +6 -23
- package/extensions/goal/state.ts +39 -57
- package/extensions/goal/tools.ts +26 -20
- package/extensions/goal/types.ts +11 -1
- package/extensions/job/README.md +20 -6
- package/extensions/job/format.ts +18 -68
- package/extensions/job/index.ts +26 -41
- package/extensions/job/log-writer.ts +208 -0
- package/extensions/job/logs.ts +3 -2
- package/extensions/job/process.ts +6 -99
- package/extensions/job/runtime.ts +42 -181
- package/extensions/job/schema.ts +1 -4
- package/extensions/job/tools.ts +1 -15
- package/extensions/job/tui.ts +117 -307
- package/extensions/job/ui.ts +7 -33
- package/extensions/memory/README.md +1 -1
- package/extensions/memory/errors.ts +5 -6
- package/extensions/memory/tui.ts +14 -24
- package/extensions/plan/README.md +94 -18
- package/extensions/plan/commands.ts +9 -5
- package/extensions/plan/format.ts +87 -0
- package/extensions/plan/index.ts +31 -15
- package/extensions/plan/plans.ts +2 -3
- package/extensions/plan/policy.ts +0 -6
- package/extensions/plan/runtime.ts +4 -2
- package/extensions/plan/steps.ts +37 -8
- package/extensions/plan/todos.ts +44 -3
- package/extensions/plan/tools/enter.ts +94 -0
- package/extensions/plan/tools/exit.ts +296 -0
- package/extensions/plan/tools/nested.ts +21 -0
- package/extensions/plan/tools/steps.ts +136 -0
- package/extensions/plan/tools/todo-seed.ts +76 -0
- package/extensions/plan/tools/write.ts +127 -0
- package/extensions/plan/tools.ts +18 -703
- package/extensions/plan/web-client.ts +6 -22
- package/extensions/plan/web-page.ts +19 -30
- package/extensions/plan/web-router.ts +34 -71
- package/extensions/plan/web.ts +13 -40
- package/extensions/question/answers.ts +3 -8
- package/extensions/question/tui.ts +6 -6
- package/extensions/rewind/README.md +95 -22
- package/extensions/rewind/checklist.ts +185 -0
- package/extensions/rewind/commands.ts +8 -0
- package/extensions/rewind/config.ts +3 -11
- package/extensions/rewind/format.ts +220 -2
- package/extensions/rewind/git.ts +74 -7
- package/extensions/rewind/hunk-picker.ts +114 -0
- package/extensions/rewind/patch.ts +88 -0
- package/extensions/rewind/path-picker.ts +67 -100
- package/extensions/rewind/paths.ts +93 -19
- package/extensions/rewind/restore.ts +53 -10
- package/extensions/rewind/review-tui.ts +320 -0
- package/extensions/rewind/review.ts +397 -0
- package/extensions/rewind/rewind-tui.ts +4 -3
- package/extensions/rewind/runtime.ts +94 -17
- package/extensions/subagent/README.md +61 -6
- package/extensions/subagent/background-tui.ts +208 -0
- package/extensions/subagent/background.ts +470 -0
- package/extensions/subagent/commands.ts +80 -0
- package/extensions/subagent/config.ts +4 -0
- package/extensions/subagent/format.ts +24 -1
- package/extensions/subagent/index.ts +91 -2
- package/extensions/subagent/render.ts +13 -0
- package/extensions/subagent/run.ts +17 -16
- package/extensions/subagent/schema.ts +12 -0
- package/extensions/subagent/types.ts +11 -0
- package/extensions/subagent/ui.ts +50 -0
- package/extensions/todo/README.md +18 -13
- package/extensions/todo/format.ts +3 -2
- package/extensions/todo/runtime.ts +3 -2
- package/extensions/todo/schema.ts +8 -84
- package/extensions/todo/state.ts +5 -46
- package/extensions/todo/tools.ts +2 -1
- package/extensions/todo/types.ts +6 -15
- package/extensions/watch/README.md +14 -6
- package/extensions/watch/child.ts +150 -0
- package/extensions/watch/format.ts +8 -48
- package/extensions/watch/index.ts +42 -58
- package/extensions/watch/runtime.ts +49 -128
- package/extensions/watch/schema.ts +1 -4
- package/extensions/watch/tools.ts +1 -11
- package/extensions/watch/tui.ts +103 -273
- package/extensions/watch/ui.ts +5 -32
- package/extensions/web-access/README.md +1 -1
- package/extensions/web-access/fetch/page.ts +1 -1
- package/extensions/web-access/fetch/tool.ts +6 -2
- package/extensions/web-access/search/search.ts +2 -5
- package/extensions/wechat/README.md +1 -1
- package/extensions/wechat/client.ts +23 -0
- package/extensions/wechat/commands.ts +57 -42
- package/extensions/wechat/deliver.ts +5 -9
- package/extensions/wechat/errors.ts +3 -6
- package/extensions/wechat/inbound.ts +2 -5
- package/extensions/wechat/index.ts +2 -8
- package/extensions/wechat/outbound.ts +2 -4
- package/extensions/wechat/peer-worker.ts +3 -8
- package/extensions/wechat/poll.ts +10 -9
- package/extensions/wechat/presence.ts +35 -62
- package/extensions/wechat/reply-pipeline.ts +3 -1
- package/extensions/wechat/status-screen.ts +1 -1
- package/extensions/wechat/timer.ts +57 -0
- package/extensions/worktree/config.ts +8 -20
- package/extensions/worktree/enter.ts +7 -12
- package/extensions/worktree/git.ts +38 -69
- package/extensions/worktree/index.ts +5 -4
- package/extensions/worktree/registry.ts +7 -7
- package/extensions/worktree/root-tools.ts +2 -2
- package/extensions/worktree/status.ts +3 -2
- package/lib/README.md +22 -8
- package/lib/config.ts +50 -3
- package/lib/errors.ts +12 -0
- package/lib/format.ts +50 -0
- package/lib/git/read.ts +8 -0
- package/lib/goal-state.ts +23 -8
- package/lib/http/headers.ts +54 -0
- package/lib/http/index.ts +13 -0
- package/lib/http/response.ts +46 -0
- package/lib/http/router.ts +52 -0
- package/lib/http/server.ts +91 -0
- package/lib/lines.ts +77 -0
- package/lib/pdf.ts +82 -0
- package/lib/plan-entry.ts +71 -0
- package/lib/plan-state.ts +34 -0
- package/lib/report.ts +99 -0
- package/lib/requirements.ts +25 -19
- package/lib/spawn.ts +170 -0
- package/lib/todo-state.ts +110 -23
- package/lib/tool-names.ts +7 -0
- package/lib/tool-result.ts +33 -0
- package/lib/tui/README.md +68 -3
- package/lib/tui/chrome.ts +28 -4
- package/lib/tui/columns.ts +6 -1
- package/lib/tui/confirm.ts +65 -0
- package/lib/tui/dock-screen.ts +410 -0
- package/lib/tui/glyphs.ts +2 -1
- package/lib/tui/indent.ts +1 -1
- package/lib/tui/index.ts +2 -0
- package/lib/tui/rows.ts +21 -0
- package/lib/tui/status-controller.ts +35 -1
- package/lib/tui/tool.ts +59 -1
- package/lib/web/components.ts +20 -1
- package/lib/web/markup.ts +32 -0
- package/lib/web/runtime.ts +50 -0
- package/package.json +22 -5
- package/skills/init/SKILL.md +165 -0
- package/skills/init/references/agents-md-quality.md +103 -0
- package/skills/init/references/interview-questions.md +81 -0
- package/skills/refactor/SKILL.md +141 -0
- package/extensions/watch/process.ts +0 -63
- package/extensions/web-access/fetch/pdf.ts +0 -45
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `run_check` transcript block.
|
|
3
|
+
*
|
|
4
|
+
* The `/check` screens live in `tui.ts` and the model report in `format.ts`;
|
|
5
|
+
* this is the one result a session keeps inline. It is a capped view of the same
|
|
6
|
+
* `details`: a status line, the first few failures, and the bounded tail when
|
|
7
|
+
* nothing parsed. The transcript never prints the model report, and `/check`
|
|
8
|
+
* remains the full navigable list.
|
|
9
|
+
*
|
|
10
|
+
* Every field comes from command output, a manifest script name, or project
|
|
11
|
+
* config, so each is sanitized before the theme is applied.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
16
|
+
import { sanitizeLine } from "../../lib/format.ts";
|
|
17
|
+
import { clip } from "../../lib/tui/columns.ts";
|
|
18
|
+
import { expandHint, GLYPHS, SEPARATORS } from "../../lib/tui/glyphs.ts";
|
|
19
|
+
import { listRow } from "../../lib/tui/rows.ts";
|
|
20
|
+
import { paint, type TuiRole } from "../../lib/tui/tokens.ts";
|
|
21
|
+
import { earlierHint } from "../../lib/tui/tool.ts";
|
|
22
|
+
import { MODEL_FAILURES, formatFailureLine, formatResultSummary, hasFailureLocation } from "./format.ts";
|
|
23
|
+
import type { Failure, RunCheckDetails } from "./types.ts";
|
|
24
|
+
|
|
25
|
+
/** Failure rows a collapsed transcript result shows. */
|
|
26
|
+
export const COLLAPSED_ROWS = 5;
|
|
27
|
+
/** Tail lines a collapsed unparsed result shows. */
|
|
28
|
+
export const COLLAPSED_TAIL_ROWS = 5;
|
|
29
|
+
|
|
30
|
+
/** The design role a check status is drawn in. */
|
|
31
|
+
function statusRole(status: RunCheckDetails["status"]): TuiRole {
|
|
32
|
+
switch (status) {
|
|
33
|
+
case "passed":
|
|
34
|
+
return "ok";
|
|
35
|
+
case "failed":
|
|
36
|
+
case "error":
|
|
37
|
+
return "bad";
|
|
38
|
+
case "timeout":
|
|
39
|
+
return "warn";
|
|
40
|
+
case "cancelled":
|
|
41
|
+
return "meta";
|
|
42
|
+
case "running":
|
|
43
|
+
return "info";
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Width-1 glyph for a check status, from the shared vocabulary. */
|
|
48
|
+
function statusGlyph(status: RunCheckDetails["status"]): string {
|
|
49
|
+
switch (status) {
|
|
50
|
+
case "passed":
|
|
51
|
+
return GLYPHS.check;
|
|
52
|
+
case "failed":
|
|
53
|
+
return GLYPHS.jobsFailure;
|
|
54
|
+
case "timeout":
|
|
55
|
+
return GLYPHS.warn;
|
|
56
|
+
case "cancelled":
|
|
57
|
+
return GLYPHS.stopped;
|
|
58
|
+
case "error":
|
|
59
|
+
return GLYPHS.cross;
|
|
60
|
+
case "running":
|
|
61
|
+
return GLYPHS.jobsRunning;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The summary line: the screen's wording plus the two facts it leaves to a
|
|
67
|
+
* detail row, the exit code of an unparsed failure and a dropped log prefix.
|
|
68
|
+
*/
|
|
69
|
+
function formatStatusSummary(details: RunCheckDetails): string {
|
|
70
|
+
const text = formatResultSummary(details);
|
|
71
|
+
const extras: string[] = [];
|
|
72
|
+
const parsed = details.failures.some(hasFailureLocation);
|
|
73
|
+
if (!parsed && (details.status === "failed" || details.status === "timeout") && details.exitCode !== null) {
|
|
74
|
+
extras.push(`exit ${details.exitCode}`);
|
|
75
|
+
}
|
|
76
|
+
if (details.truncated) extras.push("log truncated");
|
|
77
|
+
return extras.length === 0 ? text : `${text}${SEPARATORS.item}${extras.join(SEPARATORS.item)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** One parsed failure: its location and reason, plus its command when several ran. */
|
|
81
|
+
function failureRow(failure: Failure, theme: Theme, width: number, showCommand: boolean): string {
|
|
82
|
+
const command = showCommand ? `${SEPARATORS.item}${sanitizeLine(failure.command)}` : "";
|
|
83
|
+
return listRow(theme, width, {
|
|
84
|
+
marker: false,
|
|
85
|
+
glyph: GLYPHS.jobsFailure,
|
|
86
|
+
glyphRole: "bad",
|
|
87
|
+
label: `${formatFailureLine(failure)}${command}`,
|
|
88
|
+
labelRole: "label",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A command that failed without a parseable location, named by its command. */
|
|
93
|
+
function unparsedRow(failure: Failure, theme: Theme, width: number): string {
|
|
94
|
+
return listRow(theme, width, {
|
|
95
|
+
marker: false,
|
|
96
|
+
glyph: GLYPHS.jobsFailure,
|
|
97
|
+
glyphRole: "bad",
|
|
98
|
+
label: `${sanitizeLine(failure.command)}${SEPARATORS.item}${sanitizeLine(failure.message)}`,
|
|
99
|
+
labelRole: "meta",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `… N more failures`, pointing at the expand key or at `/check`. */
|
|
104
|
+
function moreFailures(theme: Theme, width: number, hidden: number, expandable: boolean): string {
|
|
105
|
+
const note = paint(theme, "faint", `… ${hidden} more failure${hidden === 1 ? "" : "s"}`);
|
|
106
|
+
const next = expandable ? expandHint(theme) : paint(theme, "faint", "/check lists all");
|
|
107
|
+
return clip(`${note} ${next}`, width);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** The bounded tail, newest lines last, with the number dropped by the collapse. */
|
|
111
|
+
function tailLines(tail: string, theme: Theme, width: number, expanded: boolean): string[] {
|
|
112
|
+
const all = tail.split("\n").map((line) => sanitizeLine(line).replaceAll("\t", " "));
|
|
113
|
+
while (all.length > 0 && all[all.length - 1] === "") all.pop();
|
|
114
|
+
const shown = expanded ? all : all.slice(Math.max(0, all.length - COLLAPSED_TAIL_ROWS));
|
|
115
|
+
const lines: string[] = [];
|
|
116
|
+
if (shown.length < all.length) {
|
|
117
|
+
lines.push(clip(earlierHint(theme, all.length - shown.length, "lines"), width));
|
|
118
|
+
}
|
|
119
|
+
for (const line of shown) lines.push(clip(paint(theme, "label", line), width));
|
|
120
|
+
return lines;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** What a result block draws: the run's details and whether the view is expanded. */
|
|
124
|
+
export interface CheckResultInput {
|
|
125
|
+
details: RunCheckDetails;
|
|
126
|
+
expanded: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The transcript result: one status line, then parsed failures up to the cap,
|
|
131
|
+
* then the bounded tail when nothing parsed. Reused across renders so the
|
|
132
|
+
* transcript updates it in place.
|
|
133
|
+
*/
|
|
134
|
+
export class CheckResultBlock implements Component {
|
|
135
|
+
private input: CheckResultInput;
|
|
136
|
+
private theme: Theme;
|
|
137
|
+
|
|
138
|
+
constructor(input: CheckResultInput, theme: Theme) {
|
|
139
|
+
this.input = input;
|
|
140
|
+
this.theme = theme;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Update in place so the transcript can reuse this component across renders. */
|
|
144
|
+
setInput(input: CheckResultInput, theme: Theme): void {
|
|
145
|
+
this.input = input;
|
|
146
|
+
this.theme = theme;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
invalidate(): void {}
|
|
150
|
+
|
|
151
|
+
render(width: number): string[] {
|
|
152
|
+
const w = Math.max(1, width);
|
|
153
|
+
const theme = this.theme;
|
|
154
|
+
const { details, expanded } = this.input;
|
|
155
|
+
const role = statusRole(details.status);
|
|
156
|
+
const lines = [
|
|
157
|
+
clip(`${paint(theme, role, statusGlyph(details.status))} ${paint(theme, role, formatStatusSummary(details))}`, w),
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
const parsed = details.failures.filter(hasFailureLocation);
|
|
161
|
+
const unparsed = details.failures.filter((failure) => !hasFailureLocation(failure));
|
|
162
|
+
const limit = expanded ? MODEL_FAILURES : COLLAPSED_ROWS;
|
|
163
|
+
const shown = parsed.slice(0, limit);
|
|
164
|
+
for (const failure of shown) {
|
|
165
|
+
lines.push(failureRow(failure, theme, w, details.commands.length > 1));
|
|
166
|
+
}
|
|
167
|
+
if (parsed.length > shown.length) {
|
|
168
|
+
lines.push(moreFailures(theme, w, parsed.length - shown.length, !expanded));
|
|
169
|
+
}
|
|
170
|
+
// A command that failed without a location is named after the parsed rows;
|
|
171
|
+
// when no row parsed at all, the tail is the honest evidence instead.
|
|
172
|
+
if (parsed.length > 0) {
|
|
173
|
+
for (const failure of unparsed) lines.push(unparsedRow(failure, theme, w));
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const tail = details.tail ?? "";
|
|
178
|
+
if (details.status === "passed" || details.status === "error" || tail.trim() === "") return lines;
|
|
179
|
+
lines.push(
|
|
180
|
+
clip(paint(theme, "meta", details.status === "failed" ? "no parsed failure; last output:" : "last output:"), w),
|
|
181
|
+
);
|
|
182
|
+
lines.push(...tailLines(tail, theme, w, expanded));
|
|
183
|
+
return lines;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-shot process runner for the check extension.
|
|
3
|
+
*
|
|
4
|
+
* A check is foreground and bounded: it streams stdout/stderr, keeps only a
|
|
5
|
+
* tail, and dies with the command. This is deliberately smaller than `job`:
|
|
6
|
+
* there is no registry, reattachment, stdin pipe, or detached lifetime. The
|
|
7
|
+
* spawn and tree-kill seams are the shared ones in `lib/spawn.ts`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { defaultKillTree, defaultSpawn, type KillTreeFn, type SpawnedProcess, type SpawnFn } from "../../lib/spawn.ts";
|
|
11
|
+
|
|
12
|
+
export interface RunOptions {
|
|
13
|
+
cwd: string;
|
|
14
|
+
command: string;
|
|
15
|
+
/** Wall-clock limit; 0 disables it. */
|
|
16
|
+
timeoutMs: number;
|
|
17
|
+
/** Bytes kept from the end of stdout+stderr. */
|
|
18
|
+
maxLogBytes: number;
|
|
19
|
+
signal?: AbortSignal;
|
|
20
|
+
/** Called with raw chunks as they arrive, for the live screen. */
|
|
21
|
+
onOutput?: (chunk: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface RunOutcome {
|
|
25
|
+
exitCode: number | null;
|
|
26
|
+
signal: string | null;
|
|
27
|
+
timedOut: boolean;
|
|
28
|
+
cancelled: boolean;
|
|
29
|
+
/** The retained tail, not necessarily the whole output. */
|
|
30
|
+
output: string;
|
|
31
|
+
truncated: boolean;
|
|
32
|
+
durationMs: number;
|
|
33
|
+
/** A spawn failure, if the command could not start. */
|
|
34
|
+
error?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface RunnerOptions {
|
|
38
|
+
spawn?: SpawnFn;
|
|
39
|
+
killTree?: KillTreeFn;
|
|
40
|
+
now?: () => number;
|
|
41
|
+
/** Time between SIGTERM and SIGKILL; tests shorten it. */
|
|
42
|
+
killGraceMs?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A string buffer that keeps the last `maxBytes` bytes.
|
|
47
|
+
*
|
|
48
|
+
* Output is already a string; slicing by characters is close enough at a
|
|
49
|
+
* megabyte scale, and keeps a multi-byte character whole. `truncated` is set
|
|
50
|
+
* once anything is dropped.
|
|
51
|
+
*/
|
|
52
|
+
export class BoundedLog {
|
|
53
|
+
private buffer = "";
|
|
54
|
+
private truncated = false;
|
|
55
|
+
|
|
56
|
+
constructor(private readonly maxBytes: number) {}
|
|
57
|
+
|
|
58
|
+
append(chunk: string): void {
|
|
59
|
+
if (!chunk) return;
|
|
60
|
+
this.buffer += chunk;
|
|
61
|
+
if (Buffer.byteLength(this.buffer) <= this.maxBytes) return;
|
|
62
|
+
this.truncated = true;
|
|
63
|
+
this.buffer = this.buffer.slice(-this.maxBytes);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
text(): string {
|
|
67
|
+
return this.buffer;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get wasTruncated(): boolean {
|
|
71
|
+
return this.truncated;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The checker's env: no color, no pager, no interactive prompts, CI mode. */
|
|
76
|
+
export function checkEnv(base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
|
77
|
+
return {
|
|
78
|
+
...base,
|
|
79
|
+
CI: "1",
|
|
80
|
+
NO_COLOR: "1",
|
|
81
|
+
FORCE_COLOR: "0",
|
|
82
|
+
TERM: "dumb",
|
|
83
|
+
GIT_PAGER: "cat",
|
|
84
|
+
PAGER: "cat",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build a runner over injected process seams. */
|
|
89
|
+
export function createRunner(options: RunnerOptions = {}): (options: RunOptions) => Promise<RunOutcome> {
|
|
90
|
+
const spawn = options.spawn ?? defaultSpawn;
|
|
91
|
+
const killTree = options.killTree ?? defaultKillTree;
|
|
92
|
+
const now = options.now ?? (() => Date.now());
|
|
93
|
+
const killGraceMs = options.killGraceMs ?? 2_000;
|
|
94
|
+
|
|
95
|
+
return (run) =>
|
|
96
|
+
new Promise<RunOutcome>((resolve) => {
|
|
97
|
+
const started = now();
|
|
98
|
+
const log = new BoundedLog(run.maxLogBytes);
|
|
99
|
+
let settled = false;
|
|
100
|
+
let timedOut = false;
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
let spawnError: string | undefined;
|
|
103
|
+
let killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
104
|
+
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
|
|
105
|
+
let child: SpawnedProcess | undefined;
|
|
106
|
+
|
|
107
|
+
const settle = (exitCode: number | null, signal: string | null): void => {
|
|
108
|
+
if (settled) return;
|
|
109
|
+
settled = true;
|
|
110
|
+
if (killTimer) clearTimeout(killTimer);
|
|
111
|
+
if (timeoutTimer) clearTimeout(timeoutTimer);
|
|
112
|
+
run.signal?.removeEventListener("abort", onAbort);
|
|
113
|
+
resolve({
|
|
114
|
+
exitCode,
|
|
115
|
+
signal,
|
|
116
|
+
timedOut,
|
|
117
|
+
cancelled,
|
|
118
|
+
output: log.text(),
|
|
119
|
+
truncated: log.wasTruncated,
|
|
120
|
+
durationMs: Math.max(0, now() - started),
|
|
121
|
+
...(spawnError ? { error: spawnError } : {}),
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const stop = (reason: "timeout" | "abort"): void => {
|
|
126
|
+
if (settled) return;
|
|
127
|
+
if (reason === "timeout") timedOut = true;
|
|
128
|
+
else cancelled = true;
|
|
129
|
+
const pid = child?.pid;
|
|
130
|
+
if (!pid) {
|
|
131
|
+
child?.kill("SIGTERM");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
killTree(pid, "SIGTERM");
|
|
135
|
+
killTimer = setTimeout(() => {
|
|
136
|
+
if (!settled) killTree(pid, "SIGKILL", true);
|
|
137
|
+
}, killGraceMs);
|
|
138
|
+
killTimer.unref?.();
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const onAbort = (): void => stop("abort");
|
|
142
|
+
if (run.signal?.aborted) {
|
|
143
|
+
cancelled = true;
|
|
144
|
+
settle(null, null);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
run.signal?.addEventListener("abort", onAbort, { once: true });
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
child = spawn(run.command, { cwd: run.cwd, env: checkEnv() });
|
|
151
|
+
} catch (error) {
|
|
152
|
+
spawnError = error instanceof Error ? error.message : String(error);
|
|
153
|
+
settle(null, null);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const onData = (chunk: Buffer | string): void => {
|
|
158
|
+
const text = String(chunk);
|
|
159
|
+
log.append(text);
|
|
160
|
+
if (run.onOutput) run.onOutput(text);
|
|
161
|
+
};
|
|
162
|
+
child.stdout?.on("data", onData);
|
|
163
|
+
child.stderr?.on("data", onData);
|
|
164
|
+
child.on("error", (error) => {
|
|
165
|
+
spawnError = error.message;
|
|
166
|
+
// Node follows an error with close; still settle if a fake does not.
|
|
167
|
+
settle(null, null);
|
|
168
|
+
});
|
|
169
|
+
child.on("close", (code, signal) => settle(code, signal));
|
|
170
|
+
|
|
171
|
+
if (run.timeoutMs > 0) {
|
|
172
|
+
timeoutTimer = setTimeout(() => stop("timeout"), run.timeoutMs);
|
|
173
|
+
timeoutTimer.unref?.();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The check runtime: planning, sequencing, the live line buffer, and the one
|
|
3
|
+
* active run.
|
|
4
|
+
*
|
|
5
|
+
* Detection is synchronous; a run is a sequence of bounded one-shot processes.
|
|
6
|
+
* The runtime knows nothing about a language, a package manager, or a test
|
|
7
|
+
* framework: it receives `CommandSpec`s and hands their output to the parser.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { resolveEffectiveCwd } from "../../lib/env.ts";
|
|
12
|
+
import { stripControlChars } from "../../lib/format.ts";
|
|
13
|
+
import { LineSplitter } from "../../lib/lines.ts";
|
|
14
|
+
import { loadConfig, type CheckConfig } from "./config.ts";
|
|
15
|
+
import { planCommands } from "./detect.ts";
|
|
16
|
+
import { failureKey, parseFailures } from "./failures.ts";
|
|
17
|
+
import { createRunner, type RunOutcome } from "./runner.ts";
|
|
18
|
+
import type { ActiveCheck, CheckResult, CheckStatus, CheckTarget, CommandSpec, Failure } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** Lines kept for the live screen and the tail. */
|
|
21
|
+
const MAX_LINES = 5_000;
|
|
22
|
+
/** Lines in the tail shown in a detail view / model fallback. */
|
|
23
|
+
const TAIL_LINES = 40;
|
|
24
|
+
/** Characters kept from one output line before the TUI clips it. */
|
|
25
|
+
const MAX_LINE_CHARS = 2_000;
|
|
26
|
+
/** Failures retained across commands. */
|
|
27
|
+
const MAX_FAILURES = 200;
|
|
28
|
+
|
|
29
|
+
/** A planning or lifecycle error a command/tool can show verbatim. */
|
|
30
|
+
export class CheckError extends Error {}
|
|
31
|
+
|
|
32
|
+
export interface CheckRuntime {
|
|
33
|
+
/** Start a run and return its observable handle. Throws `CheckError`. */
|
|
34
|
+
start(target: CheckTarget, ctx: ExtensionContext, onUpdate?: () => void): ActiveCheck;
|
|
35
|
+
/** Run to completion; planning errors become an error result, not a throw. */
|
|
36
|
+
run(target: CheckTarget, ctx: ExtensionContext): Promise<CheckResult>;
|
|
37
|
+
isRunning(): boolean;
|
|
38
|
+
/** Cancel and release the active run (idempotent). */
|
|
39
|
+
shutdown(): void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CheckRuntimeOptions {
|
|
43
|
+
/** Fixed config, for tests; production loads it per run. */
|
|
44
|
+
config?: CheckConfig;
|
|
45
|
+
/** Runner seam, for tests. */
|
|
46
|
+
runner?: (options: {
|
|
47
|
+
cwd: string;
|
|
48
|
+
command: string;
|
|
49
|
+
timeoutMs: number;
|
|
50
|
+
maxLogBytes: number;
|
|
51
|
+
signal?: AbortSignal;
|
|
52
|
+
onOutput?: (chunk: string) => void;
|
|
53
|
+
}) => Promise<RunOutcome>;
|
|
54
|
+
now?: () => number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One sanitized line, bounded so a minified error cannot bloat the buffer. */
|
|
58
|
+
function cleanLine(line: string): string {
|
|
59
|
+
return stripControlChars(line.replace(/\r$/, "")).slice(0, MAX_LINE_CHARS);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** An error result for a plan or lifecycle failure. */
|
|
63
|
+
export function checkErrorResult(target: CheckTarget, message: string): CheckResult {
|
|
64
|
+
return {
|
|
65
|
+
target,
|
|
66
|
+
status: "error",
|
|
67
|
+
commands: [],
|
|
68
|
+
exitCode: null,
|
|
69
|
+
durationMs: 0,
|
|
70
|
+
truncated: false,
|
|
71
|
+
failures: [],
|
|
72
|
+
tail: "",
|
|
73
|
+
error: message,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Dedupe failures across commands, preserving command provenance. */
|
|
78
|
+
function mergeFailures(failures: readonly Failure[]): Failure[] {
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
const merged: Failure[] = [];
|
|
81
|
+
for (const failure of failures) {
|
|
82
|
+
const key = failureKey(failure, { command: true });
|
|
83
|
+
if (seen.has(key)) continue;
|
|
84
|
+
seen.add(key);
|
|
85
|
+
merged.push(failure);
|
|
86
|
+
if (merged.length >= MAX_FAILURES) break;
|
|
87
|
+
}
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function createCheckRuntime(options: CheckRuntimeOptions = {}): CheckRuntime {
|
|
92
|
+
const now = options.now ?? (() => Date.now());
|
|
93
|
+
const runner = options.runner ?? createRunner();
|
|
94
|
+
let active: ActiveCheck | undefined;
|
|
95
|
+
let controller: AbortController | undefined;
|
|
96
|
+
|
|
97
|
+
const configFor = (cwd: string): CheckConfig => options.config ?? loadConfig(cwd);
|
|
98
|
+
|
|
99
|
+
const execute = async (
|
|
100
|
+
target: CheckTarget,
|
|
101
|
+
cwd: string,
|
|
102
|
+
config: CheckConfig,
|
|
103
|
+
commands: readonly CommandSpec[],
|
|
104
|
+
lines: string[],
|
|
105
|
+
signal: AbortSignal,
|
|
106
|
+
onUpdate?: () => void,
|
|
107
|
+
): Promise<CheckResult> => {
|
|
108
|
+
const started = now();
|
|
109
|
+
const outcomes: RunOutcome[] = [];
|
|
110
|
+
const failures: Failure[] = [];
|
|
111
|
+
const splitter = new LineSplitter();
|
|
112
|
+
let status: CheckStatus = "passed";
|
|
113
|
+
let error: string | undefined;
|
|
114
|
+
|
|
115
|
+
const append = (line: string): void => {
|
|
116
|
+
lines.push(cleanLine(line));
|
|
117
|
+
if (lines.length > MAX_LINES) lines.splice(0, lines.length - MAX_LINES);
|
|
118
|
+
};
|
|
119
|
+
const flush = (): void => {
|
|
120
|
+
for (const line of splitter.flush()) append(line);
|
|
121
|
+
};
|
|
122
|
+
const onOutput = (chunk: string): void => {
|
|
123
|
+
for (const line of splitter.push(chunk)) append(line);
|
|
124
|
+
onUpdate?.();
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
for (const spec of commands) {
|
|
128
|
+
if (signal.aborted) {
|
|
129
|
+
status = "cancelled";
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
append(`$ ${spec.command}`);
|
|
133
|
+
let outcome: RunOutcome;
|
|
134
|
+
try {
|
|
135
|
+
outcome = await runner({
|
|
136
|
+
cwd,
|
|
137
|
+
command: spec.command,
|
|
138
|
+
timeoutMs: config.timeoutMs,
|
|
139
|
+
maxLogBytes: config.maxLogBytes,
|
|
140
|
+
signal,
|
|
141
|
+
onOutput,
|
|
142
|
+
});
|
|
143
|
+
} catch (failure) {
|
|
144
|
+
status = "error";
|
|
145
|
+
error = failure instanceof Error ? failure.message : String(failure);
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
flush();
|
|
149
|
+
outcomes.push(outcome);
|
|
150
|
+
if (outcome.cancelled) {
|
|
151
|
+
status = "cancelled";
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
if (outcome.timedOut) {
|
|
155
|
+
status = "timeout";
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (outcome.error) {
|
|
159
|
+
status = "error";
|
|
160
|
+
error = outcome.error;
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
if (outcome.exitCode !== 0) {
|
|
164
|
+
status = "failed";
|
|
165
|
+
failures.push(...parseFailures(outcome.output, spec.command, outcome.exitCode));
|
|
166
|
+
}
|
|
167
|
+
append("");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const exitCode =
|
|
171
|
+
outcomes.find((outcome) => outcome.exitCode !== null && outcome.exitCode !== 0)?.exitCode ??
|
|
172
|
+
outcomes.at(-1)?.exitCode ??
|
|
173
|
+
null;
|
|
174
|
+
const tail = lines.slice(-TAIL_LINES).join("\n");
|
|
175
|
+
onUpdate?.();
|
|
176
|
+
return {
|
|
177
|
+
target,
|
|
178
|
+
status,
|
|
179
|
+
commands: commands.map((spec) => spec.label),
|
|
180
|
+
exitCode,
|
|
181
|
+
durationMs: Math.max(0, now() - started),
|
|
182
|
+
truncated: outcomes.some((outcome) => outcome.truncated),
|
|
183
|
+
failures: mergeFailures(failures),
|
|
184
|
+
tail,
|
|
185
|
+
...(error ? { error } : {}),
|
|
186
|
+
};
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const start = (target: CheckTarget, ctx: ExtensionContext, onUpdate?: () => void): ActiveCheck => {
|
|
190
|
+
if (active) throw new CheckError("A check is already running.");
|
|
191
|
+
const cwd = resolveEffectiveCwd(ctx.cwd);
|
|
192
|
+
const config = configFor(cwd);
|
|
193
|
+
const planned = planCommands(cwd, target, config.command);
|
|
194
|
+
if (!planned.ok) throw new CheckError(planned.error);
|
|
195
|
+
const commands = planned.commands;
|
|
196
|
+
if (commands.length === 0) throw new CheckError("No check command detected.");
|
|
197
|
+
|
|
198
|
+
const started = now();
|
|
199
|
+
const lines: string[] = [];
|
|
200
|
+
const runController = new AbortController();
|
|
201
|
+
controller = runController;
|
|
202
|
+
let handle: ActiveCheck;
|
|
203
|
+
const promise = execute(target, cwd, config, commands, lines, runController.signal, onUpdate)
|
|
204
|
+
.catch((error) => checkErrorResult(target, error instanceof Error ? error.message : String(error)))
|
|
205
|
+
.finally(() => {
|
|
206
|
+
if (controller === runController) controller = undefined;
|
|
207
|
+
if (active === handle) active = undefined;
|
|
208
|
+
});
|
|
209
|
+
handle = {
|
|
210
|
+
target,
|
|
211
|
+
commands: commands.map((spec) => spec.label),
|
|
212
|
+
lines: () => lines,
|
|
213
|
+
elapsedMs: () => Math.max(0, now() - started),
|
|
214
|
+
promise,
|
|
215
|
+
cancel: () => runController.abort(),
|
|
216
|
+
};
|
|
217
|
+
active = handle;
|
|
218
|
+
return handle;
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const run = async (target: CheckTarget, ctx: ExtensionContext): Promise<CheckResult> => {
|
|
222
|
+
let handle: ActiveCheck;
|
|
223
|
+
try {
|
|
224
|
+
handle = start(target, ctx);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (error instanceof CheckError) return checkErrorResult(target, error.message);
|
|
227
|
+
return checkErrorResult(target, error instanceof Error ? error.message : String(error));
|
|
228
|
+
}
|
|
229
|
+
return handle.promise;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const shutdown = (): void => {
|
|
233
|
+
controller?.abort();
|
|
234
|
+
controller = undefined;
|
|
235
|
+
active = undefined;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
return {
|
|
239
|
+
start,
|
|
240
|
+
run,
|
|
241
|
+
isRunning: () => active !== undefined,
|
|
242
|
+
shutdown,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a small source excerpt around a parsed failure's location.
|
|
3
|
+
*
|
|
4
|
+
* The path comes from command output, so it is treated as untrusted: it must
|
|
5
|
+
* resolve inside the project root (including through symlinks), be a regular
|
|
6
|
+
* file, and pass a size/NUL check before any text is returned. Anything else
|
|
7
|
+
* is a reason the detail pane can show while it falls back to the run tail.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { isAbsolute, resolve } from "node:path";
|
|
12
|
+
import { isInside, isInsideReal } from "../../lib/path.ts";
|
|
13
|
+
import type { Failure } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** Lines shown before and after the target line. */
|
|
16
|
+
export const SOURCE_CONTEXT = 5;
|
|
17
|
+
|
|
18
|
+
/** Largest source file read for a detail pane. */
|
|
19
|
+
export const MAX_SOURCE_BYTES = 512 * 1024;
|
|
20
|
+
|
|
21
|
+
export interface SourceExcerpt {
|
|
22
|
+
/** 1-based line number of the first returned row. */
|
|
23
|
+
startLine: number;
|
|
24
|
+
/** 1-based line number of the last returned row. */
|
|
25
|
+
endLine: number;
|
|
26
|
+
/** 1-based target line. */
|
|
27
|
+
targetLine: number;
|
|
28
|
+
/** Raw file lines, one per row, without line numbers or markers. */
|
|
29
|
+
lines: string[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type SourceResult = { ok: true; excerpt: SourceExcerpt } | { ok: false; reason: string };
|
|
33
|
+
|
|
34
|
+
/** A source excerpt for a failure, or the reason one cannot be shown. */
|
|
35
|
+
export function readSourceExcerpt(cwd: string, failure: Failure): SourceResult {
|
|
36
|
+
if (!failure.path) return { ok: false, reason: "no file location" };
|
|
37
|
+
if (failure.line === undefined || !Number.isFinite(failure.line) || failure.line < 1) {
|
|
38
|
+
return { ok: false, reason: "no line number" };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const root = resolve(cwd);
|
|
42
|
+
const absolute = isAbsolute(failure.path) ? resolve(failure.path) : resolve(root, failure.path);
|
|
43
|
+
if (!isInside(root, absolute) || !isInsideReal(root, absolute)) {
|
|
44
|
+
return { ok: false, reason: "outside the project" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let stat;
|
|
48
|
+
try {
|
|
49
|
+
stat = statSync(absolute);
|
|
50
|
+
} catch {
|
|
51
|
+
return { ok: false, reason: "file is missing" };
|
|
52
|
+
}
|
|
53
|
+
if (!stat.isFile()) return { ok: false, reason: "not a regular file" };
|
|
54
|
+
if (stat.size > MAX_SOURCE_BYTES) return { ok: false, reason: "file is too large" };
|
|
55
|
+
|
|
56
|
+
let text: string;
|
|
57
|
+
try {
|
|
58
|
+
text = readFileSync(absolute, "utf-8");
|
|
59
|
+
} catch {
|
|
60
|
+
return { ok: false, reason: "file could not be read" };
|
|
61
|
+
}
|
|
62
|
+
if (text.includes("\u0000")) return { ok: false, reason: "binary file" };
|
|
63
|
+
|
|
64
|
+
const fileLines = text.split(/\r?\n/);
|
|
65
|
+
if (failure.line > fileLines.length) {
|
|
66
|
+
return { ok: false, reason: `line ${failure.line} is outside the file (${fileLines.length} lines)` };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const startLine = Math.max(1, failure.line - SOURCE_CONTEXT);
|
|
70
|
+
const endLine = Math.min(fileLines.length, failure.line + SOURCE_CONTEXT);
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
excerpt: {
|
|
74
|
+
startLine,
|
|
75
|
+
endLine,
|
|
76
|
+
targetLine: failure.line,
|
|
77
|
+
lines: fileLines.slice(startLine - 1, endLine),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|