tickmarkr 2.8.4 → 2.8.6
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/dist/adapters/claude-code.js +3 -2
- package/dist/adapters/codex-commit-check.d.ts +4 -3
- package/dist/adapters/codex-commit-check.js +44 -29
- package/dist/adapters/codex.d.ts +2 -2
- package/dist/adapters/codex.js +38 -32
- package/dist/adapters/cursor-agent.js +2 -1
- package/dist/adapters/grok.js +2 -1
- package/dist/adapters/kimi.js +11 -9
- package/dist/adapters/model-lints.js +4 -3
- package/dist/adapters/opencode.js +2 -1
- package/dist/adapters/pi.js +3 -2
- package/dist/adapters/qwen.js +2 -1
- package/dist/adapters/registry.js +2 -1
- package/dist/adapters/types.d.ts +10 -1
- package/dist/adapters/types.js +18 -3
- package/dist/cli/commands/approve.js +9 -28
- package/dist/cli/commands/doctor.d.ts +49 -5
- package/dist/cli/commands/doctor.js +956 -94
- package/dist/cli/commands/fleet.js +11 -3
- package/dist/cli/commands/init.js +27 -19
- package/dist/cli/commands/status.js +9 -6
- package/dist/cli/commands/verify.js +12 -3
- package/dist/compile/gsd.js +4 -3
- package/dist/compile/index.js +4 -1
- package/dist/compile/native.js +8 -6
- package/dist/config/config.d.ts +15 -0
- package/dist/config/config.js +67 -5
- package/dist/drivers/host-env.d.ts +3 -0
- package/dist/drivers/host-env.js +47 -0
- package/dist/drivers/orca.js +3 -1
- package/dist/drivers/subprocess.d.ts +1 -1
- package/dist/drivers/subprocess.js +4 -3
- package/dist/gates/baseline.d.ts +1 -1
- package/dist/gates/baseline.js +4 -3
- package/dist/gates/cache.js +2 -4
- package/dist/graph/schema.js +3 -1
- package/dist/run/daemon.js +54 -4
- package/dist/run/git-trust.d.ts +11 -0
- package/dist/run/git-trust.js +1 -1
- package/dist/run/git.d.ts +13 -1
- package/dist/run/git.js +34 -14
- package/dist/run/journal.d.ts +15 -1
- package/dist/run/journal.js +83 -17
- package/dist/run/lock.d.ts +5 -0
- package/dist/run/lock.js +202 -9
- package/dist/run/protocol.d.ts +1 -1
- package/dist/tui/cockpit/live-store.js +12 -2
- package/package.json +1 -1
- package/skills/tickmarkr-auto/SKILL.md +65 -21
- package/skills/tickmarkr-loop/SKILL.md +65 -21
- package/skills/tickmarkr-overseer/SKILL.md +88 -32
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { writeFileSync } from "node:fs";
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { Script } from "node:vm";
|
|
4
|
+
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
5
|
+
import { extname, join, normalize, resolve } from "node:path";
|
|
5
6
|
import { detectPackageManager, turboContinueFindings } from "../../gates/baseline.js";
|
|
6
7
|
import { version } from "./version.js";
|
|
7
8
|
import { allAdapters, binaryShadowWarnings, detectCandidateClis, doctorAgeMs, flagDriftWarnings, modelAliasExclusions, modelAliasLine, probeAll, probeModels, readDoctor, resolveShellBinary, servableExclusions, servabilityLine, writeDoctor } from "../../adapters/registry.js";
|
|
@@ -12,6 +13,7 @@ import { graphPath, loadGraph, tickmarkrDir, stateDirName } from "../../graph/gr
|
|
|
12
13
|
import { catalogModelAdvisory, catalogTierRanking, declaredModelWindow, hasWindowsConfig, modelLints, suggestOverlay, ttyVisual } from "../../adapters/model-lints.js";
|
|
13
14
|
import { loadConfig, overlayPreferShapes } from "../../config/config.js";
|
|
14
15
|
import { HerdrDriver } from "../../drivers/herdr.js";
|
|
16
|
+
import { classifyHost } from "../../drivers/index.js";
|
|
15
17
|
import { ORCA_FIXTURE_VERSION, parseEnvelope, resolveOrcaCliBinary } from "../../drivers/orca.js";
|
|
16
18
|
import { kimi, probeKimiDoctorTurn } from "../../adapters/kimi.js";
|
|
17
19
|
import { recordCodexCommit } from "../../adapters/codex-commit-check.js";
|
|
@@ -190,8 +192,19 @@ export async function probeOrcaCapability(cwd, opts = {}) {
|
|
|
190
192
|
return { verdict: "fail", detail: "CLI installed but runtime probe failed" };
|
|
191
193
|
}
|
|
192
194
|
}
|
|
195
|
+
/** A file's text, "" when absent, or the error code when it cannot be read (D-2102 add.31: never a throw). */
|
|
196
|
+
function readText(cwd, p) {
|
|
197
|
+
if (!existsSync(join(cwd, p)))
|
|
198
|
+
return { text: "" };
|
|
199
|
+
try {
|
|
200
|
+
return { text: readFileSync(join(cwd, p), "utf8") };
|
|
201
|
+
}
|
|
202
|
+
catch (e) {
|
|
203
|
+
return { text: "", error: e.code ?? "unreadable" };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
193
206
|
function detectRunner(cwd) {
|
|
194
|
-
const readIf = (p) => (
|
|
207
|
+
const readIf = (p) => readText(cwd, p).text;
|
|
195
208
|
let pkg = {};
|
|
196
209
|
try {
|
|
197
210
|
pkg = JSON.parse(readIf("package.json") || "{}");
|
|
@@ -208,71 +221,840 @@ function detectRunner(cwd) {
|
|
|
208
221
|
}
|
|
209
222
|
return undefined;
|
|
210
223
|
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
224
|
+
// Vitest 3.2.7's configDefaults.exclude, written out so an exclude doctor adds keeps the runner's defaults.
|
|
225
|
+
const VITEST_DEFAULT_EXCLUDE = ["**/node_modules/**", "**/dist/**", "**/cypress/**", "**/.{idea,git,cache,output,temp}/**",
|
|
226
|
+
"**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*"];
|
|
227
|
+
// Queue row 197 (D-2102 add.15, add.30): the canonical entries, as string values.
|
|
228
|
+
const CANONICAL = {
|
|
229
|
+
jest: { state: "<rootDir>/\\.tickmarkr/", session: "<rootDir>/\\.claude/worktrees/", ignore: "testPathIgnorePatterns" },
|
|
230
|
+
vitest: { state: ".tickmarkr/**", session: ".claude/worktrees/**", ignore: "test.exclude" },
|
|
231
|
+
};
|
|
232
|
+
const DIRS = ["state", "session"];
|
|
233
|
+
const SCANS = { state: ".tickmarkr/ run worktrees", session: ".claude/worktrees/ session worktrees" };
|
|
234
|
+
const INSIDE = {
|
|
235
|
+
state: "inside run worktrees (provisioned under .tickmarkr/worktrees.noindex/)",
|
|
236
|
+
session: "inside a session worktree (.claude/worktrees/<name>/)",
|
|
237
|
+
};
|
|
238
|
+
const STRING_LITERAL = /^(["'])(?:\\.|(?!\1)[^\\\n])*\1$/;
|
|
239
|
+
// The one spread with a known value: Vitest 3.2.7's own default exclude list (it holds neither entry).
|
|
240
|
+
// The ECMAScript line terminators: a line comment ends at any of them (queue row 197 review, D-2102 add.31).
|
|
241
|
+
const LINE_END = /[\n\r\u2028\u2029]/;
|
|
242
|
+
/** The offset of the first line terminator at or after `from`, or the end of `text`. */
|
|
243
|
+
const lineEnd = (text, from) => {
|
|
244
|
+
const at = text.slice(from).search(LINE_END);
|
|
245
|
+
return at < 0 ? text.length : from + at;
|
|
246
|
+
};
|
|
247
|
+
// Whitespace and commas between members or elements (comments are already blanked).
|
|
248
|
+
const BETWEEN = /^[\s,]*/;
|
|
249
|
+
// Comments are blanked (offsets kept, so the writer can splice the original) before judgment: a
|
|
250
|
+
// commented-out ignore must not false-pass, and a prose comment mentioning .tickmarkr must not
|
|
251
|
+
// false-warn the anchor probe. The scan skips string literals on purpose: regex stripping ate glob
|
|
252
|
+
// strings — `'**/*.d.ts'` contains `/*` and later globs contain `*/`, so a naive block-comment regex
|
|
253
|
+
// deleted the middle of real configs and false-PASSED a repo-wide vitest include (caught on
|
|
254
|
+
// Intl-Dossier's config; shipped briefly in 1.90.1). Queue row 197: a block comment's inner lines
|
|
255
|
+
// are blanked too, so entries commented out between its delimiters never read as live.
|
|
256
|
+
function blankComments(s) {
|
|
257
|
+
let out = "";
|
|
258
|
+
for (let i = 0; i < s.length; i++) {
|
|
259
|
+
const c = s[i];
|
|
260
|
+
let end = i + 1;
|
|
261
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
262
|
+
for (; end < s.length && s[end] !== c && (c === "`" || !LINE_END.test(s[end])); end++)
|
|
263
|
+
if (s[end] === "\\")
|
|
264
|
+
end++;
|
|
265
|
+
end = Math.min(end + 1, s.length);
|
|
266
|
+
}
|
|
267
|
+
else if (c === "\\")
|
|
268
|
+
end = i + 2;
|
|
269
|
+
else if (s.startsWith("//", i))
|
|
270
|
+
end = lineEnd(s, i);
|
|
271
|
+
else if (s.startsWith("/*", i))
|
|
272
|
+
end = s.indexOf("*/", i + 2) < 0 ? s.length : s.indexOf("*/", i + 2) + 2;
|
|
273
|
+
const chunk = s.slice(i, end);
|
|
274
|
+
out += c === "/" && (s[i + 1] === "/" || s[i + 1] === "*") ? chunk.replace(/[^\n\r\u2028\u2029]/g, " ") : chunk;
|
|
275
|
+
i = end - 1;
|
|
276
|
+
}
|
|
277
|
+
return out;
|
|
278
|
+
}
|
|
279
|
+
/** Offset of the first top-level , ) ] or } at or after `start` (strings skipped, brackets nested). */
|
|
280
|
+
function scanValue(text, start) {
|
|
281
|
+
let depth = 0;
|
|
282
|
+
for (let i = start; i < text.length; i++) {
|
|
283
|
+
const c = text[i];
|
|
284
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
285
|
+
for (i++; i < text.length && text[i] !== c; i++)
|
|
286
|
+
if (text[i] === "\\")
|
|
287
|
+
i++;
|
|
288
|
+
}
|
|
289
|
+
else if ("([{".includes(c))
|
|
290
|
+
depth++;
|
|
291
|
+
else if (")]}".includes(c) && depth-- === 0)
|
|
292
|
+
return i;
|
|
293
|
+
else if (c === "," && depth === 0)
|
|
294
|
+
return i;
|
|
295
|
+
}
|
|
296
|
+
return text.length;
|
|
297
|
+
}
|
|
298
|
+
/** The offset just past the bracket opened at `open` (strings skipped, brackets nested). */
|
|
299
|
+
function closing(text, open) {
|
|
300
|
+
let depth = 0;
|
|
301
|
+
for (let i = open; i < text.length; i++) {
|
|
302
|
+
const c = text[i];
|
|
303
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
304
|
+
for (i++; i < text.length && text[i] !== c; i++)
|
|
305
|
+
if (text[i] === "\\")
|
|
306
|
+
i++;
|
|
307
|
+
}
|
|
308
|
+
else if ("([{".includes(c))
|
|
309
|
+
depth++;
|
|
310
|
+
else if (")]}".includes(c) && --depth === 0)
|
|
311
|
+
return i + 1;
|
|
312
|
+
}
|
|
313
|
+
return text.length;
|
|
314
|
+
}
|
|
315
|
+
/** The top-level elements of an array literal's source (offsets into it); undefined for any other expression. */
|
|
316
|
+
function arrayElements(value) {
|
|
317
|
+
if (!value.startsWith("[") || closing(value, 0) !== value.length)
|
|
215
318
|
return undefined;
|
|
319
|
+
const out = [];
|
|
320
|
+
for (let pos = 1; pos < value.length - 1;) {
|
|
321
|
+
pos += BETWEEN.exec(value.slice(pos, value.length - 1))?.[0].length ?? 0;
|
|
322
|
+
if (pos >= value.length - 1)
|
|
323
|
+
break;
|
|
324
|
+
const end = Math.min(scanValue(value, pos), value.length - 1);
|
|
325
|
+
out.push({ text: value.slice(pos, end).trimEnd(), start: pos });
|
|
326
|
+
pos = end > pos ? end : pos + 1;
|
|
327
|
+
}
|
|
328
|
+
return out;
|
|
329
|
+
}
|
|
330
|
+
/** The members directly inside the object literal opened at `open`; a spread, shorthand, method or computed key has key "". */
|
|
331
|
+
function members(text, open) {
|
|
332
|
+
const close = closing(text, open) - 1;
|
|
333
|
+
const out = [];
|
|
334
|
+
for (let i = open + 1; i < close;) {
|
|
335
|
+
i += BETWEEN.exec(text.slice(i, close))?.[0].length ?? 0;
|
|
336
|
+
if (i >= close)
|
|
337
|
+
break;
|
|
338
|
+
const key = /^(?:(["'])((?:\\.|(?!\1)[^\\\n])*)\1|((?:[A-Za-z_$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})(?:[\w$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})*))\s*:\s*/.exec(text.slice(i, close));
|
|
339
|
+
const start = i + (key?.[0].length ?? 0);
|
|
340
|
+
const stop = Math.min(scanValue(text, start), close);
|
|
341
|
+
out.push({ key: key ? key[3] ?? key[2] : "", start, value: text.slice(start, stop).trimEnd() });
|
|
342
|
+
i = stop > i ? stop : i + 1;
|
|
343
|
+
}
|
|
344
|
+
return out;
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* D-2102 add.25 (c′) and add.28: a settings key's or a string value's escapes decoded strictly — \xHH,
|
|
348
|
+
* \uHHHH, \u{H…} and \n \t \r \b \f \v \0 \' \" \\ — or undefined for any other escape (an octal, a line
|
|
349
|
+
* continuation, any other character).
|
|
350
|
+
*/
|
|
351
|
+
function decodeString(raw) {
|
|
352
|
+
let bad = false;
|
|
353
|
+
const SINGLE = { n: "\n", t: "\t", r: "\r", b: "\b", f: "\f", v: "\v", "0": "\0" };
|
|
354
|
+
const out = raw.replace(/\\(?:x([0-9a-fA-F]{2})|u([0-9a-fA-F]{4})|u\{([0-9a-fA-F]{1,6})\}|([ntrbfv'"\\]|0(?![0-9]))|[\s\S]?)/g, (_, x, u, cp, single) => {
|
|
355
|
+
const code = x ?? u ?? cp;
|
|
356
|
+
if (code !== undefined && parseInt(code, 16) <= 0x10ffff)
|
|
357
|
+
return String.fromCodePoint(parseInt(code, 16));
|
|
358
|
+
if (single !== undefined)
|
|
359
|
+
return SINGLE[single] ?? single;
|
|
360
|
+
bad = true;
|
|
361
|
+
return "";
|
|
362
|
+
});
|
|
363
|
+
return bad ? undefined : out;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The named members of a PATH object (the exported config, its `test` object), and why they cannot be read
|
|
367
|
+
* literally (D-2102 add.25, add.35): every member must be `<plain identifier | plain string>: <value>` and every
|
|
368
|
+
* key must appear once — a spread, a computed, escaped or shorthand key, a method or getter, `__proto__` or a
|
|
369
|
+
* repeated key reads unverifiable. Values off the path are only compiled; the keys of nested value maps
|
|
370
|
+
* (transform, moduleNameMapper, alias…) are data and are never read here.
|
|
371
|
+
*/
|
|
372
|
+
function settingsOf(text, open, keys, what) {
|
|
373
|
+
const all = members(text, open);
|
|
374
|
+
const found = new Map();
|
|
375
|
+
const seen = new Set();
|
|
376
|
+
let computed;
|
|
377
|
+
for (const m of all) {
|
|
378
|
+
if (m.key === "" || m.key.includes("\\"))
|
|
379
|
+
computed ??= `${what} has a member that is not a plain key and value`;
|
|
380
|
+
else if (m.key === "__proto__")
|
|
381
|
+
computed ??= `${what} sets __proto__`;
|
|
382
|
+
else if (seen.has(m.key))
|
|
383
|
+
computed ??= `${m.key} is set twice`;
|
|
384
|
+
seen.add(m.key);
|
|
385
|
+
if (keys.includes(m.key))
|
|
386
|
+
found.set(m.key, m);
|
|
387
|
+
}
|
|
388
|
+
return { get: (key) => found.get(key), computed };
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* D-2102 add.28: what a decoded value may still escape. A jest regex may use \. \/ \- and \\ outside a
|
|
392
|
+
* character class only; a glob or a path may hold no backslash at all.
|
|
393
|
+
*/
|
|
394
|
+
function plainEscapes(value, form) {
|
|
395
|
+
if (form === "path")
|
|
396
|
+
return !value.includes("\\");
|
|
397
|
+
let inClass = false;
|
|
398
|
+
for (let i = 0; i < value.length; i++) {
|
|
399
|
+
const c = value[i];
|
|
400
|
+
if (c === "\\") {
|
|
401
|
+
if (inClass || i + 1 >= value.length || !"./-\\".includes(value[i + 1]))
|
|
402
|
+
return false;
|
|
403
|
+
i++;
|
|
404
|
+
}
|
|
405
|
+
else if (inClass)
|
|
406
|
+
inClass = c !== "]";
|
|
407
|
+
else if (c === "[")
|
|
408
|
+
inClass = true;
|
|
409
|
+
}
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* A setting's literal strings, each decoded strictly (D-2102 add.28) so every check reads the value the
|
|
414
|
+
* runner reads: undefined when absent, "computed" for any other expression, "escape" for an escape the
|
|
415
|
+
* decoder or the value's form does not allow.
|
|
416
|
+
*/
|
|
417
|
+
function literalList(value, form = "path", defaults = new Set()) {
|
|
418
|
+
if (value === undefined)
|
|
419
|
+
return undefined;
|
|
420
|
+
const elements = STRING_LITERAL.test(value) ? [{ text: value, start: 0 }] : arrayElements(value);
|
|
421
|
+
if (!elements?.every((e) => STRING_LITERAL.test(e.text) || defaultsSpread(e.text, defaults)))
|
|
422
|
+
return "computed";
|
|
423
|
+
const out = [];
|
|
424
|
+
for (const e of elements.filter((e) => STRING_LITERAL.test(e.text))) {
|
|
425
|
+
const decoded = decodeString(e.text.slice(1, -1));
|
|
426
|
+
if (decoded === undefined || !plainEscapes(decoded, form))
|
|
427
|
+
return "escape";
|
|
428
|
+
out.push(decoded);
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
/** The spread of Vitest's own default excludes, `...<configDefaults>.exclude`, through a bound configDefaults. */
|
|
433
|
+
const defaultsSpread = (element, defaults) => defaults.has(/^\.\.\.\s*([A-Za-z_$][\w$]*)\.exclude$/.exec(element)?.[1] ?? "");
|
|
434
|
+
/** Why a literalList result is unreadable, for a setting shown as `name`. */
|
|
435
|
+
const unreadableList = (list, name) => list === "computed" ? `${name} is not a literal` : list === "escape" ? `${name} holds an escape doctor does not read` : undefined;
|
|
436
|
+
const NOT_ONE_OBJECT = "is not one object literal";
|
|
437
|
+
// D-2102 add.34: the modules whose defineConfig / defineProject return their one object argument unchanged.
|
|
438
|
+
const WRAPPER_MODULES = { vitest: ["vitest/config", "vitest", "vite"], jest: ["jest"] };
|
|
439
|
+
/**
|
|
440
|
+
* The offset of the object literal a value is COMPLETELY — itself, or the one argument of an identity wrapper
|
|
441
|
+
* call — at `start` in its text, or why it is not: `a && b`, a conditional, a trailing operator, and any call
|
|
442
|
+
* whose callee is not one of `wrappers` (a custom, local, re-exported or unresolved defineConfig included).
|
|
443
|
+
*/
|
|
444
|
+
function objectIn(value, start, wrappers) {
|
|
445
|
+
const call = /^([A-Za-z_$][\w$]*)\(\s*/.exec(value);
|
|
446
|
+
if (call && !wrappers.has(call[1]))
|
|
447
|
+
return `the call ${call[1]}(…) is not an identity wrapper imported from the runner`;
|
|
448
|
+
const lead = call?.[0].length ?? 0;
|
|
449
|
+
if (value[lead] !== "{")
|
|
450
|
+
return NOT_ONE_OBJECT;
|
|
451
|
+
const rest = value.slice(closing(value, lead)).trim();
|
|
452
|
+
return rest === (lead ? ")" : "") ? start + lead : NOT_ONE_OBJECT;
|
|
453
|
+
}
|
|
454
|
+
const ID = "[A-Za-z_$][\\w$]*";
|
|
455
|
+
// D-2102 add.37: the only value imports doctor reads — named bindings from the runner's own module (an import of
|
|
456
|
+
// any other module runs that module's code). `import type` binds no value and is checked by the real parse.
|
|
457
|
+
const NAMED_IMPORT = /^import\s+\{([^}]*)\}\s*from\s*(["'])([^"'\n]+)\2$/;
|
|
458
|
+
const REQUIRE = new RegExp(`^const\\s+\\{([^}]*)\\}\\s*=\\s*require\\(\\s*(["'])([^"'\\n]+)\\2\\s*\\)$`);
|
|
459
|
+
// The modules whose configDefaults is Vitest's own default data (D-2102 add.37 follow-up).
|
|
460
|
+
const DEFAULTS_MODULES = ["vitest/config", "vitest"];
|
|
461
|
+
// What any other code a config would run reads as (D-2102 add.37).
|
|
462
|
+
const RUNS = "it runs code doctor does not read";
|
|
463
|
+
function bindingsOf(statement, kind) {
|
|
464
|
+
const named = (list, rename, dataModule) => {
|
|
465
|
+
const out = { names: [], wrappers: [], defaults: [] };
|
|
466
|
+
const specs = list.split(",").map((x) => x.trim());
|
|
467
|
+
if (specs.length > 1 && specs[specs.length - 1] === "")
|
|
468
|
+
specs.pop(); // one trailing comma
|
|
469
|
+
for (const spec of specs) {
|
|
470
|
+
const m = rename.exec(spec);
|
|
471
|
+
if (!m)
|
|
472
|
+
return "invalid";
|
|
473
|
+
if (m[1])
|
|
474
|
+
continue; // a `type` specifier binds no value
|
|
475
|
+
const local = m[3] ?? m[2];
|
|
476
|
+
out.names.push(local);
|
|
477
|
+
if (m[2] === "defineConfig" || m[2] === "defineProject")
|
|
478
|
+
out.wrappers.push(local);
|
|
479
|
+
if (m[2] === "configDefaults" && dataModule)
|
|
480
|
+
out.defaults.push(local);
|
|
481
|
+
}
|
|
482
|
+
return out;
|
|
483
|
+
};
|
|
484
|
+
if (/^import(?=[\s{*'"])/.test(statement)) {
|
|
485
|
+
const m = NAMED_IMPORT.exec(statement);
|
|
486
|
+
if (!m || !WRAPPER_MODULES[kind].includes(m[3]))
|
|
487
|
+
return "runs";
|
|
488
|
+
return named(m[1], new RegExp(`^(type\\s+)?(${ID})(?:\\s+as\\s+(${ID}))?$`), DEFAULTS_MODULES.includes(m[3]));
|
|
489
|
+
}
|
|
490
|
+
const r = REQUIRE.exec(statement);
|
|
491
|
+
if (!r || !WRAPPER_MODULES[kind].includes(r[3]))
|
|
492
|
+
return undefined;
|
|
493
|
+
return named(r[1], new RegExp(`^()(${ID})(?:\\s*:\\s*(${ID}))?$`), DEFAULTS_MODULES.includes(r[3]));
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* D-2102 add.37: whether `value` is PURE DATA, read with a small strict grammar — string, number, boolean and
|
|
497
|
+
* null literals, a template with no `${`, `process.env.<NAME>`, an identifier naming an earlier pure-data
|
|
498
|
+
* const, arrays of pure data, and objects whose every member is `<plain identifier | string>: <pure data>`
|
|
499
|
+
* with each key once, at every level. Nothing else reads: no call, `new`, function or arrow, operator but a
|
|
500
|
+
* unary minus on a number, `this`, `arguments`, spread, computed key, getter, method, shorthand or `__proto__`.
|
|
501
|
+
* ponytail: the declared cost — a config that calls anything beyond the runner's own defineConfig or
|
|
502
|
+
* defineProject (a plugin, a helper, a ternary on the environment) warns, queue row 208.
|
|
503
|
+
*/
|
|
504
|
+
function pureData(value, consts, defaults = new Set()) {
|
|
505
|
+
let i = 0;
|
|
506
|
+
const ws = () => { while (i < value.length && /\s/.test(value[i]))
|
|
507
|
+
i++; };
|
|
508
|
+
const take = (re) => {
|
|
509
|
+
const m = re.exec(value.slice(i));
|
|
510
|
+
if (m)
|
|
511
|
+
i += m[0].length;
|
|
512
|
+
return m;
|
|
513
|
+
};
|
|
514
|
+
let path = [];
|
|
515
|
+
const list = (close, item) => {
|
|
516
|
+
i++;
|
|
517
|
+
ws();
|
|
518
|
+
if (value[i] === close) {
|
|
519
|
+
i++;
|
|
520
|
+
return true;
|
|
521
|
+
}
|
|
522
|
+
for (;;) {
|
|
523
|
+
if (!item())
|
|
524
|
+
return false;
|
|
525
|
+
ws();
|
|
526
|
+
if (value[i] === ",") {
|
|
527
|
+
i++;
|
|
528
|
+
ws();
|
|
529
|
+
if (value[i] === close) {
|
|
530
|
+
i++;
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
continue;
|
|
534
|
+
}
|
|
535
|
+
if (value[i] === close) {
|
|
536
|
+
i++;
|
|
537
|
+
return true;
|
|
538
|
+
}
|
|
539
|
+
return false;
|
|
540
|
+
}
|
|
541
|
+
};
|
|
542
|
+
const datum = () => {
|
|
543
|
+
ws();
|
|
544
|
+
const c = value[i];
|
|
545
|
+
if (c === '"' || c === "'")
|
|
546
|
+
return take(/^(["'])(?:\\[\s\S]|(?!\1)[^\\\n\r])*\1/) !== null;
|
|
547
|
+
if (c === "`")
|
|
548
|
+
return take(/^`(?:\\[\s\S]|[^\\`$]|\$(?!\{))*`/) !== null;
|
|
549
|
+
if (c === "[") {
|
|
550
|
+
// The one spread pure data admits: Vitest's own default excludes, as an element of test.exclude, from a
|
|
551
|
+
// configDefaults bound by a named import of "vitest/config" or "vitest" (the docs' own spelling).
|
|
552
|
+
const inExclude = path.join(".") === "test.exclude";
|
|
553
|
+
return list("]", () => {
|
|
554
|
+
ws();
|
|
555
|
+
const spread = inExclude ? /^\.\.\.\s*([A-Za-z_$][\w$]*)\.exclude(?![\w$.([])/.exec(value.slice(i)) : null;
|
|
556
|
+
if (spread && defaults.has(spread[1])) {
|
|
557
|
+
i += spread[0].length;
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
return datum();
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (c === "{") {
|
|
564
|
+
const seen = new Set();
|
|
565
|
+
return list("}", () => {
|
|
566
|
+
ws();
|
|
567
|
+
const k = take(/^(?:([A-Za-z_$][\w$]*)|(["'])((?:\\[\s\S]|(?!\2)[^\\\n\r])*)\2)\s*:/);
|
|
568
|
+
if (!k)
|
|
569
|
+
return false;
|
|
570
|
+
const key = k[1] ?? decodeString(k[3]);
|
|
571
|
+
if (key === undefined || key === "__proto__" || seen.has(key))
|
|
572
|
+
return false;
|
|
573
|
+
seen.add(key);
|
|
574
|
+
const outer = path;
|
|
575
|
+
path = [...outer, key];
|
|
576
|
+
const ok = datum();
|
|
577
|
+
path = outer;
|
|
578
|
+
return ok;
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
if (take(/^-?(?:0[xX][\da-fA-F](?:_?[\da-fA-F])*|0[oO][0-7](?:_?[0-7])*|0[bB][01](?:_?[01])*|(?:\d(?:_?\d)*)?\.?\d(?:_?\d)*(?:[eE][+-]?\d+)?)n?(?![\w$.])/))
|
|
582
|
+
return true;
|
|
583
|
+
const word = /^(?:process\.env\.[A-Za-z_$][\w$]*|[A-Za-z_$][\w$]*)(?![\w$.([`])/.exec(value.slice(i))?.[0];
|
|
584
|
+
if (word && (["true", "false", "null"].includes(word) || word.startsWith("process.env.") || consts.has(word))) {
|
|
585
|
+
i += word.length;
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
return false;
|
|
589
|
+
};
|
|
590
|
+
if (!datum())
|
|
591
|
+
return false;
|
|
592
|
+
ws();
|
|
593
|
+
return i === value.length;
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* The top-level statements of a (comment-blanked) module: each ends at a `;` or a line break outside
|
|
597
|
+
* strings and brackets, unless the line ends in an operator or the next one continues the expression.
|
|
598
|
+
*/
|
|
599
|
+
function topLevel(text) {
|
|
600
|
+
const out = [];
|
|
601
|
+
let [depth, start] = [0, -1];
|
|
602
|
+
const end = (at) => {
|
|
603
|
+
const raw = text.slice(start, at);
|
|
604
|
+
if (raw.trim())
|
|
605
|
+
out.push({ start: start + raw.length - raw.trimStart().length, text: raw.trim() });
|
|
606
|
+
start = -1;
|
|
607
|
+
};
|
|
608
|
+
for (let i = 0; i < text.length; i++) {
|
|
609
|
+
const c = text[i];
|
|
610
|
+
if (start < 0) {
|
|
611
|
+
if (/[\s;]/.test(c))
|
|
612
|
+
continue;
|
|
613
|
+
start = i;
|
|
614
|
+
}
|
|
615
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
616
|
+
for (i++; i < text.length && text[i] !== c; i++)
|
|
617
|
+
if (text[i] === "\\")
|
|
618
|
+
i++;
|
|
619
|
+
}
|
|
620
|
+
else if ("([{".includes(c))
|
|
621
|
+
depth++;
|
|
622
|
+
else if (")]}".includes(c))
|
|
623
|
+
depth--;
|
|
624
|
+
else if (depth === 0 && c === ";")
|
|
625
|
+
end(i);
|
|
626
|
+
else if (depth === 0 && LINE_END.test(c) && !/[-=+*/%&|^!?:,.<>([{]$/.test(text.slice(start, i).trimEnd())
|
|
627
|
+
&& !/^(?:[-.?:&|+*/%=,<>()[\]{}`]|as\b|satisfies\b|in\b|instanceof\b)/.test(text.slice(i + 1).trimStart()))
|
|
628
|
+
end(i);
|
|
629
|
+
}
|
|
630
|
+
if (start >= 0)
|
|
631
|
+
end(text.length);
|
|
632
|
+
return out;
|
|
633
|
+
}
|
|
634
|
+
function staticExport(text, json, kind) {
|
|
635
|
+
const wrappers = new Set();
|
|
636
|
+
const defaults = new Set();
|
|
637
|
+
const literal = (value, start) => {
|
|
638
|
+
const open = objectIn(value, start, wrappers);
|
|
639
|
+
return typeof open === "number" ? { open, wrappers, defaults } : open === NOT_ONE_OBJECT ? `the export ${NOT_ONE_OBJECT}` : open;
|
|
640
|
+
};
|
|
641
|
+
if (json) {
|
|
642
|
+
const open = objectIn(text.trim(), text.search(/\S/), wrappers);
|
|
643
|
+
return typeof open === "number" ? { open, wrappers, defaults } : `the config ${NOT_ONE_OBJECT}`;
|
|
644
|
+
}
|
|
645
|
+
// D-2102 add.35, a whitelist: the top level holds only imports in the strict grammar (type-only included),
|
|
646
|
+
// at most one `const { … } = require(<the runner's module>)`, `const <ident>[: T] = <expr>` lines, and EXACTLY
|
|
647
|
+
// ONE export of an object or an identity wrapper around one, directly or through one such const. Any other
|
|
648
|
+
// statement — let or var, a function or class, an expression, a second export, exports.default,
|
|
649
|
+
// `export { x as default }`, a reassignment — reads unverifiable.
|
|
650
|
+
const other = RUNS;
|
|
651
|
+
let exported;
|
|
652
|
+
const consts = new Map();
|
|
653
|
+
const bound = new Map(); // every value name a top-level statement binds, and how often
|
|
654
|
+
const values = []; // every expression a top-level statement evaluates
|
|
655
|
+
let requires = 0;
|
|
656
|
+
for (const { start, text: t } of topLevel(text)) {
|
|
657
|
+
const b = bindingsOf(t, kind);
|
|
658
|
+
if (b === "invalid")
|
|
659
|
+
return "invalid syntax";
|
|
660
|
+
if (b === "runs")
|
|
661
|
+
return RUNS;
|
|
662
|
+
if (b) {
|
|
663
|
+
for (const name of b.names)
|
|
664
|
+
bound.set(name, (bound.get(name) ?? 0) + 1);
|
|
665
|
+
for (const name of b.wrappers)
|
|
666
|
+
wrappers.add(name);
|
|
667
|
+
if (kind === "vitest")
|
|
668
|
+
for (const name of b.defaults)
|
|
669
|
+
defaults.add(name);
|
|
670
|
+
if (!/^import/.test(t)) {
|
|
671
|
+
if (++requires > 1)
|
|
672
|
+
return "the config requires the runner's module more than once";
|
|
673
|
+
values.push(t.slice(t.indexOf("=") + 1));
|
|
674
|
+
}
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
const exp = /^(?:export\s+default|(module)\.exports\s*=)\s*/.exec(t);
|
|
678
|
+
const decl = /^const\s+([A-Za-z_$][\w$]*)\s*=\s*/.exec(t);
|
|
679
|
+
if (exp) {
|
|
680
|
+
if (exported)
|
|
681
|
+
return "the config exports more than once";
|
|
682
|
+
exported = { start: start + exp[0].length, value: t.slice(exp[0].length), cjs: exp[1] !== undefined };
|
|
683
|
+
values.push(exported.value);
|
|
684
|
+
}
|
|
685
|
+
else if (decl) {
|
|
686
|
+
consts.set(decl[1], { start: start + decl[0].length, value: t.slice(decl[0].length) });
|
|
687
|
+
bound.set(decl[1], (bound.get(decl[1]) ?? 0) + 1);
|
|
688
|
+
values.push(t.slice(decl[0].length));
|
|
689
|
+
}
|
|
690
|
+
else
|
|
691
|
+
return other;
|
|
692
|
+
}
|
|
693
|
+
if (!exported)
|
|
694
|
+
return "the config has no export";
|
|
695
|
+
// Wrapper trust through require holds only for the unbound CommonJS global, so a file that binds require,
|
|
696
|
+
// module or exports itself — or binds any name twice — is unread.
|
|
697
|
+
const shadow = ["require", "module", "exports"].find((name) => bound.has(name));
|
|
698
|
+
if (shadow)
|
|
699
|
+
return `the config binds its own ${shadow}`;
|
|
700
|
+
const twice = [...bound].find(([, n]) => n > 1)?.[0];
|
|
701
|
+
if (twice)
|
|
702
|
+
return `the config binds ${twice} more than once`;
|
|
703
|
+
const id = /^[A-Za-z_$][\w$]*$/.exec(exported.value.trim())?.[0];
|
|
704
|
+
const value = id ? consts.get(id) : exported;
|
|
705
|
+
if (!value)
|
|
706
|
+
return `the export of ${id}`;
|
|
707
|
+
// Balanced brackets are not syntax: every evaluated expression must compile as JavaScript (compiled, never
|
|
708
|
+
// run). A TypeScript config arrives here with its types already erased to whitespace (D-2102 add.39, see
|
|
709
|
+
// realParse), so no TypeScript form is skipped or stripped by hand.
|
|
710
|
+
for (const v of values) {
|
|
711
|
+
try {
|
|
712
|
+
new Script(`(${v}\n)`);
|
|
713
|
+
}
|
|
714
|
+
catch {
|
|
715
|
+
return "invalid syntax";
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
// D-2102 add.37: the exported config is pure data — every const, and the export itself (or the one argument
|
|
719
|
+
// of an identity wrapper around it), read with the pure-data grammar; a const may name only earlier consts.
|
|
720
|
+
const earlier = new Set();
|
|
721
|
+
for (const [name, c] of consts) {
|
|
722
|
+
if (!pureData(c.value, earlier, name === id ? defaults : undefined))
|
|
723
|
+
return RUNS;
|
|
724
|
+
earlier.add(name);
|
|
725
|
+
}
|
|
726
|
+
const shown = exported.value;
|
|
727
|
+
const call = /^([A-Za-z_$][\w$]*)\(([\s\S]*)\)$/.exec(shown);
|
|
728
|
+
if (call && !wrappers.has(call[1]))
|
|
729
|
+
return `the call ${call[1]}(…) is not an identity wrapper imported from the runner`;
|
|
730
|
+
if (!pureData(call ? call[2] : shown, earlier, defaults))
|
|
731
|
+
return RUNS;
|
|
732
|
+
return literal(value.value, value.start);
|
|
733
|
+
}
|
|
734
|
+
// D-2102 add.33: the jest ignore entries doctor judges, by a whitelist — ANCHORED: one leading <rootDir>, then
|
|
735
|
+
// only [A-Za-z0-9_./-] or "\." and no ".." segment (so "<rootDir>/.next/" reads); LITERAL: no <rootDir>, only
|
|
736
|
+
// [A-Za-z0-9_/-] or "\." (a bare "." is a wildcard). Every other entry is opaque and leaves the config unread.
|
|
737
|
+
const ANCHORED_ENTRY = /^<rootDir>(?:[A-Za-z0-9_./-]|\\\.)*$/;
|
|
738
|
+
const LITERAL_ENTRY = /^(?:[A-Za-z0-9_/-]|\\\.)*$/;
|
|
739
|
+
const judged = (entry) => (ANCHORED_ENTRY.test(entry) && !/(?:^|\/)\.\.(?:\/|$)/.test(entry.slice("<rootDir>".length))) || LITERAL_ENTRY.test(entry);
|
|
740
|
+
// The exact unanchored spellings the SentioQ run-2 shape used (Q130s), which --fix replaces with the canonical twin.
|
|
741
|
+
const POISON = { state: /^\/?\\?\.tickmarkr\/?$/, session: /^\/?\\?\.claude\/worktrees\/?$/ };
|
|
742
|
+
const WORKTREE_DIRS = { state: ".tickmarkr/worktrees.noindex", session: ".claude/worktrees" };
|
|
743
|
+
const PROBE = "tkr-doctor-probe";
|
|
744
|
+
/** Jest's reading of an ignore entry under `rootDir`: a leading <rootDir> resolved as jest-config does, then a RegExp. */
|
|
745
|
+
const jestIgnore = (entry, rootDir) => new RegExp(entry.startsWith("<rootDir>") ? resolve(rootDir, normalize(`./${entry.slice("<rootDir>".length)}`)) : entry);
|
|
746
|
+
/**
|
|
747
|
+
* D-2102 add.33: the first ignore entry that SELF-MATCHES (Q130s), found by executing it as jest reads it — it
|
|
748
|
+
* matches a probe suite inside a run or session worktree, rooted there, but not the same suite in the main
|
|
749
|
+
* checkout at `root` (probed at src/ and, for a literal, along its own tail: add.34, see literalTails). It
|
|
750
|
+
* classifies the ignore list only; it never claims what the runner collects.
|
|
751
|
+
* ponytail: residual — a literal keyed on a worktree NAME that does not exist yet is not probed (queue row
|
|
752
|
+
* 208); the names probed are a fixed one plus every directory present under each worktree root.
|
|
753
|
+
*/
|
|
754
|
+
function selfMatching(entries, root) {
|
|
755
|
+
const file = `${PROBE}.test.ts`;
|
|
756
|
+
for (const pattern of entries) {
|
|
757
|
+
for (const dir of DIRS) {
|
|
758
|
+
if (POISON[dir].test(pattern))
|
|
759
|
+
return { pattern, dir };
|
|
760
|
+
const base = join(root, WORKTREE_DIRS[dir]);
|
|
761
|
+
let present = [];
|
|
762
|
+
try {
|
|
763
|
+
present = readdirSync(base, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
764
|
+
}
|
|
765
|
+
catch { /* none yet */ }
|
|
766
|
+
for (const name of [PROBE, ...present]) {
|
|
767
|
+
const tree = join(base, name);
|
|
768
|
+
try {
|
|
769
|
+
for (const rel of [`src/${file}`, ...literalTails(pattern, `${tree}/`, root, file)]) {
|
|
770
|
+
if (jestIgnore(pattern, tree).test(`${tree}/${rel}`) && !jestIgnore(pattern, root).test(`${root}/${rel}`))
|
|
771
|
+
return { pattern, dir };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
catch {
|
|
775
|
+
return { pattern, dir };
|
|
776
|
+
} // a root path that does not compile as a regex: judged self-matching
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return undefined;
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* D-2102 add.34: the probe paths a LITERAL entry's own tail builds — wherever the end of `tree` (with its "/")
|
|
784
|
+
* begins the literal and that occurrence reaches past `root`, the rest of the literal becomes the path below
|
|
785
|
+
* the tree, so an entry like "worktrees/feature-x/test/" is probed at feature-x/test/ and not only at src/.
|
|
786
|
+
*/
|
|
787
|
+
function literalTails(pattern, tree, root, file) {
|
|
788
|
+
if (pattern.startsWith("<rootDir>"))
|
|
789
|
+
return [];
|
|
790
|
+
const literal = pattern.replace(/\\\./g, ".");
|
|
791
|
+
const out = [];
|
|
792
|
+
for (let i = 0; i < tree.length; i++) {
|
|
793
|
+
const tail = tree.slice(i);
|
|
794
|
+
if (literal.length > tail.length && literal.startsWith(tail) && i + literal.length > root.length)
|
|
795
|
+
out.push(`${literal.slice(tail.length)}${file}`);
|
|
796
|
+
}
|
|
797
|
+
return out;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* D-2102 add.22 and add.29: why the source is not one clean parse, else undefined — a "/" outside strings
|
|
801
|
+
* and comments (a regex literal or a division: doctor parses neither), an unclosed string or comment, a
|
|
802
|
+
* template with a substitution, or an unmatched or unclosed bracket. The end of the file never closes anything.
|
|
803
|
+
*/
|
|
804
|
+
function unclean(text) {
|
|
805
|
+
const stack = [];
|
|
806
|
+
for (let i = 0; i < text.length; i++) {
|
|
807
|
+
const c = text[i];
|
|
808
|
+
if (text.startsWith("//", i))
|
|
809
|
+
i = lineEnd(text, i);
|
|
810
|
+
else if (text.startsWith("/*", i)) {
|
|
811
|
+
const end = text.indexOf("*/", i + 2);
|
|
812
|
+
if (end < 0)
|
|
813
|
+
return "an unclosed comment";
|
|
814
|
+
i = end + 1;
|
|
815
|
+
}
|
|
816
|
+
else if (c === "/")
|
|
817
|
+
return "a / outside strings and comments (a regex literal or a division)";
|
|
818
|
+
else if (c === "\\")
|
|
819
|
+
return RUNS; // D-2102 add.37: an escaped identifier spells a name doctor would not see
|
|
820
|
+
else if (c === '"' || c === "'" || c === "`") {
|
|
821
|
+
let j = i + 1;
|
|
822
|
+
for (; j < text.length && text[j] !== c; j++) {
|
|
823
|
+
if (text[j] === "\\")
|
|
824
|
+
j++;
|
|
825
|
+
else if (c !== "`" && LINE_END.test(text[j]))
|
|
826
|
+
return "an unclosed string";
|
|
827
|
+
else if (c === "`" && text.startsWith("${", j))
|
|
828
|
+
return "a template literal with a substitution";
|
|
829
|
+
}
|
|
830
|
+
if (j >= text.length)
|
|
831
|
+
return "an unclosed string";
|
|
832
|
+
i = j;
|
|
833
|
+
}
|
|
834
|
+
else if ("([{".includes(c))
|
|
835
|
+
stack.push(c);
|
|
836
|
+
else if (")]}".includes(c) && stack.pop() !== "([{"[")]}".indexOf(c)])
|
|
837
|
+
return "an unmatched closing bracket";
|
|
838
|
+
}
|
|
839
|
+
return stack.length ? "an unclosed bracket" : undefined;
|
|
840
|
+
}
|
|
841
|
+
// D-2102 add.37 and add.39: the one child that reads the source on stdin, erases TypeScript types (when this Node
|
|
842
|
+
// has stripTypeScriptTypes; its default strip mode replaces each type with whitespace, so offsets stay put) and
|
|
843
|
+
// parses the result as a module (vm.SourceTextModule, never linked or evaluated), then as a script (vm.Script,
|
|
844
|
+
// never run), reporting {"ok":true,"text":<stripped>} or {"ok":false,"why":"no-strip"|"syntax"} on stdout. It
|
|
845
|
+
// runs with --no-warnings, so 22.x's ExperimentalWarning never reaches doctor's output; no temp file is written.
|
|
846
|
+
const PARSE_CHILD = `(() => {
|
|
847
|
+
const fs = require("node:fs"), vm = require("node:vm"), mod = require("node:module");
|
|
848
|
+
const [ext, strip] = process.argv.slice(1);
|
|
849
|
+
const out = (r) => process.stdout.write(JSON.stringify(r));
|
|
850
|
+
let code = fs.readFileSync(0, "utf8");
|
|
851
|
+
if (/^\\.(?:m|c)?ts$/.test(ext)) {
|
|
852
|
+
if (strip === "absent" || typeof mod.stripTypeScriptTypes !== "function") return out({ ok: false, why: "no-strip" });
|
|
853
|
+
try { code = mod.stripTypeScriptTypes(code); } catch { return out({ ok: false, why: "syntax" }); }
|
|
854
|
+
}
|
|
855
|
+
let parsed = false;
|
|
856
|
+
if (typeof vm.SourceTextModule === "function") {
|
|
857
|
+
try { new vm.SourceTextModule(code); parsed = true; } catch {}
|
|
858
|
+
}
|
|
859
|
+
if (!parsed) {
|
|
860
|
+
try { new vm.Script(code); parsed = true; } catch {}
|
|
861
|
+
}
|
|
862
|
+
out(parsed ? { ok: true, text: code } : { ok: false, why: "syntax" });
|
|
863
|
+
})()`;
|
|
864
|
+
// ponytail: verdicts are memoized by exact bytes (and extension and seam), so one doctor run's reads before and
|
|
865
|
+
// after a repair, and its re-reads of unchanged bytes, parse once; the memo is dropped past 64 entries.
|
|
866
|
+
const parsed = new Map();
|
|
867
|
+
/**
|
|
868
|
+
* D-2102 add.37 and add.39: the config's text with its TypeScript types erased (offsets unchanged), once a real
|
|
869
|
+
* parser accepts it — or why not. The source reaches PARSE_CHILD on stdin through process.execPath with no shell
|
|
870
|
+
* and no temp file. A spawn error, a timeout, a non-zero exit, a missing or malformed answer, or erased text of
|
|
871
|
+
* another length reads "doctor could not check <file>'s syntax"; a TypeScript config on a Node without
|
|
872
|
+
* stripTypeScriptTypes (below 22.13) reads unverifiable; nothing here throws.
|
|
873
|
+
*/
|
|
874
|
+
export function realParse(source, configPath, seam = {}) {
|
|
875
|
+
const ext = extname(configPath) || ".js";
|
|
876
|
+
const key = `${seam.strip ?? ""}\0${seam.timeoutMs ?? ""}\0${ext}\0${source}`;
|
|
877
|
+
const hit = parsed.get(key);
|
|
878
|
+
if (hit)
|
|
879
|
+
return hit;
|
|
880
|
+
const unchecked = { why: `doctor could not check ${configPath}'s syntax` };
|
|
881
|
+
let result = unchecked;
|
|
882
|
+
try {
|
|
883
|
+
const r = spawnSync(process.execPath, ["--no-warnings", "--experimental-vm-modules", "-e", PARSE_CHILD, ext, seam.strip ?? "auto"], {
|
|
884
|
+
input: source, encoding: "utf8", env: {}, timeout: seam.timeoutMs ?? 10_000, maxBuffer: 4 * (source.length + 1024 * 1024), shell: false,
|
|
885
|
+
});
|
|
886
|
+
let verdict;
|
|
887
|
+
try {
|
|
888
|
+
verdict = JSON.parse(r.stdout ?? "");
|
|
889
|
+
}
|
|
890
|
+
catch {
|
|
891
|
+
verdict = undefined;
|
|
892
|
+
}
|
|
893
|
+
if (r.error || r.status !== 0 || !verdict)
|
|
894
|
+
result = unchecked;
|
|
895
|
+
else if (verdict.ok === true && typeof verdict.text === "string")
|
|
896
|
+
result = verdict.text.length === source.length ? { text: verdict.text } : unchecked;
|
|
897
|
+
else if (verdict.why === "no-strip")
|
|
898
|
+
result = { why: "this Node has no stripTypeScriptTypes to read a TypeScript config" };
|
|
899
|
+
else if (verdict.why === "syntax")
|
|
900
|
+
result = { why: "invalid syntax" };
|
|
901
|
+
}
|
|
902
|
+
catch {
|
|
903
|
+
result = unchecked;
|
|
904
|
+
}
|
|
905
|
+
if (parsed.size >= 64)
|
|
906
|
+
parsed.clear();
|
|
907
|
+
parsed.set(key, result);
|
|
908
|
+
return result;
|
|
909
|
+
}
|
|
910
|
+
// Out of claim (D-2102 add.30, add.35, queue row 207): a Vite plugin's config hook and a jest preset change what
|
|
911
|
+
// the runner resolves, not the config's own literal list, so this reader neither follows nor warns on them.
|
|
912
|
+
function ignoreList(cwd, runner) {
|
|
216
913
|
const isJest = runner.kind === "jest";
|
|
217
|
-
const jestConfig = isJest ? runner.configPath : undefined;
|
|
218
|
-
const vitestConfig = isJest ? undefined : runner.configPath;
|
|
219
914
|
let pkg = {};
|
|
220
915
|
try {
|
|
221
|
-
pkg = JSON.parse(
|
|
916
|
+
pkg = JSON.parse(readText(cwd, "package.json").text || "{}");
|
|
222
917
|
}
|
|
223
|
-
catch { /* judged
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
//
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if (
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
};
|
|
918
|
+
catch { /* judged by detectRunner */ }
|
|
919
|
+
const config = runner.configPath ? readText(cwd, runner.configPath) : { text: isJest && pkg.jest ? JSON.stringify(pkg.jest) : "" };
|
|
920
|
+
const source = config.text;
|
|
921
|
+
const json = !runner.configPath || runner.configPath.endsWith(".json");
|
|
922
|
+
const base = { file: runner.configPath ?? (isJest ? "the package.json jest block" : "a vitest config"), key: CANONICAL[runner.kind].ignore, json };
|
|
923
|
+
const unreadable = (why) => ({ ...base, unreadable: why });
|
|
924
|
+
if (config.error)
|
|
925
|
+
return unreadable(config.error);
|
|
926
|
+
// D-2102 add.22: another file the runner may read instead of, or beside, this one.
|
|
927
|
+
const present = (stem) => ["ts", "mts", "cts", "js", "mjs", "cjs", "json"].map((e) => `${stem}.${e}`).filter((f) => existsSync(join(cwd, f)));
|
|
928
|
+
const external = isJest ? [] : [...present("vitest.workspace"), ...present("vitest.projects")];
|
|
929
|
+
if (external.length)
|
|
930
|
+
return unreadable(`${external[0]} declares projects beside it`);
|
|
931
|
+
const configs = isJest ? [...present("jest.config"), ...(pkg.jest !== undefined ? ["the package.json jest block"] : [])] : present("vitest.config");
|
|
932
|
+
if (configs.length > 1)
|
|
933
|
+
return unreadable(`${configs.join(" and ")} both configure ${runner.kind}`);
|
|
934
|
+
if (!source.trim())
|
|
935
|
+
return base; // no config: the runner's default list
|
|
936
|
+
// D-2102 add.32: a JSON config is read only once JSON.parse accepts it — jest reads it with JSON.parse.
|
|
937
|
+
if (json) {
|
|
938
|
+
try {
|
|
939
|
+
JSON.parse(source);
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
return unreadable("invalid JSON");
|
|
249
943
|
}
|
|
250
|
-
return { verdict: "pass", detail: `${isJest ? "jest" : "vitest"} config ignores .tickmarkr/ — run worktrees stay out of the suite` };
|
|
251
944
|
}
|
|
252
|
-
//
|
|
253
|
-
//
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
945
|
+
// D-2102 add.39: a TypeScript config is read only through its type-erased text (same offsets, so the writer
|
|
946
|
+
// edits the original bytes in place); a JavaScript one is read as written and parsed for real last.
|
|
947
|
+
const path = runner.configPath ?? "config.js";
|
|
948
|
+
const ts = /\.(?:m|c)?ts$/.test(path);
|
|
949
|
+
const erased = ts ? realParse(source, path) : undefined;
|
|
950
|
+
if (erased && "why" in erased)
|
|
951
|
+
return unreadable(erased.why);
|
|
952
|
+
const plain = erased?.text ?? source;
|
|
953
|
+
const dirty = unclean(plain);
|
|
954
|
+
if (dirty)
|
|
955
|
+
return unreadable(dirty);
|
|
956
|
+
const text = blankComments(plain);
|
|
957
|
+
const exported = staticExport(text, json, runner.kind);
|
|
958
|
+
if (typeof exported === "string")
|
|
959
|
+
return unreadable(exported);
|
|
960
|
+
if (!json && !ts) {
|
|
961
|
+
const parse = realParse(source, path);
|
|
962
|
+
if ("why" in parse)
|
|
963
|
+
return unreadable(parse.why);
|
|
257
964
|
}
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
965
|
+
const top = exported.open;
|
|
966
|
+
// Projects carry their own ignore lists, which the config's own list does not speak for.
|
|
967
|
+
const read = (open, keys, what, key, form) => {
|
|
968
|
+
const s = settingsOf(text, open, keys, what);
|
|
969
|
+
if (s.computed)
|
|
970
|
+
return unreadable(s.computed);
|
|
971
|
+
if (s.get("projects") ?? s.get("workspace"))
|
|
972
|
+
return unreadable("it declares projects, each with its own ignore list");
|
|
973
|
+
const target = s.get(key);
|
|
974
|
+
const entries = literalList(target?.value, form, isJest ? undefined : exported.defaults);
|
|
975
|
+
if (typeof entries === "string")
|
|
976
|
+
return unreadable(unreadableList(entries, base.key) ?? entries);
|
|
977
|
+
if (!isJest)
|
|
978
|
+
return { ...base, entries, target, top, open, defaults: exported.defaults };
|
|
979
|
+
// D-2102 add.32 and add.33: an entry outside the whitelist can spell a worktree directory without its
|
|
980
|
+
// letters in a row (a class, a group, a bare-dot wildcard), so a config holding one is unread while it
|
|
981
|
+
// stays; the canonical entries are anchored. The exact SentioQ spellings stay judged, so --fix replaces them.
|
|
982
|
+
const opaque = (entries ?? []).find((e) => !judged(e) && !POISON.state.test(e) && !POISON.session.test(e));
|
|
983
|
+
if (opaque !== undefined)
|
|
984
|
+
return unreadable(`testPathIgnorePatterns entry ${JSON.stringify(opaque)} holds regex syntax doctor does not read`);
|
|
985
|
+
// D-2102 add.33: a rootDir other than the config's own directory re-roots <rootDir>, canonical entries included.
|
|
986
|
+
const rootDir = literalList(s.get("rootDir")?.value);
|
|
987
|
+
if (typeof rootDir === "string" || (rootDir && (rootDir.length !== 1 || !/^(?:\.\/?|<rootDir>\/?)$/.test(rootDir[0])))) {
|
|
988
|
+
return unreadable(typeof rootDir === "string" ? "rootDir is not a literal, so doctor cannot tell whether it re-roots <rootDir>"
|
|
989
|
+
: `rootDir ${JSON.stringify(rootDir.join(", "))} re-roots <rootDir> away from ${base.file}'s own directory`);
|
|
990
|
+
}
|
|
991
|
+
let root = cwd;
|
|
992
|
+
try {
|
|
993
|
+
root = realpathSync(cwd);
|
|
994
|
+
}
|
|
995
|
+
catch { /* judged from cwd as given */ }
|
|
996
|
+
return { ...base, entries, target, top, open, floating: selfMatching(entries ?? [], root) };
|
|
264
997
|
};
|
|
998
|
+
if (isJest)
|
|
999
|
+
return read(top, ["testPathIgnorePatterns", "projects", "rootDir"], "the config", "testPathIgnorePatterns", "regex");
|
|
1000
|
+
const outer = settingsOf(text, top, ["test"], "the config");
|
|
1001
|
+
if (outer.computed)
|
|
1002
|
+
return unreadable(outer.computed);
|
|
1003
|
+
const test = outer.get("test");
|
|
1004
|
+
if (!test)
|
|
1005
|
+
return { ...base, entries: undefined, top };
|
|
1006
|
+
const open = objectIn(test.value, test.start, exported.wrappers);
|
|
1007
|
+
if (typeof open === "string")
|
|
1008
|
+
return unreadable(open === NOT_ONE_OBJECT ? `test ${NOT_ONE_OBJECT}` : open);
|
|
1009
|
+
return { ...read(open, ["exclude", "projects", "workspace"], "test", "exclude", "path"), top };
|
|
1010
|
+
}
|
|
1011
|
+
export function runnerIgnoreFinding(cwd) {
|
|
1012
|
+
const runner = detectRunner(cwd);
|
|
1013
|
+
if (!runner)
|
|
1014
|
+
return undefined;
|
|
1015
|
+
const name = runner.kind;
|
|
1016
|
+
const quoted = (d) => JSON.stringify(CANONICAL[name][d]);
|
|
1017
|
+
const list = ignoreList(cwd, runner);
|
|
1018
|
+
const where = `${list.key} in ${list.file}`;
|
|
1019
|
+
if (list.unreadable) {
|
|
1020
|
+
return { verdict: "warn", detail: `doctor could not read ${list.file} (${list.unreadable}); add ${quoted("session")} to ${list.key}, with ${quoted("state")} beside it` };
|
|
1021
|
+
}
|
|
1022
|
+
// Q130s, and queue row 197 for the session worktree: a run started inside a worktree carries the
|
|
1023
|
+
// directories above the checkout in every absolute test path.
|
|
1024
|
+
const float = list.floating;
|
|
1025
|
+
if (float) {
|
|
1026
|
+
const what = float.dir === "state" ? ".tickmarkr/" : ".claude/worktrees/";
|
|
1027
|
+
return {
|
|
1028
|
+
verdict: "warn",
|
|
1029
|
+
detail: `jest ignore for ${what} is not <rootDir>-anchored: ${JSON.stringify(float.pattern)} — testPathIgnorePatterns are regexes against ABSOLUTE paths, so ${INSIDE[float.dir]} it matches the checkout's own suites and the test gate fails closed; use ${quoted("state")} and ${quoted("session")} in ${where}`,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const missing = DIRS.filter((d) => !(list.entries ?? []).includes(CANONICAL[name][d]));
|
|
1033
|
+
if (missing.length) {
|
|
1034
|
+
const [first, second] = missing.map(quoted);
|
|
1035
|
+
return {
|
|
1036
|
+
verdict: "warn",
|
|
1037
|
+
detail: `${where} does not carry ${missing.map(quoted).join(" or ")}, so ${name} may scan ${missing.map((d) => SCANS[d]).join(" and ")} (duplicate suites, false reds) — add ${first} to ${where}${second ? `, with ${second} beside it` : ""}`,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
return { verdict: "pass", detail: `${where} carries ${quoted("session")} beside ${quoted("state")} — doctor reads this literal list only and does not prove what ${name} collects` };
|
|
265
1041
|
}
|
|
266
1042
|
/**
|
|
267
1043
|
* Q134s (doctor --fix): the WRITE half of Q122s/Q130s. Doctor's advisory row cost two
|
|
268
1044
|
* repos a hand-edit (SentioQ run 2 paid a whole run first); --fix applies the ignore
|
|
269
1045
|
* where a safe textual edit exists and refuses loudly otherwise.
|
|
270
1046
|
*
|
|
271
|
-
* Safety contract:
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
1047
|
+
* Safety contract (queue row 197; D-2102 add.8 and add.30): the canonical entries the config's own literal
|
|
1048
|
+
* ignore list lacks go into that array as complete string-literal elements (never a coverage array or a
|
|
1049
|
+
* scalar), beside the state dir's canonical entry, else first; a jest element that is exactly an unanchored
|
|
1050
|
+
* entry for either directory (the SentioQ run-2 shape) is replaced by its canonical twin; a jest config with
|
|
1051
|
+
* no testPathIgnorePatterns gets the property, preserving jest's default /node_modules/ ignore, as before
|
|
1052
|
+
* this change (a JSON config with its key quoted), and a vitest config without test.exclude gets one that
|
|
1053
|
+
* keeps Vitest 3.2.7's default excludes ahead of the two entries — inside a new test object where it has
|
|
1054
|
+
* none (D-2102 add.31). A config doctor could not read or any other element is manual with the file
|
|
1055
|
+
* byte-unchanged. The write is verified by
|
|
1056
|
+
* re-running the same rule on the written file (and JSON.parse for a .json config); a write that does not
|
|
1057
|
+
* turn the row green is REVERTED byte-for-byte and reported as manual. Never touches package.json.
|
|
276
1058
|
*/
|
|
277
1059
|
export function applyRunnerIgnore(cwd) {
|
|
278
1060
|
const finding = runnerIgnoreFinding(cwd);
|
|
@@ -281,49 +1063,123 @@ export function applyRunnerIgnore(cwd) {
|
|
|
281
1063
|
if (finding.verdict === "pass")
|
|
282
1064
|
return { action: "none", detail: finding.detail };
|
|
283
1065
|
const runner = detectRunner(cwd);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1066
|
+
const manual = { action: "manual", detail: finding.detail };
|
|
1067
|
+
// package.json jest block, or a runner detected only via the test script: hand edit.
|
|
1068
|
+
if (!runner?.configPath)
|
|
1069
|
+
return manual;
|
|
1070
|
+
const list = ignoreList(cwd, runner);
|
|
1071
|
+
if (list.unreadable)
|
|
1072
|
+
return manual;
|
|
1073
|
+
const kind = runner.kind;
|
|
1074
|
+
const canon = CANONICAL[kind];
|
|
288
1075
|
const file = join(cwd, runner.configPath);
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
1076
|
+
const read = readText(cwd, runner.configPath);
|
|
1077
|
+
if (read.error)
|
|
1078
|
+
return manual;
|
|
1079
|
+
const original = read.text;
|
|
1080
|
+
const edits = [];
|
|
1081
|
+
const entries = (...values) => `[${values.map((v) => JSON.stringify(v)).join(", ")}]`;
|
|
1082
|
+
/** Insert `member` first in the object literal opened at `open`, in the object's own layout. */
|
|
1083
|
+
const addMember = (open, member) => {
|
|
1084
|
+
const lead = /^\s*/.exec(original.slice(open + 1))?.[0] ?? "";
|
|
1085
|
+
const empty = original[open + 1 + lead.length] === "}";
|
|
1086
|
+
const gap = /[\n\r]/.test(lead) ? lead.replace(/^[^\n\r]*/, "") : " ";
|
|
1087
|
+
edits.push({ at: open + 1, remove: 0, insert: `${gap}${member}${empty ? "" : ","}` });
|
|
1088
|
+
};
|
|
1089
|
+
const target = list.target;
|
|
1090
|
+
if (target) {
|
|
1091
|
+
const elements = arrayElements(target.value);
|
|
1092
|
+
if (!elements?.every((e) => STRING_LITERAL.test(e.text) || defaultsSpread(e.text, list.defaults ?? new Set())))
|
|
1093
|
+
return manual;
|
|
1094
|
+
const at = (i) => target.start + elements[i].start;
|
|
1095
|
+
const values = elements.map((e) => {
|
|
1096
|
+
const value = STRING_LITERAL.test(e.text) ? decodeString(e.text.slice(1, -1)) : undefined;
|
|
1097
|
+
if (kind !== "jest" || value === undefined)
|
|
1098
|
+
return value;
|
|
1099
|
+
return POISON.state.test(value) ? canon.state : POISON.session.test(value) ? canon.session : value;
|
|
1100
|
+
});
|
|
1101
|
+
values.forEach((v, i) => {
|
|
1102
|
+
if (v !== undefined && v !== decodeString(elements[i].text.slice(1, -1)))
|
|
1103
|
+
edits.push({ at: at(i), remove: elements[i].text.length, insert: JSON.stringify(v) });
|
|
1104
|
+
});
|
|
1105
|
+
const add = DIRS.filter((d) => !values.includes(canon[d])).map((d) => JSON.stringify(canon[d])).join(", ");
|
|
1106
|
+
const state = values.indexOf(canon.state);
|
|
1107
|
+
if (add) {
|
|
1108
|
+
edits.push(state >= 0 ? { at: at(state) + elements[state].text.length, remove: 0, insert: `, ${add}` }
|
|
1109
|
+
: elements.length ? { at: at(0), remove: 0, insert: `${add}, ` }
|
|
1110
|
+
: { at: target.start + 1, remove: 0, insert: add });
|
|
309
1111
|
}
|
|
310
1112
|
}
|
|
311
|
-
else if (
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
1113
|
+
else if (kind === "jest" && list.top !== undefined) {
|
|
1114
|
+
// No ignore property: add it whole, preserving jest's default /node_modules/ ignore (the 2.8.4 repair;
|
|
1115
|
+
// a JSON config gets the quoted key, D-2102 add.31).
|
|
1116
|
+
addMember(list.top, `${list.json ? '"testPathIgnorePatterns"' : "testPathIgnorePatterns"}: ${entries("/node_modules/", canon.state, canon.session)}`);
|
|
1117
|
+
}
|
|
1118
|
+
else if (kind === "vitest" && list.top !== undefined) {
|
|
1119
|
+
// No test.exclude (D-2102 add.31): add it, keeping Vitest's own defaults ahead of the two entries; with
|
|
1120
|
+
// no test object at all, add that.
|
|
1121
|
+
const exclude = `exclude: ${entries(...VITEST_DEFAULT_EXCLUDE, canon.state, canon.session)}`;
|
|
1122
|
+
if (list.open !== undefined)
|
|
1123
|
+
addMember(list.open, exclude);
|
|
1124
|
+
else
|
|
1125
|
+
addMember(list.top, `test: { ${exclude} }`);
|
|
1126
|
+
}
|
|
1127
|
+
if (!edits.length)
|
|
1128
|
+
return manual; // no safe target, or a self-match no canonical entry can fix
|
|
1129
|
+
// Last offset first; at one offset the replacement precedes the insertion before it.
|
|
1130
|
+
const text = [...edits].sort((a, b) => b.at - a.at || b.remove - a.remove)
|
|
1131
|
+
.reduce((t, e) => t.slice(0, e.at) + e.insert + t.slice(e.at + e.remove), original);
|
|
1132
|
+
let after;
|
|
1133
|
+
try {
|
|
1134
|
+
writeFileSync(file, text);
|
|
1135
|
+
if (runner.configPath.endsWith(".json"))
|
|
1136
|
+
JSON.parse(text);
|
|
1137
|
+
after = runnerIgnoreFinding(cwd);
|
|
1138
|
+
}
|
|
1139
|
+
catch {
|
|
1140
|
+
after = undefined;
|
|
317
1141
|
}
|
|
318
|
-
if (
|
|
319
|
-
return { action: "manual", detail: finding.detail };
|
|
320
|
-
writeFileSync(file, text);
|
|
321
|
-
const after = runnerIgnoreFinding(cwd);
|
|
322
|
-
if (after?.verdict === "pass") {
|
|
1142
|
+
if (after?.verdict === "pass")
|
|
323
1143
|
return { action: "wrote", detail: `wrote ${runner.configPath} — ${after.detail}` };
|
|
1144
|
+
try {
|
|
1145
|
+
writeFileSync(file, original);
|
|
1146
|
+
}
|
|
1147
|
+
catch { /* the write itself failed, so the file is unchanged */ } // verify failed: never leave a half-fix behind
|
|
1148
|
+
return manual;
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Queue row 196 (D-2102 add.1): the host decides where seats run. herdr or Orca markers (classifyHost)
|
|
1152
|
+
* name that driver; with neither, seats run headless on the subprocess driver with no daemon board.
|
|
1153
|
+
* Only the exact claude-desktop entry point names Claude Desktop — a cli entry point is a terminal seat
|
|
1154
|
+
* and no Claude variable alone is a host. Panes configured where no pane host exists cannot be honored.
|
|
1155
|
+
*/
|
|
1156
|
+
export function hostRows(env, llm) {
|
|
1157
|
+
const host = classifyHost(env);
|
|
1158
|
+
if (host !== "none") {
|
|
1159
|
+
return [{ verdict: "pass", key: "host", detail: `${host === "herdr" ? "herdr" : "Orca"} — auto selects the ${host} driver` }];
|
|
324
1160
|
}
|
|
325
|
-
|
|
326
|
-
return
|
|
1161
|
+
const name = env.CLAUDE_CODE_ENTRYPOINT === "claude-desktop" ? "Claude Desktop" : "no herdr or Orca host";
|
|
1162
|
+
return [
|
|
1163
|
+
{ verdict: "pass", key: "host", detail: `${name} — seats run headless on the subprocess driver with no daemon board` },
|
|
1164
|
+
...(llm === "pane"
|
|
1165
|
+
? [{ verdict: "warn", key: "visibility", detail: "visibility.llm is pane, but panes cannot be honored on this host — run under herdr or Orca, or set visibility.llm: headless" }]
|
|
1166
|
+
: []),
|
|
1167
|
+
];
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Queue row 196 (D-2102 add.1): gates inherit the launching locale. The effective value is LC_ALL, else
|
|
1171
|
+
* LC_CTYPE, else LANG (an empty value counts as unset). Unset, C or POSIX is the C-locale fallback;
|
|
1172
|
+
* C.UTF-8 is a UTF-8 locale and passes. Advisory only — doctor never sets or writes a locale.
|
|
1173
|
+
*/
|
|
1174
|
+
export function localeFinding(env) {
|
|
1175
|
+
const name = ["LC_ALL", "LC_CTYPE", "LANG"].find((key) => env[key]);
|
|
1176
|
+
const value = name ? env[name] : undefined;
|
|
1177
|
+
if (name && value !== "C" && value !== "POSIX")
|
|
1178
|
+
return { verdict: "pass", detail: `${name}=${value}` };
|
|
1179
|
+
return {
|
|
1180
|
+
verdict: "warn",
|
|
1181
|
+
detail: `${name ? `${name}=${value}` : "no locale set (LC_ALL, LC_CTYPE and LANG unset)"} — gates inherit the C-locale fallback (Node formats as en-US); fix: set LANG in your shell profile, e.g. export LANG=en_US.UTF-8`,
|
|
1182
|
+
};
|
|
327
1183
|
}
|
|
328
1184
|
/**
|
|
329
1185
|
* Q140s(b) / operator directive (D-OBS-10): the repo's package manager must resolve in
|
|
@@ -469,9 +1325,10 @@ const RUNNER_CONFIG_PATHS = [
|
|
|
469
1325
|
"vitest.config.cts",
|
|
470
1326
|
];
|
|
471
1327
|
function runnerConfigSnapshot(cwd) {
|
|
1328
|
+
// An unreadable config is not snapshotted: the writer never edits a file it could not read (D-2102 add.31).
|
|
472
1329
|
return new Map(RUNNER_CONFIG_PATHS.flatMap((path) => {
|
|
473
|
-
const
|
|
474
|
-
return existsSync(
|
|
1330
|
+
const read = readText(cwd, path);
|
|
1331
|
+
return existsSync(join(cwd, path)) && !read.error ? [[path, read.text]] : [];
|
|
475
1332
|
}));
|
|
476
1333
|
}
|
|
477
1334
|
function actualTextDiff(path, before, after) {
|
|
@@ -502,8 +1359,8 @@ export function doctorFixOnly(cwd = process.cwd()) {
|
|
|
502
1359
|
: { action: "none", detail: finding?.detail ?? "no recognized test runner" };
|
|
503
1360
|
const verified = runnerIgnoreFinding(cwd);
|
|
504
1361
|
const diffs = [...before].flatMap(([path, original]) => {
|
|
505
|
-
const current =
|
|
506
|
-
return current === original ? [] : [actualTextDiff(path, original, current)];
|
|
1362
|
+
const current = readText(cwd, path);
|
|
1363
|
+
return current.error || current.text === original ? [] : [actualTextDiff(path, original, current.text)];
|
|
507
1364
|
});
|
|
508
1365
|
const result = repair.action === "wrote" && verified?.verdict !== "pass"
|
|
509
1366
|
? "invalid repair result (verification did not pass)"
|
|
@@ -737,6 +1594,11 @@ export async function doctor(_argv, cwd = process.cwd(), adapters = allAdapters(
|
|
|
737
1594
|
rows.push(...detectCandidateClis({ cwd }).map(({ binary, path }) => alignedStatusRow("warn", binary, `detected at ${path} (no drive contract — not routable)`)));
|
|
738
1595
|
const herdr = HerdrDriver.available();
|
|
739
1596
|
rows.push(alignedStatusRow(herdr ? "pass" : "fail", "herdr", herdr ? "driver available (HERDR_ENV=1)" : "not detected — subprocess driver will be used"));
|
|
1597
|
+
// Queue rows 196 and 197 (D-2102 add.1): where seats run, and the locale every gate inherits.
|
|
1598
|
+
for (const row of hostRows(process.env, cfg.visibility.llm))
|
|
1599
|
+
rows.push(alignedStatusRow(row.verdict, row.key, row.detail));
|
|
1600
|
+
const locale = localeFinding(process.env);
|
|
1601
|
+
rows.push(alignedStatusRow(locale.verdict, "locale", locale.detail));
|
|
740
1602
|
// Q122s: the target repo's runner must not scan tickmarkr's own worktrees (advisory row).
|
|
741
1603
|
// Q134s: `doctor --fix` writes the ignore when a safe edit exists (verified, else reverted).
|
|
742
1604
|
const runnerIgnore = runnerIgnoreFinding(cwd);
|