@ulysses-ai/create-workspace 0.13.0-beta.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 (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/bin/create.mjs +79 -0
  4. package/lib/git.mjs +26 -0
  5. package/lib/init.mjs +129 -0
  6. package/lib/payload.mjs +44 -0
  7. package/lib/prompts.mjs +113 -0
  8. package/lib/scaffold.mjs +84 -0
  9. package/lib/upgrade.mjs +42 -0
  10. package/package.json +43 -0
  11. package/template/.claude/agents/aside-researcher.md +48 -0
  12. package/template/.claude/agents/implementer.md +39 -0
  13. package/template/.claude/agents/researcher.md +40 -0
  14. package/template/.claude/agents/reviewer.md +47 -0
  15. package/template/.claude/hooks/_utils.mjs +196 -0
  16. package/template/.claude/hooks/_utils.test.mjs +99 -0
  17. package/template/.claude/hooks/post-compact.mjs +7 -0
  18. package/template/.claude/hooks/pre-compact.mjs +34 -0
  19. package/template/.claude/hooks/repo-write-detection.mjs +107 -0
  20. package/template/.claude/hooks/session-end.mjs +91 -0
  21. package/template/.claude/hooks/session-start.mjs +150 -0
  22. package/template/.claude/hooks/subagent-start.mjs +44 -0
  23. package/template/.claude/hooks/workspace-update-check.mjs +42 -0
  24. package/template/.claude/hooks/worktree-create.mjs +53 -0
  25. package/template/.claude/lib/session-frontmatter.mjs +265 -0
  26. package/template/.claude/lib/session-frontmatter.test.mjs +242 -0
  27. package/template/.claude/recipes/migrate-from-notion.md +120 -0
  28. package/template/.claude/rules/agent-rules.md.skip +32 -0
  29. package/template/.claude/rules/cloud-infrastructure.md.skip +15 -0
  30. package/template/.claude/rules/coherent-revisions.md +24 -0
  31. package/template/.claude/rules/documentation.md.skip +13 -0
  32. package/template/.claude/rules/git-conventions.md +34 -0
  33. package/template/.claude/rules/honest-pushback.md +56 -0
  34. package/template/.claude/rules/local-dev-environment.md.skip +60 -0
  35. package/template/.claude/rules/memory-guidance.md +26 -0
  36. package/template/.claude/rules/product-integrity.md.skip +24 -0
  37. package/template/.claude/rules/scope-guard.md.skip +22 -0
  38. package/template/.claude/rules/superpowers-workflow.md.skip +22 -0
  39. package/template/.claude/rules/token-economics.md.skip +31 -0
  40. package/template/.claude/rules/work-item-tracking.md +90 -0
  41. package/template/.claude/rules/workspace-structure.md +69 -0
  42. package/template/.claude/scripts/add-repo-to-session.mjs +78 -0
  43. package/template/.claude/scripts/cleanup-work-session.mjs +108 -0
  44. package/template/.claude/scripts/create-work-session.mjs +124 -0
  45. package/template/.claude/scripts/migrate-open-work.mjs +91 -0
  46. package/template/.claude/scripts/migrate-session-layout.mjs +236 -0
  47. package/template/.claude/scripts/migrate-session-layout.test.mjs +144 -0
  48. package/template/.claude/scripts/trackers/github-issues.mjs +170 -0
  49. package/template/.claude/scripts/trackers/github-issues.test.mjs +190 -0
  50. package/template/.claude/scripts/trackers/interface.mjs +25 -0
  51. package/template/.claude/scripts/trackers/interface.test.mjs +40 -0
  52. package/template/.claude/settings.json +107 -0
  53. package/template/.claude/skills/aside/SKILL.md +125 -0
  54. package/template/.claude/skills/braindump/SKILL.md +96 -0
  55. package/template/.claude/skills/build-docs-site/SKILL.md +323 -0
  56. package/template/.claude/skills/build-docs-site/checklists/framing.md +221 -0
  57. package/template/.claude/skills/build-docs-site/checklists/pitfalls.md +228 -0
  58. package/template/.claude/skills/build-docs-site/checklists/review.md +130 -0
  59. package/template/.claude/skills/build-docs-site/scripts/bulk-fill-migration.py +393 -0
  60. package/template/.claude/skills/build-docs-site/scripts/forbidden-word-grep.mjs +159 -0
  61. package/template/.claude/skills/build-docs-site/scripts/leak-grep.mjs +328 -0
  62. package/template/.claude/skills/build-docs-site/templates/custom.css.tmpl +212 -0
  63. package/template/.claude/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +95 -0
  64. package/template/.claude/skills/build-docs-site/templates/primitives/Arrow.tsx +87 -0
  65. package/template/.claude/skills/build-docs-site/templates/primitives/Box.tsx +90 -0
  66. package/template/.claude/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +46 -0
  67. package/template/.claude/skills/build-docs-site/templates/primitives/Region.tsx +68 -0
  68. package/template/.claude/skills/build-docs-site/templates/primitives/SectionTitle.tsx +42 -0
  69. package/template/.claude/skills/build-docs-site/templates/primitives/tokens.ts +67 -0
  70. package/template/.claude/skills/build-docs-site/templates/sidebars.ts.tmpl +89 -0
  71. package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +119 -0
  72. package/template/.claude/skills/complete-work/SKILL.md +369 -0
  73. package/template/.claude/skills/handoff/SKILL.md +98 -0
  74. package/template/.claude/skills/maintenance/SKILL.md +116 -0
  75. package/template/.claude/skills/pause-work/SKILL.md +98 -0
  76. package/template/.claude/skills/promote/SKILL.md +77 -0
  77. package/template/.claude/skills/release/SKILL.md +126 -0
  78. package/template/.claude/skills/setup-tracker/SKILL.md +117 -0
  79. package/template/.claude/skills/start-work/SKILL.md +234 -0
  80. package/template/.claude/skills/sync-work/SKILL.md +73 -0
  81. package/template/.claude/skills/workspace-init/SKILL.md +420 -0
  82. package/template/.claude/skills/workspace-update/SKILL.md +108 -0
  83. package/template/.mcp.json +12 -0
  84. package/template/CLAUDE.md.tmpl +32 -0
  85. package/template/_gitignore +28 -0
  86. package/template/workspace.json.tmpl +15 -0
@@ -0,0 +1,393 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ bulk-fill-migration.py — migrate SVG fill/stroke attributes to class-based styling.
4
+
5
+ Replaces patterns like:
6
+ fill={colors.primary} → className={cls.fill.primary}
7
+ stroke={colors.surface} → className={cls.stroke.surface}
8
+
9
+ This script handles three known regression cases that arose when this
10
+ migration was first done by hand:
11
+
12
+ 1. Duplicate className=
13
+ Some elements already have a className attribute. The migration
14
+ would add a second one. JSX silently keeps only the second
15
+ (the original, now stale). We detect duplicates and merge them.
16
+
17
+ 2. Missing cls import
18
+ The script adds cls.* references but doesn't update the import
19
+ line. Build fails with undefined `cls`. We add `cls` to any
20
+ tokens import line that doesn't already have it.
21
+
22
+ 3. Variable-bound fills
23
+ The script only matches the literal pattern `fill={colors.X}`.
24
+ Variable-bound fills like `fill={labelColor}`,
25
+ `fill={fillByVariant[variant]}`, or ternary fills are left
26
+ alone and reported for manual review.
27
+
28
+ Idempotent: running twice on already-migrated code produces no changes.
29
+
30
+ Usage:
31
+ python3 bulk-fill-migration.py <directory> [--dry-run]
32
+
33
+ Output: JSON to stdout
34
+ {
35
+ "filesScanned": int,
36
+ "filesModified": [...],
37
+ "needsManualReview": [{file, line, snippet}],
38
+ "regressions": [...]
39
+ }
40
+ """
41
+
42
+ import argparse
43
+ import json
44
+ import re
45
+ import sys
46
+ from pathlib import Path
47
+
48
+
49
+ # ---------- Patterns ----------
50
+
51
+ # Match `fill={colors.X}` or `stroke={colors.X}` where X is a simple identifier.
52
+ # This intentionally does NOT match `fill={colors[key]}`, ternaries, or variables.
53
+ LITERAL_FILL = re.compile(r"\bfill=\{colors\.([a-zA-Z][a-zA-Z0-9]*)\}")
54
+ LITERAL_STROKE = re.compile(r"\bstroke=\{colors\.([a-zA-Z][a-zA-Z0-9]*)\}")
55
+
56
+ # Match variable-bound or ternary fills/strokes — flagged for manual review
57
+ VARIABLE_FILL = re.compile(
58
+ r"\b(fill|stroke)=\{(?!colors\.[a-zA-Z][a-zA-Z0-9]*\})([^}]+)\}"
59
+ )
60
+
61
+ # Match the tokens import line so we can ensure `cls` is included
62
+ TOKENS_IMPORT = re.compile(
63
+ r"import\s*\{\s*([^}]+?)\s*\}\s*from\s*['\"]([^'\"]*tokens[^'\"]*)['\"]"
64
+ )
65
+
66
+
67
+ # ---------- Migration logic ----------
68
+
69
+
70
+ def migrate_content(content: str) -> tuple[str, dict]:
71
+ """Apply the migration to a file's content. Returns (new_content, info)."""
72
+ info = {
73
+ "literal_fill_replacements": 0,
74
+ "literal_stroke_replacements": 0,
75
+ "variable_fills_found": [],
76
+ "duplicate_classnames_merged": 0,
77
+ "import_updated": False,
78
+ }
79
+
80
+ # Step 1: replace literal fill={colors.X} → className={cls.fill.X}
81
+ def replace_fill(match):
82
+ info["literal_fill_replacements"] += 1
83
+ return f"className={{cls.fill.{match.group(1)}}}"
84
+
85
+ def replace_stroke(match):
86
+ info["literal_stroke_replacements"] += 1
87
+ return f"className={{cls.stroke.{match.group(1)}}}"
88
+
89
+ content = LITERAL_FILL.sub(replace_fill, content)
90
+ content = LITERAL_STROKE.sub(replace_stroke, content)
91
+
92
+ # Step 2: detect variable-bound fills/strokes for manual review
93
+ for line_num, line in enumerate(content.split("\n"), start=1):
94
+ for m in VARIABLE_FILL.finditer(line):
95
+ info["variable_fills_found"].append(
96
+ {"line": line_num, "snippet": line.strip()[:200]}
97
+ )
98
+
99
+ # Step 3: merge duplicate className= attributes on the same element
100
+ content, dup_count = merge_duplicate_classnames(content)
101
+ info["duplicate_classnames_merged"] = dup_count
102
+
103
+ # Step 4: ensure `cls` is in the tokens import
104
+ if "cls.fill" in content or "cls.stroke" in content:
105
+ content, import_updated = ensure_cls_import(content)
106
+ info["import_updated"] = import_updated
107
+
108
+ return content, info
109
+
110
+
111
+ def merge_duplicate_classnames(content: str) -> tuple[str, int]:
112
+ """
113
+ Merge multiple className= attributes on the same JSX element.
114
+
115
+ Walks the content character by character to find JSX opening tags,
116
+ then merges any duplicate className= attributes inside them.
117
+ """
118
+ result = []
119
+ i = 0
120
+ merge_count = 0
121
+ n = len(content)
122
+
123
+ while i < n:
124
+ # Look for the start of a JSX opening tag
125
+ if content[i] == "<" and i + 1 < n and (content[i + 1].isalpha() or content[i + 1] == "_"):
126
+ # Find the end of the tag (closing > or />, balancing braces)
127
+ tag_start = i
128
+ tag_end = find_tag_end(content, i)
129
+ if tag_end is None:
130
+ result.append(content[i])
131
+ i += 1
132
+ continue
133
+ tag_text = content[tag_start:tag_end + 1]
134
+ merged_tag, merged_here = merge_classnames_in_tag(tag_text)
135
+ merge_count += merged_here
136
+ result.append(merged_tag)
137
+ i = tag_end + 1
138
+ else:
139
+ result.append(content[i])
140
+ i += 1
141
+
142
+ return "".join(result), merge_count
143
+
144
+
145
+ def find_tag_end(content: str, start: int) -> int | None:
146
+ """Find the closing > of a JSX tag starting at `start`, balancing braces."""
147
+ i = start
148
+ n = len(content)
149
+ brace_depth = 0
150
+ in_string = None # None, '"', or "'"
151
+ while i < n:
152
+ c = content[i]
153
+ if in_string:
154
+ if c == "\\":
155
+ i += 2
156
+ continue
157
+ if c == in_string:
158
+ in_string = None
159
+ elif c in ("'", '"'):
160
+ in_string = c
161
+ elif c == "{":
162
+ brace_depth += 1
163
+ elif c == "}":
164
+ brace_depth -= 1
165
+ elif c == ">" and brace_depth == 0:
166
+ return i
167
+ i += 1
168
+ return None
169
+
170
+
171
+ def merge_classnames_in_tag(tag_text: str) -> tuple[str, int]:
172
+ """
173
+ If the tag has multiple className= attributes, merge them into one.
174
+ Returns (new_tag, merge_count_added).
175
+ """
176
+ # Find all className={...} occurrences, balancing braces.
177
+ matches = list(find_classname_attrs(tag_text))
178
+ if len(matches) < 2:
179
+ return tag_text, 0
180
+
181
+ # Extract class names from each match (everything inside {...})
182
+ class_values = []
183
+ for start, end in matches:
184
+ inside = tag_text[start:end + 1]
185
+ # className={...} → ...
186
+ value = inside[len("className=") + 1:-1]
187
+ class_values.append(value)
188
+
189
+ # Build the merged value: combine into a single template literal
190
+ # if any value is dynamic, otherwise concatenate string literals
191
+ if all(is_string_literal(v) for v in class_values):
192
+ joined = " ".join(strip_quotes(v) for v in class_values)
193
+ merged = f'className="{joined}"'
194
+ else:
195
+ # Use template literal to combine: `${a} ${b}`
196
+ parts = [to_template_part(v) for v in class_values]
197
+ merged = "className={`" + " ".join(parts) + "`}"
198
+
199
+ # Replace: keep first slot, remove subsequent ones
200
+ first_start, first_end = matches[0]
201
+ new_tag = tag_text[:first_start] + merged + tag_text[first_end + 1:]
202
+ # Walk backwards to remove the rest (positions are now stale, so re-find)
203
+ # Easier: rebuild from scratch by re-running on the new text
204
+ if len(matches) > 2:
205
+ new_tag, _ = merge_classnames_in_tag(new_tag)
206
+
207
+ # Remove any remaining duplicate className attribute
208
+ second_attrs = list(find_classname_attrs(new_tag))
209
+ while len(second_attrs) > 1:
210
+ # Remove the second one
211
+ s, e = second_attrs[1]
212
+ # Also remove leading whitespace before it
213
+ ws_start = s
214
+ while ws_start > 0 and new_tag[ws_start - 1] in " \t":
215
+ ws_start -= 1
216
+ new_tag = new_tag[:ws_start] + new_tag[e + 1:]
217
+ second_attrs = list(find_classname_attrs(new_tag))
218
+
219
+ return new_tag, len(matches) - 1
220
+
221
+
222
+ def find_classname_attrs(tag_text: str):
223
+ """Yield (start, end) for each className= attribute in a tag."""
224
+ i = 0
225
+ n = len(tag_text)
226
+ while i < n:
227
+ idx = tag_text.find("className=", i)
228
+ if idx == -1:
229
+ return
230
+ # Make sure it's a real attribute (preceded by whitespace or <)
231
+ if idx > 0 and tag_text[idx - 1] not in " \t\n<":
232
+ i = idx + 1
233
+ continue
234
+ value_start = idx + len("className=")
235
+ if value_start >= n:
236
+ return
237
+ end = find_attr_value_end(tag_text, value_start)
238
+ if end is None:
239
+ return
240
+ yield (idx, end)
241
+ i = end + 1
242
+
243
+
244
+ def find_attr_value_end(tag_text: str, start: int) -> int | None:
245
+ """Find the end of an attribute value starting at `start`."""
246
+ n = len(tag_text)
247
+ if start >= n:
248
+ return None
249
+ c = tag_text[start]
250
+ if c == '"' or c == "'":
251
+ # String value
252
+ i = start + 1
253
+ while i < n:
254
+ if tag_text[i] == "\\":
255
+ i += 2
256
+ continue
257
+ if tag_text[i] == c:
258
+ return i
259
+ i += 1
260
+ return None
261
+ if c == "{":
262
+ # Brace expression — balance
263
+ depth = 1
264
+ i = start + 1
265
+ in_string = None
266
+ while i < n:
267
+ ch = tag_text[i]
268
+ if in_string:
269
+ if ch == "\\":
270
+ i += 2
271
+ continue
272
+ if ch == in_string:
273
+ in_string = None
274
+ elif ch in ("'", '"', "`"):
275
+ in_string = ch
276
+ elif ch == "{":
277
+ depth += 1
278
+ elif ch == "}":
279
+ depth -= 1
280
+ if depth == 0:
281
+ return i
282
+ i += 1
283
+ return None
284
+ return None
285
+
286
+
287
+ def is_string_literal(value: str) -> bool:
288
+ """True if value is a string literal like "foo" or 'foo'."""
289
+ v = value.strip()
290
+ return (
291
+ len(v) >= 2
292
+ and v[0] == v[-1]
293
+ and v[0] in ("'", '"')
294
+ )
295
+
296
+
297
+ def strip_quotes(value: str) -> str:
298
+ v = value.strip()
299
+ return v[1:-1]
300
+
301
+
302
+ def to_template_part(value: str) -> str:
303
+ """Convert an attribute value into a template literal interpolation."""
304
+ v = value.strip()
305
+ if is_string_literal(v):
306
+ return strip_quotes(v)
307
+ return "${" + v + "}"
308
+
309
+
310
+ def ensure_cls_import(content: str) -> tuple[str, bool]:
311
+ """Ensure `cls` is imported from any tokens import line."""
312
+ updated = False
313
+
314
+ def replace_import(match):
315
+ nonlocal updated
316
+ names = [n.strip() for n in match.group(1).split(",")]
317
+ if "cls" in names:
318
+ return match.group(0)
319
+ names.append("cls")
320
+ updated = True
321
+ return f"import {{ {', '.join(names)} }} from '{match.group(2)}'"
322
+
323
+ new_content = TOKENS_IMPORT.sub(replace_import, content)
324
+ return new_content, updated
325
+
326
+
327
+ # ---------- Walk and apply ----------
328
+
329
+
330
+ def main():
331
+ parser = argparse.ArgumentParser(description=__doc__)
332
+ parser.add_argument("directory", help="Directory containing chapter component files")
333
+ parser.add_argument("--dry-run", action="store_true", help="Report changes without writing")
334
+ args = parser.parse_args()
335
+
336
+ root = Path(args.directory)
337
+ if not root.exists():
338
+ print(f"Error: directory not found: {root}", file=sys.stderr)
339
+ sys.exit(2)
340
+
341
+ files_scanned = 0
342
+ files_modified = []
343
+ needs_manual_review = []
344
+
345
+ for path in root.rglob("*.tsx"):
346
+ files_scanned += 1
347
+ try:
348
+ original = path.read_text(encoding="utf-8")
349
+ except Exception as e:
350
+ print(f"Warning: could not read {path}: {e}", file=sys.stderr)
351
+ continue
352
+
353
+ new_content, info = migrate_content(original)
354
+
355
+ if info["variable_fills_found"]:
356
+ for finding in info["variable_fills_found"]:
357
+ needs_manual_review.append(
358
+ {
359
+ "file": str(path.relative_to(root)),
360
+ "line": finding["line"],
361
+ "snippet": finding["snippet"],
362
+ }
363
+ )
364
+
365
+ if new_content != original:
366
+ files_modified.append(
367
+ {
368
+ "file": str(path.relative_to(root)),
369
+ "literal_fill_replacements": info["literal_fill_replacements"],
370
+ "literal_stroke_replacements": info["literal_stroke_replacements"],
371
+ "duplicate_classnames_merged": info["duplicate_classnames_merged"],
372
+ "import_updated": info["import_updated"],
373
+ }
374
+ )
375
+ if not args.dry_run:
376
+ path.write_text(new_content, encoding="utf-8")
377
+
378
+ result = {
379
+ "filesScanned": files_scanned,
380
+ "filesModified": files_modified,
381
+ "needsManualReview": needs_manual_review,
382
+ "summary": {
383
+ "scanned": files_scanned,
384
+ "modified": len(files_modified),
385
+ "manualReviewCount": len(needs_manual_review),
386
+ "dryRun": args.dry_run,
387
+ },
388
+ }
389
+ print(json.dumps(result, indent=2))
390
+
391
+
392
+ if __name__ == "__main__":
393
+ main()
@@ -0,0 +1,159 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * forbidden-word-grep.mjs — sweep documentation for user-supplied words
4
+ * the documentation should avoid.
5
+ *
6
+ * The word list comes from the Phase 1 language-constraints answer in
7
+ * the spec. Each project supplies its own list — there is no default.
8
+ *
9
+ * Usage:
10
+ * node forbidden-word-grep.mjs <docs-path> <wordlist.json> [--word-boundary] [--case-sensitive]
11
+ *
12
+ * Word list format (JSON):
13
+ * ["word1", "word2", "phrase three"]
14
+ *
15
+ * Or with metadata per word:
16
+ * [
17
+ * {"word": "grounded", "reason": "implies the design was causally grounded in research"},
18
+ * {"word": "leverage", "reason": "corporate filler"}
19
+ * ]
20
+ *
21
+ * Output: JSON to stdout
22
+ * {
23
+ * wordsChecked: [...],
24
+ * hits: [{file, line, word, context, reason}],
25
+ * summary: {fileCount, hitCount}
26
+ * }
27
+ *
28
+ * Exit code: 0 if no hits, 1 if hits found.
29
+ */
30
+
31
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
32
+ import { join, relative } from 'node:path';
33
+
34
+ // ---------- CLI parsing ----------
35
+
36
+ const args = process.argv.slice(2);
37
+ if (args.length < 2) {
38
+ console.error('Usage: forbidden-word-grep.mjs <docs-path> <wordlist.json> [--word-boundary] [--case-sensitive]');
39
+ process.exit(1);
40
+ }
41
+
42
+ const docsPath = args[0];
43
+ const wordlistPath = args[1];
44
+ const wordBoundary = args.includes('--word-boundary');
45
+ const caseSensitive = args.includes('--case-sensitive');
46
+
47
+ // ---------- Word list parsing ----------
48
+
49
+ let wordlistRaw;
50
+ try {
51
+ wordlistRaw = JSON.parse(readFileSync(wordlistPath, 'utf8'));
52
+ } catch (err) {
53
+ console.error(`Failed to read word list: ${err.message}`);
54
+ process.exit(2);
55
+ }
56
+
57
+ if (!Array.isArray(wordlistRaw)) {
58
+ console.error('Word list must be a JSON array');
59
+ process.exit(2);
60
+ }
61
+
62
+ // Normalize to {word, reason} entries
63
+ const wordEntries = wordlistRaw.map((entry) => {
64
+ if (typeof entry === 'string') {
65
+ return { word: entry, reason: null };
66
+ }
67
+ if (typeof entry === 'object' && entry !== null && typeof entry.word === 'string') {
68
+ return { word: entry.word, reason: entry.reason ?? null };
69
+ }
70
+ console.error(`Invalid word list entry: ${JSON.stringify(entry)}`);
71
+ process.exit(2);
72
+ });
73
+
74
+ if (wordEntries.length === 0) {
75
+ console.log(JSON.stringify({ wordsChecked: [], hits: [], summary: { fileCount: 0, hitCount: 0 } }, null, 2));
76
+ process.exit(0);
77
+ }
78
+
79
+ // ---------- Doc walking and grepping ----------
80
+
81
+ const hits = [];
82
+ let fileCount = 0;
83
+
84
+ function walkDocs(dir) {
85
+ let entries;
86
+ try {
87
+ entries = readdirSync(dir);
88
+ } catch {
89
+ return;
90
+ }
91
+ for (const entry of entries) {
92
+ const full = join(dir, entry);
93
+ let stat;
94
+ try {
95
+ stat = statSync(full);
96
+ } catch {
97
+ continue;
98
+ }
99
+ if (stat.isDirectory()) {
100
+ walkDocs(full);
101
+ } else if (/\.(md|mdx)$/.test(entry)) {
102
+ grepFile(full);
103
+ fileCount++;
104
+ }
105
+ }
106
+ }
107
+
108
+ function grepFile(filePath) {
109
+ let content;
110
+ try {
111
+ content = readFileSync(filePath, 'utf8');
112
+ } catch {
113
+ return;
114
+ }
115
+ const lines = content.split('\n');
116
+ const relPath = relative(process.cwd(), filePath);
117
+
118
+ let inCodeBlock = false;
119
+ for (let i = 0; i < lines.length; i++) {
120
+ const line = lines[i];
121
+ if (line.trim().startsWith('```')) {
122
+ inCodeBlock = !inCodeBlock;
123
+ continue;
124
+ }
125
+ if (inCodeBlock) continue; // Don't flag inside code blocks
126
+
127
+ for (const entry of wordEntries) {
128
+ const escaped = entry.word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
129
+ const pattern = wordBoundary ? `\\b${escaped}\\b` : escaped;
130
+ const flags = caseSensitive ? '' : 'i';
131
+ const regex = new RegExp(pattern, flags);
132
+ if (regex.test(line)) {
133
+ hits.push({
134
+ file: relPath,
135
+ line: i + 1,
136
+ word: entry.word,
137
+ reason: entry.reason,
138
+ context: line.trim().slice(0, 200),
139
+ });
140
+ }
141
+ }
142
+ }
143
+ }
144
+
145
+ // ---------- Run ----------
146
+
147
+ walkDocs(docsPath);
148
+
149
+ const result = {
150
+ wordsChecked: wordEntries.map((e) => e.word),
151
+ hits,
152
+ summary: {
153
+ fileCount,
154
+ hitCount: hits.length,
155
+ },
156
+ };
157
+
158
+ console.log(JSON.stringify(result, null, 2));
159
+ process.exit(hits.length > 0 ? 1 : 0);