1/**
2 * Tests for the shared context loader (PRODUCT.md / DESIGN.md resolver).
3 * Run with: node --test tests/load-context.test.mjs
4 *
5 * Covers the resolution order added for issue #119:
6 * 1. IMPECCABLE_CONTEXT_DIR env var (absolute or relative)
7 * 2. cwd, when canonical or legacy files are at the root (back-compat)
8 * 3. Auto-fallback to .agents/context/ then docs/
9 * 4. Default to cwd when nothing is found
10 *
11 * Each test runs in its own scratch dir under os.tmpdir() so the suite stays
12 * independent of the project root and parallel-safe.
13 */
14
15import { describe, it, beforeEach, afterEach } from 'node:test';
16import assert from 'node:assert/strict';
17import fs from 'node:fs';
18import path from 'node:path';
19import os from 'node:os';
20
21import { loadContext, resolveContextDir } from '../skill/scripts/load-context.mjs';
22
23let scratch;
24let savedEnv;
25
26beforeEach(() => {
27 scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-loadctx-'));
28 savedEnv = process.env.IMPECCABLE_CONTEXT_DIR;
29 delete process.env.IMPECCABLE_CONTEXT_DIR;
30});
31
32afterEach(() => {
33 if (savedEnv === undefined) delete process.env.IMPECCABLE_CONTEXT_DIR;
34 else process.env.IMPECCABLE_CONTEXT_DIR = savedEnv;
35 fs.rmSync(scratch, { recursive: true, force: true });
36});
37
38function write(rel, body = '# placeholder\n') {
39 const abs = path.join(scratch, rel);
40 fs.mkdirSync(path.dirname(abs), { recursive: true });
41 fs.writeFileSync(abs, body);
42 return abs;
43}
44
45describe('resolveContextDir', () => {
46 it('returns cwd when PRODUCT.md is at the root', () => {
47 write('PRODUCT.md');
48 assert.equal(resolveContextDir(scratch), scratch);
49 });
50
51 it('returns cwd when DESIGN.md is at the root', () => {
52 write('DESIGN.md');
53 assert.equal(resolveContextDir(scratch), scratch);
54 });
55
56 it('returns cwd when only legacy .impeccable.md is at the root', () => {
57 write('.impeccable.md');
58 assert.equal(resolveContextDir(scratch), scratch);
59 });
60
61 it('falls back to .agents/context/ when root is clean', () => {
62 write('.agents/context/PRODUCT.md');
63 assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context'));
64 });
65
66 it('falls back to docs/ when root is clean and .agents/context/ is empty', () => {
67 write('docs/PRODUCT.md');
68 assert.equal(resolveContextDir(scratch), path.join(scratch, 'docs'));
69 });
70
71 it('prefers .agents/context/ over docs/ when both exist', () => {
72 write('.agents/context/PRODUCT.md');
73 write('docs/PRODUCT.md');
74 assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context'));
75 });
76
77 it('prefers cwd over fallback dirs when canonical files are at the root', () => {
78 write('PRODUCT.md');
79 write('.agents/context/PRODUCT.md');
80 assert.equal(resolveContextDir(scratch), scratch);
81 });
82
83 it('honors IMPECCABLE_CONTEXT_DIR with a relative path', () => {
84 write('design/PRODUCT.md');
85 process.env.IMPECCABLE_CONTEXT_DIR = 'design';
86 assert.equal(resolveContextDir(scratch), path.join(scratch, 'design'));
87 });
88
89 it('honors IMPECCABLE_CONTEXT_DIR with an absolute path', () => {
90 const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-elsewhere-'));
91 try {
92 process.env.IMPECCABLE_CONTEXT_DIR = elsewhere;
93 assert.equal(resolveContextDir(scratch), elsewhere);
94 } finally {
95 fs.rmSync(elsewhere, { recursive: true, force: true });
96 }
97 });
98
99 it('IMPECCABLE_CONTEXT_DIR wins even when files exist at the root', () => {
100 write('PRODUCT.md', 'root');
101 write('design/PRODUCT.md', 'overridden');
102 process.env.IMPECCABLE_CONTEXT_DIR = 'design';
103 assert.equal(resolveContextDir(scratch), path.join(scratch, 'design'));
104 });
105
106 it('ignores empty IMPECCABLE_CONTEXT_DIR', () => {
107 write('PRODUCT.md');
108 process.env.IMPECCABLE_CONTEXT_DIR = ' ';
109 assert.equal(resolveContextDir(scratch), scratch);
110 });
111
112 it('returns cwd when nothing is found anywhere', () => {
113 assert.equal(resolveContextDir(scratch), scratch);
114 });
115});
116
117describe('loadContext (backward compatibility)', () => {
118 it('reads PRODUCT.md and DESIGN.md from the root the same way as before', () => {
119 write('PRODUCT.md', '# product content\n');
120 write('DESIGN.md', '# design content\n');
121 const ctx = loadContext(scratch);
122 assert.equal(ctx.hasProduct, true);
123 assert.equal(ctx.hasDesign, true);
124 assert.match(ctx.product, /product content/);
125 assert.match(ctx.design, /design content/);
126 assert.equal(ctx.productPath, 'PRODUCT.md');
127 assert.equal(ctx.designPath, 'DESIGN.md');
128 assert.equal(ctx.contextDir, scratch);
129 });
130
131 it('migrates legacy .impeccable.md -> PRODUCT.md at root', () => {
132 write('.impeccable.md', '# legacy body\n');
133 const ctx = loadContext(scratch);
134 assert.equal(ctx.migrated, true);
135 assert.equal(ctx.hasProduct, true);
136 assert.match(ctx.product, /legacy body/);
137 assert.ok(fs.existsSync(path.join(scratch, 'PRODUCT.md')));
138 assert.ok(!fs.existsSync(path.join(scratch, '.impeccable.md')));
139 });
140});
141
142describe('loadContext (fallback dirs)', () => {
143 it('reads from .agents/context/ when the root is clean', () => {
144 write('.agents/context/PRODUCT.md', '# product in agents\n');
145 write('.agents/context/DESIGN.md', '# design in agents\n');
146 const ctx = loadContext(scratch);
147 assert.equal(ctx.hasProduct, true);
148 assert.equal(ctx.hasDesign, true);
149 assert.match(ctx.product, /product in agents/);
150 assert.equal(ctx.contextDir, path.join(scratch, '.agents', 'context'));
151 // productPath/designPath are relative to cwd, not contextDir
152 assert.equal(ctx.productPath, path.join('.agents', 'context', 'PRODUCT.md'));
153 assert.equal(ctx.designPath, path.join('.agents', 'context', 'DESIGN.md'));
154 assert.equal(ctx.migrated, false);
155 });
156
157 it('reads from docs/ when .agents/context/ is empty', () => {
158 write('docs/PRODUCT.md', '# product in docs\n');
159 const ctx = loadContext(scratch);
160 assert.equal(ctx.hasProduct, true);
161 assert.equal(ctx.contextDir, path.join(scratch, 'docs'));
162 assert.equal(ctx.productPath, path.join('docs', 'PRODUCT.md'));
163 });
164
165 it('does not auto-migrate .impeccable.md inside fallback dirs', () => {
166 write('docs/.impeccable.md', '# legacy in docs\n');
167 const ctx = loadContext(scratch);
168 // .impeccable.md inside a fallback dir doesn't pull the lookup there,
169 // and we never auto-rename outside the cwd root.
170 assert.equal(ctx.hasProduct, false);
171 assert.equal(ctx.migrated, false);
172 assert.ok(fs.existsSync(path.join(scratch, 'docs', '.impeccable.md')));
173 });
174});
175
176describe('loadContext (IMPECCABLE_CONTEXT_DIR override)', () => {
177 it('reads from the override path when set', () => {
178 write('design/PRODUCT.md', '# overridden product\n');
179 write('design/DESIGN.md', '# overridden design\n');
180 process.env.IMPECCABLE_CONTEXT_DIR = 'design';
181 const ctx = loadContext(scratch);
182 assert.equal(ctx.hasProduct, true);
183 assert.equal(ctx.hasDesign, true);
184 assert.match(ctx.product, /overridden product/);
185 assert.equal(ctx.contextDir, path.join(scratch, 'design'));
186 });
187
188 it('reports a missing override directory as no-context, not as a crash', () => {
189 process.env.IMPECCABLE_CONTEXT_DIR = 'no/such/dir';
190 const ctx = loadContext(scratch);
191 assert.equal(ctx.hasProduct, false);
192 assert.equal(ctx.hasDesign, false);
193 assert.equal(ctx.product, null);
194 assert.equal(ctx.design, null);
195 assert.equal(ctx.contextDir, path.resolve(scratch, 'no/such/dir'));
196 });
197});