mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
Treat empty and whitespace-only snapshot values as missing so malformed frontmatter cannot reintroduce plausible zeroes. Prepared with AI assistance under maintainer pbakaus's standing automation authorization.
683 lines
31 KiB
JavaScript
683 lines
31 KiB
JavaScript
/**
|
|
* Tests for context-signals.mjs — the signal gatherer behind the
|
|
* context-aware bare `/impeccable` (no-argument) path.
|
|
*
|
|
* The script collects deterministic project signals and emits JSON; it does
|
|
* not score or rank (the agent reasons over the raw signals). These tests
|
|
* cover signal collection and the never-throw / always-valid-JSON contract.
|
|
*
|
|
* Each test runs in its own scratch dir under os.tmpdir().
|
|
*/
|
|
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 { fileURLToPath } from 'node:url';
|
|
|
|
import { gatherSignals } from '../skill/scripts/context-signals.mjs';
|
|
|
|
const SCRIPT_PATH = path.join(
|
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
'..', 'skill', 'scripts', 'context-signals.mjs',
|
|
);
|
|
|
|
let scratch;
|
|
beforeEach(() => {
|
|
scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-signals-'));
|
|
});
|
|
afterEach(() => {
|
|
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);
|
|
}
|
|
|
|
describe('gatherSignals', () => {
|
|
it('reports no setup context in an empty dir', async () => {
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.setup.hasProduct, false);
|
|
assert.equal(s.setup.hasDesign, false);
|
|
assert.equal(Object.hasOwn(s.setup, 'register'), false);
|
|
assert.equal(s.setup.hasCode, false);
|
|
assert.equal(s.critique.latest, null);
|
|
});
|
|
|
|
it('detects PRODUCT.md, platform, and code presence', async () => {
|
|
write('PRODUCT.md', '# Product\n\n## Platform\n\nweb\n');
|
|
write('package.json', '{"name":"x"}');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.setup.hasProduct, true);
|
|
assert.equal(s.setup.platform, 'web');
|
|
assert.equal(s.setup.hasCode, true);
|
|
});
|
|
|
|
it('flags missing DESIGN.md when code exists', async () => {
|
|
write('PRODUCT.md', '# Product\n\n## Platform\n\nweb\n');
|
|
write('src/App.tsx', 'export default 1;');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.setup.hasProduct, true);
|
|
assert.equal(s.setup.hasDesign, false);
|
|
assert.equal(s.setup.hasCode, true);
|
|
assert.equal(s.setup.platform, 'web');
|
|
});
|
|
|
|
it('reads the newest critique snapshot score', async () => {
|
|
write('.impeccable/critique/2026-05-01T10-00-00Z__home.md',
|
|
'---\nslug: home\nscore: 6\np0: 1\np1: 3\ntimestamp: 2026-05-01T10-00-00Z\n---\nbody\n');
|
|
write('.impeccable/critique/2026-05-02T10-00-00Z__home.md',
|
|
'---\nslug: home\nscore: 8\np0: 0\np1: 1\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.critique.latest.score, 8); // newest by timestamp prefix
|
|
assert.equal(s.critique.latest.p0, 0);
|
|
assert.equal(s.critique.latest.slug, 'home');
|
|
});
|
|
|
|
it('reads the documented critique snapshot metadata keys', async () => {
|
|
write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md',
|
|
'---\nslug: pricing\ntotal_score: 24\np0_count: 2\np1_count: 5\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.critique.latest.score, 24);
|
|
assert.equal(s.critique.latest.p0, 2);
|
|
assert.equal(s.critique.latest.p1, 5);
|
|
});
|
|
|
|
it('reports missing critique metrics as null', async () => {
|
|
write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md',
|
|
'---\nslug: pricing\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.critique.latest.score, null);
|
|
assert.equal(s.critique.latest.p0, null);
|
|
assert.equal(s.critique.latest.p1, null);
|
|
});
|
|
|
|
it('reports empty and invalid critique metrics as null', async () => {
|
|
write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md',
|
|
'---\nslug: pricing\ntotal_score: \np0_count: \np1_count: nope\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.critique.latest.score, null);
|
|
assert.equal(s.critique.latest.p0, null);
|
|
assert.equal(s.critique.latest.p1, null);
|
|
});
|
|
|
|
it('reads the newest critique snapshot across target slugs', async () => {
|
|
write('.impeccable/critique/2026-05-01T10-00-00Z__home.md',
|
|
'---\nslug: home\nscore: 6\np0: 1\np1: 3\ntimestamp: 2026-05-01T10-00-00Z\n---\nbody\n');
|
|
write('.impeccable/critique/2026-05-02T10-00-00Z__pricing.md',
|
|
'---\nslug: pricing\nscore: 9\np0: 0\np1: 1\ntimestamp: 2026-05-02T10-00-00Z\n---\nbody\n');
|
|
write('.impeccable/critique/ignore.md', '# Critique ignores\n');
|
|
write('.impeccable/critique/9999-not-a-snapshot.md', '# Draft\n');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.critique.latest.slug, 'pricing');
|
|
assert.equal(s.critique.latest.score, 9);
|
|
assert.equal(
|
|
s.critique.latest.file,
|
|
'.impeccable/critique/2026-05-02T10-00-00Z__pricing.md',
|
|
);
|
|
});
|
|
|
|
it('handles a non-git dir without throwing', async () => {
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.isRepo, false);
|
|
assert.deepEqual(s.git.changedFiles, []);
|
|
assert.equal(s.git.changedCount, 0);
|
|
});
|
|
|
|
it('reports working-tree changes with full, untruncated paths', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('site/styles/home.css', 'a{}\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
// Modify it so it shows as ` M ...` (leading-space porcelain line) — the
|
|
// exact shape that a naive global trim would truncate to "ite/...".
|
|
write('site/styles/home.css', 'a{color:red}\n');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.isRepo, true);
|
|
assert.ok(
|
|
s.git.changedFiles.includes('site/styles/home.css'),
|
|
`expected full path, got: ${JSON.stringify(s.git.changedFiles)}`,
|
|
);
|
|
});
|
|
|
|
it('always includes a well-formed devServer probe', async () => {
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(typeof s.devServer.running, 'boolean');
|
|
assert.ok(Array.isArray(s.devServer.ports));
|
|
});
|
|
|
|
it('targets a local source dir (never a URL), even with a dev server up', async () => {
|
|
write('src/App.tsx', 'export default 1;');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.scan.via, 'source-dir');
|
|
assert.deepEqual(s.scan.targets, ['src']);
|
|
// No target is ever an http(s) URL.
|
|
assert.ok(s.scan.targets.every((t) => !/^https?:/.test(t)));
|
|
});
|
|
|
|
it('prefers the dirty tree: scans changed markup/style files', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
write('README.md', 'x\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
write('src/Hero.tsx', 'export const Hero = () => 2;\n'); // dirty
|
|
write('README.md', 'y\n'); // dirty but not scannable
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.scan.via, 'git-changes');
|
|
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // README.md filtered out
|
|
});
|
|
|
|
it('filters harness-dir files out of git-changes scan targets (#303)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
write('src/Hero.tsx', 'export const Hero = () => 2;\n'); // dirty app code
|
|
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // dirty vendored skill
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.scan.via, 'git-changes');
|
|
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']); // harness path filtered out
|
|
});
|
|
|
|
it('keeps hidden-source-dir files (VitePress/Storybook) in scan targets', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('.vitepress/theme/Layout.vue', '<template><div/></template>\n');
|
|
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
write('.vitepress/theme/Layout.vue', '<template><span/></template>\n'); // real UI source
|
|
write('.claude/skills/impeccable/scripts/detector.js', 'export const x = 2;\n'); // vendored
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.scan.via, 'git-changes');
|
|
assert.deepEqual(s.scan.targets, ['.vitepress/theme/Layout.vue']);
|
|
});
|
|
|
|
it('falls through to source dirs when only harness files changed (#303)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
write('.cursor/skills/impeccable/example.css', 'a{}\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
write('.cursor/skills/impeccable/example.css', 'a{color:red}\n'); // only harness dirty
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.scan.via, 'source-dir');
|
|
assert.deepEqual(s.scan.targets, ['src']);
|
|
});
|
|
|
|
it('diffs a feature branch against a develop integration branch (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'develop');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('checkout', '-q', '-b', 'feature/x');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
// The hardcoded main/master candidate list found no base here, so the
|
|
// committed feature work was invisible to the scan targets.
|
|
assert.equal(s.git.base, 'develop');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
assert.deepEqual(s.scan.targets, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('prefers the remote default branch (origin/HEAD) as the diff base (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'trunk');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
// Fabricate the remote's default-branch symref without a network remote.
|
|
git('update-ref', 'refs/remotes/origin/trunk', 'HEAD');
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk');
|
|
git('checkout', '-q', '-b', 'feature/y');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'trunk');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('a branch tracking the integration branch diffs against its upstream (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'release');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
// A self-pointing remote gives git the fetch refspec it needs to map
|
|
// refs/heads/release -> refs/remotes/origin/release; no network involved.
|
|
git('remote', 'add', 'origin', '.');
|
|
git('update-ref', 'refs/remotes/origin/release', 'HEAD');
|
|
git('checkout', '-q', '-b', 'feature/z');
|
|
git('branch', '-q', '--set-upstream-to=origin/release');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'release');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('sitting on the integration branch itself falls back to the working tree', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'develop');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
write('src/App.tsx', 'export default 2;\n'); // dirty, uncommitted
|
|
const s = await gatherSignals(scratch);
|
|
// No self-diff: base must be null and the dirty working tree is the scope.
|
|
assert.equal(s.git.base, null);
|
|
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
|
});
|
|
|
|
it('uses the remote-tracking ref when the base has no local branch (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'develop');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('update-ref', 'refs/remotes/origin/develop', 'HEAD');
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop');
|
|
git('checkout', '-q', '-b', 'feature/w');
|
|
git('branch', '-q', '-D', 'develop'); // remote default exists, local doesn't
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'develop');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('honors an upstream on a non-origin remote (fork workflow) (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'release');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('remote', 'add', 'upstream', '.');
|
|
git('update-ref', 'refs/remotes/upstream/release', 'HEAD');
|
|
git('checkout', '-q', '-b', 'feature/v');
|
|
git('branch', '-q', '--set-upstream-to=upstream/release');
|
|
git('branch', '-q', '-D', 'release'); // the tracked base lives only on the fork parent
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'release');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('an existing develop outranks a main-pointing origin/HEAD (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'main');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('branch', '-q', 'develop');
|
|
// Classic git-flow with the platform default never flipped off main.
|
|
git('update-ref', 'refs/remotes/origin/main', 'main');
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
|
git('checkout', '-q', 'develop');
|
|
git('checkout', '-q', '-b', 'feature/g');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
// Features merge to develop here; picking origin/HEAD's main would drag
|
|
// the develop-vs-main divergence into scan targets.
|
|
assert.equal(s.git.base, 'develop');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('remote signals cannot bypass the integration-branch guard (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'main');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('branch', '-q', 'develop');
|
|
git('checkout', '-q', 'develop');
|
|
// The remote default is main; sitting on develop must still not produce
|
|
// a develop-vs-main integration diff via the origin/HEAD signal.
|
|
git('update-ref', 'refs/remotes/origin/main', 'main');
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
|
write('src/App.tsx', 'export default 2;\n'); // dirty on develop
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, null);
|
|
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
|
});
|
|
|
|
it('honors a local (slashless) upstream branch (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'canary');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('checkout', '-q', '-b', 'feature/u');
|
|
git('branch', '-q', '--set-upstream-to=canary'); // local upstream, no remote
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
// canary is neither conventional nor remote, but the configured
|
|
// upstream names it as the merge target.
|
|
assert.equal(s.git.base, 'canary');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('sitting on a non-standard default branch keeps the working-tree scope (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'trunk');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('branch', '-q', 'develop'); // a conventional name also exists
|
|
git('update-ref', 'refs/remotes/origin/trunk', 'HEAD');
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/trunk');
|
|
write('src/App.tsx', 'export default 2;\n'); // dirty on trunk
|
|
const s = await gatherSignals(scratch);
|
|
// trunk IS the integration branch (origin/HEAD says so); develop must
|
|
// not win the candidate scan and produce a trunk-vs-develop diff.
|
|
assert.equal(s.git.base, null);
|
|
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
|
});
|
|
|
|
it('a detached HEAD keeps the working-tree scope (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'main');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('branch', '-q', 'develop');
|
|
git('checkout', '-q', '--detach');
|
|
write('src/App.tsx', 'export default 2;\n'); // dirty on a detached tip
|
|
const s = await gatherSignals(scratch);
|
|
// A detached checkout has no branch identity to diff for; picking
|
|
// develop here would refill changedFiles with integration divergence.
|
|
assert.equal(s.git.base, null);
|
|
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
|
});
|
|
|
|
it('the integration guard sees non-origin remote defaults (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'trunk');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('branch', '-q', 'develop');
|
|
// The only remote is upstream (fork-parent layout, no origin at all);
|
|
// its default branch is trunk, which is exactly where we're sitting.
|
|
git('remote', 'add', 'upstream', '.');
|
|
git('update-ref', 'refs/remotes/upstream/trunk', 'HEAD');
|
|
git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/trunk');
|
|
write('src/App.tsx', 'export default 2;\n'); // dirty on trunk
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, null);
|
|
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
|
});
|
|
|
|
it('finds a develop that exists only on a non-origin remote (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'feature/f');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
// Fork-parent layout: develop lives only as upstream/develop, no local
|
|
// copy, no origin remote, and the feature branch has no upstream.
|
|
git('remote', 'add', 'upstream', '.');
|
|
git('update-ref', 'refs/remotes/upstream/develop', 'HEAD');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'develop');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('a same-name default on a second remote still resolves (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'feature/h');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('remote', 'add', 'origin', '.');
|
|
git('remote', 'add', 'upstream', '.');
|
|
// origin advertises main but its tracking ref is gone (pruned); the
|
|
// real main lives only as upstream/main. Name-level dedup must not
|
|
// discard the upstream rev.
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
|
git('update-ref', 'refs/remotes/upstream/main', 'HEAD');
|
|
git('symbolic-ref', 'refs/remotes/upstream/HEAD', 'refs/remotes/upstream/main');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'main');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('a local upstream with a slash in its name is not misparsed (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'release/2.0');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('checkout', '-q', '-b', 'hotfix/x');
|
|
git('branch', '-q', '--set-upstream-to=release/2.0');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'hotfix work');
|
|
const s = await gatherSignals(scratch);
|
|
// "release" is not a remote here; the whole ref is the local base name.
|
|
assert.equal(s.git.base, 'release/2.0');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('a local upstream sharing the branch leaf name is not self-skipped (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'feature/foo');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('checkout', '-q', '-b', 'foo');
|
|
git('branch', '-q', '--set-upstream-to=feature/foo');
|
|
// Adversarial twist: a remote literally named "feature" exists, so any
|
|
// prefix-based guess would still misread the LOCAL feature/foo upstream
|
|
// as remote-tracking. Only the full symbolic ref disambiguates.
|
|
git('remote', 'add', 'feature', '.');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'work');
|
|
const s = await gatherSignals(scratch);
|
|
// Truncating feature/foo to "foo" made it look like the current branch
|
|
// and the valid upstream was discarded.
|
|
assert.equal(s.git.base, 'feature/foo');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('a pruned upstream tracking ref falls back to other remotes (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'feature/p');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('remote', 'add', 'origin', '.');
|
|
git('remote', 'add', 'upstream', '.');
|
|
// The branch tracks origin/main, but that tracking ref was pruned; the
|
|
// live main exists only on the upstream remote.
|
|
git('config', 'branch.feature/p.remote', 'origin');
|
|
git('config', 'branch.feature/p.merge', 'refs/heads/main');
|
|
git('update-ref', 'refs/remotes/upstream/main', 'HEAD');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'main');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('a remote-advertised default outranks a stale local checkout (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'main');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/base.css', 'a{}\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'A');
|
|
write('src/extra.css', 'b{}\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'A2');
|
|
git('update-ref', 'refs/remotes/origin/main', 'HEAD');
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/main');
|
|
git('checkout', '-q', '-b', 'feature/s');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
// The local main checkout is stale (still at A); the remote default is
|
|
// at A2. Diffing against the stale local would drag src/extra.css in.
|
|
git('branch', '-f', 'main', 'HEAD~2');
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'main');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('a develop-pointing remote default outranks a stale local develop (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'develop');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/base.css', 'a{}\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'A');
|
|
write('src/extra.css', 'b{}\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'A2');
|
|
git('update-ref', 'refs/remotes/origin/develop', 'HEAD');
|
|
git('symbolic-ref', 'refs/remotes/origin/HEAD', 'refs/remotes/origin/develop');
|
|
git('checkout', '-q', '-b', 'feature/t');
|
|
write('src/Hero.tsx', 'export const Hero = () => null;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'feature work');
|
|
git('branch', '-f', 'develop', 'HEAD~2'); // local develop is stale at A
|
|
const s = await gatherSignals(scratch);
|
|
assert.equal(s.git.base, 'develop');
|
|
assert.deepEqual(s.git.changedFiles, ['src/Hero.tsx']);
|
|
});
|
|
|
|
it('never diffs one integration branch against another (#302)', async () => {
|
|
const { execFileSync } = await import('node:child_process');
|
|
const git = (...args) => execFileSync('git', args, { cwd: scratch, stdio: 'ignore' });
|
|
git('init', '-q', '-b', 'main');
|
|
git('config', 'user.email', 't@example.com');
|
|
git('config', 'user.name', 'Test');
|
|
write('src/App.tsx', 'export default 1;\n');
|
|
git('add', '.');
|
|
git('commit', '-qm', 'init');
|
|
git('branch', '-q', 'develop'); // both integration branches exist
|
|
write('src/App.tsx', 'export default 2;\n'); // dirty on main
|
|
const s = await gatherSignals(scratch);
|
|
// Sitting on main must not pick develop as a base; the dirty working
|
|
// tree is the scope, exactly as before this change.
|
|
assert.equal(s.git.base, null);
|
|
assert.deepEqual(s.git.changedFiles, ['src/App.tsx']);
|
|
});
|
|
|
|
it('has empty scan.targets only when there is no code at all', async () => {
|
|
const s = await gatherSignals(scratch);
|
|
assert.deepEqual(s.scan.targets, []);
|
|
assert.equal(s.scan.via, null);
|
|
});
|
|
});
|
|
|
|
describe('context-signals CLI', () => {
|
|
it('emits valid JSON with all top-level signal groups', async () => {
|
|
const { spawnSync } = await import('node:child_process');
|
|
const res = spawnSync(process.execPath, [SCRIPT_PATH], { cwd: scratch, encoding: 'utf8' });
|
|
assert.equal(res.status, 0);
|
|
const parsed = JSON.parse(res.stdout);
|
|
for (const k of ['setup', 'critique', 'git', 'devServer']) {
|
|
assert.ok(k in parsed, `expected "${k}" in signals output`);
|
|
}
|
|
});
|
|
});
|