do-better 1.0.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/src/utils.js ADDED
@@ -0,0 +1,449 @@
1
+ // src/utils.js — shared utilities: argv parsing, logging, error classes,
2
+ // git/exec helpers, guards. Zero runtime dependencies (node builtins only).
3
+
4
+ import { spawnSync } from "node:child_process";
5
+ import { createHash } from "node:crypto";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Colors + logging (skill-mining style: raw ANSI fns, disabled when not a TTY)
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const colorEnabled = !process.env.NO_COLOR && process.stdout.isTTY === true;
15
+ const wrap = (open, close) => (s) =>
16
+ colorEnabled ? `[${open}m${s}[${close}m` : String(s);
17
+
18
+ export const colors = {
19
+ bold: wrap(1, 22),
20
+ dim: wrap(2, 22),
21
+ red: wrap(31, 39),
22
+ green: wrap(32, 39),
23
+ yellow: wrap(33, 39),
24
+ blue: wrap(34, 39),
25
+ cyan: wrap(36, 39),
26
+ magenta: wrap(35, 39),
27
+ };
28
+
29
+ export const log = {
30
+ info: (m) => console.log(`${colors.blue("ℹ")} ${m}`),
31
+ success: (m) => console.log(`${colors.green("✔")} ${m}`),
32
+ warn: (m) => console.error(`${colors.yellow("⚠")} ${m}`),
33
+ error: (m) => console.error(`${colors.red("✖")} ${m}`),
34
+ phase: (id, name) => console.log(colors.bold(colors.cyan(`\n=== ${id}: ${name} ===`))),
35
+ gate: (name, human) =>
36
+ console.log(colors.bold(colors.magenta(`\n=== ⟂ Gate: ${name}${human ? " (HUMAN)" : ""} ===`))),
37
+ step: (m) => console.log(` ${m}`),
38
+ substep: (m) => console.log(colors.dim(` ${m}`)),
39
+ errorTrace: (err) => console.error(colors.dim(String((err && err.stack) || err))),
40
+ };
41
+
42
+ // Under --json (H17), decorative progress must NOT pollute stdout — stdout has
43
+ // to carry ONLY the final JSON object so a CI wrapper can parse the whole
44
+ // stream. This variant routes every human-facing line (including the ones phase
45
+ // modules emit via ctx.log: phase/step/substep/success/info) to STDERR; warn
46
+ // and error already go to stderr. Callers pass this as ctx.log when flags.json.
47
+ export const stderrLog = {
48
+ info: (m) => console.error(`${colors.blue("ℹ")} ${m}`),
49
+ success: (m) => console.error(`${colors.green("✔")} ${m}`),
50
+ warn: log.warn,
51
+ error: log.error,
52
+ phase: (id, name) => console.error(colors.bold(colors.cyan(`\n=== ${id}: ${name} ===`))),
53
+ gate: (name, human) =>
54
+ console.error(colors.bold(colors.magenta(`\n=== ⟂ Gate: ${name}${human ? " (HUMAN)" : ""} ===`))),
55
+ step: (m) => console.error(` ${m}`),
56
+ substep: (m) => console.error(colors.dim(` ${m}`)),
57
+ errorTrace: log.errorTrace,
58
+ };
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // Error classes (exit-code contract: 0 success/human pause, 1 operational,
62
+ // 2 deterministic gate failure)
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export class OpError extends Error {
66
+ constructor(message) {
67
+ super(message);
68
+ this.name = "OpError";
69
+ this.exitCode = 1;
70
+ }
71
+ }
72
+
73
+ export class BudgetError extends OpError {
74
+ constructor(message) {
75
+ super(message);
76
+ this.name = "BudgetError";
77
+ }
78
+ }
79
+
80
+ export class OfflineError extends OpError {
81
+ constructor(message) {
82
+ super(message);
83
+ this.name = "OfflineError";
84
+ }
85
+ }
86
+
87
+ export class GateError extends Error {
88
+ constructor(gate, detail) {
89
+ super(`Gate failed: ${gate} — ${detail}`);
90
+ this.name = "GateError";
91
+ this.exitCode = 2;
92
+ this.gate = gate;
93
+ this.detail = detail;
94
+ }
95
+ }
96
+
97
+ // Single constructor for a gate failure (H15) — the ONE correct way to build a
98
+ // GateError, replacing five divergent per-phase copies that misused the
99
+ // `(gate, detail)` constructor (passing a pre-formatted `${gate}: ${detail}`
100
+ // string as the gate arg produced garbled "Gate failed: X: Y — Y" messages).
101
+ // Attaches `state` for the CLI to persist when supplied. Message is well-formed:
102
+ // "Gate failed: <gate> — <detail>".
103
+ export function gateError(gate, detail, state) {
104
+ const err = new GateError(gate, detail);
105
+ if (state !== undefined) err.state = state;
106
+ return err;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // Commands + fixed taxonomy floor (D5 — order is canonical)
111
+ // ---------------------------------------------------------------------------
112
+
113
+ export const COMMANDS = new Set(["scan", "charter", "audit", "roadmap", "rail", "run", "refresh"]);
114
+
115
+ export const TAXONOMY = [
116
+ { id: "correctness", label: "Correctness risk" },
117
+ { id: "security", label: "Security" },
118
+ { id: "maintainability", label: "Maintainability / debt" },
119
+ { id: "performance", label: "Performance" },
120
+ { id: "operability", label: "Operability" },
121
+ { id: "test-quality", label: "Test quality" },
122
+ { id: "dependency-health", label: "Dependency health" },
123
+ { id: "dx", label: "Developer experience" },
124
+ ];
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // Argv parsing — hand-rolled, supports `--flag value` and `--flag=value`;
128
+ // warns on unknown flags, never dies. First positional consumed as command
129
+ // iff ∈ COMMANDS; second positional → flags.target.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ const VALUE_FLAGS = new Map([
133
+ ["--provider", "provider"],
134
+ ["--budget", "budget"],
135
+ ["--target", "target"],
136
+ ["--model-cheap", "modelCheap"],
137
+ ["--model-mid", "modelMid"],
138
+ ["--model-frontier", "modelFrontier"],
139
+ ["--n", "n"],
140
+ ["--threshold", "threshold"],
141
+ ]);
142
+
143
+ const BOOL_FLAGS = new Map([
144
+ ["--offline", "offline"],
145
+ ["--approve", "approve"],
146
+ ["--yes", "yes"],
147
+ ["--json", "json"],
148
+ ["--help", "help"],
149
+ ["-h", "help"],
150
+ ]);
151
+
152
+ function coerceFlagValue(name, value) {
153
+ if (name === "--budget") {
154
+ const n = Number(value);
155
+ if (!Number.isFinite(n) || n <= 0) throw new OpError(`Invalid --budget: "${value}" (expected a positive number of USD)`);
156
+ return n;
157
+ }
158
+ if (name === "--n") {
159
+ const n = Number(value);
160
+ if (!Number.isInteger(n) || n < 1) throw new OpError(`Invalid --n: "${value}" (expected a positive integer)`);
161
+ return n;
162
+ }
163
+ if (name === "--threshold") {
164
+ const n = Number(value);
165
+ if (!Number.isFinite(n) || n < 0) throw new OpError(`Invalid --threshold: "${value}" (expected a non-negative number)`);
166
+ return n;
167
+ }
168
+ return value;
169
+ }
170
+
171
+ export function parseArgs(argv) {
172
+ const flags = {
173
+ command: null,
174
+ target: ".",
175
+ provider: null,
176
+ budget: null,
177
+ offline: false,
178
+ modelCheap: null,
179
+ modelMid: null,
180
+ modelFrontier: null,
181
+ n: null,
182
+ threshold: null,
183
+ approve: false,
184
+ yes: false,
185
+ json: false,
186
+ help: false,
187
+ };
188
+ const positionals = [];
189
+
190
+ for (let i = 0; i < argv.length; i++) {
191
+ const arg = argv[i];
192
+ if (!arg.startsWith("-")) {
193
+ positionals.push(arg);
194
+ continue;
195
+ }
196
+ let name = arg;
197
+ let value = null;
198
+ const eq = arg.indexOf("=");
199
+ if (eq !== -1) {
200
+ name = arg.slice(0, eq);
201
+ value = arg.slice(eq + 1);
202
+ }
203
+ if (BOOL_FLAGS.has(name)) {
204
+ flags[BOOL_FLAGS.get(name)] = true;
205
+ continue;
206
+ }
207
+ if (VALUE_FLAGS.has(name)) {
208
+ if (value === null) {
209
+ value = argv[i + 1];
210
+ if (value === undefined) throw new OpError(`Missing value for ${name}`);
211
+ i++;
212
+ }
213
+ flags[VALUE_FLAGS.get(name)] = coerceFlagValue(name, value);
214
+ continue;
215
+ }
216
+ log.warn(`Unknown flag ignored: ${arg}`);
217
+ }
218
+
219
+ if (positionals.length > 0) {
220
+ if (COMMANDS.has(positionals[0])) {
221
+ flags.command = positionals[0];
222
+ if (positionals[1] !== undefined) flags.target = positionals[1];
223
+ for (const extra of positionals.slice(2)) log.warn(`Extra positional ignored: ${extra}`);
224
+ } else {
225
+ flags.target = positionals[0];
226
+ for (const extra of positionals.slice(1)) log.warn(`Extra positional ignored: ${extra}`);
227
+ }
228
+ }
229
+
230
+ return flags;
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Help text
235
+ // ---------------------------------------------------------------------------
236
+
237
+ export const HELP_TEXT = `
238
+ ${colors.bold("do-better")} — brownfield codebase analysis → verified findings → technical roadmap
239
+
240
+ ${colors.bold("USAGE")}
241
+ do-better <command> [target] [flags]
242
+
243
+ ${colors.bold("COMMANDS")}
244
+ scan D-1 Cheap repo scan: facts, incantations, draft codemap
245
+ charter D0 Stakeholder interview → quality charter [HUMAN GATE 1]
246
+ audit D1+2 Comprehend (7 artifacts) + identify verified findings
247
+ roadmap D3 Score, sequence, phase → ROADMAP.md + backlog tickets [HUMAN GATE 2]
248
+ rail D4 Characterization rails + hollow-test audit
249
+ run Full pipeline; stops cleanly at human gates; resumable
250
+ refresh Idempotent re-run: stale-claim flagging, behavior diff
251
+
252
+ ${colors.bold("FLAGS")}
253
+ --provider <anthropic|gemini|openai|local> LLM provider (default: autodetect, anthropic-first;
254
+ local = OpenAI-compatible endpoint via DOBETTER_LOCAL_BASE_URL)
255
+ --budget <usd> Hard spend cap; resumable when exceeded
256
+ --offline No LLM calls; static analysis + structure-only artifacts
257
+ --model-cheap|--model-mid|--model-frontier <id> Per-tier model overrides
258
+ --target <dir> Target repo (or 2nd positional; default ".")
259
+ --approve Approve charter/roadmap (human gates)
260
+ --n <int> Parallax fan width override (audit)
261
+ --threshold <num> Divergence threshold override (audit)
262
+ --yes Skip confirmations
263
+ --json Machine summary on stdout
264
+ -h, --help Show this help
265
+
266
+ ${colors.bold("ENVIRONMENT")}
267
+ ANTHROPIC_API_KEY | GEMINI_API_KEY | OPENAI_API_KEY provider keys (autodetected in that order)
268
+ DOBETTER_LOCAL_BASE_URL OpenAI-compatible endpoint for --provider local (e.g. http://localhost:11434/v1)
269
+ DOBETTER_LOCAL_MODEL default model for --provider local (per-tier --model-* flags override)
270
+ DOBETTER_LOCAL_API_KEY optional bearer token for --provider local
271
+ DOBETTER_ADLC_DIR path to an aidlc checkout (parallax, coldstart, hollow-test, …)
272
+ DOBETTER_SKILL_MINING_DIR path to a skill-mining checkout
273
+ DOBETTER_ANSWERS path to JSON string[] of scripted charter answers
274
+ DOBETTER_FAKE_LLM path to a fake-LLM module (test seam; no network)
275
+ DOBETTER_DEBUG print stack traces on error
276
+
277
+ ${colors.bold("EXIT CODES")}
278
+ 0 success, or a clean human-gate pause with printed resume instructions
279
+ 1 operational error (bad input, missing provider, network, budget exceeded)
280
+ 2 deterministic gate failure (divergence, unverified findings, coldstart gaps,
281
+ rails red, hollow audit survivor)
282
+ `;
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Crypto / time
286
+ // ---------------------------------------------------------------------------
287
+
288
+ export function sha256Hex(text) {
289
+ return createHash("sha256").update(text).digest("hex");
290
+ }
291
+
292
+ export function nowIso() {
293
+ return new Date().toISOString();
294
+ }
295
+
296
+ // ---------------------------------------------------------------------------
297
+ // Guards (validate external input: argv, LLM-emitted paths/model names)
298
+ // ---------------------------------------------------------------------------
299
+
300
+ export function assertSafeModelName(name, flagLabel) {
301
+ if (typeof name !== "string" || !/^[\w.:/-]+$/.test(name)) {
302
+ throw new OpError(`Invalid model name for ${flagLabel}: ${JSON.stringify(name)} (allowed: letters, digits, ., :, /, -, _)`);
303
+ }
304
+ }
305
+
306
+ export function isSafeRelPath(p) {
307
+ if (typeof p !== "string" || p.length === 0) return false;
308
+ if (p.includes("\0")) return false;
309
+ if (path.isAbsolute(p) || p.startsWith("/") || p.startsWith("\\")) return false;
310
+ if (p.split(/[\\/]/).includes("..")) return false;
311
+ return true;
312
+ }
313
+
314
+ // ---------------------------------------------------------------------------
315
+ // Exec / git
316
+ // ---------------------------------------------------------------------------
317
+
318
+ export function makeExec() {
319
+ return (cmd, args, opts = {}) => {
320
+ let r;
321
+ try {
322
+ r = spawnSync(cmd, args, {
323
+ encoding: "utf8",
324
+ maxBuffer: 16 * 1024 * 1024,
325
+ shell: false,
326
+ ...opts,
327
+ });
328
+ } catch (e) {
329
+ return { status: -1, stdout: "", stderr: String(e && e.message ? e.message : e) };
330
+ }
331
+ return {
332
+ status: typeof r.status === "number" ? r.status : -1,
333
+ stdout: r.stdout ?? "",
334
+ stderr: r.error ? String(r.error.message) : r.stderr ?? "",
335
+ };
336
+ };
337
+ }
338
+
339
+ export function git(root, args, exec = makeExec()) {
340
+ const r = exec("git", args, { cwd: root });
341
+ if (r.status !== 0) {
342
+ throw new OpError(`git ${args.join(" ")} failed in ${root}: ${(r.stderr || r.stdout || "unknown error").trim()}`);
343
+ }
344
+ return (r.stdout ?? "").trim();
345
+ }
346
+
347
+ export function gitHeadSha(root, exec = makeExec()) {
348
+ return git(root, ["rev-parse", "HEAD"], exec);
349
+ }
350
+
351
+ // Working-tree cleanliness (H10). All read phases pin citations to HEAD's sha
352
+ // but read the WORKING TREE via fs; on a dirty tree those `file:line@sha` claims
353
+ // are attributed to a commit that does not contain that content. Returns
354
+ // { clean, dirtyCount }; a failed status probe is treated as clean (never
355
+ // blocks a run — the warning is advisory, degrade loudly not fatally).
356
+ export function workingTreeStatus(root, exec = makeExec()) {
357
+ const r = exec("git", ["status", "--porcelain"], { cwd: root });
358
+ if (r.status !== 0) return { clean: true, dirtyCount: 0 };
359
+ const lines = (r.stdout ?? "").split("\n").filter((l) => {
360
+ if (l.trim() === "") return false;
361
+ // Ignore do-better's OWN output dir (.dobetter/) — it is the artifact of the
362
+ // run, not a source change that would misattribute a citation, and it would
363
+ // otherwise flag every re-run as "dirty".
364
+ const p = l.slice(3).replace(/^"|"$/g, "");
365
+ return !(p === ".dobetter" || p.startsWith(".dobetter/"));
366
+ });
367
+ return { clean: lines.length === 0, dirtyCount: lines.length };
368
+ }
369
+
370
+ // A one-line declared warning that a phase is minting @sha citations against a
371
+ // dirty working tree (H10) — "declared, never silent" per the doctrine. Emits
372
+ // via log.warn when dirty; a no-op when clean. Returns the status for callers
373
+ // that want to record it.
374
+ export function warnIfDirtyTree(root, exec, log, phaseLabel) {
375
+ const status = workingTreeStatus(root, exec);
376
+ if (!status.clean) {
377
+ log?.warn?.(
378
+ `${phaseLabel}: working tree has ${status.dirtyCount} uncommitted change(s) — ` +
379
+ "file:line@sha citations are pinned to HEAD but read from the working tree, " +
380
+ "so claims may not match the committed blob. Commit before a citable run for exact provenance.",
381
+ );
382
+ }
383
+ return status;
384
+ }
385
+
386
+ // ---------------------------------------------------------------------------
387
+ // Files
388
+ // ---------------------------------------------------------------------------
389
+
390
+ export function readJsonSafe(absPath) {
391
+ if (!fs.existsSync(absPath)) return null;
392
+ const raw = fs.readFileSync(absPath, "utf8");
393
+ try {
394
+ return JSON.parse(raw);
395
+ } catch (e) {
396
+ throw new OpError(`Unparseable JSON at ${absPath}: ${e.message}`);
397
+ }
398
+ }
399
+
400
+ export function writeFileAtomic(absPath, content) {
401
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
402
+ const tmp = `${absPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
403
+ fs.writeFileSync(tmp, content);
404
+ fs.renameSync(tmp, absPath);
405
+ }
406
+
407
+ export function truncate(text, maxChars) {
408
+ const s = String(text);
409
+ if (s.length <= maxChars) return s;
410
+ return `${s.slice(0, Math.max(0, maxChars - 1))}…`;
411
+ }
412
+
413
+ // Zero-dep bounded-concurrency map (H8). Runs `fn(item, index)` over `items`
414
+ // with at most `limit` in flight, returns results in INPUT order (never
415
+ // completion order), and on the first rejection stops pulling new work, lets
416
+ // the in-flight tasks settle, then rejects with that first error. limit is
417
+ // clamped to >= 1. Used to fan out independent LLM calls (D2 pooled finders,
418
+ // D1 readers) without changing result ordering or gate semantics.
419
+ export async function mapLimit(items, limit, fn) {
420
+ const arr = Array.from(items);
421
+ const results = new Array(arr.length);
422
+ const lim = Math.max(1, Math.trunc(Number(limit)) || 1);
423
+ let next = 0;
424
+ let firstErr = null;
425
+ async function worker() {
426
+ while (next < arr.length && firstErr === null) {
427
+ const i = next++;
428
+ try {
429
+ results[i] = await fn(arr[i], i);
430
+ } catch (err) {
431
+ if (firstErr === null) firstErr = err;
432
+ }
433
+ }
434
+ }
435
+ const workers = [];
436
+ for (let w = 0; w < Math.min(lim, arr.length); w++) workers.push(worker());
437
+ await Promise.all(workers);
438
+ if (firstErr !== null) throw firstErr;
439
+ return results;
440
+ }
441
+
442
+ // Resolve a file relative to THIS package's root (not the target repo) — used
443
+ // to load do-better/SKILL.md and do-better/references/*.md as prompt sources.
444
+ export function readPackageFile(relPath) {
445
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
446
+ const abs = path.join(packageRoot, relPath);
447
+ if (!fs.existsSync(abs)) throw new OpError(`Missing package file: ${relPath} (looked in ${packageRoot})`);
448
+ return fs.readFileSync(abs, "utf8");
449
+ }