@opensip-cli/graph 0.1.4 → 0.1.6

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.
Files changed (49) hide show
  1. package/README.md +2 -2
  2. package/dist/__tests__/cli/graph-config.test.js +5 -3
  3. package/dist/__tests__/cli/graph-config.test.js.map +1 -1
  4. package/dist/__tests__/tool-branches.test.js +4 -1
  5. package/dist/__tests__/tool-branches.test.js.map +1 -1
  6. package/dist/cli/__tests__/graph-command-plan.test.d.ts +2 -0
  7. package/dist/cli/__tests__/graph-command-plan.test.d.ts.map +1 -0
  8. package/dist/cli/__tests__/graph-command-plan.test.js +46 -0
  9. package/dist/cli/__tests__/graph-command-plan.test.js.map +1 -0
  10. package/dist/cli/graph-command-plan.d.ts +23 -0
  11. package/dist/cli/graph-command-plan.d.ts.map +1 -0
  12. package/dist/cli/graph-command-plan.js +43 -0
  13. package/dist/cli/graph-command-plan.js.map +1 -0
  14. package/dist/cli/graph-config.d.ts.map +1 -1
  15. package/dist/cli/graph-config.js +20 -1
  16. package/dist/cli/graph-config.js.map +1 -1
  17. package/dist/cli/graph-feature-columns.d.ts +7 -0
  18. package/dist/cli/graph-feature-columns.d.ts.map +1 -0
  19. package/dist/cli/graph-feature-columns.js +10 -0
  20. package/dist/cli/graph-feature-columns.js.map +1 -0
  21. package/dist/cli/graph-multi-path-mode.d.ts +24 -0
  22. package/dist/cli/graph-multi-path-mode.d.ts.map +1 -0
  23. package/dist/cli/graph-multi-path-mode.js +64 -0
  24. package/dist/cli/graph-multi-path-mode.js.map +1 -0
  25. package/dist/cli/graph-run-outcome.d.ts +12 -0
  26. package/dist/cli/graph-run-outcome.d.ts.map +1 -0
  27. package/dist/cli/graph-run-outcome.js +2 -0
  28. package/dist/cli/graph-run-outcome.js.map +1 -0
  29. package/dist/cli/graph-session-contribution.d.ts +29 -0
  30. package/dist/cli/graph-session-contribution.d.ts.map +1 -0
  31. package/dist/cli/graph-session-contribution.js +58 -0
  32. package/dist/cli/graph-session-contribution.js.map +1 -0
  33. package/dist/cli/graph-sharded-engine.d.ts +77 -0
  34. package/dist/cli/graph-sharded-engine.d.ts.map +1 -0
  35. package/dist/cli/graph-sharded-engine.js +229 -0
  36. package/dist/cli/graph-sharded-engine.js.map +1 -0
  37. package/dist/cli/graph-single-run-mode.d.ts +23 -0
  38. package/dist/cli/graph-single-run-mode.d.ts.map +1 -0
  39. package/dist/cli/graph-single-run-mode.js +107 -0
  40. package/dist/cli/graph-single-run-mode.js.map +1 -0
  41. package/dist/cli/graph-workspace-mode.d.ts +11 -0
  42. package/dist/cli/graph-workspace-mode.d.ts.map +1 -0
  43. package/dist/cli/graph-workspace-mode.js +87 -0
  44. package/dist/cli/graph-workspace-mode.js.map +1 -0
  45. package/dist/cli/graph.d.ts +7 -131
  46. package/dist/cli/graph.d.ts.map +1 -1
  47. package/dist/cli/graph.js +18 -681
  48. package/dist/cli/graph.js.map +1 -1
  49. package/package.json +8 -8
package/dist/cli/graph.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // @fitness-ignore-file performance-anti-patterns -- spread in CLI report aggregation iterates bounded result sets (rule counts, entry-point lists).
5
5
  // @fitness-ignore-file no-markdown-references -- docs/plans/* pointers in JSDoc are stable internal references.
6
6
  // @fitness-ignore-file public-api-jsdoc -- GraphCommandOptions interface and executeGraph are already documented with rich JSDoc on each field; the check counts the top-level export line, not the fields.
7
- // @fitness-ignore-file file-length-limit -- top-level graph command handler with rich JSDoc on options; splitting would fragment the unified subcommand surface (gate/persist/output dispatch).
7
+ // @fitness-ignore-file file-length-limit -- remaining top-level graph command handler still coordinates mode dispatch, output delivery, single-run engine selection, and CLI error mapping; large workspace/multi-path modes and sharded engine/session helpers are split out.
8
8
  /**
9
9
  * `opensip graph` — main subcommand handler.
10
10
  *
@@ -25,40 +25,24 @@
25
25
  * `--packages` flags were retired in favor of the polyglot surface
26
26
  * above; see docs/plans/graph-cli-language-neutral-scoping/.
27
27
  */
28
- import { realpathSync } from 'node:fs';
29
- import { resolve } from 'node:path';
30
- import { EXIT_CODES, passRate } from '@opensip-cli/contracts';
31
- import { ConfigurationError, currentScope, logger, SystemError, ToolError, ValidationError, } from '@opensip-cli/core';
32
- import { pickAdapter } from '../lang-adapter/registry.js';
33
- import { CatalogRepo } from '../persistence/catalog-repo.js';
34
- import { buildGraphSessionPayload } from '../persistence/session-payload.js';
28
+ import { EXIT_CODES } from '@opensip-cli/contracts';
29
+ import { ConfigurationError, currentScope, logger, ToolError, ValidationError, } from '@opensip-cli/core';
35
30
  import { resolveRecipeToRules } from '../recipes/resolve.js';
36
- import { mapOpenSipRuleIdToEngineSlug } from '../render/rule-id-mapping.js';
37
- import { currentRules } from '../rules/registry.js';
38
- import { assertFinalizedAcrossBoundary, finalizeGraphSignals, } from './apply-suppressions.js';
31
+ import { finalizeGraphSignals } from './apply-suppressions.js';
39
32
  import { buildGraphEnvelope } from './build-envelope.js';
33
+ import { planGraphExecution } from './graph-command-plan.js';
40
34
  import { runCatalogJsonMode, runGateMode } from './graph-modes.js';
41
- import { buildLiveGraphOutput, buildUnifiedReportLines, countFiles, resolutionBannerText, } from './graph-report.js';
42
- import { resolveCanonicalFileSet } from './orchestrate/canonical-file-set.js';
43
- import { detectMonorepoLayout, partitionFlatRepo, selectStrategyForLayout, } from './orchestrate/flat-monorepo-strategy.js';
44
- import { partitionFilesIntoShards } from './orchestrate/partition-files.js';
45
- import { loadGraphConfig, resolveGraphRecipeSelection, runGraph, runShardedGraph, } from './orchestrate.js';
46
- import { positionalPathLabel, resolvePositionalPaths } from './positional-paths.js';
35
+ import { executeMultiPathGraph } from './graph-multi-path-mode.js';
36
+ import { buildUnifiedReportLines, resolutionBannerText } from './graph-report.js';
37
+ import { buildGraphSessionContribution } from './graph-session-contribution.js';
38
+ import { executeSinglePathGraph } from './graph-single-run-mode.js';
39
+ import { executeWorkspaceGraph } from './graph-workspace-mode.js';
40
+ import { resolveGraphRecipeSelection } from './orchestrate.js';
47
41
  import { MemoryPressureError } from './pressure-monitor.js';
48
42
  import { GraphProfileBuilder, writeGraphProfile } from './profile.js';
49
- import { resolveAdaptersForRun } from './resolve-adapters.js';
50
- import { buildWorkspaceJsonDocument, writeWorkspaceReport } from './workspace-report.js';
51
- import { discoverPolyglotUnits, runWorkspaceUnitsInParallel } from './workspace-runner.js';
52
43
  const EVT_GRAPH_COMPLETE = 'graph.cli.graph.complete';
53
44
  const MODULE_GRAPH_CLI = 'graph:cli';
54
45
  const MODULE_GRAPH_RENDER = 'graph:render';
55
- /**
56
- * The feature columns the decoupled dashboard renders (ADR-0006): only these
57
- * are materialized into the persisted catalog, and only on the standard
58
- * (catalog-producing) `graph` run. Export-only paths (sarif/catalog export)
59
- * do not go through this dispatch, so they stay lean (no features persisted).
60
- */
61
- const DASHBOARD_FEATURE_COLUMNS = ['blast', 'scc', 'packageCoupling'];
62
46
  /**
63
47
  * Run graph and return the run's {@link GraphRunOutcome} — the deliverable
64
48
  * {@link SignalEnvelope} (so the composition root can cloud + `--report-to`
@@ -105,7 +89,7 @@ export async function executeGraph(opts, cli) {
105
89
  });
106
90
  // (profile / startedAtForProfile already declared at top of fn for branch visibility)
107
91
  try {
108
- validateMutuallyExclusiveFlags(opts);
92
+ const plan = planGraphExecution(opts);
109
93
  // Resolve the recipe once at the top of the run (CLI layer owns selection;
110
94
  // the engine stays recipe-agnostic). Tool-scoped (ADR-0022): precedence is
111
95
  // `--recipe` flag > `graph.recipe` > `default`.
@@ -125,107 +109,17 @@ export async function executeGraph(opts, cli) {
125
109
  // request-scoped parsed-options bag the pre-action hook already augments, so
126
110
  // this is the single point that owns graph's recipe normalization.
127
111
  opts.recipe = recipeSelection.name;
128
- if (opts.workspace === true) {
112
+ if (plan.shape === 'workspace') {
129
113
  const outcome = await executeWorkspaceGraph(opts, cli, profile);
130
114
  writeProfileIfRequested(opts, profile);
131
115
  return outcome;
132
116
  }
133
- const positionalPaths = resolvePositionalScope(opts);
134
- if (positionalPaths.length > 1) {
135
- const outcome = await executeMultiPathGraph({ opts, cli, rules, startedAt, profile }, positionalPaths);
117
+ if (plan.shape === 'multi-path') {
118
+ const outcome = await executeMultiPathGraph({ opts, cli, rules, startedAt, profile, deliverGraphResult }, plan.positionalPaths);
136
119
  writeProfileIfRequested(opts, profile);
137
120
  return outcome;
138
121
  }
139
- // Realpath the run root ONCE, before engine selection (F3 path parity). The
140
- // EXACT engine normalizes its project dir via realpathSync internally
141
- // (graph-typescript normalize-project-dir); the SHARDED worker derives
142
- // project-relative `code.file` paths against this `projectRoot`. Under a
143
- // symlinked cwd a RAW root would make the sharded paths gain `../..` prefixes
144
- // while exact stays canonical → the two engines emit different `code.file`,
145
- // and since the engine choice is environment-sensitive, a `--gate-save`
146
- // baseline written by one engine would flag everything new under the other.
147
- // Canonicalizing here (idempotent for non-symlinks) keeps both engines'
148
- // emitted paths byte-identical. Shard discovery already realpaths internally
149
- // (discoverFiles → normalizeProjectDir), so the shard files and this root now
150
- // share the same canonical base.
151
- const runCwd = realpathOrSelf(positionalPaths[0] ?? opts.cwd, opts.cwd);
152
- // Honor the project's `graph:` config block (rule knobs like
153
- // minCrossPackageDuplicatePackages). Resolved from the original cwd so a
154
- // positional subtree run still picks up the project-root config.
155
- const config = loadGraphConfig(opts.cwd);
156
- // Determinism (ADR-0033, superseding ADR-0032/0031): the build engine is
157
- // chosen by an explicit, deterministic policy — the SHARDED engine is the
158
- // DEFAULT (both engines resolve through one shared model — exact = the
159
- // 1-shard case — held equivalent by the directional soundness invariant +
160
- // completeness floor; ADR-0033), and
161
- // `--exact` opts OUT to the single-program engine. It is NOT chosen by
162
- // `process.stdout.isTTY` or on-disk discovery state, so a bare `graph` builds
163
- // the same catalog whether run in a terminal, piped, or under
164
- // `--gate-*`/`--json`. When the project can't shard (no worker script,
165
- // single-unit, discovery failure) we fall back to the exact engine — the
166
- // natural single-package/small-repo path — rather than failing.
167
- const resolution = await resolveEngineShards(opts, cli, positionalPaths);
168
- const shards = resolution.shards;
169
- logger.info({
170
- evt: 'graph.cli.graph.engine',
171
- module: MODULE_GRAPH_CLI,
172
- mode: shards.length > 1 ? 'sharded' : 'exact',
173
- requestedExact: opts.exact === true,
174
- shards: shards.length,
175
- reason: engineSelectionReason(opts, positionalPaths, shards.length > 1),
176
- });
177
- const profileRun = profile?.startRun({
178
- label: positionalPaths.length === 0 ? 'root' : positionalPathLabel(runCwd, opts.cwd),
179
- cwd: runCwd,
180
- mode: shards.length > 1 ? 'sharded' : 'single-process',
181
- });
182
- // The synthetic partitioner runs BEFORE the profile run recorder exists
183
- // (its `mode` label needs the shard count), so its wall time is measured
184
- // where it runs and recorded here (ADR-0045 measurement plane).
185
- if (resolution.partition !== undefined) {
186
- profileRun?.recordStage('partition', resolution.partition.durationMs, resolution.partition.detail);
187
- }
188
- const result = shards.length > 1
189
- ? await runProfiledShardedBuild(profileRun, {
190
- opts,
191
- shards,
192
- projectRoot: runCwd,
193
- cli,
194
- config,
195
- rules,
196
- })
197
- : await runGraph({
198
- cwd: runCwd,
199
- noCache: opts.noCache,
200
- resolution: opts.resolution,
201
- language: opts.language,
202
- config,
203
- rules,
204
- datastore: cli.scope.datastore(),
205
- emitFeatures: DASHBOARD_FEATURE_COLUMNS,
206
- onProgress: profileRun?.onProgress,
207
- });
208
- profileRun?.finish(result);
209
- // Propagate shard failures so incomplete catalogs do not silently produce
210
- // baselines or pass --gate-compare (per-audit: failedShardIds was computed
211
- // but never surfaced to the gate or as a hard error).
212
- if (shards.length > 1) {
213
- const sharded = result;
214
- if (sharded.failedShardIds && sharded.failedShardIds.length > 0) {
215
- throw new SystemError(`Sharded graph build had ${sharded.failedShardIds.length} shard failure(s); ` +
216
- `catalog and any --gate-* / baseline artifacts are incomplete. ` +
217
- `See 'graph.sharded.shard_failed' log events for per-shard details.`, { code: 'GRAPH.SHARD.FAILURES' });
218
- }
219
- }
220
- enforceLanguageMismatchPolicy(opts, result.catalog, [runCwd]);
221
- currentScope()?.diagnostics?.event('execute', 'debug', 'graph build complete', {
222
- mode: shards.length > 1 ? 'sharded' : 'exact',
223
- shards: shards.length,
224
- });
225
- // `runCwd` (= positionalPaths[0] ?? opts.cwd) is the build root the signals
226
- // are relative to — the correct base for resolving `@graph-ignore` directive
227
- // files. For the sharded build it equals `projectRoot` passed above.
228
- const outcome = await dispatchGraphResult(opts, result, cli, startedAt, runCwd);
122
+ const outcome = await executeSinglePathGraph({ opts, cli, rules, startedAt, profile, dispatchGraphResult }, plan.positionalPaths);
229
123
  writeProfileIfRequested(opts, profile);
230
124
  return outcome;
231
125
  }
@@ -255,277 +149,6 @@ export async function executeGraph(opts, cli) {
255
149
  * no worker script is available — a graceful fall-through to exact, the
256
150
  * natural single-package path.
257
151
  */
258
- /**
259
- * Resolve a path to absolute (a RELATIVE input resolves against `base` —
260
- * the command's `opts.cwd`, NOT `process.cwd()`, which may differ when the
261
- * CLI is hosted), then realpath it (follow symlinks) — the SAME normalization
262
- * the exact engine's `normalizeProjectDir` applies, so both engines see one
263
- * canonical run root (F3). Falls back to the absolute path if realpath fails
264
- * (e.g. the path doesn't exist yet — discovery reports the error downstream).
265
- * Idempotent on an already-canonical path.
266
- */
267
- function realpathOrSelf(input, base) {
268
- // `resolve(base, input)` returns `input` unchanged when it is already absolute.
269
- const absolute = resolve(base, input);
270
- try {
271
- return realpathSync(absolute);
272
- }
273
- catch {
274
- /* v8 ignore next */
275
- return absolute;
276
- }
277
- }
278
- async function resolveEngineShards(opts, cli, positionalPaths) {
279
- if (opts.exact === true)
280
- return { shards: [] };
281
- if (positionalPaths.length > 0)
282
- return { shards: [] };
283
- return resolveShards(opts, cli);
284
- }
285
- /**
286
- * Human-readable explanation of the engine decision for the
287
- * `graph.cli.graph.engine` observability event. Pure; mirrors
288
- * {@link resolveEngineShards}'s branches.
289
- */
290
- function engineSelectionReason(opts, positionalPaths, sharded) {
291
- if (sharded)
292
- return 'sharded-default';
293
- if (opts.exact === true)
294
- return 'exact-opt-out';
295
- if (positionalPaths.length > 0)
296
- return 'exact-positional-paths';
297
- return 'exact-not-shardable';
298
- }
299
- /**
300
- * Resolve a project to its shards (one per workspace package). Returns an
301
- * empty array — signalling the caller to use the single-process build —
302
- * when the project isn't multi-package, when no worker script is
303
- * available to spawn, or when discovery fails. Each unit's file set is
304
- * enumerated via the graph adapter; partitions with no files are dropped.
305
- *
306
- * ADR-0032: reached for any run that did NOT pass `--exact` (see
307
- * {@link resolveEngineShards}) — sharded is the default. A project that yields
308
- * ≤1 non-empty shard falls back to the exact single-program engine naturally.
309
- */
310
- async function resolveShards(opts, cli) {
311
- const cliScript = opts.cliScript ?? process.argv[1];
312
- if (typeof cliScript !== 'string' || cliScript.length === 0)
313
- return { shards: [] };
314
- let units;
315
- try {
316
- units = await discoverPolyglotUnits(opts.cwd, resolveAdaptersForRun(opts, cli));
317
- }
318
- catch {
319
- /* v8 ignore next */
320
- return resolveSyntheticFlatShards(opts);
321
- }
322
- if (units.length <= 1)
323
- return resolveSyntheticFlatShards(opts);
324
- // Phase 1 (graph-sharded-exact-parity): enumerate the canonical file set ONCE
325
- // from project-wide root discovery — the SAME source + filter the exact engine
326
- // uses — then PARTITION it across the discovered unit boundaries. The old loop
327
- // re-derived each shard's files from that package's own tsconfig, which
328
- // excludes the package's __fixtures__ tree and (for some) its test files, so
329
- // the sharded engine silently dropped files the exact engine kept. Partitioning
330
- // the canonical set guarantees both engines see the identical files.
331
- const adapter = pickAdapter(opts.cwd);
332
- let rootDiscovery;
333
- try {
334
- rootDiscovery = adapter.discoverFiles({ cwd: opts.cwd });
335
- }
336
- catch {
337
- /* v8 ignore next */
338
- return resolveSyntheticFlatShards(opts);
339
- }
340
- const canonicalFiles = resolveCanonicalFileSet(rootDiscovery.files);
341
- const shards = partitionFilesIntoShards({
342
- canonicalFiles,
343
- units: units.map((u) => ({
344
- id: u.id,
345
- rootDir: u.rootDir,
346
- ...(u.configPath === undefined ? {} : { configPathAbs: u.configPath }),
347
- })),
348
- projectRoot: rootDiscovery.projectDirAbs,
349
- rootConfigPathAbs: rootDiscovery.configPathAbs,
350
- });
351
- // Need at least two non-empty shards to justify the parallel/merge overhead.
352
- if (shards.length > 1)
353
- return { shards };
354
- return resolveSyntheticFlatShards(opts);
355
- }
356
- /**
357
- * Resolve a project's shard set the SAME way a production `graph` run does
358
- * (workspace units → canonical-file partition, else synthetic flat shards),
359
- * exposed for the real-repo equivalence guardrail (`graph-equivalence-check`).
360
- * Returns `[]` when the project isn't shardable (≤1 shard / no worker script) —
361
- * the guardrail rejects that, since the comparison is only meaningful on a
362
- * shardable multi-package repo. Reuses the private {@link resolveShards} so
363
- * there is ONE shard-resolution model, never a drifting copy.
364
- */
365
- export async function resolveShardsForCwd(cwd, cliScript, cli) {
366
- const resolution = await resolveShards({ cwd, cliScript, noCache: true }, cli);
367
- return resolution.shards;
368
- }
369
- /**
370
- * Flat-large fallback for single-tsconfig TypeScript repos. Workspace
371
- * sharding is preferred because package boundaries are semantically real. When
372
- * no workspace split exists and the TypeScript file count crosses the same
373
- * threshold as heap preflight's 12 GB tier, synthesize directory-coherent
374
- * shards and feed them into the existing sharded build.
375
- */
376
- function resolveSyntheticFlatShards(opts) {
377
- if (typeof opts.language === 'string' && opts.language.length > 0)
378
- return { shards: [] };
379
- const adapter = pickAdapter(opts.cwd);
380
- if (adapter.id !== 'typescript')
381
- return { shards: [] };
382
- let discovery;
383
- try {
384
- discovery = adapter.discoverFiles({ cwd: opts.cwd });
385
- }
386
- catch {
387
- return { shards: [] };
388
- }
389
- // Canonical set (Phase 1): drop fixtures before partitioning so the flat-large
390
- // fallback shards match the exact engine's file set just like the workspace path.
391
- const canonicalFiles = resolveCanonicalFileSet(discovery.files);
392
- // Measure the partition compute (layout detection + strategy resolution +
393
- // partitioning) where it runs — the profile run recorder does not exist yet
394
- // (its `mode` label needs the shard count), so executeGraph records the
395
- // timing afterwards via `recordStage` (ADR-0045 measurement plane).
396
- const partitionStart = Date.now();
397
- const layout = detectMonorepoLayout({
398
- repoRoot: discovery.projectDirAbs,
399
- files: canonicalFiles,
400
- });
401
- const selection = selectStrategyForLayout(layout);
402
- if (layout.kind !== 'flat-large' || selection.mode !== 'synthetic-partition') {
403
- return { shards: [] };
404
- }
405
- // Strategy precedence: `graph.partitionStrategy` (config/env, ADR-0045) >
406
- // the layout-recommended default > 'hybrid'. `loadGraphConfig` is
407
- // scope-first and cheap, so reading it here (off the hot path) is safe.
408
- const graphConfig = loadGraphConfig(opts.cwd);
409
- const strategy = graphConfig.partitionStrategy ?? selection.partitionStrategy ?? 'hybrid';
410
- const partitions = partitionFlatRepo({
411
- files: layout.files,
412
- repoRoot: discovery.projectDirAbs,
413
- strategy,
414
- });
415
- const shards = partitions
416
- .filter((p) => p.files.length > 0)
417
- .map((p) => ({
418
- id: `partition:${p.id}`,
419
- rootDir: discovery.projectDirAbs,
420
- files: p.files,
421
- configPathAbs: discovery.configPathAbs,
422
- }));
423
- if (shards.length <= 1)
424
- return { shards: [] };
425
- return {
426
- shards,
427
- partition: {
428
- durationMs: Date.now() - partitionStart,
429
- detail: `${strategy}: ${String(shards.length)} partition(s)`,
430
- },
431
- };
432
- }
433
- /** Drive the sharded build and adapt it to the RunGraphResult dispatch shape. */
434
- async function runShardedBuild(ctx) {
435
- const { opts, shards, projectRoot, cli, config, rules } = ctx;
436
- const datastore = cli.scope.datastore();
437
- const sharded = await runShardedGraph({
438
- shards,
439
- projectRoot,
440
- cliScript: opts.cliScript ?? process.argv[1] ?? '',
441
- adapter: pickAdapter(projectRoot, opts.language),
442
- resolutionMode: opts.resolution ?? 'exact',
443
- concurrency: opts.concurrency,
444
- useCache: opts.noCache !== true,
445
- config,
446
- rules,
447
- catalogRepo: datastore ? new CatalogRepo(datastore) : null,
448
- emitFeatures: DASHBOARD_FEATURE_COLUMNS,
449
- ...(ctx.onProgress === undefined ? {} : { onProgress: ctx.onProgress }),
450
- ...(opts.language === undefined ? {} : { language: opts.language }),
451
- });
452
- return {
453
- catalog: sharded.catalog,
454
- indexes: sharded.indexes,
455
- signals: sharded.signals,
456
- resolutionStats: sharded.resolutionStats,
457
- cacheHit: sharded.cacheHit,
458
- features: sharded.features,
459
- shardStats: sharded.shardStats,
460
- };
461
- }
462
- async function runProfiledShardedBuild(profileRun, ctx) {
463
- const started = Date.now();
464
- const result = await runShardedBuild(ctx);
465
- profileRun?.recordStage('sharded-build', Date.now() - started, `${String(ctx.shards.length)} shard(s)`);
466
- return result;
467
- }
468
- /**
469
- * Resolve the build engine for the interactive live path — the SAME policy
470
- * `executeGraph` uses (ADR-0032): the SHARDED engine when `--exact` is absent
471
- * and the project yields >1 non-empty shard, the EXACT (single-program) engine
472
- * otherwise. Returns the shard set (`length > 1` ⇒ sharded) so the live runner
473
- * can choose engine-aware labels and pass the plain-data plan to
474
- * `graph-run-worker` (ADR-0028). `isTTY` is NEVER consulted — the engine is a
475
- * pure function of the request + shardability, identical to the static path.
476
- */
477
- export async function resolveLiveEngineShards(args, cli) {
478
- const opts = {
479
- cwd: args.cwd,
480
- noCache: args.noCache,
481
- resolution: args.resolution,
482
- exact: args.exact,
483
- ...(args.cliScript === undefined ? {} : { cliScript: args.cliScript }),
484
- };
485
- // No positional paths in the whole-project live view, so the engine decision
486
- // is `--exact` + shardability alone.
487
- const resolution = await resolveEngineShards(opts, cli, []);
488
- return resolution.shards;
489
- }
490
- /**
491
- * Run the SHARDED live build and reduce it to the slim, serializable
492
- * {@link LiveGraphOutput} the interactive runner consumes — IDENTICAL in shape
493
- * to what the exact worker streams back, so live transports converge on one
494
- * payload. In the normal path this runs inside `graph-run-worker`, keeping the
495
- * render process free while the worker coordinates shard subprocesses and the
496
- * synchronous merge/link/rules work. The same function remains the degraded
497
- * in-process fallback when worker execution is explicitly disabled or unavailable.
498
- * Progress events flow through `onProgress`, mapped onto the same seven
499
- * canonical stages the exact engine emits.
500
- *
501
- * Crosses the single suppression chokepoint via {@link buildLiveGraphOutput}
502
- * (against `args.cwd`, the build root) — so the live sharded path waives
503
- * `@graph-ignore` directives IDENTICALLY to the static/exact paths (ADR-0014/0031).
504
- */
505
- export async function runShardedLiveBuild(args, shards, datastore, onProgress) {
506
- const result = await runShardedGraph({
507
- shards,
508
- projectRoot: args.cwd,
509
- cliScript: args.cliScript ?? process.argv[1] ?? '',
510
- adapter: pickAdapter(args.cwd),
511
- resolutionMode: args.resolution ?? 'exact',
512
- useCache: args.noCache !== true,
513
- config: args.config ?? {},
514
- // The live dispatch always resolves the recipe → rules; fall back to the
515
- // full registered set if a programmatic caller omits them (parity with the
516
- // exact path, where `runGraph` applies the same `?? currentRules()` default).
517
- rules: args.rules ?? currentRules(),
518
- catalogRepo: datastore ? new CatalogRepo(datastore) : null,
519
- emitFeatures: DASHBOARD_FEATURE_COLUMNS,
520
- onProgress,
521
- });
522
- return buildLiveGraphOutput({
523
- catalog: result.catalog,
524
- indexes: result.indexes,
525
- signals: result.signals,
526
- cacheHit: result.cacheHit,
527
- }, args.cwd);
528
- }
529
152
  /** Profile bucket for the run shape: workspace fan-out, multi-path, or single graph. */
530
153
  function profileMode(opts) {
531
154
  if (opts.workspace === true)
@@ -557,102 +180,6 @@ function writeProfileIfRequested(opts, profile) {
557
180
  output: outPath,
558
181
  });
559
182
  }
560
- function validateMutuallyExclusiveFlags(opts) {
561
- if (opts.gateSave === true && opts.gateCompare === true) {
562
- throw new ConfigurationError('--gate-save and --gate-compare are mutually exclusive.');
563
- }
564
- if (opts.workspace === true && (opts.paths?.length ?? 0) > 0) {
565
- throw new ConfigurationError('--workspace and positional paths are mutually exclusive. Use one or the other.');
566
- }
567
- if (opts.workspace === true && (opts.gateSave === true || opts.gateCompare === true)) {
568
- throw new ConfigurationError('--workspace and --gate-save/--gate-compare are mutually exclusive. ' +
569
- 'Gates and baselines apply to production code; --workspace intentionally scans the full project (including dependencies and test fixtures).');
570
- }
571
- }
572
- function resolvePositionalScope(opts) {
573
- if (!opts.paths || opts.paths.length === 0)
574
- return [];
575
- return resolvePositionalPaths(opts.paths, opts.cwd);
576
- }
577
- /**
578
- * D14 mixed policy. When `--language X` was specified and the run
579
- * discovered zero files matching that adapter, exit 2 with the
580
- * canonical error. Auto-detection paths (no `--language`) do NOT
581
- * trigger this check — a "zero files" outcome there is a valid
582
- * (non-error) state.
583
- */
584
- function enforceLanguageMismatchPolicy(opts, catalog, paths) {
585
- if (typeof opts.language !== 'string' || opts.language.length === 0)
586
- return;
587
- const fileCount = catalog === null ? 0 : countFiles(catalog);
588
- if (fileCount > 0)
589
- return;
590
- const pathLabel = paths.map((p) => positionalPathLabel(p, opts.cwd)).join(', ');
591
- throw new ConfigurationError(`--language ${opts.language} matched 0 files under ${pathLabel}; check the flag or paths.`);
592
- }
593
- async function executeMultiPathGraph(ctx, paths) {
594
- const { opts, cli, rules, startedAt, profile } = ctx;
595
- const allSignals = [];
596
- let combinedFiles = 0;
597
- let totalSuppressed = 0;
598
- let lastResult = null;
599
- const config = loadGraphConfig(opts.cwd);
600
- for (const p of paths) {
601
- const profileRun = profile?.startRun({
602
- label: positionalPathLabel(p, opts.cwd),
603
- cwd: p,
604
- mode: 'single-process',
605
- });
606
- const r = await runGraph({
607
- cwd: p,
608
- noCache: opts.noCache,
609
- resolution: opts.resolution,
610
- language: opts.language,
611
- config,
612
- rules,
613
- datastore: cli.scope.datastore(),
614
- emitFeatures: DASHBOARD_FEATURE_COLUMNS,
615
- onProgress: profileRun?.onProgress,
616
- });
617
- profileRun?.finish(r);
618
- lastResult = r;
619
- // Each path's signals are relative to THAT path's root — so waive them
620
- // against `p` here, before aggregating. A single post-aggregation pass
621
- // (the old shape) could only use one base and would leak waivers for every
622
- // path but one. Each per-path call crosses the single suppression
623
- // chokepoint (finalizeGraphSignals); the aggregate is then re-branded once
624
- // below via assertFinalizedAcrossBoundary (an assertion that every member
625
- // was finalized, NOT a second suppression pass).
626
- const finalized = await finalizeGraphSignals(r.signals, p);
627
- totalSuppressed += finalized.suppressedCount;
628
- allSignals.push(...finalized.signals);
629
- if (r.catalog !== null)
630
- combinedFiles += countFiles(r.catalog);
631
- }
632
- // D14: count files across every analyzed path. Zero files + a
633
- // `--language` override → exit 2 with the canonical message.
634
- if (typeof opts.language === 'string' && opts.language.length > 0 && combinedFiles === 0) {
635
- throw new ConfigurationError(`--language ${opts.language} matched 0 files under ${paths.map((p) => positionalPathLabel(p, opts.cwd)).join(', ')}; check the flag or paths.`);
636
- }
637
- /* v8 ignore next */
638
- if (lastResult === null)
639
- return undefined;
640
- const combined = {
641
- catalog: lastResult.catalog,
642
- indexes: lastResult.indexes,
643
- signals: allSignals,
644
- resolutionStats: lastResult.resolutionStats,
645
- cacheHit: lastResult.cacheHit,
646
- features: lastResult.features,
647
- };
648
- // `allSignals` is already waived per-path (each against its own root), so
649
- // deliver directly — a second suppression pass would have no single correct
650
- // root and risk re-resolving paths under the wrong base. Re-brand the
651
- // aggregate FinalizedSignals (each member already crossed finalizeGraphSignals
652
- // above) so deliverGraphResult's persist call gets the type it requires.
653
- const finalizedAggregate = assertFinalizedAcrossBoundary(allSignals, totalSuppressed);
654
- return await deliverGraphResult(opts, combined, cli, startedAt, finalizedAggregate);
655
- }
656
183
  /**
657
184
  * Assemble the run's {@link SignalEnvelope} from its raw engine signals
658
185
  * (ADR-0011). Centralises `runId`/`createdAt` resolution off the live scope so
@@ -839,198 +366,6 @@ async function renderGraphResult(opts, result, startedAt, cli) {
839
366
  });
840
367
  return envelope;
841
368
  }
842
- /**
843
- * `graph --workspace` — fan a graph run out across every workspace
844
- * unit returned by the adapters' `discoverWorkspaceUnits` hook. Per
845
- * spec D8b, polyglot repos aggregate units from EVERY detected
846
- * adapter (or the single adapter named by `--language`).
847
- */
848
- async function executeWorkspaceGraph(opts, cli, profile) {
849
- const cliScript = opts.cliScript ?? process.argv[1];
850
- if (typeof cliScript !== 'string' || cliScript.length === 0) {
851
- throw new ConfigurationError('--workspace: could not determine the CLI entry script (process.argv[1] is empty).');
852
- }
853
- const adapters = resolveAdaptersForRun(opts, cli);
854
- const units = await discoverPolyglotUnits(opts.cwd, adapters);
855
- if (units.length === 0) {
856
- const adapterLabel = adapters.map((a) => a.id).join(', ') || '(no language adapters available)';
857
- throw new ConfigurationError(`--workspace: no workspace units detected for [${adapterLabel}]. Use 'opensip graph' for whole-project analysis.`);
858
- }
859
- const profileRun = profile?.startRun({
860
- label: 'workspace',
861
- cwd: opts.cwd,
862
- mode: 'workspace',
863
- });
864
- // Internal per-run timer for the workspace REPORT artifact (durationMs in the
865
- // JSON document / report header + the profile stage). NOT a session timestamp:
866
- // the generic session row's timing is host-owned (host-owned-run-timing Phase
867
- // 3), stamped from the host RunTimer after this handler returns.
868
- const startedAt = Date.now();
869
- const result = await runWorkspaceUnitsInParallel({
870
- cwd: opts.cwd,
871
- units,
872
- cliScript,
873
- concurrency: opts.concurrency,
874
- noCache: opts.noCache,
875
- resolution: opts.resolution,
876
- recipe: opts.recipe,
877
- ...(opts.language === undefined ? {} : { language: opts.language }),
878
- });
879
- const durationMs = Date.now() - startedAt;
880
- profileRun?.recordStage('workspace-fanout', durationMs, `${String(units.length)} unit(s)`);
881
- const allSignals = [];
882
- for (const r of result.perUnit)
883
- allSignals.push(...r.signals);
884
- profileRun?.finishSummary({
885
- cacheHit: false,
886
- signals: allSignals.length,
887
- });
888
- // Build exactly one aggregate session contribution for the whole --workspace
889
- // invocation (non-json path only). Matches the contract "one human-facing CLI
890
- // invocation = one session" that fitness/sim already follow; the per-unit child
891
- // processes don't contribute because they always run with --json (see
892
- // dispatchGraphResult). The host run plane persists the returned `session`
893
- // after this handler resolves — graph never writes the row itself
894
- // (host-owned-run-timing Phase 3).
895
- //
896
- // Cloud signal sync (ADR-0008) is intentionally NOT emitted for --workspace
897
- // (audit P1-2): the parent aggregates per-unit signals for the dashboard, not
898
- // for the cloud, and the --json children skip emit to avoid fragmented
899
- // per-unit batches. So the returned outcome carries a `session` but NO
900
- // envelope. A whole-project `graph` run emits normally (the root's
901
- // deliverSignals on the returned envelope).
902
- let session;
903
- if (opts.json === true) {
904
- // ADR-0011: emit through the CLI seam, not process.stdout directly.
905
- // cli.emitJson applies the same JSON.stringify(_, null, 2) + '\n'.
906
- cli.emitJson(buildWorkspaceJsonDocument(result.perUnit, durationMs));
907
- }
908
- else {
909
- await writeWorkspaceReport(result.perUnit, durationMs, cli);
910
- session = buildWorkspaceSessionContribution(opts, allSignals);
911
- }
912
- // If any child failed to spawn or exited with an error, surface it
913
- // as a runtime error. The parent itself succeeded if every child
914
- // returned exit 0.
915
- if (result.anyChildFailed) {
916
- cli.setExitCode(EXIT_CODES.RUNTIME_ERROR);
917
- process.stderr.write(`graph --workspace: at least one unit run failed; see per-unit output above.\n`);
918
- }
919
- else {
920
- cli.setExitCode(EXIT_CODES.SUCCESS);
921
- }
922
- logger.info({
923
- evt: EVT_GRAPH_COMPLETE,
924
- module: MODULE_GRAPH_CLI,
925
- units: result.perUnit.length,
926
- findings: allSignals.length,
927
- failed: result.anyChildFailed,
928
- durationMs,
929
- });
930
- // The aggregate outcome carries the single session (non-json path) but NO
931
- // envelope — the parent does not cloud-emit per-unit signals (audit P1-2).
932
- // `undefined` on the --json carrier path keeps the host from persisting.
933
- return session === undefined ? undefined : { session };
934
- }
935
- /**
936
- * Build the generic-session contribution for a single-process graph run from
937
- * the branded {@link FinalizedSignals} (host-owned-run-timing Phase 3). The
938
- * host run plane stamps timing + id and persists the row after the handler
939
- * returns — graph never writes the generic `StoredSession` itself.
940
- *
941
- * Takes the branded {@link FinalizedSignals} (not a raw `Signal[]`): the only
942
- * way to obtain one is to cross the single suppression chokepoint
943
- * (`finalizeGraphSignals`, or `assertFinalizedAcrossBoundary` after the worker
944
- * IPC boundary). This is the compile-time guardrail that makes the TTY-leak bug
945
- * un-regressable — a caller that hands raw, un-waived signals here does not
946
- * type-check, so the dashboard history can never record un-waived findings.
947
- *
948
- * The single-process path holds the raw engine signals (engine slugs), so the
949
- * session payload's per-rule keys are engine slugs directly.
950
- */
951
- function buildGraphSessionContribution(opts, finalized) {
952
- return contributionFromSignals(opts, finalized.signals);
953
- }
954
- /**
955
- * Build the aggregate generic-session contribution for a `--workspace` run.
956
- *
957
- * Child envelopes carry Option-A-mapped OpenSIP rule IDs; reverse-map back to
958
- * engine slugs so the aggregate session payload's per-rule metric columns
959
- * (keyed on engine slugs in the dashboard) keep working — exactly what the old
960
- * `persistWorkspaceSession` did before handing off to the shared save.
961
- */
962
- function buildWorkspaceSessionContribution(opts, signals) {
963
- const engineSignals = signals.map((s) => {
964
- const ruleId = mapOpenSipRuleIdToEngineSlug(s.ruleId);
965
- return { ...s, ruleId, source: ruleId };
966
- });
967
- return contributionFromSignals(opts, engineSignals);
968
- }
969
- /**
970
- * Shared contribution builder: derive graph's opaque session payload + the
971
- * generic verdict (`score`/`passed`) from a run's engine-slug `Signal[]`. The
972
- * payload is graph-owned detail (summary + rule-grouped per-signal findings);
973
- * the generic session row holds zero graph vocabulary. `score`/`passed` mirror
974
- * exactly what the former `saveGraphSession` computed (pass rate over
975
- * passed/total rules; `passed` ⇔ no error-severity signals).
976
- */
977
- /**
978
- * Engine slugs of every rule a run evaluated — the session payload's full rule
979
- * list, so a CLEAN run still records a PASS row per rule (the session detail
980
- * then shows the complete rule list, exactly the way fitness shows every check,
981
- * not just the failing ones).
982
- *
983
- * Prefer the EXPLICITLY-resolved rule set the run actually used (the `--recipe`
984
- * subset, threaded from the dispatch seam as `args.rules`); otherwise read the
985
- * current scope's full registry — this mirrors `runGraph`'s own
986
- * `args.rules ?? currentRules()` resolution, so the recorded set is exactly what
987
- * ran. Degrades to the empty set (fired-rules-only) when no graph scope is
988
- * active — e.g. an isolated dispatch unit test; production always runs the
989
- * handler inside the entered RunScope.
990
- *
991
- * Exported so BOTH contribution-building paths derive the evaluated set
992
- * identically: the static `executeGraph` dispatch (below) AND the live Ink
993
- * runner (`graph-runner.tsx`). Before it was shared, only the static path
994
- * threaded it, so a clean run on the live (interactive) path persisted an empty
995
- * `checks[]` and the report rendered "no results" instead of the rule list.
996
- */
997
- export function evaluatedRuleSlugs(explicitRules) {
998
- if (explicitRules)
999
- return explicitRules.map((r) => r.slug);
1000
- try {
1001
- return currentRules().map((r) => r.slug);
1002
- }
1003
- catch {
1004
- // @swallow-ok no graph scope (isolated dispatch unit test); degrade to the
1005
- // fired-rule set. Production always runs the handler inside a RunScope.
1006
- return [];
1007
- }
1008
- }
1009
- /**
1010
- * Build graph's generic-session contribution from a run's engine-slug
1011
- * `Signal[]` plus the engine slugs of the rules it evaluated. The SINGLE
1012
- * assembly point for both the static dispatch path and the live Ink runner, so
1013
- * the contribution shape (and the "every evaluated rule gets a row" behaviour)
1014
- * can never drift between them again. The payload is graph-owned detail
1015
- * (summary + rule-grouped per-signal findings); the generic session row holds
1016
- * zero graph vocabulary. `score`/`passed` follow fit's semantics (pass rate over
1017
- * passed/total rules; `passed` ⇔ no error-severity signal).
1018
- *
1019
- * `evaluatedSlugs` defaults to {@link evaluatedRuleSlugs}() (the scope's full
1020
- * registry) for the static callers, which build inside the entered RunScope; the
1021
- * live runner passes its `args.rules`-derived set explicitly.
1022
- */
1023
- export function contributionFromSignals(opts, signals, evaluatedSlugs = evaluatedRuleSlugs()) {
1024
- const payload = buildGraphSessionPayload(signals, evaluatedSlugs);
1025
- return {
1026
- tool: 'graph',
1027
- cwd: opts.cwd,
1028
- ...(opts.recipe === undefined ? {} : { recipe: opts.recipe }),
1029
- score: passRate(payload.summary),
1030
- passed: payload.summary.errors === 0,
1031
- payload,
1032
- };
1033
- }
1034
369
  export function handleGraphError(label, error, cli) {
1035
370
  logger.error({
1036
371
  evt: `graph.cli.${label}.error`,
@@ -1058,5 +393,7 @@ export function handleGraphError(label, error, cli) {
1058
393
  }
1059
394
  process.stderr.write(`${label}: ${error instanceof Error ? error.message : String(error)}\n`);
1060
395
  }
396
+ export { contributionFromSignals, evaluatedRuleSlugs } from './graph-session-contribution.js';
397
+ export { resolveLiveEngineShards, resolveShardsForCwd, runShardedLiveBuild, } from './graph-sharded-engine.js';
1061
398
  export { buildUnifiedReportLines, buildLiveGraphOutput } from './graph-report.js';
1062
399
  //# sourceMappingURL=graph.js.map