Skip to content

Commit f27128f

Browse files
tyaginidhiclaude
andcommitted
Address #194 Copilot review comments (3)
1. hook: forward reconcile failure detail to stderr. The empty JSON.parse catch swallowed spawn errors / timeouts / non-zero exits, and rec.stderr was never forwarded — so the "See stderr for details" pointer was empty. Now track spawnFailed (rec.error / non-zero status / signal) and a parsed flag, report on STDERR only on actual failure (clean runs stay quiet — the hook fires on every Skill use), and forward the child's stderr verbatim where refresh-alm-plan-data.js already writes its per-phase error detail. Stays non-blocking (validator's exit code unchanged). + test: malformed plan → broken reconcile is surfaced AND exit code is unchanged; + quiet-on-success assertion on the heal test. 2. render-alm-plan.js: COMPLETED_AT is optional, not required. Move it out of the required-keys list into its own "optional lifecycle key" note — it's present only once the plan reaches "Completed"; the renderer omits the footer line otherwise. 3. powerpages-hook-utils.js: isAlmPlanSkill JSDoc said @PARAM {string} but the function (and its tests) accept non-strings (null/undefined → false). Widen to {*} and note the contract. Full suite: 1247 pass. legacy-compat + alm-lint: clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 1f5ddd2 commit f27128f

4 files changed

Lines changed: 56 additions & 8 deletions

File tree

plugins/power-pages/hooks/run-skill-posttool-validation.js

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,26 +71,45 @@ process.stdin.on('end', () => {
7171
cwd,
7272
timeout: 20000,
7373
});
74+
// spawnSync surfaces a spawn/timeout failure on rec.error (e.g. ETIMEDOUT)
75+
// and a non-zero / signalled exit on rec.status / rec.signal — none of which
76+
// produce parseable stdout. Track those so a broken reconcile is reported
77+
// rather than silently swallowed by the JSON.parse catch below.
78+
const spawnFailed = !!rec.error || rec.status !== 0 || !!rec.signal;
7479
let reconciled = [];
7580
let failed = [];
81+
let parsed = false;
7682
try {
7783
const out = JSON.parse((rec.stdout || '').trim());
7884
reconciled = out.reconciled || [];
7985
failed = out.failed || [];
80-
} catch {}
86+
parsed = true;
87+
} catch { /* parsed stays false — surfaced in the spawnFailed/!parsed branch */ }
8188
if (reconciled.length > 0) {
8289
process.stdout.write(
8390
`[power-pages] ALM plan was out of sync with ${reconciled.length} run marker(s) — refreshed automatically (${reconciled.join(', ')}).\n`,
8491
);
8592
}
93+
// Failure reporting goes to STDERR (only on an actual failure, never on the
94+
// happy path — the hook fires on every Skill use, so clean runs must stay
95+
// quiet). A swallowed reconcile failure is exactly what makes a stale plan
96+
// impossible to diagnose, so we forward the child's stderr verbatim — that's
97+
// where refresh-alm-plan-data.js already writes its per-phase error detail,
98+
// which is what makes the summary line below actionable.
8699
if (failed.length > 0) {
87-
// Non-blocking, but surfaced — a swallowed reconcile failure is exactly
88-
// what makes a stale plan impossible to diagnose.
89-
process.stdout.write(
90-
`[power-pages] ALM plan reconcile could not heal ${failed.length} phase(s): ${failed.map((f) => f.phase).join(', ')}. See stderr for details.\n`,
100+
process.stderr.write(
101+
`[power-pages] ALM plan reconcile could not heal ${failed.length} phase(s): ${failed.map((f) => f.phase).join(', ')}. Details below.\n`,
91102
);
103+
if (rec.stderr) process.stderr.write(rec.stderr);
104+
} else if (spawnFailed || !parsed) {
105+
// The reconcile didn't even produce a parseable result (spawn error,
106+
// timeout, non-zero exit, or garbled stdout). Non-blocking, but the user
107+
// should still see why the auto-heal didn't run.
108+
const why = rec.error ? rec.error.message : rec.signal ? `signal ${rec.signal}` : `exit ${rec.status}`;
109+
process.stderr.write(`[power-pages] ALM plan reconcile did not complete (${why}). Details below.\n`);
110+
if (rec.stderr) process.stderr.write(rec.stderr);
92111
}
93-
debug(`[power-pages hook] reconcile reconciled=${JSON.stringify(reconciled)} failed=${JSON.stringify(failed)}\n`);
112+
debug(`[power-pages hook] reconcile reconciled=${JSON.stringify(reconciled)} failed=${JSON.stringify(failed)} spawnFailed=${spawnFailed} parsed=${parsed}\n`);
94113
} catch (e) {
95114
// Best-effort — a reconcile failure must never break the skill or the hook.
96115
debug(`[power-pages hook] reconcile error (ignored): ${e.message}\n`);

plugins/power-pages/scripts/lib/powerpages-hook-utils.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ const ALM_PLAN_SKILLS = new Set([
126126
* True when `value` (a raw skill name, `/skill`, or `power-pages:skill`) resolves
127127
* to an ALM plan skill. Normalizes via `detectTrackedSkill`, so it also confirms
128128
* the skill actually exists in this plugin.
129-
* @param {string} value
129+
* Accepts any value — non-strings (including null/undefined) resolve to false
130+
* via detectTrackedSkill, so callers may pass an unvalidated skill name.
131+
* @param {*} value
130132
* @returns {boolean}
131133
*/
132134
function isAlmPlanSkill(value) {

plugins/power-pages/scripts/tests/run-skill-posttool-validation.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,28 @@ test('hook spawns the reconcile backstop and heals a skipped refresh after an AL
8484
const planData = readJson(path.join(root, 'docs', '.alm-plan-data.json'));
8585
assert.equal(planData.pipelineMeta.lastDeploy.status, 'Succeeded');
8686
assert.equal(planData.pipelineMeta.lastDeploy.componentCount, 118);
87+
// A clean reconcile must stay quiet on stderr — the hook fires on every Skill
88+
// use, so success must not produce failure noise.
89+
assert.doesNotMatch(res.stderr, /reconcile/i, 'a successful reconcile must not write failure noise to stderr');
90+
});
91+
92+
test('hook forwards reconcile failure detail to stderr without changing the exit code', (t) => {
93+
// A malformed plan file makes refresh-alm-plan-data.js --reconcile throw and exit
94+
// non-zero with its reason on stderr. The hook must (a) surface that — the prior
95+
// empty JSON.parse catch swallowed spawn errors / timeouts / non-zero exits — and
96+
// (b) stay non-blocking (the reconcile is best-effort; the validator's status stands).
97+
const root = makeProject(t);
98+
fs.writeFileSync(path.join(root, 'docs', '.alm-plan-data.json'), 'not json {{{', 'utf8');
99+
// A newer marker guarantees the reconcile reaches the plan-parse (and would heal if it could).
100+
writeJson(path.join(root, 'docs', 'alm', 'last-export.json'), { solutionUniqueName: 'S', exportedAt: '2026-06-16T00:00:00.000Z' });
101+
backdatePlan(root);
102+
103+
// export-solution is an ALM skill; its validator gracefully approves (no zip) → exit 0.
104+
const res = runHook(root, 'export-solution');
105+
106+
assert.equal(res.status, 0, 'a broken reconcile must not change the validator-determined exit code');
107+
assert.match(res.stderr, /reconcile did not complete/i, 'the hook must report the broken reconcile');
108+
assert.match(res.stderr, /Could not parse/i, 'the child reconcile stderr must be forwarded verbatim');
87109
});
88110

89111
test('hook does NOT reconcile for a non-ALM skill even when a plan + newer marker exist', (t) => {

plugins/power-pages/skills/plan-alm/scripts/render-alm-plan.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,14 @@
66
* node render-alm-plan.js --output <path> --data <json-file>
77
*
88
* Required top-level keys in the JSON data file:
9-
* SITE_NAME, GENERATED_AT, STRATEGY, PLAN_STATUS, APPROVED_BY, APPROVAL_DATE, COMPLETED_AT,
9+
* SITE_NAME, GENERATED_AT, STRATEGY, PLAN_STATUS, APPROVED_BY, APPROVAL_DATE,
1010
* stages, steps, risks
1111
*
12+
* Optional lifecycle key:
13+
* COMPLETED_AT — present only once the plan reaches PLAN_STATUS "Completed";
14+
* the renderer emits the footer "Completed" line when it exists and omits it
15+
* otherwise (an in-flight plan has no COMPLETED_AT).
16+
*
1217
* Optional v2 keys (added for split-solutions support):
1318
* sizeAnalysis, assetAdvisory, proposedSolutions, appliedStrategies,
1419
* recommendations, envVars, breakdown, estimationMethod, estimationAccuracyPct

0 commit comments

Comments
 (0)