@esneiderbravo/speclaw 0.3.0 → 0.3.2
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/README.md +24 -2
- package/dist/cli/commands/check.js +100 -0
- package/dist/cli/index.js +3 -0
- package/dist/modules/compass/db.js +10 -1
- package/dist/modules/compass/git-history-cache.js +65 -0
- package/dist/modules/foundation/assets/laws/laws-manifest.json +49 -0
- package/dist/modules/foundation/check.js +144 -0
- package/dist/modules/foundation/doctor.js +92 -1
- package/dist/modules/foundation/hooks.js +165 -0
- package/dist/modules/foundation/laws.js +211 -0
- package/dist/modules/foundation/register.js +13 -0
- package/dist/modules/foundation/scaffold.js +26 -0
- package/dist/shared/agents.js +1 -0
- package/dist/shared/git-history.js +204 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -55,7 +55,8 @@ The `speclaw` command is now available everywhere — run `speclaw index`,
|
|
|
55
55
|
|
|
56
56
|
1. **Ask which agents you use** (Claude Code, Cursor, Codex, …) — and configure
|
|
57
57
|
only those. Add more later; nothing is forced on you.
|
|
58
|
-
2. Write the **foundation** (constitution + standards) and the **lawbook workflow
|
|
58
|
+
2. Write the **foundation** (constitution + standards) and the **lawbook workflow**,
|
|
59
|
+
and compile your blocking laws into **agent hooks** for the agents that support them.
|
|
59
60
|
3. **Index your code** with a live progress bar and a summary of what it found.
|
|
60
61
|
4. Register the speclaw **MCP server** in each chosen agent's config.
|
|
61
62
|
5. Print a prompt to paste into your agent so it fills the constitution with your
|
|
@@ -81,7 +82,7 @@ too (also `pnpm dlx` / `yarn dlx`) — but installing globally means you can run
|
|
|
81
82
|
|
|
82
83
|
| Module | What it does |
|
|
83
84
|
| :-- | :-- |
|
|
84
|
-
| **Foundation** | The project's constitution: `LAWS.md` binding a set of granular standards under `docs/standards/` (base, architecture, backend, frontend, testing, documentation, conventions, lawbook), plus strict `CLAUDE.md` / `AGENTS.md` agent contracts — filled from your real codebase. |
|
|
85
|
+
| **Foundation** | The project's constitution: `LAWS.md` binding a set of granular standards under `docs/standards/` (base, architecture, backend, frontend, testing, documentation, conventions, lawbook), plus strict `CLAUDE.md` / `AGENTS.md` agent contracts — filled from your real codebase. It also **enforces** them: blocking laws compile into agent hooks that deny a forbidden edit at the keystroke (`speclaw check` / `speclaw_check`). |
|
|
85
86
|
| **Compass** | speclaw's own local code graph. Parses your code (tree-sitter) into nodes + edges plus a local vector store, so an agent finds and understands code with a fraction of the tokens a grep/read loop would cost. No LLM, 100% local, lives in `.speclaw/` (gitignored). |
|
|
86
87
|
| **Lawbook** | speclaw's own spec-driven workflow: `draft → build → sync → archive` (and `explore`), backed by `lawbook_*` engine tools. No external CLI. |
|
|
87
88
|
| **Tools** | Opt-in packs of skills and subagents (currently the dev-agents) that agents use for specific tasks. |
|
|
@@ -175,6 +176,12 @@ ai-specs` command to stop tracking it (they never touch your git index
|
|
|
175
176
|
themselves). The agent directories (`.claude/`, `.cursor/`, …) are **left to
|
|
176
177
|
you** — commit your own skills and commands there if you want to.
|
|
177
178
|
|
|
179
|
+
**Enforcement artifacts.** For agents that support hooks, speclaw merges its law
|
|
180
|
+
hooks into that agent's settings (e.g. `.claude/settings.json`) **by identity** —
|
|
181
|
+
it never touches hooks you added yourself. The compiled law manifest lives in
|
|
182
|
+
`.speclaw/laws-manifest.json` (gitignored, regenerated on `init`/`update`), and a
|
|
183
|
+
context-coverage log in `.speclaw/context-log.jsonl` feeds `speclaw doctor`.
|
|
184
|
+
|
|
178
185
|
<br/>
|
|
179
186
|
|
|
180
187
|
## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle"> Philosophy — why "laws"?
|
|
@@ -186,6 +193,21 @@ you** — commit your own skills and commands there if you want to.
|
|
|
186
193
|
> knowledge explicit, executable, and binding, and gives agents a local map
|
|
187
194
|
> (Compass) and a disciplined workflow (Lawbook) to act on it — without burning tokens.
|
|
188
195
|
|
|
196
|
+
This is why "enforced" is literal, not a metaphor. Anthropic's own guidance puts
|
|
197
|
+
it plainly:
|
|
198
|
+
|
|
199
|
+
> _"An instruction like 'never edit .env' in CLAUDE.md or a skill is **a request,
|
|
200
|
+
> not a guarantee**. A `PreToolUse` hook that blocks the edit is enforcement. If a
|
|
201
|
+
> rule must hold every time, make it a hook rather than a prompt instruction."_
|
|
202
|
+
> — [Claude Code — Hooks](https://code.claude.com/docs/en/hooks)
|
|
203
|
+
|
|
204
|
+
So `speclaw init` compiles your blocking laws into agent hooks: a law marked
|
|
205
|
+
`bloqueo` is denied at the keystroke (`PreToolUse`), citing the law's id, text,
|
|
206
|
+
and source. `speclaw check --dry-run --path <file>` previews what would block, and
|
|
207
|
+
`speclaw doctor` reports how many of your laws actually reached the agent's
|
|
208
|
+
context. Agents without hooks (Cursor, Codex) enforce the same laws in CI via
|
|
209
|
+
`speclaw verify`.
|
|
210
|
+
|
|
189
211
|
<br/>
|
|
190
212
|
|
|
191
213
|
## <img src="https://raw.githubusercontent.com/esneiderbravo/speclaw/main/brand/diamond.png" height="20" alt="◆" align="absmiddle"> Staying up to date
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { ui, c } from "../lib/ui.js";
|
|
2
|
+
import { checkAction } from "../../modules/foundation/check.js";
|
|
3
|
+
import { hasBackend, readLawManifest } from "../../modules/foundation/laws.js";
|
|
4
|
+
/** Read all of stdin as UTF-8 text (used for `--hook-payload -`). */
|
|
5
|
+
async function readStdin() {
|
|
6
|
+
const chunks = [];
|
|
7
|
+
for await (const chunk of process.stdin)
|
|
8
|
+
chunks.push(chunk);
|
|
9
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* `speclaw check` — the CLI twin of the `speclaw_check` MCP tool, and the
|
|
13
|
+
* command-hook fallback for agents driven purely by the CLI.
|
|
14
|
+
*
|
|
15
|
+
* - `--hook-payload -` reads a hook event JSON from stdin, evaluates it, prints
|
|
16
|
+
* the `hookSpecificOutput` contract, and exits 2 on `deny` (the documented
|
|
17
|
+
* command-hook block signal).
|
|
18
|
+
* - `--dry-run [--path P] [--event E]` previews the verdict for a synthetic
|
|
19
|
+
* action against path `P`, without blocking anything (always exits 0).
|
|
20
|
+
* - with no flags, prints a summary of the project's declared laws.
|
|
21
|
+
*
|
|
22
|
+
* @param flags - Parsed CLI flags.
|
|
23
|
+
*/
|
|
24
|
+
export async function runCheck(flags) {
|
|
25
|
+
const cwd = process.cwd();
|
|
26
|
+
if (flags["hook-payload"]) {
|
|
27
|
+
const raw = await readStdin();
|
|
28
|
+
let payload;
|
|
29
|
+
try {
|
|
30
|
+
payload = JSON.parse(raw);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Fail open: an unreadable payload must never block the agent.
|
|
34
|
+
console.log(JSON.stringify({ hookSpecificOutput: { permissionDecision: "allow" } }));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const event = (payload.hook_event_name ?? payload.event ?? "PreToolUse");
|
|
38
|
+
const toolName = (payload.tool_name ?? payload.toolName);
|
|
39
|
+
const result = checkAction({ projectPath: cwd, event, toolName, payload });
|
|
40
|
+
const decision = result.verdict === "deny" ? "deny" : "allow";
|
|
41
|
+
console.log(JSON.stringify({
|
|
42
|
+
hookSpecificOutput: {
|
|
43
|
+
hookEventName: event,
|
|
44
|
+
permissionDecision: decision,
|
|
45
|
+
permissionDecisionReason: result.reason ?? "",
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
if (result.verdict === "deny")
|
|
49
|
+
process.exit(2);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (flags["dry-run"]) {
|
|
53
|
+
const target = typeof flags.path === "string" ? flags.path : "";
|
|
54
|
+
const event = (typeof flags.event === "string" ? flags.event : "PreToolUse");
|
|
55
|
+
if (!target) {
|
|
56
|
+
ui.err(`Pass ${ui.code("--path <file>")} to preview what a law would do to that path.`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const result = checkAction({
|
|
60
|
+
projectPath: cwd,
|
|
61
|
+
event,
|
|
62
|
+
payload: { tool_input: { file_path: target } },
|
|
63
|
+
});
|
|
64
|
+
ui.heading(`speclaw check --dry-run (${event})`);
|
|
65
|
+
ui.info(`target: ${c.cream(target)}`);
|
|
66
|
+
if (result.diagnostic)
|
|
67
|
+
ui.warn(result.diagnostic);
|
|
68
|
+
if (result.evaluated.length === 0) {
|
|
69
|
+
ui.ok("No law applies to this path.");
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
for (const e of result.evaluated) {
|
|
73
|
+
// The message already opens with the law id (see check.ts `cite`).
|
|
74
|
+
const line = e.message ?? e.lawId;
|
|
75
|
+
if (e.passed)
|
|
76
|
+
ui.info(line);
|
|
77
|
+
else
|
|
78
|
+
ui.warn(line);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
ui.plain();
|
|
82
|
+
const verb = result.verdict === "deny" ? c.red("would BLOCK") : c.green("would allow");
|
|
83
|
+
ui.plain(` verdict: ${verb} · evaluated in ${result.elapsedMs.toFixed(1)} ms`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
// Default: summarize the declared laws.
|
|
87
|
+
const manifest = readLawManifest(cwd);
|
|
88
|
+
ui.heading("speclaw check");
|
|
89
|
+
if (!manifest) {
|
|
90
|
+
ui.warn("No law manifest — run `speclaw init` to seed .speclaw/laws-manifest.json.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
ui.info(`${manifest.laws.length} law(s) declared:`);
|
|
94
|
+
for (const law of manifest.laws) {
|
|
95
|
+
const backend = hasBackend(law)
|
|
96
|
+
? law.verification.kind
|
|
97
|
+
: `${law.verification.kind} (no backend yet)`;
|
|
98
|
+
ui.plain(` · ${c.cream(law.id)} — ${law.enforcement} · ${backend} · [${law.scope.join(", ")}]`);
|
|
99
|
+
}
|
|
100
|
+
}
|
package/dist/cli/index.js
CHANGED
|
@@ -34,6 +34,7 @@ Lawbook (spec-driven workflow)
|
|
|
34
34
|
|
|
35
35
|
Other
|
|
36
36
|
doctor Verify the installation
|
|
37
|
+
check Evaluate an action against the laws (hooks call this; --dry-run to preview)
|
|
37
38
|
mcp Start the MCP server (used by your agent's config)
|
|
38
39
|
help Show this help
|
|
39
40
|
--version Print the installed speclaw version
|
|
@@ -110,6 +111,8 @@ async function dispatch(cmd, flags) {
|
|
|
110
111
|
return (await import("./commands/lawbook.js")).runSpec(flags);
|
|
111
112
|
case "doctor":
|
|
112
113
|
return (await import("./commands/doctor.js")).runDoctor(flags);
|
|
114
|
+
case "check":
|
|
115
|
+
return (await import("./commands/check.js")).runCheck(flags);
|
|
113
116
|
default:
|
|
114
117
|
ui.err(`Unknown command: ${cmd}`);
|
|
115
118
|
console.log(HELP);
|
|
@@ -47,9 +47,17 @@ CREATE TABLE IF NOT EXISTS node_embeddings (
|
|
|
47
47
|
model TEXT NOT NULL,
|
|
48
48
|
vec BLOB NOT NULL
|
|
49
49
|
);
|
|
50
|
+
-- git_history_cache: memoized results of the expensive git-history scans
|
|
51
|
+
-- (churn, co-change), keyed by query and invalidated when HEAD moves.
|
|
52
|
+
CREATE TABLE IF NOT EXISTS git_history_cache (
|
|
53
|
+
query_key TEXT PRIMARY KEY,
|
|
54
|
+
head_sha TEXT NOT NULL,
|
|
55
|
+
payload TEXT NOT NULL,
|
|
56
|
+
computed_at INTEGER NOT NULL
|
|
57
|
+
);
|
|
50
58
|
`;
|
|
51
59
|
/** Schema version stamped into the `meta` table on first creation. */
|
|
52
|
-
export const SCHEMA_VERSION = "
|
|
60
|
+
export const SCHEMA_VERSION = "4";
|
|
53
61
|
/** The stamped schema version, or null if the db predates versioning / has no meta table. */
|
|
54
62
|
function readSchemaVersion(db) {
|
|
55
63
|
try {
|
|
@@ -81,6 +89,7 @@ function isStale(db) {
|
|
|
81
89
|
/** Drop every table (children first) so the current schema can be recreated cleanly. */
|
|
82
90
|
function resetSchema(db) {
|
|
83
91
|
db.exec(`
|
|
92
|
+
DROP TABLE IF EXISTS git_history_cache;
|
|
84
93
|
DROP TABLE IF EXISTS node_embeddings;
|
|
85
94
|
DROP TABLE IF EXISTS edges;
|
|
86
95
|
DROP TABLE IF EXISTS nodes;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { churn, coChanges, headSha, } from "../../shared/git-history.js";
|
|
2
|
+
import { openDb } from "./db.js";
|
|
3
|
+
/**
|
|
4
|
+
* Look up a cached payload valid at the current HEAD, or compute it and store it.
|
|
5
|
+
*
|
|
6
|
+
* When `head` is `null` (no commits / not a repo) the cache is bypassed entirely
|
|
7
|
+
* and `compute()` runs directly, so an empty repo never poisons the cache.
|
|
8
|
+
*
|
|
9
|
+
* @param projectPath - Project root, whose `.speclaw/index.db` holds the cache.
|
|
10
|
+
* @param head - The current HEAD SHA, or `null` when there is none.
|
|
11
|
+
* @param queryKey - Stable key identifying this query (function + options).
|
|
12
|
+
* @param compute - Produces the fresh result on a miss.
|
|
13
|
+
* @param serialize - Turns the result into a JSON-safe payload string.
|
|
14
|
+
* @param deserialize - Rebuilds the result from a stored payload string.
|
|
15
|
+
* @returns The cached-or-freshly-computed result.
|
|
16
|
+
*/
|
|
17
|
+
function readThrough(projectPath, head, queryKey, compute, serialize, deserialize) {
|
|
18
|
+
if (head === null)
|
|
19
|
+
return compute();
|
|
20
|
+
const db = openDb(projectPath);
|
|
21
|
+
try {
|
|
22
|
+
const row = db
|
|
23
|
+
.prepare("SELECT head_sha, payload FROM git_history_cache WHERE query_key = ?")
|
|
24
|
+
.get(queryKey);
|
|
25
|
+
if (row && row.head_sha === head) {
|
|
26
|
+
return deserialize(row.payload);
|
|
27
|
+
}
|
|
28
|
+
const value = compute();
|
|
29
|
+
db.prepare(`INSERT INTO git_history_cache(query_key, head_sha, payload, computed_at)
|
|
30
|
+
VALUES (?, ?, ?, 0)
|
|
31
|
+
ON CONFLICT(query_key) DO UPDATE SET
|
|
32
|
+
head_sha = excluded.head_sha,
|
|
33
|
+
payload = excluded.payload,
|
|
34
|
+
computed_at = excluded.computed_at`).run(queryKey, head, serialize(value));
|
|
35
|
+
return value;
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
db.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* {@link churn}, memoized in the Compass index until `HEAD` moves.
|
|
43
|
+
*
|
|
44
|
+
* @param projectPath - Project root to query.
|
|
45
|
+
* @param opts - Same options as {@link churn}.
|
|
46
|
+
* @returns Per-path change counts and the shallow marker, cached per HEAD.
|
|
47
|
+
*/
|
|
48
|
+
export function cachedChurn(projectPath, opts = {}) {
|
|
49
|
+
const key = `churn:${JSON.stringify({ since: opts.since ?? null, pathspec: opts.pathspec ?? null })}`;
|
|
50
|
+
return readThrough(projectPath, headSha(projectPath), key, () => churn(projectPath, opts), (value) => JSON.stringify({ shallow: value.shallow, byPath: [...value.byPath] }), (payload) => {
|
|
51
|
+
const parsed = JSON.parse(payload);
|
|
52
|
+
return { shallow: parsed.shallow, byPath: new Map(parsed.byPath) };
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* {@link coChanges}, memoized in the Compass index until `HEAD` moves.
|
|
57
|
+
*
|
|
58
|
+
* @param projectPath - Project root to query.
|
|
59
|
+
* @param opts - Same options as {@link coChanges}.
|
|
60
|
+
* @returns The co-change pairs and the shallow marker, cached per HEAD.
|
|
61
|
+
*/
|
|
62
|
+
export function cachedCoChanges(projectPath, opts = {}) {
|
|
63
|
+
const key = `coChanges:${JSON.stringify({ since: opts.since ?? null, minSupport: opts.minSupport ?? null })}`;
|
|
64
|
+
return readThrough(projectPath, headSha(projectPath), key, () => coChanges(projectPath, opts), (value) => JSON.stringify(value), (payload) => JSON.parse(payload));
|
|
65
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"laws": [
|
|
4
|
+
{
|
|
5
|
+
"id": "law~no-secrets-in-repo~1",
|
|
6
|
+
"title": "No secrets in the repository",
|
|
7
|
+
"rationale": "A committed secret is a leaked secret; enforcement at the keystroke is the only reliable guard.",
|
|
8
|
+
"severity": "error",
|
|
9
|
+
"scope": ["**/.env", "**/.env.*", "**/*.env"],
|
|
10
|
+
"prose": "Never write a .env file into the repository. Secrets live in the environment, not in version control.",
|
|
11
|
+
"verification": { "kind": "path" },
|
|
12
|
+
"enforcement": "bloqueo",
|
|
13
|
+
"source": { "file": "docs/standards/base-standards.md" }
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"id": "law~local-first~1",
|
|
17
|
+
"title": "Local-first is non-negotiable",
|
|
18
|
+
"rationale": "speclaw runs entirely on the user's machine; every new dependency must be justified in the PR.",
|
|
19
|
+
"severity": "warn",
|
|
20
|
+
"scope": ["package.json"],
|
|
21
|
+
"prose": "Justify any new dependency in the PR: it must not break 'runs offline with no API keys'.",
|
|
22
|
+
"verification": { "kind": "path" },
|
|
23
|
+
"enforcement": "feedback",
|
|
24
|
+
"source": { "file": "LAWS.md" }
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"id": "law~protect-templates~1",
|
|
28
|
+
"title": "Protect the templates",
|
|
29
|
+
"rationale": "Assets under src/modules/*/assets/** are product output; the build must copy them and the {{placeholder}} contracts must stay intact.",
|
|
30
|
+
"severity": "warn",
|
|
31
|
+
"scope": ["src/modules/*/assets/**"],
|
|
32
|
+
"prose": "Assets under src/modules/*/assets/** are product output — keep the {{placeholder}} and speclaw-init contracts intact, and let the build copy them (never hand-copy into dist/).",
|
|
33
|
+
"verification": { "kind": "path" },
|
|
34
|
+
"enforcement": "feedback",
|
|
35
|
+
"source": { "file": "LAWS.md" }
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"id": "law~honest-attribution~1",
|
|
39
|
+
"title": "Keep attribution honest",
|
|
40
|
+
"rationale": "Compass reimplements CodeGraph and Lawbook reimplements OpenSpec (both MIT); ATTRIBUTION.md must stay accurate.",
|
|
41
|
+
"severity": "info",
|
|
42
|
+
"scope": ["ATTRIBUTION.md"],
|
|
43
|
+
"prose": "Keep ATTRIBUTION.md accurate as the Compass and Lawbook modules evolve.",
|
|
44
|
+
"verification": { "kind": "path" },
|
|
45
|
+
"enforcement": "feedback",
|
|
46
|
+
"source": { "file": "LAWS.md" }
|
|
47
|
+
}
|
|
48
|
+
]
|
|
49
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { compileScope, hasBackend, manifestPath, matchCompiled, readLawManifest, } from "./laws.js";
|
|
5
|
+
const cache = new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Load the project's compiled law index, using the in-process cache when the
|
|
8
|
+
* manifest is unchanged (keeps `PreToolUse` off the disk and off the regex
|
|
9
|
+
* compiler on the hot path). Returns null when the manifest is missing or
|
|
10
|
+
* unparseable, so the caller fails open.
|
|
11
|
+
*/
|
|
12
|
+
function loadLaws(projectPath) {
|
|
13
|
+
let mtimeMs;
|
|
14
|
+
try {
|
|
15
|
+
mtimeMs = fs.statSync(manifestPath(projectPath)).mtimeMs;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
cache.delete(projectPath);
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const hit = cache.get(projectPath);
|
|
22
|
+
if (hit && hit.mtimeMs === mtimeMs)
|
|
23
|
+
return hit.laws;
|
|
24
|
+
const manifest = readLawManifest(projectPath);
|
|
25
|
+
if (!manifest)
|
|
26
|
+
return null;
|
|
27
|
+
const laws = manifest.laws.map((law) => ({ law, scope: compileScope(law.scope) }));
|
|
28
|
+
cache.set(projectPath, { mtimeMs, laws });
|
|
29
|
+
return laws;
|
|
30
|
+
}
|
|
31
|
+
/** Clear the manifest cache (used by tests and by a manifest rewrite in-process). */
|
|
32
|
+
export function clearLawCache() {
|
|
33
|
+
cache.clear();
|
|
34
|
+
}
|
|
35
|
+
/** POSIX-normalize a path and make it project-relative when it is absolute. */
|
|
36
|
+
function toRelPosix(projectPath, p) {
|
|
37
|
+
let rel = p;
|
|
38
|
+
if (path.isAbsolute(p))
|
|
39
|
+
rel = path.relative(projectPath, p);
|
|
40
|
+
return rel.split(path.sep).join("/").replace(/^\.\//, "");
|
|
41
|
+
}
|
|
42
|
+
/** Extract the file the action targets from a tool payload, across common shapes. */
|
|
43
|
+
function targetPath(projectPath, payload) {
|
|
44
|
+
const input = (payload.tool_input ?? payload.toolInput ?? payload);
|
|
45
|
+
const candidate = input.file_path ?? input.filePath ?? input.path ?? input.notebook_path ?? input.notebookPath;
|
|
46
|
+
return typeof candidate === "string" ? toRelPosix(projectPath, candidate) : null;
|
|
47
|
+
}
|
|
48
|
+
/** Extract the instructions file that was loaded, across common payload shapes. */
|
|
49
|
+
function loadedFile(projectPath, payload) {
|
|
50
|
+
const candidate = payload.file ?? payload.filePath ?? payload.path ?? payload.file_path;
|
|
51
|
+
return typeof candidate === "string" ? toRelPosix(projectPath, candidate) : null;
|
|
52
|
+
}
|
|
53
|
+
/** Format the citation for a matched law: id, source, and its literal prose. */
|
|
54
|
+
function cite(law) {
|
|
55
|
+
const at = law.source.line ? `${law.source.file}:${law.source.line}` : law.source.file;
|
|
56
|
+
return `${law.id} (${at}): «${law.prose}»`;
|
|
57
|
+
}
|
|
58
|
+
/** Append the ids of the laws a just-loaded instructions file declares to the context log. */
|
|
59
|
+
function recordContextCoverage(projectPath, laws, file) {
|
|
60
|
+
const ids = laws.filter((l) => (file ? l.source.file === file : true)).map((l) => l.id);
|
|
61
|
+
if (ids.length === 0)
|
|
62
|
+
return ids;
|
|
63
|
+
try {
|
|
64
|
+
const logDir = path.join(projectPath, ".speclaw");
|
|
65
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
66
|
+
const line = JSON.stringify({ at: new Date().toISOString(), file, lawIds: ids }) + "\n";
|
|
67
|
+
fs.appendFileSync(path.join(logDir, "context-log.jsonl"), line);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// Best-effort audit — never let a logging failure affect the verdict.
|
|
71
|
+
}
|
|
72
|
+
return ids;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Evaluate an agent action against every law whose scope matches its target and
|
|
76
|
+
* return an ACS-aligned verdict. Only the `path` backend is evaluated; laws with
|
|
77
|
+
* another (unimplemented) backend are skipped. The evaluator fails open: a
|
|
78
|
+
* missing or unparseable manifest, or any exception, yields `allow` with a
|
|
79
|
+
* diagnostic — an enforcement layer that blocks when its own checker crashes is
|
|
80
|
+
* worse than none.
|
|
81
|
+
*
|
|
82
|
+
* @param args - The project, hook event, optional tool name, and raw payload.
|
|
83
|
+
* @returns The verdict, the laws evaluated, an optional reason, and `elapsedMs`.
|
|
84
|
+
*/
|
|
85
|
+
export function checkAction(args) {
|
|
86
|
+
const start = performance.now();
|
|
87
|
+
const done = (r) => ({
|
|
88
|
+
...r,
|
|
89
|
+
elapsedMs: performance.now() - start,
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
const laws = loadLaws(args.projectPath);
|
|
93
|
+
if (!laws) {
|
|
94
|
+
return done({
|
|
95
|
+
verdict: "allow",
|
|
96
|
+
evaluated: [],
|
|
97
|
+
diagnostic: "law manifest missing or unparseable — failing open",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
if (args.event === "InstructionsLoaded") {
|
|
101
|
+
const ids = recordContextCoverage(args.projectPath, laws.map((il) => il.law), loadedFile(args.projectPath, args.payload));
|
|
102
|
+
return done({
|
|
103
|
+
verdict: "allow",
|
|
104
|
+
evaluated: ids.map((id) => ({ lawId: id, severity: "info", passed: true })),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const target = targetPath(args.projectPath, args.payload);
|
|
108
|
+
if (target === null)
|
|
109
|
+
return done({ verdict: "allow", evaluated: [] });
|
|
110
|
+
const matched = laws
|
|
111
|
+
.filter((il) => hasBackend(il.law) && matchCompiled(il.scope, target))
|
|
112
|
+
.map((il) => il.law);
|
|
113
|
+
const evaluated = matched.map((l) => ({
|
|
114
|
+
lawId: l.id,
|
|
115
|
+
severity: l.severity,
|
|
116
|
+
passed: l.enforcement !== "bloqueo" || args.event !== "PreToolUse",
|
|
117
|
+
message: cite(l),
|
|
118
|
+
file: target,
|
|
119
|
+
}));
|
|
120
|
+
// Only a `bloqueo` law, and only on PreToolUse, stops the keystroke. Every
|
|
121
|
+
// other match still enters context as a message the agent reads.
|
|
122
|
+
const blocking = args.event === "PreToolUse" ? matched.filter((l) => l.enforcement === "bloqueo") : [];
|
|
123
|
+
if (blocking.length > 0) {
|
|
124
|
+
return done({
|
|
125
|
+
verdict: "deny",
|
|
126
|
+
evaluated,
|
|
127
|
+
reason: `Blocked by ${blocking.map(cite).join("; ")}`,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
const messages = evaluated.map((e) => e.message).filter(Boolean);
|
|
131
|
+
return done({
|
|
132
|
+
verdict: "allow",
|
|
133
|
+
evaluated,
|
|
134
|
+
reason: messages.length ? messages.join("; ") : undefined,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (err) {
|
|
138
|
+
return done({
|
|
139
|
+
verdict: "allow",
|
|
140
|
+
evaluated: [],
|
|
141
|
+
diagnostic: `check failed open: ${err.message}`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { AGENTS, detectConfiguredAgents } from "../../shared/agents.js";
|
|
3
|
+
import { AGENTS, agentById, detectConfiguredAgents } from "../../shared/agents.js";
|
|
4
|
+
import { globError, hasBackend, readLawManifest } from "./laws.js";
|
|
4
5
|
/**
|
|
5
6
|
* Run the speclaw installation health checks against a project: ai-specs and
|
|
6
7
|
* LAWS.md presence, agent contracts, the docs/standards set, per-agent IDE
|
|
@@ -99,5 +100,95 @@ export function doctor(projectPath) {
|
|
|
99
100
|
ok: has(".mcp.json"),
|
|
100
101
|
detail: has(".mcp.json") ? "present" : "missing — scaffold writes it",
|
|
101
102
|
});
|
|
103
|
+
lawEnforcementChecks(projectPath, checks);
|
|
102
104
|
return checks;
|
|
103
105
|
}
|
|
106
|
+
/** The law ids recorded as loaded into agent context, from the append-only log. */
|
|
107
|
+
function loadedLawIds(projectPath) {
|
|
108
|
+
const loaded = new Set();
|
|
109
|
+
try {
|
|
110
|
+
const log = fs.readFileSync(path.join(projectPath, ".speclaw", "context-log.jsonl"), "utf8");
|
|
111
|
+
for (const line of log.split(/\r?\n/)) {
|
|
112
|
+
if (!line.trim())
|
|
113
|
+
continue;
|
|
114
|
+
const ids = JSON.parse(line).lawIds ?? [];
|
|
115
|
+
for (const id of ids)
|
|
116
|
+
loaded.add(id);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// No log yet — hooks have not recorded any context loads.
|
|
121
|
+
}
|
|
122
|
+
return loaded;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Append the law-enforcement health checks: manifest presence and backend
|
|
126
|
+
* coverage, glob validity (caught here rather than at runtime), context-coverage
|
|
127
|
+
* with the post-compact caveat, and the agents where blocking laws don't apply.
|
|
128
|
+
*/
|
|
129
|
+
function lawEnforcementChecks(projectPath, checks) {
|
|
130
|
+
const manifest = readLawManifest(projectPath);
|
|
131
|
+
if (!manifest) {
|
|
132
|
+
checks.push({
|
|
133
|
+
name: "law manifest",
|
|
134
|
+
ok: false,
|
|
135
|
+
detail: "missing — run `speclaw init`/`update` to seed .speclaw/laws-manifest.json",
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const withBackend = manifest.laws.filter(hasBackend);
|
|
140
|
+
const noBackend = manifest.laws.filter((l) => !hasBackend(l));
|
|
141
|
+
checks.push({
|
|
142
|
+
name: "law manifest",
|
|
143
|
+
ok: true,
|
|
144
|
+
detail: `${manifest.laws.length} law(s): ${withBackend.length} enforced (path)` +
|
|
145
|
+
(noBackend.length
|
|
146
|
+
? `, ${noBackend.length} declared without a backend yet (${noBackend
|
|
147
|
+
.map((l) => l.id)
|
|
148
|
+
.join(", ")})`
|
|
149
|
+
: ""),
|
|
150
|
+
});
|
|
151
|
+
// Glob validation — a malformed scope must fail loudly here, never silently
|
|
152
|
+
// match zero files at runtime.
|
|
153
|
+
const badGlobs = [];
|
|
154
|
+
for (const law of manifest.laws) {
|
|
155
|
+
for (const pattern of law.scope) {
|
|
156
|
+
const err = globError(pattern);
|
|
157
|
+
if (err)
|
|
158
|
+
badGlobs.push(`${law.id}: ${pattern} (${err})`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
checks.push({
|
|
162
|
+
name: "law scope globs",
|
|
163
|
+
ok: badGlobs.length === 0,
|
|
164
|
+
detail: badGlobs.length === 0 ? "all valid" : `malformed: ${badGlobs.join("; ")}`,
|
|
165
|
+
});
|
|
166
|
+
// Context coverage — which laws actually entered the agent's context.
|
|
167
|
+
const loaded = loadedLawIds(projectPath);
|
|
168
|
+
const declared = manifest.laws.map((l) => l.id);
|
|
169
|
+
const missing = declared.filter((id) => !loaded.has(id));
|
|
170
|
+
checks.push({
|
|
171
|
+
name: "law context coverage",
|
|
172
|
+
ok: true,
|
|
173
|
+
detail: `${declared.length - missing.length} of ${declared.length} laws loaded into context` +
|
|
174
|
+
(missing.length ? ` — not yet loaded: ${missing.join(", ")}` : "") +
|
|
175
|
+
". Note: after a compact, root CLAUDE.md is re-injected but `paths:`-scoped rules are not," +
|
|
176
|
+
" until a matching file is next touched — so a path-scoped law can be out of context" +
|
|
177
|
+
" exactly when it matters, which is why it is also a hook.",
|
|
178
|
+
});
|
|
179
|
+
// Agent asymmetry — where blocking laws cannot be enforced at the keystroke.
|
|
180
|
+
const configured = detectConfiguredAgents(projectPath);
|
|
181
|
+
const unhooked = configured
|
|
182
|
+
.map((id) => agentById(id))
|
|
183
|
+
.filter((a) => a && !a.hooks)
|
|
184
|
+
.map((a) => a.label);
|
|
185
|
+
if (unhooked.length) {
|
|
186
|
+
const blocking = manifest.laws.filter((l) => l.enforcement === "bloqueo").length;
|
|
187
|
+
checks.push({
|
|
188
|
+
name: "hook coverage across agents",
|
|
189
|
+
ok: true,
|
|
190
|
+
detail: `no hook support for ${unhooked.join(", ")} — your ${blocking} blocking law(s) apply ` +
|
|
191
|
+
"there only via `speclaw verify`.",
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { agentById } from "../../shared/agents.js";
|
|
4
|
+
import { sha256 } from "../../shared/install.js";
|
|
5
|
+
import { globError, hasBackend } from "./laws.js";
|
|
6
|
+
/** The speclaw hook object — its `{type, server}` pair is the merge identity. */
|
|
7
|
+
const SPECLAW_HOOK = {
|
|
8
|
+
type: "mcp_tool",
|
|
9
|
+
server: "speclaw",
|
|
10
|
+
tool: "speclaw_check",
|
|
11
|
+
timeout: 5,
|
|
12
|
+
};
|
|
13
|
+
/** Tool-name matcher for the file-mutating tools the `path` backend can evaluate. */
|
|
14
|
+
const MUTATION_MATCHER = "Write|Edit|MultiEdit|NotebookEdit";
|
|
15
|
+
/** True when a hook object is one speclaw owns (safe to replace on merge). */
|
|
16
|
+
function isSpeclawHook(h) {
|
|
17
|
+
const o = h;
|
|
18
|
+
return o?.type === "mcp_tool" && o?.server === "speclaw";
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Compile a law manifest into the hook groups speclaw contributes, one per event
|
|
22
|
+
* the laws demand: `PreToolUse` when any `bloqueo` law exists, `PostToolUse` for
|
|
23
|
+
* `feedback`, `Stop` for `gate`, and `InstructionsLoaded` whenever any law exists
|
|
24
|
+
* (the context-coverage audit). A law whose scope contains a malformed glob is
|
|
25
|
+
* excluded and reported, so a bad pattern fails loudly at generation rather than
|
|
26
|
+
* silently matching nothing at runtime.
|
|
27
|
+
*
|
|
28
|
+
* @param manifest - The project's law manifest.
|
|
29
|
+
* @returns The per-event hook groups and the list of laws rejected for bad globs.
|
|
30
|
+
*/
|
|
31
|
+
export function compileHooks(manifest) {
|
|
32
|
+
const invalid = [];
|
|
33
|
+
const valid = [];
|
|
34
|
+
for (const law of manifest.laws) {
|
|
35
|
+
const bad = law.scope.map((p) => ({ p, e: globError(p) })).find((x) => x.e);
|
|
36
|
+
if (bad)
|
|
37
|
+
invalid.push({ lawId: law.id, pattern: bad.p, error: bad.e });
|
|
38
|
+
else
|
|
39
|
+
valid.push(law);
|
|
40
|
+
}
|
|
41
|
+
const byEvent = {};
|
|
42
|
+
const hasBloqueo = valid.some((l) => l.enforcement === "bloqueo" && hasBackend(l));
|
|
43
|
+
const hasFeedback = valid.some((l) => l.enforcement === "feedback" && hasBackend(l));
|
|
44
|
+
const hasGate = valid.some((l) => l.enforcement === "gate");
|
|
45
|
+
if (hasBloqueo)
|
|
46
|
+
byEvent.PreToolUse = [{ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
|
|
47
|
+
if (hasFeedback)
|
|
48
|
+
byEvent.PostToolUse = [{ matcher: MUTATION_MATCHER, hooks: [{ ...SPECLAW_HOOK }] }];
|
|
49
|
+
if (hasGate)
|
|
50
|
+
byEvent.Stop = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
51
|
+
if (valid.length > 0)
|
|
52
|
+
byEvent.InstructionsLoaded = [{ hooks: [{ ...SPECLAW_HOOK }] }];
|
|
53
|
+
return { byEvent, invalid };
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Merge speclaw's compiled hook groups into an existing `hooks` object by
|
|
57
|
+
* identity: for every event, drop the groups speclaw owns (a group whose hooks
|
|
58
|
+
* are all speclaw's) and re-add the freshly compiled ones, never touching a
|
|
59
|
+
* group with a foreign `server` or `type`. Idempotent, marker-free, and it
|
|
60
|
+
* cannot delete another tool's hooks.
|
|
61
|
+
*
|
|
62
|
+
* @param existing - The current `hooks` object from the agent's settings (any shape).
|
|
63
|
+
* @param compiled - speclaw's per-event hook groups from {@link compileHooks}.
|
|
64
|
+
* @returns A new `hooks` object with speclaw's entries reconciled in.
|
|
65
|
+
*/
|
|
66
|
+
export function mergeHooks(existing, compiled) {
|
|
67
|
+
const out = {};
|
|
68
|
+
const events = new Set([...Object.keys(existing ?? {}), ...Object.keys(compiled)]);
|
|
69
|
+
for (const event of events) {
|
|
70
|
+
const prior = Array.isArray(existing?.[event]) ? existing[event] : [];
|
|
71
|
+
// Keep foreign groups: drop speclaw hooks from each group, then any group left empty.
|
|
72
|
+
const kept = prior
|
|
73
|
+
.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isSpeclawHook(h)) }))
|
|
74
|
+
.filter((g) => g.hooks.length > 0);
|
|
75
|
+
const mine = compiled[event] ?? [];
|
|
76
|
+
const merged = [...kept, ...mine];
|
|
77
|
+
if (merged.length > 0)
|
|
78
|
+
out[event] = merged;
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Install (or refresh) speclaw's hooks into one agent's settings file, merging by
|
|
84
|
+
* identity and honoring the managed-file baseline: a settings file that diverged
|
|
85
|
+
* from what speclaw last wrote is backed up to `<file>.bak` first when `backup`
|
|
86
|
+
* is set, and always reported. The baseline sha of the written file is recorded.
|
|
87
|
+
*
|
|
88
|
+
* @param projectPath - Project root.
|
|
89
|
+
* @param agent - The agent whose `hooks` capability names the settings file and key.
|
|
90
|
+
* @param compiled - speclaw's compiled hook groups.
|
|
91
|
+
* @param report - Install report mutated in place.
|
|
92
|
+
* @param opts - Managed-file behavior: recorded baselines, backup, and a record sink.
|
|
93
|
+
*/
|
|
94
|
+
function installForAgent(projectPath, agent, compiled, report, opts) {
|
|
95
|
+
if (!agent.hooks)
|
|
96
|
+
return;
|
|
97
|
+
const settingsPath = path.join(projectPath, agent.hooks.file);
|
|
98
|
+
const rel = path.relative(projectPath, settingsPath);
|
|
99
|
+
let settings = {};
|
|
100
|
+
let current = null;
|
|
101
|
+
if (fs.existsSync(settingsPath)) {
|
|
102
|
+
current = fs.readFileSync(settingsPath, "utf8");
|
|
103
|
+
try {
|
|
104
|
+
settings = JSON.parse(current);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A settings file we cannot parse is the user's — never clobber it silently.
|
|
108
|
+
report.skipped.push(`${settingsPath} (unparseable — left untouched)`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
settings[agent.hooks.key] = mergeHooks(settings[agent.hooks.key], compiled);
|
|
113
|
+
const content = JSON.stringify(settings, null, 2) + "\n";
|
|
114
|
+
const newSha = sha256(content);
|
|
115
|
+
if (current !== null) {
|
|
116
|
+
if (sha256(current) === newSha) {
|
|
117
|
+
if (opts.record)
|
|
118
|
+
opts.record[rel] = newSha;
|
|
119
|
+
return; // already current — no drift
|
|
120
|
+
}
|
|
121
|
+
const baseline = opts.baselines?.[rel];
|
|
122
|
+
if (!baseline || sha256(current) !== baseline) {
|
|
123
|
+
if (opts.backup) {
|
|
124
|
+
fs.copyFileSync(settingsPath, settingsPath + ".bak");
|
|
125
|
+
report.backedUp.push(settingsPath);
|
|
126
|
+
}
|
|
127
|
+
report.refreshedDiverged.push(settingsPath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
131
|
+
fs.writeFileSync(settingsPath, content);
|
|
132
|
+
report.written.push(`${settingsPath} (speclaw hooks)`);
|
|
133
|
+
if (opts.record)
|
|
134
|
+
opts.record[rel] = newSha;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Compile the manifest and install speclaw's hooks into every hook-capable agent
|
|
138
|
+
* among those selected, skipping agents without a `hooks` capability (Cursor,
|
|
139
|
+
* Codex, Windsurf) by construction. A malformed glob excludes only that law and
|
|
140
|
+
* is surfaced in the result.
|
|
141
|
+
*
|
|
142
|
+
* @param projectPath - Project root.
|
|
143
|
+
* @param agentIds - Ids of the agents configured for this project.
|
|
144
|
+
* @param manifest - The project's law manifest.
|
|
145
|
+
* @param report - Install report mutated in place.
|
|
146
|
+
* @param opts - Managed-file behavior: recorded baselines, backup, and a record sink.
|
|
147
|
+
* @returns Which agents were hooked, which were skipped, and any rejected laws.
|
|
148
|
+
*/
|
|
149
|
+
export function installHooks(projectPath, agentIds, manifest, report, opts) {
|
|
150
|
+
const { byEvent, invalid } = compileHooks(manifest);
|
|
151
|
+
const hooked = [];
|
|
152
|
+
const unhooked = [];
|
|
153
|
+
for (const id of agentIds) {
|
|
154
|
+
const agent = agentById(id);
|
|
155
|
+
if (!agent)
|
|
156
|
+
continue;
|
|
157
|
+
if (!agent.hooks) {
|
|
158
|
+
unhooked.push(id);
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
installForAgent(projectPath, agent, byEvent, report, opts);
|
|
162
|
+
hooked.push(id);
|
|
163
|
+
}
|
|
164
|
+
return { hooked, unhooked, invalid };
|
|
165
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { assetsDir } from "../../shared/paths.js";
|
|
5
|
+
// The machine-readable law model and its manifest. This is the contract seam
|
|
6
|
+
// (`.speclaw/laws-manifest.json`) between where laws come from and how they are
|
|
7
|
+
// enforced: check-dispatcher owns the schema and the single `path` verification
|
|
8
|
+
// backend; executable-laws extends the same model with `ast`/`deps`/`process`
|
|
9
|
+
// backends by filling in more `verification.kind` cases — it never rewrites it.
|
|
10
|
+
const ASSETS = assetsDir(import.meta.url);
|
|
11
|
+
const lawSchema = z.object({
|
|
12
|
+
id: z.string().min(1),
|
|
13
|
+
title: z.string().min(1),
|
|
14
|
+
rationale: z.string().optional(),
|
|
15
|
+
severity: z.enum(["error", "warn", "info"]),
|
|
16
|
+
scope: z.array(z.string()),
|
|
17
|
+
prose: z.string().min(1),
|
|
18
|
+
verification: z.object({
|
|
19
|
+
kind: z.enum(["path", "ast", "graph", "deps", "process", "traceability", "semantic", "none"]),
|
|
20
|
+
}),
|
|
21
|
+
enforcement: z.enum(["bloqueo", "feedback", "gate"]),
|
|
22
|
+
source: z.object({ file: z.string(), line: z.number().optional() }),
|
|
23
|
+
});
|
|
24
|
+
const manifestSchema = z.object({
|
|
25
|
+
version: z.number(),
|
|
26
|
+
laws: z.array(lawSchema),
|
|
27
|
+
});
|
|
28
|
+
/** The verification backends this change actually evaluates at runtime. */
|
|
29
|
+
export const IMPLEMENTED_BACKENDS = ["path"];
|
|
30
|
+
/** True when a law's verification backend is evaluated at runtime (only `path` today). */
|
|
31
|
+
export function hasBackend(law) {
|
|
32
|
+
return IMPLEMENTED_BACKENDS.includes(law.verification.kind);
|
|
33
|
+
}
|
|
34
|
+
/** Absolute path to a project's compiled law manifest (under the gitignored `.speclaw/`). */
|
|
35
|
+
export function manifestPath(projectPath) {
|
|
36
|
+
return path.join(projectPath, ".speclaw", "laws-manifest.json");
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read and validate a project's law manifest.
|
|
40
|
+
*
|
|
41
|
+
* @param projectPath - Project root to read from.
|
|
42
|
+
* @returns The parsed manifest, or null if it is missing or unparseable (the
|
|
43
|
+
* caller fails open — a broken manifest never blocks the agent).
|
|
44
|
+
*/
|
|
45
|
+
export function readLawManifest(projectPath) {
|
|
46
|
+
try {
|
|
47
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath(projectPath), "utf8"));
|
|
48
|
+
return manifestSchema.parse(raw);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Write a project's law manifest, validating every law first.
|
|
56
|
+
*
|
|
57
|
+
* @param projectPath - Project root to write into.
|
|
58
|
+
* @param manifest - The manifest to persist; each law is schema-validated.
|
|
59
|
+
* @throws If any law fails validation.
|
|
60
|
+
*/
|
|
61
|
+
export function writeLawManifest(projectPath, manifest) {
|
|
62
|
+
const validated = manifestSchema.parse(manifest);
|
|
63
|
+
const p = manifestPath(projectPath);
|
|
64
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
65
|
+
fs.writeFileSync(p, JSON.stringify(validated, null, 2) + "\n");
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The starter law manifest shipped with speclaw, seeded from a speclaw-style
|
|
69
|
+
* project's own `path`-verifiable Project-specific laws. It is the source the
|
|
70
|
+
* MVP compiles into `.speclaw/laws-manifest.json`; once executable-laws lands,
|
|
71
|
+
* laws are authored in `docs/standards/*` and compiled here instead. Laws whose
|
|
72
|
+
* scope does not match a given repo are simply inert there.
|
|
73
|
+
*
|
|
74
|
+
* @returns The validated seed manifest read from the module's assets.
|
|
75
|
+
* @throws If the seed asset is missing or fails validation.
|
|
76
|
+
*/
|
|
77
|
+
export function seedManifest() {
|
|
78
|
+
const raw = JSON.parse(fs.readFileSync(path.join(ASSETS, "laws", "laws-manifest.json"), "utf8"));
|
|
79
|
+
return manifestSchema.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
// ─── Glob matching (the `path` backend) ──────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Validate a scope glob without compiling it for use, so generation can fail
|
|
84
|
+
* loudly on a malformed pattern (e.g. an unclosed `[`) rather than silently
|
|
85
|
+
* matching zero files at runtime.
|
|
86
|
+
*
|
|
87
|
+
* @param pattern - A single scope glob (a leading `!` negation is allowed).
|
|
88
|
+
* @returns An error message if the glob is malformed, else null.
|
|
89
|
+
*/
|
|
90
|
+
export function globError(pattern) {
|
|
91
|
+
try {
|
|
92
|
+
compileGlob(pattern);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
return err.message;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Regex-special characters that are literals in a glob and must be escaped. */
|
|
100
|
+
const REGEX_SPECIALS = new Set([".", "+", "^", "$", "(", ")", "|", "\\"]);
|
|
101
|
+
/**
|
|
102
|
+
* Compile a glob into an anchored regular expression matching a POSIX-style
|
|
103
|
+
* relative path. Supports `**` (any run of segments), `*`/`?` (within a
|
|
104
|
+
* segment), `{a,b}` alternation, and `[...]`/`[!...]` character classes.
|
|
105
|
+
*
|
|
106
|
+
* @param pattern - A single scope glob; a leading `!` is stripped by the caller.
|
|
107
|
+
* @returns A `RegExp` anchored to the whole path.
|
|
108
|
+
* @throws If the glob contains an unclosed `[` or `{`.
|
|
109
|
+
*/
|
|
110
|
+
export function compileGlob(pattern) {
|
|
111
|
+
let re = "";
|
|
112
|
+
let braceDepth = 0;
|
|
113
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
114
|
+
const ch = pattern[i];
|
|
115
|
+
if (ch === "*") {
|
|
116
|
+
if (pattern[i + 1] === "*") {
|
|
117
|
+
// `**` (optionally followed by `/`) spans any number of segments.
|
|
118
|
+
i++;
|
|
119
|
+
if (pattern[i + 1] === "/")
|
|
120
|
+
i++;
|
|
121
|
+
re += "(?:[^/]*(?:/|$))*";
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
re += "[^/]*";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
else if (ch === "?") {
|
|
128
|
+
re += "[^/]";
|
|
129
|
+
}
|
|
130
|
+
else if (ch === "{") {
|
|
131
|
+
braceDepth++;
|
|
132
|
+
re += "(?:";
|
|
133
|
+
}
|
|
134
|
+
else if (ch === "}") {
|
|
135
|
+
if (braceDepth === 0)
|
|
136
|
+
throw new Error(`unmatched '}' in glob: ${pattern}`);
|
|
137
|
+
braceDepth--;
|
|
138
|
+
re += ")";
|
|
139
|
+
}
|
|
140
|
+
else if (ch === "," && braceDepth > 0) {
|
|
141
|
+
re += "|";
|
|
142
|
+
}
|
|
143
|
+
else if (ch === "[") {
|
|
144
|
+
const close = pattern.indexOf("]", i + 1);
|
|
145
|
+
if (close === -1)
|
|
146
|
+
throw new Error(`unclosed '[' in glob: ${pattern}`);
|
|
147
|
+
let cls = pattern.slice(i + 1, close);
|
|
148
|
+
if (cls.startsWith("!"))
|
|
149
|
+
cls = "^" + cls.slice(1);
|
|
150
|
+
re += `[${cls}]`;
|
|
151
|
+
i = close;
|
|
152
|
+
}
|
|
153
|
+
else if (REGEX_SPECIALS.has(ch)) {
|
|
154
|
+
re += "\\" + ch;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
re += ch;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (braceDepth !== 0)
|
|
161
|
+
throw new Error(`unclosed '{' in glob: ${pattern}`);
|
|
162
|
+
return new RegExp(`^${re}$`);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Compile a law's scope globs into regexes once, so runtime matching does no
|
|
166
|
+
* regex compilation on the critical path. Malformed globs are dropped (they are
|
|
167
|
+
* caught and reported at generation time).
|
|
168
|
+
*
|
|
169
|
+
* @param scope - The law's scope globs.
|
|
170
|
+
* @returns The compiled positive and negative matchers.
|
|
171
|
+
*/
|
|
172
|
+
export function compileScope(scope) {
|
|
173
|
+
const positives = [];
|
|
174
|
+
const negatives = [];
|
|
175
|
+
for (const g of scope) {
|
|
176
|
+
const negated = g.startsWith("!");
|
|
177
|
+
try {
|
|
178
|
+
(negated ? negatives : positives).push(compileGlob(negated ? g.slice(1) : g));
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// Skip malformed globs — generation already flagged them.
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return { matchAll: scope.length === 0, positives, negatives };
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Test a target path against a pre-compiled scope. Positive globs are OR-ed; a
|
|
188
|
+
* `!`-prefixed glob excludes; an empty scope matches everything.
|
|
189
|
+
*
|
|
190
|
+
* @param compiled - The scope compiled by {@link compileScope}.
|
|
191
|
+
* @param target - A POSIX-style project-relative path (forward slashes).
|
|
192
|
+
* @returns True when the target is in scope.
|
|
193
|
+
*/
|
|
194
|
+
export function matchCompiled(compiled, target) {
|
|
195
|
+
const included = compiled.matchAll ||
|
|
196
|
+
compiled.positives.length === 0 ||
|
|
197
|
+
compiled.positives.some((r) => r.test(target));
|
|
198
|
+
return included && !compiled.negatives.some((r) => r.test(target));
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Test whether a target path matches a law's scope, compiling on the spot.
|
|
202
|
+
* Convenience for non-hot paths (doctor, dry-run); the evaluator uses
|
|
203
|
+
* {@link compileScope} + {@link matchCompiled} to stay off the compiler.
|
|
204
|
+
*
|
|
205
|
+
* @param scope - The law's scope globs.
|
|
206
|
+
* @param target - A POSIX-style project-relative path (forward slashes).
|
|
207
|
+
* @returns True when the target is in scope. Malformed globs never match.
|
|
208
|
+
*/
|
|
209
|
+
export function matchesScope(scope, target) {
|
|
210
|
+
return matchCompiled(compileScope(scope), target);
|
|
211
|
+
}
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { text } from "../../shared/mcp.js";
|
|
3
3
|
import { scaffold } from "./scaffold.js";
|
|
4
4
|
import { doctor } from "./doctor.js";
|
|
5
|
+
import { checkAction } from "./check.js";
|
|
5
6
|
import { loadPacks } from "../tools/packs.js";
|
|
6
7
|
import { AGENTS, configureAgent } from "../../shared/agents.js";
|
|
7
8
|
import { emptyReport } from "../../shared/install.js";
|
|
@@ -116,6 +117,18 @@ export function registerFoundation(server) {
|
|
|
116
117
|
configureAgent(projectPath, agent, report);
|
|
117
118
|
return text(report);
|
|
118
119
|
});
|
|
120
|
+
server.registerTool("speclaw_check", {
|
|
121
|
+
// ≤12 words: this is invoked by speclaw's hooks, never called directly.
|
|
122
|
+
description: "Invoked by speclaw's hooks to enforce laws — do not call directly.",
|
|
123
|
+
inputSchema: {
|
|
124
|
+
projectPath: z.string().describe("Absolute path to the project"),
|
|
125
|
+
event: z
|
|
126
|
+
.enum(["PreToolUse", "PostToolUse", "Stop", "InstructionsLoaded"])
|
|
127
|
+
.describe("The hook event that fired"),
|
|
128
|
+
toolName: z.string().optional().describe("The tool the agent is invoking, when relevant"),
|
|
129
|
+
payload: z.record(z.unknown()).describe("The raw hook event payload from the agent"),
|
|
130
|
+
},
|
|
131
|
+
}, async ({ projectPath, event, toolName, payload }) => text(checkAction({ projectPath, event: event, toolName, payload })));
|
|
119
132
|
server.registerTool("doctor", {
|
|
120
133
|
description: "Verify a speclaw installation: ai-specs presence, the foundation (LAWS.md + standards + agent contracts), IDE symlinks health, the lawbook/ workflow, the Compass index, and .mcp.json wiring. Returns a checklist with remediation hints.",
|
|
121
134
|
inputSchema: { projectPath: z.string().describe("Absolute path to the project") },
|
|
@@ -8,6 +8,8 @@ import { installWorkflow } from "../lawbook/register.js";
|
|
|
8
8
|
import { installPack, loadPacks } from "../tools/packs.js";
|
|
9
9
|
import { readManifest, writeManifest } from "../../shared/manifest.js";
|
|
10
10
|
import { pkgVersion } from "../../shared/version.js";
|
|
11
|
+
import { readLawManifest, seedManifest, writeLawManifest } from "./laws.js";
|
|
12
|
+
import { installHooks } from "./hooks.js";
|
|
11
13
|
const ASSETS = assetsDir(import.meta.url);
|
|
12
14
|
// Every {{var}} the foundation templates may reference. Ones the agent didn't
|
|
13
15
|
// provide default to empty so a bare `scaffold` never leaves a raw {{tag}}.
|
|
@@ -22,6 +24,21 @@ const FOUNDATION_DEFAULTS = {
|
|
|
22
24
|
versioning_rules: "",
|
|
23
25
|
documentation_extra: "",
|
|
24
26
|
};
|
|
27
|
+
/**
|
|
28
|
+
* Ensure the project has a law manifest, seeding it from the package's starter
|
|
29
|
+
* laws when absent. The manifest is a derived artifact under the gitignored
|
|
30
|
+
* `.speclaw/`; seeding only when missing keeps a curated manifest (the MVP's
|
|
31
|
+
* authoring surface until executable-laws) from being overwritten on update.
|
|
32
|
+
*/
|
|
33
|
+
function ensureLawManifest(projectPath, report) {
|
|
34
|
+
const existing = readLawManifest(projectPath);
|
|
35
|
+
if (existing)
|
|
36
|
+
return existing;
|
|
37
|
+
const seed = seedManifest();
|
|
38
|
+
writeLawManifest(projectPath, seed);
|
|
39
|
+
report.written.push(path.join(projectPath, ".speclaw", "laws-manifest.json"));
|
|
40
|
+
return seed;
|
|
41
|
+
}
|
|
25
42
|
/**
|
|
26
43
|
* Render the foundation: walk the module's assets/, mirror its structure into
|
|
27
44
|
* the project, stripping the `.template` marker (foo.template.md -> foo.md).
|
|
@@ -110,6 +127,15 @@ export function scaffold(projectPath, profile, packNames, agents = [], opts = {}
|
|
|
110
127
|
ensureGitignore(projectPath, "ai-specs/", "speclaw workflow content (regenerated by init/update; never commit)", report);
|
|
111
128
|
for (const id of agents)
|
|
112
129
|
configureAgent(projectPath, id, report); // only the chosen agents
|
|
130
|
+
// Compile the declared laws into agent hooks for every hook-capable agent just
|
|
131
|
+
// configured. The seam is the manifest: check-dispatcher enforces `path` laws;
|
|
132
|
+
// executable-laws will extend the same manifest with more backends.
|
|
133
|
+
const lawManifest = ensureLawManifest(projectPath, report);
|
|
134
|
+
report.hooks = installHooks(projectPath, agents, lawManifest, report, {
|
|
135
|
+
baselines: managedOpts.baselines,
|
|
136
|
+
backup: managedOpts.backup,
|
|
137
|
+
record,
|
|
138
|
+
});
|
|
113
139
|
// Record what was installed so `speclaw update` can re-apply these packs and
|
|
114
140
|
// gate feature migrations by version, plus the managed-file baselines that let
|
|
115
141
|
// a later update tell user edits from stale files.
|
package/dist/shared/agents.js
CHANGED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
/** ASCII NUL — the record/field separator git emits with `-z` and we request via `%x00`. */
|
|
3
|
+
const NUL = "\0";
|
|
4
|
+
/**
|
|
5
|
+
* Run git in `projectPath` and return stdout, or `null` on any failure.
|
|
6
|
+
*
|
|
7
|
+
* Best-effort like {@link isGitRepo}: git missing, not a repo, or a non-zero
|
|
8
|
+
* exit all yield `null` so callers can fail soft rather than throw.
|
|
9
|
+
*/
|
|
10
|
+
function git(projectPath, args) {
|
|
11
|
+
// `core.quotePath=false` keeps non-ASCII paths as raw UTF-8 instead of git's
|
|
12
|
+
// default octal-escaped, double-quoted form — so our path parsing stays exact.
|
|
13
|
+
const res = spawnSync("git", ["-C", projectPath, "-c", "core.quotePath=false", ...args], {
|
|
14
|
+
encoding: "utf8",
|
|
15
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
16
|
+
});
|
|
17
|
+
if (res.status !== 0 || typeof res.stdout !== "string")
|
|
18
|
+
return null;
|
|
19
|
+
return res.stdout;
|
|
20
|
+
}
|
|
21
|
+
/** Parse a `--numstat` count field: `-` (binary) becomes `0`, anything non-numeric too. */
|
|
22
|
+
function numstat(field) {
|
|
23
|
+
if (field === "-")
|
|
24
|
+
return 0;
|
|
25
|
+
const n = Number.parseInt(field, 10);
|
|
26
|
+
return Number.isFinite(n) ? n : 0;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The current `HEAD` commit SHA, or `null` when the repo has no commits yet
|
|
30
|
+
* (or is not a repo / git is unavailable).
|
|
31
|
+
*
|
|
32
|
+
* @param projectPath - Directory inside the work tree to query.
|
|
33
|
+
* @returns The 40-char SHA, or `null`.
|
|
34
|
+
*/
|
|
35
|
+
export function headSha(projectPath) {
|
|
36
|
+
const out = git(projectPath, ["rev-parse", "HEAD"]);
|
|
37
|
+
const sha = out?.trim();
|
|
38
|
+
return sha ? sha : null;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Whether `projectPath` is inside a shallow clone (e.g. CI's `--depth=1`), where
|
|
42
|
+
* history is truncated and change/coupling counts would be misleadingly low.
|
|
43
|
+
*
|
|
44
|
+
* @param projectPath - Directory inside the work tree to query.
|
|
45
|
+
* @returns `true` only when git reports the repository is shallow.
|
|
46
|
+
*/
|
|
47
|
+
export function isShallowRepo(projectPath) {
|
|
48
|
+
const out = git(projectPath, ["rev-parse", "--is-shallow-repository"]);
|
|
49
|
+
return out?.trim() === "true";
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The commits that touched `relPath`, most-recent first, with the line churn
|
|
53
|
+
* each introduced there.
|
|
54
|
+
*
|
|
55
|
+
* Fail-soft: a path with no history, a repo with no commits, or an unavailable
|
|
56
|
+
* git binary all yield an empty list. Records are parsed over NUL separators, so
|
|
57
|
+
* paths containing spaces or unicode are handled correctly.
|
|
58
|
+
*
|
|
59
|
+
* `since`/`until` bound the history as a **revision range** (`since..until`,
|
|
60
|
+
* `until` defaulting to `HEAD`) — the exact, deterministic form drift needs
|
|
61
|
+
* (`<archived-sha>..HEAD`), not an approximate date window. `since` is exclusive.
|
|
62
|
+
*
|
|
63
|
+
* @param projectPath - Project root to query.
|
|
64
|
+
* @param relPath - Project-relative path whose history to read.
|
|
65
|
+
* @param opts - Optional revision bounds: `since` (exclusive lower bound) and
|
|
66
|
+
* `until` (upper bound, default `HEAD`) — any revision git accepts, e.g. a SHA.
|
|
67
|
+
* @returns The touching commits, newest first; empty when there is no history.
|
|
68
|
+
*/
|
|
69
|
+
export function logForPath(projectPath, relPath, opts = {}) {
|
|
70
|
+
const range = [];
|
|
71
|
+
if (opts.since)
|
|
72
|
+
range.push(`${opts.since}..${opts.until ?? "HEAD"}`);
|
|
73
|
+
else if (opts.until)
|
|
74
|
+
range.push(opts.until);
|
|
75
|
+
// Per commit: <sha>\0<ts>\0 then one numstat line per file it touched.
|
|
76
|
+
const out = git(projectPath, [
|
|
77
|
+
"log",
|
|
78
|
+
"--format=%x00%H%x00%ct%x00",
|
|
79
|
+
"--numstat",
|
|
80
|
+
...range,
|
|
81
|
+
"--",
|
|
82
|
+
relPath,
|
|
83
|
+
]);
|
|
84
|
+
if (out === null)
|
|
85
|
+
return [];
|
|
86
|
+
const touches = [];
|
|
87
|
+
// The stream is a sequence of "\0<sha>\0<ts>\0<numstat lines>" per commit.
|
|
88
|
+
const records = out.split(NUL);
|
|
89
|
+
// records[0] is empty (leading NUL); then repeating [sha, ts, tail...] where
|
|
90
|
+
// `tail` holds the numstat lines for that commit up to the next leading NUL.
|
|
91
|
+
for (let i = 1; i + 1 < records.length; i += 3) {
|
|
92
|
+
const sha = records[i]?.trim();
|
|
93
|
+
const ts = Number.parseInt(records[i + 1] ?? "", 10);
|
|
94
|
+
const tail = records[i + 2] ?? "";
|
|
95
|
+
if (!sha || !Number.isFinite(ts))
|
|
96
|
+
continue;
|
|
97
|
+
let added = 0;
|
|
98
|
+
let deleted = 0;
|
|
99
|
+
for (const line of tail.split("\n")) {
|
|
100
|
+
const cols = line.split("\t");
|
|
101
|
+
if (cols.length < 3)
|
|
102
|
+
continue;
|
|
103
|
+
added += numstat(cols[0]);
|
|
104
|
+
deleted += numstat(cols[1]);
|
|
105
|
+
}
|
|
106
|
+
touches.push({ sha, ts, added, deleted });
|
|
107
|
+
}
|
|
108
|
+
return touches;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* How many commits touched each file in the window, summed from `--numstat`.
|
|
112
|
+
*
|
|
113
|
+
* Fail-soft: yields an empty map on any git failure. Does not follow renames —
|
|
114
|
+
* a renamed file is counted under its path as it appears in each commit (a safe
|
|
115
|
+
* superset, not a precise lineage). The result carries the {@link isShallowRepo}
|
|
116
|
+
* marker so consumers can degrade to "insufficient data" on a shallow clone.
|
|
117
|
+
*
|
|
118
|
+
* @param projectPath - Project root to query.
|
|
119
|
+
* @param opts - Optional `since` window (any date/revision git accepts) and a
|
|
120
|
+
* `pathspec` list to restrict which paths are considered.
|
|
121
|
+
* @returns Per-path change counts and the shallow marker.
|
|
122
|
+
*/
|
|
123
|
+
export function churn(projectPath, opts = {}) {
|
|
124
|
+
const shallow = isShallowRepo(projectPath);
|
|
125
|
+
const args = ["log", "--numstat", "--format=%x00"];
|
|
126
|
+
if (opts.since)
|
|
127
|
+
args.push(`--since=${opts.since}`);
|
|
128
|
+
if (opts.pathspec && opts.pathspec.length > 0)
|
|
129
|
+
args.push("--", ...opts.pathspec);
|
|
130
|
+
const out = git(projectPath, args);
|
|
131
|
+
const byPath = new Map();
|
|
132
|
+
if (out === null)
|
|
133
|
+
return { shallow, byPath };
|
|
134
|
+
for (const line of out.split("\n")) {
|
|
135
|
+
// Numstat rows are "<added>\t<deleted>\t<path>"; the %x00 format lines and
|
|
136
|
+
// blank lines have no tabs and are skipped.
|
|
137
|
+
const cols = line.split("\t");
|
|
138
|
+
if (cols.length < 3)
|
|
139
|
+
continue;
|
|
140
|
+
const path = cols[2].replace(/^\0+/, "").trim();
|
|
141
|
+
if (!path)
|
|
142
|
+
continue;
|
|
143
|
+
byPath.set(path, (byPath.get(path) ?? 0) + 1);
|
|
144
|
+
}
|
|
145
|
+
return { shallow, byPath };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* For every pair of files that changed together, how many commits touched both.
|
|
149
|
+
*
|
|
150
|
+
* Groups each commit's changed files and emits a count per unordered pair. Pairs
|
|
151
|
+
* with fewer than `minSupport` shared commits are omitted. Fail-soft (empty on
|
|
152
|
+
* git failure) and does not follow renames. Carries the shallow marker.
|
|
153
|
+
*
|
|
154
|
+
* @param projectPath - Project root to query.
|
|
155
|
+
* @param opts - Optional `since` window and `minSupport` threshold (default `1`).
|
|
156
|
+
* @returns The qualifying co-change pairs and the shallow marker.
|
|
157
|
+
*/
|
|
158
|
+
export function coChanges(projectPath, opts = {}) {
|
|
159
|
+
const shallow = isShallowRepo(projectPath);
|
|
160
|
+
const minSupport = opts.minSupport ?? 1;
|
|
161
|
+
const args = ["log", "--name-only", "--format=%x00"];
|
|
162
|
+
if (opts.since)
|
|
163
|
+
args.push(`--since=${opts.since}`);
|
|
164
|
+
const out = git(projectPath, args);
|
|
165
|
+
if (out === null)
|
|
166
|
+
return { shallow, pairs: [] };
|
|
167
|
+
const counts = new Map();
|
|
168
|
+
// Each commit's file list is the run of lines between two %x00 markers.
|
|
169
|
+
for (const commitBlock of out.split(NUL)) {
|
|
170
|
+
const files = commitBlock
|
|
171
|
+
.split("\n")
|
|
172
|
+
.map((l) => l.trim())
|
|
173
|
+
.filter((l) => l.length > 0);
|
|
174
|
+
const unique = [...new Set(files)].sort();
|
|
175
|
+
for (let i = 0; i < unique.length; i++) {
|
|
176
|
+
for (let j = i + 1; j < unique.length; j++) {
|
|
177
|
+
const key = `${unique[i]}\t${unique[j]}`;
|
|
178
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const pairs = [];
|
|
183
|
+
for (const [key, count] of counts) {
|
|
184
|
+
if (count < minSupport)
|
|
185
|
+
continue;
|
|
186
|
+
const [a, b] = key.split("\t");
|
|
187
|
+
pairs.push({ a: a, b: b, count });
|
|
188
|
+
}
|
|
189
|
+
pairs.sort((x, y) => y.count - x.count || x.a.localeCompare(y.a) || x.b.localeCompare(y.b));
|
|
190
|
+
return { shallow, pairs };
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The SHA of the most recent commit that touched `relPath`, or `null` when the
|
|
194
|
+
* path has no history (or on any git failure).
|
|
195
|
+
*
|
|
196
|
+
* @param projectPath - Project root to query.
|
|
197
|
+
* @param relPath - Project-relative path.
|
|
198
|
+
* @returns The last-touching commit SHA, or `null`.
|
|
199
|
+
*/
|
|
200
|
+
export function lastTouch(projectPath, relPath) {
|
|
201
|
+
const out = git(projectPath, ["log", "-1", "--format=%H", "--", relPath]);
|
|
202
|
+
const sha = out?.trim();
|
|
203
|
+
return sha ? sha : null;
|
|
204
|
+
}
|