@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.
- package/LICENSE +21 -0
- package/README.md +108 -0
- package/bin/create.mjs +79 -0
- package/lib/git.mjs +26 -0
- package/lib/init.mjs +129 -0
- package/lib/payload.mjs +44 -0
- package/lib/prompts.mjs +113 -0
- package/lib/scaffold.mjs +84 -0
- package/lib/upgrade.mjs +42 -0
- package/package.json +43 -0
- package/template/.claude/agents/aside-researcher.md +48 -0
- package/template/.claude/agents/implementer.md +39 -0
- package/template/.claude/agents/researcher.md +40 -0
- package/template/.claude/agents/reviewer.md +47 -0
- package/template/.claude/hooks/_utils.mjs +196 -0
- package/template/.claude/hooks/_utils.test.mjs +99 -0
- package/template/.claude/hooks/post-compact.mjs +7 -0
- package/template/.claude/hooks/pre-compact.mjs +34 -0
- package/template/.claude/hooks/repo-write-detection.mjs +107 -0
- package/template/.claude/hooks/session-end.mjs +91 -0
- package/template/.claude/hooks/session-start.mjs +150 -0
- package/template/.claude/hooks/subagent-start.mjs +44 -0
- package/template/.claude/hooks/workspace-update-check.mjs +42 -0
- package/template/.claude/hooks/worktree-create.mjs +53 -0
- package/template/.claude/lib/session-frontmatter.mjs +265 -0
- package/template/.claude/lib/session-frontmatter.test.mjs +242 -0
- package/template/.claude/recipes/migrate-from-notion.md +120 -0
- package/template/.claude/rules/agent-rules.md.skip +32 -0
- package/template/.claude/rules/cloud-infrastructure.md.skip +15 -0
- package/template/.claude/rules/coherent-revisions.md +24 -0
- package/template/.claude/rules/documentation.md.skip +13 -0
- package/template/.claude/rules/git-conventions.md +34 -0
- package/template/.claude/rules/honest-pushback.md +56 -0
- package/template/.claude/rules/local-dev-environment.md.skip +60 -0
- package/template/.claude/rules/memory-guidance.md +26 -0
- package/template/.claude/rules/product-integrity.md.skip +24 -0
- package/template/.claude/rules/scope-guard.md.skip +22 -0
- package/template/.claude/rules/superpowers-workflow.md.skip +22 -0
- package/template/.claude/rules/token-economics.md.skip +31 -0
- package/template/.claude/rules/work-item-tracking.md +90 -0
- package/template/.claude/rules/workspace-structure.md +69 -0
- package/template/.claude/scripts/add-repo-to-session.mjs +78 -0
- package/template/.claude/scripts/cleanup-work-session.mjs +108 -0
- package/template/.claude/scripts/create-work-session.mjs +124 -0
- package/template/.claude/scripts/migrate-open-work.mjs +91 -0
- package/template/.claude/scripts/migrate-session-layout.mjs +236 -0
- package/template/.claude/scripts/migrate-session-layout.test.mjs +144 -0
- package/template/.claude/scripts/trackers/github-issues.mjs +170 -0
- package/template/.claude/scripts/trackers/github-issues.test.mjs +190 -0
- package/template/.claude/scripts/trackers/interface.mjs +25 -0
- package/template/.claude/scripts/trackers/interface.test.mjs +40 -0
- package/template/.claude/settings.json +107 -0
- package/template/.claude/skills/aside/SKILL.md +125 -0
- package/template/.claude/skills/braindump/SKILL.md +96 -0
- package/template/.claude/skills/build-docs-site/SKILL.md +323 -0
- package/template/.claude/skills/build-docs-site/checklists/framing.md +221 -0
- package/template/.claude/skills/build-docs-site/checklists/pitfalls.md +228 -0
- package/template/.claude/skills/build-docs-site/checklists/review.md +130 -0
- package/template/.claude/skills/build-docs-site/scripts/bulk-fill-migration.py +393 -0
- package/template/.claude/skills/build-docs-site/scripts/forbidden-word-grep.mjs +159 -0
- package/template/.claude/skills/build-docs-site/scripts/leak-grep.mjs +328 -0
- package/template/.claude/skills/build-docs-site/templates/custom.css.tmpl +212 -0
- package/template/.claude/skills/build-docs-site/templates/docusaurus.config.ts.tmpl +95 -0
- package/template/.claude/skills/build-docs-site/templates/primitives/Arrow.tsx +87 -0
- package/template/.claude/skills/build-docs-site/templates/primitives/Box.tsx +90 -0
- package/template/.claude/skills/build-docs-site/templates/primitives/DiagramContainer.tsx +46 -0
- package/template/.claude/skills/build-docs-site/templates/primitives/Region.tsx +68 -0
- package/template/.claude/skills/build-docs-site/templates/primitives/SectionTitle.tsx +42 -0
- package/template/.claude/skills/build-docs-site/templates/primitives/tokens.ts +67 -0
- package/template/.claude/skills/build-docs-site/templates/sidebars.ts.tmpl +89 -0
- package/template/.claude/skills/build-docs-site/templates/spec.md.tmpl +119 -0
- package/template/.claude/skills/complete-work/SKILL.md +369 -0
- package/template/.claude/skills/handoff/SKILL.md +98 -0
- package/template/.claude/skills/maintenance/SKILL.md +116 -0
- package/template/.claude/skills/pause-work/SKILL.md +98 -0
- package/template/.claude/skills/promote/SKILL.md +77 -0
- package/template/.claude/skills/release/SKILL.md +126 -0
- package/template/.claude/skills/setup-tracker/SKILL.md +117 -0
- package/template/.claude/skills/start-work/SKILL.md +234 -0
- package/template/.claude/skills/sync-work/SKILL.md +73 -0
- package/template/.claude/skills/workspace-init/SKILL.md +420 -0
- package/template/.claude/skills/workspace-update/SKILL.md +108 -0
- package/template/.mcp.json +12 -0
- package/template/CLAUDE.md.tmpl +32 -0
- package/template/_gitignore +28 -0
- 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);
|