@ulysses-ai/create-workspace 0.14.0-beta.3 → 0.15.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/package.json +1 -1
- package/template/.claude/agents/reviewer.md +1 -1
- package/template/.claude/hooks/pre-compact.mjs +1 -1
- package/template/.claude/hooks/repo-write-detection.mjs +2 -2
- package/template/.claude/hooks/session-start.mjs +10 -7
- package/template/.claude/hooks/subagent-start.mjs +3 -3
- package/template/.claude/recipes/migrate-from-notion.md +6 -6
- package/template/.claude/rules/coherent-revisions.md +2 -2
- package/template/.claude/rules/local-dev-environment.md.skip +2 -2
- package/template/.claude/rules/memory-guidance.md +20 -14
- package/template/.claude/rules/token-economics.md.skip +2 -2
- package/template/.claude/rules/work-item-tracking.md +1 -1
- package/template/.claude/rules/workspace-structure.md +36 -15
- package/template/.claude/scripts/build-workspace-context.mjs +365 -0
- package/template/.claude/scripts/build-workspace-context.test.mjs +633 -0
- package/template/.claude/scripts/capture-context.mjs +217 -0
- package/template/.claude/scripts/capture-context.test.mjs +383 -0
- package/template/.claude/scripts/generate-claude-local.mjs +104 -0
- package/template/.claude/scripts/generate-claude-local.test.mjs +184 -0
- package/template/.claude/scripts/migrate-open-work.mjs +1 -1
- package/template/.claude/scripts/migrate-to-workspace-context.mjs +520 -0
- package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +325 -0
- package/template/.claude/scripts/sweep-references.mjs +177 -0
- package/template/.claude/scripts/sweep-references.test.mjs +184 -0
- package/template/.claude/skills/aside/SKILL.md +49 -44
- package/template/.claude/skills/braindump/SKILL.md +25 -19
- package/template/.claude/skills/build-docs-site/SKILL.md +1 -1
- package/template/.claude/skills/build-docs-site/checklists/framing.md +1 -1
- package/template/.claude/skills/complete-work/SKILL.md +3 -3
- package/template/.claude/skills/handoff/SKILL.md +31 -30
- package/template/.claude/skills/maintenance/SKILL.md +18 -18
- package/template/.claude/skills/pause-work/SKILL.md +1 -1
- package/template/.claude/skills/promote/SKILL.md +18 -8
- package/template/.claude/skills/release/SKILL.md +17 -13
- package/template/.claude/skills/start-work/SKILL.md +1 -1
- package/template/.claude/skills/workspace-init/SKILL.md +12 -12
- package/template/CLAUDE.md.tmpl +4 -3
- package/template/_gitignore +1 -0
- package/template/workspace.json.tmpl +2 -2
- package/template/.claude/scripts/build-shared-context-index.mjs +0 -212
- package/template/.claude/scripts/build-shared-context-index.test.mjs +0 -318
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for migrate-to-workspace-context.mjs
|
|
3
|
+
// Run: node template/.claude/scripts/migrate-to-workspace-context.test.mjs
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
mkdtempSync,
|
|
7
|
+
mkdirSync,
|
|
8
|
+
writeFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
existsSync,
|
|
12
|
+
cpSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { migrate, dirLooksLikeUserScope } from './migrate-to-workspace-context.mjs';
|
|
18
|
+
|
|
19
|
+
let failed = 0;
|
|
20
|
+
let passed = 0;
|
|
21
|
+
|
|
22
|
+
function assert(cond, msg) {
|
|
23
|
+
if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function assertEq(actual, expected, msg) {
|
|
27
|
+
const a = JSON.stringify(actual);
|
|
28
|
+
const e = JSON.stringify(expected);
|
|
29
|
+
if (a === e) { passed++; } else {
|
|
30
|
+
failed++;
|
|
31
|
+
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function setupGitRoot() {
|
|
36
|
+
const root = mkdtempSync(join(tmpdir(), 'mig-test-'));
|
|
37
|
+
spawnSync('git', ['init', '-q'], { cwd: root });
|
|
38
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: root });
|
|
39
|
+
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: root });
|
|
40
|
+
// Install the generator and capture-context scripts so step12 can run
|
|
41
|
+
mkdirSync(join(root, '.claude', 'scripts'), { recursive: true });
|
|
42
|
+
mkdirSync(join(root, '.claude', 'lib'), { recursive: true });
|
|
43
|
+
cpSync(
|
|
44
|
+
new URL('./build-workspace-context.mjs', import.meta.url).pathname,
|
|
45
|
+
join(root, '.claude', 'scripts', 'build-workspace-context.mjs'),
|
|
46
|
+
);
|
|
47
|
+
cpSync(
|
|
48
|
+
new URL('./generate-claude-local.mjs', import.meta.url).pathname,
|
|
49
|
+
join(root, '.claude', 'scripts', 'generate-claude-local.mjs'),
|
|
50
|
+
);
|
|
51
|
+
cpSync(
|
|
52
|
+
new URL('../lib/session-frontmatter.mjs', import.meta.url).pathname,
|
|
53
|
+
join(root, '.claude', 'lib', 'session-frontmatter.mjs'),
|
|
54
|
+
);
|
|
55
|
+
// Configure user for CLAUDE.local.md generation
|
|
56
|
+
writeFileSync(
|
|
57
|
+
join(root, '.claude', 'settings.local.json'),
|
|
58
|
+
JSON.stringify({ workspace: { user: 'alice' } }),
|
|
59
|
+
);
|
|
60
|
+
return root;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function cleanup(root) {
|
|
64
|
+
rmSync(root, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function writeFM(path, fm, body = 'body\n') {
|
|
68
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
69
|
+
const yaml = Object.entries(fm).map(([k, v]) => `${k}: ${v}`).join('\n');
|
|
70
|
+
writeFileSync(path, `---\n${yaml}\n---\n\n${body}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function gitAddCommit(root) {
|
|
74
|
+
spawnSync('git', ['add', '-A'], { cwd: root });
|
|
75
|
+
spawnSync('git', ['commit', '-q', '-m', 'fixture'], { cwd: root });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function summarize(results) {
|
|
79
|
+
return Object.fromEntries(results.map((r) => [r.name, r.status]));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
console.log('# fresh template (no shared-context, no release-notes)');
|
|
83
|
+
|
|
84
|
+
{
|
|
85
|
+
const root = setupGitRoot();
|
|
86
|
+
// Just CLAUDE.md and workspace.json present
|
|
87
|
+
writeFileSync(
|
|
88
|
+
join(root, 'CLAUDE.md'),
|
|
89
|
+
'# Fresh\n\n## Workspace Config\n@workspace.json\n',
|
|
90
|
+
);
|
|
91
|
+
writeFileSync(
|
|
92
|
+
join(root, 'workspace.json'),
|
|
93
|
+
JSON.stringify({ workspace: { name: 'fresh' } }, null, 2) + '\n',
|
|
94
|
+
);
|
|
95
|
+
gitAddCommit(root);
|
|
96
|
+
|
|
97
|
+
const results = migrate(root);
|
|
98
|
+
const s = summarize(results);
|
|
99
|
+
assertEq(s['rename-shared-context'], 'noop', 'no shared-context to migrate');
|
|
100
|
+
assertEq(s['consolidate-locked'], 'noop', 'noop on fresh');
|
|
101
|
+
assertEq(s['move-release-notes'], 'noop', 'no release-notes to move');
|
|
102
|
+
// No source dirs, but workspace.json still gets the new fields
|
|
103
|
+
assertEq(s['update-workspace-json'], 'applied', 'workspace.json gets workspaceContextDir');
|
|
104
|
+
cleanup(root);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
console.log('# full migration of pre-v0.15 layout');
|
|
108
|
+
|
|
109
|
+
{
|
|
110
|
+
const root = setupGitRoot();
|
|
111
|
+
// Old layout
|
|
112
|
+
writeFM(
|
|
113
|
+
join(root, OLD_FILE('locked', 'naming.md')),
|
|
114
|
+
{ state: 'locked', type: 'reference', topic: 'naming' },
|
|
115
|
+
'# Naming\n',
|
|
116
|
+
);
|
|
117
|
+
writeFM(
|
|
118
|
+
join(root, OLD_FILE('inventory.md')),
|
|
119
|
+
{ state: 'ephemeral', type: 'reference', description: 'Inventory.' },
|
|
120
|
+
'# Inventory\n',
|
|
121
|
+
);
|
|
122
|
+
writeFM(
|
|
123
|
+
join(root, OLD_FILE('alice', 'mythoughts.md')),
|
|
124
|
+
{ state: 'ephemeral', type: 'braindump', topic: 'mythoughts', author: 'alice' },
|
|
125
|
+
'# Thoughts\n',
|
|
126
|
+
);
|
|
127
|
+
writeFM(
|
|
128
|
+
join(root, OLD_FILE('product-x', 'feature-design.md')),
|
|
129
|
+
{ state: 'ephemeral', type: 'design', description: 'Feature design.' },
|
|
130
|
+
'design\n',
|
|
131
|
+
);
|
|
132
|
+
// release-notes at root
|
|
133
|
+
mkdirSync(join(root, 'release-notes', 'unreleased'), { recursive: true });
|
|
134
|
+
writeFileSync(
|
|
135
|
+
join(root, 'release-notes', 'unreleased', 'note.md'),
|
|
136
|
+
'## Note\n',
|
|
137
|
+
);
|
|
138
|
+
// CLAUDE.md with broken import
|
|
139
|
+
writeFileSync(
|
|
140
|
+
join(root, 'CLAUDE.md'),
|
|
141
|
+
'# Workspace\n\n## Team Knowledge (always loaded)\n@shared-context/locked/\n',
|
|
142
|
+
);
|
|
143
|
+
// workspace.json with old field
|
|
144
|
+
writeFileSync(
|
|
145
|
+
join(root, 'workspace.json'),
|
|
146
|
+
JSON.stringify({
|
|
147
|
+
workspace: {
|
|
148
|
+
name: 'demo',
|
|
149
|
+
sharedContextDir: 'shared-context',
|
|
150
|
+
releaseNotesDir: 'release-notes',
|
|
151
|
+
},
|
|
152
|
+
}, null, 2) + '\n',
|
|
153
|
+
);
|
|
154
|
+
// .indexignore with legacy header
|
|
155
|
+
writeFileSync(
|
|
156
|
+
join(root, OLD_FILE('.indexignore')),
|
|
157
|
+
'# Shared-context paths excluded from shared-context/index.md.\nscaffolder-release-history/\n',
|
|
158
|
+
);
|
|
159
|
+
gitAddCommit(root);
|
|
160
|
+
|
|
161
|
+
const results = migrate(root);
|
|
162
|
+
const s = summarize(results);
|
|
163
|
+
|
|
164
|
+
// Step 1
|
|
165
|
+
assertEq(s['rename-shared-context'], 'applied', 'shared-context renamed');
|
|
166
|
+
assert(existsSync(join(root, 'workspace-context')), 'workspace-context dir present');
|
|
167
|
+
assert(!existsSync(join(root, 'shared-context')), 'shared-context dir gone');
|
|
168
|
+
|
|
169
|
+
// Step 2: locked moved into shared/
|
|
170
|
+
assert(
|
|
171
|
+
existsSync(join(root, 'workspace-context', 'shared', 'locked', 'naming.md')),
|
|
172
|
+
'locked file under shared/locked/',
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Step 3: root .md moved into shared/
|
|
176
|
+
assert(
|
|
177
|
+
existsSync(join(root, 'workspace-context', 'shared', 'inventory.md')),
|
|
178
|
+
'root md moved to shared/',
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Step 4: alice (user dir, has author: alice) → team-member/alice/
|
|
182
|
+
assert(
|
|
183
|
+
existsSync(join(root, 'workspace-context', 'team-member', 'alice', 'mythoughts.md'))
|
|
184
|
+
// post-step5 it may be renamed to braindump_mythoughts.md; check both
|
|
185
|
+
|| existsSync(join(root, 'workspace-context', 'team-member', 'alice', 'braindump_mythoughts.md')),
|
|
186
|
+
'alice dir → team-member/alice/',
|
|
187
|
+
);
|
|
188
|
+
// product-x (no author match) → shared/product-x/
|
|
189
|
+
assert(
|
|
190
|
+
existsSync(join(root, 'workspace-context', 'shared', 'product-x', 'feature-design.md')),
|
|
191
|
+
'product-x dir → shared/product-x/',
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
// Step 5: braindump file got prefixed
|
|
195
|
+
assert(
|
|
196
|
+
existsSync(join(root, 'workspace-context', 'team-member', 'alice', 'braindump_mythoughts.md')),
|
|
197
|
+
'braindump_ prefix applied',
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
// Step 6: release-notes moved
|
|
201
|
+
assert(
|
|
202
|
+
existsSync(join(root, 'workspace-context', 'release-notes', 'unreleased', 'note.md')),
|
|
203
|
+
'release-notes moved under workspace-context/',
|
|
204
|
+
);
|
|
205
|
+
assert(!existsSync(join(root, 'release-notes')), 'old release-notes/ gone');
|
|
206
|
+
|
|
207
|
+
// Step 7: CLAUDE.md updated
|
|
208
|
+
const claudeMd = readFileSync(join(root, 'CLAUDE.md'), 'utf-8');
|
|
209
|
+
assert(claudeMd.includes('@workspace-context/canonical.md'), 'CLAUDE.md imports canonical');
|
|
210
|
+
assert(claudeMd.includes('@workspace-context/index.md'), 'CLAUDE.md imports index');
|
|
211
|
+
assert(!claudeMd.includes('@shared-context/locked/'), 'broken import gone');
|
|
212
|
+
|
|
213
|
+
// Step 8: workspace.json
|
|
214
|
+
const ws = JSON.parse(readFileSync(join(root, 'workspace.json'), 'utf-8'));
|
|
215
|
+
assertEq(ws.workspace.workspaceContextDir, 'workspace-context', 'workspaceContextDir set');
|
|
216
|
+
assert(!('sharedContextDir' in ws.workspace), 'sharedContextDir removed');
|
|
217
|
+
assertEq(ws.workspace.releaseNotesDir, 'workspace-context/release-notes', 'releaseNotesDir updated');
|
|
218
|
+
|
|
219
|
+
// Step 9: .indexignore
|
|
220
|
+
const ii = readFileSync(join(root, 'workspace-context', '.indexignore'), 'utf-8');
|
|
221
|
+
assert(ii.includes('workspace-context'), '.indexignore header updated');
|
|
222
|
+
// scaffolder-release-history/ stays at workspace-context root (it's reserved),
|
|
223
|
+
// so its .indexignore entry stays as a bare prefix relative to workspace-context/
|
|
224
|
+
assert(ii.includes('scaffolder-release-history/'), 'scaffolder entry preserved');
|
|
225
|
+
assert(!ii.includes('shared/scaffolder-release-history/'), 'no incorrect prefix-shift');
|
|
226
|
+
assert(ii.includes('release-notes/'), 'release-notes/ added');
|
|
227
|
+
|
|
228
|
+
// Step 10: CLAUDE.local.md
|
|
229
|
+
assert(existsSync(join(root, 'CLAUDE.local.md')), 'CLAUDE.local.md generated');
|
|
230
|
+
const local = readFileSync(join(root, 'CLAUDE.local.md'), 'utf-8');
|
|
231
|
+
assert(local.includes('alice/index.md'), 'CLAUDE.local.md has alice import');
|
|
232
|
+
|
|
233
|
+
// Step 12: auto-files exist
|
|
234
|
+
assert(existsSync(join(root, 'workspace-context', 'index.md')), 'index.md generated');
|
|
235
|
+
assert(existsSync(join(root, 'workspace-context', 'canonical.md')), 'canonical.md generated');
|
|
236
|
+
|
|
237
|
+
cleanup(root);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
console.log('# idempotence: re-running migration is a no-op');
|
|
241
|
+
|
|
242
|
+
{
|
|
243
|
+
const root = setupGitRoot();
|
|
244
|
+
writeFM(
|
|
245
|
+
join(root, OLD_FILE('locked', 'rule.md')),
|
|
246
|
+
{ state: 'locked', type: 'reference' },
|
|
247
|
+
'# Rule\n',
|
|
248
|
+
);
|
|
249
|
+
writeFileSync(join(root, 'CLAUDE.md'), '@shared-context/locked/\n');
|
|
250
|
+
writeFileSync(join(root, 'workspace.json'),
|
|
251
|
+
JSON.stringify({ workspace: { sharedContextDir: 'shared-context' } }, null, 2) + '\n',
|
|
252
|
+
);
|
|
253
|
+
gitAddCommit(root);
|
|
254
|
+
|
|
255
|
+
const first = migrate(root);
|
|
256
|
+
const firstApplied = first.filter((r) => r.status === 'applied').map((r) => r.name);
|
|
257
|
+
assert(firstApplied.length > 0, 'first run applied changes');
|
|
258
|
+
|
|
259
|
+
// Commit the migration so step5 can re-mv files
|
|
260
|
+
spawnSync('git', ['add', '-A'], { cwd: root });
|
|
261
|
+
spawnSync('git', ['commit', '-q', '-m', 'migrated'], { cwd: root });
|
|
262
|
+
|
|
263
|
+
const second = migrate(root);
|
|
264
|
+
const secondApplied = second.filter((r) => r.status === 'applied').map((r) => r.name);
|
|
265
|
+
// Step 12 re-runs the generator (it always applies if outputs differ from disk).
|
|
266
|
+
// Anything else applied a second time is a bug.
|
|
267
|
+
for (const name of secondApplied) {
|
|
268
|
+
assert(name === 'build-auto-files', `step "${name}" should be idempotent`);
|
|
269
|
+
}
|
|
270
|
+
cleanup(root);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.log('# dirLooksLikeUserScope heuristic');
|
|
274
|
+
|
|
275
|
+
{
|
|
276
|
+
const root = mkdtempSync(join(tmpdir(), 'mig-uscope-'));
|
|
277
|
+
// alice/ has a file with author: alice
|
|
278
|
+
mkdirSync(join(root, 'alice'), { recursive: true });
|
|
279
|
+
writeFM(
|
|
280
|
+
join(root, 'alice', 'note.md'),
|
|
281
|
+
{ type: 'braindump', author: 'alice' },
|
|
282
|
+
);
|
|
283
|
+
// bob/ has files but author is unknown
|
|
284
|
+
mkdirSync(join(root, 'bob'), { recursive: true });
|
|
285
|
+
writeFM(
|
|
286
|
+
join(root, 'bob', 'design.md'),
|
|
287
|
+
{ type: 'design' },
|
|
288
|
+
);
|
|
289
|
+
// empty dir
|
|
290
|
+
mkdirSync(join(root, 'empty'), { recursive: true });
|
|
291
|
+
|
|
292
|
+
assert(dirLooksLikeUserScope(join(root, 'alice'), 'alice'), 'alice dir detected as user');
|
|
293
|
+
assert(!dirLooksLikeUserScope(join(root, 'bob'), 'bob'), 'bob dir not detected as user');
|
|
294
|
+
assert(!dirLooksLikeUserScope(join(root, 'empty'), 'empty'), 'empty dir not detected');
|
|
295
|
+
cleanup(root);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
console.log('# project repo (no release-notes/, no shared-context/)');
|
|
299
|
+
|
|
300
|
+
{
|
|
301
|
+
const root = setupGitRoot();
|
|
302
|
+
writeFileSync(join(root, 'CLAUDE.md'), '# Project\n');
|
|
303
|
+
writeFileSync(join(root, 'workspace.json'),
|
|
304
|
+
JSON.stringify({ workspace: { name: 'p' } }, null, 2) + '\n',
|
|
305
|
+
);
|
|
306
|
+
gitAddCommit(root);
|
|
307
|
+
const results = migrate(root);
|
|
308
|
+
const s = summarize(results);
|
|
309
|
+
// No source content to migrate
|
|
310
|
+
assertEq(s['rename-shared-context'], 'noop');
|
|
311
|
+
assertEq(s['move-release-notes'], 'noop');
|
|
312
|
+
// No errors
|
|
313
|
+
for (const r of results) assert(r.status !== 'error', `step ${r.name} did not error`);
|
|
314
|
+
cleanup(root);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
console.log('');
|
|
318
|
+
console.log(`${passed} passed, ${failed} failed`);
|
|
319
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
320
|
+
|
|
321
|
+
// helpers
|
|
322
|
+
|
|
323
|
+
function OLD_FILE(...parts) {
|
|
324
|
+
return join('shared-context', ...parts);
|
|
325
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sed-style scripted find-replace across a target tree.
|
|
3
|
+
//
|
|
4
|
+
// Mechanizes the long-tail string updates in rule prose, skill instructions,
|
|
5
|
+
// hooks, and code so we don't burn LLM tokens (and chances for typo) doing
|
|
6
|
+
// it by hand. Used by:
|
|
7
|
+
// - the v0.15 template PR (run on template/)
|
|
8
|
+
// - the migrator (run on the dogfood workspace itself)
|
|
9
|
+
//
|
|
10
|
+
// Replacement rules are ordered longest-pattern-first so we don't get
|
|
11
|
+
// cascading double-replacements (e.g. shared-context/locked → workspace-context/shared/locked
|
|
12
|
+
// must run before shared-context/ → workspace-context/).
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node sweep-references.mjs --check --target <dir>
|
|
16
|
+
// node sweep-references.mjs --write --target <dir>
|
|
17
|
+
//
|
|
18
|
+
// Skips:
|
|
19
|
+
// - any path containing /release-notes/archive/
|
|
20
|
+
// - any path containing /scaffolder-release-history/
|
|
21
|
+
// - .git/, node_modules/
|
|
22
|
+
// - the script's own file (avoids self-rewrite)
|
|
23
|
+
// - binary files (detected via null-byte heuristic)
|
|
24
|
+
|
|
25
|
+
import { readdirSync, readFileSync, statSync, writeFileSync, realpathSync } from 'node:fs';
|
|
26
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
function isMainModule(metaUrl) {
|
|
30
|
+
if (!process.argv[1]) return false;
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(fileURLToPath(metaUrl)) === realpathSync(process.argv[1]);
|
|
33
|
+
} catch { return false; }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const DEFAULT_RULES = [
|
|
37
|
+
// Order matters — longest patterns first.
|
|
38
|
+
{ from: 'shared-context/locked', to: 'workspace-context/shared/locked' },
|
|
39
|
+
{ from: 'shared-context/', to: 'workspace-context/' },
|
|
40
|
+
{ from: 'shared-context', to: 'workspace-context' },
|
|
41
|
+
{ from: 'sharedContextDir', to: 'workspaceContextDir' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
const SKIP_PATH_FRAGMENTS = [
|
|
45
|
+
`${sep}release-notes${sep}archive${sep}`,
|
|
46
|
+
`${sep}scaffolder-release-history${sep}`,
|
|
47
|
+
`${sep}.git${sep}`,
|
|
48
|
+
`${sep}node_modules${sep}`,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Scripts whose contents intentionally contain the literal "before" strings as
|
|
52
|
+
// part of their behavior (the migrator's constants, test fixtures verifying
|
|
53
|
+
// rule firing). Skipping by basename keeps the sweeper from mangling them.
|
|
54
|
+
const SKIP_BASENAMES = new Set([
|
|
55
|
+
'migrate-to-workspace-context.mjs',
|
|
56
|
+
'migrate-to-workspace-context.test.mjs',
|
|
57
|
+
'sweep-references.test.mjs',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
const args = { mode: null, target: null };
|
|
62
|
+
for (let i = 2; i < argv.length; i++) {
|
|
63
|
+
const a = argv[i];
|
|
64
|
+
if (a === '--check') args.mode = 'check';
|
|
65
|
+
else if (a === '--write') args.mode = 'write';
|
|
66
|
+
else if (a === '--target') args.target = argv[++i];
|
|
67
|
+
else throw new Error(`Unknown arg: ${a}`);
|
|
68
|
+
}
|
|
69
|
+
if (!args.mode) throw new Error('Specify --check or --write');
|
|
70
|
+
if (!args.target) throw new Error('--target <dir> is required');
|
|
71
|
+
return args;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function shouldSkip(absPath, scriptPath) {
|
|
75
|
+
if (absPath === scriptPath) return true;
|
|
76
|
+
for (const frag of SKIP_PATH_FRAGMENTS) {
|
|
77
|
+
if (absPath.includes(frag)) return true;
|
|
78
|
+
}
|
|
79
|
+
const base = absPath.split(sep).pop();
|
|
80
|
+
if (SKIP_BASENAMES.has(base)) return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function* walk(dir) {
|
|
85
|
+
const entries = readdirSync(dir);
|
|
86
|
+
for (const name of entries) {
|
|
87
|
+
const full = join(dir, name);
|
|
88
|
+
let st;
|
|
89
|
+
try { st = statSync(full); } catch { continue; }
|
|
90
|
+
if (st.isDirectory()) {
|
|
91
|
+
yield* walk(full);
|
|
92
|
+
} else if (st.isFile()) {
|
|
93
|
+
yield full;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isLikelyBinary(buffer) {
|
|
99
|
+
// Quick null-byte heuristic, sampling the first 4 KiB
|
|
100
|
+
const sample = buffer.slice(0, Math.min(4096, buffer.length));
|
|
101
|
+
for (let i = 0; i < sample.length; i++) {
|
|
102
|
+
if (sample[i] === 0) return true;
|
|
103
|
+
}
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function applyRules(content, rules = DEFAULT_RULES) {
|
|
108
|
+
let out = content;
|
|
109
|
+
let total = 0;
|
|
110
|
+
const perRule = [];
|
|
111
|
+
for (const rule of rules) {
|
|
112
|
+
let count = 0;
|
|
113
|
+
let idx = out.indexOf(rule.from);
|
|
114
|
+
while (idx !== -1) {
|
|
115
|
+
count++;
|
|
116
|
+
idx = out.indexOf(rule.from, idx + rule.from.length);
|
|
117
|
+
}
|
|
118
|
+
if (count > 0) {
|
|
119
|
+
out = out.split(rule.from).join(rule.to);
|
|
120
|
+
total += count;
|
|
121
|
+
perRule.push({ from: rule.from, to: rule.to, count });
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return { content: out, total, perRule };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function sweep(targetDir, { rules = DEFAULT_RULES, write = false, scriptPath = '' } = {}) {
|
|
128
|
+
const absTarget = resolve(targetDir);
|
|
129
|
+
const changes = [];
|
|
130
|
+
for (const path of walk(absTarget)) {
|
|
131
|
+
if (shouldSkip(path, scriptPath)) continue;
|
|
132
|
+
let buf;
|
|
133
|
+
try { buf = readFileSync(path); } catch { continue; }
|
|
134
|
+
if (isLikelyBinary(buf)) continue;
|
|
135
|
+
const text = buf.toString('utf-8');
|
|
136
|
+
const result = applyRules(text, rules);
|
|
137
|
+
if (result.total === 0) continue;
|
|
138
|
+
changes.push({
|
|
139
|
+
path: relative(absTarget, path),
|
|
140
|
+
total: result.total,
|
|
141
|
+
perRule: result.perRule,
|
|
142
|
+
});
|
|
143
|
+
if (write) writeFileSync(path, result.content);
|
|
144
|
+
}
|
|
145
|
+
return changes;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function main() {
|
|
149
|
+
const args = parseArgs(process.argv);
|
|
150
|
+
const scriptPath = process.argv[1];
|
|
151
|
+
const changes = sweep(args.target, {
|
|
152
|
+
rules: DEFAULT_RULES,
|
|
153
|
+
write: args.mode === 'write',
|
|
154
|
+
scriptPath,
|
|
155
|
+
});
|
|
156
|
+
process.stdout.write(
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
mode: args.mode,
|
|
159
|
+
target: args.target,
|
|
160
|
+
filesChanged: changes.length,
|
|
161
|
+
totalReplacements: changes.reduce((acc, c) => acc + c.total, 0),
|
|
162
|
+
changes,
|
|
163
|
+
}, null, 2) + '\n',
|
|
164
|
+
);
|
|
165
|
+
if (args.mode === 'check' && changes.length > 0) process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (isMainModule(import.meta.url)) {
|
|
169
|
+
try {
|
|
170
|
+
main();
|
|
171
|
+
} catch (err) {
|
|
172
|
+
process.stderr.write(`sweep-references: ${err.message}\n`);
|
|
173
|
+
process.exit(2);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export { applyRules, sweep, DEFAULT_RULES };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Unit tests for sweep-references.mjs
|
|
3
|
+
// Run: node template/.claude/scripts/sweep-references.test.mjs
|
|
4
|
+
|
|
5
|
+
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { spawnSync } from 'node:child_process';
|
|
9
|
+
import { applyRules, sweep, DEFAULT_RULES } from './sweep-references.mjs';
|
|
10
|
+
|
|
11
|
+
let failed = 0;
|
|
12
|
+
let passed = 0;
|
|
13
|
+
|
|
14
|
+
function assert(cond, msg) {
|
|
15
|
+
if (cond) { passed++; } else { failed++; console.error(` FAIL: ${msg}`); }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function assertEq(actual, expected, msg) {
|
|
19
|
+
const a = JSON.stringify(actual);
|
|
20
|
+
const e = JSON.stringify(expected);
|
|
21
|
+
if (a === e) { passed++; } else {
|
|
22
|
+
failed++;
|
|
23
|
+
console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function setupTree() {
|
|
28
|
+
return mkdtempSync(join(tmpdir(), 'sweep-test-'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function cleanup(root) {
|
|
32
|
+
rmSync(root, { recursive: true, force: true });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
console.log('# applyRules ordering');
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
const input = 'See shared-context/locked/x.md and shared-context/y.md and shared-context';
|
|
39
|
+
const { content, total, perRule } = applyRules(input);
|
|
40
|
+
assert(content.includes('workspace-context/shared/locked/x.md'), 'locked path expanded correctly');
|
|
41
|
+
assert(content.includes('workspace-context/y.md'), 'plain shared-context/ rewritten');
|
|
42
|
+
assert(content.includes('workspace-context'), 'bare shared-context rewritten');
|
|
43
|
+
assert(!content.includes('shared-context'), 'no leftover shared-context');
|
|
44
|
+
assertEq(total, 3, 'three replacements counted');
|
|
45
|
+
assertEq(perRule.length, 3, 'three rule entries');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
{
|
|
49
|
+
// longest-first: shared-context/locked must NOT be touched by the shorter rules first
|
|
50
|
+
const input = 'shared-context/locked/foo';
|
|
51
|
+
const out = applyRules(input).content;
|
|
52
|
+
assertEq(out, 'workspace-context/shared/locked/foo', 'longest-first wins');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
// sharedContextDir → workspaceContextDir
|
|
57
|
+
const input = '"sharedContextDir": "shared-context"';
|
|
58
|
+
const out = applyRules(input).content;
|
|
59
|
+
assert(out.includes('workspaceContextDir'), 'config key renamed');
|
|
60
|
+
assert(out.includes('"workspace-context"'), 'value renamed');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
// no replacements when input has nothing matching
|
|
65
|
+
const input = 'totally unrelated content';
|
|
66
|
+
const result = applyRules(input);
|
|
67
|
+
assertEq(result.content, input, 'no-op leaves content alone');
|
|
68
|
+
assertEq(result.total, 0, 'zero replacements');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
console.log('# sweep walks tree');
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
const root = setupTree();
|
|
75
|
+
mkdirSync(join(root, 'rules'), { recursive: true });
|
|
76
|
+
mkdirSync(join(root, 'skills', 'aside'), { recursive: true });
|
|
77
|
+
writeFileSync(join(root, 'rules', 'a.md'), 'See shared-context/locked/x.md');
|
|
78
|
+
writeFileSync(join(root, 'skills', 'aside', 'SKILL.md'), '`shared-context/{user}/`');
|
|
79
|
+
writeFileSync(join(root, 'unrelated.md'), 'no matches here');
|
|
80
|
+
const changes = sweep(root, { write: true });
|
|
81
|
+
assertEq(changes.length, 2, 'two files changed');
|
|
82
|
+
const a = readFileSync(join(root, 'rules', 'a.md'), 'utf-8');
|
|
83
|
+
assert(a.includes('workspace-context/shared/locked/x.md'), 'a.md rewritten');
|
|
84
|
+
const skill = readFileSync(join(root, 'skills', 'aside', 'SKILL.md'), 'utf-8');
|
|
85
|
+
assert(skill.includes('workspace-context/{user}/'), 'SKILL.md rewritten');
|
|
86
|
+
const unrelated = readFileSync(join(root, 'unrelated.md'), 'utf-8');
|
|
87
|
+
assertEq(unrelated, 'no matches here', 'unrelated file unchanged');
|
|
88
|
+
cleanup(root);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
console.log('# sweep skip rules');
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
const root = setupTree();
|
|
95
|
+
mkdirSync(join(root, 'release-notes', 'archive', 'v0.1'), { recursive: true });
|
|
96
|
+
mkdirSync(join(root, 'scaffolder-release-history'), { recursive: true });
|
|
97
|
+
mkdirSync(join(root, 'live'), { recursive: true });
|
|
98
|
+
writeFileSync(join(root, 'release-notes', 'archive', 'v0.1', 'old.md'), 'shared-context/x');
|
|
99
|
+
writeFileSync(join(root, 'scaffolder-release-history', 'log.md'), 'shared-context/y');
|
|
100
|
+
writeFileSync(join(root, 'live', 'doc.md'), 'shared-context/z');
|
|
101
|
+
const changes = sweep(root, { write: true });
|
|
102
|
+
assertEq(changes.length, 1, 'only live/ touched');
|
|
103
|
+
assertEq(changes[0].path, join('live', 'doc.md'), 'live/doc.md is the only change');
|
|
104
|
+
const archived = readFileSync(join(root, 'release-notes', 'archive', 'v0.1', 'old.md'), 'utf-8');
|
|
105
|
+
assertEq(archived, 'shared-context/x', 'archive untouched');
|
|
106
|
+
cleanup(root);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
console.log('# sweep skips binary files');
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
const root = setupTree();
|
|
113
|
+
// Insert a literal null byte in the buffer
|
|
114
|
+
const binary = Buffer.from([0x73, 0x68, 0x61, 0x72, 0x65, 0x64, 0x2d, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x00, 0x00]);
|
|
115
|
+
writeFileSync(join(root, 'fake.bin'), binary);
|
|
116
|
+
writeFileSync(join(root, 'real.md'), 'shared-context');
|
|
117
|
+
const changes = sweep(root, { write: true });
|
|
118
|
+
assertEq(changes.length, 1, 'only the text file changed');
|
|
119
|
+
assertEq(changes[0].path, 'real.md', 'real.md identified');
|
|
120
|
+
cleanup(root);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log('# sweep --check vs --write semantics');
|
|
124
|
+
|
|
125
|
+
{
|
|
126
|
+
const root = setupTree();
|
|
127
|
+
writeFileSync(join(root, 'a.md'), 'shared-context/locked/foo');
|
|
128
|
+
|
|
129
|
+
// --check (write: false) reports but does not modify
|
|
130
|
+
const checkChanges = sweep(root, { write: false });
|
|
131
|
+
assertEq(checkChanges.length, 1, 'check reports one file');
|
|
132
|
+
const stillOriginal = readFileSync(join(root, 'a.md'), 'utf-8');
|
|
133
|
+
assertEq(stillOriginal, 'shared-context/locked/foo', 'check did not write');
|
|
134
|
+
|
|
135
|
+
// --write applies
|
|
136
|
+
sweep(root, { write: true });
|
|
137
|
+
const updated = readFileSync(join(root, 'a.md'), 'utf-8');
|
|
138
|
+
assertEq(updated, 'workspace-context/shared/locked/foo', 'write applied');
|
|
139
|
+
cleanup(root);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
console.log('# CLI end-to-end');
|
|
143
|
+
|
|
144
|
+
{
|
|
145
|
+
const root = setupTree();
|
|
146
|
+
writeFileSync(join(root, 'a.md'), 'shared-context/locked/foo and shared-context/bar');
|
|
147
|
+
const scriptPath = new URL('./sweep-references.mjs', import.meta.url).pathname;
|
|
148
|
+
|
|
149
|
+
// --check exits 1 when changes pending
|
|
150
|
+
const check = spawnSync('node', [scriptPath, '--check', '--target', root], { encoding: 'utf-8' });
|
|
151
|
+
assertEq(check.status, 1, '--check exits 1 with changes pending');
|
|
152
|
+
const checkOut = JSON.parse(check.stdout);
|
|
153
|
+
assertEq(checkOut.filesChanged, 1, 'reports one file');
|
|
154
|
+
|
|
155
|
+
// --write exits 0 and modifies
|
|
156
|
+
const write = spawnSync('node', [scriptPath, '--write', '--target', root], { encoding: 'utf-8' });
|
|
157
|
+
assertEq(write.status, 0, '--write exits 0');
|
|
158
|
+
const updated = readFileSync(join(root, 'a.md'), 'utf-8');
|
|
159
|
+
assertEq(
|
|
160
|
+
updated,
|
|
161
|
+
'workspace-context/shared/locked/foo and workspace-context/bar',
|
|
162
|
+
'sweep wrote both rules in correct order',
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// --check after --write exits 0 (no changes pending)
|
|
166
|
+
const recheck = spawnSync('node', [scriptPath, '--check', '--target', root], { encoding: 'utf-8' });
|
|
167
|
+
assertEq(recheck.status, 0, '--check after --write exits 0');
|
|
168
|
+
|
|
169
|
+
cleanup(root);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
console.log('# DEFAULT_RULES order is intentional');
|
|
173
|
+
|
|
174
|
+
{
|
|
175
|
+
// Sanity: rule[0] is longer than rule[1] (locked goes first)
|
|
176
|
+
assert(
|
|
177
|
+
DEFAULT_RULES[0].from.length > DEFAULT_RULES[1].from.length,
|
|
178
|
+
'first rule is longest',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
console.log('');
|
|
183
|
+
console.log(`${passed} passed, ${failed} failed`);
|
|
184
|
+
process.exit(failed > 0 ? 1 : 0);
|