@esneiderbravo/speclaw 0.3.7 → 0.3.9

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.
@@ -0,0 +1,168 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ const RE_REQUIREMENT = /^###\s+Requirement:\s*(.+?)\s*$/;
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;
6
+ const RE_INLINE = /\[@(test|impl)\s+([^\]]+)\]/gi;
7
+ const RE_ID_LOOSE = /\b([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)\b/g;
8
+ /** Format a SpecItemId as `type~name~rev`. */
9
+ export function formatItemId(id) {
10
+ return `${id.artifactType}~${id.name}~${id.revision}`;
11
+ }
12
+ /** Parse a single `type~name~rev` token, or null if malformed. */
13
+ export function parseItemId(text) {
14
+ const m = /^([a-z]{2,6})~([A-Za-z0-9._-]+)~(\d+)$/.exec(text.trim());
15
+ if (!m)
16
+ return null;
17
+ return { artifactType: m[1], name: m[2], revision: Number(m[3]) };
18
+ }
19
+ function splitList(value) {
20
+ return value
21
+ .split(/[, ]+/)
22
+ .map((s) => s.trim())
23
+ .filter(Boolean);
24
+ }
25
+ function parseIdList(value) {
26
+ const out = [];
27
+ for (const m of value.matchAll(RE_ID_LOOSE)) {
28
+ out.push(`${m[1]}~${m[2]}~${m[3]}`);
29
+ }
30
+ return out;
31
+ }
32
+ /**
33
+ * Parse one markdown spec file into requirement items. Headings without an
34
+ * inline-code identifier are returned with `id: null` (ignored by coverage).
35
+ *
36
+ * @param specPath - Project-relative path of the spec (for reporting).
37
+ * @param content - Full markdown source.
38
+ */
39
+ export function parseSpecItems(specPath, content) {
40
+ const lines = content.split(/\r?\n/);
41
+ const items = [];
42
+ let current = null;
43
+ const flush = () => {
44
+ if (current)
45
+ items.push(current);
46
+ current = null;
47
+ };
48
+ for (let i = 0; i < lines.length; i++) {
49
+ const line = lines[i];
50
+ const req = RE_REQUIREMENT.exec(line);
51
+ if (req) {
52
+ flush();
53
+ const rest = req[1];
54
+ const idMatch = RE_ID.exec(rest);
55
+ let title = rest;
56
+ let id = null;
57
+ let idText = null;
58
+ if (idMatch) {
59
+ id = {
60
+ artifactType: idMatch[1],
61
+ name: idMatch[2],
62
+ revision: Number(idMatch[3]),
63
+ };
64
+ idText = formatItemId(id);
65
+ title = rest.replace(idMatch[0], "").replace(/\s+/g, " ").trim();
66
+ // Common forms: "Title `id`" or "`id` Title"
67
+ title = title.replace(/^[\s—–-]+|[\s—–-]+$/g, "").trim();
68
+ }
69
+ current = {
70
+ id,
71
+ idText,
72
+ title,
73
+ status: "approved",
74
+ needs: [],
75
+ tags: [],
76
+ depends: [],
77
+ covers: [],
78
+ inlineLinks: [],
79
+ specPath,
80
+ line: i + 1,
81
+ };
82
+ continue;
83
+ }
84
+ if (!current)
85
+ continue;
86
+ // Next ### Requirement: or # heading ends the item body for keyword purposes,
87
+ // but #### Scenario lines may still carry inline links.
88
+ if (/^###?\s+/.test(line) && !/^####\s+/.test(line)) {
89
+ flush();
90
+ // Re-process this line as a potential new requirement on next iteration
91
+ // by rewinding — simpler: only #### and body lines belong to current.
92
+ i--;
93
+ continue;
94
+ }
95
+ const kw = RE_KEYWORD.exec(line);
96
+ if (kw) {
97
+ const key = kw[1].toLowerCase();
98
+ const value = kw[2];
99
+ if (key === "status")
100
+ current.status = value.trim().toLowerCase();
101
+ else if (key === "needs")
102
+ current.needs = splitList(value).map((s) => s.toLowerCase());
103
+ else if (key === "tags")
104
+ current.tags = splitList(value);
105
+ else if (key === "depends")
106
+ current.depends = parseIdList(value);
107
+ else if (key === "covers")
108
+ current.covers = parseIdList(value);
109
+ continue;
110
+ }
111
+ for (const m of line.matchAll(RE_INLINE)) {
112
+ current.inlineLinks.push({
113
+ kind: m[1].toLowerCase(),
114
+ targetPath: m[2].trim(),
115
+ line: i + 1,
116
+ });
117
+ }
118
+ }
119
+ flush();
120
+ return items;
121
+ }
122
+ /**
123
+ * Walk `lawbook/specs/**\/spec.md` (and optionally a change's delta specs) and
124
+ * parse every requirement item.
125
+ *
126
+ * @param projectPath - Absolute project root.
127
+ * @param opts.change - When set, parse only that change's delta specs.
128
+ */
129
+ export function loadSpecItems(projectPath, opts = {}) {
130
+ const roots = [];
131
+ if (opts.change) {
132
+ roots.push(path.join(projectPath, "lawbook", "changes", opts.change, "specs"));
133
+ }
134
+ else {
135
+ roots.push(path.join(projectPath, "lawbook", "specs"));
136
+ }
137
+ const items = [];
138
+ for (const root of roots) {
139
+ if (!fs.existsSync(root))
140
+ continue;
141
+ for (const file of walkSpecFiles(root)) {
142
+ const rel = path.relative(projectPath, file).split(path.sep).join("/");
143
+ const content = fs.readFileSync(file, "utf8");
144
+ items.push(...parseSpecItems(rel, content));
145
+ }
146
+ }
147
+ return items;
148
+ }
149
+ function* walkSpecFiles(dir) {
150
+ const stack = [dir];
151
+ while (stack.length) {
152
+ const cur = stack.pop();
153
+ let entries;
154
+ try {
155
+ entries = fs.readdirSync(cur, { withFileTypes: true });
156
+ }
157
+ catch {
158
+ continue;
159
+ }
160
+ for (const e of entries) {
161
+ const full = path.join(cur, e.name);
162
+ if (e.isDirectory())
163
+ stack.push(full);
164
+ else if (e.isFile() && e.name === "spec.md")
165
+ yield full;
166
+ }
167
+ }
168
+ }
@@ -5,7 +5,7 @@ import { readManifest } from "./manifest.js";
5
5
  /**
6
6
  * Tools omitted when the exposure profile is `minimal`. Kept tools are the
7
7
  * discovery + law loop: compass_explore/search/recall, lawbook_validate/sync,
8
- * law_verify, speclaw_check.
8
+ * lawbook_coverage, lawbook_drift, law_verify, speclaw_check.
9
9
  */
10
10
  export const MINIMAL_OMIT = new Set([
11
11
  "compass_index",
@@ -94,6 +94,7 @@ export function copyRendered(srcDir, destDir, vars, report, opts) {
94
94
  * @param comment - Comment line written above the entry when it is added.
95
95
  * @param report - Report mutated in place; the appended entry is recorded under `written`.
96
96
  */
97
+ // Covers: req~ai-specs-gitignore~1
97
98
  export function ensureGitignore(projectPath, entry, comment, report) {
98
99
  const gitignorePath = path.join(projectPath, ".gitignore");
99
100
  let content = "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@esneiderbravo/speclaw",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },