load-context.test.mjs

  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});