@opengsd/gsd-core 1.5.0-rc.1 → 1.5.0-rc.2
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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +3 -2
- package/agents/gsd-debug-session-manager.md +2 -1
- package/agents/gsd-debugger.md +4 -3
- package/agents/gsd-executor.md +16 -15
- package/agents/gsd-intel-updater.md +38 -41
- package/agents/gsd-phase-researcher.md +8 -8
- package/agents/gsd-plan-checker.md +12 -11
- package/agents/gsd-planner.md +21 -181
- package/agents/gsd-project-researcher.md +5 -4
- package/agents/gsd-research-synthesizer.md +2 -1
- package/agents/gsd-ui-researcher.md +2 -1
- package/agents/gsd-verifier.md +9 -8
- package/bin/install.js +205 -1402
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd_run +20 -0
- package/gsd-core/bin/lib/capability-registry.cjs +1820 -3
- package/gsd-core/bin/lib/check-command-router.cjs +133 -2
- package/gsd-core/bin/lib/core.cjs +51 -1
- package/gsd-core/bin/lib/edge-probe.cjs +173 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +63 -25
- package/gsd-core/bin/lib/intel.cjs +3 -3
- package/gsd-core/bin/lib/io.cjs +61 -6
- package/gsd-core/bin/lib/phase-command-router.cjs +20 -0
- package/gsd-core/bin/lib/phase.cjs +17 -0
- package/gsd-core/bin/lib/probe-core.cjs +257 -0
- package/gsd-core/bin/lib/roadmap.cjs +40 -0
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +51 -141
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +67 -29
- package/gsd-core/bin/lib/runtime-homes.cjs +137 -101
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1439 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +1 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +7 -2
- package/gsd-core/bin/lib/state-document.cjs +8 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +329 -0
- package/gsd-core/bin/lib/update-context.cjs +4 -1
- package/gsd-core/bin/lib/verify.cjs +103 -0
- package/gsd-core/bin/lib/worktree-base-ref.cjs +33 -8
- package/gsd-core/bin/shared/model-catalog.json +11 -6
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +2 -1
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +7 -0
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +8 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/requirements.json +1 -0
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/resolutions.json +4 -0
- package/gsd-core/references/edge-probe.md +261 -0
- package/gsd-core/references/planner-antipatterns.md +41 -0
- package/gsd-core/references/planner-guidance.md +186 -0
- package/gsd-core/templates/spec.md +12 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +1 -1
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +26 -37
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +1 -1
- package/gsd-core/workflows/code-review.md +51 -16
- package/gsd-core/workflows/complete-milestone.md +1 -1
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- package/gsd-core/workflows/import.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +1 -1
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +9 -1
- package/gsd-core/workflows/new-project.md +9 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-milestone-gaps.md +1 -1
- package/gsd-core/workflows/plan-phase.md +39 -27
- package/gsd-core/workflows/plan-review-convergence.md +1 -1
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +1 -1
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +1 -1
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/settings-advanced.md +18 -14
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +2 -2
- package/gsd-core/workflows/ship.md +1 -1
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +130 -1
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +1 -1
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/update.md +2 -2
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +1 -1
- package/package.json +6 -3
- package/scripts/gen-capability-registry.cjs +498 -13
- package/scripts/lib/allowlist-ratchet.cjs +101 -1
- package/scripts/lint-test-file-count.allowlist.json +15 -2
- package/scripts/lint-windows-test-portability.cjs +178 -0
- package/scripts/research-profiles.cjs +10 -10
- package/scripts/run-tests.cjs +54 -13
- package/scripts/sync-next-version.cjs +133 -0
- package/scripts/sync-runtime-launcher.cjs +21 -5
- package/scripts/update-size-baseline.cjs +68 -0
- package/scripts/workflow-policy.cjs +42 -9
- package/scripts/workflow-size.cjs +90 -0
|
@@ -16,6 +16,10 @@ const node_child_process_1 = require("node:child_process");
|
|
|
16
16
|
const core = require("./core.cjs");
|
|
17
17
|
const { output, error, ERROR_REASON } = core;
|
|
18
18
|
const decisions_cjs_1 = require("./decisions.cjs");
|
|
19
|
+
const ui_safety_gate_cjs_1 = require("./ui-safety-gate.cjs");
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
|
+
const roadmapModule = require("./roadmap.cjs");
|
|
22
|
+
const { getRoadmapPhaseWithFallback } = roadmapModule;
|
|
19
23
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
20
24
|
function normalizePhrase(text) {
|
|
21
25
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
@@ -320,8 +324,130 @@ function cmdDecisionCoverageVerify(projectDir, args, raw) {
|
|
|
320
324
|
message: buildVerifyMessage(notHonored),
|
|
321
325
|
}, raw, undefined);
|
|
322
326
|
}
|
|
327
|
+
// ─── ui-plan-gate ─────────────────────────────────────────────────────────────
|
|
328
|
+
/**
|
|
329
|
+
* ui-plan-gate: given a phase number, checks whether the phase has frontend
|
|
330
|
+
* indicators and whether a *-UI-SPEC.md already exists in the phase directory.
|
|
331
|
+
*
|
|
332
|
+
* Returns JSON: { frontend: boolean, hasUiSpec: boolean, block: boolean }
|
|
333
|
+
* block = frontend && !hasUiSpec (gate fires when UI work is detected but no spec exists)
|
|
334
|
+
*
|
|
335
|
+
* Invocable as: gsd_run check ui-plan-gate <phase>
|
|
336
|
+
*
|
|
337
|
+
* Uses checkUiPresence from ui-safety-gate.cjs — does NOT reimplement frontend detection.
|
|
338
|
+
* Uses getRoadmapPhaseInternal + findPhaseInternal from core.cjs for phase data.
|
|
339
|
+
*/
|
|
340
|
+
function findUiSpecInDir(phaseDir) {
|
|
341
|
+
if (!phaseDir || !node_fs_1.default.existsSync(phaseDir))
|
|
342
|
+
return '';
|
|
343
|
+
try {
|
|
344
|
+
const files = node_fs_1.default.readdirSync(phaseDir);
|
|
345
|
+
const found = files.find((f) => /-UI-SPEC\.md$/.test(f));
|
|
346
|
+
return found ? node_path_1.default.join(phaseDir, found) : '';
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return '';
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Pure logic for ui-plan-gate — exposed for direct behavioral testing.
|
|
354
|
+
*
|
|
355
|
+
* Given a projectDir and phase number:
|
|
356
|
+
* (a) Reads the phase section from ROADMAP.md via getRoadmapPhaseWithFallback —
|
|
357
|
+
* same two-pass lookup (current milestone → full roadmap) as `roadmap.get-phase`
|
|
358
|
+
* (cmdRoadmapGetPhase). Cross-milestone / older frontend phases resolve correctly.
|
|
359
|
+
* If ROADMAP.md is missing, phaseSection is '' (ROADMAP.md not present = project
|
|
360
|
+
* has no roadmap = cannot be frontend). If the phase truly can't be found after
|
|
361
|
+
* both passes, phaseSection is '' and phaseLookupFailed is set so callers can
|
|
362
|
+
* surface the miss — we do NOT silently degrade to frontend:false if the roadmap
|
|
363
|
+
* exists but the phase header is absent.
|
|
364
|
+
* (b) Runs checkUiPresence (frontend detection) — no reimplementation.
|
|
365
|
+
* (c) Resolves the phase directory via core.findPhaseInternal; checks for *-UI-SPEC.md.
|
|
366
|
+
*
|
|
367
|
+
* Returns: { frontend, hasUiSpec, block, uiSpecPath, phaseLookupFailed }
|
|
368
|
+
* block = frontend && !hasUiSpec
|
|
369
|
+
* phaseLookupFailed = ROADMAP.md present but phase header not found (surfaced for
|
|
370
|
+
* onError:halt gates so a missing phase doesn't silently bypass)
|
|
371
|
+
*/
|
|
372
|
+
function computeUiPlanGate(projectDir, phase) {
|
|
373
|
+
// (a) Read the phase section text using the same two-pass lookup as roadmap.get-phase.
|
|
374
|
+
// getRoadmapPhaseWithFallback: current-milestone first, then stripShippedMilestones
|
|
375
|
+
// fallback — mirrors cmdRoadmapGetPhase exactly.
|
|
376
|
+
let phaseSection = '';
|
|
377
|
+
let phaseLookupFailed;
|
|
378
|
+
try {
|
|
379
|
+
const section = getRoadmapPhaseWithFallback(projectDir, phase);
|
|
380
|
+
if (section === null) {
|
|
381
|
+
// Distinguish: ROADMAP.md missing (no-roadmap project) vs phase not found in ROADMAP.
|
|
382
|
+
// core.planningDir(cwd) resolves the .planning/ root for workstream-aware paths.
|
|
383
|
+
const planDir = typeof core['planningDir'] === 'function'
|
|
384
|
+
? core['planningDir'](projectDir)
|
|
385
|
+
: node_path_1.default.join(projectDir, '.planning');
|
|
386
|
+
const roadmapPath = node_path_1.default.join(planDir, 'ROADMAP.md');
|
|
387
|
+
if (node_fs_1.default.existsSync(roadmapPath)) {
|
|
388
|
+
// ROADMAP.md exists but phase was not found → surface the miss
|
|
389
|
+
phaseLookupFailed = true;
|
|
390
|
+
}
|
|
391
|
+
// phaseSection stays ''
|
|
392
|
+
}
|
|
393
|
+
else {
|
|
394
|
+
phaseSection = section;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
catch { /* roadmap read failure → treat as empty (non-frontend) */ }
|
|
398
|
+
// (b) Run checkUiPresence (frontend detection) — reuse existing helper; no reimplementation
|
|
399
|
+
const presenceResult = (0, ui_safety_gate_cjs_1.checkUiPresence)(phaseSection);
|
|
400
|
+
const frontend = presenceResult.hasUI;
|
|
401
|
+
// (c) Resolve phase directory via findPhaseInternal and check for *-UI-SPEC.md
|
|
402
|
+
const coreModule = core;
|
|
403
|
+
let phaseDir = '';
|
|
404
|
+
try {
|
|
405
|
+
const findPhase = coreModule['findPhaseInternal'];
|
|
406
|
+
if (typeof findPhase === 'function') {
|
|
407
|
+
const result = findPhase(projectDir, phase);
|
|
408
|
+
if (result && typeof result === 'object') {
|
|
409
|
+
// findPhaseInternal returns { directory: '<relative-posix-path>', ... }
|
|
410
|
+
// directory is relative to cwd — resolve it to absolute.
|
|
411
|
+
const relDir = typeof result['directory'] === 'string' ? result['directory'] : '';
|
|
412
|
+
if (relDir) {
|
|
413
|
+
phaseDir = node_path_1.default.resolve(projectDir, relDir);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
else if (typeof result === 'string') {
|
|
417
|
+
phaseDir = result;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
catch { /* phase dir lookup failure → hasUiSpec=false */ }
|
|
422
|
+
const uiSpecPath = findUiSpecInDir(phaseDir);
|
|
423
|
+
const hasUiSpec = uiSpecPath !== '';
|
|
424
|
+
// block = frontend phase with no UI-SPEC
|
|
425
|
+
const block = frontend && !hasUiSpec;
|
|
426
|
+
const result = {
|
|
427
|
+
frontend, hasUiSpec, block, uiSpecPath: hasUiSpec ? uiSpecPath : null,
|
|
428
|
+
};
|
|
429
|
+
if (phaseLookupFailed)
|
|
430
|
+
result.phaseLookupFailed = true;
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
function cmdUiPlanGate(projectDir, args, raw) {
|
|
434
|
+
// args[0] = 'check', args[1] = 'ui-plan-gate', args[2] = phase
|
|
435
|
+
const phase = args[2] || '';
|
|
436
|
+
if (!phase) {
|
|
437
|
+
error('ui-plan-gate requires a phase argument: check ui-plan-gate <phase>', ERROR_REASON.SDK_MISSING_ARG);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
output(computeUiPlanGate(projectDir, phase), raw, undefined);
|
|
441
|
+
}
|
|
323
442
|
function routeCheckCommand({ args, cwd, raw }) {
|
|
324
|
-
|
|
443
|
+
// Normalize dots to hyphens in the subcommand so both forms are accepted.
|
|
444
|
+
// This makes `check.query = "ui.plan-gate"` (dotted form in capability.json gates)
|
|
445
|
+
// directly runnable as `gsd_run check ui.plan-gate` — the dot is normalized to
|
|
446
|
+
// `ui-plan-gate` before routing. The generic gate-dispatch in §5.6 reads
|
|
447
|
+
// `check.query` from the active gate hook and runs `gsd_run check ${hook.check.query}`,
|
|
448
|
+
// so the declared query must be dispatchable exactly as declared.
|
|
449
|
+
const rawSubcommand = args[1];
|
|
450
|
+
const subcommand = typeof rawSubcommand === 'string' ? rawSubcommand.replace(/\./g, '-') : rawSubcommand;
|
|
325
451
|
if (subcommand === 'auto-mode') {
|
|
326
452
|
cmdAutoMode(cwd, raw);
|
|
327
453
|
return;
|
|
@@ -334,10 +460,15 @@ function routeCheckCommand({ args, cwd, raw }) {
|
|
|
334
460
|
cmdDecisionCoverageVerify(cwd, args, raw);
|
|
335
461
|
return;
|
|
336
462
|
}
|
|
337
|
-
|
|
463
|
+
if (subcommand === 'ui-plan-gate') {
|
|
464
|
+
cmdUiPlanGate(cwd, args, raw);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
error('Unknown check subcommand. Available: auto-mode, decision-coverage-plan, decision-coverage-verify, ui-plan-gate', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
|
338
468
|
}
|
|
339
469
|
module.exports = {
|
|
340
470
|
routeCheckCommand,
|
|
341
471
|
decisionMentioned,
|
|
342
472
|
extractPlanDesignatedSections,
|
|
473
|
+
computeUiPlanGate,
|
|
343
474
|
};
|
|
@@ -159,6 +159,7 @@ function checkAgentsInstalled(runtime) {
|
|
|
159
159
|
agents_installed: false,
|
|
160
160
|
missing_agents: expectedAgents,
|
|
161
161
|
installed_agents: [],
|
|
162
|
+
incomplete_agents: [],
|
|
162
163
|
agents_dir: agentsDir,
|
|
163
164
|
agent_runtime: resolvedRuntime,
|
|
164
165
|
};
|
|
@@ -182,10 +183,59 @@ function checkAgentsInstalled(runtime) {
|
|
|
182
183
|
missing.push(agent);
|
|
183
184
|
}
|
|
184
185
|
}
|
|
186
|
+
// ── Manifest-backed completeness check ──────────────────────────────────────
|
|
187
|
+
// If a gsd-file-manifest.json exists alongside the agents dir (parent dir),
|
|
188
|
+
// verify that every manifest-tracked file for each expected agent is present
|
|
189
|
+
// on disk. Missing manifest-tracked files indicate an incomplete install even
|
|
190
|
+
// when the plain presence check above passed (e.g. .md present, .toml absent).
|
|
191
|
+
// If no manifest is found the check is a no-op (graceful for claude/bundled).
|
|
192
|
+
const incomplete = [];
|
|
193
|
+
const manifestPath = node_path_1.default.join(node_path_1.default.dirname(agentsDir), 'gsd-file-manifest.json');
|
|
194
|
+
let manifestFiles = {};
|
|
195
|
+
try {
|
|
196
|
+
const raw = node_fs_1.default.readFileSync(manifestPath, 'utf8');
|
|
197
|
+
const parsed = JSON.parse(raw);
|
|
198
|
+
if (parsed !== null &&
|
|
199
|
+
typeof parsed === 'object' &&
|
|
200
|
+
'files' in parsed &&
|
|
201
|
+
typeof parsed['files'] === 'object' &&
|
|
202
|
+
parsed['files'] !== null) {
|
|
203
|
+
manifestFiles = parsed['files'];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// No manifest or unreadable — completeness check is skipped
|
|
208
|
+
}
|
|
209
|
+
if (Object.keys(manifestFiles).length > 0) {
|
|
210
|
+
for (const agent of expectedAgents) {
|
|
211
|
+
// Find all manifest keys that belong to this agent:
|
|
212
|
+
// key must be "agents/<agentName>.<ext>" with no further path segments.
|
|
213
|
+
const agentPrefix = `agents/${agent}.`;
|
|
214
|
+
const agentManifestKeys = Object.keys(manifestFiles).filter(key => {
|
|
215
|
+
if (!key.startsWith(agentPrefix))
|
|
216
|
+
return false;
|
|
217
|
+
const rest = key.slice(agentPrefix.length);
|
|
218
|
+
// rest must be a bare extension (no slashes, non-empty)
|
|
219
|
+
return rest.length > 0 && !rest.includes('/');
|
|
220
|
+
});
|
|
221
|
+
if (agentManifestKeys.length === 0) {
|
|
222
|
+
// Agent not tracked in manifest — skip completeness check for this agent
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
const allPresent = agentManifestKeys.every(key => {
|
|
226
|
+
const basename = key.slice('agents/'.length);
|
|
227
|
+
return node_fs_1.default.existsSync(node_path_1.default.join(agentsDir, basename));
|
|
228
|
+
});
|
|
229
|
+
if (!allPresent) {
|
|
230
|
+
incomplete.push(agent);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
185
234
|
return {
|
|
186
|
-
agents_installed: installed.length > 0 && missing.length === 0,
|
|
235
|
+
agents_installed: installed.length > 0 && missing.length === 0 && incomplete.length === 0,
|
|
187
236
|
missing_agents: missing,
|
|
188
237
|
installed_agents: installed,
|
|
238
|
+
incomplete_agents: incomplete,
|
|
189
239
|
agents_dir: agentsDir,
|
|
190
240
|
agent_runtime: resolvedRuntime,
|
|
191
241
|
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Spec-completeness edge-probe — the FIRST adapter of the probe-core resolution model
|
|
4
|
+
* (ADR-457 build model; ADR-550 Decision 7 seam).
|
|
5
|
+
*
|
|
6
|
+
* The generic resolution lifecycle, the status×verification re-cut, `validateResolution`,
|
|
7
|
+
* `validateRequirement`, the `analyzeCoverage` merge/rollup/orphan-reject engine, and the
|
|
8
|
+
* `runProbeCli` scaffold all live in `src/probe-core.cts`. This module keeps ONLY the
|
|
9
|
+
* edge-specific cluster: the five data/behavior shapes, the closed 8-category edge taxonomy,
|
|
10
|
+
* shape classification, edge proposal, and the `{ explicit, backstop }` verification validators.
|
|
11
|
+
*
|
|
12
|
+
* Authored as strict TypeScript (`src/edge-probe.cts`) and compiled by
|
|
13
|
+
* `tsc -p tsconfig.build.json` to the gitignored runtime artifact
|
|
14
|
+
* `gsd-core/bin/lib/edge-probe.cjs`. Do NOT hand-write the `.cjs`; it is emitted. Tests
|
|
15
|
+
* `require()` the built artifact; `pretest` runs `build:lib` first.
|
|
16
|
+
*
|
|
17
|
+
* Pure and dependency-free: it classifies each requirement's data/behavior shape, filters
|
|
18
|
+
* the closed 8-category edge taxonomy to applicable categories, proposes concrete candidate
|
|
19
|
+
* edges, and (via probe-core) merges author resolutions into a coverage report.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.EDGE_VALIDATORS = exports.TAXONOMY = exports.VALID_SHAPES = exports.SHAPE_CUES = void 0;
|
|
23
|
+
exports.classifyShape = classifyShape;
|
|
24
|
+
exports.applicableCategories = applicableCategories;
|
|
25
|
+
exports.validateRequirement = validateRequirement;
|
|
26
|
+
exports.validateResolution = validateResolution;
|
|
27
|
+
exports.proposeEdges = proposeEdges;
|
|
28
|
+
exports.analyzeCoverage = analyzeCoverage;
|
|
29
|
+
const probe_core_cjs_1 = require("./probe-core.cjs");
|
|
30
|
+
/**
|
|
31
|
+
* Word-boundary cues mapping requirement prose -> data/behavior shape.
|
|
32
|
+
* Heuristic and intentionally lossy; an authored `shapes` array overrides it.
|
|
33
|
+
*/
|
|
34
|
+
exports.SHAPE_CUES = {
|
|
35
|
+
'numeric-range': /\b(round(ing|ed)?|threshold|max(imum)?|min(imum)?|limit|bound(ary)?|between|cap|percent|amount|price|count|number|score|rate|decimal)\b/i,
|
|
36
|
+
'collection': /\b(lists?|arrays?|sets?|items?|collections?|each|every|all|sort(ed|ing)?|merge|dedupe|group|ranges?|intervals?|overlap(ping)?)\b/i,
|
|
37
|
+
'text': /\b(string|text|names?|labels?|truncate|substring|char(acter)?s?|length|slug|message|unicode)\b/i,
|
|
38
|
+
'stateful': /\b(save|persist|store|update|toggle|create|delete|remove|submit|retry|apply|register|insert)\b/i,
|
|
39
|
+
'io': /\b(files?|requests?|fetch|upload|download|network|api|endpoints?|connections?|sockets?)\b/i,
|
|
40
|
+
};
|
|
41
|
+
/** The locked shape vocabulary — exactly the keys of SHAPE_CUES (single source of truth). */
|
|
42
|
+
exports.VALID_SHAPES = new Set(Object.keys(exports.SHAPE_CUES));
|
|
43
|
+
/** Detect which shapes a requirement's prose matches (heuristic). */
|
|
44
|
+
function classifyShape(text) {
|
|
45
|
+
const shapes = [];
|
|
46
|
+
const subject = String(text == null ? '' : text);
|
|
47
|
+
for (const shape of Object.keys(exports.SHAPE_CUES)) {
|
|
48
|
+
if (exports.SHAPE_CUES[shape].test(subject))
|
|
49
|
+
shapes.push(shape);
|
|
50
|
+
}
|
|
51
|
+
return shapes;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Closed taxonomy of 8 domain-boundary edge categories (established QA names).
|
|
55
|
+
* `shapes` lists which requirement shapes make the category relevant.
|
|
56
|
+
*/
|
|
57
|
+
exports.TAXONOMY = [
|
|
58
|
+
{ id: 'boundary', name: 'Boundary values', shapes: ['numeric-range'], probe: 'What happens exactly at each min/max/threshold — and one step either side?' },
|
|
59
|
+
{ id: 'adjacency', name: 'Adjacency / touching', shapes: ['collection'], probe: 'When two things are exactly equal or just touch, do they merge, collide, or separate?' },
|
|
60
|
+
{ id: 'empty', name: 'Empty / degenerate', shapes: ['collection', 'text'], probe: 'What is the result for empty, single-element, or null input?' },
|
|
61
|
+
{ id: 'encoding', name: 'Encoding / representation', shapes: ['text'], probe: 'Whose definition of length/equality applies — bytes, code points, grapheme clusters, or normalized form?' },
|
|
62
|
+
{ id: 'ordering', name: 'Ordering / stability', shapes: ['collection'], probe: 'When elements compare equal, is output order specified and stable?' },
|
|
63
|
+
{ id: 'precision', name: 'Precision / overflow', shapes: ['numeric-range'], probe: 'Where can precision loss, overflow, or rounding/tie-breaking occur — and what is the exact contract (e.g. half-up vs half-to-even, ceil/floor/truncate)?' },
|
|
64
|
+
{ id: 'idempotency', name: 'Idempotency / repetition', shapes: ['stateful'], probe: 'What happens if this runs twice on the same input?' },
|
|
65
|
+
{ id: 'concurrency', name: 'Concurrency / effect ordering', shapes: ['stateful', 'io'], probe: 'If interrupted or run in parallel, what is guaranteed?' },
|
|
66
|
+
];
|
|
67
|
+
/** Return taxonomy category ids whose applicable shapes intersect the input set. */
|
|
68
|
+
function applicableCategories(shapes) {
|
|
69
|
+
const set = new Set(shapes);
|
|
70
|
+
return exports.TAXONOMY.filter((c) => c.shapes.some((s) => set.has(s))).map((c) => c.id);
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The edge adapter's injected runtime validators (ADR-550 #5). `categories` is the closed
|
|
74
|
+
* taxonomy; both verification tiers require a non-empty `resolution` (an explicit AC's text
|
|
75
|
+
* or a backstop note) so plan-phase has a criterion to lift.
|
|
76
|
+
*/
|
|
77
|
+
exports.EDGE_VALIDATORS = {
|
|
78
|
+
categories: exports.TAXONOMY.map((c) => c.id),
|
|
79
|
+
verification: ['explicit', 'backstop'],
|
|
80
|
+
requiredFieldsByVerification: { explicit: ['resolution'], backstop: ['resolution'] },
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Validate a single requirement — the generic id/text checks (probe-core) plus the edge's
|
|
84
|
+
* `shapes`-must-be-an-array check. A bare string like `shapes:"numeric-range"` would otherwise
|
|
85
|
+
* fall through to prose classification, silently ignoring the authored override.
|
|
86
|
+
*
|
|
87
|
+
* The edge adapter's `text` is REQUIRED (the prose is the classification signal), so reject a
|
|
88
|
+
* missing/empty `text` when no authored `shapes` override is present. Without this, a `{ id }`
|
|
89
|
+
* requirement classifies to zero shapes → zero edges → it is silently DROPPED from coverage
|
|
90
|
+
* with no signal — the exact fail-open this feature exists to eliminate. An explicit `shapes`
|
|
91
|
+
* array (including `[]` for "no applicable categories") is the legitimate way to opt out of
|
|
92
|
+
* prose classification, so `text` is only required when `shapes` is absent.
|
|
93
|
+
*/
|
|
94
|
+
function validateRequirement(requirement) {
|
|
95
|
+
(0, probe_core_cjs_1.validateRequirement)(requirement);
|
|
96
|
+
const r = requirement;
|
|
97
|
+
if (r.shapes != null && !Array.isArray(r.shapes)) {
|
|
98
|
+
throw new Error(`requirement ${requirement.id} shapes must be an array when present`);
|
|
99
|
+
}
|
|
100
|
+
if (r.shapes == null && !(typeof r.text === 'string' && r.text.trim())) {
|
|
101
|
+
throw new Error(`requirement ${requirement.id} text must be a non-empty string when no shapes override is provided`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/** Validate an edge resolution against the edge verification vocabulary. */
|
|
105
|
+
function validateResolution(resolution) {
|
|
106
|
+
return (0, probe_core_cjs_1.validateResolution)(resolution, exports.EDGE_VALIDATORS);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Propose candidate edges for a requirement. Uses authored `shapes` when present, else
|
|
110
|
+
* classifies from prose. Every proposed edge starts unresolved (verification null).
|
|
111
|
+
*/
|
|
112
|
+
function proposeEdges(requirement) {
|
|
113
|
+
validateRequirement(requirement);
|
|
114
|
+
let shapes;
|
|
115
|
+
if (Array.isArray(requirement.shapes)) {
|
|
116
|
+
// Fail closed: an authored array must contain only locked shape values. A non-empty
|
|
117
|
+
// but invalid array (e.g. ['numeric'], a typo for 'numeric-range') would otherwise
|
|
118
|
+
// intersect no category and silently suppress every probe — the gate reads green while
|
|
119
|
+
// nothing was checked. An empty array stays a valid "no applicable categories" override.
|
|
120
|
+
for (const s of requirement.shapes) {
|
|
121
|
+
if (typeof s !== 'string' || !exports.VALID_SHAPES.has(s)) {
|
|
122
|
+
throw new Error(`invalid shape ${JSON.stringify(s)} for requirement ${requirement.id} — must be one of: ${[...exports.VALID_SHAPES].join(', ')}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
shapes = requirement.shapes;
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
shapes = classifyShape(requirement.text);
|
|
129
|
+
}
|
|
130
|
+
return applicableCategories(shapes).map((catId) => {
|
|
131
|
+
const cat = exports.TAXONOMY.find((c) => c.id === catId);
|
|
132
|
+
return {
|
|
133
|
+
requirement_id: requirement.id,
|
|
134
|
+
category: catId,
|
|
135
|
+
status: 'unresolved',
|
|
136
|
+
verification: null,
|
|
137
|
+
resolution: null,
|
|
138
|
+
reason: null,
|
|
139
|
+
probe: cat ? cat.probe : '',
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Propose edges for every requirement (deterministic propose), then delegate the
|
|
145
|
+
* merge/rollup/orphan-reject to probe-core. Edge-specific pre-checks: requirements must be an
|
|
146
|
+
* array, requirement ids must be unique. Throws on any invalid resolution.
|
|
147
|
+
*/
|
|
148
|
+
function analyzeCoverage(requirements, resolutions = []) {
|
|
149
|
+
if (!Array.isArray(requirements)) {
|
|
150
|
+
throw new Error('requirements must be an array');
|
|
151
|
+
}
|
|
152
|
+
const items = [];
|
|
153
|
+
const seenReqIds = new Set();
|
|
154
|
+
for (const req of requirements) {
|
|
155
|
+
validateRequirement(req);
|
|
156
|
+
if (seenReqIds.has(req.id)) {
|
|
157
|
+
throw new Error(`duplicate requirement id ${JSON.stringify(req.id)}`);
|
|
158
|
+
}
|
|
159
|
+
seenReqIds.add(req.id);
|
|
160
|
+
for (const edge of proposeEdges(req))
|
|
161
|
+
items.push(edge);
|
|
162
|
+
}
|
|
163
|
+
return (0, probe_core_cjs_1.analyzeCoverage)(items, resolutions, exports.EDGE_VALIDATORS);
|
|
164
|
+
}
|
|
165
|
+
/*
|
|
166
|
+
* CLI entry (EP-06 invokable surface): `edge-probe.cjs <requirements.json> [resolutions.json]`.
|
|
167
|
+
* The generic I/O plumbing (parse, fail-closed exit 2, pretty-JSON out) lives in probe-core's
|
|
168
|
+
* `runProbeCli`; the edge adapter supplies its `analyzeCoverage`. Guarded by
|
|
169
|
+
* `require.main === module` so it runs only when the compiled `.cjs` is executed directly.
|
|
170
|
+
*/
|
|
171
|
+
if (require.main === module) {
|
|
172
|
+
(0, probe_core_cjs_1.runProbeCli)((requirements, resolutions) => analyzeCoverage(requirements, resolutions), { usage: 'edge-probe.cjs <requirements.json> [resolutions.json]' });
|
|
173
|
+
}
|
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
* ADR-457 build-at-publish: the hand-written bin/lib/fallow-runner.cjs
|
|
6
6
|
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
|
7
7
|
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
|
8
|
+
*
|
|
9
|
+
* Parses the real fallow `audit --format json` schema (schema_version 3
|
|
10
|
+
* envelope, nested dead_code/duplication sections). See fallow 2.70.0+.
|
|
8
11
|
*/
|
|
9
12
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
13
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -13,6 +16,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
13
16
|
exports.resolveFallowBinary = resolveFallowBinary;
|
|
14
17
|
exports.requireFallowBinary = requireFallowBinary;
|
|
15
18
|
exports.normalizeFallowReport = normalizeFallowReport;
|
|
19
|
+
exports.normalizeFallowReportFile = normalizeFallowReportFile;
|
|
16
20
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
21
|
const node_path_1 = __importDefault(require("node:path"));
|
|
18
22
|
function candidateNames() {
|
|
@@ -68,48 +72,82 @@ function requireFallowBinary({ cwd, envPath = process.env['PATH'] ?? '' }) {
|
|
|
68
72
|
throw new Error('Fallow is enabled but no binary was found. Please install fallow via `npm install -D fallow` or `cargo install fallow`.');
|
|
69
73
|
}
|
|
70
74
|
function normalizeFallowReport(report) {
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
75
|
+
const deadCodeRaw = report?.dead_code;
|
|
76
|
+
const duplicationRaw = report?.duplication;
|
|
77
|
+
const unusedExports = (Array.isArray(deadCodeRaw?.unused_exports)
|
|
78
|
+
? (deadCodeRaw?.unused_exports ?? [])
|
|
79
|
+
: []).filter((x) => x !== null && typeof x === 'object');
|
|
80
|
+
const unusedFiles = (Array.isArray(deadCodeRaw?.unused_files)
|
|
81
|
+
? (deadCodeRaw?.unused_files ?? [])
|
|
82
|
+
: []).filter((x) => x !== null && typeof x === 'object');
|
|
83
|
+
const circularDeps = (Array.isArray(deadCodeRaw?.circular_dependencies)
|
|
84
|
+
? (deadCodeRaw?.circular_dependencies ?? [])
|
|
85
|
+
: []).filter((x) => x !== null && typeof x === 'object');
|
|
86
|
+
const cloneGroups = (Array.isArray(duplicationRaw?.clone_groups)
|
|
87
|
+
? (duplicationRaw?.clone_groups ?? [])
|
|
88
|
+
: []).filter((x) => x !== null && typeof x === 'object');
|
|
80
89
|
const findings = [];
|
|
81
|
-
for (const item of
|
|
90
|
+
for (const item of unusedExports) {
|
|
91
|
+
if (!item || typeof item !== 'object')
|
|
92
|
+
continue;
|
|
82
93
|
findings.push({
|
|
83
94
|
type: 'unused_export',
|
|
84
|
-
message: `Unused export ${item.
|
|
85
|
-
file: item.
|
|
95
|
+
message: `Unused export ${item.export_name ?? '<unknown>'}`,
|
|
96
|
+
file: item.path ?? '',
|
|
86
97
|
line: item.line ?? null,
|
|
87
98
|
});
|
|
88
99
|
}
|
|
89
|
-
for (const item of
|
|
100
|
+
for (const item of unusedFiles) {
|
|
101
|
+
if (!item || typeof item !== 'object')
|
|
102
|
+
continue;
|
|
90
103
|
findings.push({
|
|
91
|
-
type: '
|
|
92
|
-
message: `
|
|
93
|
-
file: item.
|
|
94
|
-
line:
|
|
95
|
-
related_file: item.right?.file ?? '',
|
|
104
|
+
type: 'unused_file',
|
|
105
|
+
message: `Unused file ${item.path ?? '<unknown>'}`,
|
|
106
|
+
file: item.path ?? '',
|
|
107
|
+
line: null,
|
|
96
108
|
});
|
|
97
109
|
}
|
|
98
|
-
for (const item of
|
|
110
|
+
for (const item of circularDeps) {
|
|
111
|
+
if (!item || typeof item !== 'object')
|
|
112
|
+
continue;
|
|
113
|
+
const files = Array.isArray(item.files) ? item.files : [];
|
|
99
114
|
findings.push({
|
|
100
115
|
type: 'circular_dependency',
|
|
101
|
-
message: `Circular dependency: ${
|
|
102
|
-
file:
|
|
103
|
-
line: null,
|
|
116
|
+
message: `Circular dependency: ${files.join(' -> ')}`,
|
|
117
|
+
file: files.length > 0 ? files[0] : '',
|
|
118
|
+
line: item.line ?? null,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
for (const group of cloneGroups) {
|
|
122
|
+
if (!group || typeof group !== 'object')
|
|
123
|
+
continue;
|
|
124
|
+
const instances = Array.isArray(group.instances) ? group.instances : [];
|
|
125
|
+
findings.push({
|
|
126
|
+
type: 'duplicate_block',
|
|
127
|
+
message: `Duplicate block (${instances.length} instances)`,
|
|
128
|
+
file: instances[0]?.file ?? '',
|
|
129
|
+
line: instances[0]?.start_line ?? null,
|
|
130
|
+
related_file: instances[1]?.file ?? '',
|
|
104
131
|
});
|
|
105
132
|
}
|
|
106
133
|
return {
|
|
107
134
|
summary: {
|
|
108
|
-
unused_exports:
|
|
109
|
-
|
|
110
|
-
|
|
135
|
+
unused_exports: unusedExports.length,
|
|
136
|
+
unused_files: unusedFiles.length,
|
|
137
|
+
duplicates: cloneGroups.length,
|
|
138
|
+
circular_dependencies: circularDeps.length,
|
|
111
139
|
total: findings.length,
|
|
112
140
|
},
|
|
113
141
|
findings,
|
|
114
142
|
};
|
|
115
143
|
}
|
|
144
|
+
function normalizeFallowReportFile(filePath) {
|
|
145
|
+
try {
|
|
146
|
+
const raw = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
147
|
+
const parsed = JSON.parse(raw);
|
|
148
|
+
return normalizeFallowReport(parsed);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return normalizeFallowReport(null);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
@@ -149,7 +149,7 @@ function matchesInValue(value, lowerTerm) {
|
|
|
149
149
|
}
|
|
150
150
|
/**
|
|
151
151
|
* Query intel files for a search term.
|
|
152
|
-
* Searches across all JSON intel files (keys and values)
|
|
152
|
+
* Searches across all JSON intel files in INTEL_FILES (keys and values), including arch-decisions.json (parsed as JSON, not as text).
|
|
153
153
|
*/
|
|
154
154
|
function intelQuery(term, planningDir) {
|
|
155
155
|
if (!isIntelEnabled(planningDir))
|
|
@@ -328,7 +328,7 @@ function intelValidate(planningDir) {
|
|
|
328
328
|
}
|
|
329
329
|
// Validate entries are objects with expected fields
|
|
330
330
|
if (data.entries && typeof data.entries === 'object') {
|
|
331
|
-
//
|
|
331
|
+
// file-roles.json (INTEL_FILES key 'files'): check exports are actual symbol names (no spaces)
|
|
332
332
|
if (key === 'files') {
|
|
333
333
|
for (const [entryPath, entry] of Object.entries(data.entries)) {
|
|
334
334
|
const entryObj = entry;
|
|
@@ -348,7 +348,7 @@ function intelValidate(planningDir) {
|
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
}
|
|
351
|
-
//
|
|
351
|
+
// dependency-graph.json (INTEL_FILES key 'deps'): check entries have version, type, used_by
|
|
352
352
|
if (key === 'deps') {
|
|
353
353
|
for (const [depName, entry] of Object.entries(data.entries)) {
|
|
354
354
|
const entryObj = entry;
|
package/gsd-core/bin/lib/io.cjs
CHANGED
|
@@ -65,6 +65,61 @@ function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnl
|
|
|
65
65
|
}
|
|
66
66
|
}
|
|
67
67
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
68
|
+
/**
|
|
69
|
+
* Transient write errnos. When stdout/stderr is a NON-BLOCKING pipe — as it is
|
|
70
|
+
* under the parallel `node --test` runner on Linux CI — a full pipe buffer makes
|
|
71
|
+
* `fs.writeSync` throw EAGAIN, and a signal can interrupt it with EINTR. Both
|
|
72
|
+
* clear on retry once the reader drains. This is the same transient class the
|
|
73
|
+
* STATE.md lock path already retries (ACQUIRE_LOCK_RETRY_ERRNOS, #3776); #1008.
|
|
74
|
+
*/
|
|
75
|
+
const WRITE_RETRY_ERRNOS = new Set(['EAGAIN', 'EINTR']);
|
|
76
|
+
// Bounded so a pathological never-draining fd cannot spin forever. Each retry
|
|
77
|
+
// yields the thread for ~1ms via Atomics.wait (the project's sync-sleep idiom —
|
|
78
|
+
// see clock.cts realClock.sleep), so the cap is ~1s of total back-pressure wait.
|
|
79
|
+
const WRITE_MAX_RETRIES = 1000;
|
|
80
|
+
const WRITE_RETRY_BACKOFF_MS = 1;
|
|
81
|
+
// Sleep buffer is lazily allocated on the FIRST back-pressure retry (rare — only
|
|
82
|
+
// when a non-blocking pipe is full) and then reused. Keeping it out of module
|
|
83
|
+
// load costs nothing on the overwhelmingly common no-retry path and avoids
|
|
84
|
+
// perturbing SharedArrayBuffer-allocation accounting in other modules (perf-316).
|
|
85
|
+
let _writeSleepBuf = null;
|
|
86
|
+
function backoffOnce() {
|
|
87
|
+
if (_writeSleepBuf === null)
|
|
88
|
+
_writeSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
89
|
+
Atomics.wait(_writeSleepBuf, 0, 0, WRITE_RETRY_BACKOFF_MS);
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Write the entire payload to `fd`, tolerating non-blocking-pipe back-pressure.
|
|
93
|
+
*
|
|
94
|
+
* `fs.writeSync` does NOT block on a non-blocking pipe: a full buffer throws
|
|
95
|
+
* EAGAIN, and a partially-drained buffer returns a SHORT count (fewer bytes than
|
|
96
|
+
* requested). The previous bare `fs.writeSync(fd, string)` call assumed it always
|
|
97
|
+
* blocked until the kernel accepted every byte — false under load, which both
|
|
98
|
+
* threw spurious errors and risked silently truncating output (#1008).
|
|
99
|
+
*
|
|
100
|
+
* This loops on short counts (advancing the offset) and retries EAGAIN/EINTR with
|
|
101
|
+
* a brief Atomics.wait backoff that yields the thread so the reader can drain.
|
|
102
|
+
* Non-transient errors (e.g. EPIPE) propagate unchanged.
|
|
103
|
+
*/
|
|
104
|
+
function writeAllSync(fd, data) {
|
|
105
|
+
const buf = Buffer.from(data, 'utf8');
|
|
106
|
+
let offset = 0;
|
|
107
|
+
let retries = 0;
|
|
108
|
+
while (offset < buf.length) {
|
|
109
|
+
try {
|
|
110
|
+
offset += node_fs_1.default.writeSync(fd, buf, offset, buf.length - offset);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const code = err.code ?? '';
|
|
114
|
+
if (WRITE_RETRY_ERRNOS.has(code) && retries < WRITE_MAX_RETRIES) {
|
|
115
|
+
retries += 1;
|
|
116
|
+
backoffOnce();
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
throw err;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
68
123
|
function output(result, raw, rawValue) {
|
|
69
124
|
let data;
|
|
70
125
|
if (raw && rawValue !== undefined) {
|
|
@@ -87,10 +142,10 @@ function output(result, raw, rawValue) {
|
|
|
87
142
|
}
|
|
88
143
|
}
|
|
89
144
|
// process.stdout.write() is async when stdout is a pipe — process.exit()
|
|
90
|
-
// can tear down the process before the reader consumes the buffer.
|
|
91
|
-
//
|
|
92
|
-
// skipping process.exit() lets the event loop drain naturally.
|
|
93
|
-
|
|
145
|
+
// can tear down the process before the reader consumes the buffer. writeAllSync
|
|
146
|
+
// pushes every byte synchronously (looping short counts, retrying EAGAIN/EINTR),
|
|
147
|
+
// and skipping process.exit() lets the event loop drain naturally.
|
|
148
|
+
writeAllSync(1, data);
|
|
94
149
|
}
|
|
95
150
|
/**
|
|
96
151
|
* Frozen enum of typed reason codes used by error() for structured errors.
|
|
@@ -148,10 +203,10 @@ function getJsonErrorMode() { return _jsonErrorMode; }
|
|
|
148
203
|
function error(message, reason = ERROR_REASON.UNKNOWN) {
|
|
149
204
|
if (_jsonErrorMode) {
|
|
150
205
|
const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
|
|
151
|
-
|
|
206
|
+
writeAllSync(2, payload);
|
|
152
207
|
}
|
|
153
208
|
else {
|
|
154
|
-
|
|
209
|
+
writeAllSync(2, 'Error: ' + message + '\n');
|
|
155
210
|
}
|
|
156
211
|
process.exit(1);
|
|
157
212
|
}
|