@esneiderbravo/speclaw 0.4.0 → 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 +32 -10
- package/dist/cli/commands/update.js +28 -0
- package/dist/cli/commands/verify.js +8 -0
- package/dist/cli/index.js +13 -4
- package/dist/modules/compass/budget.js +128 -0
- package/dist/modules/compass/db.js +290 -30
- package/dist/modules/compass/embed-input.js +28 -0
- package/dist/modules/compass/embedder.js +3 -1
- package/dist/modules/compass/explore-rich.js +10 -5
- package/dist/modules/compass/extract.js +86 -0
- package/dist/modules/compass/hybrid.js +318 -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 +8 -4
- package/dist/modules/foundation/check.js +4 -2
- package/dist/modules/foundation/compile-laws.js +212 -0
- 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 +220 -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/scaffold.js +25 -0
- package/dist/modules/foundation/scan.js +227 -0
- package/dist/modules/foundation/verify.js +9 -1
- 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/spec-items.js +4 -1
- package/dist/modules/team/owners.js +464 -0
- package/package.json +4 -3
|
@@ -5,14 +5,18 @@ import { isMinimalMode, packageRoot } from "../../shared/exposure.js";
|
|
|
5
5
|
import { isGitRepo } from "../../shared/git.js";
|
|
6
6
|
import { readManifest } from "../../shared/manifest.js";
|
|
7
7
|
import { pkgName, pkgVersion } from "../../shared/version.js";
|
|
8
|
-
import { indexExists, openDb } from "../compass/db.js";
|
|
8
|
+
import { indexExists, openDb, probeFts5Support } from "../compass/db.js";
|
|
9
|
+
import { getEmbedder } from "../compass/embedder.js";
|
|
9
10
|
import { specList } from "../lawbook/engine.js";
|
|
10
11
|
import { doctorDriftCheck } from "../lawbook/drift.js";
|
|
11
12
|
import { loadCeremonyConfig } from "../lawbook/levels.js";
|
|
13
|
+
import { doctorOwnersChecks } from "../team/owners.js";
|
|
12
14
|
import { globError, hasBackend, hasBatchBackend, readLawManifest } from "./laws.js";
|
|
15
|
+
import { estimateAlwaysOnTokens } from "./compile-laws.js";
|
|
13
16
|
import { redactValue } from "../../shared/redact.js";
|
|
14
17
|
import { readDeprecatedCallCounts, scanRetiredToolReferences } from "../../shared/deprecation.js";
|
|
15
18
|
import { CANONICAL_TOOLS, ALIAS_TARGETS, isCanonicalTool } from "../../shared/tool-catalog.js";
|
|
19
|
+
import { discoverIntegrityPaths, lockfilePath, readLockfile, rootDigest } from "./lock.js";
|
|
16
20
|
const STATUS_RANK = {
|
|
17
21
|
skip: 0,
|
|
18
22
|
ok: 1,
|
|
@@ -48,18 +52,29 @@ function detectInstallKind() {
|
|
|
48
52
|
function enginesRequirement() {
|
|
49
53
|
try {
|
|
50
54
|
const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot(), "package.json"), "utf8"));
|
|
51
|
-
return pkg.engines?.node ?? ">=22";
|
|
55
|
+
return pkg.engines?.node ?? ">=22.16";
|
|
52
56
|
}
|
|
53
57
|
catch {
|
|
54
|
-
return ">=22";
|
|
58
|
+
return ">=22.16";
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
function nodeSatisfies(required, version) {
|
|
58
|
-
const m = /^>=\s*(\d+)
|
|
62
|
+
const m = /^>=\s*(\d+)(?:\.(\d+))?/.exec(required.trim());
|
|
59
63
|
if (!m)
|
|
60
64
|
return true;
|
|
61
|
-
const
|
|
62
|
-
|
|
65
|
+
const parts = version
|
|
66
|
+
.replace(/^v/, "")
|
|
67
|
+
.split(".")
|
|
68
|
+
.map((x) => parseInt(x, 10));
|
|
69
|
+
const major = parts[0] ?? 0;
|
|
70
|
+
const minor = parts[1] ?? 0;
|
|
71
|
+
const needMajor = parseInt(m[1], 10);
|
|
72
|
+
const needMinor = m[2] !== undefined ? parseInt(m[2], 10) : 0;
|
|
73
|
+
if (major > needMajor)
|
|
74
|
+
return true;
|
|
75
|
+
if (major < needMajor)
|
|
76
|
+
return false;
|
|
77
|
+
return minor >= needMinor;
|
|
63
78
|
}
|
|
64
79
|
function libcLabel() {
|
|
65
80
|
if (process.platform !== "linux")
|
|
@@ -179,6 +194,25 @@ function buildEnvironment(projectPath) {
|
|
|
179
194
|
detail: git ? "repository" : "not a git repository",
|
|
180
195
|
remedy: git ? undefined : "git init",
|
|
181
196
|
});
|
|
197
|
+
const ftsOk = probeFts5Support();
|
|
198
|
+
addCheck(checks, {
|
|
199
|
+
id: "env.fts5",
|
|
200
|
+
title: "fts5",
|
|
201
|
+
status: ftsOk ? "ok" : "warn",
|
|
202
|
+
value: ftsOk,
|
|
203
|
+
detail: ftsOk
|
|
204
|
+
? "node:sqlite FTS5 available"
|
|
205
|
+
: "FTS5 unavailable — hybrid search degrades to vector+name (need Node >=22.16)",
|
|
206
|
+
remedy: ftsOk ? undefined : "Upgrade to Node.js >=22.16",
|
|
207
|
+
});
|
|
208
|
+
const embedderId = getEmbedder().id;
|
|
209
|
+
addCheck(checks, {
|
|
210
|
+
id: "env.embedder",
|
|
211
|
+
title: "embedder",
|
|
212
|
+
status: "ok",
|
|
213
|
+
value: embedderId,
|
|
214
|
+
detail: `active embedder: ${embedderId}`,
|
|
215
|
+
});
|
|
182
216
|
addCheck(checks, {
|
|
183
217
|
id: "env.ast-engine",
|
|
184
218
|
title: "ast engine",
|
|
@@ -369,12 +403,23 @@ function lawsCheck(projectPath) {
|
|
|
369
403
|
}
|
|
370
404
|
const withPath = manifest.laws.filter(hasBackend).length;
|
|
371
405
|
const withBatch = manifest.laws.filter(hasBatchBackend).length;
|
|
406
|
+
const budget = estimateAlwaysOnTokens(manifest.laws);
|
|
407
|
+
if (budget.total > 2000) {
|
|
408
|
+
const top = budget.top.map((t) => `${t.id}(~${t.tokens})`).join(", ");
|
|
409
|
+
return {
|
|
410
|
+
id: "cfg.laws",
|
|
411
|
+
title: "laws",
|
|
412
|
+
status: "warn",
|
|
413
|
+
detail: `always-on laws ~${budget.total} tokens (budget 2000); top: ${top || "n/a"}`,
|
|
414
|
+
remedy: "Add scope globs to the largest always-on laws, then speclaw laws compile",
|
|
415
|
+
};
|
|
416
|
+
}
|
|
372
417
|
return {
|
|
373
418
|
id: "cfg.laws",
|
|
374
419
|
title: "laws",
|
|
375
420
|
status: "ok",
|
|
376
421
|
value: manifest.laws.length,
|
|
377
|
-
detail: `${manifest.laws.length} declared · ${withPath} path · ${withBatch} deps/graph ·
|
|
422
|
+
detail: `${manifest.laws.length} declared · ${withPath} path · ${withBatch} deps/graph · always-on ~${budget.total} tokens`,
|
|
378
423
|
};
|
|
379
424
|
}
|
|
380
425
|
async function budgetCheck(projectPath) {
|
|
@@ -512,6 +557,154 @@ function specsOrphansCheck(projectPath) {
|
|
|
512
557
|
remedy: `speclaw lawbook archive ${active[0]}`,
|
|
513
558
|
};
|
|
514
559
|
}
|
|
560
|
+
function integrityChecks(projectPath) {
|
|
561
|
+
// Covers: req~doctor-integrity~1
|
|
562
|
+
const out = [];
|
|
563
|
+
const abs = lockfilePath(projectPath);
|
|
564
|
+
if (!fs.existsSync(abs)) {
|
|
565
|
+
out.push({
|
|
566
|
+
id: "cfg.integrity.lock",
|
|
567
|
+
title: "rule lockfile",
|
|
568
|
+
status: "warn",
|
|
569
|
+
detail: "no speclaw.lock — rule digests are not pinned",
|
|
570
|
+
remedy: "speclaw laws lock",
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
else {
|
|
574
|
+
try {
|
|
575
|
+
const lock = readLockfile(projectPath);
|
|
576
|
+
const matches = rootDigest(lock.files) === lock.root;
|
|
577
|
+
out.push({
|
|
578
|
+
id: "cfg.integrity.lock",
|
|
579
|
+
title: "rule lockfile",
|
|
580
|
+
status: matches ? "ok" : "warn",
|
|
581
|
+
value: matches,
|
|
582
|
+
detail: matches
|
|
583
|
+
? `speclaw.lock root matches (${Object.keys(lock.files).length} files)`
|
|
584
|
+
: "speclaw.lock root does not match recomputed digests",
|
|
585
|
+
remedy: matches ? undefined : "speclaw laws lock",
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
catch (err) {
|
|
589
|
+
out.push({
|
|
590
|
+
id: "cfg.integrity.lock",
|
|
591
|
+
title: "rule lockfile",
|
|
592
|
+
status: "error",
|
|
593
|
+
detail: err.message,
|
|
594
|
+
remedy: "speclaw laws lock",
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const imports = findExternalImports(projectPath, 4);
|
|
599
|
+
out.push({
|
|
600
|
+
id: "cfg.integrity.imports",
|
|
601
|
+
title: "external rule imports",
|
|
602
|
+
status: imports.length ? "warn" : "ok",
|
|
603
|
+
value: imports.length ? imports.slice(0, 8).join("; ") : null,
|
|
604
|
+
detail: imports.length
|
|
605
|
+
? `${imports.length} external @import hop(s) (≤4): ${imports.slice(0, 5).join("; ")}`
|
|
606
|
+
: "no external @import / @~/ paths detected in rule files",
|
|
607
|
+
remedy: imports.length
|
|
608
|
+
? "review imports that resolve outside the working directory"
|
|
609
|
+
: undefined,
|
|
610
|
+
});
|
|
611
|
+
const { files } = discoverIntegrityPaths(projectPath);
|
|
612
|
+
const outside = files.filter((f) => {
|
|
613
|
+
const n = f.split("\\").join("/");
|
|
614
|
+
return (n === ".clinerules" ||
|
|
615
|
+
n === ".windsurfrules" ||
|
|
616
|
+
n === "BUGBOT.md" ||
|
|
617
|
+
n === ".cursorrules" ||
|
|
618
|
+
n.startsWith("ai-specs/skills/") ||
|
|
619
|
+
n.startsWith(".claude/skills/"));
|
|
620
|
+
});
|
|
621
|
+
out.push({
|
|
622
|
+
id: "cfg.integrity.outside-pipeline",
|
|
623
|
+
title: "outside-pipeline rules",
|
|
624
|
+
status: "ok",
|
|
625
|
+
value: outside.length,
|
|
626
|
+
detail: outside.length > 0
|
|
627
|
+
? `${outside.length} scan-only / outside-pipeline path(s): ${outside.slice(0, 6).join(", ")}`
|
|
628
|
+
: "no outside-pipeline rule files discovered",
|
|
629
|
+
});
|
|
630
|
+
return out;
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Follow `@~/…`, absolute `@/…`, and `@import "…"` targets outside the project,
|
|
634
|
+
* transitively up to `maxHops`.
|
|
635
|
+
*/
|
|
636
|
+
/* node:coverage disable */
|
|
637
|
+
function findExternalImports(projectPath, maxHops) {
|
|
638
|
+
const roots = [
|
|
639
|
+
"CLAUDE.md",
|
|
640
|
+
"AGENTS.md",
|
|
641
|
+
"LAWS.md",
|
|
642
|
+
".cursorrules",
|
|
643
|
+
".clinerules",
|
|
644
|
+
".windsurfrules",
|
|
645
|
+
];
|
|
646
|
+
const seen = new Set();
|
|
647
|
+
const out = [];
|
|
648
|
+
const queue = [];
|
|
649
|
+
for (const r of roots) {
|
|
650
|
+
const abs = path.join(projectPath, r);
|
|
651
|
+
if (fs.existsSync(abs))
|
|
652
|
+
queue.push({ file: abs, hop: 0 });
|
|
653
|
+
}
|
|
654
|
+
while (queue.length) {
|
|
655
|
+
const { file, hop } = queue.shift();
|
|
656
|
+
if (hop > maxHops || seen.has(file))
|
|
657
|
+
continue;
|
|
658
|
+
seen.add(file);
|
|
659
|
+
let text;
|
|
660
|
+
try {
|
|
661
|
+
text = fs.readFileSync(file, "utf8");
|
|
662
|
+
}
|
|
663
|
+
catch {
|
|
664
|
+
continue;
|
|
665
|
+
}
|
|
666
|
+
for (const line of text.split(/\r?\n/)) {
|
|
667
|
+
const m = /@([~/][^\s)\]>"']+)/.exec(line) ??
|
|
668
|
+
/@import\s+["']([^"']+)["']/.exec(line) ??
|
|
669
|
+
/@([A-Za-z]:[^\s)\]>"']+)/.exec(line);
|
|
670
|
+
if (!m)
|
|
671
|
+
continue;
|
|
672
|
+
const target = m[1];
|
|
673
|
+
const resolved = resolveImportTarget(file, target);
|
|
674
|
+
if (!resolved)
|
|
675
|
+
continue;
|
|
676
|
+
const projectRoot = path.resolve(projectPath);
|
|
677
|
+
const outside = target.startsWith("~/") ||
|
|
678
|
+
path.isAbsolute(target) ||
|
|
679
|
+
!resolved.startsWith(projectRoot + path.sep);
|
|
680
|
+
if (outside) {
|
|
681
|
+
const label = `${path.relative(projectPath, file) || path.basename(file)} → ${target}`;
|
|
682
|
+
out.push(label);
|
|
683
|
+
if (hop < maxHops && fs.existsSync(resolved) && fs.statSync(resolved).isFile()) {
|
|
684
|
+
queue.push({ file: resolved, hop: hop + 1 });
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return out;
|
|
690
|
+
}
|
|
691
|
+
function resolveImportTarget(fromFile, target) {
|
|
692
|
+
if (target.startsWith("~/")) {
|
|
693
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
694
|
+
if (!home)
|
|
695
|
+
return null;
|
|
696
|
+
return path.resolve(home, target.slice(2));
|
|
697
|
+
}
|
|
698
|
+
if (target.startsWith("/") || /^[A-Za-z]:/.test(target))
|
|
699
|
+
return path.resolve(target);
|
|
700
|
+
if (target.startsWith("./") || target.startsWith("../")) {
|
|
701
|
+
return path.resolve(path.dirname(fromFile), target);
|
|
702
|
+
}
|
|
703
|
+
if (!target.includes("://"))
|
|
704
|
+
return path.resolve(path.dirname(fromFile), target);
|
|
705
|
+
return null;
|
|
706
|
+
}
|
|
707
|
+
/* node:coverage enable */
|
|
515
708
|
/** Ceremony config validity + archived level histogram. */
|
|
516
709
|
function ceremonyChecks(projectPath) {
|
|
517
710
|
const out = [];
|
|
@@ -617,13 +810,16 @@ function configurationChecks(projectPath, initialised) {
|
|
|
617
810
|
"cfg.index.freshness",
|
|
618
811
|
"cfg.specs.orphans",
|
|
619
812
|
];
|
|
620
|
-
return
|
|
621
|
-
id
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
813
|
+
return [
|
|
814
|
+
...ids.map((id) => ({
|
|
815
|
+
id,
|
|
816
|
+
title: id.replace(/^cfg\./, ""),
|
|
817
|
+
status: "skip",
|
|
818
|
+
detail: "project not initialised",
|
|
819
|
+
remedy: "speclaw init",
|
|
820
|
+
})),
|
|
821
|
+
...integrityChecks(projectPath),
|
|
822
|
+
];
|
|
627
823
|
}
|
|
628
824
|
const checks = [];
|
|
629
825
|
const manifest = readManifest(projectPath);
|
|
@@ -650,6 +846,7 @@ function configurationChecks(projectPath, initialised) {
|
|
|
650
846
|
remedy: "speclaw update",
|
|
651
847
|
});
|
|
652
848
|
checks.push(lawsCheck(projectPath));
|
|
849
|
+
checks.push(...integrityChecks(projectPath));
|
|
653
850
|
// budget + mcp + freshness + specs filled async by caller
|
|
654
851
|
return checks;
|
|
655
852
|
}
|
|
@@ -709,6 +906,15 @@ export async function doctor(projectPath, opts = {}) {
|
|
|
709
906
|
remedy: d.remedy,
|
|
710
907
|
});
|
|
711
908
|
}
|
|
909
|
+
for (const o of doctorOwnersChecks(projectPath)) {
|
|
910
|
+
addCheck(configuration, {
|
|
911
|
+
id: o.id,
|
|
912
|
+
title: o.title,
|
|
913
|
+
status: o.status,
|
|
914
|
+
detail: o.detail,
|
|
915
|
+
remedy: o.remedy,
|
|
916
|
+
});
|
|
917
|
+
}
|
|
712
918
|
const configured = detectConfiguredAgents(projectPath);
|
|
713
919
|
const mcpAgents = AGENTS.filter((a) => a.mcpFile && configured.includes(a.id));
|
|
714
920
|
if (mcpAgents.length === 0) {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readLawManifest, writeLawManifest } from "./laws.js";
|
|
4
|
+
/**
|
|
5
|
+
* Import rulesync-style markdown rules under `.rulesync/` or `rulesync/` into
|
|
6
|
+
* draft semantic laws and append them to the manifest.
|
|
7
|
+
*/
|
|
8
|
+
export function importRulesFrom(projectPath, from) {
|
|
9
|
+
if (from !== "rulesync") {
|
|
10
|
+
throw new Error(`unsupported import source "${from}" — try rulesync`);
|
|
11
|
+
}
|
|
12
|
+
const candidates = [".rulesync", "rulesync", ".rulesync/rules", "rulesync/rules"];
|
|
13
|
+
let root = null;
|
|
14
|
+
for (const c of candidates) {
|
|
15
|
+
const abs = path.join(projectPath, c);
|
|
16
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) {
|
|
17
|
+
root = abs;
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (!root) {
|
|
22
|
+
throw new Error("no rulesync rules directory found (.rulesync/ or rulesync/)");
|
|
23
|
+
}
|
|
24
|
+
const report = { imported: [], skipped: [] };
|
|
25
|
+
const existing = readLawManifest(projectPath) ?? { version: 1, laws: [] };
|
|
26
|
+
const have = new Set(existing.laws.map((l) => l.id));
|
|
27
|
+
const walk = (dir) => {
|
|
28
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
29
|
+
const abs = path.join(dir, ent.name);
|
|
30
|
+
if (ent.isDirectory()) {
|
|
31
|
+
walk(abs);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (!/\.(md|mdc)$/i.test(ent.name))
|
|
35
|
+
continue;
|
|
36
|
+
const rel = path.relative(projectPath, abs).split(path.sep).join("/");
|
|
37
|
+
const prose = fs.readFileSync(abs, "utf8").trim() || "(empty rule)";
|
|
38
|
+
const slug = ent.name
|
|
39
|
+
.replace(/\.(md|mdc)$/i, "")
|
|
40
|
+
.toLowerCase()
|
|
41
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
42
|
+
.replace(/^-|-$/g, "");
|
|
43
|
+
const id = `law~import-rulesync-${slug}~1`;
|
|
44
|
+
if (have.has(id)) {
|
|
45
|
+
report.skipped.push(id);
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
const law = {
|
|
49
|
+
id,
|
|
50
|
+
title: `Imported: ${ent.name}`,
|
|
51
|
+
severity: "warn",
|
|
52
|
+
scope: [],
|
|
53
|
+
prose,
|
|
54
|
+
verification: { kind: "semantic" },
|
|
55
|
+
enforcement: "feedback",
|
|
56
|
+
source: { file: rel, line: 1 },
|
|
57
|
+
status: "draft",
|
|
58
|
+
};
|
|
59
|
+
existing.laws.push(law);
|
|
60
|
+
have.add(id);
|
|
61
|
+
report.imported.push(id);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
walk(root);
|
|
65
|
+
writeLawManifest(projectPath, existing);
|
|
66
|
+
return report;
|
|
67
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-file integrity verification (digests + injection scan).
|
|
3
|
+
* Distinct from {@link verifyLaws} (deps/graph engines).
|
|
4
|
+
*/
|
|
5
|
+
// Covers: req~integrity-verify~1, req~laws-accept-human~1
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { digestText, discoverIntegrityPaths, integrityPolicy, isRegenerableIdeMirror, lockfilePath, prepareIntegrityText, readLockfile, refreshLockfile, rootDigest, writeLockfile, } from "./lock.js";
|
|
9
|
+
import { loadScanSuppressions, scanPaths } from "./scan.js";
|
|
10
|
+
/**
|
|
11
|
+
* Verify digests and/or scan rule files. Missing lockfile is soft (ok=true)
|
|
12
|
+
* with guidance to run `speclaw laws lock`.
|
|
13
|
+
*/
|
|
14
|
+
export function verifyIntegrity(opts) {
|
|
15
|
+
const projectPath = opts.projectPath;
|
|
16
|
+
const checks = opts.checks ?? "both";
|
|
17
|
+
const doIntegrity = checks === "integrity" || checks === "both";
|
|
18
|
+
const doScan = checks === "scan" || checks === "both";
|
|
19
|
+
let lock;
|
|
20
|
+
try {
|
|
21
|
+
lock = readLockfile(projectPath);
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
return {
|
|
25
|
+
ok: false,
|
|
26
|
+
lockPresent: fs.existsSync(lockfilePath(projectPath)),
|
|
27
|
+
rootMatches: false,
|
|
28
|
+
guidance: err.message,
|
|
29
|
+
files: [],
|
|
30
|
+
symlinks: [],
|
|
31
|
+
findings: [],
|
|
32
|
+
verifyFindings: [
|
|
33
|
+
{
|
|
34
|
+
lawId: "integrity~lockfile~1",
|
|
35
|
+
severity: "error",
|
|
36
|
+
engine: "integrity",
|
|
37
|
+
file: "speclaw.lock",
|
|
38
|
+
message: err.message,
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (!lock && doIntegrity) {
|
|
44
|
+
const scanned = doScan ? scanAll(projectPath) : [];
|
|
45
|
+
const scanErrors = scanned.filter((f) => f.severity === "error");
|
|
46
|
+
return {
|
|
47
|
+
ok: scanErrors.length === 0,
|
|
48
|
+
lockPresent: false,
|
|
49
|
+
rootMatches: false,
|
|
50
|
+
guidance: "No speclaw.lock — run `speclaw laws lock` to create the baseline.",
|
|
51
|
+
files: [],
|
|
52
|
+
symlinks: [],
|
|
53
|
+
findings: scanned,
|
|
54
|
+
verifyFindings: scanned
|
|
55
|
+
.filter((f) => f.severity === "error" || f.severity === "warn")
|
|
56
|
+
.map(scanToFinding),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
const files = [];
|
|
60
|
+
const symlinks = [];
|
|
61
|
+
const verifyFindings = [];
|
|
62
|
+
let ok = true;
|
|
63
|
+
if (lock && doIntegrity) {
|
|
64
|
+
const accepted = new Map(lock.accepted.map((a) => [a.path, a.digest]));
|
|
65
|
+
for (const [rel, entry] of Object.entries(lock.files)) {
|
|
66
|
+
const abs = path.join(projectPath, rel);
|
|
67
|
+
if (!fs.existsSync(abs)) {
|
|
68
|
+
// Stale lock entries for regenerable IDE mirrors (ai-specs gitignored) —
|
|
69
|
+
// warn only; never fail CI on a clean clone.
|
|
70
|
+
if (isRegenerableIdeMirror(rel)) {
|
|
71
|
+
files.push({
|
|
72
|
+
path: rel,
|
|
73
|
+
ownership: entry.ownership,
|
|
74
|
+
status: "missing",
|
|
75
|
+
expected: entry.digest,
|
|
76
|
+
});
|
|
77
|
+
verifyFindings.push({
|
|
78
|
+
lawId: "integrity~missing~1",
|
|
79
|
+
severity: "warn",
|
|
80
|
+
engine: "integrity",
|
|
81
|
+
file: rel,
|
|
82
|
+
message: "Regenerable IDE rule mirror missing — run `speclaw update` or `speclaw laws lock` to drop stale entries",
|
|
83
|
+
});
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const severity = entry.ownership === "strict" ? "error" : "warn";
|
|
87
|
+
if (entry.ownership === "strict")
|
|
88
|
+
ok = false;
|
|
89
|
+
files.push({
|
|
90
|
+
path: rel,
|
|
91
|
+
ownership: entry.ownership,
|
|
92
|
+
status: "missing",
|
|
93
|
+
expected: entry.digest,
|
|
94
|
+
});
|
|
95
|
+
verifyFindings.push({
|
|
96
|
+
lawId: "integrity~missing~1",
|
|
97
|
+
severity,
|
|
98
|
+
engine: "integrity",
|
|
99
|
+
file: rel,
|
|
100
|
+
message: entry.ownership === "strict"
|
|
101
|
+
? `Managed rule file missing (expected ${entry.digest})`
|
|
102
|
+
: `Advisory rule/source file missing (expected ${entry.digest})`,
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const raw = prepareIntegrityText(rel, fs.readFileSync(abs, "utf8"));
|
|
107
|
+
const actual = digestText(raw);
|
|
108
|
+
if (actual === entry.digest) {
|
|
109
|
+
files.push({
|
|
110
|
+
path: rel,
|
|
111
|
+
ownership: entry.ownership,
|
|
112
|
+
status: "ok",
|
|
113
|
+
expected: entry.digest,
|
|
114
|
+
actual,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (accepted.get(rel) === actual) {
|
|
119
|
+
files.push({
|
|
120
|
+
path: rel,
|
|
121
|
+
ownership: entry.ownership,
|
|
122
|
+
status: "accepted",
|
|
123
|
+
expected: entry.digest,
|
|
124
|
+
actual,
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const unified = `--- speclaw.lock (${entry.digest})\n+++ ${rel} (${actual})\n`;
|
|
129
|
+
const isStrict = entry.ownership === "strict";
|
|
130
|
+
if (isStrict)
|
|
131
|
+
ok = false;
|
|
132
|
+
files.push({
|
|
133
|
+
path: rel,
|
|
134
|
+
ownership: entry.ownership,
|
|
135
|
+
status: isStrict ? "modified" : "advisory-modified",
|
|
136
|
+
expected: entry.digest,
|
|
137
|
+
actual,
|
|
138
|
+
diff: unified,
|
|
139
|
+
});
|
|
140
|
+
verifyFindings.push({
|
|
141
|
+
lawId: isStrict ? "integrity~digest-mismatch~1" : "integrity~advisory-mismatch~1",
|
|
142
|
+
severity: isStrict ? "error" : "warn",
|
|
143
|
+
engine: "integrity",
|
|
144
|
+
file: rel,
|
|
145
|
+
message: isStrict
|
|
146
|
+
? `Digest mismatch for strict rule file`
|
|
147
|
+
: `Advisory rule/source file changed`,
|
|
148
|
+
detail: `expected ${entry.digest} found ${actual}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
const { files: discovered } = discoverIntegrityPaths(projectPath);
|
|
152
|
+
for (const rel of discovered) {
|
|
153
|
+
if (lock.files[rel])
|
|
154
|
+
continue;
|
|
155
|
+
const ownership = integrityPolicy(rel);
|
|
156
|
+
if (ownership === "scan-only")
|
|
157
|
+
continue;
|
|
158
|
+
files.push({ path: rel, ownership, status: "untracked-by-lock" });
|
|
159
|
+
verifyFindings.push({
|
|
160
|
+
lawId: "integrity~untracked~1",
|
|
161
|
+
severity: "warn",
|
|
162
|
+
engine: "integrity",
|
|
163
|
+
file: rel,
|
|
164
|
+
message: `Rule file not listed in speclaw.lock`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
for (const [rel, entry] of Object.entries(lock.symlinks)) {
|
|
168
|
+
const abs = path.join(projectPath, rel);
|
|
169
|
+
let actual = null;
|
|
170
|
+
try {
|
|
171
|
+
if (fs.lstatSync(abs).isSymbolicLink())
|
|
172
|
+
actual = fs.readlinkSync(abs);
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
actual = null;
|
|
176
|
+
}
|
|
177
|
+
if (actual === null) {
|
|
178
|
+
symlinks.push({
|
|
179
|
+
path: rel,
|
|
180
|
+
expectedTarget: entry.target,
|
|
181
|
+
actualTarget: null,
|
|
182
|
+
status: "missing",
|
|
183
|
+
});
|
|
184
|
+
ok = false;
|
|
185
|
+
verifyFindings.push({
|
|
186
|
+
lawId: "integrity~symlink~1",
|
|
187
|
+
severity: "error",
|
|
188
|
+
engine: "integrity",
|
|
189
|
+
file: rel,
|
|
190
|
+
message: `Managed symlink missing (expected → ${entry.target})`,
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
else if (normalizeLink(actual) !== normalizeLink(entry.target)) {
|
|
194
|
+
symlinks.push({
|
|
195
|
+
path: rel,
|
|
196
|
+
expectedTarget: entry.target,
|
|
197
|
+
actualTarget: actual,
|
|
198
|
+
status: "mismatch",
|
|
199
|
+
});
|
|
200
|
+
ok = false;
|
|
201
|
+
verifyFindings.push({
|
|
202
|
+
lawId: "integrity~symlink~1",
|
|
203
|
+
severity: "error",
|
|
204
|
+
engine: "integrity",
|
|
205
|
+
file: rel,
|
|
206
|
+
message: `Managed symlink retargeted`,
|
|
207
|
+
detail: `expected ${entry.target} found ${actual}`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
symlinks.push({
|
|
212
|
+
path: rel,
|
|
213
|
+
expectedTarget: entry.target,
|
|
214
|
+
actualTarget: actual,
|
|
215
|
+
status: "ok",
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const findings = doScan ? scanAll(projectPath) : [];
|
|
221
|
+
for (const f of findings.filter((x) => x.severity === "error")) {
|
|
222
|
+
ok = false;
|
|
223
|
+
verifyFindings.push(scanToFinding(f));
|
|
224
|
+
}
|
|
225
|
+
for (const f of findings.filter((x) => x.severity === "warn")) {
|
|
226
|
+
verifyFindings.push(scanToFinding(f));
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
ok,
|
|
230
|
+
lockPresent: lock !== null,
|
|
231
|
+
rootMatches: lock ? rootDigest(lock.files) === lock.root : false,
|
|
232
|
+
guidance: !lock && doIntegrity
|
|
233
|
+
? "No speclaw.lock — run `speclaw laws lock` to create the baseline."
|
|
234
|
+
: undefined,
|
|
235
|
+
files,
|
|
236
|
+
symlinks,
|
|
237
|
+
findings,
|
|
238
|
+
verifyFindings,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Fold integrity findings into a batch {@link VerifyReport} (SARIF / exit codes).
|
|
243
|
+
* Error-severity findings increment `summary.failed`.
|
|
244
|
+
*/
|
|
245
|
+
export function foldIntegrityIntoReport(report, integrity) {
|
|
246
|
+
for (const f of integrity.verifyFindings) {
|
|
247
|
+
report.findings.push(f);
|
|
248
|
+
if (f.severity === "error")
|
|
249
|
+
report.summary.failed += 1;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function normalizeLink(t) {
|
|
253
|
+
return t.split("\\").join("/").replace(/\/+$/, "");
|
|
254
|
+
}
|
|
255
|
+
function scanAll(projectPath) {
|
|
256
|
+
const { files } = discoverIntegrityPaths(projectPath);
|
|
257
|
+
return scanPaths(projectPath, files, {
|
|
258
|
+
suppressions: loadScanSuppressions(projectPath),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
function scanToFinding(f) {
|
|
262
|
+
return {
|
|
263
|
+
lawId: f.detector.replace(/\//g, "~"),
|
|
264
|
+
severity: f.severity,
|
|
265
|
+
engine: "integrity",
|
|
266
|
+
file: f.path,
|
|
267
|
+
line: f.line,
|
|
268
|
+
message: f.message,
|
|
269
|
+
detail: f.excerpt,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Accept a changed file's current digest into the lock (caller must ensure TTY).
|
|
274
|
+
* Updates digest, root, and accepted[].
|
|
275
|
+
*/
|
|
276
|
+
export function acceptLockPath(projectPath, relPath, meta) {
|
|
277
|
+
const lock = readLockfile(projectPath);
|
|
278
|
+
if (!lock)
|
|
279
|
+
throw new Error("No speclaw.lock — run `speclaw laws lock` first.");
|
|
280
|
+
const abs = path.join(projectPath, relPath);
|
|
281
|
+
if (!fs.existsSync(abs))
|
|
282
|
+
throw new Error(`File not found: ${relPath}`);
|
|
283
|
+
const raw = prepareIntegrityText(relPath, fs.readFileSync(abs, "utf8"));
|
|
284
|
+
const dig = digestText(raw);
|
|
285
|
+
const ownership = lock.files[relPath]?.ownership ?? integrityPolicy(relPath);
|
|
286
|
+
if (ownership === "scan-only") {
|
|
287
|
+
throw new Error(`${relPath} is scan-only — digests are not locked for this path.`);
|
|
288
|
+
}
|
|
289
|
+
lock.files[relPath] = { digest: dig, ownership };
|
|
290
|
+
lock.root = rootDigest(lock.files);
|
|
291
|
+
const entry = {
|
|
292
|
+
path: relPath,
|
|
293
|
+
digest: dig,
|
|
294
|
+
at: meta.at ?? new Date().toISOString(),
|
|
295
|
+
by: meta.by,
|
|
296
|
+
note: meta.note,
|
|
297
|
+
};
|
|
298
|
+
lock.accepted = [...lock.accepted.filter((a) => a.path !== relPath), entry];
|
|
299
|
+
writeLockfile(projectPath, lock);
|
|
300
|
+
return lock;
|
|
301
|
+
}
|
|
302
|
+
/** Re-export refresh for CLI `laws lock`. */
|
|
303
|
+
export { refreshLockfile };
|
|
304
|
+
/** True when stdin is a TTY (accept requires this). */
|
|
305
|
+
export function isInteractiveTty() {
|
|
306
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
307
|
+
}
|