fix(#4652): use the validated path, collapse the duplication, correct two false claims
Seven findings from the two-axis review, all fixed in place. THE ONE THAT MATTERS: cmdTodoComplete validated sourcePath and targetPath and then ran every fs call against the RAW strings — existsSync, statSync, readFileSync, platformWriteSync, unlinkSync, and the dry-run path payload — never sourceCheck.resolved / targetCheck.resolved. That is the exact "validate one path, use another" shape ADR-4650 names as the defect this epic exists to prevent, and it is the same bug this phase had just fixed in check-command-router. Committed inside the fix for it. All I/O now uses the resolved paths; user-facing messages still echo the raw filename, never a resolved absolute path. A VACUOUS TEST, and the false doc claim it was propping up. The test "[RED #4327] an absolute path outside the project is rejected" would have passed with ZERO containment logic: path.join(pendingDir, '/abs/outside/x') yields <pendingDir>/abs/outside/x — Node does not let a later absolute segment escape — so the name is FOLDED under the root, passes containment, and simply 404s. The test only ever observed "Todo not found". It now asserts what is actually true and actually valuable: an absolute name is neutralized, and the real outside file is not read, not moved, and still present afterward. docs/CLI-TOOLS.md claimed such a path "is rejected as a usage error", which was false; it now describes the fold-under-root behavior. Traversal and embedded separators ARE rejected, and those claims stand. DUPLICATION THIS EPIC EXISTS TO REMOVE. resolvePath already did isAbsolute-or-join + validatePath + reject; cmdGapAnalysisPlanPost and cmdCheckPredicate each re-inlined the identical triplet in the same file. Both now call resolvePath. Cost, stated rather than hidden: its generic message replaces the two sites' distinct "phase-dir escapes…" wording. The message still names the offending input, and one predicate with one message is the point. SYMLINK COVERAGE was required by #4652's "Done when" and was missing. Added for both the todos root and --phase-dir, skipping cleanly on EPERM so the Windows lanes do not fail where unprivileged symlink creation is disallowed. Both fast-check properties were UNSEEDED. Seeded now. The changeset named "check decision-coverage-plan" as a boundary; that is a caller of the shared resolvePath, which the body never mentioned. Corrected. DISCLOSED, not hidden: ctx.phaseDir is now always the resolved ABSOLUTE path, so ${PHASE_DIR} interpolation and the "not found in <targetDir>" message show an absolute value where a relative --phase-dir previously produced a relative one. That is an observable output change. A test pins it and docs/reference/gate-predicates.md states it. Also regenerated scripts/lib/platform-conformance-tier.generated.cjs and its macos twin — the new tests changed check-predicate.test.cjs's tier classification. Caught by npm run lint:ci locally rather than by a bench run. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1199,17 +1199,9 @@ function cmdGapAnalysisPlanPost(projectDir: string, args: string[], raw: boolean
|
||||
error('gap-analysis.plan-post requires a phase-dir argument: check gap-analysis.plan-post <phase-dir> [phase-req-ids]', ERROR_REASON.SDK_MISSING_ARG);
|
||||
return;
|
||||
}
|
||||
const phaseDirCheck = validatePath(
|
||||
path.isAbsolute(phaseDir) ? phaseDir : path.join(projectDir, phaseDir),
|
||||
projectDir,
|
||||
{ allowAbsolute: true },
|
||||
);
|
||||
if (!phaseDirCheck.safe) {
|
||||
error(`phase-dir escapes its allowed directory: ${phaseDir}`, ERROR_REASON.USAGE);
|
||||
return;
|
||||
}
|
||||
const resolvedPhaseDir = resolvePath(phaseDir, projectDir);
|
||||
const phaseReqIds = args[3] ?? undefined;
|
||||
const result = runGapAnalysis(projectDir, phaseDirCheck.resolved, { phaseReqIds });
|
||||
const result = runGapAnalysis(projectDir, resolvedPhaseDir, { phaseReqIds });
|
||||
// Uniform gate contract: block = false (gap-analysis is always advisory, never blocks).
|
||||
// `message` carries the human-readable gap analysis report so the dispatch's
|
||||
// advisory branch can surface it. --raw emits JSON (rawValue=undefined), not
|
||||
@@ -1379,16 +1371,7 @@ function cmdCheckPredicate(projectDir: string, args: string[], raw: boolean): vo
|
||||
const rawPhaseDir = flags['phase-dir'];
|
||||
let resolvedPhaseDir: string | undefined = rawPhaseDir;
|
||||
if (typeof rawPhaseDir === 'string' && rawPhaseDir !== '') {
|
||||
const phaseDirCheck = validatePath(
|
||||
path.isAbsolute(rawPhaseDir) ? rawPhaseDir : path.join(projectDir, rawPhaseDir),
|
||||
projectDir,
|
||||
{ allowAbsolute: true },
|
||||
);
|
||||
if (!phaseDirCheck.safe) {
|
||||
error(`phase-dir escapes its allowed directory: ${rawPhaseDir}`, ERROR_REASON.USAGE);
|
||||
return;
|
||||
}
|
||||
resolvedPhaseDir = phaseDirCheck.resolved;
|
||||
resolvedPhaseDir = resolvePath(rawPhaseDir, projectDir);
|
||||
}
|
||||
const ctx = {
|
||||
cwd: projectDir,
|
||||
|
||||
@@ -3502,7 +3502,10 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod
|
||||
error(`todo file escapes its allowed directory: ${filename as string}`, ERROR_REASON.USAGE);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(sourcePath)) {
|
||||
const resolvedSource = sourceCheck.resolved;
|
||||
const resolvedTarget = targetCheck.resolved;
|
||||
|
||||
if (!fs.existsSync(resolvedSource)) {
|
||||
error(`Todo not found: ${filename as string}`);
|
||||
}
|
||||
|
||||
@@ -3510,11 +3513,11 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod
|
||||
// todosRoot, so containment passes) but are not a todo file — reject them
|
||||
// the same way as any other invalid name instead of letting
|
||||
// fs.readFileSync throw an uncaught EISDIR with an absolute-path stack trace.
|
||||
if (!fs.statSync(sourcePath).isFile()) {
|
||||
if (!fs.statSync(resolvedSource).isFile()) {
|
||||
error(`todo name is not a file: ${filename as string}`, ERROR_REASON.USAGE);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(sourcePath, 'utf-8');
|
||||
const content = fs.readFileSync(resolvedSource, 'utf-8');
|
||||
const today = realClock.localToday();
|
||||
|
||||
// #4096: --dry-run mirrors `milestone complete --dry-run` (#2118) — every
|
||||
@@ -3527,8 +3530,8 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod
|
||||
file: filename,
|
||||
date: today,
|
||||
would_move: {
|
||||
source: path.relative(cwd, sourcePath).split(path.sep).join('/'),
|
||||
target: path.relative(cwd, path.join(completedDir, filename as string)).split(path.sep).join('/'),
|
||||
source: path.relative(cwd, resolvedSource).split(path.sep).join('/'),
|
||||
target: path.relative(cwd, resolvedTarget).split(path.sep).join('/'),
|
||||
},
|
||||
would_set: { completed: today, status: 'completed' },
|
||||
}, raw);
|
||||
@@ -3541,8 +3544,8 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod
|
||||
|
||||
const completedContent = upsertTodoCompletionFields(content, today);
|
||||
|
||||
platformWriteSync(path.join(completedDir, filename as string), completedContent);
|
||||
fs.unlinkSync(sourcePath);
|
||||
platformWriteSync(resolvedTarget, completedContent);
|
||||
fs.unlinkSync(resolvedSource);
|
||||
|
||||
output({ completed: true, file: filename, date: today }, raw, 'completed');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user