@ulysses-ai/create-workspace 0.15.0-beta.0 → 0.15.0-beta.2

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 (26) hide show
  1. package/lib/init.mjs +12 -25
  2. package/lib/scaffold.mjs +3 -2
  3. package/package.json +1 -1
  4. package/template/.claude/rules/memory-guidance.md +30 -0
  5. package/template/.claude/scripts/build-workspace-context.mjs +370 -23
  6. package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -0
  7. package/template/.claude/skills/complete-work/SKILL.md +88 -0
  8. package/template/.claude/skills/maintenance/SKILL.md +79 -11
  9. package/template/.claude/skills/release/SKILL.md +3 -0
  10. package/template/.claude/skills/workspace-update/SKILL.md +7 -1
  11. package/template/workspace.json.tmpl +1 -0
  12. package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
  13. package/template/.claude/hooks/_utils.test.mjs +0 -99
  14. package/template/.claude/lib/freshness.test.mjs +0 -175
  15. package/template/.claude/lib/registry-check.test.mjs +0 -130
  16. package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
  17. package/template/.claude/scripts/build-workspace-context.test.mjs +0 -633
  18. package/template/.claude/scripts/capture-context.test.mjs +0 -383
  19. package/template/.claude/scripts/generate-claude-local.test.mjs +0 -184
  20. package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
  21. package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
  22. package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +0 -325
  23. package/template/.claude/scripts/sweep-references.test.mjs +0 -184
  24. package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
  25. package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
  26. package/template/.claude/scripts/trackers/interface.test.mjs +0 -40
@@ -1,144 +0,0 @@
1
- #!/usr/bin/env node
2
- // Tests for migrate-session-layout.mjs
3
- // Run: node .claude/scripts/migrate-session-layout.test.mjs
4
- import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, rmSync } from 'fs';
5
- import { tmpdir } from 'os';
6
- import { join } from 'path';
7
- import { execSync } from 'child_process';
8
- import { migrateSession, migrateMain } from './migrate-session-layout.mjs';
9
-
10
- let failed = 0;
11
- let passed = 0;
12
-
13
- function assertEq(actual, expected, msg) {
14
- const a = JSON.stringify(actual);
15
- const e = JSON.stringify(expected);
16
- if (a === e) { passed++; }
17
- else {
18
- failed++;
19
- console.error(` FAIL: ${msg}\n expected: ${e}\n actual: ${a}`);
20
- }
21
- }
22
-
23
- function assertTrue(cond, msg) {
24
- if (cond) passed++;
25
- else { failed++; console.error(` FAIL: ${msg}`); }
26
- }
27
-
28
- // Spin up a fake workspace with a session worktree on a session branch.
29
- // Uses real git so we test end-to-end.
30
- function buildFixture() {
31
- const root = mkdtempSync(join(tmpdir(), 'migrator-test-'));
32
- execSync('git init -q -b main', { cwd: root });
33
- execSync('git config user.email test@example.com', { cwd: root });
34
- execSync('git config user.name Test', { cwd: root });
35
-
36
- writeFileSync(join(root, 'workspace.json'), JSON.stringify({
37
- workspace: { workSessionsDir: 'work-sessions', templateVersion: '0.8.0' },
38
- }, null, 2) + '\n');
39
-
40
- const oldGitignore = [
41
- '# Work sessions — the folder and worktrees are local, but the session tracker,',
42
- '# specs, and plans are tracked so durable thinking travels across machines.',
43
- 'work-sessions/**',
44
- '!work-sessions/*/',
45
- '!work-sessions/*/session.md',
46
- '!work-sessions/*/design-*.md',
47
- '!work-sessions/*/plan-*.md',
48
- '',
49
- ].join('\n');
50
- writeFileSync(join(root, '.gitignore'), oldGitignore);
51
- execSync('git add -A && git commit -q -m "init"', { cwd: root });
52
-
53
- // Session "demo" — tracker + spec exist at launcher-side paths, tracked on main.
54
- mkdirSync(join(root, 'work-sessions', 'demo'), { recursive: true });
55
- writeFileSync(join(root, 'work-sessions', 'demo', 'session.md'),
56
- '---\ntype: session-tracker\nname: demo\nstatus: active\n---\n\nbody\n');
57
- writeFileSync(join(root, 'work-sessions', 'demo', 'design-demo.md'),
58
- '---\ntopic: demo\n---\n\nspec body\n');
59
- execSync('git add work-sessions/demo/session.md work-sessions/demo/design-demo.md && git commit -q -m "add demo tracker and spec"',
60
- { cwd: root });
61
-
62
- // Create branch + worktree for demo. Branch inherits the tracker.
63
- execSync('git branch feature/demo main', { cwd: root });
64
- execSync('git worktree add -q work-sessions/demo/workspace feature/demo', { cwd: root });
65
-
66
- return root;
67
- }
68
-
69
- // migrateSession copies launcher tracker+spec into worktree, removes ghosts,
70
- // and commits on the session branch.
71
- {
72
- const root = buildFixture();
73
- try {
74
- const result = migrateSession(root, 'demo');
75
- assertEq(result.status, 'migrated', 'first migration returns migrated');
76
-
77
- const inWorktreeTracker = join(root, 'work-sessions', 'demo', 'workspace', 'session.md');
78
- assertTrue(existsSync(inWorktreeTracker), 'tracker exists at worktree top');
79
-
80
- const inWorktreeSpec = join(root, 'work-sessions', 'demo', 'workspace', 'design-demo.md');
81
- assertTrue(existsSync(inWorktreeSpec), 'spec exists at worktree top');
82
-
83
- const worktree = join(root, 'work-sessions', 'demo', 'workspace');
84
- const listed = execSync('git ls-files', { cwd: worktree }).toString();
85
- assertTrue(listed.includes('\nsession.md\n') || listed.startsWith('session.md\n'),
86
- 'top-level session.md is tracked on branch');
87
- assertTrue(listed.includes('design-demo.md'),
88
- 'top-level design-demo.md is tracked on branch');
89
- assertTrue(!listed.includes('work-sessions/demo/session.md'),
90
- 'branch no longer tracks ghost work-sessions/demo/session.md');
91
-
92
- const log = execSync('git log --oneline feature/demo', { cwd: root }).toString();
93
- assertTrue(log.includes('migrate session content into worktree'),
94
- 'branch has migration commit');
95
- } finally {
96
- rmSync(root, { recursive: true, force: true });
97
- }
98
- }
99
-
100
- // Idempotency: re-running on an already-migrated session is a no-op.
101
- {
102
- const root = buildFixture();
103
- try {
104
- migrateSession(root, 'demo');
105
- const result = migrateSession(root, 'demo');
106
- assertEq(result.status, 'already-migrated', 'second run reports already-migrated');
107
- } finally {
108
- rmSync(root, { recursive: true, force: true });
109
- }
110
- }
111
-
112
- // migrateMain updates .gitignore, git rm --cached's tracker paths, bumps
113
- // templateVersion, and commits on main.
114
- {
115
- const root = buildFixture();
116
- try {
117
- migrateSession(root, 'demo');
118
- const result = migrateMain(root);
119
- assertEq(result.status, 'migrated', 'main migration returns migrated');
120
-
121
- const gi = readFileSync(join(root, '.gitignore'), 'utf-8');
122
- assertTrue(gi.includes('work-sessions/'), '.gitignore has work-sessions/ line');
123
- assertTrue(!gi.includes('!work-sessions/*/session.md'),
124
- '.gitignore exception removed');
125
-
126
- const mainTracked = execSync('git ls-files', { cwd: root }).toString();
127
- assertTrue(!mainTracked.includes('work-sessions/demo/session.md'),
128
- 'main no longer tracks work-sessions/demo/session.md');
129
- assertTrue(!mainTracked.includes('work-sessions/demo/design-demo.md'),
130
- 'main no longer tracks work-sessions/demo/design-demo.md');
131
-
132
- const ws = JSON.parse(readFileSync(join(root, 'workspace.json'), 'utf-8'));
133
- assertEq(ws.workspace.templateVersion, '0.9.0', 'templateVersion bumped to 0.9.0');
134
-
135
- const mainLog = execSync('git log --oneline main -1', { cwd: root }).toString();
136
- assertTrue(mainLog.includes('migrate workspace to in-worktree session layout'),
137
- 'main has migration commit');
138
- } finally {
139
- rmSync(root, { recursive: true, force: true });
140
- }
141
- }
142
-
143
- console.log(`Passed: ${passed}, Failed: ${failed}`);
144
- if (failed > 0) process.exit(1);
@@ -1,325 +0,0 @@
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
- }
@@ -1,184 +0,0 @@
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);