Merge branch 'next' into fix/2028-phase-complete-milestone-end-and-workstream-guard
This commit is contained in:
109
src/config.cts
109
src/config.cts
@@ -38,6 +38,14 @@ interface SetConfigValueResult {
|
||||
previousValue: unknown;
|
||||
}
|
||||
|
||||
interface UnsetConfigValueResult {
|
||||
updated: boolean;
|
||||
unset: true;
|
||||
key: string;
|
||||
value: null;
|
||||
previousValue: unknown;
|
||||
}
|
||||
|
||||
interface WorkstreamContext {
|
||||
configPath?: string;
|
||||
[key: string]: unknown;
|
||||
@@ -452,6 +460,87 @@ function _setNestedValue(
|
||||
return previousValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a value from the config object, allowing nested values via dot
|
||||
* notation (e.g., "review.models.gemini"). Mirrors `_setNestedValue`'s
|
||||
* prototype-pollution guard on every path segment (including intermediates).
|
||||
*
|
||||
* Unlike `_setNestedValue`, this NEVER creates missing intermediate objects —
|
||||
* if any segment along the path is missing (or not a plain, non-array
|
||||
* object), the key doesn't exist and we return early without mutating
|
||||
* `config` at all.
|
||||
*
|
||||
* Does not prune now-empty parent objects after deletion (matches the
|
||||
* conservative, structure-preserving behaviour callers expect from a bare
|
||||
* unset).
|
||||
*
|
||||
* Returns { previousValue, existed } — existed is false when the leaf key
|
||||
* (or an intermediate segment) was never present.
|
||||
* Calls error() (process.exit(1)) on prototype-pollution attempts.
|
||||
*/
|
||||
function _unsetNestedValue(
|
||||
config: Record<string, unknown>,
|
||||
keyPath: string,
|
||||
): { previousValue: unknown; existed: boolean } {
|
||||
const keys = keyPath.split('.');
|
||||
let current: Record<string, unknown> = config;
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
if (key === '__proto__' || key === 'prototype' || key === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
const existingChild = current[key];
|
||||
if (existingChild === undefined || existingChild === null || typeof existingChild !== 'object' || Array.isArray(existingChild)) {
|
||||
// Path doesn't exist — nothing to unset, and we must not create it.
|
||||
return { previousValue: undefined, existed: false };
|
||||
}
|
||||
current = existingChild as Record<string, unknown>;
|
||||
}
|
||||
const lastKey = keys[keys.length - 1];
|
||||
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
|
||||
error('Invalid config key (prototype pollution guard): ' + keyPath, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
const existed = Object.prototype.hasOwnProperty.call(current, lastKey);
|
||||
const previousValue = current[lastKey];
|
||||
if (existed) {
|
||||
delete current[lastKey];
|
||||
}
|
||||
return { previousValue, existed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes a key from the config file, allowing nested values via dot
|
||||
* notation. Mirrors `setConfigValue`'s load/lock/write cycle.
|
||||
*
|
||||
* Does not call `output()`, so can be used as one step in a command without triggering `exit(0)` in
|
||||
* the happy path. But note that `error()` will still `exit(1)` out of the process.
|
||||
*/
|
||||
function unsetConfigValue(cwd: string, keyPath: string): UnsetConfigValueResult {
|
||||
const configPath = path.join(planningDir(cwd), 'config.json');
|
||||
|
||||
return withPlanningLock(cwd, () => {
|
||||
// Load existing config or start with empty object
|
||||
let config: Record<string, unknown> = {};
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
config = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as Record<string, unknown>;
|
||||
}
|
||||
} catch (err) {
|
||||
error('Failed to read config.json: ' + (err as Error).message, ERROR_REASON.CONFIG_PARSE_FAILED);
|
||||
}
|
||||
|
||||
const { previousValue, existed } = _unsetNestedValue(config, keyPath);
|
||||
|
||||
// Write back
|
||||
try {
|
||||
platformWriteSync(configPath, JSON.stringify(config, null, 2));
|
||||
return { updated: existed, unset: true, key: keyPath, value: null, previousValue };
|
||||
} catch (err) {
|
||||
error('Failed to write config.json: ' + (err as Error).message);
|
||||
}
|
||||
}) as UnsetConfigValueResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value in the config file, allowing nested values via dot notation (e.g.,
|
||||
* "workflow.research").
|
||||
@@ -586,6 +675,7 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
|
||||
let parsedValue: unknown = val;
|
||||
if (val === 'true') parsedValue = true;
|
||||
else if (val === 'false') parsedValue = false;
|
||||
else if (val === 'null') parsedValue = null;
|
||||
// #1581: Number.isFinite (not !isNaN) so 'Infinity'/'-Infinity' are NOT
|
||||
// coerced to non-finite numbers that JSON.stringify later renders as `null`
|
||||
// (disk=null while the CLI echoed 'Infinity'). They fall through to the
|
||||
@@ -596,6 +686,25 @@ function cmdConfigSet(cwd: string, keyPath: string | undefined, value: string |
|
||||
try { parsedValue = JSON.parse(val); } catch { /* keep as string */ }
|
||||
}
|
||||
|
||||
// #2046: a bare `null` unsets (deletes) the key — the documented "Clear" action.
|
||||
// Short-circuits before every typed per-key validator so clearing a typed key
|
||||
// (enum/boolean/number) removes it rather than being rejected. Deleting (not
|
||||
// persisting JSON null) is the correct "clear": a persisted null is still a
|
||||
// present, truthy-adjacent value that consumers must special-case — worst for
|
||||
// secret keys where a leftover value can be passed as a real credential.
|
||||
if (parsedValue === null) {
|
||||
const unsetResult = unsetConfigValue(cwd, kp);
|
||||
if (isSecretKey(kp)) {
|
||||
const maskedPrev = unsetResult.previousValue === undefined
|
||||
? undefined
|
||||
: maskSecret(unsetResult.previousValue as Parameters<typeof maskSecret>[0]);
|
||||
output({ ...unsetResult, value: null, previousValue: maskedPrev, masked: true }, raw, `${kp} unset`);
|
||||
return;
|
||||
}
|
||||
output(unsetResult, raw, `${kp} unset`);
|
||||
return;
|
||||
}
|
||||
|
||||
// #1581: project_code is an identifier string — never number-coerce it. A
|
||||
// leading-zero code like '007' must persist verbatim (not collapse to 7).
|
||||
if (kp === 'project_code') {
|
||||
|
||||
@@ -178,7 +178,10 @@ function timeAgo(date: Date): string {
|
||||
function extractCanonicalPlanId(filename: string): string {
|
||||
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
|
||||
const parts = base.split('-').filter(Boolean);
|
||||
const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
|
||||
// #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
|
||||
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
||||
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
||||
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
||||
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
|
||||
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
|
||||
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
||||
|
||||
@@ -205,9 +205,29 @@ function extractPhaseToken(dirName: string): string {
|
||||
|
||||
const segments = rest.split('-');
|
||||
const tokenSegments: string[] = [];
|
||||
// #2043: distinguish a real (zero-padded, ≥2-digit) phase/sub-phase segment
|
||||
// from a single-digit slug word. A pure-numeric leading segment ("46") only
|
||||
// continues with ≥2-digit segments, so "46-6-rs-…" yields "46" (the "6" is the
|
||||
// slug's first word), not "46-6". Milestone-prefixed ids like "M1-2" reach here
|
||||
// with "M1-" already stripped as a project-code prefix (see
|
||||
// PROJECT_CODE_PREFIX_CAPTURE_RE_I), so "2" is the leading segment and the same
|
||||
// pure-numeric rule applies (M1-46-6-rs → "M1-46"). The firstLetterPrefixed
|
||||
// carve-out covers letter+digit leading segments that survive prefix stripping
|
||||
// because of punctuation (e.g. "P0.3-2"), whose single-digit continuation is
|
||||
// intentionally preserved (unchanged from prior behaviour).
|
||||
let firstLetterPrefixed = false;
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const seg = segments[i];
|
||||
if (/^\d/.test(seg) || (i === 0 && /^[A-Za-z]{1,3}\d/.test(seg))) {
|
||||
if (i === 0) {
|
||||
if (/^\d/.test(seg)) {
|
||||
tokenSegments.push(seg);
|
||||
} else if (/^[A-Za-z]{1,3}\d/.test(seg)) {
|
||||
tokenSegments.push(seg);
|
||||
firstLetterPrefixed = true;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
} else if (/^\d{2,}/.test(seg) || (firstLetterPrefixed && /^\d/.test(seg))) {
|
||||
tokenSegments.push(seg);
|
||||
} else {
|
||||
break;
|
||||
|
||||
@@ -104,7 +104,10 @@ function extractCanonicalPlanId(filename: string): string {
|
||||
.replace(/-SUMMARY\.md$/i, '')
|
||||
.replace(/\.md$/i, '');
|
||||
const parts = base.split('-').filter(Boolean);
|
||||
const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
|
||||
// #2043: a phase/plan token component is either a zero-padded number (≥2 digits)
|
||||
// or a single-digit-plus-letter id ("3A"); a *bare* single digit is a slug word,
|
||||
// so "46-6-rs-…" is not paired into a "46-6" id while "3A-01" stays intact.
|
||||
const tokenRe = /^(?:\d{2,}[A-Z]?|\d[A-Z])(?:\.\d+)*$/i;
|
||||
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
|
||||
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
|
||||
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
|
||||
|
||||
@@ -469,8 +469,12 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
}
|
||||
|
||||
const roadmapUsesHyphenedIds = [...normalized].some(n => n.includes('-'));
|
||||
// #2043: milestone-prefixed sub-phase components must be zero-padded (≥2 digits)
|
||||
// — "-\d{2,}" instead of "-0*\d+" — so a single-digit slug word after the phase
|
||||
// number (e.g. dir "46-6-rs-…") captures "46" and is not silently excluded from
|
||||
// the milestone as a bogus "46-6" id.
|
||||
const numericRe = roadmapUsesHyphenedIds
|
||||
? /^0*(\d+(?:-0*\d+)*[A-Za-z]?(?:\.\d+)*)/
|
||||
? /^0*(\d+(?:-\d{2,})*[A-Za-z]?(?:\.\d+)*)/
|
||||
: /^0*(\d+[A-Za-z]?(?:\.\d+)*)/;
|
||||
|
||||
function isDirInMilestone(dirName: string): boolean {
|
||||
|
||||
@@ -44,16 +44,25 @@ export const phaseDirNameRe = new RegExp(
|
||||
);
|
||||
// Extracts the full phase token from a directory name, including milestone-prefixed
|
||||
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
|
||||
// Greedily captures all leading all-digit segments before the first letter-start segment.
|
||||
// #2043: a *continuation* sub-phase segment must be zero-padded (≥2 digits), so a
|
||||
// single-digit slug word after a phase number (e.g. "46-6-rs-…", slug "6 Rs …") is
|
||||
// NOT absorbed — it captures "46", not "46-6". The first component stays "\d+"
|
||||
// (with the "[A-Z]?" suffix) so single-digit letter-suffixed phase ids ("1A") and
|
||||
// milestone-prefixed single-digit sub-phases ("M1-2" → prefix "M1-" stripped, then
|
||||
// "2") still match. The trailing boundary "(?:-|$)" (was "(?:-[a-z]|$)") lets a slug
|
||||
// that starts with a digit terminate the token.
|
||||
export const PHASE_TOKEN_FROM_DIR_RE = new RegExp(
|
||||
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d+)*[A-Z]?(?:\\.\\d+)*)(?:-[a-z]|$)`,
|
||||
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d{2,})*[A-Z]?(?:\\.\\d+)*)(?:-|$)`,
|
||||
'i',
|
||||
);
|
||||
export const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
||||
|
||||
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
|
||||
export function canonicalPlanStem(stem: string): string {
|
||||
const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d+)/i);
|
||||
// #2043: the plan component (after the phase number) must be zero-padded
|
||||
// (≥2 digits), so a digit-leading slug word (e.g. "46-6-rs-…") is not mistaken
|
||||
// for a "46-6" phase/plan pair.
|
||||
const m = stem.match(/^(\d+[A-Z]?(?:\.\d+)*-\d{2,})/i);
|
||||
return m ? m[1] : stem;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user