* 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:
5
.changeset/humble-cranes-click.md
Normal file
5
.changeset/humble-cranes-click.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 4509
|
||||||
|
---
|
||||||
|
**Whitespace-only planning scope values no longer create phantom directories** — `GSD_WORKSTREAM`, `GSD_PROJECT`, and their explicit equivalents now fall back to the root scope, while padded real names are normalized consistently.
|
||||||
@@ -683,7 +683,7 @@ function loadConfigResolved(cwd: string, options: Record<string, unknown> = {}):
|
|||||||
: (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
|
: (options['workstreamContext'] && Object.prototype.hasOwnProperty.call(options['workstreamContext'], 'ws'))
|
||||||
? (options['workstreamContext'] as Record<string, unknown>)['ws']
|
? (options['workstreamContext'] as Record<string, unknown>)['ws']
|
||||||
: (process.env['GSD_WORKSTREAM'] || null);
|
: (process.env['GSD_WORKSTREAM'] || null);
|
||||||
const ws = typeof activeWorkstream === 'string' ? activeWorkstream : (activeWorkstream === null ? null : null);
|
const ws = typeof activeWorkstream === 'string' ? activeWorkstream.trim() || null : null;
|
||||||
// wsRequested: true when caller explicitly requested a non-empty workstream.
|
// wsRequested: true when caller explicitly requested a non-empty workstream.
|
||||||
// Used for source labeling (Fix 4) and early absent-dir intercept (Fix 2).
|
// Used for source labeling (Fix 4) and early absent-dir intercept (Fix 2).
|
||||||
const wsRequested = ws != null && ws !== '';
|
const wsRequested = ws != null && ws !== '';
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { CONFIG_DEFAULTS } = configLoader;
|
|||||||
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import planningWorkspace = require('./planning-workspace.cjs');
|
import planningWorkspace = require('./planning-workspace.cjs');
|
||||||
const { planningDir, planningRoot, withPlanningLock } = planningWorkspace;
|
const { planningDir, planningRoot, resolveEnvWorkstream, withPlanningLock } = planningWorkspace;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import modelProfiles = require('./model-profiles.cjs');
|
import modelProfiles = require('./model-profiles.cjs');
|
||||||
const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
|
const { VALID_PROFILES, getAgentToModelMapForProfile, formatAgentToModelMapAsTable } = modelProfiles;
|
||||||
@@ -1269,7 +1269,7 @@ function resolveFromRootConfig(cwd: string, kp: string): { found: boolean; value
|
|||||||
// diverges from planningRoot without a workstream and loadConfigResolved does NOT
|
// diverges from planningRoot without a workstream and loadConfigResolved does NOT
|
||||||
// inherit root — matching the runtime's own `if (ws)` gate keeps the two surfaces
|
// inherit root — matching the runtime's own `if (ws)` gate keeps the two surfaces
|
||||||
// from diverging on the project-scoped (non-workstream) case.
|
// from diverging on the project-scoped (non-workstream) case.
|
||||||
if (!process.env['GSD_WORKSTREAM']) return { found: false, value: undefined };
|
if (!resolveEnvWorkstream()) return { found: false, value: undefined };
|
||||||
const root = planningRoot(cwd);
|
const root = planningRoot(cwd);
|
||||||
const rootConfigPath = path.join(root, 'config.json');
|
const rootConfigPath = path.join(root, 'config.json');
|
||||||
let rootConfig: Record<string, unknown>;
|
let rootConfig: Record<string, unknown>;
|
||||||
@@ -1388,7 +1388,7 @@ function cmdConfigPath(cwd: string, _raw: boolean, workstreamContext: Workstream
|
|||||||
* (caller uses `await` which is safe on a sync return value).
|
* (caller uses `await` which is safe on a sync return value).
|
||||||
*/
|
*/
|
||||||
function cmdMigrateConfig(cwd: string, raw: boolean): void {
|
function cmdMigrateConfig(cwd: string, raw: boolean): void {
|
||||||
const ws = process.env['GSD_WORKSTREAM'] || null;
|
const ws = resolveEnvWorkstream();
|
||||||
// #3749: resolve the migration target through the project-aware resolver so
|
// #3749: resolve the migration target through the project-aware resolver so
|
||||||
// GSD_PROJECT scopes the write; migrateOnDisk itself cannot (see its
|
// GSD_PROJECT scopes the write; migrateOnDisk itself cannot (see its
|
||||||
// configPathOverride note).
|
// configPathOverride note).
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ const {
|
|||||||
todosDir,
|
todosDir,
|
||||||
listAvailableWorkstreams,
|
listAvailableWorkstreams,
|
||||||
peekActiveWorkstream,
|
peekActiveWorkstream,
|
||||||
|
resolveEnvWorkstream,
|
||||||
diagnoseUnresolvedActiveWorkstream,
|
diagnoseUnresolvedActiveWorkstream,
|
||||||
describeUnresolvedWorkstreamReason,
|
describeUnresolvedWorkstreamReason,
|
||||||
findContextMdIn,
|
findContextMdIn,
|
||||||
@@ -1552,7 +1553,7 @@ function cmdInitNewMilestone(cwd: string, raw: boolean, options: Record<string,
|
|||||||
// would otherwise silently delete a stale/invalid pointer as a side effect
|
// would otherwise silently delete a stale/invalid pointer as a side effect
|
||||||
// of building a JSON report field, and (per #3579) could change what a
|
// of building a JSON report field, and (per #3579) could change what a
|
||||||
// LATER resolution in the same process observes.
|
// LATER resolution in the same process observes.
|
||||||
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
const resolvedWorkstream = resolveEnvWorkstream() ?? peekActiveWorkstream(cwd);
|
||||||
const workstreamActive = !!resolvedWorkstream;
|
const workstreamActive = !!resolvedWorkstream;
|
||||||
const flatMode = !workstreamActive;
|
const flatMode = !workstreamActive;
|
||||||
|
|
||||||
@@ -3370,7 +3371,7 @@ function cmdInitUpdate(cwd: string, raw: boolean, options: Record<string, unknow
|
|||||||
function cmdInitTransition(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
function cmdInitTransition(cwd: string, raw: boolean, options: Record<string, unknown> = {}): void {
|
||||||
// #3579 root-cause fix: read-only informational field — peek, don't
|
// #3579 root-cause fix: read-only informational field — peek, don't
|
||||||
// self-heal (see cmdInitNewMilestone's identical rationale above).
|
// self-heal (see cmdInitNewMilestone's identical rationale above).
|
||||||
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
const resolvedWorkstream = resolveEnvWorkstream() ?? peekActiveWorkstream(cwd);
|
||||||
const workstreamActive = !!resolvedWorkstream;
|
const workstreamActive = !!resolvedWorkstream;
|
||||||
|
|
||||||
const result: Record<string, unknown> = {
|
const result: Record<string, unknown> = {
|
||||||
@@ -3470,7 +3471,7 @@ function cmdInitProgress(cwd: string, raw: boolean, options: Record<string, unkn
|
|||||||
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
||||||
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
||||||
// which would misreport a present-but-bad marker as no marker at all.
|
// which would misreport a present-but-bad marker as no marker at all.
|
||||||
const _resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
const _resolvedWorkstream = resolveEnvWorkstream() ?? peekActiveWorkstream(cwd);
|
||||||
if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
|
if (_availableWorkstreams.length > 0 && !_resolvedWorkstream) {
|
||||||
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
||||||
// from the shared .planning/active-workstream marker, so reaching this
|
// from the shared .planning/active-workstream marker, so reaching this
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ const { computeHaltPropagation, buildSummaryFileIndex, isSummaryFileHalted, isSu
|
|||||||
const {
|
const {
|
||||||
planningDir, withPlanningLock, listAvailableWorkstreams,
|
planningDir, withPlanningLock, listAvailableWorkstreams,
|
||||||
peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason,
|
peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, describeUnresolvedWorkstreamReason,
|
||||||
resolvePhaseIdConvention,
|
resolveEnvWorkstream, resolvePhaseIdConvention,
|
||||||
} = planningWorkspace;
|
} = planningWorkspace;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- milestone-lock.cjs is an export= CommonJS module
|
||||||
import milestoneLockMod = require('./milestone-lock.cjs');
|
import milestoneLockMod = require('./milestone-lock.cjs');
|
||||||
@@ -3358,7 +3358,7 @@ function cmdPhaseComplete(cwd: string, phaseNum: string, raw: boolean): void {
|
|||||||
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
// non-mutating peek so an unresolvable pointer isn't self-healed (cleared)
|
||||||
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
// here and then found "absent" by diagnoseUnresolvedActiveWorkstream below,
|
||||||
// which would misreport a present-but-bad marker as no marker at all.
|
// which would misreport a present-but-bad marker as no marker at all.
|
||||||
const resolvedWorkstream = process.env['GSD_WORKSTREAM'] || peekActiveWorkstream(cwd);
|
const resolvedWorkstream = resolveEnvWorkstream() ?? peekActiveWorkstream(cwd);
|
||||||
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
if (availableWorkstreams.length > 0 && !resolvedWorkstream) {
|
||||||
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
// #3579: getActiveWorkstream now inherits a pointer-less session's read
|
||||||
// from the shared .planning/active-workstream marker, so reaching this
|
// from the shared .planning/active-workstream marker, so reaching this
|
||||||
|
|||||||
@@ -139,12 +139,15 @@ type WorkstreamAdapterOpts = Record<string, unknown>;
|
|||||||
* two-readers-two-bases lesson).
|
* two-readers-two-bases lesson).
|
||||||
*/
|
*/
|
||||||
function resolveEnvWorkstream(): string | null {
|
function resolveEnvWorkstream(): string | null {
|
||||||
return process.env['GSD_WORKSTREAM'] ?? null;
|
const value = process.env['GSD_WORKSTREAM']?.trim();
|
||||||
|
return value || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function planningDir(cwd: string, ws?: string | null, project?: string | null): string {
|
function planningDir(cwd: string, ws?: string | null, project?: string | null): string {
|
||||||
if (project === undefined) project = process.env['GSD_PROJECT'] ?? null;
|
if (project === undefined) project = process.env['GSD_PROJECT']?.trim() || null;
|
||||||
|
else if (typeof project === 'string') project = project.trim() || null;
|
||||||
if (ws === undefined) ws = resolveEnvWorkstream();
|
if (ws === undefined) ws = resolveEnvWorkstream();
|
||||||
|
else if (typeof ws === 'string') ws = ws.trim() || null;
|
||||||
|
|
||||||
// Reject path separators and traversal components in project/workstream names
|
// Reject path separators and traversal components in project/workstream names
|
||||||
const BAD_SEGMENT = /[/\\]|\.\./;
|
const BAD_SEGMENT = /[/\\]|\.\./;
|
||||||
@@ -210,7 +213,7 @@ function worktreesOptedOutUnguarded(cwd: string): boolean {
|
|||||||
};
|
};
|
||||||
const scoped = ownKey(readCfg(path.join(planningDir(cwd), 'config.json')));
|
const scoped = ownKey(readCfg(path.join(planningDir(cwd), 'config.json')));
|
||||||
if (scoped.present) return scoped.value === false;
|
if (scoped.present) return scoped.value === false;
|
||||||
if (process.env['GSD_WORKSTREAM']) {
|
if (resolveEnvWorkstream() !== null) {
|
||||||
const root = ownKey(readCfg(path.join(planningRoot(cwd), 'config.json')));
|
const root = ownKey(readCfg(path.join(planningRoot(cwd), 'config.json')));
|
||||||
if (root.present) return root.value === false;
|
if (root.present) return root.value === false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -494,6 +494,26 @@ describe('loadConfigResolved — provenance', () => {
|
|||||||
assert.equal(result.degraded, false);
|
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', () => {
|
test('Fix 2a: GSD_WORKSTREAM set to nonexistent workstream (dir absent) → source:"root", degraded:true', () => {
|
||||||
writeConfig(tmpDir, { model_profile: 'root-value' });
|
writeConfig(tmpDir, { model_profile: 'root-value' });
|
||||||
const origWs = process.env['GSD_WORKSTREAM'];
|
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');
|
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)', () => {
|
test('succeeds with --ws (reads the named workstream, not root)', () => {
|
||||||
seedWs('alpha', 'v9.0');
|
seedWs('alpha', 'v9.0');
|
||||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'milestone: v7.1\nstatus: executing\n');
|
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)', () => {
|
test('phase next-decimal --ws is unaffected (planningDir-scoped already)', () => {
|
||||||
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4225-dec-'));
|
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-4225-dec-'));
|
||||||
activeDirs.push(repoDir);
|
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');
|
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
|
// An explicit --ws satisfies the guard (it sets GSD_WORKSTREAM upstream) AND
|
||||||
// targets that workstream — the write must land in the workstream's own
|
// targets that workstream — the write must land in the workstream's own
|
||||||
// STATE.md/ROADMAP.md, leaving root untouched.
|
// STATE.md/ROADMAP.md, leaving root untouched.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
|
const fc = require('fast-check');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
const path = require('path');
|
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/);
|
||||||
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', () => {
|
describe('planning-workspace: session adapter precedence', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user