@webpieces/nx-webpieces-rules 0.4.918 → 0.4.920

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Impact mode's data: what one COMPARISON's build looks like, in three shades (plus untouched grey).
2
+ * Impact mode's data: what one COMPARISON's build looks like, and WHY each project is in it.
3
3
  *
4
4
  * There are two comparisons, and only two (#1163). Both end at the WORKING TREE, so uncommitted and
5
5
  * untracked work counts in either:
@@ -25,15 +25,13 @@
25
25
  * - AFFECTED is `nx show projects --affected --base=<the comparison's base> --json`, with NO
26
26
  * `--head`, so uncommitted and untracked work counts. nx already gets right everything a
27
27
  * hand-rolled diff gets wrong: lockfile and package.json changes resolved per project, root/global
28
- * inputs, namedInputs, implicitDependencies, nested roots and deleted files. This uses its public
29
- * CLI surface only, never nx internals, and never NX_BASE/NX_HEAD, whose value depends on whoever
30
- * launched the process.
31
- * - TOUCHED is the subset of that set which OWNS a changed file, by nx's own file-to-project
32
- * ownership (the project file map from `createProjectFileMapUsingProjectGraph`, public devkit API)
33
- * — never a path-prefix guess. The ownership map is computed ONCE and shared by every comparison.
34
- * A project whose only change is a DELETED file is not in that map (the file is gone), so it shows
35
- * as affected rather than touched. The changed-file list is `DiffScope.getChangedFiles`, the same
36
- * base→working-tree diff (with untracked files) every code rule uses.
28
+ * inputs, namedInputs, implicitDependencies, nested roots and deleted files. It never reads
29
+ * NX_BASE/NX_HEAD, whose value depends on whoever launched the process.
30
+ * - WHY each affected project is in it (#1264) — changed, dependent (with the edge path), implicitly
31
+ * touched (with the target, glob and file) or root-file touched — comes from nx's OWN affected
32
+ * locators, run in-process against the workspace's own nx (graph-impact-reasons.ts). They are
33
+ * loaded ONCE and shared by every comparison. The changed-file list is `DiffScope.getChangedFiles`,
34
+ * the same base→working-tree diff (with untracked files) every code rule uses.
37
35
  *
38
36
  * The answer changes per checkout, so it never goes into the committed dependencies.html or
39
37
  * dependencies.json (both stay byte-stable). It goes into a SIDECAR,
@@ -52,7 +50,11 @@
52
50
  * stale one is removed) and the page disables Impact with a one-line reason. It never fails
53
51
  * generation.
54
52
  */
53
+ import type { NxJsonConfiguration, ProjectGraph } from '@nx/devkit';
54
+ import type { FileChange } from 'nx/src/project-graph/file-utils';
55
+ import type { PackageJson } from 'nx/src/utils/package-json';
55
56
  import { DiffScope } from '@webpieces/rules-config';
57
+ import { AffectedClassification, AffectedClassifier, AffectedReason, WorkspaceNx } from './graph-impact-reasons';
56
58
  import type { EnhancedGraph } from './graph-sorter';
57
59
  /** Relative to the architecture/ directory the page lives in; the page's `<script src>`. */
58
60
  export declare const IMPACT_SIDECAR_DIR = ".impact";
@@ -84,10 +86,16 @@ export declare class ImpactReport {
84
86
  readonly reason: string;
85
87
  /** The base's short sha ('' when there is none). */
86
88
  readonly base: string;
87
- /** Affected AND owning a changed file (node ids). */
89
+ /** Affected AND owning a changed file (node ids): "changed". */
88
90
  readonly touched: string[];
89
- /** Affected but owning no changed file (node ids): their ci re-runs. */
90
- readonly affected: string[];
91
+ /** Affected through dependency edges from a changed or touched project (node ids). */
92
+ readonly dependents: string[];
93
+ /** Affected because a `{workspaceRoot}` target input matched a changed file (node ids). */
94
+ readonly implicit: string[];
95
+ /** Affected by a lockfile, package.json, tsconfig.base.json, nx.json or deleted-config change (node ids). */
96
+ readonly rootFile: string[];
97
+ /** Affected, and nothing here could say why (node ids). Never shown as a dependent. */
98
+ readonly unexplained: string[];
91
99
  /**
92
100
  * Transitive dependencies of the affected set that are NOT affected (node ids). `ci` dependsOn
93
101
  * `^build`, so they must exist as build output although their inputs did not change: nx
@@ -98,16 +106,16 @@ export declare class ImpactReport {
98
106
  readonly changedFiles: number;
99
107
  /**
100
108
  * Transitive dependencies of the TOUCHED set that are not touched themselves (node ids): the
101
- * Filter popover's "Changed + dependencies". ("Changed + dependents" is touched + affected,
102
- * which nx already computed as the dependents whose tests re-run.)
109
+ * Filter popover's "Changed + dependencies". ("Changed + dependents" is every affected project.)
103
110
  */
104
111
  readonly dependencies: string[];
105
112
  /**
106
113
  * Changed files no project owns (repo-relative, sorted): workspace-global inputs such as
107
- * pnpm-lock.yaml. When nothing is touched they are WHY everything is affected, and the
108
- * legend names them. Empty when nx's file ownership was unavailable — then nothing is known.
114
+ * pnpm-lock.yaml. Empty when nx's locators were unavailable — then nothing is known.
109
115
  */
110
116
  readonly globalFiles: string[];
117
+ /** WHY each affected project is in the build (graph-impact-reasons.ts): one per affected node. */
118
+ readonly reasons: AffectedReason[];
111
119
  constructor(kind: ImpactKind,
112
120
  /** What the page says this scan compares, e.g. "Changed on this branch (since abc1234)". */
113
121
  label: string,
@@ -117,10 +125,16 @@ export declare class ImpactReport {
117
125
  reason: string,
118
126
  /** The base's short sha ('' when there is none). */
119
127
  base: string,
120
- /** Affected AND owning a changed file (node ids). */
128
+ /** Affected AND owning a changed file (node ids): "changed". */
121
129
  touched: string[],
122
- /** Affected but owning no changed file (node ids): their ci re-runs. */
123
- affected: string[],
130
+ /** Affected through dependency edges from a changed or touched project (node ids). */
131
+ dependents: string[],
132
+ /** Affected because a `{workspaceRoot}` target input matched a changed file (node ids). */
133
+ implicit: string[],
134
+ /** Affected by a lockfile, package.json, tsconfig.base.json, nx.json or deleted-config change (node ids). */
135
+ rootFile: string[],
136
+ /** Affected, and nothing here could say why (node ids). Never shown as a dependent. */
137
+ unexplained: string[],
124
138
  /**
125
139
  * Transitive dependencies of the affected set that are NOT affected (node ids). `ci` dependsOn
126
140
  * `^build`, so they must exist as build output although their inputs did not change: nx
@@ -130,16 +144,22 @@ export declare class ImpactReport {
130
144
  buildInputs: string[], changedFiles: number,
131
145
  /**
132
146
  * Transitive dependencies of the TOUCHED set that are not touched themselves (node ids): the
133
- * Filter popover's "Changed + dependencies". ("Changed + dependents" is touched + affected,
134
- * which nx already computed as the dependents whose tests re-run.)
147
+ * Filter popover's "Changed + dependencies". ("Changed + dependents" is every affected project.)
135
148
  */
136
149
  dependencies: string[],
137
150
  /**
138
151
  * Changed files no project owns (repo-relative, sorted): workspace-global inputs such as
139
- * pnpm-lock.yaml. When nothing is touched they are WHY everything is affected, and the
140
- * legend names them. Empty when nx's file ownership was unavailable — then nothing is known.
152
+ * pnpm-lock.yaml. Empty when nx's locators were unavailable — then nothing is known.
141
153
  */
142
- globalFiles: string[]);
154
+ globalFiles: string[],
155
+ /** WHY each affected project is in the build (graph-impact-reasons.ts): one per affected node. */
156
+ reasons: AffectedReason[]);
157
+ }
158
+ /** The one-line count split, shared by the executor's console line (the page builds the same line). */
159
+ export declare class ImpactCounts {
160
+ /** `8 changed · 3 dependents · 22 implicitly touched (runtime-wiring-check inputs) · 47 dependencies`. */
161
+ line(report: ImpactReport): string;
162
+ private targets;
143
163
  }
144
164
  /** The sidecar's one global: every scan, and the one the page shows first. */
145
165
  export declare class ImpactSidecarData {
@@ -210,49 +230,64 @@ export declare class NxCli {
210
230
  /** Resolved the way node would from the workspace, so a linked worktree finds its main tree's nx. */
211
231
  private binary;
212
232
  }
213
- /** nx's own file → project ownership, from the public devkit file map. Injected for specs. */
214
- export declare class ProjectFileOwners {
215
- owners(): Promise<Map<string, string>>;
233
+ /**
234
+ * The workspace's nx facts every comparison classifies against: its project graph (with npm nodes
235
+ * and every edge), nx.json, package.json, and nx's own affected locators. Subclassed by specs.
236
+ */
237
+ export declare class AffectedContext {
238
+ readonly graph: ProjectGraph;
239
+ readonly nxJson: NxJsonConfiguration;
240
+ readonly packageJson: PackageJson;
241
+ readonly classifier: AffectedClassifier;
242
+ private readonly nx;
243
+ constructor(graph: ProjectGraph, nxJson: NxJsonConfiguration, packageJson: PackageJson, classifier: AffectedClassifier, nx: WorkspaceNx | null);
244
+ /** nx's FileChange list for `files`, base → working tree. */
245
+ fileChanges(files: string[], base: string): FileChange[];
246
+ }
247
+ /** Loads the workspace's nx facts. Injected so specs never load the real project graph. */
248
+ export declare class AffectedContextLoader {
249
+ load(workspaceRoot: string): Promise<AffectedContext>;
216
250
  }
217
251
  /**
218
- * nx's ownership map, computed at most ONCE however many comparisons ask for it. Failing is not
219
- * fatal: the set then shows in one shade, all "affected".
252
+ * The context, loaded at most ONCE however many comparisons ask for it. Failing is not fatal: every
253
+ * affected project is then "unexplained", with this error as its reason — never a dependent.
220
254
  */
221
- export declare class SharedOwners {
222
- private readonly ownership;
255
+ export declare class SharedContext {
256
+ private readonly loader;
257
+ private readonly workspaceRoot;
223
258
  private pending;
224
- constructor(ownership: ProjectFileOwners);
225
- get(): Promise<Map<string, string>>;
259
+ constructor(loader: AffectedContextLoader, workspaceRoot: string);
260
+ get(): Promise<AffectedContext | Error>;
226
261
  private load;
227
262
  }
228
263
  export declare class NxImpactScanner {
229
264
  private readonly diffScope;
230
265
  private readonly nx;
231
- private readonly ownership;
266
+ private readonly loader;
232
267
  private readonly names;
233
- constructor(diffScope?: DiffScope, nx?: NxCli, ownership?: ProjectFileOwners);
234
- /** Every comparison, in order, sharing one ownership map. */
268
+ constructor(diffScope?: DiffScope, nx?: NxCli, loader?: AffectedContextLoader);
269
+ /** Every comparison, in order, sharing one nx context. */
235
270
  scanAll(workspaceRoot: string, graph: EnhancedGraph, comparisons: ImpactComparison[]): Promise<(ImpactReport | ImpactUnavailable)[]>;
236
271
  /** One comparison: nx affected from ITS base, with NO `--head`, so uncommitted work counts. */
237
- scan(workspaceRoot: string, graph: EnhancedGraph, comparison: ImpactComparison, owners?: SharedOwners): Promise<ImpactReport | ImpactUnavailable>;
272
+ scan(workspaceRoot: string, graph: EnhancedGraph, comparison: ImpactComparison, context?: SharedContext): Promise<ImpactReport | ImpactUnavailable>;
273
+ /** nx's locators' answer; any failure makes every affected project "unexplained", with the reason. */
274
+ classify(context: AffectedContext | Error, files: string[], base: string, affected: string[]): Promise<AffectedClassification>;
238
275
  /**
239
276
  * An empty scan is still an ANSWER (available, zero sets): a fresh branch with a clean tree has
240
277
  * simply changed nothing yet, and the page says so instead of disabling Impact.
241
278
  */
242
279
  emptyNote(comparison: ImpactComparison, changedFiles: number, short: string): string;
243
- /** Changed files no project owns; none are claimed when ownership itself was unavailable. */
244
- globalFiles(files: string[], owners: Map<string, string>): string[];
245
280
  /**
246
281
  * Every transitive dependency of `roots` that is not itself one of them: a pure downward walk over
247
282
  * the graph's own dependsOn edges, no nx call. Of the affected set these are the build inputs; of
248
283
  * the touched set, "Changed + dependencies".
249
284
  */
250
285
  buildInputs(roots: string[], graph: EnhancedGraph): string[];
251
- /** Affected projects that own one of the changed files, by nx's own ownership. */
252
- touched(affected: string[], files: string[], owners: Map<string, string>): string[];
253
- /** The JSON list, narrowed to projects the graph draws; null when it is not a list of names. */
254
- parse(json: string, graph: EnhancedGraph): string[] | null;
286
+ /** The JSON list of project names, sorted; null when it is not a list of names. */
287
+ parse(json: string): string[] | null;
288
+ /** The drawn projects only: a reason for a project the page does not draw has nowhere to show. */
255
289
  private report;
290
+ private empty;
256
291
  private ids;
257
292
  }
258
293
  /** Writes (or removes) the sidecar, self-ignored, beside architecture/dependencies.html. */
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  /**
3
- * Impact mode's data: what one COMPARISON's build looks like, in three shades (plus untouched grey).
3
+ * Impact mode's data: what one COMPARISON's build looks like, and WHY each project is in it.
4
4
  *
5
5
  * There are two comparisons, and only two (#1163). Both end at the WORKING TREE, so uncommitted and
6
6
  * untracked work counts in either:
@@ -26,15 +26,13 @@
26
26
  * - AFFECTED is `nx show projects --affected --base=<the comparison's base> --json`, with NO
27
27
  * `--head`, so uncommitted and untracked work counts. nx already gets right everything a
28
28
  * hand-rolled diff gets wrong: lockfile and package.json changes resolved per project, root/global
29
- * inputs, namedInputs, implicitDependencies, nested roots and deleted files. This uses its public
30
- * CLI surface only, never nx internals, and never NX_BASE/NX_HEAD, whose value depends on whoever
31
- * launched the process.
32
- * - TOUCHED is the subset of that set which OWNS a changed file, by nx's own file-to-project
33
- * ownership (the project file map from `createProjectFileMapUsingProjectGraph`, public devkit API)
34
- * — never a path-prefix guess. The ownership map is computed ONCE and shared by every comparison.
35
- * A project whose only change is a DELETED file is not in that map (the file is gone), so it shows
36
- * as affected rather than touched. The changed-file list is `DiffScope.getChangedFiles`, the same
37
- * base→working-tree diff (with untracked files) every code rule uses.
29
+ * inputs, namedInputs, implicitDependencies, nested roots and deleted files. It never reads
30
+ * NX_BASE/NX_HEAD, whose value depends on whoever launched the process.
31
+ * - WHY each affected project is in it (#1264) — changed, dependent (with the edge path), implicitly
32
+ * touched (with the target, glob and file) or root-file touched — comes from nx's OWN affected
33
+ * locators, run in-process against the workspace's own nx (graph-impact-reasons.ts). They are
34
+ * loaded ONCE and shared by every comparison. The changed-file list is `DiffScope.getChangedFiles`,
35
+ * the same base→working-tree diff (with untracked files) every code rule uses.
38
36
  *
39
37
  * The answer changes per checkout, so it never goes into the committed dependencies.html or
40
38
  * dependencies.json (both stay byte-stable). It goes into a SIDECAR,
@@ -54,13 +52,14 @@
54
52
  * generation.
55
53
  */
56
54
  Object.defineProperty(exports, "__esModule", { value: true });
57
- exports.ImpactRefresh = exports.ImpactSidecar = exports.NxImpactScanner = exports.SharedOwners = exports.ProjectFileOwners = exports.NxCli = exports.ImpactScopeResolver = exports.ImpactGit = exports.ImpactPlan = exports.ImpactComparison = exports.ImpactUnavailable = exports.ImpactSidecarData = exports.ImpactReport = exports.GENERATE_IMPACT_KINDS = exports.VISUALIZE_IMPACT_KINDS = exports.ImpactKind = exports.UNCOMMITTED_SUFFIX = exports.ROOT_COMMIT_REASON = exports.NOTHING_CHANGED_ON_BRANCH = exports.IMPACT_SIDECAR_SRC = exports.IMPACT_SIDECAR_FILE = exports.IMPACT_SIDECAR_DIR = void 0;
55
+ exports.ImpactRefresh = exports.ImpactSidecar = exports.NxImpactScanner = exports.SharedContext = exports.AffectedContextLoader = exports.AffectedContext = exports.NxCli = exports.ImpactScopeResolver = exports.ImpactGit = exports.ImpactPlan = exports.ImpactComparison = exports.ImpactUnavailable = exports.ImpactSidecarData = exports.ImpactCounts = exports.ImpactReport = exports.GENERATE_IMPACT_KINDS = exports.VISUALIZE_IMPACT_KINDS = exports.ImpactKind = exports.UNCOMMITTED_SUFFIX = exports.ROOT_COMMIT_REASON = exports.NOTHING_CHANGED_ON_BRANCH = exports.IMPACT_SIDECAR_SRC = exports.IMPACT_SIDECAR_FILE = exports.IMPACT_SIDECAR_DIR = void 0;
58
56
  const tslib_1 = require("tslib");
59
57
  const child_process_1 = require("child_process");
60
58
  const fs = tslib_1.__importStar(require("fs"));
61
59
  const path = tslib_1.__importStar(require("path"));
62
60
  const devkit_1 = require("@nx/devkit");
63
61
  const rules_config_1 = require("@webpieces/rules-config");
62
+ const graph_impact_reasons_1 = require("./graph-impact-reasons");
64
63
  const graph_names_1 = require("./graph-names");
65
64
  const toError_1 = require("../toError");
66
65
  /** Relative to the architecture/ directory the page lives in; the page's `<script src>`. */
@@ -95,11 +94,15 @@ class ImpactReport {
95
94
  reason;
96
95
  base;
97
96
  touched;
98
- affected;
97
+ dependents;
98
+ implicit;
99
+ rootFile;
100
+ unexplained;
99
101
  buildInputs;
100
102
  changedFiles;
101
103
  dependencies;
102
104
  globalFiles;
105
+ reasons;
103
106
  constructor(kind,
104
107
  /** What the page says this scan compares, e.g. "Changed on this branch (since abc1234)". */
105
108
  label,
@@ -109,10 +112,16 @@ class ImpactReport {
109
112
  reason,
110
113
  /** The base's short sha ('' when there is none). */
111
114
  base,
112
- /** Affected AND owning a changed file (node ids). */
115
+ /** Affected AND owning a changed file (node ids): "changed". */
113
116
  touched,
114
- /** Affected but owning no changed file (node ids): their ci re-runs. */
115
- affected,
117
+ /** Affected through dependency edges from a changed or touched project (node ids). */
118
+ dependents,
119
+ /** Affected because a `{workspaceRoot}` target input matched a changed file (node ids). */
120
+ implicit,
121
+ /** Affected by a lockfile, package.json, tsconfig.base.json, nx.json or deleted-config change (node ids). */
122
+ rootFile,
123
+ /** Affected, and nothing here could say why (node ids). Never shown as a dependent. */
124
+ unexplained,
116
125
  /**
117
126
  * Transitive dependencies of the affected set that are NOT affected (node ids). `ci` dependsOn
118
127
  * `^build`, so they must exist as build output although their inputs did not change: nx
@@ -122,16 +131,16 @@ class ImpactReport {
122
131
  buildInputs, changedFiles,
123
132
  /**
124
133
  * Transitive dependencies of the TOUCHED set that are not touched themselves (node ids): the
125
- * Filter popover's "Changed + dependencies". ("Changed + dependents" is touched + affected,
126
- * which nx already computed as the dependents whose tests re-run.)
134
+ * Filter popover's "Changed + dependencies". ("Changed + dependents" is every affected project.)
127
135
  */
128
136
  dependencies,
129
137
  /**
130
138
  * Changed files no project owns (repo-relative, sorted): workspace-global inputs such as
131
- * pnpm-lock.yaml. When nothing is touched they are WHY everything is affected, and the
132
- * legend names them. Empty when nx's file ownership was unavailable — then nothing is known.
139
+ * pnpm-lock.yaml. Empty when nx's locators were unavailable — then nothing is known.
133
140
  */
134
- globalFiles) {
141
+ globalFiles,
142
+ /** WHY each affected project is in the build (graph-impact-reasons.ts): one per affected node. */
143
+ reasons) {
135
144
  this.kind = kind;
136
145
  this.label = label;
137
146
  this.dirty = dirty;
@@ -139,14 +148,41 @@ class ImpactReport {
139
148
  this.reason = reason;
140
149
  this.base = base;
141
150
  this.touched = touched;
142
- this.affected = affected;
151
+ this.dependents = dependents;
152
+ this.implicit = implicit;
153
+ this.rootFile = rootFile;
154
+ this.unexplained = unexplained;
143
155
  this.buildInputs = buildInputs;
144
156
  this.changedFiles = changedFiles;
145
157
  this.dependencies = dependencies;
146
158
  this.globalFiles = globalFiles;
159
+ this.reasons = reasons;
147
160
  }
148
161
  }
149
162
  exports.ImpactReport = ImpactReport;
163
+ /** The one-line count split, shared by the executor's console line (the page builds the same line). */
164
+ class ImpactCounts {
165
+ /** `8 changed · 3 dependents · 22 implicitly touched (runtime-wiring-check inputs) · 47 dependencies`. */
166
+ line(report) {
167
+ const parts = [`${report.touched.length} changed`, `${report.dependents.length} dependents`];
168
+ if (report.implicit.length > 0)
169
+ parts.push(`${report.implicit.length} implicitly touched${this.targets(report)}`);
170
+ if (report.rootFile.length > 0)
171
+ parts.push(`${report.rootFile.length} root-file touched`);
172
+ if (report.unexplained.length > 0)
173
+ parts.push(`${report.unexplained.length} unexplained`);
174
+ parts.push(`${report.buildInputs.length} dependencies`);
175
+ return parts.join(' · ');
176
+ }
177
+ targets(report) {
178
+ const targets = new Set();
179
+ for (const reason of report.reasons)
180
+ if (reason.kind === graph_impact_reasons_1.AffectedClass.IMPLICIT && reason.target !== '')
181
+ targets.add(reason.target);
182
+ return targets.size === 0 ? '' : ` (${[...targets].sort().join(', ')} inputs)`;
183
+ }
184
+ }
185
+ exports.ImpactCounts = ImpactCounts;
150
186
  /** The sidecar's one global: every scan, and the one the page shows first. */
151
187
  class ImpactSidecarData {
152
188
  scans;
@@ -311,28 +347,50 @@ class NxCli {
311
347
  }
312
348
  }
313
349
  exports.NxCli = NxCli;
314
- /** nx's own file → project ownership, from the public devkit file map. Injected for specs. */
315
- class ProjectFileOwners {
316
- async owners() {
317
- const fileMap = await (0, devkit_1.createProjectFileMapUsingProjectGraph)(await (0, devkit_1.createProjectGraphAsync)());
318
- const owners = new Map();
319
- for (const project of Object.keys(fileMap)) {
320
- for (const data of fileMap[project])
321
- owners.set(data.file, project);
322
- }
323
- return owners;
350
+ /**
351
+ * The workspace's nx facts every comparison classifies against: its project graph (with npm nodes
352
+ * and every edge), nx.json, package.json, and nx's own affected locators. Subclassed by specs.
353
+ */
354
+ class AffectedContext {
355
+ graph;
356
+ nxJson;
357
+ packageJson;
358
+ classifier;
359
+ nx;
360
+ constructor(graph, nxJson, packageJson, classifier, nx) {
361
+ this.graph = graph;
362
+ this.nxJson = nxJson;
363
+ this.packageJson = packageJson;
364
+ this.classifier = classifier;
365
+ this.nx = nx;
366
+ }
367
+ /** nx's FileChange list for `files`, base → working tree. */
368
+ fileChanges(files, base) {
369
+ if (this.nx === null)
370
+ throw new Error('no workspace nx to read file changes with');
371
+ return this.nx.fileChanges(files, base);
324
372
  }
325
373
  }
326
- exports.ProjectFileOwners = ProjectFileOwners;
374
+ exports.AffectedContext = AffectedContext;
375
+ /** Loads the workspace's nx facts. Injected so specs never load the real project graph. */
376
+ class AffectedContextLoader {
377
+ async load(workspaceRoot) {
378
+ const nx = new graph_impact_reasons_1.WorkspaceNx(workspaceRoot);
379
+ return new AffectedContext(await (0, devkit_1.createProjectGraphAsync)(), nx.nxJson(), nx.packageJson(), new graph_impact_reasons_1.AffectedClassifier(nx.locators()), nx);
380
+ }
381
+ }
382
+ exports.AffectedContextLoader = AffectedContextLoader;
327
383
  /**
328
- * nx's ownership map, computed at most ONCE however many comparisons ask for it. Failing is not
329
- * fatal: the set then shows in one shade, all "affected".
384
+ * The context, loaded at most ONCE however many comparisons ask for it. Failing is not fatal: every
385
+ * affected project is then "unexplained", with this error as its reason — never a dependent.
330
386
  */
331
- class SharedOwners {
332
- ownership;
387
+ class SharedContext {
388
+ loader;
389
+ workspaceRoot;
333
390
  pending = null;
334
- constructor(ownership) {
335
- this.ownership = ownership;
391
+ constructor(loader, workspaceRoot) {
392
+ this.loader = loader;
393
+ this.workspaceRoot = workspaceRoot;
336
394
  }
337
395
  get() {
338
396
  if (this.pending === null)
@@ -340,60 +398,45 @@ class SharedOwners {
340
398
  return this.pending;
341
399
  }
342
400
  async load() {
343
- // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- the second shade is optional
401
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- the reasons are optional; the affected set still shows
344
402
  try {
345
- return await this.ownership.owners();
403
+ return await this.loader.load(this.workspaceRoot);
346
404
  }
347
405
  catch (err) {
348
406
  const error = (0, toError_1.toError)(err);
349
- console.warn(`⚠️ Impact: nx file ownership unavailable (${error.message}); showing one shade`);
350
- return new Map();
407
+ console.warn(`⚠️ Impact: nx's affected locators unavailable (${error.message}); reasons unknown`);
408
+ return error;
351
409
  }
352
410
  }
353
411
  }
354
- exports.SharedOwners = SharedOwners;
355
- /** The sets one scan found; an empty one is still an answer. */
356
- class ImpactSets {
357
- touched;
358
- affected;
359
- buildInputs;
360
- dependencies;
361
- globalFiles;
362
- constructor(touched = [], affected = [], buildInputs = [], dependencies = [], globalFiles = []) {
363
- this.touched = touched;
364
- this.affected = affected;
365
- this.buildInputs = buildInputs;
366
- this.dependencies = dependencies;
367
- this.globalFiles = globalFiles;
368
- }
369
- }
412
+ exports.SharedContext = SharedContext;
370
413
  class NxImpactScanner {
371
414
  diffScope;
372
415
  nx;
373
- ownership;
416
+ loader;
374
417
  names = new graph_names_1.GraphNames();
375
- constructor(diffScope = new rules_config_1.DiffScope(), nx = new NxCli(), ownership = new ProjectFileOwners()) {
418
+ constructor(diffScope = new rules_config_1.DiffScope(), nx = new NxCli(), loader = new AffectedContextLoader()) {
376
419
  this.diffScope = diffScope;
377
420
  this.nx = nx;
378
- this.ownership = ownership;
421
+ this.loader = loader;
379
422
  }
380
- /** Every comparison, in order, sharing one ownership map. */
423
+ /** Every comparison, in order, sharing one nx context. */
381
424
  async scanAll(workspaceRoot, graph, comparisons) {
382
- const owners = new SharedOwners(this.ownership);
425
+ const context = new SharedContext(this.loader, workspaceRoot);
383
426
  const results = [];
384
427
  for (const comparison of comparisons)
385
- results.push(await this.scan(workspaceRoot, graph, comparison, owners));
428
+ results.push(await this.scan(workspaceRoot, graph, comparison, context));
386
429
  return results;
387
430
  }
388
431
  /** One comparison: nx affected from ITS base, with NO `--head`, so uncommitted work counts. */
389
- async scan(workspaceRoot, graph, comparison, owners = new SharedOwners(this.ownership)) {
432
+ async scan(workspaceRoot, graph, comparison, context = new SharedContext(this.loader, workspaceRoot)) {
390
433
  const base = comparison.base;
391
434
  if (base === null)
392
- return this.report(comparison, false, comparison.reason, '', 0, new ImpactSets());
435
+ return this.empty(comparison, false, comparison.reason, '', 0);
393
436
  const json = this.nx.run(workspaceRoot, ['show', 'projects', '--affected', `--base=${base}`, '--json'], NX_TIMEOUT_MS);
394
437
  if (json === null)
395
438
  return new ImpactUnavailable(`nx show projects --affected failed or took over ${NX_TIMEOUT_MS / 1000}s`, comparison.label);
396
- const affectedSet = this.parse(json, graph);
439
+ const affectedSet = this.parse(json);
397
440
  if (affectedSet === null)
398
441
  return new ImpactUnavailable('nx show projects --affected printed something other than a JSON list', comparison.label);
399
442
  const options = new rules_config_1.ChangedFilesOptions();
@@ -401,13 +444,26 @@ class NxImpactScanner {
401
444
  options.includeDeletions = true;
402
445
  const files = this.diffScope.getChangedFiles(workspaceRoot, base, undefined, options);
403
446
  const short = base.slice(0, 7);
404
- if (affectedSet.length === 0)
405
- return this.report(comparison, true, this.emptyNote(comparison, files.length, short), short, files.length, new ImpactSets());
406
- const map = await owners.get();
407
- const touched = this.touched(affectedSet, files, map);
408
- const rest = affectedSet.filter((project) => !touched.includes(project));
409
- const sets = new ImpactSets(this.ids(touched), this.ids(rest), this.ids(this.buildInputs(affectedSet, graph)), this.ids(this.buildInputs(touched, graph)), this.globalFiles(files, map));
410
- return this.report(comparison, true, '', short, files.length, sets);
447
+ const drawn = affectedSet.filter((project) => graph[project] !== undefined);
448
+ if (drawn.length === 0)
449
+ return this.empty(comparison, true, this.emptyNote(comparison, files.length, short), short, files.length);
450
+ const classification = await this.classify(await context.get(), files, base, affectedSet);
451
+ return this.report(comparison, short, files.length, graph, classification);
452
+ }
453
+ /** nx's locators' answer; any failure makes every affected project "unexplained", with the reason. */
454
+ async classify(context, files, base, affected) {
455
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- the reasons are optional; a locator failure leaves them unknown
456
+ try {
457
+ if (context instanceof Error)
458
+ throw context;
459
+ const changes = context.fileChanges(files, base);
460
+ return await context.classifier.classify(new graph_impact_reasons_1.AffectedInputs(context.graph, context.nxJson, context.packageJson, changes, affected));
461
+ }
462
+ catch (err) {
463
+ const error = (0, toError_1.toError)(err);
464
+ const why = `nx's affected locators could not run here (${error.message}), so why nx marked it is unknown`;
465
+ return new graph_impact_reasons_1.AffectedClassification([...affected].sort().map((project) => new graph_impact_reasons_1.AffectedReason(project, graph_impact_reasons_1.AffectedClass.UNEXPLAINED, [], [], '', '', why)), []);
466
+ }
411
467
  }
412
468
  /**
413
469
  * An empty scan is still an ANSWER (available, zero sets): a fresh branch with a clean tree has
@@ -420,12 +476,6 @@ class NxImpactScanner {
420
476
  return `Nothing changed since ${short}`;
421
477
  return `Nothing affected since ${short}`;
422
478
  }
423
- /** Changed files no project owns; none are claimed when ownership itself was unavailable. */
424
- globalFiles(files, owners) {
425
- if (owners.size === 0)
426
- return [];
427
- return files.filter((file) => !owners.has(file)).sort();
428
- }
429
479
  /**
430
480
  * Every transitive dependency of `roots` that is not itself one of them: a pure downward walk over
431
481
  * the graph's own dependsOn edges, no nx call. Of the affected set these are the build inputs; of
@@ -449,27 +499,15 @@ class NxImpactScanner {
449
499
  }
450
500
  return inputs.sort();
451
501
  }
452
- /** Affected projects that own one of the changed files, by nx's own ownership. */
453
- touched(affected, files, owners) {
454
- const owning = new Set();
455
- for (const file of files) {
456
- const owner = owners.get(file);
457
- if (owner !== undefined)
458
- owning.add(owner);
459
- }
460
- return affected.filter((project) => owning.has(project));
461
- }
462
- /** The JSON list, narrowed to projects the graph draws; null when it is not a list of names. */
463
- parse(json, graph) {
502
+ /** The JSON list of project names, sorted; null when it is not a list of names. */
503
+ parse(json) {
464
504
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions -- untrusted CLI output; anything unparseable is reported as a reason
465
505
  try {
466
506
  // webpieces-disable no-any-unknown -- CLI JSON is untrusted until narrowed below
467
507
  const parsed = JSON.parse(json);
468
508
  if (!Array.isArray(parsed))
469
509
  return null;
470
- return parsed
471
- .filter((name) => typeof name === 'string' && graph[name] !== undefined)
472
- .sort();
510
+ return parsed.filter((name) => typeof name === 'string').sort();
473
511
  }
474
512
  catch (err) {
475
513
  const error = (0, toError_1.toError)(err);
@@ -477,8 +515,15 @@ class NxImpactScanner {
477
515
  return null;
478
516
  }
479
517
  }
480
- report(comparison, available, reason, base, changedFiles, sets) {
481
- return new ImpactReport(comparison.kind, comparison.label, comparison.dirty, available, reason, base, sets.touched, sets.affected, sets.buildInputs, changedFiles, sets.dependencies, sets.globalFiles);
518
+ /** The drawn projects only: a reason for a project the page does not draw has nowhere to show. */
519
+ report(comparison, short, changedFiles, graph, classification) {
520
+ const drawn = (projects) => projects.filter((project) => graph[project] !== undefined);
521
+ const touched = drawn(classification.of(graph_impact_reasons_1.AffectedClass.CHANGED));
522
+ const affected = drawn(classification.reasons.map((reason) => reason.project));
523
+ return new ImpactReport(comparison.kind, comparison.label, comparison.dirty, true, '', short, this.ids(touched), this.ids(drawn(classification.of(graph_impact_reasons_1.AffectedClass.DEPENDENT))), this.ids(drawn(classification.of(graph_impact_reasons_1.AffectedClass.IMPLICIT))), this.ids(drawn(classification.of(graph_impact_reasons_1.AffectedClass.ROOT_FILE))), this.ids(drawn(classification.of(graph_impact_reasons_1.AffectedClass.UNEXPLAINED))), this.ids(this.buildInputs(affected, graph)), changedFiles, this.ids(this.buildInputs(touched, graph)), classification.globalFiles, classification.reasons.filter((reason) => graph[reason.project] !== undefined));
524
+ }
525
+ empty(comparison, available, reason, base, changedFiles) {
526
+ return new ImpactReport(comparison.kind, comparison.label, comparison.dirty, available, reason, base, [], [], [], [], [], [], changedFiles, [], [], []);
482
527
  }
483
528
  ids(projects) {
484
529
  return projects.map((project) => this.names.getNodeId(project));
@@ -571,8 +616,7 @@ class ImpactRefresh {
571
616
  return `ℹ️ Impact · ${result.label}: unavailable — ${result.reason}`;
572
617
  if (result.reason !== '')
573
618
  return `✅ Impact · ${result.label}: ${result.reason}`;
574
- return (`✅ Impact · ${result.label}: ${result.touched.length} changed · ${result.affected.length} dependents · ` +
575
- `${result.buildInputs.length} dependencies, from ${result.changedFiles} changed file(s)`);
619
+ return `✅ Impact · ${result.label}: ${new ImpactCounts().line(result)}, from ${result.changedFiles} changed file(s)`;
576
620
  }
577
621
  }
578
622
  exports.ImpactRefresh = ImpactRefresh;