@esneiderbravo/speclaw 0.3.13 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +88 -72
- package/dist/cli/commands/index-build.js +12 -3
- package/dist/cli/commands/lawbook.js +1 -0
- package/dist/cli/commands/laws.js +149 -8
- package/dist/cli/commands/owners.js +44 -0
- package/dist/cli/commands/query.js +52 -10
- package/dist/cli/commands/update.js +35 -5
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +15 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/diff-context.js +134 -0
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +134 -0
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -0
- package/dist/modules/compass/impact-summary.js +33 -0
- package/dist/modules/compass/indexer.js +204 -33
- package/dist/modules/compass/merkle.js +76 -0
- package/dist/modules/compass/pagerank.js +122 -0
- package/dist/modules/compass/rank.js +95 -0
- package/dist/modules/compass/register.js +169 -75
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- package/dist/modules/foundation/context-budget.js +1 -14
- package/dist/modules/foundation/dialects/agentsmd.js +95 -0
- package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
- package/dist/modules/foundation/dialects/coderabbit.js +27 -0
- package/dist/modules/foundation/dialects/copilot.js +35 -0
- package/dist/modules/foundation/dialects/index.js +5 -0
- package/dist/modules/foundation/dialects/types.js +58 -0
- package/dist/modules/foundation/doctor.js +266 -14
- package/dist/modules/foundation/import-rules.js +67 -0
- package/dist/modules/foundation/integrity.js +307 -0
- package/dist/modules/foundation/laws-parse.js +131 -0
- package/dist/modules/foundation/laws.js +5 -0
- package/dist/modules/foundation/lock.js +283 -0
- package/dist/modules/foundation/ownership.js +4 -0
- package/dist/modules/foundation/register-core.js +57 -88
- package/dist/modules/foundation/register.js +1 -21
- package/dist/modules/foundation/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/setup-tool.js +96 -0
- package/dist/modules/foundation/verify.js +9 -1
- package/dist/modules/lawbook/assets/commands/archive.md +1 -1
- package/dist/modules/lawbook/assets/commands/draft.md +1 -1
- package/dist/modules/lawbook/assets/commands/explore.md +1 -1
- package/dist/modules/lawbook/assets/commands/sync.md +2 -2
- package/dist/modules/lawbook/assets/skills/archive/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/archive/steps/03-validate-and-sync.md +3 -3
- package/dist/modules/lawbook/assets/skills/archive/steps/04-archive.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/02-understand.md +1 -1
- package/dist/modules/lawbook/assets/skills/draft/steps/05-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/explore/steps/01-investigate.md +1 -1
- package/dist/modules/lawbook/assets/skills/quick/steps/02-implement.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/SKILL.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/03-validate.md +1 -1
- package/dist/modules/lawbook/assets/skills/sync/steps/04-promote.md +1 -1
- package/dist/modules/lawbook/change-tool.js +90 -0
- package/dist/modules/lawbook/coverage.js +45 -6
- package/dist/modules/lawbook/ears.js +417 -0
- package/dist/modules/lawbook/engine.js +29 -0
- package/dist/modules/lawbook/register.js +96 -54
- package/dist/modules/lawbook/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/dist/modules/tools/register.js +4 -26
- package/dist/shared/deprecation.js +99 -0
- package/dist/shared/exposure.js +4 -19
- package/dist/shared/git.js +25 -0
- package/dist/shared/mcp.js +29 -3
- package/dist/shared/output-budget.js +68 -0
- package/dist/shared/tool-catalog.js +49 -0
- package/package.json +4 -3
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import { defineTool, text } from "../../shared/mcp.js";
|
|
3
|
+
import { defineTool, defineAliasTool, text } from "../../shared/mcp.js";
|
|
4
4
|
import { shouldExpose } from "../../shared/exposure.js";
|
|
5
|
+
import { aliasesEnabled } from "../../shared/tool-catalog.js";
|
|
6
|
+
import { logDeprecatedCall, prefixDeprecated } from "../../shared/deprecation.js";
|
|
5
7
|
import { assetsDir } from "../../shared/paths.js";
|
|
6
8
|
import { copyRendered } from "../../shared/install.js";
|
|
7
|
-
import { specInit, specValidate, specSync, specArchive, specList } from "./engine.js";
|
|
8
|
-
import { handleLevel } from "./quick.js";
|
|
9
9
|
import { investigate, formatInvestigateResult } from "./investigate.js";
|
|
10
|
-
import {
|
|
11
|
-
import { buildDriftReport, renderDriftAgent } from "./drift.js";
|
|
10
|
+
import { handleLawbookChange, lawbookChangeSchema } from "./change-tool.js";
|
|
12
11
|
const ASSETS = assetsDir(import.meta.url);
|
|
13
12
|
/**
|
|
14
|
-
* Install the spec module's workflow interface into a project's ai-specs
|
|
15
|
-
* the draft/build/sync/archive/explore skills, the /spec commands, and the
|
|
16
|
-
* mandatory-task-steps rule. Always installed — it's the core workflow.
|
|
13
|
+
* Install the spec module's workflow interface into a project's ai-specs/.
|
|
17
14
|
*/
|
|
18
15
|
export function installWorkflow(projectPath, vars, report, opts) {
|
|
19
16
|
const aiSpecs = path.join(projectPath, "ai-specs");
|
|
@@ -21,7 +18,7 @@ export function installWorkflow(projectPath, vars, report, opts) {
|
|
|
21
18
|
copyRendered(path.join(ASSETS, "commands"), path.join(aiSpecs, "commands", "lawbook"), vars, report, opts);
|
|
22
19
|
copyRendered(path.join(ASSETS, "rules"), path.join(aiSpecs, "rules"), vars, report, opts);
|
|
23
20
|
}
|
|
24
|
-
/** Register the spec workflow MCP tools
|
|
21
|
+
/** Register the spec workflow MCP tools. */
|
|
25
22
|
export function registerSpec(server, opts = {}) {
|
|
26
23
|
const minimal = Boolean(opts.minimal);
|
|
27
24
|
const add = (name, description, inputSchema, handler) => {
|
|
@@ -29,57 +26,102 @@ export function registerSpec(server, opts = {}) {
|
|
|
29
26
|
return;
|
|
30
27
|
defineTool(server, { name, description, inputSchema, handler });
|
|
31
28
|
};
|
|
32
|
-
add("
|
|
33
|
-
add("
|
|
34
|
-
add("lawbook_level", "Propose, set, promote, or explain a change's ceremony level (0–3).", {
|
|
35
|
-
projectPath: z.string(),
|
|
36
|
-
mode: z.enum(["propose", "set", "promote", "explain"]),
|
|
37
|
-
change: z.string().optional(),
|
|
38
|
-
paths: z.array(z.string()).optional(),
|
|
39
|
-
symbols: z.array(z.string()).optional(),
|
|
40
|
-
level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]).optional(),
|
|
41
|
-
reason: z.string().optional(),
|
|
42
|
-
}, async (args) => text(handleLevel(args)));
|
|
43
|
-
add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom. Returns suspects with reasons — evidence, not a verdict.", {
|
|
29
|
+
add("lawbook_change", "Lawbook lifecycle: init, list, validate, sync, archive, level, coverage, drift.", lawbookChangeSchema, async (args) => text(handleLawbookChange(args)));
|
|
30
|
+
add("lawbook_investigate", "Rank bug origins from the graph. Pass stackTrace or symptom — evidence, not verdict.", {
|
|
44
31
|
projectPath: z.string(),
|
|
45
32
|
stackTrace: z.string().optional(),
|
|
46
33
|
symptom: z.string().optional(),
|
|
47
34
|
hintPaths: z.array(z.string()).optional(),
|
|
48
35
|
maxSuspects: z.number().int().min(1).max(25).optional(),
|
|
49
36
|
}, async (args) => text(formatInvestigateResult(await investigate(args))));
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
projectPath
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const report = buildCoverageReport(projectPath, { change, cfg });
|
|
65
|
-
if (json)
|
|
66
|
-
return text(JSON.stringify(report));
|
|
67
|
-
return text(renderCoverageAgent(report, onlyDefects !== false));
|
|
37
|
+
if (minimal || !aliasesEnabled())
|
|
38
|
+
return;
|
|
39
|
+
const aliasHandler = (alias, action) => async (args) => {
|
|
40
|
+
logDeprecatedCall(args.projectPath, alias);
|
|
41
|
+
const merged = { ...args, action };
|
|
42
|
+
const result = handleLawbookChange(merged);
|
|
43
|
+
const body = typeof result === "string" ? result : JSON.stringify(result, null, 2);
|
|
44
|
+
return text(prefixDeprecated(alias, body));
|
|
45
|
+
};
|
|
46
|
+
defineAliasTool(server, {
|
|
47
|
+
name: "lawbook_init",
|
|
48
|
+
description: "Deprecated alias for lawbook_change action init.",
|
|
49
|
+
inputSchema: { projectPath: z.string() },
|
|
50
|
+
handler: aliasHandler("lawbook_init", "init"),
|
|
68
51
|
});
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
52
|
+
defineAliasTool(server, {
|
|
53
|
+
name: "lawbook_list",
|
|
54
|
+
description: "Deprecated alias for lawbook_change action list.",
|
|
55
|
+
inputSchema: { projectPath: z.string() },
|
|
56
|
+
handler: aliasHandler("lawbook_list", "list"),
|
|
57
|
+
});
|
|
58
|
+
defineAliasTool(server, {
|
|
59
|
+
name: "lawbook_validate",
|
|
60
|
+
description: "Deprecated alias for lawbook_change action validate.",
|
|
61
|
+
inputSchema: { projectPath: z.string(), change: z.string() },
|
|
62
|
+
handler: aliasHandler("lawbook_validate", "validate"),
|
|
63
|
+
});
|
|
64
|
+
defineAliasTool(server, {
|
|
65
|
+
name: "lawbook_sync",
|
|
66
|
+
description: "Deprecated alias for lawbook_change action sync.",
|
|
67
|
+
inputSchema: { projectPath: z.string(), change: z.string() },
|
|
68
|
+
handler: aliasHandler("lawbook_sync", "sync"),
|
|
69
|
+
});
|
|
70
|
+
defineAliasTool(server, {
|
|
71
|
+
name: "lawbook_archive",
|
|
72
|
+
description: "Deprecated alias for lawbook_change action archive.",
|
|
73
|
+
inputSchema: {
|
|
74
|
+
projectPath: z.string(),
|
|
75
|
+
change: z.string(),
|
|
76
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
77
|
+
},
|
|
78
|
+
handler: aliasHandler("lawbook_archive", "archive"),
|
|
79
|
+
});
|
|
80
|
+
defineAliasTool(server, {
|
|
81
|
+
name: "lawbook_level",
|
|
82
|
+
description: "Deprecated alias for lawbook_change action level.",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
projectPath: z.string(),
|
|
85
|
+
mode: z.string().optional(),
|
|
86
|
+
change: z.string().optional(),
|
|
87
|
+
level: z.number().optional(),
|
|
88
|
+
reason: z.string().optional(),
|
|
89
|
+
},
|
|
90
|
+
handler: async (args) => {
|
|
91
|
+
logDeprecatedCall(args.projectPath, "lawbook_level");
|
|
92
|
+
const body = JSON.stringify(handleLawbookChange({ ...args, action: "level" }), null, 2);
|
|
93
|
+
return text(prefixDeprecated("lawbook_level", body));
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
defineAliasTool(server, {
|
|
97
|
+
name: "lawbook_coverage",
|
|
98
|
+
description: "Deprecated alias for lawbook_change action coverage.",
|
|
99
|
+
inputSchema: {
|
|
100
|
+
projectPath: z.string(),
|
|
101
|
+
change: z.string().optional(),
|
|
102
|
+
onlyDefects: z.boolean().optional(),
|
|
103
|
+
json: z.boolean().optional(),
|
|
104
|
+
},
|
|
105
|
+
handler: async (args) => {
|
|
106
|
+
logDeprecatedCall(args.projectPath, "lawbook_coverage");
|
|
107
|
+
const body = JSON.stringify(handleLawbookChange({ ...args, action: "coverage" }), null, 2);
|
|
108
|
+
return text(prefixDeprecated("lawbook_coverage", body));
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
defineAliasTool(server, {
|
|
112
|
+
name: "lawbook_drift",
|
|
113
|
+
description: "Deprecated alias for lawbook_change action drift.",
|
|
114
|
+
inputSchema: {
|
|
115
|
+
projectPath: z.string(),
|
|
116
|
+
capability: z.string().optional(),
|
|
117
|
+
includeReverse: z.boolean().optional(),
|
|
118
|
+
maxItems: z.number().int().optional(),
|
|
119
|
+
json: z.boolean().optional(),
|
|
120
|
+
},
|
|
121
|
+
handler: async (args) => {
|
|
122
|
+
logDeprecatedCall(args.projectPath, "lawbook_drift");
|
|
123
|
+
const body = JSON.stringify(handleLawbookChange({ ...args, action: "drift" }), null, 2);
|
|
124
|
+
return text(prefixDeprecated("lawbook_drift", body));
|
|
125
|
+
},
|
|
84
126
|
});
|
|
85
127
|
}
|
|
@@ -2,7 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
const RE_REQUIREMENT = /^###\s+Requirement:\s*(.+?)\s*$/;
|
|
4
4
|
const RE_ID = /`([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)`/;
|
|
5
|
-
const RE_KEYWORD = /^(Status|Needs|Tags|Depends|Covers)\s*:\s*(.+?)\s*$/i;
|
|
5
|
+
const RE_KEYWORD = /^(Status|Needs|Tags|Depends|Covers|Verification)\s*:\s*(.+?)\s*$/i;
|
|
6
6
|
const RE_INLINE = /\[@(test|impl)\s+([^\]]+)\]/gi;
|
|
7
7
|
const RE_ID_LOOSE = /\b([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)\b/g;
|
|
8
8
|
/** Format a SpecItemId as `type~name~rev`. */
|
|
@@ -75,6 +75,7 @@ export function parseSpecItems(specPath, content) {
|
|
|
75
75
|
tags: [],
|
|
76
76
|
depends: [],
|
|
77
77
|
covers: [],
|
|
78
|
+
verification: null,
|
|
78
79
|
inlineLinks: [],
|
|
79
80
|
specPath,
|
|
80
81
|
line: i + 1,
|
|
@@ -106,6 +107,8 @@ export function parseSpecItems(specPath, content) {
|
|
|
106
107
|
current.depends = parseIdList(value);
|
|
107
108
|
else if (key === "covers")
|
|
108
109
|
current.covers = parseIdList(value);
|
|
110
|
+
else if (key === "verification")
|
|
111
|
+
current.verification = value.trim().toLowerCase();
|
|
109
112
|
continue;
|
|
110
113
|
}
|
|
111
114
|
for (const m of line.matchAll(RE_INLINE)) {
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Spec ownership: compile `team.owners` from `lawbook/config.yaml` into a
|
|
5
|
+
* managed block at the **end** of `.github/CODEOWNERS` (GitHub: last match wins).
|
|
6
|
+
*
|
|
7
|
+
* Markers are the merge identity — user content outside them is preserved.
|
|
8
|
+
* No MCP surface; humans run `speclaw owners --write`.
|
|
9
|
+
*/
|
|
10
|
+
export const OWNERS_START = "# >>> speclaw:owners";
|
|
11
|
+
export const OWNERS_END = "# <<< speclaw:owners";
|
|
12
|
+
const START_LINE_RE = /^#\s*>>>\s*speclaw:owners\b.*$/m;
|
|
13
|
+
const END_LINE_RE = /^#\s*<<<\s*speclaw:owners\s*$/m;
|
|
14
|
+
/** `@user`, `@org/team`, or a simple email. */
|
|
15
|
+
const OWNER_TOKEN_RE = /^(?:@[a-zA-Z0-9](?:[a-zA-Z0-9]|-(?=[a-zA-Z0-9])){0,38}(?:\/[a-zA-Z0-9._-]+)?|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})$/;
|
|
16
|
+
/**
|
|
17
|
+
* True when `token` is a GitHub-style `@user`, `@org/team`, or a simple email.
|
|
18
|
+
*
|
|
19
|
+
* @param token - Owner token from config (trimmed).
|
|
20
|
+
*/
|
|
21
|
+
// Covers: req~owners-syntax~1
|
|
22
|
+
export function isValidOwnerToken(token) {
|
|
23
|
+
return OWNER_TOKEN_RE.test(token.trim());
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Collect owner tokens that fail {@link isValidOwnerToken}.
|
|
27
|
+
*
|
|
28
|
+
* @param owners - Capability → tokens map.
|
|
29
|
+
*/
|
|
30
|
+
export function invalidOwnerTokens(owners) {
|
|
31
|
+
const bad = [];
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
for (const tokens of Object.values(owners)) {
|
|
34
|
+
for (const t of tokens) {
|
|
35
|
+
const tok = t.trim();
|
|
36
|
+
if (!tok || seen.has(tok))
|
|
37
|
+
continue;
|
|
38
|
+
seen.add(tok);
|
|
39
|
+
if (!isValidOwnerToken(tok))
|
|
40
|
+
bad.push(tok);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return bad;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Load `team.owners` from `lawbook/config.yaml` with a line-oriented subset
|
|
47
|
+
* parser (no YAML dependency). Returns `null` when the key is absent.
|
|
48
|
+
*
|
|
49
|
+
* @param projectPath - Project root.
|
|
50
|
+
*/
|
|
51
|
+
// Covers: req~owners-absent~1, req~owners-no-derive~1
|
|
52
|
+
export function loadTeamOwners(projectPath) {
|
|
53
|
+
const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
|
|
54
|
+
if (!fs.existsSync(cfgPath))
|
|
55
|
+
return null;
|
|
56
|
+
const text = fs.readFileSync(cfgPath, "utf8");
|
|
57
|
+
return parseTeamOwnersYaml(text);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse the `team.owners` map from a config.yaml body.
|
|
61
|
+
*
|
|
62
|
+
* Supports inline lists (`cap: ["@a", "@b"]`) and simple dashed lists under a
|
|
63
|
+
* key. `"*"` may be written as `"*":`, `'*':`, or `*:`.
|
|
64
|
+
*
|
|
65
|
+
* @param text - Full config.yaml contents.
|
|
66
|
+
*/
|
|
67
|
+
export function parseTeamOwnersYaml(text) {
|
|
68
|
+
const lines = text.split(/\r?\n/);
|
|
69
|
+
let inTeam = false;
|
|
70
|
+
let inOwners = false;
|
|
71
|
+
let teamIndent = 0;
|
|
72
|
+
let ownersIndent = 0;
|
|
73
|
+
const owners = {};
|
|
74
|
+
let deriveFromTraceability = false;
|
|
75
|
+
let currentKey = null;
|
|
76
|
+
let currentIndent = 0;
|
|
77
|
+
for (const raw of lines) {
|
|
78
|
+
const line = raw.replace(/\t/g, " ");
|
|
79
|
+
if (/^\s*#/.test(line) || !line.trim())
|
|
80
|
+
continue;
|
|
81
|
+
const indent = line.match(/^ */)?.[0]?.length ?? 0;
|
|
82
|
+
const trimmed = line.trim();
|
|
83
|
+
if (!inTeam) {
|
|
84
|
+
if (/^team\s*:/.test(trimmed)) {
|
|
85
|
+
inTeam = true;
|
|
86
|
+
teamIndent = indent;
|
|
87
|
+
}
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (indent <= teamIndent && !/^team\s*:/.test(trimmed)) {
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
if (!inOwners) {
|
|
94
|
+
if (/^owners\s*:/.test(trimmed)) {
|
|
95
|
+
inOwners = true;
|
|
96
|
+
ownersIndent = indent;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (/^deriveFromTraceability\s*:\s*(true|false)\s*$/i.test(trimmed)) {
|
|
100
|
+
deriveFromTraceability = /true/i.test(trimmed);
|
|
101
|
+
}
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (indent <= ownersIndent && !/^owners\s*:/.test(trimmed)) {
|
|
105
|
+
// Still inside team — another sibling key
|
|
106
|
+
if (indent > teamIndent) {
|
|
107
|
+
inOwners = false;
|
|
108
|
+
currentKey = null;
|
|
109
|
+
if (/^deriveFromTraceability\s*:\s*(true|false)\s*$/i.test(trimmed)) {
|
|
110
|
+
deriveFromTraceability = /true/i.test(trimmed);
|
|
111
|
+
}
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
// Dashed list item under current key
|
|
117
|
+
if (currentKey && indent > currentIndent && trimmed.startsWith("- ")) {
|
|
118
|
+
const tok = stripQuotes(trimmed.slice(2).trim());
|
|
119
|
+
if (tok)
|
|
120
|
+
owners[currentKey].push(tok);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
const inline = /^([^:]+):\s*(.*)$/.exec(trimmed);
|
|
124
|
+
if (!inline)
|
|
125
|
+
continue;
|
|
126
|
+
const keyRaw = inline[1].trim();
|
|
127
|
+
const rest = inline[2].trim();
|
|
128
|
+
if (keyRaw === "owners" || keyRaw === "deriveFromTraceability")
|
|
129
|
+
continue;
|
|
130
|
+
const key = normalizeOwnerKey(keyRaw);
|
|
131
|
+
currentKey = key;
|
|
132
|
+
currentIndent = indent;
|
|
133
|
+
if (!owners[key])
|
|
134
|
+
owners[key] = [];
|
|
135
|
+
if (rest.startsWith("[")) {
|
|
136
|
+
const inside = rest.replace(/^\[/, "").replace(/\]\s*$/, "");
|
|
137
|
+
for (const part of inside.split(",")) {
|
|
138
|
+
const tok = stripQuotes(part.trim());
|
|
139
|
+
if (tok)
|
|
140
|
+
owners[key].push(tok);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
else if (rest && !rest.startsWith("#")) {
|
|
144
|
+
const tok = stripQuotes(rest);
|
|
145
|
+
if (tok)
|
|
146
|
+
owners[key].push(tok);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (Object.keys(owners).length === 0)
|
|
150
|
+
return null;
|
|
151
|
+
// Drop empty capability lists — write will reject them if any remain empty after filter
|
|
152
|
+
for (const [k, v] of Object.entries(owners)) {
|
|
153
|
+
if (v.length === 0)
|
|
154
|
+
delete owners[k];
|
|
155
|
+
}
|
|
156
|
+
if (Object.keys(owners).length === 0)
|
|
157
|
+
return null;
|
|
158
|
+
return { owners, deriveFromTraceability };
|
|
159
|
+
}
|
|
160
|
+
function normalizeOwnerKey(raw) {
|
|
161
|
+
const q = stripQuotes(raw);
|
|
162
|
+
return q === "*" ? "*" : q;
|
|
163
|
+
}
|
|
164
|
+
function stripQuotes(s) {
|
|
165
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
166
|
+
return s.slice(1, -1);
|
|
167
|
+
}
|
|
168
|
+
return s;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Render the marked CODEOWNERS block body (including start/end markers).
|
|
172
|
+
* Does not emit derived `src/**` lines even when `deriveFromTraceability` is true
|
|
173
|
+
* in this release — callers must pass `derive: false` for emission (v1).
|
|
174
|
+
*
|
|
175
|
+
* @param owners - Capability → tokens.
|
|
176
|
+
* @param opts - When `derive` is true, still no src paths in v1 (contract).
|
|
177
|
+
*/
|
|
178
|
+
// Covers: req~owners-compile~1, req~owners-no-derive~1
|
|
179
|
+
export function renderOwnersBlock(owners, opts = {}) {
|
|
180
|
+
void opts.derive; // reserved; v1 never emits derived paths
|
|
181
|
+
const lines = [
|
|
182
|
+
`${OWNERS_START} (generated — do not edit by hand; \`speclaw owners --write\`)`,
|
|
183
|
+
];
|
|
184
|
+
const keys = Object.keys(owners).sort((a, b) => {
|
|
185
|
+
if (a === "*")
|
|
186
|
+
return 1;
|
|
187
|
+
if (b === "*")
|
|
188
|
+
return -1;
|
|
189
|
+
return a.localeCompare(b);
|
|
190
|
+
});
|
|
191
|
+
for (const key of keys) {
|
|
192
|
+
const tokens = (owners[key] ?? []).map((t) => t.trim()).filter(Boolean);
|
|
193
|
+
if (tokens.length === 0)
|
|
194
|
+
continue;
|
|
195
|
+
const joined = tokens.join(" ");
|
|
196
|
+
if (key === "*") {
|
|
197
|
+
lines.push(`lawbook/config.yaml ${joined}`);
|
|
198
|
+
lines.push(`docs/standards/ ${joined}`);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
lines.push(`lawbook/specs/${key}/ ${joined}`);
|
|
202
|
+
lines.push(`lawbook/changes/*/specs/${key}/ ${joined}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
lines.push(OWNERS_END);
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Merge a full marked block into existing CODEOWNERS text, preserving content
|
|
210
|
+
* outside markers and placing the speclaw block at the end.
|
|
211
|
+
*
|
|
212
|
+
* @param existing - Current file contents (may be empty).
|
|
213
|
+
* @param block - Full marked block from {@link renderOwnersBlock}.
|
|
214
|
+
*/
|
|
215
|
+
// Covers: req~owners-merge~1
|
|
216
|
+
export function mergeOwnersBlock(existing, block) {
|
|
217
|
+
const stripped = stripOwnersBlock(existing).replace(/\s+$/, "");
|
|
218
|
+
if (!stripped)
|
|
219
|
+
return block.endsWith("\n") ? block : `${block}\n`;
|
|
220
|
+
return `${stripped}\n\n${block.endsWith("\n") ? block : `${block}\n`}`;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Remove any existing speclaw owners block from CODEOWNERS text.
|
|
224
|
+
*
|
|
225
|
+
* @param text - Full CODEOWNERS contents.
|
|
226
|
+
*/
|
|
227
|
+
export function stripOwnersBlock(text) {
|
|
228
|
+
const start = START_LINE_RE.exec(text);
|
|
229
|
+
if (!start || start.index === undefined)
|
|
230
|
+
return text;
|
|
231
|
+
const afterStart = text.slice(start.index + start[0].length);
|
|
232
|
+
const end = END_LINE_RE.exec(afterStart);
|
|
233
|
+
if (!end || end.index === undefined) {
|
|
234
|
+
// Orphan start — remove from start to EOF
|
|
235
|
+
return text.slice(0, start.index).replace(/\s+$/, "");
|
|
236
|
+
}
|
|
237
|
+
const endAbs = start.index + start[0].length + end.index + end[0].length;
|
|
238
|
+
const before = text.slice(0, start.index);
|
|
239
|
+
const after = text.slice(endAbs);
|
|
240
|
+
return (before + after).replace(/\s+$/, "");
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* True when non-empty content appears after the end marker (last-match trap).
|
|
244
|
+
*
|
|
245
|
+
* @param text - Full CODEOWNERS contents.
|
|
246
|
+
*/
|
|
247
|
+
export function hasContentAfterOwnersBlock(text) {
|
|
248
|
+
const end = END_LINE_RE.exec(text);
|
|
249
|
+
if (!end || end.index === undefined)
|
|
250
|
+
return false;
|
|
251
|
+
const after = text.slice(end.index + end[0].length);
|
|
252
|
+
return after.trim().length > 0;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Extract the marked block (including markers), or `null` if absent.
|
|
256
|
+
*
|
|
257
|
+
* @param text - Full CODEOWNERS contents.
|
|
258
|
+
*/
|
|
259
|
+
export function extractOwnersBlock(text) {
|
|
260
|
+
const start = START_LINE_RE.exec(text);
|
|
261
|
+
if (!start || start.index === undefined)
|
|
262
|
+
return null;
|
|
263
|
+
const fromStart = text.slice(start.index);
|
|
264
|
+
const end = END_LINE_RE.exec(fromStart);
|
|
265
|
+
if (!end || end.index === undefined)
|
|
266
|
+
return null;
|
|
267
|
+
return fromStart.slice(0, end.index + end[0].length).trimEnd();
|
|
268
|
+
}
|
|
269
|
+
function codeownersPath(projectPath) {
|
|
270
|
+
return path.join(projectPath, ".github", "CODEOWNERS");
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Compile `team.owners` and write the managed block at the end of
|
|
274
|
+
* `.github/CODEOWNERS`. No-op (success) when owners are undeclared.
|
|
275
|
+
*
|
|
276
|
+
* @param projectPath - Project root.
|
|
277
|
+
* @throws When owner tokens fail local syntax or a capability list is empty.
|
|
278
|
+
*/
|
|
279
|
+
// Covers: req~owners-compile~1, req~owners-absent~1, req~owners-syntax~1
|
|
280
|
+
export function writeOwners(projectPath) {
|
|
281
|
+
const out = codeownersPath(projectPath);
|
|
282
|
+
const cfg = loadTeamOwners(projectPath);
|
|
283
|
+
if (!cfg) {
|
|
284
|
+
return {
|
|
285
|
+
written: false,
|
|
286
|
+
path: out,
|
|
287
|
+
reason: "no team.owners in lawbook/config.yaml",
|
|
288
|
+
capabilities: 0,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
const bad = invalidOwnerTokens(cfg.owners);
|
|
292
|
+
if (bad.length > 0) {
|
|
293
|
+
throw new Error(`invalid owner token(s): ${bad.join(", ")}`);
|
|
294
|
+
}
|
|
295
|
+
for (const [k, v] of Object.entries(cfg.owners)) {
|
|
296
|
+
if (v.length === 0)
|
|
297
|
+
throw new Error(`team.owners.${k} has no owners`);
|
|
298
|
+
}
|
|
299
|
+
// v1: never derive from traceability even if the knob is true
|
|
300
|
+
const block = renderOwnersBlock(cfg.owners, { derive: false });
|
|
301
|
+
const existing = fs.existsSync(out) ? fs.readFileSync(out, "utf8") : "";
|
|
302
|
+
const next = mergeOwnersBlock(existing, block);
|
|
303
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
304
|
+
fs.writeFileSync(out, next);
|
|
305
|
+
return { written: true, path: out, capabilities: Object.keys(cfg.owners).length };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Compare the on-disk speclaw owners block to what `team.owners` would emit.
|
|
309
|
+
*
|
|
310
|
+
* @param projectPath - Project root.
|
|
311
|
+
*/
|
|
312
|
+
export function checkOwners(projectPath) {
|
|
313
|
+
const out = codeownersPath(projectPath);
|
|
314
|
+
const cfg = loadTeamOwners(projectPath);
|
|
315
|
+
if (!cfg) {
|
|
316
|
+
return {
|
|
317
|
+
ok: true,
|
|
318
|
+
path: out,
|
|
319
|
+
detail: "no team.owners declared — nothing to check",
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const bad = invalidOwnerTokens(cfg.owners);
|
|
323
|
+
if (bad.length > 0) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
path: out,
|
|
327
|
+
detail: `invalid owner token(s): ${bad.join(", ")}`,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
const expected = renderOwnersBlock(cfg.owners, { derive: false });
|
|
331
|
+
const existing = fs.existsSync(out) ? fs.readFileSync(out, "utf8") : "";
|
|
332
|
+
if (hasContentAfterOwnersBlock(existing)) {
|
|
333
|
+
return {
|
|
334
|
+
ok: false,
|
|
335
|
+
path: out,
|
|
336
|
+
detail: "content appears after # <<< speclaw:owners (last matching pattern wins)",
|
|
337
|
+
expected,
|
|
338
|
+
actual: extractOwnersBlock(existing) ?? "",
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
const actual = extractOwnersBlock(existing);
|
|
342
|
+
if (actual === null) {
|
|
343
|
+
return {
|
|
344
|
+
ok: false,
|
|
345
|
+
path: out,
|
|
346
|
+
detail: "speclaw owners block missing",
|
|
347
|
+
expected,
|
|
348
|
+
actual: "",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
if (normalizeBlock(actual) !== normalizeBlock(expected)) {
|
|
352
|
+
return {
|
|
353
|
+
ok: false,
|
|
354
|
+
path: out,
|
|
355
|
+
detail: "speclaw owners block does not match team.owners",
|
|
356
|
+
expected,
|
|
357
|
+
actual,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
return { ok: true, path: out, detail: "owners block matches team.owners" };
|
|
361
|
+
}
|
|
362
|
+
function normalizeBlock(s) {
|
|
363
|
+
return s.replace(/\r\n/g, "\n").trimEnd();
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Doctor checks for CODEOWNERS owners posture. Skips when `team.owners` is absent.
|
|
367
|
+
*
|
|
368
|
+
* @param projectPath - Project root.
|
|
369
|
+
*/
|
|
370
|
+
// Covers: req~doctor-owners~1
|
|
371
|
+
export function doctorOwnersChecks(projectPath) {
|
|
372
|
+
const cfg = loadTeamOwners(projectPath);
|
|
373
|
+
if (!cfg) {
|
|
374
|
+
return [
|
|
375
|
+
{
|
|
376
|
+
id: "cfg.owners",
|
|
377
|
+
title: "spec owners",
|
|
378
|
+
status: "skip",
|
|
379
|
+
detail: "no team.owners in lawbook/config.yaml",
|
|
380
|
+
remedy: "add team.owners then speclaw owners --write",
|
|
381
|
+
},
|
|
382
|
+
];
|
|
383
|
+
}
|
|
384
|
+
const checks = [];
|
|
385
|
+
const bad = invalidOwnerTokens(cfg.owners);
|
|
386
|
+
if (bad.length > 0) {
|
|
387
|
+
checks.push({
|
|
388
|
+
id: "cfg.owners.syntax",
|
|
389
|
+
title: "owner token syntax",
|
|
390
|
+
status: "error",
|
|
391
|
+
detail: `invalid token(s): ${bad.join(", ")}`,
|
|
392
|
+
remedy: "fix team.owners (@user, @org/team, or email)",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
checks.push({
|
|
397
|
+
id: "cfg.owners.syntax",
|
|
398
|
+
title: "owner token syntax",
|
|
399
|
+
status: "ok",
|
|
400
|
+
detail: `${Object.keys(cfg.owners).length} capability key(s)`,
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
const out = codeownersPath(projectPath);
|
|
404
|
+
if (!fs.existsSync(out)) {
|
|
405
|
+
checks.push({
|
|
406
|
+
id: "cfg.owners.block",
|
|
407
|
+
title: "CODEOWNERS owners block",
|
|
408
|
+
status: "warn",
|
|
409
|
+
detail: ".github/CODEOWNERS missing",
|
|
410
|
+
remedy: "speclaw owners --write",
|
|
411
|
+
});
|
|
412
|
+
return checks;
|
|
413
|
+
}
|
|
414
|
+
const text = fs.readFileSync(out, "utf8");
|
|
415
|
+
const block = extractOwnersBlock(text);
|
|
416
|
+
if (!block) {
|
|
417
|
+
checks.push({
|
|
418
|
+
id: "cfg.owners.block",
|
|
419
|
+
title: "CODEOWNERS owners block",
|
|
420
|
+
status: "warn",
|
|
421
|
+
detail: "speclaw owners block missing",
|
|
422
|
+
remedy: "speclaw owners --write",
|
|
423
|
+
});
|
|
424
|
+
return checks;
|
|
425
|
+
}
|
|
426
|
+
if (hasContentAfterOwnersBlock(text)) {
|
|
427
|
+
checks.push({
|
|
428
|
+
id: "cfg.owners.block",
|
|
429
|
+
title: "CODEOWNERS owners block",
|
|
430
|
+
status: "error",
|
|
431
|
+
detail: "content after # <<< speclaw:owners — last matching pattern wins in CODEOWNERS",
|
|
432
|
+
remedy: "speclaw owners --write (reorders the managed block to the end)",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
checks.push({
|
|
437
|
+
id: "cfg.owners.block",
|
|
438
|
+
title: "CODEOWNERS owners block",
|
|
439
|
+
status: "ok",
|
|
440
|
+
detail: "managed block is last in .github/CODEOWNERS",
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
checks.push({
|
|
444
|
+
id: "cfg.owners.protection",
|
|
445
|
+
title: "code owners review",
|
|
446
|
+
status: "warn",
|
|
447
|
+
detail: "CODEOWNERS is decorative unless branch protection requires review from Code Owners",
|
|
448
|
+
remedy: "enable Require review from Code Owners on the default branch",
|
|
449
|
+
});
|
|
450
|
+
return checks;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* Refresh the owners block when declared; silent no-op when absent.
|
|
454
|
+
* Used by init/update — never invents owners.
|
|
455
|
+
*
|
|
456
|
+
* @param projectPath - Project root.
|
|
457
|
+
*/
|
|
458
|
+
// Covers: req~owners-refresh-update~1
|
|
459
|
+
export function refreshOwnersIfConfigured(projectPath) {
|
|
460
|
+
const cfg = loadTeamOwners(projectPath);
|
|
461
|
+
if (!cfg)
|
|
462
|
+
return null;
|
|
463
|
+
return writeOwners(projectPath);
|
|
464
|
+
}
|