@esneiderbravo/speclaw 2.0.21 → 2.0.23
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/modules/compass/register.js +11 -2
- package/dist/modules/cortex/harness.js +11 -1
- package/dist/modules/foundation/assets/AGENTS.template.md +5 -2
- package/dist/modules/foundation/assets/CLAUDE.template.md +8 -4
- package/dist/modules/foundation/assets/docs/standards/testing-standards.template.md +4 -2
- package/dist/modules/foundation/lock.js +4 -17
- package/dist/modules/lawbook/assets/commands/archive.md +3 -2
- package/dist/modules/lawbook/change-tool.js +39 -11
- package/dist/modules/lawbook/engine.js +7 -3
- package/dist/modules/lawbook/register.js +7 -4
- package/dist/modules/lawbook/ship.js +152 -8
- package/dist/shared/compass-map.js +20 -0
- package/package.json +1 -1
|
@@ -30,12 +30,21 @@ export function registerCompass(server, opts = {}) {
|
|
|
30
30
|
};
|
|
31
31
|
add("compass_explore", "Symbol context in one call: source, callers, callees, blast radius, tests. maxDepth>1 returns the whole callee chain with source.", {
|
|
32
32
|
projectPath: z.string(),
|
|
33
|
-
node: z.string(),
|
|
33
|
+
node: z.string().optional(),
|
|
34
|
+
query: z.string().optional(),
|
|
34
35
|
to: z.string().optional(),
|
|
35
36
|
include: includeEnum.optional(),
|
|
36
37
|
mode: z.enum(["brief", "full"]).optional(),
|
|
37
38
|
maxDepth: z.number().int().min(1).max(8).optional(),
|
|
38
|
-
}, async ({ projectPath, node, to, include, mode, maxDepth }) => {
|
|
39
|
+
}, async ({ projectPath, node, query, to, include, mode, maxDepth }) => {
|
|
40
|
+
if (!node) {
|
|
41
|
+
// Agents send a search phrase here; answer it as a find instead of a
|
|
42
|
+
// validation error that costs a second call.
|
|
43
|
+
if (!query)
|
|
44
|
+
throw new Error("compass_explore requires 'node' (a symbol name)");
|
|
45
|
+
const found = await findSymbols(projectPath, query, "concept");
|
|
46
|
+
return text(formatFindResponse(found), { maxTokens: found.cap });
|
|
47
|
+
}
|
|
39
48
|
const result = await exploreRich({
|
|
40
49
|
projectPath,
|
|
41
50
|
node,
|
|
@@ -37,7 +37,17 @@ function nowIso() {
|
|
|
37
37
|
function requireChangeDir(projectPath, change) {
|
|
38
38
|
const resolved = resolveChangeDir(projectPath, change);
|
|
39
39
|
if (!resolved) {
|
|
40
|
-
|
|
40
|
+
const changesDir = path.join(specRoot(projectPath), "changes");
|
|
41
|
+
const active = fs.existsSync(changesDir)
|
|
42
|
+
? fs
|
|
43
|
+
.readdirSync(changesDir, { withFileTypes: true })
|
|
44
|
+
.filter((e) => e.isDirectory() && e.name !== "archive")
|
|
45
|
+
.map((e) => e.name)
|
|
46
|
+
: [];
|
|
47
|
+
// Name the next step: most misses are a start before the change was drafted.
|
|
48
|
+
throw new Error(`change "${change}" not found under lawbook/changes/ — draft it first ` +
|
|
49
|
+
`(lawbook_change action 'draft')` +
|
|
50
|
+
(active.length ? `; active: ${active.join(", ")}` : ""));
|
|
41
51
|
}
|
|
42
52
|
return resolved;
|
|
43
53
|
}
|
|
@@ -25,7 +25,7 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
|
|
|
25
25
|
sheet: [`docs/compass.md`](docs/compass.md).
|
|
26
26
|
3. **Cortex, always — one brain on the critical path** (*One brain. Many
|
|
27
27
|
agents.*). Branch `<type>/<slug>`, locate with Compass, implement the
|
|
28
|
-
change and its test yourself, run the tests, finish. The `Stop` hook
|
|
28
|
+
change and its test yourself, run the tests that cover it, finish. The `Stop` hook
|
|
29
29
|
(`speclaw ship-on-stop`) — or `speclaw ship <change> --summary "<what and
|
|
30
30
|
why>"` as your last step on agents without hooks — sizes the change from
|
|
31
31
|
its diff, names the artifacts that level owes while you edit (the why,
|
|
@@ -36,7 +36,10 @@ Claude-specific notes: [`CLAUDE.md`](CLAUDE.md). The law: [`LAWS.md`](LAWS.md).
|
|
|
36
36
|
agents only to run three or more large, independent parts in parallel.
|
|
37
37
|
Details: the `cortex` skill and
|
|
38
38
|
[`docs/standards/lawbook.md`](docs/standards/lawbook.md).
|
|
39
|
-
4. **
|
|
39
|
+
4. **The quality gates run once** before anything is declared done — by the
|
|
40
|
+
`Stop` hook where there is one, scoped to the tests the diff reaches (run only
|
|
41
|
+
those yourself: `speclaw affected-tests --file <path>`; the full suite runs
|
|
42
|
+
in CI), else by you or `speclaw ship` — see
|
|
40
43
|
[`docs/standards/testing-standards.md`](docs/standards/testing-standards.md):
|
|
41
44
|
- Tests: `{{test_commands}}`
|
|
42
45
|
- Lint / type-check: `{{lint_commands}}`
|
|
@@ -54,9 +54,11 @@ See [`docs/compass.md`](docs/compass.md).
|
|
|
54
54
|
|
|
55
55
|
Every change is built with **Cortex** (*One brain. Many agents.*), and Cortex
|
|
56
56
|
costs no more time than working alone: branch `<type>/<slug>`, locate with
|
|
57
|
-
Compass, implement the change and its test yourself, run the tests
|
|
57
|
+
Compass, implement the change and its test yourself, run only the tests that
|
|
58
|
+
cover it (`speclaw affected-tests --file <path>` prints the command), finish.
|
|
58
59
|
Then stop: the `Stop` hook (`speclaw ship-on-stop`) sizes the change from its
|
|
59
|
-
diff, runs the gates once,
|
|
60
|
+
diff, runs the gates once — lint, build and the tests the diff reaches (the
|
|
61
|
+
full suite runs in CI) — writes the report from their real output, and
|
|
60
62
|
archives level-0 work with no agent turns. What the level owes (the why — a
|
|
61
63
|
commit body counts — tasks, delta specs, proposal/design) is named while you
|
|
62
64
|
edit: write it in the same turn. A missing piece or a failing gate comes back
|
|
@@ -72,8 +74,10 @@ parallel. Details: the `cortex` skill and
|
|
|
72
74
|
- Tests: `{{test_commands}}`
|
|
73
75
|
- Lint / type-check: `{{lint_commands}}`
|
|
74
76
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
+
With the `Stop` hook they run once, at the stop, scoped to the tests the diff
|
|
78
|
+
reaches — never run the full suite yourself; a failure comes back to you. Without the hook, run them
|
|
79
|
+
yourself (or `speclaw ship`) before declaring anything done. Never claim
|
|
80
|
+
success you did not observe. Full rules:
|
|
77
81
|
[`docs/standards/testing-standards.md`](docs/standards/testing-standards.md).
|
|
78
82
|
|
|
79
83
|
## Rule 4 — Conventions
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# Testing & Quality Gates — {{project_name}}
|
|
2
2
|
|
|
3
3
|
The quality law of the project — see [`../../LAWS.md`](../../LAWS.md). These
|
|
4
|
-
gates are non-negotiable:
|
|
5
|
-
|
|
4
|
+
gates are non-negotiable: they run once, with real output, before anything is
|
|
5
|
+
declared done — by the `Stop` hook where installed (the agent then runs only
|
|
6
|
+
the tests its change touches, never the full suite twice), else by the agent
|
|
7
|
+
itself or `speclaw ship`.
|
|
6
8
|
|
|
7
9
|
## Gates
|
|
8
10
|
|
|
@@ -8,6 +8,7 @@ import crypto from "node:crypto";
|
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
10
|
import { pkgName, pkgVersion } from "../../shared/version.js";
|
|
11
|
+
import { COMPASS_MAP_END, COMPASS_MAP_START, stripCompassMapBlock, } from "../../shared/compass-map.js";
|
|
11
12
|
export const LOCKFILE_NAME = "speclaw.lock";
|
|
12
13
|
export const LOCKFILE_VERSION = 1;
|
|
13
14
|
/** Delimited provenance block excluded from digests (self-reference). */
|
|
@@ -270,23 +271,9 @@ function walkFiles(dir, projectPath, out, pred) {
|
|
|
270
271
|
}
|
|
271
272
|
}
|
|
272
273
|
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
export
|
|
276
|
-
/**
|
|
277
|
-
* Strip the regenerable map body between markers so integrity digests stay stable
|
|
278
|
-
* across `speclaw index` (which rewrites the map in CI before verify).
|
|
279
|
-
*
|
|
280
|
-
* @param text - Full docs/compass.md contents.
|
|
281
|
-
* @returns The same text with an empty map body, or `text` if markers are missing.
|
|
282
|
-
*/
|
|
283
|
-
export function stripCompassMapBlock(text) {
|
|
284
|
-
const start = text.indexOf(COMPASS_MAP_START);
|
|
285
|
-
const end = text.indexOf(COMPASS_MAP_END);
|
|
286
|
-
if (start < 0 || end < 0 || end < start)
|
|
287
|
-
return text;
|
|
288
|
-
return text.slice(0, start + COMPASS_MAP_START.length) + "\n" + text.slice(end);
|
|
289
|
-
}
|
|
274
|
+
// The map block helpers moved to shared/ so the lawbook can ignore the
|
|
275
|
+
// regenerated map too; re-exported here for existing importers.
|
|
276
|
+
export { COMPASS_MAP_END, COMPASS_MAP_START, stripCompassMapBlock };
|
|
290
277
|
/**
|
|
291
278
|
* Path-specific bytes that feed {@link digestText}: speclaw-owned coderabbit
|
|
292
279
|
* region, regenerable Compass map body stripped, otherwise the file as-is.
|
|
@@ -6,5 +6,6 @@ Archive the completed change: $ARGUMENTS
|
|
|
6
6
|
|
|
7
7
|
Follow the `archive` skill: confirm every task (or level-0 checklist) is done
|
|
8
8
|
and gates are green, reconcile if the level has delta specs, run
|
|
9
|
-
`lawbook_change` (action: validate), then `lawbook_change` (action: archive)
|
|
10
|
-
|
|
9
|
+
`lawbook_change` (action: validate), then `lawbook_change` (action: archive). Archive dates
|
|
10
|
+
the folder today and syncs the delta specs itself when the level carries them. Never move
|
|
11
|
+
the folder by hand.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
|
|
3
3
|
import { handleLevel } from "./quick.js";
|
|
4
|
-
import { shipChange } from "./ship.js";
|
|
4
|
+
import { resolveActiveChange, shipChange } from "./ship.js";
|
|
5
5
|
import { scaffoldBugfix } from "./bugfix.js";
|
|
6
6
|
import { scaffoldFeature } from "./scaffold-change.js";
|
|
7
7
|
import { buildCoverageReport, loadCoverageConfig, renderCoverageAgent } from "./coverage.js";
|
|
@@ -21,17 +21,27 @@ export const lawbookChangeActions = [
|
|
|
21
21
|
"harness",
|
|
22
22
|
"ship",
|
|
23
23
|
];
|
|
24
|
+
// Words agents reach for that name a real action; each one used to cost a failed call.
|
|
25
|
+
const ACTION_SYNONYMS = { create: "draft", new: "draft" };
|
|
26
|
+
/** A list arg sent as one comma- or newline-separated string still means the list. */
|
|
27
|
+
const looseList = z.preprocess((v) => typeof v === "string"
|
|
28
|
+
? v
|
|
29
|
+
.split(/[,\n]/)
|
|
30
|
+
.map((s) => s.trim())
|
|
31
|
+
.filter(Boolean)
|
|
32
|
+
: v, z.array(z.string()));
|
|
24
33
|
export const lawbookChangeSchema = {
|
|
25
34
|
projectPath: z.string(),
|
|
26
|
-
action: z.enum(lawbookChangeActions),
|
|
35
|
+
action: z.preprocess((v) => (typeof v === "string" ? (ACTION_SYNONYMS[v] ?? v) : v), z.enum(lawbookChangeActions)),
|
|
27
36
|
change: z.string().optional(),
|
|
37
|
+
name: z.string().optional(),
|
|
28
38
|
date: z
|
|
29
39
|
.string()
|
|
30
40
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
31
41
|
.optional(),
|
|
32
42
|
mode: z.enum(["propose", "set", "promote", "explain"]).optional(),
|
|
33
|
-
paths:
|
|
34
|
-
symbols:
|
|
43
|
+
paths: looseList.optional(),
|
|
44
|
+
symbols: looseList.optional(),
|
|
35
45
|
level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
|
|
36
46
|
reason: z.string().optional(),
|
|
37
47
|
onlyDefects: z.boolean().optional(),
|
|
@@ -52,6 +62,21 @@ function requireField(args, field) {
|
|
|
52
62
|
return v;
|
|
53
63
|
throw new Error(`lawbook_change: action '${args.action}' requires '${String(field)}'`);
|
|
54
64
|
}
|
|
65
|
+
/** The change named by `change` or `name`, else the one the project and branch point at. */
|
|
66
|
+
function targetChange(args) {
|
|
67
|
+
const named = args.change || args.name;
|
|
68
|
+
if (named)
|
|
69
|
+
return named;
|
|
70
|
+
const inferred = resolveActiveChange(args.projectPath);
|
|
71
|
+
if (inferred)
|
|
72
|
+
return inferred;
|
|
73
|
+
const active = specList(args.projectPath).activeChanges;
|
|
74
|
+
throw new Error(`lawbook_change: action '${args.action}' requires 'change'` +
|
|
75
|
+
(active.length ? ` — active: ${active.join(", ")}` : " — no active change; draft one first"));
|
|
76
|
+
}
|
|
77
|
+
function today() {
|
|
78
|
+
return new Date().toISOString().slice(0, 10);
|
|
79
|
+
}
|
|
55
80
|
/**
|
|
56
81
|
* Dispatch `lawbook_change` by action.
|
|
57
82
|
*
|
|
@@ -65,7 +90,7 @@ export function handleLawbookChange(args) {
|
|
|
65
90
|
return specList(args.projectPath);
|
|
66
91
|
case "draft": {
|
|
67
92
|
// Covers: req~feature-draft~1
|
|
68
|
-
const name = requireField(args, "change");
|
|
93
|
+
const name = args.change || args.name || requireField(args, "change");
|
|
69
94
|
if (args.bug)
|
|
70
95
|
return scaffoldBugfix(args.projectPath, name, { level: args.level });
|
|
71
96
|
return scaffoldFeature(args.projectPath, name, {
|
|
@@ -75,13 +100,13 @@ export function handleLawbookChange(args) {
|
|
|
75
100
|
});
|
|
76
101
|
}
|
|
77
102
|
case "validate":
|
|
78
|
-
return specValidate(args.projectPath,
|
|
103
|
+
return specValidate(args.projectPath, targetChange(args));
|
|
79
104
|
case "sync":
|
|
80
|
-
return specSync(args.projectPath,
|
|
105
|
+
return specSync(args.projectPath, targetChange(args));
|
|
81
106
|
case "archive":
|
|
82
|
-
return specArchive(args.projectPath,
|
|
107
|
+
return specArchive(args.projectPath, targetChange(args), args.date ?? today());
|
|
83
108
|
case "ship":
|
|
84
|
-
return shipChange(args.projectPath,
|
|
109
|
+
return shipChange(args.projectPath, targetChange(args), {
|
|
85
110
|
summary: args.note,
|
|
86
111
|
date: args.date,
|
|
87
112
|
});
|
|
@@ -91,7 +116,10 @@ export function handleLawbookChange(args) {
|
|
|
91
116
|
return handleLevel({
|
|
92
117
|
projectPath: args.projectPath,
|
|
93
118
|
mode: args.mode,
|
|
94
|
-
change
|
|
119
|
+
// `propose` and `explain` only measure; `set` and `promote` act on a change.
|
|
120
|
+
change: args.mode === "propose" || args.mode === "explain"
|
|
121
|
+
? args.change || args.name
|
|
122
|
+
: targetChange(args),
|
|
95
123
|
paths: args.paths,
|
|
96
124
|
symbols: args.symbols,
|
|
97
125
|
level: args.level,
|
|
@@ -121,7 +149,7 @@ export function handleLawbookChange(args) {
|
|
|
121
149
|
}
|
|
122
150
|
const result = handleHarness({
|
|
123
151
|
projectPath: args.projectPath,
|
|
124
|
-
change:
|
|
152
|
+
change: targetChange(args),
|
|
125
153
|
harnessOp: args.harnessOp,
|
|
126
154
|
verdict: args.verdict ?? null,
|
|
127
155
|
openQuestions: args.openQuestions,
|
|
@@ -380,9 +380,11 @@ export function specSync(projectPath, change) {
|
|
|
380
380
|
*
|
|
381
381
|
* @param projectPath - Absolute path to the project root.
|
|
382
382
|
* @param change - Change name (folder under lawbook/changes/).
|
|
383
|
+
* @param opts.syncing - The caller promotes the delta specs itself (archive
|
|
384
|
+
* does), so an unsynced delta is not a blocker.
|
|
383
385
|
* @returns Human-readable blockers; empty when the change is ready to archive.
|
|
384
386
|
*/
|
|
385
|
-
export function specArchivePreconditions(projectPath, change) {
|
|
387
|
+
export function specArchivePreconditions(projectPath, change, opts = {}) {
|
|
386
388
|
const root = specRoot(projectPath);
|
|
387
389
|
const changeDir = path.join(root, "changes", change);
|
|
388
390
|
if (!fs.existsSync(changeDir))
|
|
@@ -439,7 +441,7 @@ export function specArchivePreconditions(projectPath, change) {
|
|
|
439
441
|
const bugNeedsDelta = needs.bugfix &&
|
|
440
442
|
fs.existsSync(bugPath) &&
|
|
441
443
|
preventionRequiresDelta(fs.readFileSync(bugPath, "utf8"));
|
|
442
|
-
if (needs.deltaSpecs || bugNeedsDelta) {
|
|
444
|
+
if ((needs.deltaSpecs || bugNeedsDelta) && !opts.syncing) {
|
|
443
445
|
for (const file of deltaSpecFiles(changeDir)) {
|
|
444
446
|
const rel = path.relative(path.join(changeDir, "specs"), file);
|
|
445
447
|
const canonical = path.join(root, "specs", rel);
|
|
@@ -474,7 +476,9 @@ export function specArchive(projectPath, change, date) {
|
|
|
474
476
|
const changeDir = path.join(root, "changes", change);
|
|
475
477
|
if (!fs.existsSync(changeDir))
|
|
476
478
|
throw new Error(`change "${change}" not found`);
|
|
477
|
-
|
|
479
|
+
// Archive promotes the delta specs below; demanding a manual sync first only
|
|
480
|
+
// cost agents a failed call and a retry.
|
|
481
|
+
const blockers = specArchivePreconditions(projectPath, change, { syncing: true });
|
|
478
482
|
if (blockers.length > 0) {
|
|
479
483
|
throw new Error(`cannot archive "${change}" — resolve first:\n${blockers.map((b) => ` - ${b}`).join("\n")}`);
|
|
480
484
|
}
|
|
@@ -67,13 +67,13 @@ export function registerSpec(server, opts = {}) {
|
|
|
67
67
|
defineAliasTool(server, {
|
|
68
68
|
name: "lawbook_validate",
|
|
69
69
|
description: "Deprecated alias for lawbook_change action validate.",
|
|
70
|
-
inputSchema: { projectPath: z.string(), change: z.string() },
|
|
70
|
+
inputSchema: { projectPath: z.string(), change: z.string().optional() },
|
|
71
71
|
handler: aliasHandler("lawbook_validate", "validate"),
|
|
72
72
|
});
|
|
73
73
|
defineAliasTool(server, {
|
|
74
74
|
name: "lawbook_sync",
|
|
75
75
|
description: "Deprecated alias for lawbook_change action sync.",
|
|
76
|
-
inputSchema: { projectPath: z.string(), change: z.string() },
|
|
76
|
+
inputSchema: { projectPath: z.string(), change: z.string().optional() },
|
|
77
77
|
handler: aliasHandler("lawbook_sync", "sync"),
|
|
78
78
|
});
|
|
79
79
|
defineAliasTool(server, {
|
|
@@ -81,8 +81,11 @@ export function registerSpec(server, opts = {}) {
|
|
|
81
81
|
description: "Deprecated alias for lawbook_change action archive.",
|
|
82
82
|
inputSchema: {
|
|
83
83
|
projectPath: z.string(),
|
|
84
|
-
change: z.string(),
|
|
85
|
-
date: z
|
|
84
|
+
change: z.string().optional(),
|
|
85
|
+
date: z
|
|
86
|
+
.string()
|
|
87
|
+
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
88
|
+
.optional(),
|
|
86
89
|
},
|
|
87
90
|
handler: aliasHandler("lawbook_archive", "archive"),
|
|
88
91
|
});
|
|
@@ -4,8 +4,11 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
6
6
|
import { changedFiles, isGitRepo, mergeBase, worktreeChangedFiles } from "../../shared/git.js";
|
|
7
|
+
import { isTestPath, loadAffectedConfig, matchesAny } from "../compass/affected-config.js";
|
|
8
|
+
import { affectedTests } from "../compass/affected.js";
|
|
9
|
+
import { COMPASS_DOC, stripCompassMapBlock } from "../../shared/compass-map.js";
|
|
7
10
|
import { handleHarness, readHarness } from "../cortex/harness.js";
|
|
8
|
-
import { deltaSpecFiles, specArchive, specArchivePreconditions } from "./engine.js";
|
|
11
|
+
import { deltaSpecFiles, specArchive, specArchivePreconditions, specList } from "./engine.js";
|
|
9
12
|
import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
|
|
10
13
|
import { scaffoldQuick } from "./quick.js";
|
|
11
14
|
import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
|
|
@@ -42,6 +45,9 @@ export function readShipConfig(projectPath) {
|
|
|
42
45
|
const d = /^\s+discipline:\s*["']?([\w-]+)["']?\s*$/.exec(line);
|
|
43
46
|
if (d)
|
|
44
47
|
out.discipline = d[1];
|
|
48
|
+
const t = /^\s+tests:\s*["']?(affected|full)["']?\s*$/.exec(line);
|
|
49
|
+
if (t)
|
|
50
|
+
out.tests = t[1];
|
|
45
51
|
}
|
|
46
52
|
return out;
|
|
47
53
|
}
|
|
@@ -65,6 +71,39 @@ export function detectGates(projectPath) {
|
|
|
65
71
|
const deduped = picked.includes("check") ? picked.filter((s) => s !== "lint") : picked;
|
|
66
72
|
return deduped.map((s) => (s === "test" ? "npm test" : `npm run ${s}`));
|
|
67
73
|
}
|
|
74
|
+
/** The project's whole test suite, as `detectGates` and people write it. */
|
|
75
|
+
const TEST_GATE_RE = /^(npm|pnpm|yarn)( run)? test\s*$/;
|
|
76
|
+
/**
|
|
77
|
+
* The tests a branch diff reaches, to run at the stop instead of the whole
|
|
78
|
+
* suite: a one-line fix should not wait on every test in the repo, and the
|
|
79
|
+
* full suite still runs in CI before merge. Falls back to the full suite when
|
|
80
|
+
* the selection cannot be trusted — no index, a global file, or source changes
|
|
81
|
+
* no test reaches.
|
|
82
|
+
*
|
|
83
|
+
* @returns The command to run (null: nothing to run) and what it covers.
|
|
84
|
+
*/
|
|
85
|
+
function scopedTestGate(projectPath, fullCommand, files) {
|
|
86
|
+
const full = (why) => ({ command: fullCommand, scope: `full suite: ${why}` });
|
|
87
|
+
try {
|
|
88
|
+
const at = affectedTests(projectPath, { files });
|
|
89
|
+
if (at.mode === "all")
|
|
90
|
+
return full(at.reason);
|
|
91
|
+
if (at.tests.length === 0 || !at.command) {
|
|
92
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
93
|
+
const code = files.filter((f) => !matchesAny(f, thresholds.docGlobs));
|
|
94
|
+
if (code.length)
|
|
95
|
+
return full("no test reaches the changed code");
|
|
96
|
+
return { command: null, scope: "docs only: no test reaches the change" };
|
|
97
|
+
}
|
|
98
|
+
return {
|
|
99
|
+
command: at.command,
|
|
100
|
+
scope: `${at.tests.length} affected test file(s), ${at.skipped.files} skipped; the full suite runs in CI`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return full(e instanceof Error ? e.message : String(e));
|
|
105
|
+
}
|
|
106
|
+
}
|
|
68
107
|
function runGate(projectPath, command) {
|
|
69
108
|
const start = Date.now();
|
|
70
109
|
const r = spawnSync(command, { cwd: projectPath, shell: true, encoding: "utf8" });
|
|
@@ -89,8 +128,28 @@ function branchFiles(projectPath) {
|
|
|
89
128
|
]),
|
|
90
129
|
]
|
|
91
130
|
.filter((f) => !f.startsWith("lawbook/changes/"))
|
|
131
|
+
.filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
|
|
92
132
|
.sort();
|
|
93
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Whether `docs/compass.md` differs from `base` only inside its generated map
|
|
136
|
+
* block: every full index rewrites that block, so on a fresh branch it would
|
|
137
|
+
* otherwise read as the agent's work and ship an empty change.
|
|
138
|
+
*/
|
|
139
|
+
function onlyMapChanged(projectPath, base) {
|
|
140
|
+
if (!base)
|
|
141
|
+
return false;
|
|
142
|
+
try {
|
|
143
|
+
const now = fs.readFileSync(path.join(projectPath, COMPASS_DOC), "utf8");
|
|
144
|
+
const then = git(projectPath, ["show", `${base}:${COMPASS_DOC}`]);
|
|
145
|
+
// Line endings are the platform's (core.autocrlf), not the agent's work.
|
|
146
|
+
const lf = (t) => stripCompassMapBlock(t.replace(/\r\n/g, "\n"));
|
|
147
|
+
return then !== "" && lf(now) === lf(then);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
94
153
|
/** New files not yet added, outside ship's own output (`lawbook/changes/`, `.speclaw/`). */
|
|
95
154
|
function untrackedFiles(projectPath) {
|
|
96
155
|
return git(projectPath, [
|
|
@@ -108,7 +167,7 @@ function secs(ms) {
|
|
|
108
167
|
}
|
|
109
168
|
function renderReport(change, discipline, files, gates, passed, date) {
|
|
110
169
|
const rows = gates
|
|
111
|
-
.map((g) => `| \`${g.command}
|
|
170
|
+
.map((g) => `| \`${g.command}\`${g.scope ? ` (${g.scope})` : ""} | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
|
|
112
171
|
.join("\n");
|
|
113
172
|
const outputs = gates
|
|
114
173
|
.map((g) => `### \`${g.command}\`\n\n\`\`\`text\n${g.tail}\n\`\`\``)
|
|
@@ -207,11 +266,61 @@ function isVersionBump(projectPath, base, file) {
|
|
|
207
266
|
.filter((l) => /^[+-](?![+-]{2})/.test(l));
|
|
208
267
|
return lines.length > 0 && lines.every((l) => /^[+-]\s*"version"\s*:/.test(l));
|
|
209
268
|
}
|
|
269
|
+
/** Source lines a diff may change, in one source file, and still be a level-0 fix. */
|
|
270
|
+
const SMALL_FIX_LINES = 10;
|
|
210
271
|
/**
|
|
211
272
|
* Propose a ceremony level from the branch diff itself. A release bump of the
|
|
212
|
-
* manifests is not a global change, so it does not raise the level
|
|
273
|
+
* manifests is not a global change, so it does not raise the level, and a
|
|
274
|
+
* small fix stays at level 0 however central the code it touches: its blast
|
|
275
|
+
* radius is guarded by the gates and its test, which ceremony would not add to.
|
|
213
276
|
*/
|
|
214
277
|
function measureDiff(projectPath, files) {
|
|
278
|
+
const proposal = measureDiffSignals(projectPath, files);
|
|
279
|
+
if (!proposal.level || proposal.signals.touchesPublicApi || proposal.signals.touchesGlobalFile) {
|
|
280
|
+
return proposal;
|
|
281
|
+
}
|
|
282
|
+
const fix = smallFix(projectPath, files);
|
|
283
|
+
if (!fix)
|
|
284
|
+
return proposal;
|
|
285
|
+
return {
|
|
286
|
+
...proposal,
|
|
287
|
+
level: 0,
|
|
288
|
+
rationale: `${proposal.rationale}; small fix (${fix.lines} source line(s) in ${fix.files} file) → level 0`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* The size of a diff that changes at most one source file (tests and docs
|
|
293
|
+
* aside) by at most {@link SMALL_FIX_LINES} lines, or null when it is larger.
|
|
294
|
+
*/
|
|
295
|
+
function smallFix(projectPath, files) {
|
|
296
|
+
const { testGlobs } = loadAffectedConfig(projectPath);
|
|
297
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
298
|
+
const source = files.filter((f) => !isTestPath(f, testGlobs) && !matchesAny(f, thresholds.docGlobs));
|
|
299
|
+
if (source.length > 1)
|
|
300
|
+
return null;
|
|
301
|
+
if (source.length === 0)
|
|
302
|
+
return { files: 0, lines: 0 };
|
|
303
|
+
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
304
|
+
const numstat = base ? git(projectPath, ["diff", "--numstat", base, "--", source[0]]) : "";
|
|
305
|
+
let lines = numstat
|
|
306
|
+
.split("\n")
|
|
307
|
+
.filter(Boolean)
|
|
308
|
+
.reduce((n, l) => {
|
|
309
|
+
const [add, del] = l.split("\t");
|
|
310
|
+
return n + (Number(add) || 0) + (Number(del) || 0);
|
|
311
|
+
}, 0);
|
|
312
|
+
if (!numstat.trim()) {
|
|
313
|
+
// Untracked: every line is new.
|
|
314
|
+
try {
|
|
315
|
+
lines = fs.readFileSync(path.join(projectPath, source[0]), "utf8").split("\n").length;
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return lines <= SMALL_FIX_LINES ? { files: 1, lines } : null;
|
|
322
|
+
}
|
|
323
|
+
function measureDiffSignals(projectPath, files) {
|
|
215
324
|
const cached = cachedMeasure(projectPath, files);
|
|
216
325
|
if (cached)
|
|
217
326
|
return cached;
|
|
@@ -503,8 +612,15 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
503
612
|
const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
|
|
504
613
|
const gates = [];
|
|
505
614
|
for (const command of commands) {
|
|
506
|
-
const
|
|
507
|
-
|
|
615
|
+
const scoped = cfg.tests !== "full" && TEST_GATE_RE.test(command.trim())
|
|
616
|
+
? scopedTestGate(projectPath, command, files)
|
|
617
|
+
: null;
|
|
618
|
+
if (scoped && scoped.command === null) {
|
|
619
|
+
gates.push({ command, exitCode: 0, durationMs: 0, tail: "", scope: scoped.scope });
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const g = runGate(projectPath, scoped?.command ?? command);
|
|
623
|
+
gates.push(scoped ? { ...g, scope: scoped.scope } : g);
|
|
508
624
|
if (g.exitCode !== 0)
|
|
509
625
|
break;
|
|
510
626
|
}
|
|
@@ -529,7 +645,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
529
645
|
}
|
|
530
646
|
else if (!opts.noArchive) {
|
|
531
647
|
recordGateVerdict(projectPath, name);
|
|
532
|
-
const pre = specArchivePreconditions(projectPath, name);
|
|
648
|
+
const pre = specArchivePreconditions(projectPath, name, { syncing: true });
|
|
533
649
|
if (pre.length)
|
|
534
650
|
next.push(...pre);
|
|
535
651
|
else
|
|
@@ -690,6 +806,28 @@ export function changeNameForBranch(branch) {
|
|
|
690
806
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
691
807
|
.replace(/^-+|-+$/g, "") || "change");
|
|
692
808
|
}
|
|
809
|
+
/**
|
|
810
|
+
* The change an action means when the agent left `change` out: the only
|
|
811
|
+
* active change, else the one this branch ships (same rule as the stop hook).
|
|
812
|
+
*
|
|
813
|
+
* @param projectPath - Project root.
|
|
814
|
+
* @returns The change name, or null when no single change fits.
|
|
815
|
+
*/
|
|
816
|
+
export function resolveActiveChange(projectPath) {
|
|
817
|
+
const active = specList(projectPath).activeChanges;
|
|
818
|
+
if (active.length === 1)
|
|
819
|
+
return active[0];
|
|
820
|
+
if (active.length === 0 || !isGitRepo(projectPath))
|
|
821
|
+
return null;
|
|
822
|
+
const branch = currentBranch(projectPath);
|
|
823
|
+
const named = branchChange(projectPath, branch);
|
|
824
|
+
if (active.includes(named))
|
|
825
|
+
return named;
|
|
826
|
+
// `feat/FAR-1360-default-cost-center` ships `default-cost-center` too.
|
|
827
|
+
const slug = changeNameForBranch(branch);
|
|
828
|
+
const fits = active.filter((c) => slug.endsWith(`-${c}`) || c.endsWith(`-${slug}`));
|
|
829
|
+
return fits.length === 1 ? fits[0] : null;
|
|
830
|
+
}
|
|
693
831
|
function git(projectPath, args) {
|
|
694
832
|
const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
|
|
695
833
|
return r.status === 0 ? r.stdout : "";
|
|
@@ -748,12 +886,18 @@ function workFingerprint(projectPath) {
|
|
|
748
886
|
":(exclude).speclaw",
|
|
749
887
|
]).split("\n");
|
|
750
888
|
const hash = createHash("sha256");
|
|
751
|
-
|
|
889
|
+
const files = [...new Set([...tracked, ...untrackedFiles(projectPath)])]
|
|
890
|
+
// A map-only docs/compass.md is no work: neither its bytes nor its name count.
|
|
891
|
+
.filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
|
|
892
|
+
.sort();
|
|
893
|
+
for (const f of files) {
|
|
752
894
|
if (!f)
|
|
753
895
|
continue;
|
|
754
896
|
hash.update(`${f}\n`);
|
|
755
897
|
try {
|
|
756
|
-
|
|
898
|
+
const bytes = fs.readFileSync(path.join(projectPath, f));
|
|
899
|
+
// A re-index rewrites the map block; only the rest of the file is work.
|
|
900
|
+
hash.update(f === COMPASS_DOC ? stripCompassMapBlock(bytes.toString("utf8")) : bytes);
|
|
757
901
|
}
|
|
758
902
|
catch {
|
|
759
903
|
// Deleted, vanished or unreadable: its name stands in.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Markers for the regenerable map block inside `docs/compass.md` (see compass/map.ts). */
|
|
2
|
+
export const COMPASS_MAP_START = "<!-- speclaw:map:start -->";
|
|
3
|
+
export const COMPASS_MAP_END = "<!-- speclaw:map:end -->";
|
|
4
|
+
/** The project-relative path of the Compass guide that carries the generated map. */
|
|
5
|
+
export const COMPASS_DOC = "docs/compass.md";
|
|
6
|
+
/**
|
|
7
|
+
* Strip the regenerable map body between markers. `speclaw index` rewrites it on
|
|
8
|
+
* every full run, so integrity digests and "did the agent change this file?"
|
|
9
|
+
* checks must both ignore it.
|
|
10
|
+
*
|
|
11
|
+
* @param text - Full docs/compass.md contents.
|
|
12
|
+
* @returns The same text with an empty map body, or `text` if markers are missing.
|
|
13
|
+
*/
|
|
14
|
+
export function stripCompassMapBlock(text) {
|
|
15
|
+
const start = text.indexOf(COMPASS_MAP_START);
|
|
16
|
+
const end = text.indexOf(COMPASS_MAP_END);
|
|
17
|
+
if (start < 0 || end < 0 || end < start)
|
|
18
|
+
return text;
|
|
19
|
+
return text.slice(0, start + COMPASS_MAP_START.length) + "\n" + text.slice(end);
|
|
20
|
+
}
|