no-mistakes 0.33.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -119,6 +119,25 @@ CLI and Node analyses share a per-user machine-wide lock. CLI flags
119
119
  Waiting does not alter successful output, and Node lock/timeout failures reject
120
120
  the returned Promise.
121
121
 
122
+ Dependency graph, query, and test-planning resolution is per workspace by
123
+ default: when `tsconfig` is omitted, each import uses the config that owns its
124
+ importing file, including referenced projects. This keeps conflicting package
125
+ aliases isolated while shared code can still select all importing tests. Pass
126
+ `tsconfig` explicitly to force one config for a whole invocation when debugging
127
+ or preserving a legacy single-config workflow.
128
+
129
+ Graph queries also support `relationships: ["workflow"]` for canonical local
130
+ GitHub Actions traversal: workflow file -> virtual job -> virtual step,
131
+ `needs`, local reusable workflows/actions, supported literal `run:` targets and
132
+ package scripts, and same-run artifact handoffs. Virtual IDs are
133
+ `workflow.yml#job:<job>` and `workflow.yml#job:<job>/step:<zero-based-index>`.
134
+ Use the precise `workflow-job`, `workflow-step`, `workflow-needs`,
135
+ `workflow-uses`, `workflow-run`, or `workflow-artifact` filters to select a
136
+ semantic while retaining its required structural bridge edges. The legacy `ci`
137
+ relationship stays separate: it covers only workflow-file-to-Rust-binary Cargo
138
+ invocations. Remote actions/workflows, `workflow_run`, dynamic shell commands,
139
+ and paths outside the tracked graph are intentionally excluded.
140
+
122
141
  External `no-mistakes-*` executables on `PATH` can be invoked as subcommands.
123
142
  For example, after installing `no-mistakes-scripts`:
124
143
 
package/flow-types.d.ts CHANGED
@@ -15,13 +15,18 @@ export interface FlowOptions {
15
15
 
16
16
  export interface FlowNode {
17
17
  id: string;
18
- kind: "file" | "symbol" | "module" | "queue-job";
18
+ kind: "file" | "symbol" | "module" | "queue-job" | "workflow-job" | "workflow-step";
19
19
  depth: number;
20
20
  file?: string;
21
21
  symbol?: string;
22
22
  module?: string;
23
23
  queueFile?: string;
24
+ /** Workflow file for a virtual GitHub Actions job or step node. */
25
+ workflowFile?: string;
26
+ /** GitHub Actions job identifier for a virtual workflow job or step node. */
24
27
  job?: string;
28
+ /** Zero-based step index for a virtual GitHub Actions workflow step node. */
29
+ step?: number;
25
30
  }
26
31
 
27
32
  export interface FlowEdge {
@@ -0,0 +1,70 @@
1
+ // CI, impacted-checks, data-flow, infra, and Swift API declarations, split
2
+ // out of `index.d.ts` to stay under the 200-line file limit.
3
+
4
+ import type {
5
+ CiEnvOptions,
6
+ CiEnvReport,
7
+ CiImpactOptions,
8
+ CiImpactReport,
9
+ CiTopologyOptions,
10
+ DataPwOptions,
11
+ DataPwReport,
12
+ EffectsOptions,
13
+ EffectsReport,
14
+ ImpactedChecksOptions,
15
+ ImpactedChecksReport,
16
+ InfraOptions,
17
+ LockfileDiffEntry,
18
+ LockfileDiffOptions,
19
+ ModuleOutputsResult,
20
+ RegistryExtensionOptions,
21
+ RegistryExtensionReport,
22
+ ResourceRefRow,
23
+ RscCallersOptions,
24
+ RscCallersReport,
25
+ SwiftImporterRow,
26
+ SwiftOptions,
27
+ SwiftTestTargetRow,
28
+ TestForRow,
29
+ WithInvocationOptions,
30
+ WorkflowTopology,
31
+ WorkflowTopologyIndex,
32
+ } from "./types";
33
+
34
+ export function lockfileDiff(
35
+ options: WithInvocationOptions<LockfileDiffOptions>,
36
+ ): Promise<LockfileDiffEntry[]>;
37
+ export function ciImpact(options: WithInvocationOptions<CiImpactOptions>): Promise<CiImpactReport>;
38
+ export function ciEnv(options: WithInvocationOptions<CiEnvOptions>): Promise<CiEnvReport>;
39
+ export function ciTopology(
40
+ options?: WithInvocationOptions<CiTopologyOptions>,
41
+ ): Promise<WorkflowTopology>;
42
+ /** Builds a query index over a `ciTopology()` result. Pure JS — never crosses the N-API boundary. */
43
+ export function createWorkflowTopologyIndex(topology: WorkflowTopology): WorkflowTopologyIndex;
44
+ export function impactedChecks(
45
+ options: WithInvocationOptions<ImpactedChecksOptions>,
46
+ ): Promise<ImpactedChecksReport>;
47
+ export function dataPw(options: WithInvocationOptions<DataPwOptions>): Promise<DataPwReport>;
48
+ export function effects(options: WithInvocationOptions<EffectsOptions>): Promise<EffectsReport>;
49
+ export function rscCallers(
50
+ options: WithInvocationOptions<RscCallersOptions>,
51
+ ): Promise<RscCallersReport>;
52
+ export function registryExtension(
53
+ options: WithInvocationOptions<RegistryExtensionOptions>,
54
+ ): Promise<RegistryExtensionReport>;
55
+ export function infraResourceRefs(
56
+ options: WithInvocationOptions<InfraOptions & { address: string }>,
57
+ ): Promise<ResourceRefRow[]>;
58
+ export function infraOutputs(
59
+ options: WithInvocationOptions<InfraOptions & { moduleDir: string }>,
60
+ ): Promise<ModuleOutputsResult>;
61
+ export function infraTestFor(
62
+ options: WithInvocationOptions<InfraOptions & { tfFile: string }>,
63
+ ): Promise<TestForRow[]>;
64
+ export function swiftImporters(
65
+ options: WithInvocationOptions<SwiftOptions & { file: string }>,
66
+ ): Promise<SwiftImporterRow[]>;
67
+ export function swiftTestTargets(
68
+ options: WithInvocationOptions<SwiftOptions & { file: string }>,
69
+ ): Promise<SwiftTestTargetRow[]>;
70
+ export function version(): Promise<string>;
package/index.d.ts CHANGED
@@ -2,30 +2,16 @@ import type {
2
2
  CheckReport,
3
3
  AnalyzeProjectOptions,
4
4
  AnalyzeProjectResult,
5
- CiEnvOptions,
6
- CiEnvReport,
7
- CiImpactOptions,
8
- CiImpactReport,
9
5
  CallSitesOptions,
10
6
  CallSitesResult,
11
- DataPwOptions,
12
- DataPwReport,
13
7
  DeadExportsOptions,
14
8
  DeadExportsResult,
15
- EffectsOptions,
16
- EffectsReport,
17
- RscCallersOptions,
18
- RscCallersReport,
19
- RegistryExtensionOptions,
20
- RegistryExtensionReport,
21
9
  DependencyResult,
22
10
  ExportsOfOptions,
23
11
  ExportsOfResult,
24
12
  FetchesOptions,
25
13
  FlowOptions,
26
14
  FlowReport,
27
- ImpactedChecksOptions,
28
- ImpactedChecksReport,
29
15
  ImportUsagesOptions,
30
16
  ImportUsagesResult,
31
17
  ImportersOptions,
@@ -33,10 +19,6 @@ import type {
33
19
  ResolveCheckOptions,
34
20
  ResolveCheckResult,
35
21
  GraphEdge,
36
- InfraOptions,
37
- LockfileDiffEntry,
38
- LockfileDiffOptions,
39
- ModuleOutputsResult,
40
22
  PlaywrightOptions,
41
23
  PlaywrightRelatedOptions,
42
24
  ProjectOptions,
@@ -44,14 +26,9 @@ import type {
44
26
  ReactComponentFacts,
45
27
  ReactUsagesReport,
46
28
  ReactViolation,
47
- ResourceRefRow,
48
29
  ServerRoutesReport,
49
30
  ServerContractsReport,
50
31
  SignatureImpactResult,
51
- SwiftImporterRow,
52
- SwiftOptions,
53
- SwiftTestTargetRow,
54
- TestForRow,
55
32
  SymbolsListOptions,
56
33
  SymbolsOptions,
57
34
  SymbolsResult,
@@ -70,6 +47,7 @@ import type {
70
47
  } from "./types";
71
48
 
72
49
  export * from "./types";
50
+ export * from "./index-ci-infra";
73
51
 
74
52
  export function dependencies(
75
53
  options: WithInvocationOptions<TraverseOptions>,
@@ -166,35 +144,3 @@ export function reactCheck(
166
144
  export function reactUsages(
167
145
  options: WithInvocationOptions<ProjectOptions & { target: string }>,
168
146
  ): Promise<ReactUsagesReport>;
169
- export function lockfileDiff(
170
- options: WithInvocationOptions<LockfileDiffOptions>,
171
- ): Promise<LockfileDiffEntry[]>;
172
- export function ciImpact(options: WithInvocationOptions<CiImpactOptions>): Promise<CiImpactReport>;
173
- export function ciEnv(options: WithInvocationOptions<CiEnvOptions>): Promise<CiEnvReport>;
174
- export function impactedChecks(
175
- options: WithInvocationOptions<ImpactedChecksOptions>,
176
- ): Promise<ImpactedChecksReport>;
177
- export function dataPw(options: WithInvocationOptions<DataPwOptions>): Promise<DataPwReport>;
178
- export function effects(options: WithInvocationOptions<EffectsOptions>): Promise<EffectsReport>;
179
- export function rscCallers(
180
- options: WithInvocationOptions<RscCallersOptions>,
181
- ): Promise<RscCallersReport>;
182
- export function registryExtension(
183
- options: WithInvocationOptions<RegistryExtensionOptions>,
184
- ): Promise<RegistryExtensionReport>;
185
- export function infraResourceRefs(
186
- options: WithInvocationOptions<InfraOptions & { address: string }>,
187
- ): Promise<ResourceRefRow[]>;
188
- export function infraOutputs(
189
- options: WithInvocationOptions<InfraOptions & { moduleDir: string }>,
190
- ): Promise<ModuleOutputsResult>;
191
- export function infraTestFor(
192
- options: WithInvocationOptions<InfraOptions & { tfFile: string }>,
193
- ): Promise<TestForRow[]>;
194
- export function swiftImporters(
195
- options: WithInvocationOptions<SwiftOptions & { file: string }>,
196
- ): Promise<SwiftImporterRow[]>;
197
- export function swiftTestTargets(
198
- options: WithInvocationOptions<SwiftOptions & { file: string }>,
199
- ): Promise<SwiftTestTargetRow[]>;
200
- export function version(): Promise<string>;
package/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  const native = require("./bin/no-mistakes.node");
4
4
  const planning = require("./planning");
5
+ const { createWorkflowTopologyIndex } = require("./workflow-topology-index");
5
6
 
6
7
  async function callJson(fn, options) {
7
8
  return JSON.parse(await fn(JSON.stringify(options || {})));
@@ -115,6 +116,10 @@ async function ciEnv(options) {
115
116
  return callJson(native.ciEnvJson, options);
116
117
  }
117
118
 
119
+ async function ciTopology(options) {
120
+ return callJson(native.ciTopologyJson, options);
121
+ }
122
+
118
123
  async function impactedChecks(options) {
119
124
  return callJson(native.impactedChecksJson, options);
120
125
  }
@@ -149,6 +154,8 @@ module.exports = {
149
154
  check,
150
155
  ciEnv,
151
156
  ciImpact,
157
+ ciTopology,
158
+ createWorkflowTopologyIndex,
152
159
  dataPw,
153
160
  deadExports,
154
161
  dependencies,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "no-mistakes",
3
- "version": "0.33.1",
3
+ "version": "0.35.0",
4
4
  "description": "Static codebase analysis tools for TS/JS dependencies, dependents, and symbols",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,6 +16,7 @@
16
16
  "*.d.ts",
17
17
  "index.js",
18
18
  "planning.js",
19
+ "workflow-topology-index.js",
19
20
  "scripts/install.js",
20
21
  "scripts/install/",
21
22
  "README.md",
package/test-types.d.ts CHANGED
@@ -66,6 +66,8 @@ export interface SelectedTest {
66
66
  export interface TestExecutionTarget {
67
67
  runner: "vitest" | "playwright" | "dotnet" | "swift";
68
68
  config?: string | null;
69
+ /** True when config is a Vitest workspace/project-array source rendered with --workspace. */
70
+ workspace?: boolean;
69
71
  project?: string | null;
70
72
  base_command: string[];
71
73
  runner_args: string[];
@@ -75,8 +77,37 @@ export interface ImpactReason {
75
77
  changed_file: string;
76
78
  path: string[];
77
79
  via: string[];
80
+ /** When present, aligns index-for-index with `via`. */
81
+ via_details?: Array<ImpactEdgeDetail | null>;
78
82
  }
79
83
 
84
+ export type ImpactEdgeDetail = ResourceImpactEdgeDetail | VitestSetupImpactEdgeDetail;
85
+
86
+ export interface ResourceImpactEdgeDetail {
87
+ type: "resource";
88
+ consumer_file: string;
89
+ call_sites: ResourceCallSite[];
90
+ }
91
+
92
+ export interface VitestSetupImpactEdgeDetail {
93
+ type: "vitest-setup";
94
+ field: "setupFiles" | "globalSetup";
95
+ }
96
+
97
+ export interface ResourceCallSite {
98
+ call_kind: ResourceCallKind;
99
+ line: number;
100
+ }
101
+
102
+ /** Static runtime filesystem API that created a resource dependency edge. */
103
+ export type ResourceCallKind =
104
+ | "read-file"
105
+ | "read-file-sync"
106
+ | "read-directory"
107
+ | "read-directory-sync"
108
+ | "glob"
109
+ | "glob-sync";
110
+
80
111
  export interface TestPlanGroup {
81
112
  type: string;
82
113
  selected: string[];
@@ -88,6 +119,7 @@ export interface TestPlanWarning {
88
119
  type: string;
89
120
  message: string;
90
121
  file: string;
122
+ line?: number;
91
123
  }
92
124
 
93
125
  export interface TestsTargetsReport {
@@ -122,6 +154,7 @@ export interface TestsWhyOptions {
122
154
  export interface WhyStep {
123
155
  node: string;
124
156
  via?: string | null;
157
+ detail?: ImpactEdgeDetail | null;
125
158
  }
126
159
 
127
160
  export interface TestsPlanDocumentOptions {
@@ -131,7 +164,7 @@ export interface TestsPlanDocumentOptions {
131
164
 
132
165
  export interface TestGraph {
133
166
  nodes: Array<{ name: string; type: "changed" | "test" | "intermediate" }>;
134
- edges: Array<{ from: string; to: string; via: string }>;
167
+ edges: Array<{ from: string; to: string; via: string; detail?: ImpactEdgeDetail }>;
135
168
  }
136
169
 
137
170
  export interface LockfileDiffOptions {
@@ -12,6 +12,13 @@ export type Relationship =
12
12
  | "queue"
13
13
  | "md"
14
14
  | "ci"
15
+ | "workflow"
16
+ | "workflow-job"
17
+ | "workflow-step"
18
+ | "workflow-needs"
19
+ | "workflow-uses"
20
+ | "workflow-run"
21
+ | "workflow-artifact"
15
22
  | "http"
16
23
  | "process"
17
24
  | "asset"
@@ -19,6 +26,7 @@ export type Relationship =
19
26
  | "dotnet"
20
27
  | "swift"
21
28
  | "terraform"
29
+ | "resource"
22
30
  | "all";
23
31
 
24
32
  export interface TraverseOptions {
@@ -40,7 +48,12 @@ export interface DependencyFile {
40
48
  file?: string;
41
49
  symbol?: string;
42
50
  queueFile?: string;
51
+ /** Workflow file for a virtual GitHub Actions job or step node. */
52
+ workflowFile?: string;
53
+ /** GitHub Actions job identifier for a virtual workflow job or step node. */
43
54
  job?: string;
55
+ /** Zero-based step index for a virtual GitHub Actions workflow step node. */
56
+ step?: number;
44
57
  module?: string;
45
58
  depth: number;
46
59
  via?: string[];
@@ -51,9 +64,25 @@ export interface SymbolEntrypoint {
51
64
  symbol?: string;
52
65
  }
53
66
 
67
+ export interface TsConfigDiagnostic {
68
+ kind: "ambiguous-ownership" | "invalid-config" | "invalid-extends" | "invalid-reference";
69
+ config: string | null;
70
+ file: string | null;
71
+ detail: string;
72
+ candidates: string[];
73
+ }
74
+
75
+ export interface TsConfigProvenance {
76
+ importer: string;
77
+ config: string | null;
78
+ forced: boolean;
79
+ }
80
+
54
81
  export interface DependencyResult {
55
82
  roots: string[];
56
83
  files: DependencyFile[];
84
+ diagnostics: TsConfigDiagnostic[];
85
+ tsconfig_provenance: TsConfigProvenance[];
57
86
  }
58
87
 
59
88
  export type ExportKind =
package/types.d.ts CHANGED
@@ -7,5 +7,6 @@ export * from "./test-types";
7
7
  export * from "./report-types";
8
8
  export * from "./server-contract-types";
9
9
  export * from "./ci-types";
10
+ export * from "./workflow-topology-types";
10
11
  export * from "./query-types";
11
12
  export * from "./named-query-types";
@@ -0,0 +1,56 @@
1
+ // Artifact-dataflow types for `ciTopology()`, split out of
2
+ // `workflow-topology-types.d.ts` to stay under the 200-line file limit.
3
+ // Mirrors the schema-v1 JSON contract exactly (field names, casing) — see
4
+ // `docs/node-api.md` for the stability guarantees.
5
+
6
+ export type ArtifactValue =
7
+ | { kind: "static"; raw: string; value: string; instanceCount?: number }
8
+ | { kind: "finite"; raw: string; values: string[]; instanceCounts: Record<string, number> }
9
+ | { kind: "dynamic"; raw: string }
10
+ | { kind: "path-derived"; reason: "archive-disabled" };
11
+
12
+ export type ArtifactActionFlag =
13
+ | { kind: "static"; raw?: string; effective: boolean }
14
+ | { kind: "dynamic"; raw: string };
15
+
16
+ export interface ArtifactUploadDeclaration {
17
+ kind: "upload";
18
+ name: ArtifactValue;
19
+ archive: ArtifactActionFlag;
20
+ overwrite: ArtifactActionFlag;
21
+ }
22
+
23
+ export type ArtifactDownloadSelector =
24
+ | { kind: "name"; name: ArtifactValue }
25
+ | { kind: "pattern"; pattern: ArtifactValue }
26
+ | { kind: "all" }
27
+ | { kind: "artifact-ids"; artifactIds: ArtifactValue }
28
+ | {
29
+ kind: "unresolved";
30
+ reason: "name-with-artifact-ids";
31
+ name: ArtifactValue;
32
+ artifactIds: ArtifactValue;
33
+ };
34
+
35
+ export type ArtifactDownloadSource =
36
+ | { kind: "current-run"; repository?: ArtifactValue; runId?: ArtifactValue }
37
+ | { kind: "external"; repository?: ArtifactValue; runId?: ArtifactValue }
38
+ | { kind: "dynamic"; repository?: ArtifactValue; runId?: ArtifactValue };
39
+
40
+ export interface ArtifactDownloadDeclaration {
41
+ kind: "download";
42
+ selector: ArtifactDownloadSelector;
43
+ source: ArtifactDownloadSource;
44
+ }
45
+
46
+ export type ArtifactDeclaration = ArtifactUploadDeclaration | ArtifactDownloadDeclaration;
47
+
48
+ export interface ArtifactEdge {
49
+ kind: "artifact";
50
+ from: string;
51
+ to: string;
52
+ name: string;
53
+ producerStep: number;
54
+ consumerStep: number;
55
+ match: "exact" | "pattern" | "all" | "possible";
56
+ }
@@ -0,0 +1,39 @@
1
+ // The JS-only `createWorkflowTopologyIndex()` query index type, split out
2
+ // of `workflow-topology-types.d.ts` to stay under the 200-line file limit.
3
+
4
+ import type { ArtifactEdge } from "./workflow-topology-artifact-types";
5
+ import type {
6
+ WorkflowJobNode,
7
+ WorkflowNode,
8
+ WorkflowRunEdge,
9
+ WorkflowTopology,
10
+ } from "./workflow-topology-types";
11
+
12
+ /**
13
+ * A frozen, sorted query index over a {@link WorkflowTopology}, built
14
+ * entirely in JS from `ciTopology()`'s output via
15
+ * {@link createWorkflowTopologyIndex}. Every array-returning method throws
16
+ * `Error("unknown workflow job: <id>")` / `Error("unknown workflow: <path>")`
17
+ * for an id not present in the topology.
18
+ */
19
+ export interface WorkflowTopologyIndex {
20
+ readonly workflowsByPath: ReadonlyMap<string, Readonly<WorkflowNode>>;
21
+ readonly jobsById: ReadonlyMap<string, Readonly<WorkflowJobNode>>;
22
+ directUpstreamJobIds(jobId: string): readonly string[];
23
+ transitiveUpstreamJobIds(jobId: string): readonly string[];
24
+ directDownstreamJobIds(jobId: string): readonly string[];
25
+ transitiveDownstreamJobIds(jobId: string): readonly string[];
26
+ directCallerJobIds(workflowPath: string): readonly string[];
27
+ directCallerWorkflowPaths(workflowPath: string): readonly string[];
28
+ transitiveCallerWorkflowPaths(workflowPath: string): readonly string[];
29
+ directCalleeWorkflowPaths(workflowPath: string): readonly string[];
30
+ transitiveCalleeWorkflowPaths(workflowPath: string): readonly string[];
31
+ incomingWorkflowRunEdges(workflowPath: string): readonly Readonly<WorkflowRunEdge>[];
32
+ outgoingWorkflowRunEdges(workflowPath: string): readonly Readonly<WorkflowRunEdge>[];
33
+ directWorkflowRunSourcePaths(workflowPath: string): readonly string[];
34
+ transitiveWorkflowRunSourcePaths(workflowPath: string): readonly string[];
35
+ directWorkflowRunSubscriberPaths(workflowPath: string): readonly string[];
36
+ transitiveWorkflowRunSubscriberPaths(workflowPath: string): readonly string[];
37
+ artifactProducersForConsumerJob(jobId: string): readonly Readonly<ArtifactEdge>[];
38
+ artifactConsumersForProducerJob(jobId: string): readonly Readonly<ArtifactEdge>[];
39
+ }
@@ -0,0 +1,220 @@
1
+ "use strict";
2
+
3
+ // A pure-JS query index rebuilt from the `ciTopology()` JSON, ported from
4
+ // the original engine's `topology-index.mts` + `frozen-topology.mts`. This
5
+ // stays JS-only by design: it returns closures over frozen `Map`s, which
6
+ // doesn't cross the N-API boundary cleanly, and the native side already
7
+ // does the expensive parse/diagnose/resolve work — this index is just
8
+ // deterministic, sorted traversal over data it's handed.
9
+
10
+ function deepFrozenClone(value) {
11
+ return freezeRecursively(structuredClone(value));
12
+ }
13
+
14
+ function freezeRecursively(value) {
15
+ if (value === null || typeof value !== "object") return value;
16
+ for (const nested of Object.values(value)) freezeRecursively(nested);
17
+ return Object.freeze(value);
18
+ }
19
+
20
+ // `Object.freeze(new Map())` does NOT stop `.set()`/`.delete()` from
21
+ // working (freeze only covers own enumerable properties, not a Map's
22
+ // internal slots) — this wrapper is what actually makes `workflowsByPath`
23
+ // / `jobsById` read-only.
24
+ class FrozenReadonlyMap {
25
+ #source;
26
+
27
+ constructor(source) {
28
+ this.#source = source;
29
+ Object.freeze(this);
30
+ }
31
+
32
+ get size() {
33
+ return this.#source.size;
34
+ }
35
+
36
+ entries() {
37
+ return this.#source.entries();
38
+ }
39
+
40
+ forEach(callback, thisArg) {
41
+ for (const [key, value] of this.#source) callback.call(thisArg, value, key, this);
42
+ }
43
+
44
+ get(key) {
45
+ return this.#source.get(key);
46
+ }
47
+
48
+ has(key) {
49
+ return this.#source.has(key);
50
+ }
51
+
52
+ keys() {
53
+ return this.#source.keys();
54
+ }
55
+
56
+ values() {
57
+ return this.#source.values();
58
+ }
59
+
60
+ [Symbol.iterator]() {
61
+ return this.entries();
62
+ }
63
+
64
+ get [Symbol.toStringTag]() {
65
+ return "Map";
66
+ }
67
+ }
68
+
69
+ function adjacencyMap(ids) {
70
+ return new Map([...ids].map((id) => [id, new Set()]));
71
+ }
72
+
73
+ function artifactEdgeMap(ids) {
74
+ return new Map([...ids].map((id) => [id, []]));
75
+ }
76
+
77
+ function assertKnown(values, id, label) {
78
+ if (!values.has(id)) throw new Error(`unknown ${label}: ${id}`);
79
+ }
80
+
81
+ function query(adjacency, source, transitive) {
82
+ const direct = adjacency.get(source);
83
+ if (!transitive) return Object.freeze([...direct].sort());
84
+ const visited = new Set();
85
+ const pending = [...direct];
86
+ while (pending.length > 0) {
87
+ const current = pending.pop();
88
+ if (current === source || visited.has(current)) continue;
89
+ visited.add(current);
90
+ for (const neighbor of adjacency.get(current)) {
91
+ if (neighbor !== source && !visited.has(neighbor)) pending.push(neighbor);
92
+ }
93
+ }
94
+ return Object.freeze([...visited].sort());
95
+ }
96
+
97
+ function workflowPathFromId(id) {
98
+ const index = id.indexOf("#");
99
+ return index === -1 ? id : id.slice(0, index);
100
+ }
101
+
102
+ function artifactEdgeKey(edge) {
103
+ return [edge.name, edge.from, edge.to, edge.producerStep, edge.consumerStep, edge.match].join(
104
+ "\0",
105
+ );
106
+ }
107
+
108
+ function workflowRunEdgeKey(edge) {
109
+ const metadata = {
110
+ types: edge.types,
111
+ branches: edge.branches,
112
+ branchesIgnore: edge.branchesIgnore,
113
+ };
114
+ return [edge.from, edge.to, JSON.stringify(metadata)].join("\0");
115
+ }
116
+
117
+ /**
118
+ * Builds a frozen, sorted query index over a `WorkflowTopology` (the
119
+ * parsed object returned by `ciTopology()`, or `JSON.parse`d from
120
+ * `no-mistakes ci topology --format json`).
121
+ */
122
+ function createWorkflowTopologyIndex(topology) {
123
+ const workflowsByPath = new Map(
124
+ topology.workflows.map((workflow) => [workflow.path, deepFrozenClone(workflow)]),
125
+ );
126
+ const jobsById = new Map(topology.jobs.map((job) => [job.id, deepFrozenClone(job)]));
127
+ const upstreamJobs = adjacencyMap(jobsById.keys());
128
+ const downstreamJobs = adjacencyMap(jobsById.keys());
129
+ const callerJobs = adjacencyMap(workflowsByPath.keys());
130
+ const callers = adjacencyMap(workflowsByPath.keys());
131
+ const callees = adjacencyMap(workflowsByPath.keys());
132
+ const workflowRunSources = adjacencyMap(workflowsByPath.keys());
133
+ const workflowRunSubscribers = adjacencyMap(workflowsByPath.keys());
134
+ const artifactProducers = artifactEdgeMap(jobsById.keys());
135
+ const artifactConsumers = artifactEdgeMap(jobsById.keys());
136
+ const incomingWorkflowRunEdges = new Map([...workflowsByPath.keys()].map((path) => [path, []]));
137
+ const outgoingWorkflowRunEdges = new Map([...workflowsByPath.keys()].map((path) => [path, []]));
138
+
139
+ for (const edge of topology.edges) {
140
+ if (edge.kind === "needs") {
141
+ if (!jobsById.has(edge.from) || !jobsById.has(edge.to)) continue;
142
+ upstreamJobs.get(edge.to).add(edge.from);
143
+ downstreamJobs.get(edge.from).add(edge.to);
144
+ continue;
145
+ }
146
+ if (edge.kind === "workflow-run") {
147
+ if (!workflowsByPath.has(edge.from) || !workflowsByPath.has(edge.to)) continue;
148
+ const edgeSnapshot = deepFrozenClone(edge);
149
+ workflowRunSources.get(edge.to).add(edge.from);
150
+ workflowRunSubscribers.get(edge.from).add(edge.to);
151
+ incomingWorkflowRunEdges.get(edge.to).push(edgeSnapshot);
152
+ outgoingWorkflowRunEdges.get(edge.from).push(edgeSnapshot);
153
+ continue;
154
+ }
155
+ if (edge.kind === "artifact") {
156
+ if (!jobsById.has(edge.from) || !jobsById.has(edge.to)) continue;
157
+ const edgeSnapshot = deepFrozenClone(edge);
158
+ artifactProducers.get(edge.to).push(edgeSnapshot);
159
+ artifactConsumers.get(edge.from).push(edgeSnapshot);
160
+ continue;
161
+ }
162
+ // edge.kind === "calls"
163
+ if (!edge.local || !edge.to || !jobsById.has(edge.from) || !workflowsByPath.has(edge.to))
164
+ continue;
165
+ const callerPath = workflowPathFromId(edge.from);
166
+ if (!workflowsByPath.has(callerPath)) continue;
167
+ callerJobs.get(edge.to).add(edge.from);
168
+ callers.get(edge.to).add(callerPath);
169
+ callees.get(callerPath).add(edge.to);
170
+ }
171
+
172
+ const jobQuery = (adjacency, transitive) => (jobId) => {
173
+ assertKnown(jobsById, jobId, "workflow job");
174
+ return query(adjacency, jobId, transitive);
175
+ };
176
+ const workflowQuery = (adjacency, transitive) => (workflowPath) => {
177
+ assertKnown(workflowsByPath, workflowPath, "workflow");
178
+ return query(adjacency, workflowPath, transitive);
179
+ };
180
+ const workflowRunEdgeQuery = (edgesByPath) => (workflowPath) => {
181
+ assertKnown(workflowsByPath, workflowPath, "workflow");
182
+ return Object.freeze(
183
+ [...edgesByPath.get(workflowPath)].sort((left, right) =>
184
+ workflowRunEdgeKey(left).localeCompare(workflowRunEdgeKey(right)),
185
+ ),
186
+ );
187
+ };
188
+ const artifactEdgeQuery = (edgesByJob) => (jobId) => {
189
+ assertKnown(jobsById, jobId, "workflow job");
190
+ return Object.freeze(
191
+ [...edgesByJob.get(jobId)].sort((left, right) =>
192
+ artifactEdgeKey(left).localeCompare(artifactEdgeKey(right)),
193
+ ),
194
+ );
195
+ };
196
+
197
+ return Object.freeze({
198
+ workflowsByPath: new FrozenReadonlyMap(workflowsByPath),
199
+ jobsById: new FrozenReadonlyMap(jobsById),
200
+ directUpstreamJobIds: jobQuery(upstreamJobs, false),
201
+ transitiveUpstreamJobIds: jobQuery(upstreamJobs, true),
202
+ directDownstreamJobIds: jobQuery(downstreamJobs, false),
203
+ transitiveDownstreamJobIds: jobQuery(downstreamJobs, true),
204
+ directCallerJobIds: workflowQuery(callerJobs, false),
205
+ directCallerWorkflowPaths: workflowQuery(callers, false),
206
+ transitiveCallerWorkflowPaths: workflowQuery(callers, true),
207
+ directCalleeWorkflowPaths: workflowQuery(callees, false),
208
+ transitiveCalleeWorkflowPaths: workflowQuery(callees, true),
209
+ incomingWorkflowRunEdges: workflowRunEdgeQuery(incomingWorkflowRunEdges),
210
+ outgoingWorkflowRunEdges: workflowRunEdgeQuery(outgoingWorkflowRunEdges),
211
+ directWorkflowRunSourcePaths: workflowQuery(workflowRunSources, false),
212
+ transitiveWorkflowRunSourcePaths: workflowQuery(workflowRunSources, true),
213
+ directWorkflowRunSubscriberPaths: workflowQuery(workflowRunSubscribers, false),
214
+ transitiveWorkflowRunSubscriberPaths: workflowQuery(workflowRunSubscribers, true),
215
+ artifactProducersForConsumerJob: artifactEdgeQuery(artifactProducers),
216
+ artifactConsumersForProducerJob: artifactEdgeQuery(artifactConsumers),
217
+ });
218
+ }
219
+
220
+ module.exports = { createWorkflowTopologyIndex };
@@ -0,0 +1,179 @@
1
+ // Types for `ciTopology()` / `no-mistakes ci topology` and the JS-only
2
+ // `createWorkflowTopologyIndex()` query index built from its output.
3
+ //
4
+ // This mirrors the schema-v1 JSON contract exactly (field names, casing) —
5
+ // see `docs/node-api.md` for the stability guarantees (field order and
6
+ // array/diagnostic sort order are part of the contract).
7
+
8
+ import type { ArtifactDeclaration, ArtifactEdge } from "./workflow-topology-artifact-types";
9
+
10
+ export * from "./workflow-topology-artifact-types";
11
+ export * from "./workflow-topology-index-types";
12
+
13
+ export interface CiTopologyOptions {
14
+ /** Project root. Defaults to the current working directory. */
15
+ root?: string;
16
+ /** Path to the no-mistakes config file (e.g. .no-mistakes.yml). Auto-discovered in root if omitted. */
17
+ config?: string;
18
+ /**
19
+ * Restrict output to these workflow(s) (basename or a path inside
20
+ * `.github/workflows`) plus their transitive local reusable-workflow
21
+ * callees. Omit or pass `[]` for every workflow.
22
+ */
23
+ workflows?: string[];
24
+ }
25
+
26
+ export type JsonScalar = boolean | number | string;
27
+ export type JsonValue =
28
+ | null
29
+ | boolean
30
+ | number
31
+ | string
32
+ | JsonValue[]
33
+ | { [key: string]: JsonValue };
34
+
35
+ export type ConcurrencyValue = boolean | string;
36
+
37
+ export interface WorkflowConcurrency {
38
+ raw: { group: string; cancelInProgress?: ConcurrencyValue; queue?: string };
39
+ effective: { group: string; cancelInProgress: ConcurrencyValue; queue: string };
40
+ }
41
+
42
+ export interface WorkflowTrigger {
43
+ event: string;
44
+ config?: JsonValue;
45
+ }
46
+
47
+ export interface WorkflowCallInput {
48
+ type?: "boolean" | "number" | "string";
49
+ required: boolean;
50
+ default?: JsonScalar;
51
+ description?: string;
52
+ }
53
+
54
+ export interface WorkflowCallSecret {
55
+ required: boolean;
56
+ description?: string;
57
+ }
58
+
59
+ export interface WorkflowCallOutput {
60
+ value?: string;
61
+ description?: string;
62
+ }
63
+
64
+ export interface WorkflowCallContract {
65
+ inputs: Record<string, WorkflowCallInput>;
66
+ secrets: Record<string, WorkflowCallSecret>;
67
+ outputs: Record<string, WorkflowCallOutput>;
68
+ }
69
+
70
+ export type WorkflowNode = {
71
+ id: string;
72
+ path: string;
73
+ name: string;
74
+ triggers: WorkflowTrigger[];
75
+ jobIds: string[];
76
+ concurrency?: WorkflowConcurrency;
77
+ } & (
78
+ | { callable: true; workflowCall: WorkflowCallContract }
79
+ | { callable: false; workflowCall?: undefined }
80
+ );
81
+
82
+ export interface WorkflowStep {
83
+ index: number;
84
+ kind: "action" | "run" | "other";
85
+ id?: string;
86
+ name?: string;
87
+ condition?: string;
88
+ uses?: string;
89
+ /** Present when this step is an `actions/{upload,download}-artifact` action. */
90
+ artifact?: ArtifactDeclaration;
91
+ }
92
+
93
+ export interface WorkflowJobNode {
94
+ id: string;
95
+ workflowId: string;
96
+ key: string;
97
+ kind: "job" | "matrix-template";
98
+ name?: string;
99
+ condition?: string;
100
+ matrix?: JsonValue;
101
+ concurrency?: WorkflowConcurrency;
102
+ steps: WorkflowStep[];
103
+ }
104
+
105
+ export interface NeedsEdge {
106
+ kind: "needs";
107
+ from: string;
108
+ to: string;
109
+ }
110
+
111
+ export interface WorkflowCallEdge {
112
+ kind: "calls";
113
+ from: string;
114
+ target: string;
115
+ local: boolean;
116
+ bindings: {
117
+ inputs: Record<string, JsonScalar>;
118
+ secrets: { mode: "inherit" } | { mode: "explicit"; values: Record<string, JsonScalar> };
119
+ };
120
+ /** Present only for local (`./`) calls. */
121
+ to?: string;
122
+ }
123
+
124
+ export interface WorkflowRunEdge {
125
+ kind: "workflow-run";
126
+ from: string;
127
+ to: string;
128
+ types?: string[];
129
+ branches?: string[];
130
+ branchesIgnore?: string[];
131
+ }
132
+
133
+ export type WorkflowTopologyEdge = NeedsEdge | WorkflowCallEdge | WorkflowRunEdge | ArtifactEdge;
134
+
135
+ export type WorkflowTopologyDiagnosticCode =
136
+ | "malformed-workflow"
137
+ | "missing-needs-dependency"
138
+ | "job-dependency-cycle"
139
+ | "duplicate-step-id"
140
+ | "unknown-step-reference"
141
+ | "non-prior-step-reference"
142
+ | "duplicate-workflow-name"
143
+ | "missing-local-workflow"
144
+ | "non-callable-workflow"
145
+ | "workflow-call-cycle"
146
+ | "missing-workflow-run-source"
147
+ | "ambiguous-workflow-run-source"
148
+ | "workflow-run-cycle"
149
+ | "workflow-run-chain-limit"
150
+ | "unknown-workflow-filter"
151
+ | "invalid-workflow-filter"
152
+ | "missing-workflow-call-input"
153
+ | "unknown-workflow-call-input"
154
+ | "workflow-call-input-type-mismatch"
155
+ | "missing-workflow-call-secret"
156
+ | "unknown-workflow-call-secret"
157
+ | "unknown-workflow-call-output"
158
+ | "missing-artifact-producer"
159
+ | "ambiguous-artifact-producer"
160
+ | "artifact-resolution-limit";
161
+
162
+ export interface WorkflowTopologyDiagnostic {
163
+ severity: "error";
164
+ code: WorkflowTopologyDiagnosticCode;
165
+ message: string;
166
+ workflowPath: string;
167
+ jobId?: string;
168
+ stepIndex?: number;
169
+ callJobId?: string;
170
+ calleeWorkflowPath?: string;
171
+ }
172
+
173
+ export interface WorkflowTopology {
174
+ schemaVersion: 1;
175
+ workflows: WorkflowNode[];
176
+ jobs: WorkflowJobNode[];
177
+ edges: WorkflowTopologyEdge[];
178
+ diagnostics: WorkflowTopologyDiagnostic[];
179
+ }