gsdd-cli 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/bin/gsdd.mjs +4 -4
- package/bin/lib/closeout-report.mjs +292 -0
- package/bin/lib/control-map.mjs +612 -15
- package/bin/lib/health.mjs +46 -29
- package/bin/lib/init-runtime.mjs +8 -1
- package/bin/lib/lifecycle-preflight.mjs +58 -1
- package/bin/lib/phase.mjs +386 -11
- package/bin/lib/rendering.mjs +15 -1
- package/bin/lib/ui-proof.mjs +39 -4
- package/distilled/DESIGN.md +17 -9
- package/distilled/README.md +3 -1
- package/distilled/workflows/verify.md +3 -2
- package/docs/USER-GUIDE.md +1 -1
- package/package.json +2 -2
package/bin/lib/health.mjs
CHANGED
|
@@ -14,21 +14,19 @@ import { findUiProofBundleFiles, readUiProofBundleFile, validateUiProofBundle }
|
|
|
14
14
|
import { resolveWorkspaceContext } from './workspace-root.mjs';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
* ctx should provide: { frameworkVersion, workflows }
|
|
17
|
+
* Build the structured health report without printing or mutating workspace
|
|
18
|
+
* state. ctx should provide: { frameworkVersion, workflows }.
|
|
19
19
|
*/
|
|
20
|
-
export function
|
|
21
|
-
return async function cmdHealth(...healthArgs) {
|
|
22
|
-
const jsonMode = healthArgs.includes('--json');
|
|
20
|
+
export function buildHealthReport(ctx, healthArgs = []) {
|
|
23
21
|
const { planningDir, workspaceRoot, invalid, error } = resolveWorkspaceContext(healthArgs);
|
|
24
22
|
if (invalid) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
23
|
+
return {
|
|
24
|
+
status: 'broken',
|
|
25
|
+
errors: [{ id: 'E1', severity: 'ERROR', message: error, fix: 'Pass --workspace-root with a real path or remove the flag.' }],
|
|
26
|
+
warnings: [],
|
|
27
|
+
info: [],
|
|
28
|
+
humanMessage: error,
|
|
29
|
+
};
|
|
32
30
|
}
|
|
33
31
|
const cwd = workspaceRoot;
|
|
34
32
|
const frameworkSourceMode = isFrameworkSourceRepo(cwd);
|
|
@@ -36,13 +34,13 @@ export function createCmdHealth(ctx) {
|
|
|
36
34
|
|
|
37
35
|
// Pre-init guard
|
|
38
36
|
if (!existsSync(join(planningDir, 'config.json'))) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
37
|
+
return {
|
|
38
|
+
status: 'broken',
|
|
39
|
+
errors: [{ id: 'E1', severity: 'ERROR', message: '.planning/config.json missing', fix: 'Run `npx -y gsdd-cli init`' }],
|
|
40
|
+
warnings: [],
|
|
41
|
+
info: [],
|
|
42
|
+
humanMessage: 'Not initialized. Run `npx -y gsdd-cli init`. If `gsdd` is installed globally, `gsdd init` is also fine.',
|
|
43
|
+
};
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
const errors = [];
|
|
@@ -272,22 +270,41 @@ export function createCmdHealth(ctx) {
|
|
|
272
270
|
const hasWarnings = warnings.length > 0;
|
|
273
271
|
const status = hasErrors ? 'broken' : hasWarnings ? 'degraded' : 'healthy';
|
|
274
272
|
|
|
275
|
-
|
|
273
|
+
return { status, errors, warnings, info };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Factory function returning the health command.
|
|
277
|
+
* ctx should provide: { frameworkVersion, workflows }
|
|
278
|
+
*/
|
|
279
|
+
export function createCmdHealth(ctx) {
|
|
280
|
+
return async function cmdHealth(...healthArgs) {
|
|
281
|
+
const jsonMode = healthArgs.includes('--json');
|
|
282
|
+
const report = buildHealthReport(ctx, healthArgs);
|
|
283
|
+
const printableReport = {
|
|
284
|
+
status: report.status,
|
|
285
|
+
errors: report.errors,
|
|
286
|
+
warnings: report.warnings,
|
|
287
|
+
info: report.info,
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
if (report.status === 'broken') process.exitCode = 1;
|
|
276
291
|
|
|
277
292
|
if (jsonMode) {
|
|
278
|
-
output(
|
|
293
|
+
output(printableReport);
|
|
294
|
+
} else if (report.humanMessage) {
|
|
295
|
+
console.log(report.humanMessage);
|
|
279
296
|
} else {
|
|
280
|
-
console.log(`\ngsdd health
|
|
281
|
-
if (errors.length > 0) {
|
|
282
|
-
for (const e of errors) console.log(` ERROR: [${e.id}] ${e.message}\n Fix: ${e.fix}`);
|
|
297
|
+
console.log(`\ngsdd health - workspace integrity check\n`);
|
|
298
|
+
if (report.errors.length > 0) {
|
|
299
|
+
for (const e of report.errors) console.log(` ERROR: [${e.id}] ${e.message}\n Fix: ${e.fix}`);
|
|
283
300
|
}
|
|
284
|
-
if (warnings.length > 0) {
|
|
285
|
-
for (const w of warnings) console.log(` WARN: [${w.id}] ${w.message}\n Fix: ${w.fix}`);
|
|
301
|
+
if (report.warnings.length > 0) {
|
|
302
|
+
for (const w of report.warnings) console.log(` WARN: [${w.id}] ${w.message}\n Fix: ${w.fix}`);
|
|
286
303
|
}
|
|
287
|
-
if (info.length > 0) {
|
|
288
|
-
for (const i of info) console.log(` INFO: [${i.id}] ${i.message}${i.fix ? `\n Fix: ${i.fix}` : ''}`);
|
|
304
|
+
if (report.info.length > 0) {
|
|
305
|
+
for (const i of report.info) console.log(` INFO: [${i.id}] ${i.message}${i.fix ? `\n Fix: ${i.fix}` : ''}`);
|
|
289
306
|
}
|
|
290
|
-
console.log(`\n Verdict: ${status.toUpperCase()}\n`);
|
|
307
|
+
console.log(`\n Verdict: ${report.status.toUpperCase()}\n`);
|
|
291
308
|
}
|
|
292
309
|
};
|
|
293
310
|
}
|
package/bin/lib/init-runtime.mjs
CHANGED
|
@@ -194,6 +194,10 @@ Commands:
|
|
|
194
194
|
Compare planned UI proof slots against observed bundles
|
|
195
195
|
control-map [--json] [--with-ignored] [--annotations <path>]
|
|
196
196
|
Report computed repo/worktree/planning state and local annotations
|
|
197
|
+
control-map annotate <set|clear>
|
|
198
|
+
Maintain optional local intent annotations under .planning/.local/
|
|
199
|
+
closeout-report [--json] [--phase <N>]
|
|
200
|
+
Replay read-only closeout status from control-map, health, preflight, verify, and UI-proof signals
|
|
197
201
|
help Show this summary
|
|
198
202
|
|
|
199
203
|
Platforms (for --tools):
|
|
@@ -237,6 +241,8 @@ Examples:
|
|
|
237
241
|
npx -y gsdd-cli update
|
|
238
242
|
npx -y gsdd-cli find-phase
|
|
239
243
|
npx -y gsdd-cli verify 1
|
|
244
|
+
npx -y gsdd-cli control-map annotate set --id canonical --write-set src/app.ts
|
|
245
|
+
npx -y gsdd-cli control-map annotate clear --id canonical
|
|
240
246
|
npx -y gsdd-cli scaffold phase 4 Payments
|
|
241
247
|
|
|
242
248
|
Workflows (run via skills/adapters generated by init, not direct CLI):
|
|
@@ -265,7 +271,8 @@ Advanced/internal helpers (kept available, but not the primary first-run user st
|
|
|
265
271
|
session-fingerprint Rebaseline the local planning-state fingerprint after review
|
|
266
272
|
phase-status Update ROADMAP.md phase status through the local helper surface
|
|
267
273
|
ui-proof Validate UI proof metadata and compare planned slots to observed bundles
|
|
268
|
-
control-map Report computed repo/worktree/planning state
|
|
274
|
+
control-map Report computed repo/worktree/planning state; annotate only records local intent
|
|
275
|
+
closeout-report Read-only post-merge closure replay; reports blockers, warnings, and next safe action
|
|
269
276
|
file-op Deterministic workspace-confined file copy/delete/text mutation
|
|
270
277
|
`;
|
|
271
278
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from 'fs';
|
|
2
|
-
import { join } from 'path';
|
|
2
|
+
import { join, resolve } from 'path';
|
|
3
3
|
import { output } from './cli-utils.mjs';
|
|
4
|
+
import { buildControlMap } from './control-map.mjs';
|
|
4
5
|
import {
|
|
5
6
|
DELIVERY_POSTURES,
|
|
6
7
|
EVIDENCE_KINDS,
|
|
@@ -74,12 +75,14 @@ const RELEASE_CONTRADICTION_CHECKS = Object.freeze([
|
|
|
74
75
|
]);
|
|
75
76
|
|
|
76
77
|
const RELEASE_CONTRADICTION_STATUSES = Object.freeze(['passed', 'failed', 'not_applicable']);
|
|
78
|
+
const PREFLIGHT_CONTROL_MAP_SKIP_CODES = Object.freeze(['planning_state_drift']);
|
|
77
79
|
|
|
78
80
|
export function evaluateLifecyclePreflight({
|
|
79
81
|
planningDir,
|
|
80
82
|
surface,
|
|
81
83
|
phaseNumber = null,
|
|
82
84
|
expectsMutation = 'none',
|
|
85
|
+
controlMapReport = null,
|
|
83
86
|
} = {}) {
|
|
84
87
|
if (!planningDir) {
|
|
85
88
|
throw new Error('planningDir is required');
|
|
@@ -184,6 +187,17 @@ export function evaluateLifecyclePreflight({
|
|
|
184
187
|
}
|
|
185
188
|
}
|
|
186
189
|
|
|
190
|
+
const controlMap = buildPreflightControlMap({
|
|
191
|
+
planningDir,
|
|
192
|
+
policy,
|
|
193
|
+
existingBlockerCodes: new Set(blockers.map((entry) => entry.code)),
|
|
194
|
+
controlMapReport,
|
|
195
|
+
});
|
|
196
|
+
for (const notice of controlMap.notices) {
|
|
197
|
+
if (notice.severity === 'block') blockers.push(notice);
|
|
198
|
+
else warnings.push(notice);
|
|
199
|
+
}
|
|
200
|
+
|
|
187
201
|
if (lifecycle.phaseStatusAlignment.mismatches.length > 0) {
|
|
188
202
|
warnings.push({
|
|
189
203
|
code: 'roadmap_phase_status_mismatch',
|
|
@@ -206,6 +220,7 @@ export function evaluateLifecyclePreflight({
|
|
|
206
220
|
blockers,
|
|
207
221
|
warnings,
|
|
208
222
|
planningState,
|
|
223
|
+
controlMap: controlMap.summary,
|
|
209
224
|
lifecycle: {
|
|
210
225
|
currentMilestone: lifecycle.currentMilestone,
|
|
211
226
|
currentPhase: lifecycle.currentPhase ? lifecycle.currentPhase.number : null,
|
|
@@ -215,6 +230,48 @@ export function evaluateLifecyclePreflight({
|
|
|
215
230
|
};
|
|
216
231
|
}
|
|
217
232
|
|
|
233
|
+
function buildPreflightControlMap({ planningDir, policy, existingBlockerCodes, controlMapReport = null }) {
|
|
234
|
+
const empty = {
|
|
235
|
+
summary: null,
|
|
236
|
+
notices: [],
|
|
237
|
+
};
|
|
238
|
+
if (policy.classification !== 'owned_write' || !existsSync(planningDir)) return empty;
|
|
239
|
+
|
|
240
|
+
const map = controlMapReport || buildControlMap({
|
|
241
|
+
workspaceRoot: resolve(planningDir, '..'),
|
|
242
|
+
planningDir,
|
|
243
|
+
});
|
|
244
|
+
const risks = (map.risks || []).filter((risk) => (
|
|
245
|
+
!PREFLIGHT_CONTROL_MAP_SKIP_CODES.includes(risk.code)
|
|
246
|
+
&& !(existingBlockerCodes.has(risk.code) && risk.severity !== 'block')
|
|
247
|
+
));
|
|
248
|
+
const notices = risks.map((risk) => ({
|
|
249
|
+
...controlMapNotice(risk),
|
|
250
|
+
severity: risk.severity || 'info',
|
|
251
|
+
}));
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
summary: {
|
|
255
|
+
riskCount: map.risks.length,
|
|
256
|
+
noticeCount: notices.length,
|
|
257
|
+
blockerCount: notices.filter((notice) => notice.severity === 'block').length,
|
|
258
|
+
warningCount: notices.filter((notice) => notice.severity !== 'block').length,
|
|
259
|
+
interventions: map.interventions || [],
|
|
260
|
+
},
|
|
261
|
+
notices,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function controlMapNotice(risk) {
|
|
266
|
+
return {
|
|
267
|
+
code: risk.code,
|
|
268
|
+
source: 'control-map',
|
|
269
|
+
message: risk.message,
|
|
270
|
+
artifacts: ['gsdd control-map --json'],
|
|
271
|
+
risk,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
218
275
|
function buildPhaseBlockers({ lifecycle, phaseToken, surface }) {
|
|
219
276
|
const blockers = [];
|
|
220
277
|
const phaseEntry = lifecycle.phases.find((phase) => phase.number === phaseToken);
|
package/bin/lib/phase.mjs
CHANGED
|
@@ -4,10 +4,16 @@
|
|
|
4
4
|
// evaluate once, so CWD must be computed inside function bodies.
|
|
5
5
|
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
|
7
|
-
import { join,
|
|
7
|
+
import { dirname, join, relative } from 'path';
|
|
8
8
|
import { output } from './cli-utils.mjs';
|
|
9
9
|
import { writeFingerprint } from './session-fingerprint.mjs';
|
|
10
10
|
import { resolveWorkspaceContext } from './workspace-root.mjs';
|
|
11
|
+
import {
|
|
12
|
+
compareUiProofSlots,
|
|
13
|
+
findUiProofBundleFiles,
|
|
14
|
+
parseUiProofSlotsContent,
|
|
15
|
+
readUiProofBundleFile,
|
|
16
|
+
} from './ui-proof.mjs';
|
|
11
17
|
|
|
12
18
|
const PHASE_STATUS_MARKERS = {
|
|
13
19
|
not_started: '[ ]',
|
|
@@ -169,6 +175,317 @@ function extractPlanFileArtifacts(planContent, workspaceRoot) {
|
|
|
169
175
|
return artifacts;
|
|
170
176
|
}
|
|
171
177
|
|
|
178
|
+
function isPlanArtifactSatisfied(artifact) {
|
|
179
|
+
if (artifact.operation === 'delete') return !artifact.exists;
|
|
180
|
+
return artifact.exists;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function planArtifactFixHint(artifact) {
|
|
184
|
+
if (artifact.operation === 'delete') {
|
|
185
|
+
return `Complete the planned DELETE for ${artifact.file}, or revise the plan if the file should remain.`;
|
|
186
|
+
}
|
|
187
|
+
return `Create or update ${artifact.file} so the planned ${artifact.operation.toUpperCase()} artifact exists, or revise the plan if it is no longer in scope.`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function evaluatePlanArtifacts(artifacts) {
|
|
191
|
+
const unsatisfied = artifacts
|
|
192
|
+
.filter((artifact) => !isPlanArtifactSatisfied(artifact))
|
|
193
|
+
.map((artifact) => ({
|
|
194
|
+
...artifact,
|
|
195
|
+
severity: 'blocker',
|
|
196
|
+
expected: artifact.operation === 'delete' ? 'absent' : 'present',
|
|
197
|
+
fix_hint: planArtifactFixHint(artifact),
|
|
198
|
+
}));
|
|
199
|
+
return {
|
|
200
|
+
satisfied: unsatisfied.length === 0,
|
|
201
|
+
unsatisfied,
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeUiProofIssue(issue) {
|
|
206
|
+
return {
|
|
207
|
+
...issue,
|
|
208
|
+
severity: issue.severity || 'blocker',
|
|
209
|
+
fix_hint: issue.fix_hint || issue.fix || 'Fix the UI proof issue before claiming verification is complete.',
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function stripInlineComment(value) {
|
|
214
|
+
return String(value || '').replace(/\s+#.*$/, '').trim();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function stripOuterScalarQuotes(value) {
|
|
218
|
+
return String(value)
|
|
219
|
+
.trim()
|
|
220
|
+
.replace(/^(['"])([\s\S]*)\1$/g, '$2')
|
|
221
|
+
.trim();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function normalizeNullableFrontmatterValue(value) {
|
|
225
|
+
const stripped = stripOuterScalarQuotes(value);
|
|
226
|
+
if (!stripped) return '';
|
|
227
|
+
if (stripped === '~') return '';
|
|
228
|
+
if (/^null$/i.test(stripped)) return '';
|
|
229
|
+
return stripped;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function extractUiProofSlotIds(value) {
|
|
233
|
+
const ids = [];
|
|
234
|
+
const slotPattern = /['"]?slot_id['"]?\s*:\s*['"]?([^,'"\]\s}]+)['"]?/g;
|
|
235
|
+
for (const match of String(value || '').matchAll(slotPattern)) {
|
|
236
|
+
ids.push(match[1].replace(/^['"]|['"]$/g, ''));
|
|
237
|
+
}
|
|
238
|
+
return ids;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function readPlanFrontmatter(planContent) {
|
|
242
|
+
const content = String(planContent || '');
|
|
243
|
+
if (!content.startsWith('---')) return '';
|
|
244
|
+
const lines = content.split(/\r?\n/);
|
|
245
|
+
const frontmatter = [];
|
|
246
|
+
for (let index = 1; index < lines.length; index += 1) {
|
|
247
|
+
if (lines[index].trim() === '---') return frontmatter.join('\n');
|
|
248
|
+
frontmatter.push(lines[index]);
|
|
249
|
+
}
|
|
250
|
+
return '';
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function frontmatterKeyBlock(frontmatter, key) {
|
|
254
|
+
const lines = String(frontmatter || '').split(/\r?\n/);
|
|
255
|
+
const keyPattern = new RegExp(`^${key}:[ \\t]*(.*)$`);
|
|
256
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
257
|
+
const match = lines[index].match(keyPattern);
|
|
258
|
+
if (!match) continue;
|
|
259
|
+
const block = [];
|
|
260
|
+
for (let next = index + 1; next < lines.length; next += 1) {
|
|
261
|
+
const line = lines[next];
|
|
262
|
+
if (/^\S[^:\n]*:\s*/.test(line)) break;
|
|
263
|
+
block.push(line);
|
|
264
|
+
}
|
|
265
|
+
return { inline: stripInlineComment(match[1]), block };
|
|
266
|
+
}
|
|
267
|
+
return null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function frontmatterScalar(frontmatter, key) {
|
|
271
|
+
const entry = frontmatterKeyBlock(frontmatter, key);
|
|
272
|
+
if (!entry) return null;
|
|
273
|
+
if (entry.inline && !['|', '>'].includes(entry.inline)) return entry.inline;
|
|
274
|
+
return entry.block
|
|
275
|
+
.map((line) => line.trim())
|
|
276
|
+
.filter(Boolean)
|
|
277
|
+
.join(' ')
|
|
278
|
+
.trim();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function readPlanUiProofContract(planContent) {
|
|
282
|
+
const frontmatter = readPlanFrontmatter(planContent);
|
|
283
|
+
const slotsEntry = frontmatterKeyBlock(frontmatter, 'ui_proof_slots');
|
|
284
|
+
const rationale = normalizeNullableFrontmatterValue(frontmatterScalar(frontmatter, 'no_ui_proof_rationale') || '');
|
|
285
|
+
const result = {
|
|
286
|
+
hasUiProofKey: Boolean(slotsEntry),
|
|
287
|
+
declaresSlots: false,
|
|
288
|
+
explicitEmptySlots: false,
|
|
289
|
+
noUiProofRationale: rationale,
|
|
290
|
+
hasNoUiProofRationale: Boolean(rationale.trim()),
|
|
291
|
+
slotIds: [],
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
if (!slotsEntry) return result;
|
|
295
|
+
|
|
296
|
+
if (slotsEntry.inline) {
|
|
297
|
+
if (['[]', 'null', '~'].includes(slotsEntry.inline)) {
|
|
298
|
+
result.explicitEmptySlots = true;
|
|
299
|
+
return result;
|
|
300
|
+
}
|
|
301
|
+
result.declaresSlots = true;
|
|
302
|
+
result.slotIds.push(...extractUiProofSlotIds(slotsEntry.inline));
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
let sawMeaningfulLine = false;
|
|
307
|
+
for (const line of slotsEntry.block) {
|
|
308
|
+
const trimmed = line.trim();
|
|
309
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
310
|
+
sawMeaningfulLine = true;
|
|
311
|
+
if (/^\s+-\s+/.test(line)) result.declaresSlots = true;
|
|
312
|
+
result.slotIds.push(...extractUiProofSlotIds(trimmed));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
result.explicitEmptySlots = !result.declaresSlots && !sawMeaningfulLine;
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths) {
|
|
320
|
+
const candidates = new Set();
|
|
321
|
+
const declaredPlans = [];
|
|
322
|
+
const declaredSlotIds = [];
|
|
323
|
+
const noUiPlans = [];
|
|
324
|
+
const contractErrors = [];
|
|
325
|
+
const names = new Set([
|
|
326
|
+
'ui-proof-slots.json',
|
|
327
|
+
'ui-proof-slots.md',
|
|
328
|
+
'UI-PROOF-SLOTS.json',
|
|
329
|
+
'UI-PROOF-SLOTS.md',
|
|
330
|
+
'planned-ui-proof.json',
|
|
331
|
+
'planned-ui-proof.md',
|
|
332
|
+
]);
|
|
333
|
+
|
|
334
|
+
for (const planDisplayPath of planDisplayPaths) {
|
|
335
|
+
const fullPlanPath = join(planningDir, 'phases', planDisplayPath);
|
|
336
|
+
if (!existsSync(fullPlanPath)) continue;
|
|
337
|
+
const planContent = readFileSync(fullPlanPath, 'utf-8');
|
|
338
|
+
const relPlanPath = relative(planningDir, fullPlanPath).replace(/\\/g, '/');
|
|
339
|
+
const contract = readPlanUiProofContract(planContent);
|
|
340
|
+
const planDir = dirname(fullPlanPath);
|
|
341
|
+
const sidecars = [];
|
|
342
|
+
if (existsSync(planDir)) {
|
|
343
|
+
for (const entry of readdirSync(planDir, { withFileTypes: true })) {
|
|
344
|
+
if (entry.isFile() && names.has(entry.name)) {
|
|
345
|
+
sidecars.push(join(planDir, entry.name));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (contract.hasUiProofKey && !contract.declaresSlots && !contract.hasNoUiProofRationale) {
|
|
351
|
+
contractErrors.push(normalizeUiProofIssue({
|
|
352
|
+
code: 'missing_no_ui_proof_rationale',
|
|
353
|
+
path: `${relPlanPath}.no_ui_proof_rationale`,
|
|
354
|
+
message: 'Plan declares empty ui_proof_slots but does not provide a no_ui_proof_rationale.',
|
|
355
|
+
fix: 'Add a nonblank no_ui_proof_rationale for non-UI work, or declare required UI proof slots and add a planned slots artifact.',
|
|
356
|
+
}));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (contract.hasNoUiProofRationale && !contract.declaresSlots) {
|
|
360
|
+
noUiPlans.push({ plan: relPlanPath, sidecars });
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (!contract.declaresSlots) continue;
|
|
365
|
+
|
|
366
|
+
declaredPlans.push(relPlanPath);
|
|
367
|
+
for (const slotId of contract.slotIds) {
|
|
368
|
+
declaredSlotIds.push({ plan: relPlanPath, slot_id: slotId });
|
|
369
|
+
}
|
|
370
|
+
for (const entry of readdirSync(planDir, { withFileTypes: true })) {
|
|
371
|
+
if (entry.isFile() && names.has(entry.name)) {
|
|
372
|
+
candidates.add(join(planDir, entry.name));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { declaredPlans, declaredSlotIds, noUiPlans, contractErrors, files: [...candidates].sort() };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function comparePhaseUiProof({ planningDir, workspaceRoot, planDisplayPaths }) {
|
|
380
|
+
const plannedDiscovery = findUiProofSlotPlansAndFiles(planningDir, planDisplayPaths);
|
|
381
|
+
const plannedFiles = plannedDiscovery.files;
|
|
382
|
+
const phaseDirs = new Set(planDisplayPaths.map((planDisplayPath) => dirname(join(planningDir, 'phases', planDisplayPath))));
|
|
383
|
+
const observedFiles = findUiProofBundleFiles(planningDir)
|
|
384
|
+
.filter((filePath) => phaseDirs.has(dirname(filePath)));
|
|
385
|
+
|
|
386
|
+
const plannedSlots = [];
|
|
387
|
+
const errors = [];
|
|
388
|
+
const warnings = [];
|
|
389
|
+
const planned = [];
|
|
390
|
+
const observed = [];
|
|
391
|
+
|
|
392
|
+
errors.push(...plannedDiscovery.contractErrors);
|
|
393
|
+
for (const noUiPlan of plannedDiscovery.noUiPlans) {
|
|
394
|
+
for (const filePath of noUiPlan.sidecars) {
|
|
395
|
+
warnings.push({
|
|
396
|
+
code: 'stale_ui_proof_sidecar_ignored',
|
|
397
|
+
severity: 'warn',
|
|
398
|
+
path: relative(workspaceRoot, filePath).replace(/\\/g, '/'),
|
|
399
|
+
message: `Plan ${noUiPlan.plan} records no_ui_proof_rationale, so the UI proof sidecar is ignored for closure.`,
|
|
400
|
+
fix_hint: 'Remove or classify the stale sidecar if it no longer belongs to this non-UI phase.',
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
for (const filePath of plannedFiles) {
|
|
406
|
+
const rel = relative(workspaceRoot, filePath).replace(/\\/g, '/');
|
|
407
|
+
const parsed = parseUiProofSlotsContent(readFileSync(filePath, 'utf-8'), rel);
|
|
408
|
+
planned.push(rel);
|
|
409
|
+
plannedSlots.push(...parsed.slots);
|
|
410
|
+
errors.push(...parsed.errors.map(normalizeUiProofIssue));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (plannedSlots.length > 0 && plannedDiscovery.declaredSlotIds.length > 0) {
|
|
414
|
+
const plannedSlotIds = new Set(plannedSlots.map((slot) => String(slot?.slot_id || '')));
|
|
415
|
+
for (const declaredSlot of plannedDiscovery.declaredSlotIds) {
|
|
416
|
+
if (plannedSlotIds.has(String(declaredSlot.slot_id))) continue;
|
|
417
|
+
errors.push(normalizeUiProofIssue({
|
|
418
|
+
code: 'planned_ui_proof_slots_drift',
|
|
419
|
+
path: `${declaredSlot.plan}.ui_proof_slots`,
|
|
420
|
+
message: `Plan declares UI proof slot ${declaredSlot.slot_id}, but no matching slot exists in the planned UI proof artifact.`,
|
|
421
|
+
fix: 'Update ui-proof-slots.json or ui-proof-slots.md beside the plan so it matches the plan-declared slot IDs, or update the plan declaration.',
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const observedBundles = [];
|
|
427
|
+
for (const filePath of observedFiles) {
|
|
428
|
+
const rel = relative(workspaceRoot, filePath).replace(/\\/g, '/');
|
|
429
|
+
const parsed = readUiProofBundleFile(filePath);
|
|
430
|
+
observed.push(rel);
|
|
431
|
+
if (parsed.errors.length > 0) {
|
|
432
|
+
errors.push(...parsed.errors.map((error) => normalizeUiProofIssue({ ...error, path: error.path || rel })));
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
observedBundles.push({
|
|
436
|
+
source: rel,
|
|
437
|
+
bundle: parsed.bundle,
|
|
438
|
+
options: {
|
|
439
|
+
requireLocalArtifactExists: true,
|
|
440
|
+
workspaceRoot,
|
|
441
|
+
bundleDir: dirname(filePath),
|
|
442
|
+
},
|
|
443
|
+
});
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (plannedFiles.length === 0 && plannedDiscovery.declaredPlans.length > 0) {
|
|
447
|
+
const missingError = {
|
|
448
|
+
code: 'missing_planned_ui_proof_slots_file',
|
|
449
|
+
severity: 'blocker',
|
|
450
|
+
path: plannedDiscovery.declaredPlans[0],
|
|
451
|
+
message: 'Plan declares ui_proof_slots but no ui-proof-slots artifact was found beside the plan.',
|
|
452
|
+
fix_hint: 'Create ui-proof-slots.json or ui-proof-slots.md beside the plan, or set ui_proof_slots: [] with a no_ui_proof_rationale if the phase is not UI-sensitive.',
|
|
453
|
+
};
|
|
454
|
+
return {
|
|
455
|
+
planned,
|
|
456
|
+
observed,
|
|
457
|
+
status: 'missing',
|
|
458
|
+
comparison: { status: 'missing', slots: [], errors: [missingError] },
|
|
459
|
+
errors: [missingError],
|
|
460
|
+
warnings,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (plannedFiles.length === 0) {
|
|
465
|
+
return {
|
|
466
|
+
planned,
|
|
467
|
+
observed,
|
|
468
|
+
status: errors.length > 0 ? 'partial' : 'not_applicable',
|
|
469
|
+
comparison: null,
|
|
470
|
+
errors,
|
|
471
|
+
warnings,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const comparison = errors.length > 0
|
|
476
|
+
? { status: 'partial', slots: [], errors: errors.map(normalizeUiProofIssue) }
|
|
477
|
+
: compareUiProofSlots(plannedSlots, observedBundles);
|
|
478
|
+
|
|
479
|
+
return {
|
|
480
|
+
planned,
|
|
481
|
+
observed,
|
|
482
|
+
status: comparison.status,
|
|
483
|
+
comparison,
|
|
484
|
+
errors: comparison.errors || errors,
|
|
485
|
+
warnings,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
172
489
|
export function updateRoadmapPhaseStatus(roadmap, phaseNumber, status) {
|
|
173
490
|
const marker = PHASE_STATUS_MARKERS[status];
|
|
174
491
|
if (!marker) {
|
|
@@ -334,32 +651,68 @@ export function cmdFindPhase(...args) {
|
|
|
334
651
|
});
|
|
335
652
|
}
|
|
336
653
|
|
|
337
|
-
export function
|
|
654
|
+
export function buildPhaseVerificationReport(...args) {
|
|
338
655
|
const { args: normalizedArgs, workspaceRoot, planningDir, invalid, error } = resolveWorkspaceContext(args);
|
|
339
656
|
if (invalid) {
|
|
340
|
-
|
|
341
|
-
process.exitCode = 1;
|
|
342
|
-
return;
|
|
657
|
+
return { ok: false, error, exitCode: 1 };
|
|
343
658
|
}
|
|
344
659
|
const phaseNum = normalizedArgs[0];
|
|
345
660
|
if (!phaseNum) {
|
|
346
|
-
|
|
347
|
-
process.exitCode = 1; return;
|
|
661
|
+
return { ok: false, error: 'Usage: gsdd verify <phase-number>', exitCode: 1 };
|
|
348
662
|
}
|
|
349
663
|
|
|
350
664
|
if (!existsSync(planningDir)) {
|
|
351
|
-
|
|
352
|
-
process.exitCode = 1; return;
|
|
665
|
+
return { ok: false, error: 'No .planning/ directory found.', exitCode: 1 };
|
|
353
666
|
}
|
|
354
667
|
const phasesDir = join(planningDir, 'phases');
|
|
355
668
|
const matchingPlans = findFiles(phasesDir, `${padPhase(phaseNum)}-PLAN`);
|
|
356
669
|
const matchingSummaries = findFiles(phasesDir, `${padPhase(phaseNum)}-SUMMARY`);
|
|
670
|
+
const prerequisiteBlockers = [];
|
|
671
|
+
if (matchingPlans.length === 0) {
|
|
672
|
+
prerequisiteBlockers.push({
|
|
673
|
+
code: 'missing_phase_plan',
|
|
674
|
+
severity: 'blocker',
|
|
675
|
+
path: `.planning/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-PLAN.md`,
|
|
676
|
+
message: `No PLAN.md artifact was found for phase ${normalizePhaseToken(phaseNum)}.`,
|
|
677
|
+
fix_hint: `Run /gsdd-plan ${normalizePhaseToken(phaseNum)} before verifying this phase.`,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
if (matchingPlans.length > 0 && matchingSummaries.length === 0) {
|
|
681
|
+
prerequisiteBlockers.push({
|
|
682
|
+
code: 'missing_phase_summary',
|
|
683
|
+
severity: 'blocker',
|
|
684
|
+
path: `.planning/phases/${padPhase(phaseNum)}-*/${padPhase(phaseNum)}-SUMMARY.md`,
|
|
685
|
+
message: `No SUMMARY.md artifact was found for phase ${normalizePhaseToken(phaseNum)}.`,
|
|
686
|
+
fix_hint: `Run /gsdd-execute ${normalizePhaseToken(phaseNum)} before verifying this phase.`,
|
|
687
|
+
});
|
|
688
|
+
}
|
|
357
689
|
const artifacts = matchingPlans.flatMap((planPath) => {
|
|
358
690
|
const fullPath = join(phasesDir, planPath);
|
|
359
691
|
return existsSync(fullPath)
|
|
360
692
|
? extractPlanFileArtifacts(readFileSync(fullPath, 'utf-8'), workspaceRoot)
|
|
361
693
|
: [];
|
|
362
694
|
});
|
|
695
|
+
const artifactStatus = evaluatePlanArtifacts(artifacts);
|
|
696
|
+
const uiProof = comparePhaseUiProof({
|
|
697
|
+
planningDir,
|
|
698
|
+
workspaceRoot,
|
|
699
|
+
planDisplayPaths: matchingPlans,
|
|
700
|
+
});
|
|
701
|
+
const uiProofSatisfied = ['satisfied', 'not_applicable'].includes(uiProof.status);
|
|
702
|
+
const legacyVerified = matchingPlans.length > 0 && matchingSummaries.length > 0;
|
|
703
|
+
const uiProofGate = {
|
|
704
|
+
status: uiProof.status,
|
|
705
|
+
required: uiProof.status !== 'not_applicable',
|
|
706
|
+
satisfied: uiProofSatisfied,
|
|
707
|
+
blocks_verification: uiProof.status !== 'not_applicable' && !uiProofSatisfied,
|
|
708
|
+
required_block: uiProof.status !== 'not_applicable' && !uiProofSatisfied ? 'ui-proof-failed' : null,
|
|
709
|
+
};
|
|
710
|
+
const blockedOn = [
|
|
711
|
+
...(prerequisiteBlockers.length > 0 ? ['prerequisites'] : []),
|
|
712
|
+
...(artifactStatus.satisfied ? [] : ['artifacts']),
|
|
713
|
+
...(uiProofGate.blocks_verification ? ['ui_proof'] : []),
|
|
714
|
+
];
|
|
715
|
+
const closureVerified = legacyVerified && prerequisiteBlockers.length === 0 && artifactStatus.satisfied && uiProofSatisfied;
|
|
363
716
|
|
|
364
717
|
const result = {
|
|
365
718
|
phase: normalizePhaseToken(phaseNum),
|
|
@@ -368,9 +721,31 @@ export function cmdVerify(...args) {
|
|
|
368
721
|
summaries: matchingSummaries,
|
|
369
722
|
artifacts,
|
|
370
723
|
allExist: artifacts.every((artifact) => artifact.exists),
|
|
371
|
-
|
|
724
|
+
artifact_status: artifactStatus,
|
|
725
|
+
uiProof,
|
|
726
|
+
verified: closureVerified,
|
|
727
|
+
legacy_verified: legacyVerified,
|
|
728
|
+
phase_artifacts_present: legacyVerified,
|
|
729
|
+
prerequisite_status: {
|
|
730
|
+
satisfied: prerequisiteBlockers.length === 0,
|
|
731
|
+
blockers: prerequisiteBlockers,
|
|
732
|
+
},
|
|
733
|
+
ui_proof: uiProofGate,
|
|
734
|
+
blocked_on: blockedOn,
|
|
735
|
+
blocks_verification: blockedOn.length > 0,
|
|
372
736
|
};
|
|
373
|
-
|
|
737
|
+
return { ok: true, result, exitCode: closureVerified ? 0 : 1 };
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function cmdVerify(...args) {
|
|
741
|
+
const report = buildPhaseVerificationReport(...args);
|
|
742
|
+
if (!report.ok) {
|
|
743
|
+
console.error(report.error);
|
|
744
|
+
process.exitCode = report.exitCode;
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
output(report.result);
|
|
748
|
+
if (report.exitCode !== 0) process.exitCode = report.exitCode;
|
|
374
749
|
}
|
|
375
750
|
|
|
376
751
|
export function cmdScaffold(...args) {
|