zames_pro 2.55.0 → 2.59.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/CHANGELOG.md +220 -0
- package/README.md +111 -2
- package/dist/agent-loop.js +67 -0
- package/dist/attachments.js +32 -0
- package/dist/commands.js +125 -0
- package/dist/config.js +3 -9
- package/dist/fsutil.js +18 -0
- package/dist/gitTools.js +37 -0
- package/dist/i18n.js +134 -16
- package/dist/index.js +285 -24
- package/dist/mcp.js +20 -0
- package/dist/sessions.js +4 -8
- package/dist/spinner.js +42 -3
- package/dist/system-prompt.js +13 -0
- package/dist/tools.js +38 -5
- package/dist/transcript.js +14 -4
- package/dist/undo.js +8 -6
- package/dist/web.js +31 -0
- package/package.json +13 -4
- package/scripts/clean-dist.mjs +17 -0
- package/scripts/coverage-gate.mjs +83 -0
package/dist/spinner.js
CHANGED
|
@@ -27,6 +27,9 @@ export function renderDots(n) {
|
|
|
27
27
|
return theme.brown(d + DOTS_PAD.slice(d.length));
|
|
28
28
|
}
|
|
29
29
|
export function createSpinner(locale = 'ru') {
|
|
30
|
+
// Set by the caller (index.ts) to the same todos summary the LineEditor
|
|
31
|
+
// shows, so the non-TTY path has parity.
|
|
32
|
+
let onTasksQuery = null;
|
|
30
33
|
let spinner = null;
|
|
31
34
|
let dotTimer = null;
|
|
32
35
|
let dotPhase = 0;
|
|
@@ -37,6 +40,10 @@ export function createSpinner(locale = 'ru') {
|
|
|
37
40
|
// zero every second and look frozen on a single dot.
|
|
38
41
|
let animBase = '';
|
|
39
42
|
let animating = false;
|
|
43
|
+
// When the current animated phase began, so a long reasoning turn can show
|
|
44
|
+
// "12s" / "2m 05s" instead of looking identical to a hung one. Parity with
|
|
45
|
+
// the LineEditor status line (src/input.ts).
|
|
46
|
+
let animStart = 0;
|
|
40
47
|
const start = (text) => {
|
|
41
48
|
if (!spinner)
|
|
42
49
|
spinner = ora(text).start();
|
|
@@ -59,9 +66,34 @@ export function createSpinner(locale = 'ru') {
|
|
|
59
66
|
// "running" dot sequence. Shared by the thinking spinner and the send-pause
|
|
60
67
|
// indicator, so the pause is animated too (before, it was a static line and
|
|
61
68
|
// the dots did not move — the operator saw a frozen spinner).
|
|
69
|
+
// Elapsed label of the current animated phase ("0s", "45s", "2m 05s").
|
|
70
|
+
// Empty when nothing is animating. Localized units come from the catalog.
|
|
71
|
+
const elapsedLabel = () => {
|
|
72
|
+
if (!animating || !animStart)
|
|
73
|
+
return '';
|
|
74
|
+
const sec = Math.floor((Date.now() - animStart) / 1000);
|
|
75
|
+
const m = Math.floor(sec / 60);
|
|
76
|
+
const s = sec % 60;
|
|
77
|
+
const text = m > 0 ? m + 'm ' + String(s).padStart(2, '0') + 's' : s + 's';
|
|
78
|
+
return theme.dim(' · ' + text);
|
|
79
|
+
};
|
|
80
|
+
// Task-list badge, refreshed from onTasksQuery on every animation tick.
|
|
81
|
+
// Mirrors the LineEditor so the non-TTY spinner shows the same "tasks: 2/5".
|
|
82
|
+
const tasksBadge = () => {
|
|
83
|
+
if (!onTasksQuery)
|
|
84
|
+
return '';
|
|
85
|
+
try {
|
|
86
|
+
const s = onTasksQuery();
|
|
87
|
+
return s ? theme.system(s) + ' ' : '';
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return '';
|
|
91
|
+
}
|
|
92
|
+
};
|
|
62
93
|
const startAnimated = (baseText) => {
|
|
63
94
|
animBase = theme.brown(stripEllipsis(baseText));
|
|
64
|
-
|
|
95
|
+
animStart = Date.now();
|
|
96
|
+
start(animBase + renderDots(0) + elapsedLabel() + tasksBadge() + HINT);
|
|
65
97
|
dotPhase = 0;
|
|
66
98
|
if (dotTimer)
|
|
67
99
|
clearInterval(dotTimer);
|
|
@@ -69,7 +101,8 @@ export function createSpinner(locale = 'ru') {
|
|
|
69
101
|
if (!spinner)
|
|
70
102
|
return;
|
|
71
103
|
dotPhase = (dotPhase + 1) % DOTS.length;
|
|
72
|
-
spinner.text =
|
|
104
|
+
spinner.text =
|
|
105
|
+
animBase + renderDots(dotPhase) + elapsedLabel() + tasksBadge() + HINT;
|
|
73
106
|
}, 400);
|
|
74
107
|
animating = true;
|
|
75
108
|
if (dotTimer.unref)
|
|
@@ -93,6 +126,12 @@ export function createSpinner(locale = 'ru') {
|
|
|
93
126
|
spinner.text = theme.prompt('✎ ') + pending + HINT;
|
|
94
127
|
};
|
|
95
128
|
return {
|
|
129
|
+
get onTasksQuery() {
|
|
130
|
+
return onTasksQuery;
|
|
131
|
+
},
|
|
132
|
+
set onTasksQuery(fn) {
|
|
133
|
+
onTasksQuery = fn;
|
|
134
|
+
},
|
|
96
135
|
thinking: () => {
|
|
97
136
|
if (pending) {
|
|
98
137
|
showPending();
|
|
@@ -112,7 +151,7 @@ export function createSpinner(locale = 'ru') {
|
|
|
112
151
|
// so the countdown refreshes without resetting the dots.
|
|
113
152
|
if (animating && dotTimer && spinner) {
|
|
114
153
|
animBase = theme.brown(stripEllipsis(label));
|
|
115
|
-
spinner.text = animBase + renderDots(dotPhase) + HINT;
|
|
154
|
+
spinner.text = animBase + renderDots(dotPhase) + elapsedLabel() + HINT;
|
|
116
155
|
return;
|
|
117
156
|
}
|
|
118
157
|
startAnimated(label);
|
package/dist/system-prompt.js
CHANGED
|
@@ -128,6 +128,19 @@ You have access to the following tools:
|
|
|
128
128
|
|
|
129
129
|
${toolDescriptions}
|
|
130
130
|
${gitSection}${attachSection}${contextSection}
|
|
131
|
+
## Choosing the right tool
|
|
132
|
+
|
|
133
|
+
Prefer the most specific tool; a wrong choice wastes a turn:
|
|
134
|
+
|
|
135
|
+
- Several small edits in ONE file -> MultiEdit (applied atomically), not
|
|
136
|
+
several Edit calls.
|
|
137
|
+
- Changes across SEVERAL files -> ApplyPatch (V4A), not a series of writes.
|
|
138
|
+
- One exact replacement -> Edit. Creating or overwriting a whole file -> Write.
|
|
139
|
+
- Searching file CONTENT -> Grep; finding files by NAME -> Glob; listing a
|
|
140
|
+
directory -> LS.
|
|
141
|
+
- Git operations -> the Git* tools, not Bash.
|
|
142
|
+
- Reading a file whose path you already know -> Read, not Bash cat.
|
|
143
|
+
|
|
131
144
|
## MCP tools
|
|
132
145
|
|
|
133
146
|
Some tools are provided by external MCP servers and their names look
|
package/dist/tools.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createExtraTools } from './extraTools.js';
|
|
|
8
8
|
// user-facing. They must be in ENGLISH (like Claude Code / Codex). Only the
|
|
9
9
|
// strings the OPERATOR sees (help, spinner, service messages) are localized —
|
|
10
10
|
// those live in src/i18n.ts (CATALOG) and are picked by the locale setting.
|
|
11
|
-
export function createTools(workdir, { undo, todos } = {}) {
|
|
11
|
+
export function createTools(workdir, { undo, todos, readOnly = false, } = {}) {
|
|
12
12
|
const root = path.resolve(workdir);
|
|
13
13
|
const safe = (p) => {
|
|
14
14
|
const resolved = path.resolve(root, p);
|
|
@@ -164,7 +164,8 @@ export function createTools(workdir, { undo, todos } = {}) {
|
|
|
164
164
|
{
|
|
165
165
|
name: 'Grep',
|
|
166
166
|
description: 'Search file contents (regular expression). include — file name ' +
|
|
167
|
-
'filter (e.g. "*.ts"
|
|
167
|
+
'filter, a single glob or a comma-separated list (e.g. "*.ts" or ' +
|
|
168
|
+
'"*.ts,*.tsx"). output: content (default, matching lines with ' +
|
|
168
169
|
'numbers), files_only (paths only), count (match count per file).',
|
|
169
170
|
parameters: {
|
|
170
171
|
pattern: 'string',
|
|
@@ -175,7 +176,11 @@ export function createTools(workdir, { undo, todos } = {}) {
|
|
|
175
176
|
fn: async ({ pattern, path: searchPath, include, output }) => {
|
|
176
177
|
const pat = req(pattern, 'pattern');
|
|
177
178
|
const target = searchPath ? safe(String(searchPath)) : workdir;
|
|
178
|
-
|
|
179
|
+
// include may be a single glob or a comma-separated list.
|
|
180
|
+
const incs = (include ? String(include) : '')
|
|
181
|
+
.split(',')
|
|
182
|
+
.map((s) => s.trim())
|
|
183
|
+
.filter(Boolean);
|
|
179
184
|
const mode = String(output || 'content').toLowerCase();
|
|
180
185
|
if (process.platform === 'win32') {
|
|
181
186
|
const escaped = pat.replace(/"/g, '\\"');
|
|
@@ -183,7 +188,9 @@ export function createTools(workdir, { undo, todos } = {}) {
|
|
|
183
188
|
return runShell(workdir, `findstr /s /n /r /c:"${escaped}" ` + scope);
|
|
184
189
|
}
|
|
185
190
|
const flags = mode === 'files_only' ? '-rlE' : mode === 'count' ? '-rcE' : '-rnE';
|
|
186
|
-
const includeArg =
|
|
191
|
+
const includeArg = incs
|
|
192
|
+
.map((g) => ' --include=' + JSON.stringify(g))
|
|
193
|
+
.join('');
|
|
187
194
|
return runShell(workdir, `grep ${flags}${includeArg} ${JSON.stringify(pat)} ` +
|
|
188
195
|
`${JSON.stringify(target)} || true`);
|
|
189
196
|
},
|
|
@@ -198,5 +205,31 @@ export function createTools(workdir, { undo, todos } = {}) {
|
|
|
198
205
|
parameters: { message: 'string' },
|
|
199
206
|
fn: async ({ message }) => message,
|
|
200
207
|
};
|
|
201
|
-
|
|
208
|
+
const all = [
|
|
209
|
+
...baseTools,
|
|
210
|
+
...extraTools,
|
|
211
|
+
...gitTools,
|
|
212
|
+
...webTools,
|
|
213
|
+
respondTool,
|
|
214
|
+
];
|
|
215
|
+
return readOnly ? filterToolsForReadOnly(all) : all;
|
|
216
|
+
}
|
|
217
|
+
// Tools that mutate the working tree or run arbitrary commands. In "plan"
|
|
218
|
+
// (read-only) mode they are REMOVED from the tool set entirely, so the model
|
|
219
|
+
// cannot write by accident while it is still investigating. Removing (rather
|
|
220
|
+
// than rejecting at call time) is the honest signal: the agent never sees a
|
|
221
|
+
// tool it is not allowed to use, so it does not waste a turn trying.
|
|
222
|
+
export const MUTATING_TOOLS = new Set([
|
|
223
|
+
'Write',
|
|
224
|
+
'Edit',
|
|
225
|
+
'MultiEdit',
|
|
226
|
+
'ApplyPatch',
|
|
227
|
+
'Bash',
|
|
228
|
+
'GitAdd',
|
|
229
|
+
'GitCommit',
|
|
230
|
+
'GitPush',
|
|
231
|
+
]);
|
|
232
|
+
/** Drop mutating tools for read-only / plan mode. Pure; unit-tested. */
|
|
233
|
+
export function filterToolsForReadOnly(tools) {
|
|
234
|
+
return tools.filter((t) => !MUTATING_TOOLS.has(t.name));
|
|
202
235
|
}
|
package/dist/transcript.js
CHANGED
|
@@ -5,11 +5,13 @@ export class Transcript {
|
|
|
5
5
|
file;
|
|
6
6
|
stream;
|
|
7
7
|
startedAt;
|
|
8
|
-
|
|
8
|
+
onLine;
|
|
9
|
+
constructor({ dir, enabled = true, sessionName = 'session', onLine = null, }) {
|
|
9
10
|
this.enabled = enabled;
|
|
10
11
|
this.file = null;
|
|
11
12
|
this.stream = null;
|
|
12
13
|
this.startedAt = Date.now();
|
|
14
|
+
this.onLine = onLine;
|
|
13
15
|
if (!enabled)
|
|
14
16
|
return;
|
|
15
17
|
try {
|
|
@@ -30,16 +32,24 @@ export class Transcript {
|
|
|
30
32
|
}
|
|
31
33
|
}
|
|
32
34
|
log(type, data = {}) {
|
|
33
|
-
if (!this.enabled || !this.stream)
|
|
34
|
-
return;
|
|
35
35
|
const entry = {
|
|
36
36
|
ts: new Date().toISOString(),
|
|
37
37
|
elapsed: Date.now() - this.startedAt,
|
|
38
38
|
type,
|
|
39
39
|
...data,
|
|
40
40
|
};
|
|
41
|
+
const line = JSON.stringify(entry) + String.fromCharCode(10);
|
|
42
|
+
// The mirror runs even when the file is disabled (jsonl mode).
|
|
43
|
+
if (this.onLine) {
|
|
44
|
+
try {
|
|
45
|
+
this.onLine(line);
|
|
46
|
+
}
|
|
47
|
+
catch { }
|
|
48
|
+
}
|
|
49
|
+
if (!this.enabled || !this.stream)
|
|
50
|
+
return;
|
|
41
51
|
try {
|
|
42
|
-
this.stream.write(
|
|
52
|
+
this.stream.write(line);
|
|
43
53
|
}
|
|
44
54
|
catch { }
|
|
45
55
|
}
|
package/dist/undo.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'fs/promises';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
+
import { writeJsonAtomic } from './fsutil.js';
|
|
4
5
|
const UNDO_DIR = path.join(os.homedir(), '.zames', 'undo');
|
|
5
6
|
const INDEX = path.join(UNDO_DIR, 'index.json');
|
|
6
7
|
export class UndoStore {
|
|
@@ -23,9 +24,7 @@ export class UndoStore {
|
|
|
23
24
|
}
|
|
24
25
|
async _writeIndex(history) {
|
|
25
26
|
// Atomic write: a crash mid-write must not corrupt the undo history.
|
|
26
|
-
|
|
27
|
-
await fs.writeFile(tmp, JSON.stringify(history, null, 2));
|
|
28
|
-
await fs.rename(tmp, INDEX);
|
|
27
|
+
writeJsonAtomic(INDEX, history);
|
|
29
28
|
}
|
|
30
29
|
async backup(filePath) {
|
|
31
30
|
if (!this.enabled)
|
|
@@ -63,15 +62,18 @@ export class UndoStore {
|
|
|
63
62
|
return { originalPath: filePath, existed, stamp };
|
|
64
63
|
}
|
|
65
64
|
async undoLast() {
|
|
65
|
+
// Machine codes, NOT prose: the caller (index.ts) localizes them through
|
|
66
|
+
// the i18n catalog. Hardcoding a Russian reason here leaked Russian into
|
|
67
|
+
// an English UI (`/undo` with an empty history).
|
|
66
68
|
if (!this.enabled)
|
|
67
|
-
return { ok: false, reason: '
|
|
69
|
+
return { ok: false, reason: 'disabled' };
|
|
68
70
|
await this._ensure();
|
|
69
71
|
const history = await this._readIndex();
|
|
70
72
|
if (!history.length)
|
|
71
|
-
return { ok: false, reason: '
|
|
73
|
+
return { ok: false, reason: 'empty' };
|
|
72
74
|
const record = history.pop();
|
|
73
75
|
if (!record)
|
|
74
|
-
return { ok: false, reason: '
|
|
76
|
+
return { ok: false, reason: 'empty' };
|
|
75
77
|
const { originalPath, existed, backupFile } = record;
|
|
76
78
|
try {
|
|
77
79
|
if (existed && backupFile) {
|
package/dist/web.js
CHANGED
|
@@ -118,6 +118,30 @@ async function assertPublicUrl(rawUrl) {
|
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
121
|
+
// True for content types whose bodies are not meant to be shown as text.
|
|
122
|
+
// WebFetch dumps the body into the context, so a PDF or an image would flood
|
|
123
|
+
// the model with garbage; the caller returns a short human note instead.
|
|
124
|
+
export function isBinaryContentType(contentType) {
|
|
125
|
+
const ct = String(contentType || '')
|
|
126
|
+
.toLowerCase()
|
|
127
|
+
.split(';')[0]
|
|
128
|
+
.trim();
|
|
129
|
+
if (!ct)
|
|
130
|
+
return false;
|
|
131
|
+
if (ct.startsWith('text/'))
|
|
132
|
+
return false;
|
|
133
|
+
// Textual application/* types must stay readable.
|
|
134
|
+
if (/^(application\/(json|xml|xhtml\+xml|javascript|ecmascript|x-httpd-php|rtf|sql|graphql|x-sh|x-yaml|yaml|toml|ld\+json|x-ndjson))$/.test(ct)) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
if (/\+(json|xml)$/.test(ct))
|
|
138
|
+
return false;
|
|
139
|
+
if (/^(image|audio|video|font|model)\//.test(ct) ||
|
|
140
|
+
/^application\/(pdf|zip|gzip|x-gzip|x-tar|octet-stream|msword|vnd\.|x-7z|x-rar|x-bzip|x-protobuf|wasm)/.test(ct)) {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
121
145
|
// fetch with retries for transient network errors and 5xx responses.
|
|
122
146
|
async function httpFetchWithRetry(url, opts = {}, retries = 2) {
|
|
123
147
|
let lastErr;
|
|
@@ -231,6 +255,13 @@ export function createWebTools() {
|
|
|
231
255
|
return formatResult(r.status, r.url, text, limit);
|
|
232
256
|
}
|
|
233
257
|
const r = await httpFetchWithRetry(String(url));
|
|
258
|
+
// Binary content (PDF, images, archives, ...) must NOT be dumped
|
|
259
|
+
// into the context as raw bytes — tell the model what it is instead.
|
|
260
|
+
if (isBinaryContentType(r.contentType)) {
|
|
261
|
+
return (`HTTP ${r.status} ${r.url}\n` +
|
|
262
|
+
`Content-Type: ${r.contentType}\n\n` +
|
|
263
|
+
`(binary content is not returned as text; content-type: ${r.contentType})`);
|
|
264
|
+
}
|
|
234
265
|
// If it's JSON/plain text — return as is
|
|
235
266
|
if (/application\/json|text\/plain|text\/markdown/i.test(r.contentType)) {
|
|
236
267
|
const trimmed = r.body.slice(0, limit);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zames_pro",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "Terminal coding agent
|
|
3
|
+
"version": "2.59.0",
|
|
4
|
+
"description": "Terminal coding agent that drives chat.deepseek.com through Playwright: reads and edits files, runs commands, commits to git.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"zames": "dist/index.js"
|
|
@@ -10,13 +10,16 @@
|
|
|
10
10
|
"dist",
|
|
11
11
|
"scripts",
|
|
12
12
|
"README.md",
|
|
13
|
+
"CHANGELOG.md",
|
|
13
14
|
"LICENSE",
|
|
14
15
|
"logo.jpg"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
17
18
|
"start": "node ./dist/index.js",
|
|
18
19
|
"dev": "ZAMES_NET_DEBUG=1 tsx ./src/index.ts --dev --headed",
|
|
19
|
-
"
|
|
20
|
+
"clean": "node scripts/clean-dist.mjs",
|
|
21
|
+
"build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json",
|
|
22
|
+
"coverage:gate": "node scripts/coverage-gate.mjs",
|
|
20
23
|
"typecheck": "tsc --noEmit",
|
|
21
24
|
"lint": "node scripts/eslint-ts6.mjs",
|
|
22
25
|
"lint:fix": "node scripts/eslint-ts6.mjs --fix",
|
|
@@ -34,7 +37,13 @@
|
|
|
34
37
|
"ai",
|
|
35
38
|
"deepseek",
|
|
36
39
|
"cli",
|
|
37
|
-
"coding-agent"
|
|
40
|
+
"coding-agent",
|
|
41
|
+
"claude-code",
|
|
42
|
+
"codex",
|
|
43
|
+
"terminal",
|
|
44
|
+
"playwright",
|
|
45
|
+
"llm",
|
|
46
|
+
"automation"
|
|
38
47
|
],
|
|
39
48
|
"license": "MIT",
|
|
40
49
|
"engines": {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { rmSync, existsSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
|
|
5
|
+
// tsc emits into dist/ without clearing it, so a source file DELETED from
|
|
6
|
+
// src/ leaves an orphan .js in the published tree (this really happened:
|
|
7
|
+
// dist/confirm.js survived after src/confirm.ts was removed). Wipe dist/ first
|
|
8
|
+
// so the build output mirrors src/ exactly.
|
|
9
|
+
//
|
|
10
|
+
// Cross-platform: use node's fs.rm instead of a shell `rm -rf` (Windows has no
|
|
11
|
+
// rm) and never touch anything outside this repo.
|
|
12
|
+
const here = path.dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
const dist = path.resolve(here, '..', 'dist')
|
|
14
|
+
if (existsSync(dist)) {
|
|
15
|
+
rmSync(dist, { recursive: true, force: true })
|
|
16
|
+
console.log('clean-dist: removed', dist)
|
|
17
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
|
|
3
|
+
// Soft coverage FLOOR for the PURE logic modules — the ones a regression would
|
|
4
|
+
// silently break without any UI symptom (command formatting, schedule parsing,
|
|
5
|
+
// the raw SSE/model-answer parsing). The floors are deliberately a few points
|
|
6
|
+
// BELOW the current values so ordinary churn does not fail the build; they
|
|
7
|
+
// only catch a real drop (a module losing its tests).
|
|
8
|
+
//
|
|
9
|
+
// Node's test runner has no built-in coverage threshold, so we parse the
|
|
10
|
+
// per-file line % from the coverage report it prints and compare here. This is
|
|
11
|
+
// informational in the sense that the FLOORS are conservative, but a drop
|
|
12
|
+
// below one is a hard failure.
|
|
13
|
+
const FLOORS = {
|
|
14
|
+
'commands.ts': 85,
|
|
15
|
+
'scheduler.ts': 90,
|
|
16
|
+
'net-capture.ts': 90,
|
|
17
|
+
'xml-toolcall.ts': 90,
|
|
18
|
+
'diff.ts': 90,
|
|
19
|
+
'theme.ts': 95,
|
|
20
|
+
// Added later: core logic or already well-tested, so a silent regression
|
|
21
|
+
// that deletes their tests should be caught too. Floors sit a few points
|
|
22
|
+
// below the current values.
|
|
23
|
+
'extraTools.ts': 85,
|
|
24
|
+
'fsutil.ts': 90,
|
|
25
|
+
'sessions.ts': 90,
|
|
26
|
+
'context.ts': 85,
|
|
27
|
+
'markdown.ts': 90,
|
|
28
|
+
'tools.ts': 90,
|
|
29
|
+
'system-prompt.ts': 90,
|
|
30
|
+
'undo.ts': 85,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const run = spawnSync(
|
|
34
|
+
'npx',
|
|
35
|
+
['tsx', '--test', '--experimental-test-coverage', 'test/*.test.ts'],
|
|
36
|
+
{ encoding: 'utf-8', shell: process.platform === 'win32', maxBuffer: 1024 * 1024 * 64 },
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const out = (run.stdout || '') + (run.stderr || '')
|
|
40
|
+
|
|
41
|
+
if (run.status !== 0) {
|
|
42
|
+
console.error('coverage-gate: test run failed (exit ' + run.status + ')')
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const linePct = new Map()
|
|
47
|
+
for (const line of out.split('\n')) {
|
|
48
|
+
// Per-file rows look like:
|
|
49
|
+
// ℹ commands.ts | 94.14 | 78.70 | 93.88 | 40-56 ...
|
|
50
|
+
const m = line.match(/^\s*\S?\s*([A-Za-z0-9._-]+\.ts)\s+\|\s+([\d.]+)\s+\|/)
|
|
51
|
+
if (m) linePct.set(m[1], Number(m[2]))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const failures = []
|
|
55
|
+
for (const [file, floor] of Object.entries(FLOORS)) {
|
|
56
|
+
const pct = linePct.get(file)
|
|
57
|
+
if (pct === undefined) {
|
|
58
|
+
failures.push(file + ': no coverage row found')
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
if (pct < floor) {
|
|
62
|
+
failures.push(file + ': ' + pct + '% < floor ' + floor + '%')
|
|
63
|
+
} else {
|
|
64
|
+
console.log('coverage-gate: ' + file + ' ' + pct + '% >= ' + floor + '%')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Print the lowest-covered modules, so a dead or untested module is visible
|
|
69
|
+
// in the CI log even when it has no floor yet. Informational only.
|
|
70
|
+
const sorted = [...linePct.entries()].sort((a, b) => a[1] - b[1]).slice(0, 8)
|
|
71
|
+
if (sorted.length) {
|
|
72
|
+
console.log('coverage-gate: lowest covered modules (informational):')
|
|
73
|
+
for (const [name, pct] of sorted) {
|
|
74
|
+
console.log(' ' + name + ' ' + pct + '%')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (failures.length) {
|
|
79
|
+
console.error('coverage-gate: FAILED')
|
|
80
|
+
for (const f of failures) console.error(' ' + f)
|
|
81
|
+
process.exit(1)
|
|
82
|
+
}
|
|
83
|
+
console.log('coverage-gate: OK')
|