@smartmemory/compose 0.2.49-beta → 0.2.51-beta
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/skills/compose/SKILL.md +24 -1
- package/.claude/skills/compose/references/hermes-tools.md +80 -0
- package/README.md +1 -1
- package/bin/compose.js +340 -70
- package/dist/assets/App-CT0vXPgd.js +724 -0
- package/dist/assets/_baseUniq-CsOHc_iS.js +1 -0
- package/dist/assets/{arc-B1TqAC_R.js → arc-BXht3LyE.js} +1 -1
- package/dist/assets/architectureDiagram-Q4EWVU46-BV1r86-w.js +36 -0
- package/dist/assets/blockDiagram-DXYQGD6D-BgcMJjAR.js +132 -0
- package/dist/assets/{browser-DW2JlJCC.js → browser-CnKiSnlr.js} +6 -6
- package/dist/assets/{c4Diagram-AAUBKEIU-Dc6swoQo.js → c4Diagram-AHTNJAMY-nBIRLJTv.js} +1 -1
- package/dist/assets/channel-Dd4XaiYv.js +1 -0
- package/dist/assets/{chunk-4BX2VUAB-CdqXgd68.js → chunk-4BX2VUAB-CNmGyhrp.js} +1 -1
- package/dist/assets/chunk-4TB4RGXK-MLT7I7w4.js +206 -0
- package/dist/assets/{chunk-55IACEB6-D7VauOmU.js → chunk-55IACEB6-D8KuKVLa.js} +1 -1
- package/dist/assets/{chunk-2J33WTMH-6HKDSnOw.js → chunk-EDXVE4YY-TPlt1bS2.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-C1Z5m8DP.js → chunk-FMBD7UC4-tYY8xcjr.js} +1 -1
- package/dist/assets/chunk-OYMX7WX6-yBPwT1AT.js +231 -0
- package/dist/assets/{chunk-QZHKN3VN-DY9GU2uD.js → chunk-QZHKN3VN-B4Wfybww.js} +1 -1
- package/dist/assets/{chunk-ND2GUHAM-DmqZMEQe.js → chunk-YZCP3GAM-DE6_5aqh.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DshSQDF9.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DshSQDF9.js +1 -0
- package/dist/assets/clone-CBsbNGAa.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-B34g9xzV.js → cose-bilkent-S5V4N54A-DzzRyJtB.js} +1 -1
- package/dist/assets/dagre-KV5264BT-CTcTVGdU.js +4 -0
- package/dist/assets/diagram-5BDNPKRD-qR6mc8kJ.js +10 -0
- package/dist/assets/diagram-G4DWMVQ6-CUcGXyNb.js +24 -0
- package/dist/assets/diagram-MMDJMWI5-B68cPonH.js +43 -0
- package/dist/assets/diagram-TYMM5635-q8Id8eFG.js +24 -0
- package/dist/assets/{erDiagram-TEJ5UH35-C05_Iqmb.js → erDiagram-SMLLAGMA-CtxJi57K.js} +1 -1
- package/dist/assets/{flowDiagram-I6XJVG4X-lXpnb-Ch.js → flowDiagram-DWJPFMVM-BzzO4JYU.js} +1 -1
- package/dist/assets/ganttDiagram-T4ZO3ILL-Dn0wIyhR.js +292 -0
- package/dist/assets/gitGraphDiagram-UUTBAWPF-tnNFXfMM.js +106 -0
- package/dist/assets/graph-DZe55uk8.js +331 -0
- package/dist/assets/graph-Tq_bs_r0.js +1 -0
- package/dist/assets/index-8UhRLbGq.js +123 -0
- package/dist/assets/index-CRqB9els.css +1 -0
- package/dist/assets/infoDiagram-42DDH7IO-BXLvLeS0.js +2 -0
- package/dist/assets/{ishikawaDiagram-YF4QCWOH-CgdsWdgu.js → ishikawaDiagram-UXIWVN3A-DWx-7BBy.js} +5 -5
- package/dist/assets/{journeyDiagram-JHISSGLW-CyvHmE3z.js → journeyDiagram-VCZTEJTY-C42hXNha.js} +1 -1
- package/dist/assets/{kanban-definition-UN3LZRKU-Bqjdyv0T.js → kanban-definition-6JOO6SKY-CEq930ew.js} +1 -1
- package/dist/assets/katex-DkKDou_j.js +257 -0
- package/dist/assets/layout-qtUgN9BC.js +1 -0
- package/dist/assets/{linear-DecnRXf0.js → linear-D158F7OT.js} +1 -1
- package/dist/assets/min-hESxN-c0.js +1 -0
- package/dist/assets/{mindmap-definition-RKZ34NQL-DM1x9vNY.js → mindmap-definition-QFDTVHPH-d9SSh2nG.js} +7 -7
- package/dist/assets/mobile-1gVCT0OK.css +1 -0
- package/dist/assets/mobile-Chw8RWyH.js +17 -0
- package/dist/assets/pieDiagram-DEJITSTG-gglmeMav.js +30 -0
- package/dist/assets/quadrantDiagram-34T5L4WZ-CqFC_htU.js +7 -0
- package/dist/assets/{requirementDiagram-4Y6WPE33-DD-Gg_y5.js → requirementDiagram-MS252O5E-B1ZFIotK.js} +1 -1
- package/dist/assets/sankeyDiagram-XADWPNL6-BvItvZt5.js +10 -0
- package/dist/assets/sequenceDiagram-FGHM5R23-CAIn5Z3U.js +157 -0
- package/dist/assets/stateDiagram-FHFEXIEX-CyJrGzib.js +1 -0
- package/dist/assets/stateDiagram-v2-QKLJ7IA2-DprhF_Ho.js +1 -0
- package/dist/assets/{timeline-definition-PNZ67QCA-BPm2AEQj.js → timeline-definition-GMOUNBTQ-k_0vJQAK.js} +1 -1
- package/dist/assets/{vennDiagram-CIIHVFJN-DQCDWwlj.js → vennDiagram-DHZGUBPP-TZAW60b0.js} +4 -4
- package/dist/assets/wardley-RL74JXVD-DzQEoG6X.js +162 -0
- package/dist/assets/wardleyDiagram-NUSXRM2D-BAoVVIjZ.js +20 -0
- package/dist/assets/{xychartDiagram-2RQKCTM6-Ba05SXXF.js → xychartDiagram-5P7HB3ND-CynkZcUb.js} +1 -1
- package/dist/index.html +4 -4
- package/lib/build-all.js +2 -1
- package/lib/build.js +363 -38
- package/lib/checkpoint/checkpoint-writer.js +3 -2
- package/lib/codex-preflight.js +151 -0
- package/lib/completion-writer.js +59 -15
- package/lib/feature-json.js +14 -3
- package/lib/feature-validator.js +27 -10
- package/lib/feature-write-guard.js +5 -7
- package/lib/feature-writer.js +3 -3
- package/lib/followup-writer.js +2 -2
- package/lib/get-roadmap.js +2 -2
- package/lib/gsd.js +2 -1
- package/lib/hooks-status.js +135 -0
- package/lib/ideabox.js +3 -2
- package/lib/install-agent-defs.js +22 -0
- package/lib/journal-writer.js +3 -3
- package/lib/migrate-anon.js +277 -0
- package/lib/migrate-roadmap.js +2 -3
- package/lib/paths-core.js +47 -0
- package/lib/project-paths.js +46 -37
- package/lib/roadmap-gen.js +3 -4
- package/lib/roadmap-graph/collect.js +5 -5
- package/lib/roadmap-graph/index.js +2 -2
- package/lib/roadmap-parser.js +1 -1
- package/lib/state-migrations.js +3 -3
- package/lib/test-bootstrap.js +165 -2
- package/lib/tracker/local-provider.js +2 -2
- package/lib/triage.js +7 -5
- package/lib/vocabulary-inject.js +103 -0
- package/lib/xref-push.js +4 -4
- package/lib/xref-sync.js +4 -4
- package/package.json +2 -1
- package/pipelines/build-quick.stratum.yaml +433 -0
- package/pipelines/build.stratum.yaml +60 -3
- package/scripts/watch-server.sh +32 -0
- package/server/build-routes.js +89 -9
- package/server/build-stream-bridge.js +12 -0
- package/server/compose-mcp-tools.js +73 -1
- package/server/compose-mcp.js +27 -0
- package/server/design-routes.js +9 -4
- package/server/drift-axes.js +5 -3
- package/server/feature-scaffold-routes.js +82 -0
- package/server/feature-scan.js +8 -2
- package/server/file-watcher.js +13 -7
- package/server/health-routes.js +261 -0
- package/server/ideabox-routes.js +12 -11
- package/server/project-root.js +7 -4
- package/server/qa-scope-routes.js +96 -0
- package/server/session-routes.js +2 -2
- package/server/stratum-sync.js +85 -9
- package/server/validate-routes.js +70 -0
- package/server/vision-routes.js +46 -2
- package/server/vision-server.js +25 -3
- package/server/vision-utils.js +23 -4
- package/dist/assets/App-DOerhoDp.js +0 -706
- package/dist/assets/architectureDiagram-3BPJPVTR-C4hfxNOw.js +0 -36
- package/dist/assets/blockDiagram-GPEHLZMM-DRqkGncy.js +0 -132
- package/dist/assets/channel-KsY1x-7s.js +0 -1
- package/dist/assets/chunk-727SXJPM-CeKpXDif.js +0 -206
- package/dist/assets/chunk-AQP2D5EJ-y2XL-YHV.js +0 -231
- package/dist/assets/classDiagram-4FO5ZUOK-zDEDoVPk.js +0 -1
- package/dist/assets/classDiagram-v2-Q7XG4LA2-zDEDoVPk.js +0 -1
- package/dist/assets/dagre-BM42HDAG-klZ_GOcw.js +0 -4
- package/dist/assets/diagram-2AECGRRQ-D0FmM6Wf.js +0 -43
- package/dist/assets/diagram-5GNKFQAL-DYuhby6q.js +0 -10
- package/dist/assets/diagram-KO2AKTUF-CCtQZgal.js +0 -3
- package/dist/assets/diagram-LMA3HP47-Cd1OqEln.js +0 -24
- package/dist/assets/diagram-OG6HWLK6-CiJrhoxo.js +0 -24
- package/dist/assets/ganttDiagram-6RSMTGT7-EPbL_2Hr.js +0 -292
- package/dist/assets/gitGraphDiagram-PVQCEYII-COCoF_vW.js +0 -106
- package/dist/assets/graph-BBXaecIU.js +0 -331
- package/dist/assets/graph-CAnANduQ.js +0 -1
- package/dist/assets/index-BwLfbbOu.css +0 -1
- package/dist/assets/index-DlgxA8dX.js +0 -123
- package/dist/assets/infoDiagram-5YYISTIA-B1sJw6j5.js +0 -2
- package/dist/assets/katex-C5jXJg4s.js +0 -257
- package/dist/assets/layout-DGIYPm2g.js +0 -1
- package/dist/assets/mobile-Cag5dHlF.css +0 -1
- package/dist/assets/mobile-DwmxS_O4.js +0 -17
- package/dist/assets/pieDiagram-4H26LBE5-CRtmCvwV.js +0 -30
- package/dist/assets/quadrantDiagram-W4KKPZXB-CsBLWg27.js +0 -7
- package/dist/assets/sankeyDiagram-5OEKKPKP-BPdMN8UU.js +0 -40
- package/dist/assets/sequenceDiagram-3UESZ5HK-D4AENdze.js +0 -162
- package/dist/assets/stateDiagram-AJRCARHV-DDplpCf_.js +0 -1
- package/dist/assets/stateDiagram-v2-BHNVJYJU-J4t9AVzF.js +0 -1
- package/dist/assets/wardley-L42UT6IY-WERqOmJt.js +0 -173
- package/dist/assets/wardleyDiagram-YWT4CUSO-5teUSOiI.js +0 -78
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* health-routes.js — Read-only environment-health REST API (COMP-PARITY-3).
|
|
3
|
+
*
|
|
4
|
+
* Routes:
|
|
5
|
+
* GET /api/environment-health — surfaces `compose doctor` (external-dep
|
|
6
|
+
* presence + version drift) and `compose hooks status` (git-hook drift) as
|
|
7
|
+
* structured JSON for the cockpit's header health-dot + popover.
|
|
8
|
+
* POST /api/environment-health/repair-hooks (COMP-PARITY-3-1) — guarded,
|
|
9
|
+
* local, idempotent remediation: runs `compose hooks install --post-commit
|
|
10
|
+
* --pre-push --workspace=<id>` (+ `--force` when body.force) in the request
|
|
11
|
+
* workspace, then returns the recomputed hook states. Mutating → gated by
|
|
12
|
+
* requireSensitiveOrPaired. Dependency installs and `compose update` are
|
|
13
|
+
* deliberately NOT executed server-side (the panel surfaces command text).
|
|
14
|
+
*
|
|
15
|
+
* Deliberately NOT under `/api/health/*`: that prefix is in the remote auth
|
|
16
|
+
* allowlist (server/index.js + auth-middleware.js prefix-match), so nesting
|
|
17
|
+
* here would make local dependency inventory and `?refresh` registry fetches
|
|
18
|
+
* publicly reachable in remote mode. This path is non-allowlisted → default-deny
|
|
19
|
+
* in remote mode (sensitive token / paired JWT), open on localhost.
|
|
20
|
+
*
|
|
21
|
+
* Degrade-never-fail: each section is computed defensively; a failing sub-check
|
|
22
|
+
* becomes `{ unavailable: true }` (deps/binaries/hooks) or `null` (version).
|
|
23
|
+
* The endpoint never 500s on a sub-check.
|
|
24
|
+
*
|
|
25
|
+
* Reuses (no logic fork):
|
|
26
|
+
* - lib/deps.js — dep/binary presence (off the compose PACKAGE_ROOT)
|
|
27
|
+
* - lib/version-check.js — version drift (off PACKAGE_ROOT + homedir cache)
|
|
28
|
+
* - lib/hooks-status.js — git-hook drift (off the request's workspace root)
|
|
29
|
+
*/
|
|
30
|
+
import { join, dirname, resolve } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
32
|
+
import { readFileSync } from 'node:fs';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
loadDeps,
|
|
37
|
+
checkExternalSkills,
|
|
38
|
+
buildDepReport,
|
|
39
|
+
checkExternalBinaries,
|
|
40
|
+
buildBinaryReport,
|
|
41
|
+
} from '../lib/deps.js';
|
|
42
|
+
import { checkLatestVersion } from '../lib/version-check.js';
|
|
43
|
+
import { computeHooksStatus } from '../lib/hooks-status.js';
|
|
44
|
+
import { requireSensitiveOrPaired as requireSensitiveToken } from './security.js';
|
|
45
|
+
|
|
46
|
+
/** The compose package root (where .compose-deps.json + package.json live). */
|
|
47
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
48
|
+
|
|
49
|
+
function safe(fn, fallback) {
|
|
50
|
+
try {
|
|
51
|
+
return fn();
|
|
52
|
+
} catch {
|
|
53
|
+
return fallback;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Map a raw hook status (from computeHooksStatus) to the API-facing shape.
|
|
59
|
+
* The key honesty rule: an installed-current hook whose workspace id could not
|
|
60
|
+
* be verified (expectedWsId was null) is reported as `workspace-unverified`,
|
|
61
|
+
* never a false `installed-current`.
|
|
62
|
+
*/
|
|
63
|
+
export function hookRawToApi(raw) {
|
|
64
|
+
if (!raw || typeof raw !== 'object') return { state: 'unavailable' };
|
|
65
|
+
if (raw.state === 'installed-current') {
|
|
66
|
+
return raw.wsVerified
|
|
67
|
+
? { state: 'installed-current', workspace: raw.bakedWorkspace ?? null }
|
|
68
|
+
: { state: 'workspace-unverified' };
|
|
69
|
+
}
|
|
70
|
+
if (raw.state === 'installed-stale') {
|
|
71
|
+
const out = { state: 'installed-stale', reason: raw.reason ?? null };
|
|
72
|
+
if (raw.reason === 'STALE_WORKSPACE_ID') out.expected = { workspaceId: raw.expectedWorkspace ?? null };
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
return { state: raw.state }; // 'absent' | 'foreign'
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Worst-wins summary rollup driving the header dot color.
|
|
80
|
+
* error: any required dep/binary missing OR any hook `foreign`
|
|
81
|
+
* warn: version behind OR hook installed-stale/workspace-unverified OR
|
|
82
|
+
* optional dep/binary missing OR a section is unavailable
|
|
83
|
+
* ok: everything present/current/up-to-date (version null & hook absent are neutral)
|
|
84
|
+
*/
|
|
85
|
+
export function rollupSummary({ dependencies, binaries, version, hooks }) {
|
|
86
|
+
let error = false;
|
|
87
|
+
let warn = false;
|
|
88
|
+
|
|
89
|
+
for (const section of [dependencies, binaries]) {
|
|
90
|
+
if (!section) continue;
|
|
91
|
+
if (section.unavailable) {
|
|
92
|
+
warn = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
for (const d of section.missing || []) {
|
|
96
|
+
if (d.optional) warn = true;
|
|
97
|
+
else error = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (version && version.behind) warn = true;
|
|
102
|
+
|
|
103
|
+
if (hooks && hooks.unavailable) {
|
|
104
|
+
warn = true;
|
|
105
|
+
} else {
|
|
106
|
+
for (const h of Object.values(hooks || {})) {
|
|
107
|
+
if (h?.state === 'foreign') error = true;
|
|
108
|
+
else if (h?.state === 'installed-stale' || h?.state === 'workspace-unverified') warn = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return error ? 'error' : warn ? 'warn' : 'ok';
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Default command runner: spawn `node <composeBin> <args...>` synchronously in
|
|
117
|
+
* the given cwd. Injectable via attachHealthRoutes so tests can stub it without
|
|
118
|
+
* shelling out. Returns a normalized `{ status, stdout, stderr, error }` so the
|
|
119
|
+
* route never has to know about spawnSync's shape and never throws on failure.
|
|
120
|
+
*
|
|
121
|
+
* @param {object} opts
|
|
122
|
+
* @param {string} opts.composeNode — node binary to run compose with
|
|
123
|
+
* @param {string} opts.composeBin — path to bin/compose.js
|
|
124
|
+
* @param {string[]} opts.args — compose CLI args (e.g. ['hooks','install',…])
|
|
125
|
+
* @param {string} opts.cwd — working directory (the workspace root)
|
|
126
|
+
* @returns {{status:number|null, stdout:string, stderr:string, error:string|null}}
|
|
127
|
+
*/
|
|
128
|
+
function defaultRunCommand({ composeNode, composeBin, args, cwd }) {
|
|
129
|
+
const r = spawnSync(composeNode, [composeBin, ...args], {
|
|
130
|
+
cwd,
|
|
131
|
+
encoding: 'utf-8',
|
|
132
|
+
timeout: 30000,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
status: r.status,
|
|
136
|
+
stdout: r.stdout || '',
|
|
137
|
+
stderr: r.stderr || '',
|
|
138
|
+
error: r.error ? r.error.message : null,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {import('express').Express} app
|
|
144
|
+
* @param {object} [deps]
|
|
145
|
+
* @param {string} [deps.packageRoot] — compose package root (default: resolved from this module)
|
|
146
|
+
* @param {string} [deps.composeBin] — expected COMPOSE_BIN baked in hooks
|
|
147
|
+
* @param {string} [deps.composeNode] — expected COMPOSE_NODE baked in hooks
|
|
148
|
+
* @param {Function} [deps.runCommand] — injectable command runner (default: spawnSync-based)
|
|
149
|
+
*/
|
|
150
|
+
export function attachHealthRoutes(app, {
|
|
151
|
+
packageRoot = PACKAGE_ROOT,
|
|
152
|
+
composeBin = join(PACKAGE_ROOT, 'bin', 'compose.js'),
|
|
153
|
+
composeNode = process.execPath,
|
|
154
|
+
runCommand = defaultRunCommand,
|
|
155
|
+
} = {}) {
|
|
156
|
+
app.get('/api/environment-health', async (req, res) => {
|
|
157
|
+
// --- dependencies + binaries (compose install) ---------------------------
|
|
158
|
+
let dependencies;
|
|
159
|
+
let binaries;
|
|
160
|
+
const deps = safe(() => loadDeps(packageRoot), null);
|
|
161
|
+
if (!deps) {
|
|
162
|
+
// loadDeps returns null (not throws) on a missing/invalid manifest — must
|
|
163
|
+
// branch explicitly; the checkers would throw or mislead on a null deps.
|
|
164
|
+
dependencies = { unavailable: true };
|
|
165
|
+
binaries = { unavailable: true };
|
|
166
|
+
} else {
|
|
167
|
+
dependencies = safe(() => {
|
|
168
|
+
// Drop scannedPaths — it leaks absolute host filesystem paths
|
|
169
|
+
// (e.g. ~/.claude/...) the UI doesn't need; this route is remotely
|
|
170
|
+
// reachable when paired. The contract is {present, missing}.
|
|
171
|
+
const { scannedPaths, ...rest } = buildDepReport(checkExternalSkills(deps));
|
|
172
|
+
return rest;
|
|
173
|
+
}, { unavailable: true });
|
|
174
|
+
binaries = safe(() => buildBinaryReport(checkExternalBinaries(deps)), { unavailable: true });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// --- version drift (compose install + homedir cache) ---------------------
|
|
178
|
+
let version = null;
|
|
179
|
+
try {
|
|
180
|
+
const current = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8')).version;
|
|
181
|
+
version = await checkLatestVersion(current, { force: req.query.refresh === '1' });
|
|
182
|
+
} catch {
|
|
183
|
+
version = null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// --- git hooks (this request's workspace) --------------------------------
|
|
187
|
+
let hooks;
|
|
188
|
+
try {
|
|
189
|
+
const root = req.workspace?.root;
|
|
190
|
+
if (!root) throw new Error('no workspace root');
|
|
191
|
+
const raw = computeHooksStatus({
|
|
192
|
+
projectRoot: root,
|
|
193
|
+
expectedWsId: req.workspace?.id ?? null,
|
|
194
|
+
composeNode,
|
|
195
|
+
composeBin,
|
|
196
|
+
});
|
|
197
|
+
hooks = {};
|
|
198
|
+
for (const [type, r] of Object.entries(raw)) hooks[type] = hookRawToApi(r);
|
|
199
|
+
} catch {
|
|
200
|
+
hooks = { unavailable: true };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const summary = rollupSummary({ dependencies, binaries, version, hooks });
|
|
204
|
+
res.json({ summary, dependencies, binaries, version, hooks });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// --- POST /api/environment-health/repair-hooks (COMP-PARITY-3-1) ----------
|
|
208
|
+
// Guarded, local, idempotent remediation. Only the hook repair is executed
|
|
209
|
+
// server-side; dependency installs and `compose update` are intentionally NOT
|
|
210
|
+
// run here (the panel surfaces their command text instead).
|
|
211
|
+
app.post('/api/environment-health/repair-hooks', requireSensitiveToken, (req, res) => {
|
|
212
|
+
const root = req.workspace?.root;
|
|
213
|
+
const wsId = req.workspace?.id ?? null;
|
|
214
|
+
if (!root) {
|
|
215
|
+
// No workspace to operate in — a guarded failure, not a 500.
|
|
216
|
+
return res.json({ ok: false, error: 'no workspace root', stderr: '' });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const force = req.body && req.body.force === true;
|
|
220
|
+
const args = ['hooks', 'install', '--post-commit', '--pre-push'];
|
|
221
|
+
if (wsId != null) args.push(`--workspace=${wsId}`);
|
|
222
|
+
if (force) args.push('--force');
|
|
223
|
+
const ranCommand = `compose ${args.join(' ')}`;
|
|
224
|
+
|
|
225
|
+
// Run the repair. The runner normalizes spawnSync's shape and never throws;
|
|
226
|
+
// a non-zero exit / spawn error becomes { ok:false }, never a 500.
|
|
227
|
+
let result;
|
|
228
|
+
try {
|
|
229
|
+
result = runCommand({ composeNode, composeBin, args, cwd: root });
|
|
230
|
+
} catch (e) {
|
|
231
|
+
return res.json({ ok: false, error: e?.message || 'command failed', stderr: '' });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const failed = result.error != null || (result.status != null && result.status !== 0);
|
|
235
|
+
if (failed) {
|
|
236
|
+
return res.json({
|
|
237
|
+
ok: false,
|
|
238
|
+
error: result.error || `compose hooks install exited with status ${result.status}`,
|
|
239
|
+
stderr: result.stderr || '',
|
|
240
|
+
ranCommand,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Recompute hook states so the panel can refresh from the response (or refetch).
|
|
245
|
+
let hooks;
|
|
246
|
+
try {
|
|
247
|
+
const raw = computeHooksStatus({
|
|
248
|
+
projectRoot: root,
|
|
249
|
+
expectedWsId: wsId,
|
|
250
|
+
composeNode,
|
|
251
|
+
composeBin,
|
|
252
|
+
});
|
|
253
|
+
hooks = {};
|
|
254
|
+
for (const [type, r] of Object.entries(raw)) hooks[type] = hookRawToApi(r);
|
|
255
|
+
} catch {
|
|
256
|
+
hooks = { unavailable: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
res.json({ ok: true, ranCommand, hooks });
|
|
260
|
+
});
|
|
261
|
+
}
|
package/server/ideabox-routes.js
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from '../lib/ideabox.js'
|
|
26
26
|
import { writeFeature } from '../lib/feature-json.js'
|
|
27
27
|
import { IdeaboxCache } from './ideabox-cache.js'
|
|
28
|
+
import { resolveIdeaboxPathFromConfig, resolveFeaturesPathFromConfig, relForDisplay } from '../lib/project-paths.js'
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* @param {object} app — Express app
|
|
@@ -41,8 +42,7 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
|
|
|
41
42
|
const dataDir = getDataDir()
|
|
42
43
|
if (!_cache || _lastProjectRoot !== projectRoot || _lastDataDir !== dataDir) {
|
|
43
44
|
const config = loadConfig(projectRoot)
|
|
44
|
-
const
|
|
45
|
-
const sourceFile = path.join(projectRoot, ideaboxRel)
|
|
45
|
+
const sourceFile = resolveIdeaboxPathFromConfig(projectRoot, config)
|
|
46
46
|
_cache = new IdeaboxCache(dataDir, sourceFile)
|
|
47
47
|
_lastProjectRoot = projectRoot
|
|
48
48
|
_lastDataDir = dataDir
|
|
@@ -172,16 +172,17 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
|
|
|
172
172
|
resolvedCode = `IDEA-${sourceIdea.num}-${slug}`.toUpperCase()
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
// Create feature folder + feature.json (same logic as CLI promote)
|
|
175
|
+
// Create feature folder + feature.json (same logic as CLI promote).
|
|
176
|
+
// COMP-PATHS-EXTERNAL: resolve the ABSOLUTE features base (may be
|
|
177
|
+
// relocated outside projectRoot). Re-rooting under projectRoot would make
|
|
178
|
+
// existsSync check the wrong path and risk overwriting external canon.
|
|
176
179
|
const composeJsonPath = path.join(projectRoot, '.compose', 'compose.json')
|
|
177
|
-
let
|
|
180
|
+
let cfg = {}
|
|
178
181
|
if (fs.existsSync(composeJsonPath)) {
|
|
179
|
-
try {
|
|
180
|
-
const cfg = JSON.parse(fs.readFileSync(composeJsonPath, 'utf-8'))
|
|
181
|
-
featuresRel = cfg?.paths?.features || 'docs/features'
|
|
182
|
-
} catch {}
|
|
182
|
+
try { cfg = JSON.parse(fs.readFileSync(composeJsonPath, 'utf-8')) } catch {}
|
|
183
183
|
}
|
|
184
|
-
const
|
|
184
|
+
const featuresBase = resolveFeaturesPathFromConfig(projectRoot, cfg) // absolute
|
|
185
|
+
const featuresDir = path.join(featuresBase, resolvedCode)
|
|
185
186
|
if (!fs.existsSync(featuresDir)) {
|
|
186
187
|
// COMP-MCP-VALIDATE-1: route through the validated writer instead of a
|
|
187
188
|
// raw fs.writeFileSync so the promoted feature.json is schema-guarded.
|
|
@@ -191,7 +192,7 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
|
|
|
191
192
|
status: 'PLANNED',
|
|
192
193
|
promotedFrom: sourceIdea.id,
|
|
193
194
|
createdAt: new Date().toISOString(),
|
|
194
|
-
},
|
|
195
|
+
}, featuresBase)
|
|
195
196
|
}
|
|
196
197
|
|
|
197
198
|
promoteIdea(parsed, id, resolvedCode)
|
|
@@ -200,7 +201,7 @@ export function attachIdeaboxRoutes(app, { getProjectRoot, getDataDir, broadcast
|
|
|
200
201
|
broadcastUpdate()
|
|
201
202
|
|
|
202
203
|
const updated = parsed.ideas.find(i => i.id.toUpperCase() === upper)
|
|
203
|
-
res.json({ ...(updated || { id }), featureCode: resolvedCode, featurePath:
|
|
204
|
+
res.json({ ...(updated || { id }), featureCode: resolvedCode, featurePath: relForDisplay(projectRoot, featuresDir) })
|
|
204
205
|
} catch (err) {
|
|
205
206
|
const status = err.message.includes('not found') ? 404 : 500
|
|
206
207
|
res.status(status).json({ error: err.message })
|
package/server/project-root.js
CHANGED
|
@@ -15,6 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
17
|
import { findProjectRoot } from './find-root.js';
|
|
18
|
+
import { DEFAULT_PATHS, resolvePathValue } from '../lib/paths-core.js';
|
|
18
19
|
|
|
19
20
|
export { findProjectRoot } from './find-root.js';
|
|
20
21
|
|
|
@@ -99,7 +100,7 @@ export function ensureDataDir() {
|
|
|
99
100
|
const DEFAULT_CONFIG = Object.freeze({
|
|
100
101
|
version: 1,
|
|
101
102
|
capabilities: Object.freeze({ stratum: true, lifecycle: true }),
|
|
102
|
-
paths:
|
|
103
|
+
paths: DEFAULT_PATHS, // single source of truth (COMP-PATHS-EXTERNAL)
|
|
103
104
|
});
|
|
104
105
|
|
|
105
106
|
function cloneConfig(obj) {
|
|
@@ -120,9 +121,11 @@ export function loadProjectConfig() {
|
|
|
120
121
|
|
|
121
122
|
export function resolveProjectPath(key) {
|
|
122
123
|
const config = loadProjectConfig();
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
// resolvePathValue handles in-root / ../-escaping / absolute overrides.
|
|
125
|
+
// Preserve the legacy `|| key` fallback for keys absent from DEFAULT_PATHS.
|
|
126
|
+
// (COMP-PATHS-EXTERNAL)
|
|
127
|
+
const value = config.paths?.[key] ?? DEFAULT_PATHS[key] ?? key;
|
|
128
|
+
return resolvePathValue(getTargetRoot(), value, key);
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
/**
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* qa-scope-routes.js — Read-only QA-scope REST API (COMP-PARITY-10).
|
|
3
|
+
*
|
|
4
|
+
* Route:
|
|
5
|
+
* GET /api/qa-scope?featureCode=<CODE> — surfaces `compose qa-scope <CODE>`
|
|
6
|
+
* (diff-to-route mapping from COMP-QA) as structured JSON for the cockpit's
|
|
7
|
+
* QA Scope view. Maps the feature's recorded filesChanged → affected /
|
|
8
|
+
* adjacent routes via lib/qa-scoping.js. Read-only, no side effects.
|
|
9
|
+
*
|
|
10
|
+
* Not auth-gated (mirrors GET /api/environment-health): it reads only
|
|
11
|
+
* feature.json + heuristic route mapping, no host paths leaked, no mutation.
|
|
12
|
+
*
|
|
13
|
+
* Degrade-never-fail: a bad feature code or unreadable feature.json returns a
|
|
14
|
+
* structured { found:false } / empty result, never a 500.
|
|
15
|
+
*
|
|
16
|
+
* Reuses (no logic fork):
|
|
17
|
+
* - lib/feature-json.js — readFeature(cwd, code)
|
|
18
|
+
* - lib/qa-scoping.js — mapFilesToRoutes(filesChanged,{cwd}), classifyRoutes(routes,[])
|
|
19
|
+
*/
|
|
20
|
+
import { readFeature as defaultReadFeature } from '../lib/feature-json.js';
|
|
21
|
+
import {
|
|
22
|
+
mapFilesToRoutes as defaultMapFilesToRoutes,
|
|
23
|
+
classifyRoutes as defaultClassifyRoutes,
|
|
24
|
+
} from '../lib/qa-scoping.js';
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {import('express').Express} app
|
|
28
|
+
* @param {object} [deps]
|
|
29
|
+
* @param {Function} [deps.readFeature] — (cwd, code) => FeatureJson|null
|
|
30
|
+
* @param {Function} [deps.mapFilesToRoutes] — (filesChanged, {cwd}) => {affectedRoutes,unmappedFiles,framework,docsOnly}
|
|
31
|
+
* @param {Function} [deps.classifyRoutes] — (routes, allKnown) => {affected,adjacent}
|
|
32
|
+
*/
|
|
33
|
+
export function attachQaScopeRoutes(app, {
|
|
34
|
+
readFeature = defaultReadFeature,
|
|
35
|
+
mapFilesToRoutes = defaultMapFilesToRoutes,
|
|
36
|
+
classifyRoutes = defaultClassifyRoutes,
|
|
37
|
+
} = {}) {
|
|
38
|
+
app.get('/api/qa-scope', (req, res) => {
|
|
39
|
+
const featureCode = (req.query.featureCode || '').toString().trim();
|
|
40
|
+
if (!featureCode) {
|
|
41
|
+
return res.json({ found: false, error: 'featureCode required' });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const root = req.workspace?.root;
|
|
45
|
+
if (!root) {
|
|
46
|
+
return res.json({ found: false, error: 'no workspace root', featureCode });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let feature;
|
|
50
|
+
try {
|
|
51
|
+
feature = readFeature(root, featureCode);
|
|
52
|
+
} catch {
|
|
53
|
+
feature = null;
|
|
54
|
+
}
|
|
55
|
+
if (!feature) {
|
|
56
|
+
return res.json({ found: false, featureCode });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const filesChanged = feature.filesChanged ?? [];
|
|
60
|
+
if (filesChanged.length === 0) {
|
|
61
|
+
// Mirror the CLI's empty-diff guidance (bin/compose.js:2716-2719).
|
|
62
|
+
return res.json({
|
|
63
|
+
found: true,
|
|
64
|
+
featureCode,
|
|
65
|
+
filesChanged: [],
|
|
66
|
+
framework: 'unknown',
|
|
67
|
+
docsOnly: false,
|
|
68
|
+
affected: [],
|
|
69
|
+
adjacent: [],
|
|
70
|
+
unmappedFiles: [],
|
|
71
|
+
emptyDiff: true,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Same pipeline as the CLI (bin/compose.js:2722-2724); allKnown=[] (v1: no registry).
|
|
76
|
+
let result, classified;
|
|
77
|
+
try {
|
|
78
|
+
result = mapFilesToRoutes(filesChanged, { cwd: root });
|
|
79
|
+
classified = classifyRoutes(result.affectedRoutes, []);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return res.json({ found: true, featureCode, error: e?.message || 'qa-scope failed' });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
res.json({
|
|
85
|
+
found: true,
|
|
86
|
+
featureCode,
|
|
87
|
+
filesChanged,
|
|
88
|
+
framework: result.framework,
|
|
89
|
+
docsOnly: result.docsOnly,
|
|
90
|
+
affected: classified.affected,
|
|
91
|
+
adjacent: classified.adjacent,
|
|
92
|
+
unmappedFiles: result.unmappedFiles,
|
|
93
|
+
emptyDiff: false,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
}
|
package/server/session-routes.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { readSessionsByFeature } from './session-store.js';
|
|
14
|
-
import { getTargetRoot, getDataDir } from './project-root.js';
|
|
14
|
+
import { getTargetRoot, getDataDir, resolveProjectPath } from './project-root.js';
|
|
15
15
|
import { appendPhaseHistory } from './lifecycle-phase-history.js';
|
|
16
16
|
import { reconcile } from '../lib/checkpoint/reconciler.js';
|
|
17
17
|
import { checkpointConfig } from '../lib/checkpoint/checkpoint-writer.js';
|
|
@@ -140,7 +140,7 @@ export function attachSessionRoutes(app, { sessionManager, scheduleBroadcast, br
|
|
|
140
140
|
featureCode,
|
|
141
141
|
item,
|
|
142
142
|
cwd: targetRoot,
|
|
143
|
-
featureDir: path.join(
|
|
143
|
+
featureDir: path.join(resolveProjectPath('features'), featureCode),
|
|
144
144
|
composeDir: path.join(targetRoot, '.compose'),
|
|
145
145
|
dataDir,
|
|
146
146
|
store: cpStore,
|
package/server/stratum-sync.js
CHANGED
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
* stratum-sync.js — Stratum flow poller + route registration.
|
|
3
3
|
*
|
|
4
4
|
* Polls stratum flow state via stratum-client (not direct file reads) and syncs
|
|
5
|
-
* into the vision store
|
|
5
|
+
* into the vision store on a self-scheduling, sleep-aware loop.
|
|
6
|
+
*
|
|
7
|
+
* Sleep behavior: each poll cold-starts a `stratum-mcp` subprocess (Python
|
|
8
|
+
* interpreter spin-up — the expensive part, not the query itself). Doing that
|
|
9
|
+
* every few seconds generated enough periodic activity to keep the host from
|
|
10
|
+
* sleeping. Two mitigations:
|
|
11
|
+
* 1. Cadence widened to 60s (override with COMPOSE_STRATUM_POLL_MS).
|
|
12
|
+
* 2. The spawn is skipped entirely when no bound flow can still change state
|
|
13
|
+
* (`hasLiveFlows`), so an idle/finished Forge does zero periodic spawning
|
|
14
|
+
* and the host is free to sleep. The cheap in-memory scan that gates this
|
|
15
|
+
* holds no power assertion.
|
|
16
|
+
* On wake, a tick that fired far later than scheduled is treated as a
|
|
17
|
+
* post-sleep resync rather than business as usual.
|
|
6
18
|
*
|
|
7
19
|
* Routes: POST /api/stratum/bind, POST /api/stratum/audit/:itemId
|
|
8
20
|
* (Flow/gate query routes are now in stratum-api.js)
|
|
@@ -10,6 +22,38 @@
|
|
|
10
22
|
|
|
11
23
|
import { queryFlows } from './stratum-client.js';
|
|
12
24
|
|
|
25
|
+
// Poll cadence. 60s is plenty for a flow list (slower than any build step) and
|
|
26
|
+
// keeps periodic process-spawn activity low enough not to fight host sleep.
|
|
27
|
+
const DEFAULT_POLL_MS = 60_000;
|
|
28
|
+
const MIN_POLL_MS = 1_000;
|
|
29
|
+
const POLL_MS = (() => {
|
|
30
|
+
const raw = parseInt(process.env.COMPOSE_STRATUM_POLL_MS ?? '', 10);
|
|
31
|
+
return Number.isFinite(raw) && raw >= MIN_POLL_MS ? raw : DEFAULT_POLL_MS;
|
|
32
|
+
})();
|
|
33
|
+
// A tick that fires later than POLL_MS * this factor means the host slept
|
|
34
|
+
// through scheduled ticks — log it and treat the next sync as a wake resync.
|
|
35
|
+
const WAKE_GAP_FACTOR = 1.5;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Sleep-aware poll gate: true only when some bound vision item could still
|
|
39
|
+
* change state (i.e. is bound to a flow and not settled-`complete`). When this
|
|
40
|
+
* is false the poller skips the stratum-mcp spawn so an idle host can sleep.
|
|
41
|
+
*
|
|
42
|
+
* A bound `complete` item is steady-state (status only advances to `complete`
|
|
43
|
+
* via the audit route, never back), so polling it changes nothing. Anything
|
|
44
|
+
* else bound — `in_progress`, `blocked`, or mid-flight — can still transition
|
|
45
|
+
* and is worth a poll.
|
|
46
|
+
*
|
|
47
|
+
* @param {Iterable<{stratumFlowId?: string, status?: string}>} items
|
|
48
|
+
* @returns {boolean}
|
|
49
|
+
*/
|
|
50
|
+
export function hasLiveFlows(items) {
|
|
51
|
+
for (const item of items) {
|
|
52
|
+
if (item.stratumFlowId && item.status !== 'complete') return true;
|
|
53
|
+
}
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
13
57
|
// ---------------------------------------------------------------------------
|
|
14
58
|
// StratumSync class
|
|
15
59
|
// ---------------------------------------------------------------------------
|
|
@@ -18,6 +62,8 @@ export class StratumSync {
|
|
|
18
62
|
#store;
|
|
19
63
|
#scheduleBroadcast;
|
|
20
64
|
#pollTimer = null;
|
|
65
|
+
#stopped = true;
|
|
66
|
+
#lastTickAt = 0;
|
|
21
67
|
|
|
22
68
|
/**
|
|
23
69
|
* @param {object} store — VisionStore instance
|
|
@@ -28,23 +74,53 @@ export class StratumSync {
|
|
|
28
74
|
this.#scheduleBroadcast = scheduleBroadcast;
|
|
29
75
|
}
|
|
30
76
|
|
|
31
|
-
/** Start the
|
|
77
|
+
/** Start the self-scheduling, sleep-aware poll loop (default 60s). */
|
|
32
78
|
start() {
|
|
33
|
-
this.#
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
});
|
|
37
|
-
}, 15_000);
|
|
79
|
+
this.#stopped = false;
|
|
80
|
+
this.#lastTickAt = Date.now();
|
|
81
|
+
this.#scheduleNext();
|
|
38
82
|
}
|
|
39
83
|
|
|
40
|
-
/** Stop the
|
|
84
|
+
/** Stop the poll loop. */
|
|
41
85
|
stop() {
|
|
86
|
+
this.#stopped = true;
|
|
42
87
|
if (this.#pollTimer) {
|
|
43
|
-
|
|
88
|
+
clearTimeout(this.#pollTimer);
|
|
44
89
|
this.#pollTimer = null;
|
|
45
90
|
}
|
|
46
91
|
}
|
|
47
92
|
|
|
93
|
+
/** Arm the next tick. No-op once stopped so stop() is final. */
|
|
94
|
+
#scheduleNext() {
|
|
95
|
+
if (this.#stopped) return;
|
|
96
|
+
this.#pollTimer = setTimeout(() => this.#tick(), POLL_MS);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One poll tick. Skips the stratum-mcp spawn when nothing is live (lets the
|
|
101
|
+
* host sleep); on a tick that fired far later than scheduled, notes the
|
|
102
|
+
* sleep gap before resyncing.
|
|
103
|
+
*/
|
|
104
|
+
async #tick() {
|
|
105
|
+
if (this.#stopped) return;
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const gap = now - this.#lastTickAt;
|
|
108
|
+
this.#lastTickAt = now;
|
|
109
|
+
|
|
110
|
+
if (hasLiveFlows(this.#store.items.values())) {
|
|
111
|
+
if (gap > POLL_MS * WAKE_GAP_FACTOR) {
|
|
112
|
+
console.log(`[vision] Stratum sync resuming after ${Math.round(gap / 1000)}s gap (host likely slept) — resyncing`);
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
await this.#syncFlows();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('[vision] Stratum poll error:', err.message);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
this.#scheduleNext();
|
|
122
|
+
}
|
|
123
|
+
|
|
48
124
|
/**
|
|
49
125
|
* Fetch flow summaries via stratum-client (stable contract, not direct file reads).
|
|
50
126
|
* Maps stratum's query output to the shape the sync logic expects.
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* validate-routes.js — Read-only cross-artifact validation REST API (COMP-PARITY-6).
|
|
3
|
+
*
|
|
4
|
+
* Route:
|
|
5
|
+
* GET /api/validate?scope=feature|project&featureCode=<CODE>
|
|
6
|
+
* Surfaces `compose validate` (lib/feature-validator.js validateFeature/
|
|
7
|
+
* validateProject) as structured JSON for the cockpit Validate view.
|
|
8
|
+
* Read-only; local-only (no network xref resolution); degrades, never 500s.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately NOT under an auth-allowlisted prefix (mirrors health-routes.js):
|
|
11
|
+
* non-allowlisted → default-deny in remote mode, open on localhost.
|
|
12
|
+
*
|
|
13
|
+
* Reuses (no logic fork): lib/feature-validator.js validateFeature/validateProject.
|
|
14
|
+
*/
|
|
15
|
+
import {
|
|
16
|
+
validateFeature as defaultValidateFeature,
|
|
17
|
+
validateProject as defaultValidateProject,
|
|
18
|
+
} from '../lib/feature-validator.js';
|
|
19
|
+
|
|
20
|
+
const SEVERITIES = ['error', 'warning', 'info'];
|
|
21
|
+
|
|
22
|
+
/** Group a findings array into { error: n, warning: n, info: n }. */
|
|
23
|
+
export function summarizeFindings(findings = []) {
|
|
24
|
+
const by = { error: 0, warning: 0, info: 0 };
|
|
25
|
+
for (const f of findings) {
|
|
26
|
+
if (by[f?.severity] !== undefined) by[f.severity] += 1;
|
|
27
|
+
}
|
|
28
|
+
return by;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {import('express').Express} app
|
|
33
|
+
* @param {object} [deps]
|
|
34
|
+
* @param {Function} [deps.validateFeature] — injectable (default: lib validator)
|
|
35
|
+
* @param {Function} [deps.validateProject] — injectable (default: lib validator)
|
|
36
|
+
*/
|
|
37
|
+
export function attachValidateRoutes(app, {
|
|
38
|
+
validateFeature = defaultValidateFeature,
|
|
39
|
+
validateProject = defaultValidateProject,
|
|
40
|
+
} = {}) {
|
|
41
|
+
app.get('/api/validate', async (req, res) => {
|
|
42
|
+
const root = req.workspace?.root;
|
|
43
|
+
if (!root) {
|
|
44
|
+
// No workspace to validate — a guarded failure, not a 500.
|
|
45
|
+
return res.json({ scope: null, findings: [], bySeverity: summarizeFindings([]), unavailable: true });
|
|
46
|
+
}
|
|
47
|
+
const scope = req.query.scope === 'feature' ? 'feature' : 'project';
|
|
48
|
+
const featureCode = typeof req.query.featureCode === 'string' ? req.query.featureCode : null;
|
|
49
|
+
|
|
50
|
+
if (scope === 'feature' && !featureCode) {
|
|
51
|
+
return res.status(400).json({ error: 'scope=feature requires featureCode' });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const result = scope === 'feature'
|
|
56
|
+
? await validateFeature(root, featureCode) // local-only; no `external`
|
|
57
|
+
: await validateProject(root, {}); // local-only; no `external`
|
|
58
|
+
const findings = Array.isArray(result.findings) ? result.findings : [];
|
|
59
|
+
// Pass the validator result through verbatim + add the severity rollup.
|
|
60
|
+
return res.json({ ...result, bySeverity: summarizeFindings(findings) });
|
|
61
|
+
} catch (err) {
|
|
62
|
+
// validateCode throws INVALID_INPUT on a malformed feature code → 400.
|
|
63
|
+
if (err && err.code === 'INVALID_INPUT') {
|
|
64
|
+
return res.status(400).json({ error: err.message });
|
|
65
|
+
}
|
|
66
|
+
// Any other failure degrades to a non-500 unavailable body.
|
|
67
|
+
return res.json({ scope, feature_code: featureCode, findings: [], bySeverity: summarizeFindings([]), unavailable: true, error: err?.message || 'validate failed' });
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|