Files
pbakaus_impeccable/tests/staleness.test.mjs
T
Paul BakausandClaude Opus 5 c489335799 Build path becomes a config key existing projects can actually reach
The build-path preference shipped as a question only `init` asks, written to
a file only `init` writes. Nothing routes an initialized project back through
init, so every existing project took the comp-first default without anyone
choosing it, and the only recourse was a footer toggle that binds one session.

Neither the setting nor the round that preceded it ever reached a release
(skill-v4.0.4 has no `buildPath`, no `comp-led`, no `.impeccable/settings.json`),
so the PRODUCT.md standing-commitment fallback describes an era that never
existed publicly. It is deleted rather than honored: told a field might exist,
models go hunting for it and preserve it.

- `buildPath` moves from `.impeccable/settings.json` into the unified
  `.impeccable/config.json`, which already has a known-keys registry, doctor
  coverage, and a gitignored `config.local.json` override. Whether a machine
  has an image tool is a property of that machine, so the local file wins.
- new-work captures the answer from behavior instead of an interview: a toggle
  flip on a project recording nothing asks once, after the round closes,
  whether to keep it. The answer is written either way, because a declined
  offer nothing writes down is an offer the next session makes again.
- Two findings: `config-invalid-build-path` (an unread value rides the default
  rather than the opposite path) and `config-build-path-unset`, gated on a
  product record plus evidence of direction work so polish-and-audit projects
  never hear about a setting they do not use.
- init treats a recorded value as a confirmed answer, resolving its conflict
  with Step 1's "do not reopen confirmed fields".
- The setting was undocumented in the README and doctor.md. Both now cover it.

Also records a measured skill-behavior baseline. Three cells fail on unmodified
main (scenarios 9 and 15, `initialized natural build`), verified against a clean
worktree; the suite README now says so, so the next person does not spend the
hour attributing them to their own branch.

Written with AI assistance (Claude Code).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 21:52:17 -04:00

571 lines
24 KiB
JavaScript

/**
* Tests for staleness detection of Impeccable's own project artifacts.
* Run with: node --test tests/staleness.test.mjs
*
* The checks are pure or near-pure, so most cases assert on the returned
* finding set. The CLI cases at the bottom cover the one thing the units
* cannot: that context.mjs emits a single CONTEXT_STALE directive at boot and
* respects its throttle and its opt-outs.
*/
import { describe, it, beforeEach, afterEach } from 'node:test';
import { spawnSync } from 'node:child_process';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { fileURLToPath } from 'node:url';
import {
PRODUCT_SCHEMA_VERSION,
productStampLine,
readProductSchemaVersion,
stampProductSchema,
} from '../skill/scripts/lib/artifact-schema.mjs';
import {
checkBuildPathUnset,
checkConfig,
checkDesignSidecar,
checkNativePlatformEvidence,
checkProduct,
checkProjectRoots,
checkSurfaceBriefs,
designSidecarCandidatesFor,
} from '../skill/scripts/lib/staleness.mjs';
import {
buildStalenessDirective,
filterFreshFindings,
stalenessCheckDisabled,
} from '../skill/scripts/lib/staleness-notice.mjs';
const SCRIPT_PATH = path.join(
path.dirname(fileURLToPath(import.meta.url)), '..', 'skill', 'scripts', 'context.mjs',
);
let scratch;
let savedCacheEnv;
beforeEach(() => {
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stale-'));
savedCacheEnv = process.env.IMPECCABLE_STALENESS_CACHE;
process.env.IMPECCABLE_STALENESS_CACHE = path.join(scratch, 'notice.json');
});
afterEach(() => {
if (savedCacheEnv === undefined) delete process.env.IMPECCABLE_STALENESS_CACHE;
else process.env.IMPECCABLE_STALENESS_CACHE = savedCacheEnv;
fs.rmSync(scratch, { recursive: true, force: true });
});
function write(rel, body) {
const abs = path.join(scratch, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
return abs;
}
function ids(findings) {
return findings.map((entry) => entry.id);
}
const CURRENT_PRODUCT = [
'# Product',
'',
productStampLine(),
'',
'## Platform',
'',
'web',
'',
'## Positioning',
'The only one that does the thing.',
'',
].join('\n');
// ─── the PRODUCT.md stamp ──────────────────────────────────────────────────
describe('product schema stamp', () => {
it('reads a stamped version and returns null when unstamped', () => {
assert.equal(readProductSchemaVersion(CURRENT_PRODUCT), PRODUCT_SCHEMA_VERSION);
assert.equal(readProductSchemaVersion('# Product\n\n## Users\nx\n'), null);
assert.equal(readProductSchemaVersion(''), null);
assert.equal(readProductSchemaVersion(null), null);
});
it('places a new stamp under the leading heading', () => {
const stamped = stampProductSchema('# Product\n\n## Users\nDesigners.\n');
assert.equal(stamped.split('\n')[0], '# Product');
assert.equal(readProductSchemaVersion(stamped), PRODUCT_SCHEMA_VERSION);
assert.match(stamped, /## Users\nDesigners\./);
});
it('stamps a body with no heading at the top', () => {
const stamped = stampProductSchema('## Users\nDesigners.\n');
assert.equal(stamped.split('\n')[0], productStampLine());
assert.equal(readProductSchemaVersion(stamped), PRODUCT_SCHEMA_VERSION);
});
it('is idempotent and updates an older stamp in place', () => {
const once = stampProductSchema('# Product\n\n## Users\nx\n');
assert.equal(stampProductSchema(once), once);
const upgraded = stampProductSchema('# Product\n\n<!-- impeccable:product-schema 0 -->\n\n## Users\nx\n');
assert.equal(readProductSchemaVersion(upgraded), PRODUCT_SCHEMA_VERSION);
assert.equal((upgraded.match(/impeccable:product-schema/g) || []).length, 1);
});
});
// ─── PRODUCT.md ────────────────────────────────────────────────────────────
describe('checkProduct', () => {
it('flags a deprecated Register section and binds the agent to ignore it', () => {
const findings = checkProduct('# Product\n\n## Register\n\nbrand\n\n## Positioning\nx\n');
assert.deepEqual(ids(findings), ['product-deprecated-register']);
assert.match(findings[0].summary, /visitor modes/);
assert.match(findings[0].fix, /Treat `## Register` as absent/);
assert.equal(findings[0].severity, 'mention');
});
it('flags an unstamped file carrying none of the current sections', () => {
const findings = checkProduct('# Product\n\n## Users\nDesigners.\n');
assert.deepEqual(ids(findings), ['product-schema-legacy']);
assert.equal(findings[0].severity, 'route');
assert.match(findings[0].fix, /Offer `init`/);
});
it('trusts a current stamp over the section heuristic', () => {
const stamped = stampProductSchema('# Product\n\n## Users\nDesigners.\n');
assert.deepEqual(checkProduct(stamped), []);
});
it('accepts an unstamped file that has the current sections', () => {
assert.deepEqual(checkProduct('# Product\n\n## Product Principles\n- One\n'), []);
});
it('flags a stamp older than the current schema', () => {
const findings = checkProduct('# Product\n\n<!-- impeccable:product-schema 0 -->\n\n## Users\nx\n');
assert.deepEqual(ids(findings), ['product-schema-outdated']);
});
it('reports nothing when there is no PRODUCT.md', () => {
assert.deepEqual(checkProduct(null), []);
});
});
// ─── native platform evidence ──────────────────────────────────────────────
describe('checkNativePlatformEvidence', () => {
it('flags a web platform on a project carrying native build files', () => {
write('ios/Podfile', "platform :ios, '15.0'\n");
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT, productPath: 'PRODUCT.md',
});
assert.deepEqual(ids(findings), ['platform-native-evidence']);
assert.match(findings[0].summary, /ios\/Podfile/);
assert.match(findings[0].fix, /`ios`/);
});
it('reads react-native and expo out of package.json', () => {
write('package.json', JSON.stringify({ devDependencies: { expo: '51' } }));
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT,
});
assert.deepEqual(ids(findings), ['platform-native-evidence']);
assert.match(findings[0].fix, /`adaptive`/);
});
it('suggests adaptive when both native targets are present', () => {
write('ios/Podfile', '');
write('android/build.gradle', '');
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT,
});
assert.match(findings[0].fix, /`adaptive`/);
});
it('says so when the platform field is missing rather than web', () => {
write('pubspec.yaml', 'name: app\n');
const findings = checkNativePlatformEvidence({
projectRoot: scratch, platform: null, product: '# Product\n\n## Users\nx\n',
});
assert.match(findings[0].summary, /no `## Platform` section/);
});
it('stays silent when the platform is already native', () => {
write('ios/Podfile', '');
for (const platform of ['ios', 'android', 'adaptive']) {
assert.deepEqual(
checkNativePlatformEvidence({ projectRoot: scratch, platform, product: CURRENT_PRODUCT }),
[],
platform,
);
}
});
it('stays silent on an ordinary web project', () => {
write('package.json', JSON.stringify({ dependencies: { react: '19' } }));
assert.deepEqual(
checkNativePlatformEvidence({ projectRoot: scratch, platform: 'web', product: CURRENT_PRODUCT }),
[],
);
});
});
// ─── design.json sidecar ───────────────────────────────────────────────────
describe('checkDesignSidecar', () => {
function candidates() {
return designSidecarCandidatesFor(scratch, scratch);
}
it('reports nothing when no sidecar exists anywhere', () => {
assert.deepEqual(checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch }), []);
});
it('accepts a current sidecar in the canonical location', () => {
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2 }));
assert.deepEqual(checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch }), []);
});
it('flags an outdated schema version as a routable repair', () => {
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 1 }));
const findings = checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch });
assert.deepEqual(ids(findings), ['design-sidecar-schema-outdated']);
assert.equal(findings[0].severity, 'route');
assert.match(findings[0].fix, /Offer `document`/);
});
it('treats a missing schemaVersion as an outdated sidecar', () => {
write('.impeccable/design.json', JSON.stringify({ title: 'x' }));
assert.deepEqual(
ids(checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch })),
['design-sidecar-schema-outdated'],
);
});
it('flags a retired location as an automatic migration', () => {
write('DESIGN.json', JSON.stringify({ schemaVersion: 2 }));
const findings = checkDesignSidecar({ sidecarCandidates: candidates(), projectRoot: scratch });
assert.deepEqual(ids(findings), ['design-sidecar-legacy-path']);
assert.equal(findings[0].severity, 'auto');
assert.match(findings[0].fix, /\.impeccable\/design\.json/);
});
it('flags a sidecar older than the DESIGN.md it extends', () => {
const sidecar = write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2 }));
const design = write('DESIGN.md', '---\nname: X\n---\n');
const old = new Date(Date.now() - 60_000);
fs.utimesSync(sidecar, old, old);
const findings = checkDesignSidecar({
designPath: design, sidecarCandidates: candidates(), projectRoot: scratch,
});
assert.deepEqual(ids(findings), ['design-sidecar-stale']);
assert.equal(findings[0].severity, 'mention');
});
it('does not flag a sidecar newer than DESIGN.md', () => {
const design = write('DESIGN.md', '---\nname: X\n---\n');
const sidecar = write('.impeccable/design.json', JSON.stringify({ schemaVersion: 2 }));
const old = new Date(Date.now() - 60_000);
fs.utimesSync(design, old, old);
fs.utimesSync(sidecar, new Date(), new Date());
assert.deepEqual(
checkDesignSidecar({ designPath: design, sidecarCandidates: candidates(), projectRoot: scratch }),
[],
);
});
});
// ─── config.json ───────────────────────────────────────────────────────────
describe('checkConfig', () => {
it('accepts a config using only recognized keys', () => {
write('.impeccable/config.json', JSON.stringify({
updateCheck: true,
projectRoots: ['apps/*'],
hook: { enabled: true, consent: 'accepted' },
detector: { ignoreRules: ['side-tab'], designSystem: { enabled: false } },
}));
assert.deepEqual(checkConfig({ projectRoot: scratch, repoRoot: scratch }), []);
});
it('flags unrecognized top-level keys', () => {
write('.impeccable/config.json', JSON.stringify({ updateChek: true }));
const findings = checkConfig({ projectRoot: scratch, repoRoot: scratch });
assert.deepEqual(ids(findings), ['config-unknown-keys']);
assert.match(findings[0].summary, /`updateChek`/);
});
it('flags unrecognized detector keys, the singular-typo case', () => {
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRule: ['side-tab'] } }));
const findings = checkConfig({ projectRoot: scratch, repoRoot: scratch });
assert.deepEqual(ids(findings), ['config-unknown-detector-keys']);
assert.match(findings[0].summary, /`ignoreRule`/);
});
it('checks the local config too', () => {
write('.impeccable/config.local.json', JSON.stringify({ bogus: 1 }));
const findings = checkConfig({ projectRoot: scratch, repoRoot: scratch });
assert.deepEqual(ids(findings), ['config-unknown-keys']);
assert.match(findings[0].path, /config\.local\.json$/);
});
it('does not check the hook subtree, which has many writers', () => {
write('.impeccable/config.json', JSON.stringify({ hook: { somethingRuntime: true } }));
assert.deepEqual(checkConfig({ projectRoot: scratch, repoRoot: scratch }), []);
});
it('ignores a malformed config rather than reporting it as drift', () => {
write('.impeccable/config.json', '{ not json');
assert.deepEqual(checkConfig({ projectRoot: scratch, repoRoot: scratch }), []);
});
it('accepts both build-path values', () => {
for (const value of ['comp', 'code']) {
write('.impeccable/config.json', JSON.stringify({ buildPath: value }));
assert.deepEqual(checkConfig({ projectRoot: scratch, repoRoot: scratch }), []);
}
});
it('flags a build-path value nothing reads', () => {
write('.impeccable/config.json', JSON.stringify({ buildPath: 'code-first' }));
const findings = checkConfig({ projectRoot: scratch, repoRoot: scratch });
assert.deepEqual(ids(findings), ['config-invalid-build-path']);
assert.match(findings[0].summary, /"code-first"/);
});
});
// ─── build path ────────────────────────────────────────────────────────────
describe('checkBuildPathUnset', () => {
const args = () => ({ projectRoot: scratch, repoRoot: scratch, product: '# Product' });
it('flags a project that has done direction work and recorded nothing', () => {
fs.mkdirSync(path.join(scratch, '.impeccable', 'surfaces'), { recursive: true });
const findings = checkBuildPathUnset(args());
assert.deepEqual(ids(findings), ['config-build-path-unset']);
// The one precondition this module cannot see must reach the reader that can.
assert.match(findings[0].fix, /image generation/);
});
it('accepts decision mocks as the same evidence', () => {
fs.mkdirSync(path.join(scratch, '.impeccable', 'mocks', 'decision'), { recursive: true });
assert.deepEqual(ids(checkBuildPathUnset(args())), ['config-build-path-unset']);
});
it('stays silent on a project that has never done direction work', () => {
assert.deepEqual(checkBuildPathUnset(args()), []);
});
it('stays silent without a product record, where init already routes', () => {
fs.mkdirSync(path.join(scratch, '.impeccable', 'surfaces'), { recursive: true });
assert.deepEqual(checkBuildPathUnset({ ...args(), product: null }), []);
});
it('stays silent once a value is recorded, in either config file', () => {
fs.mkdirSync(path.join(scratch, '.impeccable', 'surfaces'), { recursive: true });
write('.impeccable/config.json', JSON.stringify({ buildPath: 'code' }));
assert.deepEqual(checkBuildPathUnset(args()), []);
fs.rmSync(path.join(scratch, '.impeccable', 'config.json'));
write('.impeccable/config.local.json', JSON.stringify({ buildPath: 'comp' }));
assert.deepEqual(checkBuildPathUnset(args()), []);
});
it('defers to the invalid-value finding rather than reporting the key twice', () => {
fs.mkdirSync(path.join(scratch, '.impeccable', 'surfaces'), { recursive: true });
write('.impeccable/config.json', JSON.stringify({ buildPath: 'comp-first' }));
assert.deepEqual(checkBuildPathUnset(args()), []);
});
});
// ─── surface briefs ────────────────────────────────────────────────────────
describe('checkSurfaceBriefs', () => {
it('flags a brief whose primary target no longer exists', () => {
const findings = checkSurfaceBriefs({
projectRoot: scratch,
candidates: [{ path: '.impeccable/surfaces/pricing.md', primaryTarget: 'src/Pricing.tsx' }],
});
assert.deepEqual(ids(findings), ['surface-brief-orphaned']);
assert.match(findings[0].summary, /src\/Pricing\.tsx/);
});
it('accepts a brief whose target is still on disk', () => {
write('src/Pricing.tsx', 'export default null;\n');
assert.deepEqual(checkSurfaceBriefs({
projectRoot: scratch,
candidates: [{ path: '.impeccable/surfaces/pricing.md', primaryTarget: 'src/Pricing.tsx' }],
}), []);
});
it('skips route and URL targets, which have no file to check', () => {
assert.deepEqual(checkSurfaceBriefs({
projectRoot: scratch,
candidates: [
{ path: 'a.md', primaryTarget: 'route:/pricing' },
{ path: 'b.md', primaryTarget: 'https://example.com/pricing' },
],
}), []);
});
});
// ─── projectRoots ──────────────────────────────────────────────────────────
describe('checkProjectRoots', () => {
it('flags patterns that match no directory', () => {
const findings = checkProjectRoots({ patterns: ['apps/*'], candidates: [] });
assert.deepEqual(ids(findings), ['config-project-roots-match-nothing']);
assert.match(findings[0].summary, /`apps\/\*`/);
});
it('stays quiet when candidates were discovered', () => {
assert.deepEqual(checkProjectRoots({ patterns: ['apps/*'], candidates: [{ path: 'apps/web' }] }), []);
});
it('stays quiet when only negations are declared', () => {
assert.deepEqual(checkProjectRoots({ patterns: ['!apps/legacy'], candidates: [] }), []);
});
});
// ─── notice throttling ─────────────────────────────────────────────────────
describe('staleness notices', () => {
const mention = { id: 'a', severity: 'mention', summary: 's', fix: 'f', artifact: 'x', path: null };
const auto = { id: 'b', severity: 'auto', summary: 's', fix: 'f', artifact: 'x', path: null };
it('passes a finding through once, then suppresses it', () => {
assert.deepEqual(ids(filterFreshFindings([mention], { projectRoot: scratch })), ['a']);
assert.deepEqual(filterFreshFindings([mention], { projectRoot: scratch }), []);
});
it('re-surfaces a finding once the renotify window has passed', () => {
const now = Date.now();
filterFreshFindings([mention], { projectRoot: scratch, now });
const later = now + 8 * 24 * 60 * 60 * 1000;
assert.deepEqual(ids(filterFreshFindings([mention], { projectRoot: scratch, now: later })), ['a']);
});
it('never throttles auto findings, which the agent needs every session', () => {
assert.deepEqual(ids(filterFreshFindings([auto], { projectRoot: scratch })), ['b']);
assert.deepEqual(ids(filterFreshFindings([auto], { projectRoot: scratch })), ['b']);
});
it('keys throttling per project', () => {
const other = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-stale-other-'));
try {
filterFreshFindings([mention], { projectRoot: scratch });
assert.deepEqual(ids(filterFreshFindings([mention], { projectRoot: other })), ['a']);
} finally {
fs.rmSync(other, { recursive: true, force: true });
}
});
it('forgets a finding that stopped firing so a recurrence is reported again', () => {
filterFreshFindings([mention, { ...mention, id: 'c' }], { projectRoot: scratch });
filterFreshFindings([mention], { projectRoot: scratch });
assert.deepEqual(ids(filterFreshFindings([{ ...mention, id: 'c' }], { projectRoot: scratch })), ['c']);
});
it('renders one directive for the whole set, or nothing', () => {
assert.equal(buildStalenessDirective([]), null);
const directive = buildStalenessDirective([mention, auto]);
assert.equal((directive.match(/CONTEXT_STALE:/g) || []).length, 1);
assert.match(directive, /Do not stop, reorder, or expand the requested task/);
assert.match(directive, /Surface the reportable findings once/);
});
it('omits the user-facing instruction when every finding is automatic', () => {
assert.doesNotMatch(buildStalenessDirective([auto]), /Surface the reportable findings/);
});
it('honors stalenessCheck false in config, local overriding shared', () => {
assert.equal(stalenessCheckDisabled([scratch]), false);
write('.impeccable/config.json', JSON.stringify({ stalenessCheck: false }));
assert.equal(stalenessCheckDisabled([scratch]), true);
write('.impeccable/config.local.json', JSON.stringify({ stalenessCheck: true }));
assert.equal(stalenessCheckDisabled([scratch]), false);
});
it('honors the environment opt-out', () => {
process.env.IMPECCABLE_NO_STALENESS_CHECK = '1';
try {
assert.equal(stalenessCheckDisabled([scratch]), true);
} finally {
delete process.env.IMPECCABLE_NO_STALENESS_CHECK;
}
});
});
// ─── boot integration ──────────────────────────────────────────────────────
describe('context.mjs CONTEXT_STALE directive', () => {
function run(env = {}) {
return spawnSync(process.execPath, [SCRIPT_PATH], {
cwd: scratch,
encoding: 'utf8',
env: {
...process.env,
IMPECCABLE_NO_UPDATE_CHECK: '1',
IMPECCABLE_STALENESS_CACHE: path.join(scratch, 'notice.json'),
...env,
},
});
}
it('emits one directive at boot and throttles the next run', () => {
write('PRODUCT.md', '# Product\n\n## Register\n\nbrand\n\n## Users\nDesigners.\n');
const first = run();
assert.equal(first.status, 0, first.stderr);
assert.match(first.stdout, /CONTEXT_STALE:/);
assert.match(first.stdout, /product-deprecated-register/);
assert.equal((first.stdout.match(/CONTEXT_STALE:/g) || []).length, 1);
// Context itself still leads; staleness never replaces it.
assert.match(first.stdout, /^# PRODUCT\.md/);
const second = run();
assert.equal(second.status, 0, second.stderr);
assert.doesNotMatch(second.stdout, /CONTEXT_STALE:/);
});
it('says nothing on a current project', () => {
write('PRODUCT.md', CURRENT_PRODUCT);
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /CONTEXT_STALE:/);
});
it('respects IMPECCABLE_NO_STALENESS_CHECK', () => {
write('PRODUCT.md', '# Product\n\n## Register\n\nbrand\n');
const res = run({ IMPECCABLE_NO_STALENESS_CHECK: '1' });
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /CONTEXT_STALE:/);
});
it('checks sidecar and config drift with no PRODUCT.md present', () => {
write('.impeccable/design.json', JSON.stringify({ schemaVersion: 1 }));
write('.impeccable/config.json', JSON.stringify({ detector: { ignoreRule: [] } }));
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /NO_PRODUCT_MD:/);
assert.match(res.stdout, /design-sidecar-schema-outdated/);
assert.match(res.stdout, /config-unknown-detector-keys/);
});
it('leaves the native-platform question to init when no PRODUCT.md exists', () => {
write('ios/Podfile', '');
write('src/app.css', ':root { --a: 1; --b: 2; --c: 3; }\n');
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.doesNotMatch(res.stdout, /platform-native-evidence/);
});
it('flags projectRoots globs that match nothing in a monorepo', () => {
write('package.json', JSON.stringify({ name: 'root', workspaces: ['apps/*'] }));
write('.impeccable/config.json', JSON.stringify({ projectRoots: ['services/*'] }));
write('PRODUCT.md', CURRENT_PRODUCT);
const res = run();
assert.equal(res.status, 0, res.stderr);
assert.match(res.stdout, /config-project-roots-match-nothing/);
});
});