* test(#4462): expose whitespace workstream scope * fix(#4462): normalize the workstream environment scope * docs(#4462): add changeset for #4509 * fix(#4462): normalize sibling planning scope readers * test(#4462): cover workstream normalization properties * fix(#4462): share normalized workstream resolution --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -494,6 +494,26 @@ describe('loadConfigResolved — provenance', () => {
|
||||
assert.equal(result.degraded, false);
|
||||
});
|
||||
|
||||
test('whitespace-only explicit and environment workstreams resolve and label the root (#4462)', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'quality' });
|
||||
for (const workstream of [' ', '\t', '\n', ' \t\n ']) {
|
||||
const explicit = loadConfigResolved(tmpDir, { workstream });
|
||||
assert.equal(explicit.source, 'root');
|
||||
assert.equal(explicit.degraded, false);
|
||||
|
||||
const original = process.env.GSD_WORKSTREAM;
|
||||
try {
|
||||
process.env.GSD_WORKSTREAM = workstream;
|
||||
const ambient = loadConfigResolved(tmpDir);
|
||||
assert.equal(ambient.source, 'root');
|
||||
assert.equal(ambient.degraded, false);
|
||||
} finally {
|
||||
if (original === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('Fix 2a: GSD_WORKSTREAM set to nonexistent workstream (dir absent) → source:"root", degraded:true', () => {
|
||||
writeConfig(tmpDir, { model_profile: 'root-value' });
|
||||
const origWs = process.env['GSD_WORKSTREAM'];
|
||||
|
||||
@@ -2968,6 +2968,21 @@ describe('#1912 — init.progress fails safe in workstream mode with no active w
|
||||
assert.match(result.error || '', /workstream|--ws/i, 'error should name the workstream requirement');
|
||||
});
|
||||
|
||||
test('treats a whitespace environment workstream as unset and refuses root progress', (t) => {
|
||||
seedWs('alpha', 'v9.0');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
|
||||
const previous = process.env.GSD_WORKSTREAM;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = previous;
|
||||
});
|
||||
process.env.GSD_WORKSTREAM = ' ';
|
||||
|
||||
const result = runGsdTools('init progress', tmpDir);
|
||||
assert.equal(result.success, false, 'a whitespace workstream must not bypass the root-write guard');
|
||||
assert.match(result.error || '', /workstream|--ws/i);
|
||||
});
|
||||
|
||||
test('succeeds with --ws (reads the named workstream, not root)', () => {
|
||||
seedWs('alpha', 'v9.0');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
|
||||
|
||||
@@ -2948,6 +2948,28 @@ describe('phase add --ws workstream-scoped allocation vs sibling git worktrees (
|
||||
);
|
||||
});
|
||||
|
||||
test('whitespace-only GSD_WORKSTREAM uses the root sibling horizon (#4462)', () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4462-blank-ws-'));
|
||||
activeDirs.push(repoDir);
|
||||
initWsRepo(repoDir, 2, 'ws-alpha', 39);
|
||||
addSiblingAtHead(repoDir);
|
||||
|
||||
const result = runGsdTools(
|
||||
['query', 'phase.add', 'Root next'],
|
||||
repoDir,
|
||||
{ GSD_WORKSTREAM: ' ' },
|
||||
);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_number, 3);
|
||||
assert.strictEqual(output.directory, '.planning/phases/03-root-next');
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(repoDir, '.planning', 'workstreams', ' ')),
|
||||
'an effectively-empty env value must not mint a whitespace-named workstream',
|
||||
);
|
||||
});
|
||||
|
||||
test('phase next-decimal --ws is unaffected (planningDir-scoped already)', () => {
|
||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4225-dec-'));
|
||||
activeDirs.push(repoDir);
|
||||
@@ -6203,6 +6225,22 @@ describe('#2028 — phase complete milestone-end + workstream guard', () => {
|
||||
assert.match(result.error || '', /workstream|--ws/i, 'error should name the workstream requirement');
|
||||
});
|
||||
|
||||
test('refuses to write root when GSD_WORKSTREAM is whitespace only', (t) => {
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', 'alpha'), { recursive: true });
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n');
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: A\n**Goal:** x\n');
|
||||
const previous = process.env.GSD_WORKSTREAM;
|
||||
t.after(() => {
|
||||
if (previous === undefined) delete process.env.GSD_WORKSTREAM;
|
||||
else process.env.GSD_WORKSTREAM = previous;
|
||||
});
|
||||
process.env.GSD_WORKSTREAM = ' \t ';
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
assert.equal(result.success, false, 'a whitespace workstream must not write the shared root');
|
||||
assert.match(result.error || '', /workstream|--ws/i);
|
||||
});
|
||||
|
||||
// An explicit --ws satisfies the guard (it sets GSD_WORKSTREAM upstream) AND
|
||||
// targets that workstream — the write must land in the workstream's own
|
||||
// STATE.md/ROADMAP.md, leaving root untouched.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fc = require('fast-check');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
@@ -53,6 +54,56 @@ describe('planning-workspace: planningDir/planningPaths parity', () => {
|
||||
assert.throws(() => planningDir(cwd, 'foo/bar', null), /invalid path characters/);
|
||||
assert.throws(() => planningDir(cwd, 'foo\\bar', null), /invalid path characters/);
|
||||
});
|
||||
|
||||
test('normalizes whitespace-only and padded environment scope names (#4462)', () => {
|
||||
for (const whitespace of [' ', '\t', '\n', ' \t\n ']) {
|
||||
process.env.GSD_WORKSTREAM = whitespace;
|
||||
process.env.GSD_PROJECT = whitespace;
|
||||
assert.strictEqual(planningDir(cwd), path.join(cwd, '.planning'));
|
||||
}
|
||||
|
||||
process.env.GSD_WORKSTREAM = ' feature-x ';
|
||||
process.env.GSD_PROJECT = ' my-app ';
|
||||
assert.strictEqual(
|
||||
planningDir(cwd),
|
||||
path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'),
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes explicit project and workstream arguments before routing (#4462)', () => {
|
||||
assert.strictEqual(planningDir(cwd, '\t', '\n'), path.join(cwd, '.planning'));
|
||||
assert.strictEqual(
|
||||
planningDir(cwd, ' feature-x ', ' my-app '),
|
||||
path.join(cwd, '.planning', 'my-app', 'workstreams', 'feature-x'),
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizes arbitrary whitespace-padded environment workstream names idempotently (#4462)', () => {
|
||||
const whitespace = fc.array(fc.constantFrom(' ', '\t', '\n', '\r'), { maxLength: 8 })
|
||||
.map((chars) => chars.join(''));
|
||||
const segment = fc.array(fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789_-'), {
|
||||
minLength: 1,
|
||||
maxLength: 24,
|
||||
}).map((chars) => chars.join(''));
|
||||
|
||||
fc.assert(fc.property(whitespace, segment, whitespace, (leading, name, trailing) => {
|
||||
process.env.GSD_WORKSTREAM = `${leading}${name}${trailing}`;
|
||||
assert.strictEqual(
|
||||
planningDir(cwd),
|
||||
path.join(cwd, '.planning', 'workstreams', name),
|
||||
'the environment value must resolve exactly as its trimmed form',
|
||||
);
|
||||
}));
|
||||
|
||||
fc.assert(fc.property(whitespace, (value) => {
|
||||
process.env.GSD_WORKSTREAM = value;
|
||||
assert.strictEqual(
|
||||
planningDir(cwd),
|
||||
path.join(cwd, '.planning'),
|
||||
'an all-whitespace environment value must be indistinguishable from unset',
|
||||
);
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('planning-workspace: session adapter precedence', () => {
|
||||
|
||||
Reference in New Issue
Block a user