fix(#1202): make verify key-links wave-aware for planned future files (#1219)

* fix(#1202): make verify key-links wave-aware for planned future files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: backfill changeset PR number (#1219)

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-14 11:28:14 -04:00
committed by GitHub
parent 1a186013a4
commit 2e8f4f6de1
3 changed files with 232 additions and 9 deletions

View File

@@ -21,6 +21,8 @@ import frontmatterMod = require('./frontmatter.cjs');
import stateMod = require('./state.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- model-profiles.cjs is an export= CommonJS module
import modelProfilesMod = require('./model-profiles.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports -- plan-scan.cjs is an export= CommonJS module
import planScanMod = require('./plan-scan.cjs');
import { execGit, platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
import { PACKAGE_NAME } from './package-identity.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
@@ -525,6 +527,36 @@ function cmdVerifyArtifacts(cwd: string, planFilePath: string, raw: boolean): vo
);
}
/**
* Returns a Set of file paths (relative to cwd) that are promised by plans in
* the same phase directory at a wave number >= minWave.
*
* Used by cmdVerifyKeyLinks to avoid hard-failing a missing `from:` file that
* is a planned future artifact (fix #1202).
*/
function collectPromisedFilesAtOrAfterWave(phaseDir: string, minWave: number): Set<string> {
const promised = new Set<string>();
const { planFiles } = planScanMod.scanPhasePlans(phaseDir);
for (const planFile of planFiles) {
const planFullPath = path.join(phaseDir, planFile);
const planContent = safeReadFile(planFullPath);
if (!planContent) continue;
const fm = extractFrontmatter(planContent);
const waveRaw = fm['wave'];
const wave = typeof waveRaw === 'string' ? parseInt(waveRaw, 10) : (typeof waveRaw === 'number' ? waveRaw : NaN);
if (isNaN(wave) || wave < minWave) continue;
const filesModified = fm['files_modified'];
if (!filesModified) continue;
const files: unknown[] = Array.isArray(filesModified)
? filesModified
: (typeof filesModified === 'string' ? [filesModified] : []);
for (const f of files) {
if (typeof f === 'string' && f.trim()) promised.add(f.trim());
}
}
return promised;
}
function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): void {
if (!planFilePath) {
error('plan file path required');
@@ -542,7 +574,27 @@ function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): voi
return;
}
// Derive the current plan's wave number and phase directory for wave-aware
// missing-file handling (fix #1202).
const currentFm = extractFrontmatter(content);
const currentWaveRaw = currentFm['wave'];
const currentWave = typeof currentWaveRaw === 'string'
? parseInt(currentWaveRaw, 10)
: (typeof currentWaveRaw === 'number' ? currentWaveRaw : 1);
const phaseDir = path.dirname(fullPath);
// Collect files promised by plans at wave >= currentWave (lazy: computed once
// the first time a missing source is encountered).
let promisedFiles: Set<string> | null = null;
function getPromisedFiles(): Set<string> {
if (promisedFiles === null) {
promisedFiles = collectPromisedFilesAtOrAfterWave(phaseDir, isNaN(currentWave) ? 1 : currentWave);
}
return promisedFiles;
}
const results: Record<string, unknown>[] = [];
let pendingCount = 0;
for (const link of keyLinks) {
if (typeof link === 'string') continue;
const check: Record<string, unknown> = {
@@ -553,9 +605,19 @@ function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): voi
detail: '',
};
const sourceContent = safeReadFile(path.join(cwd, (link['from'] as string) || ''));
const fromPath = (link['from'] as string) || '';
const sourceContent = safeReadFile(path.join(cwd, fromPath));
if (!sourceContent) {
check['detail'] = 'Source file not found (from: must be a relative file path; describe components/endpoints in via:)';
// Check if the missing file is promised by a plan at the same or later wave.
const promised = getPromisedFiles();
const isPromised = fromPath.trim() !== '' && promised.has(fromPath.trim());
if (isPromised) {
check['pending'] = true;
check['detail'] = 'Source file not yet created — declared in files_modified of a same-or-later-wave plan';
pendingCount++;
} else {
check['detail'] = 'Source file not found (from: must be a relative file path; describe components/endpoints in via:)';
}
} else if (link['pattern']) {
try {
const regex = new RegExp(link['pattern'] as string);
@@ -587,15 +649,20 @@ function cmdVerifyKeyLinks(cwd: string, planFilePath: string, raw: boolean): voi
}
const verified = results.filter((r) => r['verified']).length;
// A pending link (from: file promised by a same-or-later-wave plan) is not a
// hard failure — it should not count against the all_verified gate (#1202).
const hardFailed = results.filter((r) => !r['verified'] && !r['pending']).length;
const allVerified = hardFailed === 0;
output(
{
all_verified: verified === results.length,
all_verified: allVerified,
verified,
pending: pendingCount,
total: results.length,
links: results,
},
raw,
verified === results.length ? 'valid' : 'invalid',
allVerified ? 'valid' : 'invalid',
);
}