@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.
Files changed (45) hide show
  1. package/README.md +88 -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 +28 -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/check.js +4 -2
  23. package/dist/modules/foundation/compile-laws.js +212 -0
  24. package/dist/modules/foundation/dialects/agentsmd.js +95 -0
  25. package/dist/modules/foundation/dialects/claude-cursor.js +45 -0
  26. package/dist/modules/foundation/dialects/coderabbit.js +27 -0
  27. package/dist/modules/foundation/dialects/copilot.js +35 -0
  28. package/dist/modules/foundation/dialects/index.js +5 -0
  29. package/dist/modules/foundation/dialects/types.js +58 -0
  30. package/dist/modules/foundation/doctor.js +220 -14
  31. package/dist/modules/foundation/import-rules.js +67 -0
  32. package/dist/modules/foundation/integrity.js +307 -0
  33. package/dist/modules/foundation/laws-parse.js +131 -0
  34. package/dist/modules/foundation/laws.js +5 -0
  35. package/dist/modules/foundation/lock.js +283 -0
  36. package/dist/modules/foundation/ownership.js +4 -0
  37. package/dist/modules/foundation/scaffold.js +25 -0
  38. package/dist/modules/foundation/scan.js +227 -0
  39. package/dist/modules/foundation/verify.js +9 -1
  40. package/dist/modules/lawbook/coverage.js +45 -6
  41. package/dist/modules/lawbook/ears.js +417 -0
  42. package/dist/modules/lawbook/engine.js +29 -0
  43. package/dist/modules/lawbook/spec-items.js +4 -1
  44. package/dist/modules/team/owners.js +464 -0
  45. package/package.json +4 -3
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Prompt-injection scanners for rule / skill files.
3
+ * Complements digests: hashes catch any edit; scanners catch known payloads.
4
+ */
5
+ // Covers: req~injection-scan~1
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ const OVERRIDE = /\b(ignore\s+previous\s+instructions|disregard\s+the\s+above|you\s+are\s+now|new\s+system\s+prompt|ignora\s+las\s+instrucciones\s+anteriores)\b/i;
9
+ const SHELL = /\b(curl\s+[^\n|]*\|\s*(ba)?sh|bash\s+-c|Invoke-Expression|iex\b|eval\s*\(|npm\s+run\s+[^\s]+.*\|\s*sh)\b/i;
10
+ const EXFIL = /\b(send\s+(this|the)\s+(repo|code|contents?)\s+to|exfiltrat|upload\s+to\s+https?:\/\/|read\s+(\.env|~\/\.ssh|~\/\.aws|\.npmrc)|exfiltra)\b/i;
11
+ const URL_RE = /https?:\/\/[^\s)>\]]+/gi;
12
+ const ZERO_WIDTH = /[\u200B-\u200D\uFEFF\u202A-\u202E]/;
13
+ const IMPERATIVE_HTML = /<!--[\s\S]{0,200}\b(run|execute|ignore|disregard|curl|bash|send)\b[\s\S]{0,200}-->/i;
14
+ /**
15
+ * Normalize text before detection (NFKC, strip zero-width/bidi, fold common
16
+ * Cyrillic lookalikes, collapse whitespace).
17
+ */
18
+ export function normalizeForScan(text) {
19
+ let t = text.normalize("NFKC");
20
+ t = t.replace(ZERO_WIDTH, "");
21
+ t = t.replace(/[\u0400-\u04FF]/g, (ch) => CYRILLIC_FOLD[ch] ?? ch);
22
+ t = t.replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&amp;/g, "&");
23
+ t = t.replace(/\\\*|\\_|\\`/g, (m) => m.slice(1));
24
+ t = t.replace(/\s+/g, " ").trim();
25
+ return t;
26
+ }
27
+ const CYRILLIC_FOLD = {
28
+ а: "a",
29
+ е: "e",
30
+ о: "o",
31
+ р: "p",
32
+ с: "c",
33
+ у: "y",
34
+ х: "x",
35
+ А: "A",
36
+ Е: "E",
37
+ О: "O",
38
+ Р: "P",
39
+ С: "C",
40
+ У: "Y",
41
+ Х: "X",
42
+ };
43
+ /** Scan one file's raw contents; returns findings with original line numbers. */
44
+ export function scanText(relPath, raw, opts = {}) {
45
+ const suppressions = opts.suppressions ?? [];
46
+ const allow = new Set((opts.allowHosts ?? []).map((h) => h.toLowerCase()));
47
+ const out = [];
48
+ const lines = raw.split(/\r?\n/);
49
+ const push = (f) => {
50
+ if (suppressions.some((s) => s.detector === f.detector && matchPath(s.path, f.path) && s.note.trim().length > 0)) {
51
+ return;
52
+ }
53
+ out.push(f);
54
+ };
55
+ if (ZERO_WIDTH.test(raw)) {
56
+ push({
57
+ detector: "injection/hidden-text",
58
+ severity: "error",
59
+ path: relPath,
60
+ line: 1,
61
+ excerpt: "zero-width or bidi control characters",
62
+ message: "Hidden / bidi control characters in a rule file.",
63
+ });
64
+ }
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ const norm = normalizeForScan(line);
68
+ const ln = i + 1;
69
+ if (OVERRIDE.test(norm) || OVERRIDE.test(line)) {
70
+ push({
71
+ detector: "injection/instruction-override",
72
+ severity: "error",
73
+ path: relPath,
74
+ line: ln,
75
+ excerpt: clip(line),
76
+ message: "Instruction-override phrasing in a rule file.",
77
+ });
78
+ }
79
+ if (SHELL.test(norm) || SHELL.test(line)) {
80
+ push({
81
+ detector: "injection/shell-execution",
82
+ severity: "error",
83
+ path: relPath,
84
+ line: ln,
85
+ excerpt: clip(line),
86
+ message: "Shell-execution instruction in a rule file.",
87
+ });
88
+ }
89
+ if (EXFIL.test(norm) || EXFIL.test(line)) {
90
+ push({
91
+ detector: "injection/exfiltration",
92
+ severity: "error",
93
+ path: relPath,
94
+ line: ln,
95
+ excerpt: clip(line),
96
+ message: "Possible exfiltration instruction in a rule file.",
97
+ });
98
+ }
99
+ for (const m of line.matchAll(URL_RE)) {
100
+ try {
101
+ const host = new URL(m[0]).hostname.toLowerCase();
102
+ if (allow.size > 0 && !allow.has(host) && !host.endsWith(".github.com")) {
103
+ push({
104
+ detector: "injection/unallowlisted-url",
105
+ severity: "warn",
106
+ path: relPath,
107
+ line: ln,
108
+ excerpt: clip(m[0]),
109
+ message: `URL host not on allowlist: ${host}`,
110
+ });
111
+ }
112
+ }
113
+ catch {
114
+ /* ignore */
115
+ }
116
+ }
117
+ }
118
+ if (IMPERATIVE_HTML.test(raw)) {
119
+ const idx = raw.search(IMPERATIVE_HTML);
120
+ const snippet = raw.slice(Math.max(0, idx), idx + 220);
121
+ // Data-only speclaw markers (map/laws/provenance) are not agent instructions.
122
+ if (!/<!--\s*speclaw:/.test(snippet)) {
123
+ const line = lineOf(raw, idx);
124
+ push({
125
+ detector: "injection/imperative-html-comment",
126
+ severity: "warn",
127
+ path: relPath,
128
+ line,
129
+ excerpt: clip(raw.slice(idx, idx + 120)),
130
+ message: "HTML comment contains imperative language (visible to some agents).",
131
+ });
132
+ }
133
+ }
134
+ // External @import / @~/ / @C:\…
135
+ // External @import / @~/ / @C:\…
136
+ for (let i = 0; i < lines.length; i++) {
137
+ const line = lines[i];
138
+ const m = /@([~/][^\s)\]>"']+)/.exec(line) ??
139
+ /@([A-Za-z]:[^\s)\]>"']+)/.exec(line) ??
140
+ /@import\s+["']([^"']+)["']/.exec(line);
141
+ if (!m)
142
+ continue;
143
+ const target = m[1];
144
+ push({
145
+ detector: "injection/external-import",
146
+ severity: "warn",
147
+ path: relPath,
148
+ line: i + 1,
149
+ excerpt: clip(line),
150
+ message: `Import may resolve outside the working directory: ${target}`,
151
+ });
152
+ }
153
+ // Frontmatter ↔ body mismatch (skills)
154
+ const fm = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/.exec(raw);
155
+ if (fm) {
156
+ const front = fm[1];
157
+ const body = fm[2];
158
+ const frontDesc = /(?:^|\n)description:\s*["']?([^\n"']+)/i.exec(front)?.[1] ?? "";
159
+ if (frontDesc && SHELL.test(body) && !SHELL.test(frontDesc)) {
160
+ push({
161
+ detector: "injection/manifest-prose-mismatch",
162
+ severity: "warn",
163
+ path: relPath,
164
+ line: 1,
165
+ excerpt: clip(frontDesc),
166
+ message: "Skill frontmatter description and body disagree on shell risk.",
167
+ });
168
+ }
169
+ }
170
+ return out;
171
+ }
172
+ function matchPath(pattern, file) {
173
+ if (pattern === file)
174
+ return true;
175
+ if (pattern.endsWith("/**")) {
176
+ const prefix = pattern.slice(0, -3);
177
+ return file === prefix || file.startsWith(prefix + "/");
178
+ }
179
+ if (pattern.includes("*")) {
180
+ const re = new RegExp("^" + pattern.split("*").map(escapeRegExp).join(".*") + "$");
181
+ return re.test(file);
182
+ }
183
+ return false;
184
+ }
185
+ function escapeRegExp(s) {
186
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
187
+ }
188
+ function clip(s, n = 120) {
189
+ const t = s.trim();
190
+ return t.length <= n ? t : t.slice(0, n - 1) + "…";
191
+ }
192
+ function lineOf(text, index) {
193
+ if (index <= 0)
194
+ return 1;
195
+ return text.slice(0, index).split(/\r?\n/).length;
196
+ }
197
+ /** Load optional scan suppressions from lawbook/config.yaml (line-oriented). */
198
+ export function loadScanSuppressions(projectPath) {
199
+ const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
200
+ if (!fs.existsSync(cfgPath))
201
+ return [];
202
+ const text = fs.readFileSync(cfgPath, "utf8");
203
+ // Very small subset: repeated blocks under scanSuppressions are not fully
204
+ // parsed; support a flat JSON-ish list in a comment-free line for v1:
205
+ // scanSuppressions: [{"detector":"…","path":"…","note":"…"}]
206
+ const m = /^\s*scanSuppressions\s*:\s*(\[[\s\S]*?\])\s*$/m.exec(text);
207
+ if (!m)
208
+ return [];
209
+ try {
210
+ const arr = JSON.parse(m[1]);
211
+ return arr.filter((s) => s.detector && s.path && s.note);
212
+ }
213
+ catch {
214
+ return [];
215
+ }
216
+ }
217
+ /** Scan a list of project-relative files that exist. */
218
+ export function scanPaths(projectPath, relPaths, opts = {}) {
219
+ const out = [];
220
+ for (const rel of relPaths) {
221
+ const abs = path.join(projectPath, rel);
222
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile())
223
+ continue;
224
+ out.push(...scanText(rel, fs.readFileSync(abs, "utf8"), opts));
225
+ }
226
+ return out;
227
+ }
@@ -1,6 +1,6 @@
1
1
  import { performance } from "node:perf_hooks";
2
2
  import { openDb, indexExists } from "../compass/db.js";
3
- import { hasBatchBackend, loadManifestForVerify } from "./laws.js";
3
+ import { hasBatchBackend, isActiveLaw, loadManifestForVerify } from "./laws.js";
4
4
  import { runDepsLaw } from "./deps.js";
5
5
  import { runGraphLaw } from "./graph.js";
6
6
  export { underPaths } from "./verify-model.js";
@@ -50,6 +50,14 @@ export function verifyLaws(args) {
50
50
  const manifest = loadManifestForVerify(args.projectPath);
51
51
  const engines = args.engines;
52
52
  const selected = manifest.laws.filter((law) => {
53
+ if (!isActiveLaw(law)) {
54
+ skipped.push({
55
+ lawId: law.id,
56
+ reason: "draft",
57
+ detail: "status=draft — pending human activation; does not gate",
58
+ });
59
+ return false;
60
+ }
53
61
  if (!hasBatchBackend(law))
54
62
  return false;
55
63
  if (args.lawIds && !args.lawIds.includes(law.id))
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { openDb, indexExists } from "../compass/db.js";
4
+ import { detectPropertyRunnerInWindow, loadPropertyRunners } from "./ears.js";
4
5
  import { formatItemId, loadSpecItems, parseItemId, parseSpecItems, } from "./spec-items.js";
5
6
  export const DEFAULT_COVERAGE_CONFIG = {
6
7
  defaultNeeds: ["impl", "utest"],
@@ -10,8 +11,10 @@ export const DEFAULT_COVERAGE_CONFIG = {
10
11
  impl: ["src/**"],
11
12
  utest: ["test/unit/**", "test/**/*.test.ts", "test/**/*.test.js"],
12
13
  itest: ["test/integration/**"],
14
+ ptest: ["test/property/**"],
13
15
  },
14
16
  exclude: ["**/node_modules/**", "**/dist/**", "**/.speclaw/**"],
17
+ propertyRunners: [],
15
18
  };
16
19
  /**
17
20
  * Load coverage config from lawbook/config.yaml when present; otherwise defaults.
@@ -20,8 +23,10 @@ export const DEFAULT_COVERAGE_CONFIG = {
20
23
  export function loadCoverageConfig(projectPath) {
21
24
  const cfg = structuredClone(DEFAULT_COVERAGE_CONFIG);
22
25
  const cfgPath = path.join(projectPath, "lawbook", "config.yaml");
23
- if (!fs.existsSync(cfgPath))
26
+ if (!fs.existsSync(cfgPath)) {
27
+ cfg.propertyRunners = loadPropertyRunners(projectPath);
24
28
  return cfg;
29
+ }
25
30
  const text = fs.readFileSync(cfgPath, "utf8");
26
31
  const gate = /^\s*gateArchive\s*:\s*(true|false)\s*$/im.exec(text);
27
32
  if (gate)
@@ -43,8 +48,21 @@ export function loadCoverageConfig(projectPath) {
43
48
  .toLowerCase())
44
49
  .filter(Boolean);
45
50
  }
51
+ cfg.propertyRunners = loadPropertyRunners(projectPath);
46
52
  return cfg;
47
53
  }
54
+ /**
55
+ * Effective coverage needs for an item: explicit `Needs:` or defaults, plus
56
+ * `ptest` when `Verification: property` is declared.
57
+ */
58
+ // Covers: req~ptest-need~1, req~ptest-archive-gate~1
59
+ export function effectiveNeeds(item, cfg) {
60
+ const needs = item.needs.length > 0 ? [...item.needs] : [...cfg.defaultNeeds];
61
+ if (item.verification === "property" && !needs.includes("ptest")) {
62
+ needs.push("ptest");
63
+ }
64
+ return needs;
65
+ }
48
66
  /** Glob match supporting `**`, `*`, and path separators. */
49
67
  export function matchGlob(relPath, pattern) {
50
68
  const norm = relPath.split("\\").join("/");
@@ -78,13 +96,33 @@ export function inferArtifactType(relPath, cfg) {
78
96
  const norm = relPath.split("\\").join("/");
79
97
  if (cfg.exclude.some((g) => matchGlob(norm, g)))
80
98
  return null;
81
- for (const type of ["itest", "utest", "impl"]) {
99
+ for (const type of ["ptest", "itest", "utest", "impl"]) {
82
100
  const globs = cfg.sources[type] ?? [];
83
101
  if (globs.some((g) => matchGlob(norm, g)))
84
102
  return type;
85
103
  }
86
104
  return null;
87
105
  }
106
+ /**
107
+ * Prefer `ptest` when a property-runner invocation sits near the link line.
108
+ */
109
+ // Covers: req~ptest-need~1
110
+ export function refineSourceType(projectPath, filePath, line, current, runners) {
111
+ if (runners.length === 0)
112
+ return current;
113
+ const abs = path.isAbsolute(filePath) ? filePath : path.join(projectPath, filePath);
114
+ if (!fs.existsSync(abs))
115
+ return current;
116
+ let source;
117
+ try {
118
+ source = fs.readFileSync(abs, "utf8");
119
+ }
120
+ catch {
121
+ return current;
122
+ }
123
+ const hit = detectPropertyRunnerInWindow(source, line, runners);
124
+ return hit ? "ptest" : current;
125
+ }
88
126
  function readIndexLinks(projectPath) {
89
127
  if (!indexExists(projectPath))
90
128
  return [];
@@ -135,7 +173,7 @@ function inlineLinksAsRaw(projectPath, items, cfg) {
135
173
  }
136
174
  return out;
137
175
  }
138
- function classifyLink(link, item, idCounts, cfg) {
176
+ function classifyLink(link, item, idCounts, cfg, projectPath) {
139
177
  const base = {
140
178
  artifactType: link.artifactType,
141
179
  name: link.name,
@@ -172,6 +210,7 @@ function classifyLink(link, item, idCounts, cfg) {
172
210
  const inferred = inferArtifactType(link.filePath, cfg);
173
211
  if (inferred)
174
212
  base.sourceType = inferred;
213
+ base.sourceType = refineSourceType(projectPath, link.filePath, link.line, base.sourceType, cfg.propertyRunners);
175
214
  return base;
176
215
  }
177
216
  /**
@@ -198,12 +237,12 @@ export function buildCoverageReport(projectPath, opts = {}) {
198
237
  const matchedKeys = new Set();
199
238
  for (const item of identified) {
200
239
  const idText = item.idText;
201
- const needs = item.needs.length > 0 ? item.needs : [...cfg.defaultNeeds];
240
+ const needs = effectiveNeeds(item, cfg);
202
241
  const itemLinks = rawLinks
203
242
  .filter((l) => l.artifactType === item.id.artifactType && l.name === item.id.name)
204
243
  .map((l) => {
205
244
  matchedKeys.add(`${l.filePath}:${l.line}:${l.revision}:${l.kind}`);
206
- return classifyLink(l, item, idCounts, cfg);
245
+ return classifyLink(l, item, idCounts, cfg, projectPath);
207
246
  });
208
247
  const covering = itemLinks.filter((l) => l.status === "Covers");
209
248
  const coveredTypes = [...new Set(covering.map((l) => l.sourceType))];
@@ -282,7 +321,7 @@ export function buildCoverageReport(projectPath, opts = {}) {
282
321
  const key = `${l.filePath}:${l.line}:${l.revision}:${l.kind}`;
283
322
  if (matchedKeys.has(key))
284
323
  continue;
285
- orphans.push(classifyLink(l, undefined, idCounts, cfg));
324
+ orphans.push(classifyLink(l, undefined, idCounts, cfg, projectPath));
286
325
  }
287
326
  const gated = results.filter((r) => cfg.gateStatuses.includes(r.status));
288
327
  const directDefects = gated.reduce((n, r) => n + r.directDefects.length, 0);