@esneiderbravo/speclaw 0.4.0 → 1.0.1

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.
Files changed (48) hide show
  1. package/README.md +91 -72
  2. package/dist/cli/commands/index-build.js +12 -3
  3. package/dist/cli/commands/lawbook.js +1 -0
  4. package/dist/cli/commands/laws.js +149 -8
  5. package/dist/cli/commands/owners.js +44 -0
  6. package/dist/cli/commands/query.js +32 -10
  7. package/dist/cli/commands/update.js +39 -0
  8. package/dist/cli/commands/verify.js +8 -0
  9. package/dist/cli/index.js +13 -4
  10. package/dist/modules/compass/budget.js +128 -0
  11. package/dist/modules/compass/db.js +290 -30
  12. package/dist/modules/compass/embed-input.js +28 -0
  13. package/dist/modules/compass/embedder.js +3 -1
  14. package/dist/modules/compass/explore-rich.js +10 -5
  15. package/dist/modules/compass/extract.js +86 -0
  16. package/dist/modules/compass/hybrid.js +318 -0
  17. package/dist/modules/compass/indexer.js +204 -33
  18. package/dist/modules/compass/merkle.js +76 -0
  19. package/dist/modules/compass/pagerank.js +122 -0
  20. package/dist/modules/compass/rank.js +95 -0
  21. package/dist/modules/compass/register.js +8 -4
  22. package/dist/modules/foundation/assets/laws/laws-manifest.json +16 -7
  23. package/dist/modules/foundation/check.js +4 -2
  24. package/dist/modules/foundation/compile-laws.js +210 -0
  25. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  26. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  27. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  28. package/dist/modules/foundation/dialects/copilot.js +35 -0
  29. package/dist/modules/foundation/dialects/index.js +5 -0
  30. package/dist/modules/foundation/dialects/types.js +58 -0
  31. package/dist/modules/foundation/doctor.js +220 -14
  32. package/dist/modules/foundation/graph.js +7 -3
  33. package/dist/modules/foundation/import-rules.js +67 -0
  34. package/dist/modules/foundation/integrity.js +307 -0
  35. package/dist/modules/foundation/laws-parse.js +131 -0
  36. package/dist/modules/foundation/laws.js +35 -32
  37. package/dist/modules/foundation/lock.js +283 -0
  38. package/dist/modules/foundation/ownership.js +4 -0
  39. package/dist/modules/foundation/scaffold.js +34 -6
  40. package/dist/modules/foundation/scan.js +227 -0
  41. package/dist/modules/foundation/seed-laws.js +263 -0
  42. package/dist/modules/foundation/verify.js +11 -3
  43. package/dist/modules/lawbook/coverage.js +45 -6
  44. package/dist/modules/lawbook/ears.js +417 -0
  45. package/dist/modules/lawbook/engine.js +29 -0
  46. package/dist/modules/lawbook/spec-items.js +4 -1
  47. package/dist/modules/team/owners.js +464 -0
  48. 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+)/.exec(required.trim());
62
+ const m = /^>=\s*(\d+)(?:\.(\d+))?/.exec(required.trim());
59
63
  if (!m)
60
64
  return true;
61
- const major = parseInt(version.replace(/^v/, "").split(".")[0], 10);
62
- return major >= parseInt(m[1], 10);
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 · 0 invalid`,
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 ids.map((id) => ({
621
- id,
622
- title: id.replace(/^cfg\./, ""),
623
- status: "skip",
624
- detail: "project not initialised",
625
- remedy: "speclaw init",
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) {
@@ -1,6 +1,7 @@
1
+ import { matchesScope } from "./laws.js";
1
2
  import { underPaths } from "./verify-model.js";
2
- /** Build the cross-file dependency graph, restricted to `paths` when given. */
3
- function buildGraph(db, paths, edgeKinds) {
3
+ /** Build the cross-file dependency graph, restricted to `paths` and `scope`. */
4
+ function buildGraph(db, paths, edgeKinds, scope) {
4
5
  const kindFilter = edgeKinds && edgeKinds.length > 0
5
6
  ? ` AND e.kind IN (${edgeKinds.map(() => "?").join(", ")})`
6
7
  : "";
@@ -16,6 +17,8 @@ function buildGraph(db, paths, edgeKinds) {
16
17
  for (const { src, dst } of rows) {
17
18
  if (!underPaths(src, paths) || !underPaths(dst, paths))
18
19
  continue;
20
+ if (!matchesScope(scope, src) || !matchesScope(scope, dst))
21
+ continue;
19
22
  const list = adj.get(src);
20
23
  if (list)
21
24
  list.push(dst);
@@ -205,8 +208,9 @@ function reachableFindings(law, rule, adj) {
205
208
  * resolved graph, so a graph law never reports an unknown here).
206
209
  */
207
210
  export function runGraphLaw(db, law, paths) {
211
+ // Covers: req~graph-honours-scope~1
208
212
  const rule = law.verification.rule;
209
- const adj = buildGraph(db, paths, rule.edgeKinds);
213
+ const adj = buildGraph(db, paths, rule.edgeKinds, law.scope);
210
214
  const findings = [];
211
215
  const wantReachable = rule.reachable === true && rule.from != null && rule.to != null;
212
216
  const wantCircular = rule.circular === true || (!rule.circular && !wantReachable);
@@ -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
+ }