no-mistakes 0.48.0 → 0.48.2

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
@@ -70,7 +70,7 @@ const {
70
70
  changedFiles: ["src/utils.mts"],
71
71
  });
72
72
  // Complete changed-file inventory, including paths that selected no tests.
73
- console.log(plan.changed_files);
73
+ console.log(plan.changedFiles);
74
74
  const targetCommands = await testsTargets({
75
75
  root: process.cwd(),
76
76
  framework: "vitest",
package/index.js CHANGED
@@ -5,6 +5,8 @@
5
5
  const native = require(process.env.NO_MISTAKES_TEST_NAPI_ADDON_PATH || "./bin/no-mistakes.node");
6
6
  const planning = require("./planning");
7
7
  const { createWorkflowTopologyIndex } = require("./workflow-topology-index");
8
+ const fs = require("node:fs");
9
+ const path = require("node:path");
8
10
 
9
11
  async function callJson(fn, options) {
10
12
  const input = Buffer.from(JSON.stringify(options || {}));
@@ -59,19 +61,90 @@ const jsonApis = createJsonApis({
59
61
  symbols: "symbolsJson",
60
62
  });
61
63
 
64
+ const PLAN_INPUT_REPORTS = new Set(["testsComment", "testsGraph", "testsGraphMermaid"]);
65
+ const CAMELIZE_REPORTS = new Set(["testsPlan", "testsImpact", "testsTargets", "testsGraph"]);
66
+
67
+ async function analyzeProject(options = {}) {
68
+ const request = { ...options };
69
+ const generatedDirs = [];
70
+ try {
71
+ if (Array.isArray(request.reports)) {
72
+ request.reports = await Promise.all(
73
+ request.reports.map(async (report) => {
74
+ if (report.type === "testsWhy") {
75
+ const prepared = await planning.prepareWhyPlan(report);
76
+ if (prepared.generatedDir) generatedDirs.push(prepared.generatedDir);
77
+ return prepared.request;
78
+ }
79
+ return PLAN_INPUT_REPORTS.has(report.type)
80
+ ? await planning.decamelizePlanOptions(report)
81
+ : report;
82
+ }),
83
+ );
84
+ }
85
+ const result = await jsonApis.analyzeProject(request);
86
+ for (const report of result.reports || []) {
87
+ if (report.type === "testsWhy") {
88
+ report.result = planning.camelizeWhy(report.result);
89
+ } else if (CAMELIZE_REPORTS.has(report.type)) {
90
+ report.result = planning.camelizeValue(report.result);
91
+ }
92
+ }
93
+ return result;
94
+ } finally {
95
+ await Promise.all(generatedDirs.map((dir) => planning.removeGeneratedDir(dir)));
96
+ }
97
+ }
98
+
99
+ const topologyMemo = new Map();
100
+
101
+ async function ciTopology(options) {
102
+ const root = path.resolve((options && options.root) || process.cwd());
103
+ const configPath = path.resolve(root, (options && options.config) || ".no-mistakes.yml");
104
+ let mtime = 0;
105
+ try {
106
+ mtime = fs.statSync(configPath).mtimeMs;
107
+ } catch {
108
+ mtime = 0;
109
+ }
110
+ const workflows = JSON.stringify(
111
+ []
112
+ .concat((options && options.workflows) || [])
113
+ .map(String)
114
+ .sort(),
115
+ );
116
+ const identity = `${root}\0${configPath}\0`;
117
+ const key = `${identity}${mtime}\0${workflows}`;
118
+ const stale = [];
119
+ for (const memoKey of topologyMemo.keys()) {
120
+ if (!memoKey.startsWith(identity)) continue;
121
+ const memoMtime = memoKey.slice(identity.length).split("\0")[0];
122
+ if (memoMtime !== String(mtime)) stale.push(memoKey);
123
+ }
124
+ for (const memoKey of stale) topologyMemo.delete(memoKey);
125
+ const cached = topologyMemo.get(key);
126
+ if (cached) return cached.then((value) => structuredClone(value));
127
+ const pending = jsonApis.ciTopology({ ...options, root }).catch((error) => {
128
+ topologyMemo.delete(key);
129
+ throw error;
130
+ });
131
+ topologyMemo.set(key, pending);
132
+ return pending.then((value) => structuredClone(value));
133
+ }
134
+
62
135
  async function version() {
63
136
  return native.version();
64
137
  }
65
138
 
66
139
  module.exports.createWorkflowTopologyIndex = createWorkflowTopologyIndex;
67
140
  module.exports.version = version;
68
- module.exports.analyzeProject = jsonApis.analyzeProject;
141
+ module.exports.analyzeProject = analyzeProject;
69
142
  module.exports.callSites = jsonApis.callSites;
70
143
  module.exports.check = jsonApis.check;
71
144
  module.exports.resolveConfig = jsonApis.resolveConfig;
72
145
  module.exports.ciEnv = jsonApis.ciEnv;
73
146
  module.exports.ciImpact = jsonApis.ciImpact;
74
- module.exports.ciTopology = jsonApis.ciTopology;
147
+ module.exports.ciTopology = ciTopology;
75
148
  module.exports.dataPw = jsonApis.dataPw;
76
149
  module.exports.deadExports = jsonApis.deadExports;
77
150
  module.exports.dependencies = jsonApis.dependencies;
@@ -11,6 +11,8 @@ export interface InvocationOptions {
11
11
  * `0` uses the CPU count, matching CLI `--jobs 0`.
12
12
  */
13
13
  jobs?: number | null;
14
+ /** `ci` sets unbounded command and lock timeouts. CLI `--profile ci` does the same. */
15
+ profile?: "ci";
14
16
  }
15
17
 
16
18
  export type WithInvocationOptions<T> = T & InvocationOptions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "no-mistakes",
3
- "version": "0.48.0",
3
+ "version": "0.48.2",
4
4
  "description": "Static codebase analysis tools for TS/JS dependencies, dependents, and symbols",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -18,6 +18,7 @@
18
18
  "index.mjs",
19
19
  "planning.js",
20
20
  "workflow-topology-index.js",
21
+ "workflow-topology-index-helpers.js",
21
22
  "scripts/install.js",
22
23
  "scripts/install/",
23
24
  "README.md",
package/planning.js CHANGED
@@ -1,5 +1,8 @@
1
1
  "use strict";
2
2
 
3
+ const fs = require("node:fs/promises");
4
+ const os = require("node:os");
5
+ const path = require("node:path");
3
6
  const native = require(process.env.NO_MISTAKES_TEST_NAPI_ADDON_PATH || "./bin/no-mistakes.node");
4
7
 
5
8
  async function callJson(fn, options) {
@@ -16,13 +19,109 @@ function createJsonApis(descriptors) {
16
19
  );
17
20
  }
18
21
 
22
+ function camelizeKey(key) {
23
+ return key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
24
+ }
25
+
26
+ function decamelizeKey(key) {
27
+ return key.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
28
+ }
29
+
30
+ function mapKeys(value, mapKey) {
31
+ if (Array.isArray(value)) return value.map((item) => mapKeys(item, mapKey));
32
+ if (value && typeof value === "object") {
33
+ return Object.fromEntries(
34
+ Object.entries(value).map(([key, nested]) => [mapKey(key), mapKeys(nested, mapKey)]),
35
+ );
36
+ }
37
+ return value;
38
+ }
39
+
40
+ function camelizeValue(value) {
41
+ return mapKeys(value, camelizeKey);
42
+ }
43
+
44
+ function decamelizeValue(value) {
45
+ return mapKeys(value, decamelizeKey);
46
+ }
47
+
48
+ function loadPlanJson(planJson) {
49
+ let parsed = planJson;
50
+ if (typeof parsed === "string") {
51
+ try {
52
+ parsed = JSON.parse(parsed);
53
+ } catch {
54
+ return planJson;
55
+ }
56
+ }
57
+ if (parsed && typeof parsed === "object") {
58
+ return decamelizeValue(parsed);
59
+ }
60
+ return planJson;
61
+ }
62
+
63
+ async function readPlanFile(planPath) {
64
+ try {
65
+ return JSON.parse(await fs.readFile(planPath, "utf8"));
66
+ } catch {
67
+ return undefined;
68
+ }
69
+ }
70
+
71
+ async function decamelizePlanOptions(options = {}) {
72
+ const next = { ...options };
73
+ if (next.planJson != null) {
74
+ next.planJson = loadPlanJson(next.planJson);
75
+ } else if (typeof next.plan === "string") {
76
+ const document = await readPlanFile(next.plan);
77
+ if (document !== undefined) {
78
+ next.planJson = loadPlanJson(document);
79
+ delete next.plan;
80
+ }
81
+ }
82
+ return next;
83
+ }
84
+
85
+ async function prepareWhyPlan(options = {}) {
86
+ const next = { ...options };
87
+ let document = next.planJson;
88
+ if (document == null && typeof next.plan === "string") {
89
+ document = await readPlanFile(next.plan);
90
+ if (document === undefined) return { request: next };
91
+ }
92
+ if (document == null) return { request: next };
93
+ const generatedDir = await fs.mkdtemp(path.join(os.tmpdir(), "no-mistakes-why-"));
94
+ await fs.writeFile(path.join(generatedDir, "plan.json"), JSON.stringify(loadPlanJson(document)));
95
+ next.plan = path.join(generatedDir, "plan.json");
96
+ delete next.planJson;
97
+ return { request: next, generatedDir };
98
+ }
99
+
100
+ async function materializeWhyPlan(options = {}) {
101
+ return (await prepareWhyPlan(options)).request;
102
+ }
103
+
104
+ async function removeGeneratedDir(generatedDir) {
105
+ if (!generatedDir) return;
106
+ await fs.rm(generatedDir, { recursive: true, force: true }).catch(() => {});
107
+ }
108
+
109
+ function camelizeWhy(value) {
110
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
111
+ return camelizeValue(value);
112
+ }
113
+ return Object.fromEntries(
114
+ Object.entries(value).map(([key, nested]) => [key, camelizeValue(nested)]),
115
+ );
116
+ }
117
+
19
118
  async function testsComment(options) {
20
- const input = Buffer.from(JSON.stringify(options || {}));
119
+ const input = Buffer.from(JSON.stringify(await decamelizePlanOptions(options)));
21
120
  return String(await native.testsCommentMarkdown(input));
22
121
  }
23
122
 
24
123
  async function testsGraphMermaid(options) {
25
- const input = Buffer.from(JSON.stringify(options || {}));
124
+ const input = Buffer.from(JSON.stringify(await decamelizePlanOptions(options)));
26
125
  return String(await native.testsGraphMermaid(input));
27
126
  }
28
127
 
@@ -44,8 +143,44 @@ const jsonApis = createJsonApis({
44
143
  testsWhy: "testsWhyJson",
45
144
  });
46
145
 
146
+ async function testsPlan(options) {
147
+ return camelizeValue(await jsonApis.testsPlan(options));
148
+ }
149
+
150
+ async function testsImpact(options) {
151
+ return camelizeValue(await jsonApis.testsImpact(options));
152
+ }
153
+
154
+ async function testsTargets(options) {
155
+ return camelizeValue(await jsonApis.testsTargets(options));
156
+ }
157
+
158
+ async function testsWhy(options) {
159
+ const { request, generatedDir } = await prepareWhyPlan(options);
160
+ try {
161
+ return camelizeWhy(await jsonApis.testsWhy(request));
162
+ } finally {
163
+ await removeGeneratedDir(generatedDir);
164
+ }
165
+ }
166
+
167
+ async function testsGraph(options) {
168
+ return camelizeValue(await jsonApis.testsGraph(await decamelizePlanOptions(options)));
169
+ }
170
+
47
171
  module.exports = {
172
+ camelizeValue,
173
+ camelizeWhy,
174
+ decamelizePlanOptions,
175
+ materializeWhyPlan,
176
+ prepareWhyPlan,
177
+ removeGeneratedDir,
48
178
  testsComment,
49
179
  testsGraphMermaid,
50
180
  ...jsonApis,
181
+ testsGraph,
182
+ testsImpact,
183
+ testsPlan,
184
+ testsTargets,
185
+ testsWhy,
51
186
  };
package/test-types.d.ts CHANGED
@@ -13,6 +13,8 @@ export type TestPlanFramework =
13
13
  | "php"
14
14
  | "java"
15
15
  | "kotlin"
16
+ | "elixir"
17
+ | "dart"
16
18
  | "jest";
17
19
 
18
20
  interface TestsPlanOptionsBase {
@@ -41,6 +43,8 @@ interface TestsPlanOptionsBase {
41
43
  globalConfigFallback?: boolean;
42
44
  /** Include the markdown PR comment as `comment` on the returned plan. */
43
45
  includeComment?: boolean;
46
+ /** Keep only selected tests whose relative path matches one of these globs. */
47
+ includeGlob?: string[];
44
48
  }
45
49
 
46
50
  /**
@@ -90,18 +94,12 @@ export interface TestsTargetsOptions {
90
94
 
91
95
  export interface TestPlan {
92
96
  /** Complete deterministic changed-file inventory, relative to the request root. */
93
- changed_files: string[];
94
- /** @deprecated Prefer `changedFiles` once both keys are present. */
95
- changedFiles?: string[];
96
- selected_tests: SelectedTest[];
97
- selectedTests?: SelectedTest[];
97
+ changedFiles: string[];
98
+ selectedTests: SelectedTest[];
98
99
  groups?: TestPlanGroup[];
99
100
  warnings: TestPlanWarning[];
100
- fallback_triggered: boolean;
101
- fallbackTriggered?: boolean;
102
- fallback_reason?: string | null;
101
+ fallbackTriggered: boolean;
103
102
  fallbackReason?: string | null;
104
- execution_targets?: GroupedExecutionTarget[];
105
103
  executionTargets?: GroupedExecutionTarget[];
106
104
  comment?: string | null;
107
105
  }
@@ -110,16 +108,15 @@ export interface GroupedExecutionTarget {
110
108
  runner: TestPlanFramework;
111
109
  config?: string | null;
112
110
  project?: string | null;
113
- baseCommand?: string[];
114
- base_command: string[];
115
- runnerArgs?: string[];
116
- runner_args: string[];
117
- testFiles?: string[];
118
- test_files: string[];
111
+ /** Path-prefix display name, such as a Swift package root. */
112
+ name?: string;
113
+ baseCommand: string[];
114
+ runnerArgs: string[];
115
+ testFiles: string[];
119
116
  }
120
117
 
121
118
  export interface SelectedTest {
122
- test_file: string;
119
+ testFile: string;
123
120
  confidence: "low" | "medium" | "high";
124
121
  reasons: ImpactReason[];
125
122
  targets?: TestExecutionTarget[];
@@ -131,24 +128,26 @@ export interface TestExecutionTarget {
131
128
  /** True when config is a Vitest workspace/project-array source rendered with --workspace. */
132
129
  workspace?: boolean;
133
130
  project?: string | null;
134
- base_command: string[];
135
- runner_args: string[];
131
+ /** Path-prefix display name, such as a Swift package root. */
132
+ name?: string;
133
+ baseCommand: string[];
134
+ runnerArgs: string[];
136
135
  }
137
136
 
138
137
  export interface ImpactReason {
139
- changed_file: string;
138
+ changedFile: string;
140
139
  path: string[];
141
140
  via: string[];
142
141
  /** When present, aligns index-for-index with `via`. */
143
- via_details?: Array<ImpactEdgeDetail | null>;
142
+ viaDetails?: Array<ImpactEdgeDetail | null>;
144
143
  }
145
144
 
146
145
  export type ImpactEdgeDetail = ResourceImpactEdgeDetail | VitestSetupImpactEdgeDetail;
147
146
 
148
147
  export interface ResourceImpactEdgeDetail {
149
148
  type: "resource";
150
- consumer_file: string;
151
- call_sites: ResourceCallSite[];
149
+ consumerFile: string;
150
+ callSites: ResourceCallSite[];
152
151
  }
153
152
 
154
153
  export interface VitestSetupImpactEdgeDetail {
@@ -157,7 +156,7 @@ export interface VitestSetupImpactEdgeDetail {
157
156
  }
158
157
 
159
158
  export interface ResourceCallSite {
160
- call_kind: ResourceCallKind;
159
+ callKind: ResourceCallKind;
161
160
  line: number;
162
161
  }
163
162
 
@@ -211,6 +210,7 @@ export interface TestsWhyOptions {
211
210
  test: string;
212
211
  changed?: string;
213
212
  plan?: string;
213
+ planJson?: SavedTestPlan | string;
214
214
  }
215
215
 
216
216
  export interface WhyStep {
@@ -219,10 +219,8 @@ export interface WhyStep {
219
219
  detail?: ImpactEdgeDetail | null;
220
220
  }
221
221
 
222
- /** A current or pre-`changed_files` plan accepted by saved-plan document APIs. */
223
- export type SavedTestPlan = Omit<TestPlan, "changed_files"> & {
224
- changed_files?: string[];
225
- };
222
+ /** A current or pre-`changedFiles` plan accepted by saved-plan document APIs. */
223
+ export type SavedTestPlan = TestPlan;
226
224
 
227
225
  export interface TestsPlanDocumentOptions {
228
226
  plan?: string;
@@ -33,6 +33,8 @@ export type Relationship =
33
33
  | "php"
34
34
  | "java"
35
35
  | "kotlin"
36
+ | "elixir"
37
+ | "dart"
36
38
  | "resource"
37
39
  | "trpc"
38
40
  | "all";
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+
3
+ function stepMatches(selector, step) {
4
+ return (
5
+ (selector.id === undefined || selector.id === step.id) &&
6
+ (selector.uses === undefined || selector.uses === step.uses) &&
7
+ (selector.name === undefined || selector.name === step.name)
8
+ );
9
+ }
10
+
11
+ function directCallerJobIdsForUses(jobsById, uses) {
12
+ const ids = [];
13
+ for (const job of jobsById.values()) {
14
+ if (job.steps.some((step) => step.uses === uses)) ids.push(job.id);
15
+ }
16
+ return Object.freeze(ids.sort());
17
+ }
18
+
19
+ function stepOrderIndexes(job, selectors) {
20
+ return Object.freeze(
21
+ selectors.map((selector) => {
22
+ const step = job.steps.find((candidate) => stepMatches(selector, candidate));
23
+ return step == null ? -1 : step.index;
24
+ }),
25
+ );
26
+ }
27
+
28
+ module.exports = { directCallerJobIdsForUses, stepOrderIndexes };
@@ -24,6 +24,13 @@ export interface WorkflowTopologyIndex {
24
24
  directDownstreamJobIds(jobId: string): readonly string[];
25
25
  transitiveDownstreamJobIds(jobId: string): readonly string[];
26
26
  directCallerJobIds(workflowPath: string): readonly string[];
27
+ /** Job ids whose steps `uses:` this action or workflow path. */
28
+ directCallerJobIdsForUses(uses: string): readonly string[];
29
+ /** Step indexes for `selectors` in `jobId`, or `-1` when a selector is missing. */
30
+ stepOrderIndexes(
31
+ jobId: string,
32
+ selectors: ReadonlyArray<{ id?: string; uses?: string; name?: string }>,
33
+ ): readonly number[];
27
34
  directCallerWorkflowPaths(workflowPath: string): readonly string[];
28
35
  transitiveCallerWorkflowPaths(workflowPath: string): readonly string[];
29
36
  directCalleeWorkflowPaths(workflowPath: string): readonly string[];
@@ -1,5 +1,10 @@
1
1
  "use strict";
2
2
 
3
+ const {
4
+ directCallerJobIdsForUses,
5
+ stepOrderIndexes,
6
+ } = require("./workflow-topology-index-helpers");
7
+
3
8
  // A pure-JS query index rebuilt from the `ciTopology()` JSON, ported from
4
9
  // the original engine's `topology-index.mts` + `frozen-topology.mts`. This
5
10
  // stays JS-only by design: it returns closures over frozen `Map`s, which
@@ -214,6 +219,11 @@ function createWorkflowTopologyIndex(topology) {
214
219
  transitiveWorkflowRunSubscriberPaths: workflowQuery(workflowRunSubscribers, true),
215
220
  artifactProducersForConsumerJob: artifactEdgeQuery(artifactProducers),
216
221
  artifactConsumersForProducerJob: artifactEdgeQuery(artifactConsumers),
222
+ directCallerJobIdsForUses: (uses) => directCallerJobIdsForUses(jobsById, uses),
223
+ stepOrderIndexes: (jobId, selectors) => {
224
+ assertKnown(jobsById, jobId, "workflow job");
225
+ return stepOrderIndexes(jobsById.get(jobId), selectors);
226
+ },
217
227
  });
218
228
  }
219
229