scripts-orchestrator 3.0.0 → 3.7.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.
@@ -0,0 +1,376 @@
1
+ /**
2
+ * @file workspaces.js
3
+ * @description First-class npm-workspace support: discover the workspaces declared in a
4
+ * repo's root package.json, then aggregate each workspace's orchestrator results JSON
5
+ * (plus the root run's global-check results) into a single, generic report document that
6
+ * the shared HTML renderer can turn into one roll-up page.
7
+ *
8
+ * The aggregator is intentionally domain-agnostic: it reads only artifacts the library
9
+ * itself writes (per-scope results JSON + the run-state file), so it needs no log scraping
10
+ * and no knowledge of any particular task runner. All path conventions are configurable.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import path from 'path';
15
+ import { renderReportHtml } from './report-html.js';
16
+
17
+ const DEFAULTS = {
18
+ title: 'Workspaces Quality Report',
19
+ outJson: 'logs/monorepo-quality-report.json',
20
+ outHtml: 'logs/monorepo-quality-report.html',
21
+ runStateFile: 'logs/.scripts-orchestrator-run.json',
22
+ rootResults: 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json',
23
+ globalResults: 'logs/scripts-orchestrator-logs/scripts-orchestrator-global-results.json',
24
+ workspaceResults: 'logs/scripts-orchestrator-logs/scripts-orchestrator-results.json',
25
+ globalPhase: 'global quality checks',
26
+ workspacePhase: 'workspace quality gates',
27
+ refreshSecs: 5,
28
+ exclude: [],
29
+ };
30
+
31
+ function readJson(absPath) {
32
+ try {
33
+ return JSON.parse(fs.readFileSync(absPath, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ function statMtimeMs(absPath) {
40
+ try {
41
+ return fs.statSync(absPath).mtimeMs;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ function toMs(iso) {
48
+ if (!iso) return null;
49
+ const ms = Date.parse(iso);
50
+ return Number.isNaN(ms) ? null : ms;
51
+ }
52
+
53
+ function toPosix(p) {
54
+ return p.split(path.sep).join('/');
55
+ }
56
+
57
+ /**
58
+ * Walk up from `startDir` (inclusive) to the nearest ancestor whose package.json declares a
59
+ * non-empty `workspaces` array. Returns that directory, or null if none is found.
60
+ *
61
+ * @param {string} startDir
62
+ * @returns {string | null}
63
+ */
64
+ export function findRepoRoot(startDir = process.cwd()) {
65
+ let dir = path.resolve(startDir);
66
+ for (;;) {
67
+ const pkg = readJson(path.join(dir, 'package.json'));
68
+ const ws = pkg?.workspaces;
69
+ const patterns = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : null;
70
+ if (patterns && patterns.length > 0) return dir;
71
+ const parent = path.dirname(dir);
72
+ if (parent === dir) return null;
73
+ dir = parent;
74
+ }
75
+ }
76
+
77
+ function workspacePatternsOf(pkg) {
78
+ const ws = pkg?.workspaces;
79
+ if (Array.isArray(ws)) return ws;
80
+ if (Array.isArray(ws?.packages)) return ws.packages;
81
+ return [];
82
+ }
83
+
84
+ /**
85
+ * Resolve the directories of the npm workspaces declared in `repoRoot`'s package.json.
86
+ *
87
+ * Supports the two common glob shapes (`dir/*` and an exact `dir/name`). A trailing `/*`
88
+ * expands to the immediate child directories that contain a package.json. Dot-prefixed
89
+ * directories (e.g. `.template`) are skipped to match npm's own glob behaviour.
90
+ *
91
+ * @param {string} repoRoot
92
+ * @returns {string[]} absolute workspace directories
93
+ */
94
+ export function discoverWorkspaceDirs(repoRoot) {
95
+ const pkg = readJson(path.join(repoRoot, 'package.json'));
96
+ const patterns = workspacePatternsOf(pkg);
97
+ const dirs = [];
98
+ const seen = new Set();
99
+ const add = (absDir) => {
100
+ if (seen.has(absDir)) return;
101
+ if (!fs.existsSync(path.join(absDir, 'package.json'))) return;
102
+ seen.add(absDir);
103
+ dirs.push(absDir);
104
+ };
105
+
106
+ for (const pattern of patterns) {
107
+ const normalized = pattern.replace(/\/+$/, '');
108
+ if (normalized.endsWith('/*')) {
109
+ const baseDir = path.join(repoRoot, normalized.slice(0, -2));
110
+ let entries;
111
+ try {
112
+ entries = fs.readdirSync(baseDir, { withFileTypes: true });
113
+ } catch {
114
+ continue;
115
+ }
116
+ for (const entry of entries) {
117
+ if (!entry.isDirectory()) continue;
118
+ if (entry.name.startsWith('.')) continue; // npm's `*` does not match dotfiles
119
+ add(path.join(baseDir, entry.name));
120
+ }
121
+ } else {
122
+ add(path.join(repoRoot, normalized));
123
+ }
124
+ }
125
+ return dirs.sort();
126
+ }
127
+
128
+ function isNoOpGate(pkgJson) {
129
+ const script = pkgJson?.scripts?.['scripts-orchestrator'];
130
+ if (script == null) return true; // workspace does not participate in the gate
131
+ return /echo\s+["']not applicable["']/i.test(script);
132
+ }
133
+
134
+ /** A results file from a previous run window (older than this run started). */
135
+ function isStale(jsonAbs, payload, runStartedAt) {
136
+ if (runStartedAt == null) return false;
137
+ if (payload?.timestamp != null) {
138
+ const ts = Date.parse(payload.timestamp);
139
+ if (!Number.isNaN(ts) && ts < runStartedAt - 2000) return true;
140
+ }
141
+ const mtime = statMtimeMs(jsonAbs);
142
+ if (mtime != null && mtime < runStartedAt) return true;
143
+ return false;
144
+ }
145
+
146
+ /** Re-root a workspace command's logFile (relative to the workspace) onto the repo root. */
147
+ function commandsWithRepoRelLogs(commands, relDir) {
148
+ return (commands || []).map((c) => {
149
+ if (!c.logFile) return { ...c };
150
+ return { ...c, logFile: toPosix(path.join(relDir, c.logFile)) };
151
+ });
152
+ }
153
+
154
+ function classifyWorkspace({ noOp, exists, stale, success, inProgress, fanoutOk }) {
155
+ if (noOp) return { statusKind: 'muted', statusLabel: 'N/A', state: 'noOp' };
156
+ if (!exists) {
157
+ return inProgress
158
+ ? { statusKind: 'muted', statusLabel: 'PENDING', state: 'pending' }
159
+ : { statusKind: 'muted', statusLabel: '—', state: 'absent' };
160
+ }
161
+ if (stale) {
162
+ if (inProgress) return { statusKind: 'muted', statusLabel: 'PENDING', state: 'pending' };
163
+ // The workspace's own results predate this run, but the root run's workspace fan-out phase
164
+ // still succeeded — so the gate passed this run from a task-runner cache replay (it was not
165
+ // re-executed, so no fresh JSON was written). Surface its last-known (cached) results rather
166
+ // than a misleading "no data" STALE. Only a clean fan-out earns this; a failed/partial fan-out
167
+ // leaves genuinely-unrun workspaces as STALE.
168
+ if (fanoutOk === true) {
169
+ return { statusKind: 'warn', statusLabel: 'CACHED', state: 'cached' };
170
+ }
171
+ return { statusKind: 'warn', statusLabel: 'STALE', state: 'stale' };
172
+ }
173
+ if (success === null || success === undefined) {
174
+ return inProgress
175
+ ? { statusKind: 'running', statusLabel: 'RUNNING', state: 'running' }
176
+ : { statusKind: 'fail', statusLabel: 'INTERRUPTED', state: 'interrupted' };
177
+ }
178
+ if (success === false) return { statusKind: 'fail', statusLabel: 'FAIL', state: 'fail' };
179
+ return { statusKind: 'ok', statusLabel: 'OK', state: 'ok' };
180
+ }
181
+
182
+ function resolveOptions(options = {}) {
183
+ const repoRoot = path.resolve(options.repoRoot || findRepoRoot() || process.cwd());
184
+ const merged = { ...DEFAULTS, ...options, repoRoot };
185
+ const abs = (p) => (path.isAbsolute(p) ? p : path.join(repoRoot, p));
186
+ return {
187
+ ...merged,
188
+ runStateAbs: abs(merged.runStateFile),
189
+ rootResultsAbs: abs(merged.rootResults),
190
+ globalResultsAbs: abs(merged.globalResults),
191
+ outJsonAbs: abs(merged.outJson),
192
+ outHtmlAbs: abs(merged.outHtml),
193
+ };
194
+ }
195
+
196
+ function collectGlobalCommands(opts, runStartedAt) {
197
+ const root = readJson(opts.rootResultsAbs);
198
+ if (root?.commands && !isStale(opts.rootResultsAbs, root, runStartedAt)) {
199
+ const fromRoot = root.commands.filter((c) => c.phase === opts.globalPhase);
200
+ if (fromRoot.length > 0) return fromRoot;
201
+ }
202
+ const global = readJson(opts.globalResultsAbs);
203
+ if (global?.commands && !isStale(opts.globalResultsAbs, global, runStartedAt)) {
204
+ return global.commands.map((c) => ({ ...c, phase: c.phase ?? opts.globalPhase }));
205
+ }
206
+ return [];
207
+ }
208
+
209
+ /**
210
+ * Build the generic report document aggregating the root run's global checks and every
211
+ * workspace's own orchestrator results.
212
+ *
213
+ * @param {object} [options] see DEFAULTS for the supported keys (all paths repo-root-relative)
214
+ * @returns {object} a renderable report payload ({ title, success, sections, ... })
215
+ */
216
+ export function aggregateWorkspacesReport(options = {}) {
217
+ const opts = resolveOptions(options);
218
+ const runState = readJson(opts.runStateAbs);
219
+ const rootJson = readJson(opts.rootResultsAbs);
220
+
221
+ // Prefer the run-state file's startedAt (present while the run is in flight). The end-of-run
222
+ // aggregate fires AFTER the orchestrator clears that file, so fall back to deriving the run
223
+ // start from the root results: its `timestamp` is the run's END, so subtract its duration.
224
+ // (Using the raw end timestamp would wrongly flag every workspace JSON — all written before
225
+ // the root run finished — as stale.) With no duration to anchor the window, stay lenient
226
+ // (null) rather than risk false "stale" verdicts on this run's results.
227
+ let runStartedAt = toMs(runState?.startedAt);
228
+ if (runStartedAt == null && rootJson?.timestamp) {
229
+ const endMs = toMs(rootJson.timestamp);
230
+ const durationMs = Number(rootJson.overallDurationMs);
231
+ if (endMs != null && Number.isFinite(durationMs) && durationMs > 0) {
232
+ runStartedAt = endMs - durationMs;
233
+ }
234
+ }
235
+
236
+ const inProgress =
237
+ options.inProgress != null
238
+ ? Boolean(options.inProgress)
239
+ : runState != null || rootJson?.success === null;
240
+
241
+ const excludeSet = new Set((opts.exclude || []).map((e) => toPosix(e)));
242
+
243
+ // Did this run's workspace fan-out phase pass? (Used to recognise cache-replayed workspaces,
244
+ // whose own results JSON predates this run because they were not re-executed.) null = no
245
+ // fan-out phase in the root results (e.g. a standalone workspace run) → no cache recovery.
246
+ const fanoutCommands = (rootJson?.commands || []).filter((c) => c.phase === opts.workspacePhase);
247
+ const fanoutOk = fanoutCommands.length === 0 ? null : fanoutCommands.every((c) => c.success !== false);
248
+
249
+ // Global quality checks section (commands run by the root orchestrator itself).
250
+ // Classify from the section's OWN commands, not the report-wide inProgress flag: the global
251
+ // checks finish before the workspace fan-out, so they must read OK as soon as they are done —
252
+ // even while other workspaces are still RUNNING in an in-progress snapshot.
253
+ const globalCommands = collectGlobalCommands(opts, runStartedAt);
254
+ const globalFailed = globalCommands.filter((c) => c.success === false).length;
255
+ const globalRunning = globalCommands.some((c) => c.success == null);
256
+ const globalState =
257
+ globalCommands.length === 0
258
+ ? { statusKind: 'muted', statusLabel: 'PENDING', success: null }
259
+ : globalRunning
260
+ ? { statusKind: 'running', statusLabel: 'RUNNING', success: null }
261
+ : globalFailed > 0
262
+ ? { statusKind: 'fail', statusLabel: 'FAIL', success: false }
263
+ : { statusKind: 'ok', statusLabel: 'OK', success: true };
264
+ const sections = [
265
+ {
266
+ title: 'Global quality checks',
267
+ statusKind: globalState.statusKind,
268
+ statusLabel: globalState.statusLabel,
269
+ success: globalState.success,
270
+ commands: globalCommands,
271
+ },
272
+ ];
273
+
274
+ // One section per discovered workspace.
275
+ let anyWorkspaceBad = false;
276
+ for (const wsDir of discoverWorkspaceDirs(opts.repoRoot)) {
277
+ const relDir = toPosix(path.relative(opts.repoRoot, wsDir));
278
+ if (excludeSet.has(relDir)) continue;
279
+
280
+ const pkg = readJson(path.join(wsDir, 'package.json'));
281
+ if (!pkg) continue;
282
+ const name = pkg.name || relDir;
283
+ const noOp = isNoOpGate(pkg);
284
+
285
+ const jsonAbs = path.join(wsDir, opts.workspaceResults);
286
+ const payload = readJson(jsonAbs);
287
+ const exists = payload != null;
288
+ const stale = exists && isStale(jsonAbs, payload, runStartedAt);
289
+ const success = payload?.success;
290
+
291
+ const st = classifyWorkspace({ noOp, exists, stale, success, inProgress, fanoutOk });
292
+ if (st.state === 'fail' || st.state === 'interrupted') anyWorkspaceBad = true;
293
+
294
+ const showCommands =
295
+ st.state === 'ok' || st.state === 'fail' || st.state === 'cached' || st.state === 'running';
296
+ const commands = showCommands ? commandsWithRepoRelLogs(payload?.commands, relDir) : [];
297
+
298
+ const note =
299
+ commands.length === 0 && st.state === 'pending'
300
+ ? 'Queued — not started yet this run'
301
+ : commands.length === 0 && st.state === 'stale'
302
+ ? 'No results for this run window (stale snapshot)'
303
+ : st.state === 'running' && commands.length > 0
304
+ ? 'In progress — partial command list'
305
+ : null;
306
+
307
+ sections.push({
308
+ title: name,
309
+ statusKind: st.statusKind,
310
+ statusLabel: st.statusLabel,
311
+ success:
312
+ st.state === 'ok' || st.state === 'cached'
313
+ ? success ?? true
314
+ : st.state === 'fail'
315
+ ? false
316
+ : null,
317
+ overallDurationMs: showCommands ? payload?.overallDurationMs : undefined,
318
+ meta: { path: relDir, ...(note ? { note } : {}) },
319
+ commands,
320
+ });
321
+ }
322
+
323
+ const rootOk = rootJson ? rootJson.success !== false : true;
324
+ const success = inProgress ? null : rootOk && globalFailed === 0 && !anyWorkspaceBad;
325
+
326
+ const overallDurationMs =
327
+ inProgress && runStartedAt != null
328
+ ? Math.max(0, Date.now() - runStartedAt)
329
+ : rootJson?.overallDurationMs;
330
+
331
+ return {
332
+ title: opts.title,
333
+ success,
334
+ timestamp: new Date().toISOString(),
335
+ overallDurationMs,
336
+ inProgress,
337
+ repoRoot: toPosix(opts.repoRoot),
338
+ sections,
339
+ // Carry heat thresholds into the aggregate payload so the renderer honours them.
340
+ ...(opts.memoryHeat ? { memoryHeat: opts.memoryHeat } : {}),
341
+ ...(opts.durationHeat ? { durationHeat: opts.durationHeat } : {}),
342
+ };
343
+ }
344
+
345
+ /** Insert a meta-refresh so an open browser tab live-reloads while the run is in flight. */
346
+ function injectAutoRefresh(html, refreshSecs) {
347
+ if (html.includes('http-equiv="refresh"')) return html;
348
+ const meta = ` <meta http-equiv="refresh" content="${refreshSecs}">\n`;
349
+ return html.replace(/(<meta charset="utf-8">\n)/, `$1${meta}`);
350
+ }
351
+
352
+ function atomicWrite(absPath, content) {
353
+ const tmp = `${absPath}.tmp`;
354
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
355
+ fs.writeFileSync(tmp, content, 'utf8');
356
+ fs.renameSync(tmp, absPath);
357
+ }
358
+
359
+ /**
360
+ * Build the aggregate report and write both the JSON document and the rendered HTML.
361
+ *
362
+ * @param {object} [options]
363
+ * @returns {{ payload: object, jsonPath: string, htmlPath: string }}
364
+ */
365
+ export function writeAggregateReport(options = {}) {
366
+ const opts = resolveOptions(options);
367
+ const payload = aggregateWorkspacesReport(options);
368
+
369
+ atomicWrite(opts.outJsonAbs, JSON.stringify(payload, null, 2));
370
+
371
+ let html = renderReportHtml(payload);
372
+ if (payload.inProgress) html = injectAutoRefresh(html, opts.refreshSecs);
373
+ atomicWrite(opts.outHtmlAbs, html);
374
+
375
+ return { payload, jsonPath: opts.outJsonAbs, htmlPath: opts.outHtmlAbs };
376
+ }