@esneiderbravo/speclaw 2.0.23 → 2.0.25

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.
@@ -1,11 +1,12 @@
1
- import { measureBranchDiff, shipOnStop } from "../../modules/lawbook/ship.js";
1
+ import { measureBranchDiff, shipOnStop, stopSummary } from "../../modules/lawbook/ship.js";
2
2
  /**
3
3
  * `speclaw ship-on-stop`: the Claude Code `Stop` hook. Ships the branch's
4
- * change when the work changed since the last ship; prints nothing on stdout.
5
- * Artifacts the change's level still owes, or a failing gate, go to stderr with
6
- * exit 2 so the agent sees them and writes or fixes them
7
- * — once: when the hook already blocked this stop (`stop_hook_active`), it
8
- * exits 0 so the agent can never loop. Any other failure exits 0.
4
+ * change when the work changed since the last ship, and prints one JSON line
5
+ * on stdout whose `systemMessage` tells the user what happened. Artifacts the
6
+ * change's level still owes, or a failing gate, also set `decision: "block"`
7
+ * with the `reason` the agent acts on — once: when the hook already blocked
8
+ * this stop (`stop_hook_active`), it does not block again, so the agent can
9
+ * never loop. A skipped stop prints nothing. It always exits 0.
9
10
  *
10
11
  * @param cwd - Project root (defaults to the process working directory).
11
12
  */
@@ -14,15 +15,23 @@ export async function runShipOnStop(cwd = process.cwd()) {
14
15
  const active = await stopHookActive();
15
16
  try {
16
17
  const out = shipOnStop(cwd);
17
- if (out.skipped === null && out.result.pending.length && !active) {
18
- process.stderr.write(`speclaw: ${out.result.next.join("\n- ")}\n`);
19
- process.exit(2);
20
- }
21
- if (out.skipped === null && !out.result.gatesPassed && !active) {
22
- const failed = out.result.gates[out.result.gates.length - 1];
23
- process.stderr.write(`speclaw: gate failed — ${failed.command} (exit ${failed.exitCode}). Fix it before finishing.\n${failed.tail}\n`);
24
- process.exit(2);
25
- }
18
+ if (out.skipped !== null)
19
+ return;
20
+ const r = out.result;
21
+ const block = !active && (r.pending.length > 0 || !r.gatesPassed);
22
+ // Claude Code reads stdout JSON only on exit 0, so a blocked stop says
23
+ // `decision: block` there too: the reason goes back to the agent and the
24
+ // systemMessage reaches the user either way.
25
+ const failed = r.gates[r.gates.length - 1];
26
+ const reason = !block
27
+ ? undefined
28
+ : r.pending.length
29
+ ? `speclaw: ${r.next.join("\n- ")}`
30
+ : `speclaw: gate failed — ${failed.command} (exit ${failed.exitCode}). Fix it before finishing.\n${failed.tail}`;
31
+ process.stdout.write(JSON.stringify({
32
+ ...(reason ? { decision: "block", reason } : {}),
33
+ systemMessage: stopSummary(r, block),
34
+ }) + "\n");
26
35
  }
27
36
  catch {
28
37
  // A hook must never break the session.
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import { openDb, FILE_NODE_KIND } from "./db.js";
4
4
  import { estimateTokens } from "../../shared/tokens.js";
5
5
  import { loadDeclaredBudget } from "../../shared/exposure.js";
6
+ import { packageEntries } from "../../shared/package-entries.js";
6
7
  export const MAP_START = "<!-- speclaw:map:start -->";
7
8
  export const MAP_END = "<!-- speclaw:map:end -->";
8
9
  /**
@@ -45,12 +46,15 @@ export function generateCompactMap(projectPath) {
45
46
  ? "hubs: (none yet)"
46
47
  : `hubs: ${hubs.map((h) => `${h.name} ${h.fan_in}`).join(" · ")}`;
47
48
  const dirLine = topDirs.map((d) => `${d.top}/ (${d.c})`).join(" ");
48
- let body = [
49
- `speclaw · ${fileCount} files · ${nodeCount} nodes`,
50
- dirLine,
51
- hubLine,
52
- "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
53
- ].join("\n");
49
+ // Only what this project's package.json declares; a workspace root or an
50
+ // app declares no entry, and the map then names none.
51
+ const entries = packageEntries(projectPath);
52
+ const entryLine = entries.length
53
+ ? `entry: ${entries.map((e) => `${e.file} (${e.kind})`).join(" · ")}`
54
+ : "";
55
+ let body = [`speclaw · ${fileCount} files · ${nodeCount} nodes`, dirLine, hubLine, entryLine]
56
+ .filter(Boolean)
57
+ .join("\n");
54
58
  const cap = loadDeclaredBudget().map;
55
59
  let omitted = false;
56
60
  while (estimateTokens(body) > cap && hubs.length > 3) {
@@ -63,7 +67,7 @@ export function generateCompactMap(projectPath) {
63
67
  `speclaw · ${fileCount} files · ${nodeCount} nodes`,
64
68
  dirLine,
65
69
  shorter,
66
- "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
70
+ entryLine,
67
71
  omitted ? "(entries omitted to fit map budget)" : "",
68
72
  ]
69
73
  .filter(Boolean)
@@ -72,9 +76,11 @@ export function generateCompactMap(projectPath) {
72
76
  if (estimateTokens(body) > cap) {
73
77
  body = [
74
78
  `speclaw · ${fileCount} files · ${nodeCount} nodes`,
75
- "entry: src/server.ts (mcp) · src/cli/index.ts (bin)",
79
+ entryLine,
76
80
  "(entries omitted to fit map budget)",
77
- ].join("\n");
81
+ ]
82
+ .filter(Boolean)
83
+ .join("\n");
78
84
  }
79
85
  return body;
80
86
  }
@@ -51,7 +51,8 @@ export const SHIP_ON_STOP_MARKER = "speclaw ship-on-stop";
51
51
  * with new changes on a feature branch, speclaw sizes the change from its diff,
52
52
  * returns the artifacts that level owes, runs the gates once, writes the report
53
53
  * from their real output, and archives level-0 work — with no agent turn. Guarded on `lawbook/` instead of the index. Its
54
- * stderr is kept so a failing gate reaches the agent (exit 2).
54
+ * stdout is kept: one JSON line blocks the stop with the reason the agent acts
55
+ * on (owed artifacts, a failing gate) and tells the user what the stop did.
55
56
  */
56
57
  export const SHIP_ON_STOP_COMMAND = speclawCommand("ship-on-stop");
57
58
  /** Seconds the Stop hook may run: the project's own gates run inside it. */
@@ -72,10 +73,11 @@ export function speclawCommand(sub) {
72
73
  const run = `if [ -x node_modules/.bin/speclaw ]; then node_modules/.bin/speclaw ${sub}; ` +
73
74
  `elif command -v speclaw >/dev/null 2>&1; then speclaw ${sub}; ` +
74
75
  `else npm_config_update_notifier=false npm_config_offline=true npx --no-install @esneiderbravo/speclaw ${sub}; fi; `;
75
- // The ship hook keeps its exit code (2 feeds a failing gate back to the
76
- // agent) and its stderr; stdout is discarded like the other hooks.
76
+ // The ship hook keeps its stdout and stderr: the one JSON line it prints
77
+ // blocks the stop when the agent owes something and tells the user what
78
+ // the stop did.
77
79
  return ship
78
- ? 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -d lawbook ] && { ' + run + "} >/dev/null"
80
+ ? 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -d lawbook ] && { ' + run + "}"
79
81
  : 'cd "${CLAUDE_PROJECT_DIR:-.}" 2>/dev/null && [ -f .speclaw/index.db ] && { ' +
80
82
  run +
81
83
  "} >/dev/null 2>&1 || true";
@@ -0,0 +1,130 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { spawnSync } from "node:child_process";
4
+ import { mergeBase } from "../../shared/git.js";
5
+ import { isTestPath, loadAffectedConfig } from "../compass/affected-config.js";
6
+ /**
7
+ * Lines that declare an HTTP route or a request/response contract, across the
8
+ * frameworks a repo is likely to use. A diff line matching one of them adds,
9
+ * changes or removes an endpoint, so the change owes an API report. Lines are
10
+ * trimmed; decorators must open the line, as they do in code, so a string or a
11
+ * regex that merely mentions one is not a route.
12
+ */
13
+ const ROUTE_LINE = [
14
+ // NestJS controllers and handlers.
15
+ /^@(Controller|Get|Post|Put|Patch|Delete|Head|Options|All|HttpCode|Header|Redirect|Sse)\s*\(/,
16
+ // Spring.
17
+ /^@(RestController|RequestMapping|GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping)\b/,
18
+ // FastAPI, Flask and similar decorator routers.
19
+ /^@\w+\.(get|post|put|patch|delete|route|api_route|websocket)\s*\(/,
20
+ // Express, Koa, Fastify, Hono: `router.get("/x", handler)` — a path and a
21
+ // handler, so a client call (`api.get("/users")`) or `app.get("port")` is not one.
22
+ /^(\w+\.)?(app|router|server|fastify|routes?)\.(get|post|put|patch|delete|all)\s*\(\s*["'`]\/[^"'`]*["'`]\s*,/,
23
+ // Go net/http, gin, echo, chi: `r.GET("/x", …)`, `mux.HandleFunc("/x", …)`.
24
+ /^\w+\.(GET|POST|PUT|PATCH|DELETE|HandleFunc|Handle)\s*\(\s*"\//,
25
+ ];
26
+ /** Next.js App Router handlers: an exported HTTP-verb function in a `route.*` file. */
27
+ const NEXT_ROUTE_FILE = /(^|\/)app\/(.+\/)?route\.[cm]?[jt]sx?$/;
28
+ const NEXT_HANDLER = /export\s+(async\s+)?(function|const)\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\b/;
29
+ /** Files that are the contract themselves: any change to them is an API change. */
30
+ const CONTRACT_FILE = /(^|\/)(openapi|swagger)[^/]*\.(ya?ml|json)$|\.(proto|graphql|gql)$/i;
31
+ /** Request/response shapes: a code (not comment) change in a DTO file. */
32
+ const DTO_FILE = /(^|\/)dtos?\/|\.dto\.[cm]?[jt]s$|(^|\/)schemas?\/.+\.py$/;
33
+ const COMMENT_OR_BLANK = /^\s*(\/\/|\/\*|\*|#|$)/;
34
+ const LINES_PER_FILE = 4;
35
+ /**
36
+ * The changed files whose diff against the branch base adds, changes or
37
+ * removes an API surface: a route declaration, a contract file, or a DTO. Test
38
+ * files never count. Reads one `git diff -U0` for every candidate at once and
39
+ * untracked files whole; never throws.
40
+ *
41
+ * @param projectPath - Project root (a git repository).
42
+ * @param files - Changed files, relative to the root.
43
+ * @returns The files that touch the API surface, in the order given.
44
+ */
45
+ export function apiSurfaceChanges(projectPath, files) {
46
+ try {
47
+ const { testGlobs } = loadAffectedConfig(projectPath);
48
+ const candidates = files.filter((f) => !isTestPath(f, testGlobs));
49
+ if (candidates.length === 0)
50
+ return [];
51
+ const changed = changedLines(projectPath, candidates);
52
+ const hits = [];
53
+ for (const file of candidates) {
54
+ const lines = changed.get(file) ?? [];
55
+ if (CONTRACT_FILE.test(file)) {
56
+ hits.push({ file, lines: [] });
57
+ continue;
58
+ }
59
+ const surface = lines.filter((l) => declaresSurface(file, l));
60
+ if (surface.length)
61
+ hits.push({ file, lines: surface.slice(0, LINES_PER_FILE) });
62
+ }
63
+ return hits;
64
+ }
65
+ catch {
66
+ return [];
67
+ }
68
+ }
69
+ function declaresSurface(file, line) {
70
+ if (ROUTE_LINE.some((re) => re.test(line)))
71
+ return true;
72
+ if (NEXT_ROUTE_FILE.test(file) && NEXT_HANDLER.test(line))
73
+ return true;
74
+ return DTO_FILE.test(file) && !COMMENT_OR_BLANK.test(line);
75
+ }
76
+ /**
77
+ * Added and removed lines per file against the merge base with `main`/`master`
78
+ * (working tree included); an untracked file contributes every line.
79
+ */
80
+ function changedLines(projectPath, files) {
81
+ const out = new Map();
82
+ const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
83
+ if (base) {
84
+ const r = spawnSync("git", [
85
+ "-c",
86
+ "core.quotePath=false",
87
+ "diff",
88
+ "-U0",
89
+ "--no-color",
90
+ "--no-ext-diff",
91
+ "--no-renames",
92
+ "--src-prefix=a/",
93
+ "--dst-prefix=b/",
94
+ base,
95
+ "--",
96
+ ...files,
97
+ ], { cwd: projectPath, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
98
+ let current = null;
99
+ for (const line of (r.status === 0 ? r.stdout : "").split("\n")) {
100
+ // A path with a space ends its header with a tab.
101
+ const head = /^(---|\+\+\+) (?:[ab]\/(.+?)|\/dev\/null)\t?$/.exec(line);
102
+ if (head) {
103
+ // A deleted file's `+++ /dev/null` keeps the name its `--- a/` line set.
104
+ if (head[2])
105
+ current = head[2];
106
+ else if (head[1] === "---")
107
+ current = null;
108
+ continue;
109
+ }
110
+ if (current && /^[+-](?![+-]{2} )/.test(line)) {
111
+ out.set(current, [...(out.get(current) ?? []), line.slice(1).trim()]);
112
+ }
113
+ }
114
+ }
115
+ const untracked = spawnSync("git", ["-c", "core.quotePath=false", "ls-files", "--others", "--exclude-standard", "--", ...files], { cwd: projectPath, encoding: "utf8" });
116
+ for (const f of (untracked.status === 0 ? untracked.stdout : "").split("\n")) {
117
+ if (!f || out.has(f))
118
+ continue;
119
+ try {
120
+ out.set(f, fs
121
+ .readFileSync(path.join(projectPath, f), "utf8")
122
+ .split("\n")
123
+ .map((l) => l.trim()));
124
+ }
125
+ catch {
126
+ // Vanished since it was listed: nothing to read.
127
+ }
128
+ }
129
+ return out;
130
+ }
@@ -7,6 +7,8 @@ import { affectedTests } from "../compass/affected.js";
7
7
  import { hotspots } from "../compass/hotspots.js";
8
8
  import { loadAffectedConfig, matchGlob, matchesAny, inferModule, } from "../compass/affected-config.js";
9
9
  import { formatJson } from "../../shared/json.js";
10
+ import { packageEntries } from "../../shared/package-entries.js";
11
+ import { apiSurfaceChanges } from "./api-surface.js";
10
12
  /** Default thresholds from the adaptive-ceremony roadmap. */
11
13
  export const DEFAULT_THRESHOLDS = {
12
14
  filesTouched: [0, 1, 3, 5],
@@ -256,8 +258,15 @@ export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
256
258
  }
257
259
  let blastRadiusNodes = 0;
258
260
  let affected = 0;
259
- let touchesPublicApi = false;
260
261
  let maxHotspotScore = 0;
262
+ // A published entry point (package.json `main`/`bin`) or an HTTP route,
263
+ // DTO or contract file in the diff: either is a surface other code calls.
264
+ const entries = packageEntries(projectPath).map((e) => e.file);
265
+ const apiSurface = pathList.length
266
+ ? apiSurfaceChanges(projectPath, pathList).map((h) => h.file)
267
+ : [];
268
+ const touchesPublicApi = apiSurface.length > 0 ||
269
+ pathList.some((p) => entries.some((e) => p === e || e.endsWith(`/${p}`) || p.endsWith(`/${e}`)));
261
270
  if (indexExists(projectPath) && pathList.length > 0) {
262
271
  try {
263
272
  const imp = impact(projectPath, { files: pathList, format: "grouped", maxDepth: 4 });
@@ -275,32 +284,6 @@ export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
275
284
  catch {
276
285
  /* soft */
277
286
  }
278
- try {
279
- const pkgPath = path.join(projectPath, "package.json");
280
- if (fs.existsSync(pkgPath)) {
281
- const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
282
- const entries = new Set();
283
- if (typeof pkg.main === "string")
284
- entries.add(pkg.main.replace(/^\.\//, ""));
285
- if (typeof pkg.bin === "string")
286
- entries.add(pkg.bin.replace(/^\.\//, ""));
287
- else if (pkg.bin && typeof pkg.bin === "object") {
288
- for (const v of Object.values(pkg.bin))
289
- entries.add(String(v).replace(/^\.\//, ""));
290
- }
291
- for (const e of entries) {
292
- if (pathList.some((p) => p === e || e.endsWith(p) || p.endsWith(e))) {
293
- touchesPublicApi = true;
294
- }
295
- }
296
- if (pathList.some((p) => p === "src/cli/index.ts" || p === "src/server.ts")) {
297
- touchesPublicApi = true;
298
- }
299
- }
300
- }
301
- catch {
302
- /* soft */
303
- }
304
287
  try {
305
288
  const hs = hotspots(projectPath, { days: 90, sortBy: "combined", limit: 200 });
306
289
  // The score is relative to the hottest file, so in a young repo any file
@@ -330,6 +313,7 @@ export function gatherSignals(projectPath, targets, t = DEFAULT_THRESHOLDS) {
330
313
  blastRadiusNodes,
331
314
  affectedTests: affected,
332
315
  touchesPublicApi,
316
+ ...(apiSurface.length ? { apiSurface } : {}),
333
317
  maxHotspotScore,
334
318
  touchesGlobalFile,
335
319
  onlyDocs,
@@ -12,6 +12,7 @@ import { deltaSpecFiles, specArchive, specArchivePreconditions, specList } from
12
12
  import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
13
13
  import { scaffoldQuick } from "./quick.js";
14
14
  import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
15
+ import { apiSurfaceChanges } from "./api-surface.js";
15
16
  const GATE_TAIL_LINES = 12;
16
17
  /**
17
18
  * Read the `ship:` block of lawbook/config.yaml (line-oriented, like the other
@@ -98,6 +99,7 @@ function scopedTestGate(projectPath, fullCommand, files) {
98
99
  return {
99
100
  command: at.command,
100
101
  scope: `${at.tests.length} affected test file(s), ${at.skipped.files} skipped; the full suite runs in CI`,
102
+ parts: at.commands.map((c) => ({ cwd: c.cwd, command: c.command, tests: c.files.length })),
101
103
  };
102
104
  }
103
105
  catch (e) {
@@ -115,6 +117,18 @@ function runGate(projectPath, command) {
115
117
  tail: combined.slice(-GATE_TAIL_LINES).join("\n"),
116
118
  };
117
119
  }
120
+ /**
121
+ * What the lawbook writes itself: change folders and reports, the canonical
122
+ * specs an archive promotes, the anchors it seals, and the local index. None
123
+ * of it is the agent's work, so none of it may trigger, size or re-run a ship
124
+ * — archiving used to read as new work and re-run every gate.
125
+ */
126
+ const OWN_OUTPUT = ["lawbook/changes/", "lawbook/specs/", "lawbook/anchors/", ".speclaw/"];
127
+ function isOwnOutput(file) {
128
+ return OWN_OUTPUT.some((dir) => file.startsWith(dir));
129
+ }
130
+ /** The same paths as git pathspecs, for commands that list or log the work. */
131
+ const OWN_OUTPUT_EXCLUDES = OWN_OUTPUT.map((dir) => `:(exclude)${dir.replace(/\/$/, "")}`);
118
132
  function branchFiles(projectPath) {
119
133
  if (!isGitRepo(projectPath))
120
134
  return [];
@@ -127,7 +141,7 @@ function branchFiles(projectPath) {
127
141
  ...untrackedFiles(projectPath),
128
142
  ]),
129
143
  ]
130
- .filter((f) => !f.startsWith("lawbook/changes/"))
144
+ .filter((f) => !isOwnOutput(f))
131
145
  .filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
132
146
  .sort();
133
147
  }
@@ -150,7 +164,7 @@ function onlyMapChanged(projectPath, base) {
150
164
  return false;
151
165
  }
152
166
  }
153
- /** New files not yet added, outside ship's own output (`lawbook/changes/`, `.speclaw/`). */
167
+ /** New files not yet added, outside the lawbook's own output ({@link OWN_OUTPUT}). */
154
168
  function untrackedFiles(projectPath) {
155
169
  return git(projectPath, [
156
170
  "-c",
@@ -160,12 +174,12 @@ function untrackedFiles(projectPath) {
160
174
  "--exclude-standard",
161
175
  ])
162
176
  .split("\n")
163
- .filter((f) => f && !f.startsWith("lawbook/changes/") && !f.startsWith(".speclaw/"));
177
+ .filter((f) => f && !isOwnOutput(f));
164
178
  }
165
179
  function secs(ms) {
166
180
  return `${(ms / 1000).toFixed(1)} s`;
167
181
  }
168
- function renderReport(change, discipline, files, gates, passed, date) {
182
+ function renderReport(change, discipline, files, gates, passed, date, others = []) {
169
183
  const rows = gates
170
184
  .map((g) => `| \`${g.command}\`${g.scope ? ` (${g.scope})` : ""} | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
171
185
  .join("\n");
@@ -184,7 +198,12 @@ ${rows || "| (no gates configured) | — | — |"}
184
198
 
185
199
  ## Files changed
186
200
 
187
- ${files.length ? files.map((f) => `- \`${f}\``).join("\n") : "- (none detected)"}
201
+ ${files.length ? files.map((f) => `- \`${f}\``).join("\n") : "- (none detected)"}${others.length
202
+ ? `\n\nPlus ${others.length} file(s) outside this discipline's package: ${others
203
+ .slice(0, 8)
204
+ .map((f) => `\`${f}\``)
205
+ .join(", ")}${others.length > 8 ? ", …" : ""}`
206
+ : ""}
188
207
 
189
208
  ## Gate output (tail)
190
209
 
@@ -203,6 +222,74 @@ None.
203
222
  ${passed ? "**Gates PASS** — every gate exited 0. Review is not decided here: it happens on the PR." : "**Gates FAIL** — at least one gate failed; nothing was archived."}
204
223
  `;
205
224
  }
225
+ /**
226
+ * The discipline a package's tests report under, named for the package
227
+ * directory: `apps/backend` → `backend`, `apps/web` → `frontend`. A generated
228
+ * report is never `api.md`: that one documents the contract and the agent writes it.
229
+ *
230
+ * @param cwd - Package directory relative to the root (`.` for the root).
231
+ * @param fallback - The discipline for the root package.
232
+ */
233
+ function disciplineOf(cwd, fallback) {
234
+ if (cwd === ".")
235
+ return fallback;
236
+ const leaf = cwd.split("/").pop().toLowerCase();
237
+ if (/^(backend|api|server|service|services)$/.test(leaf))
238
+ return "backend";
239
+ if (/^(web|frontend|client|ui|app|site|webapp)$/.test(leaf))
240
+ return "frontend";
241
+ if (/^(e2e|playwright|cypress|acceptance)$/.test(leaf))
242
+ return "e2e";
243
+ if (/^(mobile|ios|android)$/.test(leaf))
244
+ return "mobile";
245
+ return leaf.replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || fallback;
246
+ }
247
+ /** A path as one shell word. */
248
+ function shellArg(s) {
249
+ return /^[\w./-]+$/.test(s) ? s : `'${s.replace(/'/g, "'\\''")}'`;
250
+ }
251
+ /**
252
+ * Group the gates into reports. Without per-package gates there is one report
253
+ * with every gate and file. Otherwise each discipline gets its own test gates
254
+ * plus the repo-wide ones (lint, build), and the files under its packages.
255
+ */
256
+ function reportGroups(gates, files, fallback, cwds) {
257
+ if (cwds.size === 0)
258
+ return [{ discipline: fallback, gates, files, others: [] }];
259
+ const shared = gates.filter((g) => !g.discipline);
260
+ const under = (f, cwd) => cwd !== "." && f.startsWith(`${cwd}/`);
261
+ const claimed = new Set(files.filter((f) => [...cwds.values()].flat().some((c) => under(f, c))));
262
+ return [...cwds.entries()].map(([d, dirs]) => {
263
+ const own = dirs.includes(".")
264
+ ? files.filter((f) => !claimed.has(f) || dirs.some((c) => under(f, c)))
265
+ : files.filter((f) => dirs.some((c) => under(f, c)));
266
+ return {
267
+ discipline: d,
268
+ gates: [...shared, ...gates.filter((g) => g.discipline === d)],
269
+ files: own,
270
+ others: files.filter((f) => !own.includes(f) && !claimed.has(f)),
271
+ };
272
+ });
273
+ }
274
+ /** Marks a report ship wrote, so a later ship may replace or remove it. */
275
+ const GENERATED_MARK = "**Generated by:** `speclaw ship`";
276
+ /** Remove reports an earlier ship generated that this ship no longer writes. */
277
+ function dropStaleReports(projectPath, reportsRel, kept) {
278
+ const dir = path.join(projectPath, reportsRel);
279
+ for (const f of fs.readdirSync(dir)) {
280
+ const rel = path.join(reportsRel, f);
281
+ if (!f.endsWith(".md") || kept.includes(rel))
282
+ continue;
283
+ try {
284
+ if (fs.readFileSync(path.join(dir, f), "utf8").includes(GENERATED_MARK)) {
285
+ fs.rmSync(path.join(dir, f));
286
+ }
287
+ }
288
+ catch {
289
+ // A report that cannot be read is left as it is.
290
+ }
291
+ }
292
+ }
206
293
  /**
207
294
  * The archived folder of a change (`lawbook/changes/archive/<date>-<name>`),
208
295
  * newest first, or null.
@@ -243,8 +330,7 @@ function commitSummary(projectPath) {
243
330
  `${base}..HEAD`,
244
331
  "--",
245
332
  ".",
246
- ":(exclude)lawbook/changes",
247
- ":(exclude).speclaw",
333
+ ...OWN_OUTPUT_EXCLUDES,
248
334
  ])
249
335
  .split("\n")
250
336
  .filter((l) => !/^[\w-]+-by:/i.test(l))
@@ -274,8 +360,8 @@ const SMALL_FIX_LINES = 10;
274
360
  * small fix stays at level 0 however central the code it touches: its blast
275
361
  * radius is guarded by the gates and its test, which ceremony would not add to.
276
362
  */
277
- function measureDiff(projectPath, files) {
278
- const proposal = measureDiffSignals(projectPath, files);
363
+ function measureDiff(projectPath, files, measured) {
364
+ const proposal = measured ?? measureDiffSignals(projectPath, files);
279
365
  if (!proposal.level || proposal.signals.touchesPublicApi || proposal.signals.touchesGlobalFile) {
280
366
  return proposal;
281
367
  }
@@ -331,7 +417,7 @@ function measureDiffSignals(projectPath, files) {
331
417
  try {
332
418
  const file = path.join(projectPath, LEVEL_CACHE);
333
419
  fs.mkdirSync(path.dirname(file), { recursive: true });
334
- fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), proposal }) + "\n");
420
+ fs.writeFileSync(file, JSON.stringify({ key: fileSetKey(files), files, proposal }) + "\n");
335
421
  }
336
422
  catch {
337
423
  // The cache only saves time; the measurement stands without it.
@@ -357,6 +443,24 @@ function cachedMeasure(projectPath, files) {
357
443
  return null;
358
444
  }
359
445
  }
446
+ /**
447
+ * The last measurement when the diff has only grown since it was taken: its
448
+ * level is a floor for the current one, since a measured level only rises. A
449
+ * large diff takes seconds to measure and an agent at work outdates every
450
+ * measurement before it lands, so waiting for an exact one never tells it.
451
+ */
452
+ function grownFromCache(projectPath, files) {
453
+ try {
454
+ const c = JSON.parse(fs.readFileSync(path.join(projectPath, LEVEL_CACHE), "utf8"));
455
+ if (!c.proposal || !Array.isArray(c.files) || c.files.length === 0)
456
+ return null;
457
+ const now = new Set(files);
458
+ return c.files.every((f) => now.has(f)) ? c.proposal : null;
459
+ }
460
+ catch {
461
+ return null;
462
+ }
463
+ }
360
464
  /**
361
465
  * Measure the branch's current diff into the level cache (`speclaw
362
466
  * measure-diff`, the edit hook's detached background job).
@@ -403,11 +507,11 @@ function scaffoldMeasured(projectPath, name, files, measured) {
403
507
  }
404
508
  }
405
509
  /** Raise a measured level when the branch diff has grown past it; a human-set level stands. */
406
- function remeasure(projectPath, name, files) {
510
+ function remeasure(projectPath, name, files, measured) {
407
511
  const rec = readCeremonyRecord(projectPath, name);
408
512
  if (rec?.confirmedBy !== "measured")
409
513
  return;
410
- const proposal = measureDiff(projectPath, files);
514
+ const proposal = measured ?? measureDiff(projectPath, files);
411
515
  if (proposal.level !== null && proposal.level > rec.confirmedLevel) {
412
516
  promoteCeremonyLevel(projectPath, name, proposal.level, "the branch diff grew", proposal);
413
517
  }
@@ -420,7 +524,7 @@ function remeasure(projectPath, name, files) {
420
524
  *
421
525
  * @returns Whether the change was reopened.
422
526
  */
423
- function reopenIfGrown(projectPath, name, archivedRel, files) {
527
+ function reopenIfGrown(projectPath, name, archivedRel, files, measured) {
424
528
  const archivedDir = path.join(projectPath, archivedRel);
425
529
  let rec = {};
426
530
  try {
@@ -441,7 +545,7 @@ function reopenIfGrown(projectPath, name, archivedRel, files) {
441
545
  });
442
546
  if (atBase.status === 0)
443
547
  return false;
444
- const proposal = measureDiff(projectPath, files);
548
+ const proposal = measured ?? measureDiff(projectPath, files);
445
549
  if (proposal.level === null || proposal.level <= rec.confirmedLevel)
446
550
  return false;
447
551
  const changeDir = path.join(projectPath, "lawbook", "changes", name);
@@ -475,11 +579,16 @@ const STUBS = {
475
579
  * its record falls back to the commits or the file list, so it costs no turn. Bug-shaped changes keep
476
580
  * their own checks (`bugfix.md`) and are not judged here.
477
581
  *
582
+ * A change whose diff touches an API surface (a route, a DTO, a contract file)
583
+ * also owes `reports/api.md`: the contract is evidence no gate output carries.
584
+ *
478
585
  * @param projectPath - Project root with `lawbook/`.
479
586
  * @param name - Change name.
587
+ * @param files - The branch's changed files; when given, the API surface is
588
+ * read from their diff, else from the change's recorded signals.
480
589
  * @returns One instruction per missing artifact; empty when the change is documented.
481
590
  */
482
- export function pendingArtifacts(projectPath, name) {
591
+ export function pendingArtifacts(projectPath, name, files) {
483
592
  const dir = path.join(projectPath, "lawbook", "changes", name);
484
593
  if (!fs.existsSync(dir) || readChangeType(projectPath, name) === "bug")
485
594
  return [];
@@ -522,6 +631,19 @@ export function pendingArtifacts(projectPath, name) {
522
631
  }
523
632
  }
524
633
  }
634
+ // A report ship generated (`ship.discipline: api`) is gate output, not the contract.
635
+ const apiReport = path.join(dir, "reports", "api.md");
636
+ const apiWritten = fs.existsSync(apiReport) && !fs.readFileSync(apiReport, "utf8").includes(GENERATED_MARK);
637
+ if (level > 0 && !apiWritten) {
638
+ const api = files
639
+ ? apiSurfaceChanges(projectPath, files).map((h) => h.file)
640
+ : (readCeremonyRecord(projectPath, name)?.signals.apiSurface ?? []);
641
+ if (api.length) {
642
+ out.push(`${rel("reports/api.md")}: document the API contract this change touches (${api
643
+ .slice(0, 4)
644
+ .join(", ")}${api.length > 4 ? ", …" : ""}) — method and path, auth, response shape, every status code, and how it was exercised (test client or request against an isolated store)`);
645
+ }
646
+ }
525
647
  return out;
526
648
  }
527
649
  /**
@@ -582,7 +704,7 @@ export function shipChange(projectPath, name, opts = {}) {
582
704
  ? `Changed ${files.length} file(s): ${files.slice(0, 8).join(", ")}${files.length > 8 ? ", …" : ""}`
583
705
  : "No file changes detected.";
584
706
  fillRecord(path.join(changeDir, "record.md"), opts.summary ?? (commits || (confirmedLevel(projectPath, name) === 0 ? fileList : "")));
585
- const pending = pendingArtifacts(projectPath, name);
707
+ const pending = pendingArtifacts(projectPath, name, files);
586
708
  const t1 = Date.now();
587
709
  if (pending.length) {
588
710
  const level = confirmedLevel(projectPath, name);
@@ -593,6 +715,7 @@ export function shipChange(projectPath, name, opts = {}) {
593
715
  gates: [],
594
716
  files,
595
717
  report: null,
718
+ reports: [],
596
719
  pending,
597
720
  archivedTo: null,
598
721
  next: [
@@ -610,6 +733,10 @@ export function shipChange(projectPath, name, opts = {}) {
610
733
  };
611
734
  }
612
735
  const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
736
+ // Level 2+ is the spec lane: its evidence is one report per discipline, so
737
+ // tests that span several packages run (and report) package by package.
738
+ const split = (confirmedLevel(projectPath, name) ?? 0) >= 2;
739
+ const cwds = new Map();
613
740
  const gates = [];
614
741
  for (const command of commands) {
615
742
  const scoped = cfg.tests !== "full" && TEST_GATE_RE.test(command.trim())
@@ -619,6 +746,23 @@ export function shipChange(projectPath, name, opts = {}) {
619
746
  gates.push({ command, exitCode: 0, durationMs: 0, tail: "", scope: scoped.scope });
620
747
  continue;
621
748
  }
749
+ if (split && scoped?.parts && scoped.parts.length > 1) {
750
+ for (const part of scoped.parts) {
751
+ const d = disciplineOf(part.cwd, discipline);
752
+ cwds.set(d, [...(cwds.get(d) ?? []), part.cwd]);
753
+ const g = runGate(projectPath, part.cwd === "." ? part.command : `cd ${shellArg(part.cwd)} && ${part.command}`);
754
+ gates.push({
755
+ ...g,
756
+ scope: `${part.tests} affected test file(s) in ${part.cwd}; the full suite runs in CI`,
757
+ discipline: d,
758
+ });
759
+ if (g.exitCode !== 0)
760
+ break;
761
+ }
762
+ if (gates[gates.length - 1].exitCode !== 0)
763
+ break;
764
+ continue;
765
+ }
622
766
  const g = runGate(projectPath, scoped?.command ?? command);
623
767
  gates.push(scoped ? { ...g, scope: scoped.scope } : g);
624
768
  if (g.exitCode !== 0)
@@ -626,9 +770,16 @@ export function shipChange(projectPath, name, opts = {}) {
626
770
  }
627
771
  const gatesPassed = gates.every((g) => g.exitCode === 0);
628
772
  const t2 = Date.now();
629
- const reportRel = path.join(archived && !fs.existsSync(changeDir) ? archived : path.join("lawbook", "changes", name), "reports", `${discipline}.md`);
630
- fs.mkdirSync(path.dirname(path.join(projectPath, reportRel)), { recursive: true });
631
- fs.writeFileSync(path.join(projectPath, reportRel), renderReport(name, discipline, files, gates, gatesPassed, date));
773
+ const reportsRel = path.join(archived && !fs.existsSync(changeDir) ? archived : path.join("lawbook", "changes", name), "reports");
774
+ fs.mkdirSync(path.join(projectPath, reportsRel), { recursive: true });
775
+ const reports = [];
776
+ for (const r of reportGroups(gates, files, discipline, cwds)) {
777
+ const rel = path.join(reportsRel, `${r.discipline}.md`);
778
+ fs.writeFileSync(path.join(projectPath, rel), renderReport(name, r.discipline, r.files, r.gates, gatesPassed, date, r.others));
779
+ reports.push(rel);
780
+ }
781
+ dropStaleReports(projectPath, reportsRel, reports);
782
+ const reportRel = reports[0];
632
783
  const t3 = Date.now();
633
784
  let archivedTo = null;
634
785
  const next = [];
@@ -661,6 +812,7 @@ export function shipChange(projectPath, name, opts = {}) {
661
812
  gates,
662
813
  files,
663
814
  report: reportRel,
815
+ reports,
664
816
  pending,
665
817
  archivedTo,
666
818
  next,
@@ -674,6 +826,40 @@ export function shipChange(projectPath, name, opts = {}) {
674
826
  },
675
827
  };
676
828
  }
829
+ /** A gate's short name for a one-line summary: `lint`, `build`, `tests backend`. */
830
+ function gateLabel(g) {
831
+ if (g.scope)
832
+ return g.discipline ? `tests ${g.discipline}` : "tests";
833
+ const short = g.command.replace(/^(npm|pnpm|yarn)( run)? /, "");
834
+ return short.length > 40 ? `${short.slice(0, 39)}…` : short;
835
+ }
836
+ /**
837
+ * The one line the `Stop` hook shows the user after a ship: what ran, how it
838
+ * went, and what is left — so a stop that blocks nothing is never silent.
839
+ *
840
+ * @param r - The ship result.
841
+ * @param blocked - Whether this stop was sent back to the agent.
842
+ * @returns The summary, prefixed `speclaw:`.
843
+ */
844
+ export function stopSummary(r, blocked) {
845
+ if (r.pending.length) {
846
+ const owed = [...new Set(r.pending.map((p) => path.basename(p.split(":")[0])))].join(", ");
847
+ return blocked
848
+ ? `speclaw: ${/^level \d/.exec(r.next[0] ?? "")?.[0] ?? "the change"} owes ${owed} — sent back to the agent; the gates run once they are written`
849
+ : `speclaw: still owed after a second stop: ${owed} — the gates did not run`;
850
+ }
851
+ const ran = r.gates.map((g) => `${gateLabel(g)} ${secs(g.durationMs)}`).join(" · ");
852
+ if (!r.gatesPassed) {
853
+ const failed = r.gates[r.gates.length - 1];
854
+ return `speclaw: gate FAILED — ${gateLabel(failed)} (exit ${failed.exitCode})${blocked ? " — sent back to the agent" : "; fix it, then stop again"}`;
855
+ }
856
+ const where = r.archivedTo
857
+ ? `archived to ${r.archivedTo}`
858
+ : r.next.length
859
+ ? r.next.join("; ")
860
+ : `report ${r.report}`;
861
+ return `speclaw: gates PASS${ran ? ` (${ran})` : ""} · ${where}`;
862
+ }
677
863
  /** Where {@link docHint} remembers what it last measured and told. */
678
864
  const DOC_HINT_STATE = path.join(".speclaw", "doc-hint.json");
679
865
  /** The change the hook would ship on this branch: the one last shipped here, else the branch's. */
@@ -712,7 +898,7 @@ export function docHint(projectPath) {
712
898
  return null;
713
899
  const quick = status
714
900
  .split("\n")
715
- .filter((l) => !l.includes(" lawbook/changes/") && !l.includes(" .speclaw/"))
901
+ .filter((l) => !OWN_OUTPUT.some((dir) => l.includes(` ${dir}`)))
716
902
  .join("\n");
717
903
  const statePath = path.join(projectPath, DOC_HINT_STATE);
718
904
  let prev = {};
@@ -741,36 +927,50 @@ export function docHint(projectPath) {
741
927
  return null;
742
928
  }
743
929
  // Measuring a large diff takes seconds — longer than the hook may run —
744
- // so it happens in the background and the hint follows on a later call.
745
- if (!cachedMeasure(projectPath, files)) {
746
- const fresh = prev.pending?.key === key && Date.now() - prev.pending.at < MEASURE_GRACE_MS;
747
- if (!fresh)
930
+ // so it happens in the background, one job at a time; meanwhile a diff
931
+ // that only grew is told from its last measurement, a floor for its level.
932
+ let measured = cachedMeasure(projectPath, files);
933
+ let inFlight;
934
+ if (!measured) {
935
+ const running = prev.pending !== undefined &&
936
+ Date.now() - prev.pending.at < MEASURE_GRACE_MS &&
937
+ !measuredSince(projectPath, prev.pending.at);
938
+ if (!running)
748
939
  measureInBackground(projectPath);
749
- save({ ...prev, quick, pending: fresh ? prev.pending : { key, at: Date.now() } });
750
- return null;
940
+ inFlight = running ? prev.pending : { key, at: Date.now() };
941
+ measured = grownFromCache(projectPath, files);
942
+ if (!measured) {
943
+ save({ ...prev, quick, pending: inFlight });
944
+ return null;
945
+ }
751
946
  }
947
+ const proposal = measureDiff(projectPath, files, measured);
752
948
  const change = branchChange(projectPath, branch);
753
949
  const changeDir = path.join(projectPath, "lawbook", "changes", change);
754
950
  const archived = findArchived(projectPath, change);
755
951
  if (fs.existsSync(changeDir))
756
- remeasure(projectPath, change, files);
952
+ remeasure(projectPath, change, files, proposal);
757
953
  else if (archived)
758
- reopenIfGrown(projectPath, change, archived, files);
759
- else {
760
- const proposal = measureDiff(projectPath, files);
761
- if ((proposal.level ?? 0) > 0)
762
- scaffoldMeasured(projectPath, change, files, proposal);
763
- }
954
+ reopenIfGrown(projectPath, change, archived, files, proposal);
955
+ else if ((proposal.level ?? 0) > 0)
956
+ scaffoldMeasured(projectPath, change, files, proposal);
764
957
  const level = fs.existsSync(changeDir) ? confirmedLevel(projectPath, change) : 0;
765
- const pending = level > 0 ? pendingArtifacts(projectPath, change) : [];
766
- const told = prev.told?.change === change && prev.told.level === level;
958
+ const pending = level > 0 ? pendingArtifacts(projectPath, change, files) : [];
959
+ // Re-told when the change, its level, or the kind of artifact owed changes
960
+ // (an API report owed later at the same level still has to be said).
961
+ const owed = [...new Set(pending.map((p) => p.split(":")[0]))].sort().join(",");
962
+ const told = prev.told?.change === change &&
963
+ prev.told.level === level &&
964
+ (prev.told.owed === undefined ||
965
+ owed.split(",").every((o) => prev.told.owed.split(",").includes(o)));
966
+ const keep = { quick, key, ...(inFlight ? { pending: inFlight } : {}) };
767
967
  if (pending.length === 0 || told) {
768
- save({ quick, key, ...(prev.told ? { told: prev.told } : {}) });
968
+ save({ ...keep, ...(prev.told ? { told: prev.told } : {}) });
769
969
  return null;
770
970
  }
771
971
  // "Told" is recorded only with the hint actually returned, so a level is
772
972
  // never marked as told by a call that said nothing.
773
- save({ quick, key, told: { change, level } });
973
+ save({ ...keep, told: { change, level, owed } });
774
974
  const why = readCeremonyRecord(projectPath, change)?.rationale ?? "";
775
975
  return (`speclaw: this change measures level ${level}${why ? ` (${why})` : ""}. ` +
776
976
  `In this same turn, before you stop, write briefly and truthfully:\n- ${pending.join("\n- ")}\n` +
@@ -865,11 +1065,11 @@ function readMarker(projectPath) {
865
1065
  return { branch: "", change: "", fingerprint: raw };
866
1066
  }
867
1067
  /**
868
- * Fingerprint of the branch's work outside `lawbook/changes/` and `.speclaw/`: every path that
1068
+ * Fingerprint of the branch's work outside the lawbook's own output: every path that
869
1069
  * differs from the merge base with `main`/`master` or is untracked, with its
870
1070
  * working-tree contents. A file hashes the same whether it is untracked, staged
871
1071
  * or committed, so committing work — new files included — is not new work; ship
872
- * writes under `lawbook/changes/`, so its own output never changes it either.
1072
+ * and archive write only {@link OWN_OUTPUT}, so their output never changes it either.
873
1073
  * Without a merge base it falls back to the paths changed against HEAD plus HEAD itself.
874
1074
  */
875
1075
  function workFingerprint(projectPath) {
@@ -882,8 +1082,7 @@ function workFingerprint(projectPath) {
882
1082
  base ?? "HEAD",
883
1083
  "--",
884
1084
  ".",
885
- ":(exclude)lawbook/changes",
886
- ":(exclude).speclaw",
1085
+ ...OWN_OUTPUT_EXCLUDES,
887
1086
  ]).split("\n");
888
1087
  const hash = createHash("sha256");
889
1088
  const files = [...new Set([...tracked, ...untrackedFiles(projectPath)])]
@@ -0,0 +1,56 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ /**
4
+ * The entry points the root `package.json` declares in `main` and `bin`. A
5
+ * built path is mapped back to the source file that produces it when that
6
+ * file exists, so the entries name code the agent edits. A repo without a
7
+ * root package, or one that declares neither field (an app or a workspace
8
+ * root), has none.
9
+ *
10
+ * @param projectPath - Project root.
11
+ * @returns The declared entries, deduplicated by file; empty when none.
12
+ */
13
+ export function packageEntries(projectPath) {
14
+ let pkg;
15
+ try {
16
+ pkg = JSON.parse(fs.readFileSync(path.join(projectPath, "package.json"), "utf8"));
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ const declared = [];
22
+ if (typeof pkg.main === "string")
23
+ declared.push({ file: pkg.main, kind: "main" });
24
+ if (typeof pkg.bin === "string")
25
+ declared.push({ file: pkg.bin, kind: "bin" });
26
+ else if (pkg.bin && typeof pkg.bin === "object") {
27
+ const bins = Object.entries(pkg.bin);
28
+ for (const [name, file] of bins) {
29
+ if (typeof file === "string") {
30
+ declared.push({ file, kind: bins.length > 1 ? `bin ${name}` : "bin" });
31
+ }
32
+ }
33
+ }
34
+ const seen = new Set();
35
+ const out = [];
36
+ for (const e of declared) {
37
+ const file = sourceOf(projectPath, e.file.replace(/^\.\//, ""));
38
+ if (seen.has(file))
39
+ continue;
40
+ seen.add(file);
41
+ out.push({ file, kind: e.kind });
42
+ }
43
+ return out;
44
+ }
45
+ /** `dist/cli/index.js` → `src/cli/index.ts` when that source exists, else the path unchanged. */
46
+ function sourceOf(projectPath, file) {
47
+ const m = /^(?:dist|build|lib|out)\/(.+)\.[cm]?js$/.exec(file);
48
+ if (!m)
49
+ return file;
50
+ for (const ext of [".ts", ".tsx", ".mts", ".js", ".mjs"]) {
51
+ const candidate = `src/${m[1]}${ext}`;
52
+ if (fs.existsSync(path.join(projectPath, candidate)))
53
+ return candidate;
54
+ }
55
+ return file;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "2.0.23",
3
+ "version": "2.0.25",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -43,8 +43,10 @@
43
43
  "check": "prettier --check . && eslint .",
44
44
  "budget:calibrate": "node scripts/budget-calibrate.mjs",
45
45
  "pretest": "tsc -p tsconfig.test.json && node scripts/prep-test-assets.mjs",
46
- "test": "node --test --test-concurrency=1 --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' 'dist-test/test/**/*.test.js'",
47
- "prepublishOnly": "npm run build"
46
+ "test": "node --test 'dist-test/test/**/*.test.js'",
47
+ "prepublishOnly": "npm run build",
48
+ "test:ci": "node --test --test-concurrency=1 --experimental-test-coverage --test-coverage-lines=80 --test-coverage-functions=80 --test-coverage-branches=80 --test-coverage-exclude='dist-test/test/**' --test-coverage-exclude='dist/**' 'dist-test/test/**/*.test.js'",
49
+ "pretest:ci": "npm run pretest"
48
50
  },
49
51
  "engines": {
50
52
  "node": ">=22.16"