@esneiderbravo/speclaw 2.0.22 → 2.0.24
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/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 +143 -16
- package/package.json +5 -3
|
@@ -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
|
|
|
@@ -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,9 +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";
|
|
7
9
|
import { COMPASS_DOC, stripCompassMapBlock } from "../../shared/compass-map.js";
|
|
8
10
|
import { handleHarness, readHarness } from "../cortex/harness.js";
|
|
9
|
-
import { deltaSpecFiles, specArchive, specArchivePreconditions } from "./engine.js";
|
|
11
|
+
import { deltaSpecFiles, specArchive, specArchivePreconditions, specList } from "./engine.js";
|
|
10
12
|
import { artifactNeeds, confirmedLevel, gatherSignals, loadCeremonyConfig, promoteCeremonyLevel, proposeLevel, readCeremonyRecord, readChangeType, } from "./levels.js";
|
|
11
13
|
import { scaffoldQuick } from "./quick.js";
|
|
12
14
|
import { CHANGE_NAME_RE, isPlaceholderDelta, scaffoldFeature } from "./scaffold-change.js";
|
|
@@ -43,6 +45,9 @@ export function readShipConfig(projectPath) {
|
|
|
43
45
|
const d = /^\s+discipline:\s*["']?([\w-]+)["']?\s*$/.exec(line);
|
|
44
46
|
if (d)
|
|
45
47
|
out.discipline = d[1];
|
|
48
|
+
const t = /^\s+tests:\s*["']?(affected|full)["']?\s*$/.exec(line);
|
|
49
|
+
if (t)
|
|
50
|
+
out.tests = t[1];
|
|
46
51
|
}
|
|
47
52
|
return out;
|
|
48
53
|
}
|
|
@@ -66,6 +71,39 @@ export function detectGates(projectPath) {
|
|
|
66
71
|
const deduped = picked.includes("check") ? picked.filter((s) => s !== "lint") : picked;
|
|
67
72
|
return deduped.map((s) => (s === "test" ? "npm test" : `npm run ${s}`));
|
|
68
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
|
+
}
|
|
69
107
|
function runGate(projectPath, command) {
|
|
70
108
|
const start = Date.now();
|
|
71
109
|
const r = spawnSync(command, { cwd: projectPath, shell: true, encoding: "utf8" });
|
|
@@ -77,6 +115,18 @@ function runGate(projectPath, command) {
|
|
|
77
115
|
tail: combined.slice(-GATE_TAIL_LINES).join("\n"),
|
|
78
116
|
};
|
|
79
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* What the lawbook writes itself: change folders and reports, the canonical
|
|
120
|
+
* specs an archive promotes, the anchors it seals, and the local index. None
|
|
121
|
+
* of it is the agent's work, so none of it may trigger, size or re-run a ship
|
|
122
|
+
* — archiving used to read as new work and re-run every gate.
|
|
123
|
+
*/
|
|
124
|
+
const OWN_OUTPUT = ["lawbook/changes/", "lawbook/specs/", "lawbook/anchors/", ".speclaw/"];
|
|
125
|
+
function isOwnOutput(file) {
|
|
126
|
+
return OWN_OUTPUT.some((dir) => file.startsWith(dir));
|
|
127
|
+
}
|
|
128
|
+
/** The same paths as git pathspecs, for commands that list or log the work. */
|
|
129
|
+
const OWN_OUTPUT_EXCLUDES = OWN_OUTPUT.map((dir) => `:(exclude)${dir.replace(/\/$/, "")}`);
|
|
80
130
|
function branchFiles(projectPath) {
|
|
81
131
|
if (!isGitRepo(projectPath))
|
|
82
132
|
return [];
|
|
@@ -89,7 +139,7 @@ function branchFiles(projectPath) {
|
|
|
89
139
|
...untrackedFiles(projectPath),
|
|
90
140
|
]),
|
|
91
141
|
]
|
|
92
|
-
.filter((f) => !f
|
|
142
|
+
.filter((f) => !isOwnOutput(f))
|
|
93
143
|
.filter((f) => f !== COMPASS_DOC || !onlyMapChanged(projectPath, base))
|
|
94
144
|
.sort();
|
|
95
145
|
}
|
|
@@ -112,7 +162,7 @@ function onlyMapChanged(projectPath, base) {
|
|
|
112
162
|
return false;
|
|
113
163
|
}
|
|
114
164
|
}
|
|
115
|
-
/** New files not yet added, outside
|
|
165
|
+
/** New files not yet added, outside the lawbook's own output ({@link OWN_OUTPUT}). */
|
|
116
166
|
function untrackedFiles(projectPath) {
|
|
117
167
|
return git(projectPath, [
|
|
118
168
|
"-c",
|
|
@@ -122,14 +172,14 @@ function untrackedFiles(projectPath) {
|
|
|
122
172
|
"--exclude-standard",
|
|
123
173
|
])
|
|
124
174
|
.split("\n")
|
|
125
|
-
.filter((f) => f && !
|
|
175
|
+
.filter((f) => f && !isOwnOutput(f));
|
|
126
176
|
}
|
|
127
177
|
function secs(ms) {
|
|
128
178
|
return `${(ms / 1000).toFixed(1)} s`;
|
|
129
179
|
}
|
|
130
180
|
function renderReport(change, discipline, files, gates, passed, date) {
|
|
131
181
|
const rows = gates
|
|
132
|
-
.map((g) => `| \`${g.command}
|
|
182
|
+
.map((g) => `| \`${g.command}\`${g.scope ? ` (${g.scope})` : ""} | ${g.exitCode === 0 ? "pass" : `FAIL (exit ${g.exitCode})`} | ${secs(g.durationMs)} |`)
|
|
133
183
|
.join("\n");
|
|
134
184
|
const outputs = gates
|
|
135
185
|
.map((g) => `### \`${g.command}\`\n\n\`\`\`text\n${g.tail}\n\`\`\``)
|
|
@@ -205,8 +255,7 @@ function commitSummary(projectPath) {
|
|
|
205
255
|
`${base}..HEAD`,
|
|
206
256
|
"--",
|
|
207
257
|
".",
|
|
208
|
-
|
|
209
|
-
":(exclude).speclaw",
|
|
258
|
+
...OWN_OUTPUT_EXCLUDES,
|
|
210
259
|
])
|
|
211
260
|
.split("\n")
|
|
212
261
|
.filter((l) => !/^[\w-]+-by:/i.test(l))
|
|
@@ -228,11 +277,61 @@ function isVersionBump(projectPath, base, file) {
|
|
|
228
277
|
.filter((l) => /^[+-](?![+-]{2})/.test(l));
|
|
229
278
|
return lines.length > 0 && lines.every((l) => /^[+-]\s*"version"\s*:/.test(l));
|
|
230
279
|
}
|
|
280
|
+
/** Source lines a diff may change, in one source file, and still be a level-0 fix. */
|
|
281
|
+
const SMALL_FIX_LINES = 10;
|
|
231
282
|
/**
|
|
232
283
|
* Propose a ceremony level from the branch diff itself. A release bump of the
|
|
233
|
-
* manifests is not a global change, so it does not raise the level
|
|
284
|
+
* manifests is not a global change, so it does not raise the level, and a
|
|
285
|
+
* small fix stays at level 0 however central the code it touches: its blast
|
|
286
|
+
* radius is guarded by the gates and its test, which ceremony would not add to.
|
|
234
287
|
*/
|
|
235
288
|
function measureDiff(projectPath, files) {
|
|
289
|
+
const proposal = measureDiffSignals(projectPath, files);
|
|
290
|
+
if (!proposal.level || proposal.signals.touchesPublicApi || proposal.signals.touchesGlobalFile) {
|
|
291
|
+
return proposal;
|
|
292
|
+
}
|
|
293
|
+
const fix = smallFix(projectPath, files);
|
|
294
|
+
if (!fix)
|
|
295
|
+
return proposal;
|
|
296
|
+
return {
|
|
297
|
+
...proposal,
|
|
298
|
+
level: 0,
|
|
299
|
+
rationale: `${proposal.rationale}; small fix (${fix.lines} source line(s) in ${fix.files} file) → level 0`,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* The size of a diff that changes at most one source file (tests and docs
|
|
304
|
+
* aside) by at most {@link SMALL_FIX_LINES} lines, or null when it is larger.
|
|
305
|
+
*/
|
|
306
|
+
function smallFix(projectPath, files) {
|
|
307
|
+
const { testGlobs } = loadAffectedConfig(projectPath);
|
|
308
|
+
const { thresholds } = loadCeremonyConfig(projectPath);
|
|
309
|
+
const source = files.filter((f) => !isTestPath(f, testGlobs) && !matchesAny(f, thresholds.docGlobs));
|
|
310
|
+
if (source.length > 1)
|
|
311
|
+
return null;
|
|
312
|
+
if (source.length === 0)
|
|
313
|
+
return { files: 0, lines: 0 };
|
|
314
|
+
const base = mergeBase(projectPath, "main") ?? mergeBase(projectPath, "master");
|
|
315
|
+
const numstat = base ? git(projectPath, ["diff", "--numstat", base, "--", source[0]]) : "";
|
|
316
|
+
let lines = numstat
|
|
317
|
+
.split("\n")
|
|
318
|
+
.filter(Boolean)
|
|
319
|
+
.reduce((n, l) => {
|
|
320
|
+
const [add, del] = l.split("\t");
|
|
321
|
+
return n + (Number(add) || 0) + (Number(del) || 0);
|
|
322
|
+
}, 0);
|
|
323
|
+
if (!numstat.trim()) {
|
|
324
|
+
// Untracked: every line is new.
|
|
325
|
+
try {
|
|
326
|
+
lines = fs.readFileSync(path.join(projectPath, source[0]), "utf8").split("\n").length;
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
return null;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return lines <= SMALL_FIX_LINES ? { files: 1, lines } : null;
|
|
333
|
+
}
|
|
334
|
+
function measureDiffSignals(projectPath, files) {
|
|
236
335
|
const cached = cachedMeasure(projectPath, files);
|
|
237
336
|
if (cached)
|
|
238
337
|
return cached;
|
|
@@ -524,8 +623,15 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
524
623
|
const commands = opts.gates ?? cfg.gates ?? detectGates(projectPath);
|
|
525
624
|
const gates = [];
|
|
526
625
|
for (const command of commands) {
|
|
527
|
-
const
|
|
528
|
-
|
|
626
|
+
const scoped = cfg.tests !== "full" && TEST_GATE_RE.test(command.trim())
|
|
627
|
+
? scopedTestGate(projectPath, command, files)
|
|
628
|
+
: null;
|
|
629
|
+
if (scoped && scoped.command === null) {
|
|
630
|
+
gates.push({ command, exitCode: 0, durationMs: 0, tail: "", scope: scoped.scope });
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
const g = runGate(projectPath, scoped?.command ?? command);
|
|
634
|
+
gates.push(scoped ? { ...g, scope: scoped.scope } : g);
|
|
529
635
|
if (g.exitCode !== 0)
|
|
530
636
|
break;
|
|
531
637
|
}
|
|
@@ -550,7 +656,7 @@ export function shipChange(projectPath, name, opts = {}) {
|
|
|
550
656
|
}
|
|
551
657
|
else if (!opts.noArchive) {
|
|
552
658
|
recordGateVerdict(projectPath, name);
|
|
553
|
-
const pre = specArchivePreconditions(projectPath, name);
|
|
659
|
+
const pre = specArchivePreconditions(projectPath, name, { syncing: true });
|
|
554
660
|
if (pre.length)
|
|
555
661
|
next.push(...pre);
|
|
556
662
|
else
|
|
@@ -617,7 +723,7 @@ export function docHint(projectPath) {
|
|
|
617
723
|
return null;
|
|
618
724
|
const quick = status
|
|
619
725
|
.split("\n")
|
|
620
|
-
.filter((l) => !
|
|
726
|
+
.filter((l) => !OWN_OUTPUT.some((dir) => l.includes(` ${dir}`)))
|
|
621
727
|
.join("\n");
|
|
622
728
|
const statePath = path.join(projectPath, DOC_HINT_STATE);
|
|
623
729
|
let prev = {};
|
|
@@ -711,6 +817,28 @@ export function changeNameForBranch(branch) {
|
|
|
711
817
|
.replace(/[^a-z0-9-]+/g, "-")
|
|
712
818
|
.replace(/^-+|-+$/g, "") || "change");
|
|
713
819
|
}
|
|
820
|
+
/**
|
|
821
|
+
* The change an action means when the agent left `change` out: the only
|
|
822
|
+
* active change, else the one this branch ships (same rule as the stop hook).
|
|
823
|
+
*
|
|
824
|
+
* @param projectPath - Project root.
|
|
825
|
+
* @returns The change name, or null when no single change fits.
|
|
826
|
+
*/
|
|
827
|
+
export function resolveActiveChange(projectPath) {
|
|
828
|
+
const active = specList(projectPath).activeChanges;
|
|
829
|
+
if (active.length === 1)
|
|
830
|
+
return active[0];
|
|
831
|
+
if (active.length === 0 || !isGitRepo(projectPath))
|
|
832
|
+
return null;
|
|
833
|
+
const branch = currentBranch(projectPath);
|
|
834
|
+
const named = branchChange(projectPath, branch);
|
|
835
|
+
if (active.includes(named))
|
|
836
|
+
return named;
|
|
837
|
+
// `feat/FAR-1360-default-cost-center` ships `default-cost-center` too.
|
|
838
|
+
const slug = changeNameForBranch(branch);
|
|
839
|
+
const fits = active.filter((c) => slug.endsWith(`-${c}`) || c.endsWith(`-${slug}`));
|
|
840
|
+
return fits.length === 1 ? fits[0] : null;
|
|
841
|
+
}
|
|
714
842
|
function git(projectPath, args) {
|
|
715
843
|
const r = spawnSync("git", args, { cwd: projectPath, encoding: "utf8" });
|
|
716
844
|
return r.status === 0 ? r.stdout : "";
|
|
@@ -748,11 +876,11 @@ function readMarker(projectPath) {
|
|
|
748
876
|
return { branch: "", change: "", fingerprint: raw };
|
|
749
877
|
}
|
|
750
878
|
/**
|
|
751
|
-
* Fingerprint of the branch's work outside
|
|
879
|
+
* Fingerprint of the branch's work outside the lawbook's own output: every path that
|
|
752
880
|
* differs from the merge base with `main`/`master` or is untracked, with its
|
|
753
881
|
* working-tree contents. A file hashes the same whether it is untracked, staged
|
|
754
882
|
* or committed, so committing work — new files included — is not new work; ship
|
|
755
|
-
*
|
|
883
|
+
* and archive write only {@link OWN_OUTPUT}, so their output never changes it either.
|
|
756
884
|
* Without a merge base it falls back to the paths changed against HEAD plus HEAD itself.
|
|
757
885
|
*/
|
|
758
886
|
function workFingerprint(projectPath) {
|
|
@@ -765,8 +893,7 @@ function workFingerprint(projectPath) {
|
|
|
765
893
|
base ?? "HEAD",
|
|
766
894
|
"--",
|
|
767
895
|
".",
|
|
768
|
-
|
|
769
|
-
":(exclude).speclaw",
|
|
896
|
+
...OWN_OUTPUT_EXCLUDES,
|
|
770
897
|
]).split("\n");
|
|
771
898
|
const hash = createHash("sha256");
|
|
772
899
|
const files = [...new Set([...tracked, ...untrackedFiles(projectPath)])]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@esneiderbravo/speclaw",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.24",
|
|
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
|
|
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"
|