@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.
- package/lib/init.mjs +12 -25
- package/lib/scaffold.mjs +3 -2
- package/package.json +1 -1
- package/template/.claude/rules/memory-guidance.md +30 -0
- package/template/.claude/scripts/build-workspace-context.mjs +370 -23
- package/template/.claude/scripts/migrate-canonical-priority.mjs +108 -0
- package/template/.claude/skills/complete-work/SKILL.md +88 -0
- package/template/.claude/skills/maintenance/SKILL.md +79 -11
- package/template/.claude/skills/release/SKILL.md +3 -0
- package/template/.claude/skills/workspace-update/SKILL.md +7 -1
- package/template/workspace.json.tmpl +1 -0
- package/template/.claude/hooks/_bash-output-advisory.test.mjs +0 -88
- package/template/.claude/hooks/_utils.test.mjs +0 -99
- package/template/.claude/lib/freshness.test.mjs +0 -175
- package/template/.claude/lib/registry-check.test.mjs +0 -130
- package/template/.claude/lib/session-frontmatter.test.mjs +0 -242
- package/template/.claude/scripts/build-workspace-context.test.mjs +0 -633
- package/template/.claude/scripts/capture-context.test.mjs +0 -383
- package/template/.claude/scripts/generate-claude-local.test.mjs +0 -184
- package/template/.claude/scripts/migrate-claude-md-freshness-include.test.mjs +0 -54
- package/template/.claude/scripts/migrate-session-layout.test.mjs +0 -144
- package/template/.claude/scripts/migrate-to-workspace-context.test.mjs +0 -325
- package/template/.claude/scripts/sweep-references.test.mjs +0 -184
- package/template/.claude/scripts/sync-tasks.test.mjs +0 -350
- package/template/.claude/scripts/trackers/github-issues.test.mjs +0 -190
- 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);
|