mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Adds a configurable lookup path for PRODUCT.md / DESIGN.md / DESIGN.json so
they don't have to live at the project root. Resolution order (first match
wins):
1. process.env.IMPECCABLE_CONTEXT_DIR (absolute or relative to cwd)
2. cwd, when canonical or legacy files are at the root (back-compat)
3. Auto-fallback subdirs of cwd: .agents/context/ then docs/
4. cwd as a default "no context found" location
Existing layouts (PRODUCT.md / DESIGN.md at repo root) keep working unchanged
- step 2 preserves the current behaviour. The auto-fallback covers the two
most common conventions seen in the wild (.agents/context/ for AGENTS.md
auto-import setups, docs/ for the request in the issue) without needing any
configuration.
Changes:
- load-context.mjs: export resolveContextDir() and use it inside
loadContext(); add contextDir to the JSON output
- live-server.mjs: import resolveContextDir and read PRODUCT.md /
DESIGN.md / DESIGN.json from the resolved dir instead of process.cwd()
- SKILL.md: short note on the env var and fallback dirs in Setup -> Context
- tests/load-context.test.mjs: 19 cases covering env var, fallbacks,
legacy migration scope, and back-compat
Legacy .impeccable.md -> PRODUCT.md auto-migration stays scoped to cwd root;
fallback dirs are read-only as far as auto-rename is concerned.
Closes #119
198 lines
7.4 KiB
JavaScript
198 lines
7.4 KiB
JavaScript
/**
|
|
* Tests for the shared context loader (PRODUCT.md / DESIGN.md resolver).
|
|
* Run with: node --test tests/load-context.test.mjs
|
|
*
|
|
* Covers the resolution order added for issue #119:
|
|
* 1. IMPECCABLE_CONTEXT_DIR env var (absolute or relative)
|
|
* 2. cwd, when canonical or legacy files are at the root (back-compat)
|
|
* 3. Auto-fallback to .agents/context/ then docs/
|
|
* 4. Default to cwd when nothing is found
|
|
*
|
|
* Each test runs in its own scratch dir under os.tmpdir() so the suite stays
|
|
* independent of the project root and parallel-safe.
|
|
*/
|
|
|
|
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
|
|
import { loadContext, resolveContextDir } from '../source/skills/impeccable/scripts/load-context.mjs';
|
|
|
|
let scratch;
|
|
let savedEnv;
|
|
|
|
beforeEach(() => {
|
|
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-loadctx-'));
|
|
savedEnv = process.env.IMPECCABLE_CONTEXT_DIR;
|
|
delete process.env.IMPECCABLE_CONTEXT_DIR;
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (savedEnv === undefined) delete process.env.IMPECCABLE_CONTEXT_DIR;
|
|
else process.env.IMPECCABLE_CONTEXT_DIR = savedEnv;
|
|
fs.rmSync(scratch, { recursive: true, force: true });
|
|
});
|
|
|
|
function write(rel, body = '# placeholder\n') {
|
|
const abs = path.join(scratch, rel);
|
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
fs.writeFileSync(abs, body);
|
|
return abs;
|
|
}
|
|
|
|
describe('resolveContextDir', () => {
|
|
it('returns cwd when PRODUCT.md is at the root', () => {
|
|
write('PRODUCT.md');
|
|
assert.equal(resolveContextDir(scratch), scratch);
|
|
});
|
|
|
|
it('returns cwd when DESIGN.md is at the root', () => {
|
|
write('DESIGN.md');
|
|
assert.equal(resolveContextDir(scratch), scratch);
|
|
});
|
|
|
|
it('returns cwd when only legacy .impeccable.md is at the root', () => {
|
|
write('.impeccable.md');
|
|
assert.equal(resolveContextDir(scratch), scratch);
|
|
});
|
|
|
|
it('falls back to .agents/context/ when root is clean', () => {
|
|
write('.agents/context/PRODUCT.md');
|
|
assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context'));
|
|
});
|
|
|
|
it('falls back to docs/ when root is clean and .agents/context/ is empty', () => {
|
|
write('docs/PRODUCT.md');
|
|
assert.equal(resolveContextDir(scratch), path.join(scratch, 'docs'));
|
|
});
|
|
|
|
it('prefers .agents/context/ over docs/ when both exist', () => {
|
|
write('.agents/context/PRODUCT.md');
|
|
write('docs/PRODUCT.md');
|
|
assert.equal(resolveContextDir(scratch), path.join(scratch, '.agents', 'context'));
|
|
});
|
|
|
|
it('prefers cwd over fallback dirs when canonical files are at the root', () => {
|
|
write('PRODUCT.md');
|
|
write('.agents/context/PRODUCT.md');
|
|
assert.equal(resolveContextDir(scratch), scratch);
|
|
});
|
|
|
|
it('honors IMPECCABLE_CONTEXT_DIR with a relative path', () => {
|
|
write('design/PRODUCT.md');
|
|
process.env.IMPECCABLE_CONTEXT_DIR = 'design';
|
|
assert.equal(resolveContextDir(scratch), path.join(scratch, 'design'));
|
|
});
|
|
|
|
it('honors IMPECCABLE_CONTEXT_DIR with an absolute path', () => {
|
|
const elsewhere = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-elsewhere-'));
|
|
try {
|
|
process.env.IMPECCABLE_CONTEXT_DIR = elsewhere;
|
|
assert.equal(resolveContextDir(scratch), elsewhere);
|
|
} finally {
|
|
fs.rmSync(elsewhere, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('IMPECCABLE_CONTEXT_DIR wins even when files exist at the root', () => {
|
|
write('PRODUCT.md', 'root');
|
|
write('design/PRODUCT.md', 'overridden');
|
|
process.env.IMPECCABLE_CONTEXT_DIR = 'design';
|
|
assert.equal(resolveContextDir(scratch), path.join(scratch, 'design'));
|
|
});
|
|
|
|
it('ignores empty IMPECCABLE_CONTEXT_DIR', () => {
|
|
write('PRODUCT.md');
|
|
process.env.IMPECCABLE_CONTEXT_DIR = ' ';
|
|
assert.equal(resolveContextDir(scratch), scratch);
|
|
});
|
|
|
|
it('returns cwd when nothing is found anywhere', () => {
|
|
assert.equal(resolveContextDir(scratch), scratch);
|
|
});
|
|
});
|
|
|
|
describe('loadContext (backward compatibility)', () => {
|
|
it('reads PRODUCT.md and DESIGN.md from the root the same way as before', () => {
|
|
write('PRODUCT.md', '# product content\n');
|
|
write('DESIGN.md', '# design content\n');
|
|
const ctx = loadContext(scratch);
|
|
assert.equal(ctx.hasProduct, true);
|
|
assert.equal(ctx.hasDesign, true);
|
|
assert.match(ctx.product, /product content/);
|
|
assert.match(ctx.design, /design content/);
|
|
assert.equal(ctx.productPath, 'PRODUCT.md');
|
|
assert.equal(ctx.designPath, 'DESIGN.md');
|
|
assert.equal(ctx.contextDir, scratch);
|
|
});
|
|
|
|
it('migrates legacy .impeccable.md -> PRODUCT.md at root', () => {
|
|
write('.impeccable.md', '# legacy body\n');
|
|
const ctx = loadContext(scratch);
|
|
assert.equal(ctx.migrated, true);
|
|
assert.equal(ctx.hasProduct, true);
|
|
assert.match(ctx.product, /legacy body/);
|
|
assert.ok(fs.existsSync(path.join(scratch, 'PRODUCT.md')));
|
|
assert.ok(!fs.existsSync(path.join(scratch, '.impeccable.md')));
|
|
});
|
|
});
|
|
|
|
describe('loadContext (fallback dirs)', () => {
|
|
it('reads from .agents/context/ when the root is clean', () => {
|
|
write('.agents/context/PRODUCT.md', '# product in agents\n');
|
|
write('.agents/context/DESIGN.md', '# design in agents\n');
|
|
const ctx = loadContext(scratch);
|
|
assert.equal(ctx.hasProduct, true);
|
|
assert.equal(ctx.hasDesign, true);
|
|
assert.match(ctx.product, /product in agents/);
|
|
assert.equal(ctx.contextDir, path.join(scratch, '.agents', 'context'));
|
|
// productPath/designPath are relative to cwd, not contextDir
|
|
assert.equal(ctx.productPath, path.join('.agents', 'context', 'PRODUCT.md'));
|
|
assert.equal(ctx.designPath, path.join('.agents', 'context', 'DESIGN.md'));
|
|
assert.equal(ctx.migrated, false);
|
|
});
|
|
|
|
it('reads from docs/ when .agents/context/ is empty', () => {
|
|
write('docs/PRODUCT.md', '# product in docs\n');
|
|
const ctx = loadContext(scratch);
|
|
assert.equal(ctx.hasProduct, true);
|
|
assert.equal(ctx.contextDir, path.join(scratch, 'docs'));
|
|
assert.equal(ctx.productPath, path.join('docs', 'PRODUCT.md'));
|
|
});
|
|
|
|
it('does not auto-migrate .impeccable.md inside fallback dirs', () => {
|
|
write('docs/.impeccable.md', '# legacy in docs\n');
|
|
const ctx = loadContext(scratch);
|
|
// .impeccable.md inside a fallback dir doesn't pull the lookup there,
|
|
// and we never auto-rename outside the cwd root.
|
|
assert.equal(ctx.hasProduct, false);
|
|
assert.equal(ctx.migrated, false);
|
|
assert.ok(fs.existsSync(path.join(scratch, 'docs', '.impeccable.md')));
|
|
});
|
|
});
|
|
|
|
describe('loadContext (IMPECCABLE_CONTEXT_DIR override)', () => {
|
|
it('reads from the override path when set', () => {
|
|
write('design/PRODUCT.md', '# overridden product\n');
|
|
write('design/DESIGN.md', '# overridden design\n');
|
|
process.env.IMPECCABLE_CONTEXT_DIR = 'design';
|
|
const ctx = loadContext(scratch);
|
|
assert.equal(ctx.hasProduct, true);
|
|
assert.equal(ctx.hasDesign, true);
|
|
assert.match(ctx.product, /overridden product/);
|
|
assert.equal(ctx.contextDir, path.join(scratch, 'design'));
|
|
});
|
|
|
|
it('reports a missing override directory as no-context, not as a crash', () => {
|
|
process.env.IMPECCABLE_CONTEXT_DIR = 'no/such/dir';
|
|
const ctx = loadContext(scratch);
|
|
assert.equal(ctx.hasProduct, false);
|
|
assert.equal(ctx.hasDesign, false);
|
|
assert.equal(ctx.product, null);
|
|
assert.equal(ctx.design, null);
|
|
assert.equal(ctx.contextDir, path.resolve(scratch, 'no/such/dir'));
|
|
});
|
|
});
|