@opensip-cli/graph 0.1.4 → 0.1.5

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 (37) 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/graph-config.d.ts.map +1 -1
  7. package/dist/cli/graph-config.js +20 -1
  8. package/dist/cli/graph-config.js.map +1 -1
  9. package/dist/cli/graph-feature-columns.d.ts +7 -0
  10. package/dist/cli/graph-feature-columns.d.ts.map +1 -0
  11. package/dist/cli/graph-feature-columns.js +10 -0
  12. package/dist/cli/graph-feature-columns.js.map +1 -0
  13. package/dist/cli/graph-multi-path-mode.d.ts +24 -0
  14. package/dist/cli/graph-multi-path-mode.d.ts.map +1 -0
  15. package/dist/cli/graph-multi-path-mode.js +64 -0
  16. package/dist/cli/graph-multi-path-mode.js.map +1 -0
  17. package/dist/cli/graph-run-outcome.d.ts +12 -0
  18. package/dist/cli/graph-run-outcome.d.ts.map +1 -0
  19. package/dist/cli/graph-run-outcome.js +2 -0
  20. package/dist/cli/graph-run-outcome.js.map +1 -0
  21. package/dist/cli/graph-session-contribution.d.ts +29 -0
  22. package/dist/cli/graph-session-contribution.d.ts.map +1 -0
  23. package/dist/cli/graph-session-contribution.js +58 -0
  24. package/dist/cli/graph-session-contribution.js.map +1 -0
  25. package/dist/cli/graph-sharded-engine.d.ts +77 -0
  26. package/dist/cli/graph-sharded-engine.d.ts.map +1 -0
  27. package/dist/cli/graph-sharded-engine.js +229 -0
  28. package/dist/cli/graph-sharded-engine.js.map +1 -0
  29. package/dist/cli/graph-workspace-mode.d.ts +11 -0
  30. package/dist/cli/graph-workspace-mode.d.ts.map +1 -0
  31. package/dist/cli/graph-workspace-mode.js +87 -0
  32. package/dist/cli/graph-workspace-mode.js.map +1 -0
  33. package/dist/cli/graph.d.ts +5 -129
  34. package/dist/cli/graph.d.ts.map +1 -1
  35. package/dist/cli/graph.js +13 -552
  36. package/dist/cli/graph.js.map +1 -1
  37. 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,25 @@
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';
28
+ import { EXIT_CODES } from '@opensip-cli/contracts';
31
29
  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';
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 { DASHBOARD_FEATURE_COLUMNS } from './graph-feature-columns.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';
35
+ import { executeMultiPathGraph } from './graph-multi-path-mode.js';
36
+ import { buildUnifiedReportLines, countFiles, resolutionBannerText } from './graph-report.js';
37
+ import { buildGraphSessionContribution } from './graph-session-contribution.js';
38
+ import { engineSelectionReason, realpathOrSelf, resolveEngineShards, runProfiledShardedBuild, } from './graph-sharded-engine.js';
39
+ import { executeWorkspaceGraph } from './graph-workspace-mode.js';
40
+ import { loadGraphConfig, resolveGraphRecipeSelection, runGraph } from './orchestrate.js';
46
41
  import { positionalPathLabel, resolvePositionalPaths } from './positional-paths.js';
47
42
  import { MemoryPressureError } from './pressure-monitor.js';
48
43
  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
44
  const EVT_GRAPH_COMPLETE = 'graph.cli.graph.complete';
53
45
  const MODULE_GRAPH_CLI = 'graph:cli';
54
46
  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
47
  /**
63
48
  * Run graph and return the run's {@link GraphRunOutcome} — the deliverable
64
49
  * {@link SignalEnvelope} (so the composition root can cloud + `--report-to`
@@ -132,7 +117,7 @@ export async function executeGraph(opts, cli) {
132
117
  }
133
118
  const positionalPaths = resolvePositionalScope(opts);
134
119
  if (positionalPaths.length > 1) {
135
- const outcome = await executeMultiPathGraph({ opts, cli, rules, startedAt, profile }, positionalPaths);
120
+ const outcome = await executeMultiPathGraph({ opts, cli, rules, startedAt, profile, deliverGraphResult }, positionalPaths);
136
121
  writeProfileIfRequested(opts, profile);
137
122
  return outcome;
138
123
  }
@@ -255,277 +240,6 @@ export async function executeGraph(opts, cli) {
255
240
  * no worker script is available — a graceful fall-through to exact, the
256
241
  * natural single-package path.
257
242
  */
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
243
  /** Profile bucket for the run shape: workspace fan-out, multi-path, or single graph. */
530
244
  function profileMode(opts) {
531
245
  if (opts.workspace === true)
@@ -590,69 +304,6 @@ function enforceLanguageMismatchPolicy(opts, catalog, paths) {
590
304
  const pathLabel = paths.map((p) => positionalPathLabel(p, opts.cwd)).join(', ');
591
305
  throw new ConfigurationError(`--language ${opts.language} matched 0 files under ${pathLabel}; check the flag or paths.`);
592
306
  }
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
307
  /**
657
308
  * Assemble the run's {@link SignalEnvelope} from its raw engine signals
658
309
  * (ADR-0011). Centralises `runId`/`createdAt` resolution off the live scope so
@@ -839,198 +490,6 @@ async function renderGraphResult(opts, result, startedAt, cli) {
839
490
  });
840
491
  return envelope;
841
492
  }
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
493
  export function handleGraphError(label, error, cli) {
1035
494
  logger.error({
1036
495
  evt: `graph.cli.${label}.error`,
@@ -1058,5 +517,7 @@ export function handleGraphError(label, error, cli) {
1058
517
  }
1059
518
  process.stderr.write(`${label}: ${error instanceof Error ? error.message : String(error)}\n`);
1060
519
  }
520
+ export { contributionFromSignals, evaluatedRuleSlugs } from './graph-session-contribution.js';
521
+ export { resolveLiveEngineShards, resolveShardsForCwd, runShardedLiveBuild, } from './graph-sharded-engine.js';
1061
522
  export { buildUnifiedReportLines, buildLiveGraphOutput } from './graph-report.js';
1062
523
  //# sourceMappingURL=graph.js.map