no-mistakes 0.51.3 → 0.52.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
@@ -1,7 +1,12 @@
1
1
  # no-mistakes
2
2
 
3
3
  Unified static codebase intelligence CLI for TS/JS module graphs, symbols,
4
- React traits, queue hops, server routes, and project checks.
4
+ React traits, queue hops, server routes, configured language graphs, and project
5
+ checks.
6
+
7
+ Use it when an agent needs a deterministic impact map or focused test plan in
8
+ the same process as a Node tool. The async N-API facade avoids subprocess
9
+ parsing and reuses one prepared analysis for related reports.
5
10
 
6
11
  ```bash
7
12
  npm install --save-dev no-mistakes
@@ -14,6 +19,26 @@ npx no-mistakes check --json
14
19
 
15
20
  Programmatic Node usage loads the same Rust analysis through N-API:
16
21
 
22
+ ```js
23
+ const { analyzeProject } = require("no-mistakes");
24
+
25
+ const report = await analyzeProject({
26
+ root: process.cwd(),
27
+ reports: [
28
+ { type: "dependents", files: ["src/api.mts"] },
29
+ { type: "symbols", files: ["src/api.mts"], include: "both" },
30
+ {
31
+ type: "testsPlan",
32
+ framework: "vitest",
33
+ changedFiles: ["src/api.mts"],
34
+ },
35
+ ],
36
+ });
37
+ ```
38
+
39
+ Use `analyzeProject()` when reports share a root and configuration. Dedicated
40
+ functions remain convenient for a single query:
41
+
17
42
  ````js
18
43
  const {
19
44
  dependencies,
@@ -66,7 +91,7 @@ const {
66
91
  });
67
92
  const plan = await testsPlan({
68
93
  root: process.cwd(),
69
- framework: "vitest", // also python, go, cargo, rails, php, playwright, dotnet, swift
94
+ framework: "vitest", // also jest, python, go, cargo, rails, php, java, kotlin, elixir, dart, playwright, dotnet, swift
70
95
  changedFiles: ["src/utils.mts"],
71
96
  });
72
97
  // Complete changed-file inventory, including paths that selected no tests.
@@ -155,4 +180,5 @@ npx no-mistakes rust-max-lines-per-file crates/*/src crates/*/tests
155
180
 
156
181
  See the full documentation in [docs/](../../docs/README.md), the
157
182
  [CLI command index](../../docs/cli/README.md), and the
158
- [Node/N-API guide](../../docs/node-api.md).
183
+ [Node/N-API guide](../../docs/node-api.md). Agents can use the compact
184
+ [packaged skill](../../skills/no-mistakes/SKILL.md).
package/index.d.ts CHANGED
@@ -53,6 +53,8 @@ import type {
53
53
  TraverseOptions,
54
54
  WhyStep,
55
55
  WithInvocationOptions,
56
+ WritePlanningImpactArtifactsOptions,
57
+ PlanningImpactArtifacts,
56
58
  } from "./types";
57
59
 
58
60
  export * from "./types";
@@ -68,6 +70,9 @@ export function related(options: WithInvocationOptions<TraverseOptions>): Promis
68
70
  export function analyzeProject(
69
71
  options: WithInvocationOptions<AnalyzeProjectOptions>,
70
72
  ): Promise<AnalyzeProjectResult>;
73
+ export function writePlanningImpactArtifacts(
74
+ options: WithInvocationOptions<WritePlanningImpactArtifactsOptions>,
75
+ ): Promise<PlanningImpactArtifacts>;
71
76
  export function symbols(
72
77
  options: WithInvocationOptions<SymbolsSignatureImpactOptions>,
73
78
  ): Promise<SignatureImpactResult>;
package/index.js CHANGED
@@ -4,6 +4,8 @@
4
4
  // overwriting the package's checked-in install placeholder.
5
5
  const native = require(process.env.NO_MISTAKES_TEST_NAPI_ADDON_PATH || "./bin/no-mistakes.node");
6
6
  const planning = require("./planning");
7
+ const { writePlanningImpactArtifacts: writeArtifacts } = require("./planning-impact-artifacts");
8
+ const { createPlanningArtifactLock } = require("./planning-impact-artifacts-lock");
7
9
  const { createWorkflowTopologyIndex } = require("./workflow-topology-index");
8
10
  const fs = require("node:fs");
9
11
  const path = require("node:path");
@@ -64,6 +66,7 @@ const jsonApis = createJsonApis({
64
66
 
65
67
  const PLAN_INPUT_REPORTS = new Set(["testsComment", "testsGraph", "testsGraphMermaid"]);
66
68
  const CAMELIZE_REPORTS = new Set(["testsPlan", "testsImpact", "testsTargets", "testsGraph"]);
69
+ const acquirePlanningArtifactLock = createPlanningArtifactLock(native);
67
70
 
68
71
  async function analyzeProject(options = {}) {
69
72
  const request = { ...options };
@@ -97,6 +100,20 @@ async function analyzeProject(options = {}) {
97
100
  }
98
101
  }
99
102
 
103
+ async function writePlanningImpactArtifacts(options) {
104
+ return writeArtifacts(
105
+ options,
106
+ analyzeProject,
107
+ async (from, to) => {
108
+ if (await native.renameNoReplace(from, to)) return true;
109
+ const error = new Error("output directory path changed during planning artifact generation");
110
+ error.code = "EEXIST";
111
+ throw error;
112
+ },
113
+ acquirePlanningArtifactLock,
114
+ );
115
+ }
116
+
100
117
  const topologyMemo = new Map();
101
118
 
102
119
  async function ciTopology(options) {
@@ -140,6 +157,7 @@ async function version() {
140
157
  module.exports.createWorkflowTopologyIndex = createWorkflowTopologyIndex;
141
158
  module.exports.version = version;
142
159
  module.exports.analyzeProject = analyzeProject;
160
+ module.exports.writePlanningImpactArtifacts = writePlanningImpactArtifacts;
143
161
  module.exports.callSites = jsonApis.callSites;
144
162
  module.exports.check = jsonApis.check;
145
163
  module.exports.resolveConfig = jsonApis.resolveConfig;
package/index.mjs CHANGED
@@ -60,6 +60,7 @@ export const {
60
60
  testsWhy,
61
61
  validateMermaidMarkdown,
62
62
  version,
63
+ writePlanningImpactArtifacts,
63
64
  } = cjs;
64
65
 
65
66
  export default cjs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "no-mistakes",
3
- "version": "0.51.3",
3
+ "version": "0.52.2",
4
4
  "description": "Static codebase analysis tools for TS/JS dependencies, dependents, and symbols",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -16,6 +16,12 @@
16
16
  "*.d.ts",
17
17
  "index.js",
18
18
  "index.mjs",
19
+ "planning-impact-artifacts.js",
20
+ "planning-impact-artifacts-errors.js",
21
+ "planning-impact-artifacts-files.js",
22
+ "planning-impact-artifacts-inputs.js",
23
+ "planning-impact-artifacts-lock.js",
24
+ "planning-impact-artifacts-privacy.js",
19
25
  "planning.js",
20
26
  "workflow-topology-index.js",
21
27
  "workflow-topology-index-helpers.js",
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+
3
+ const RESTORATION_FAILURE = Symbol("planning artifact output restoration failure");
4
+
5
+ function outputRestorationFailure(restoreError, parked, outputPath, updateError, restored = false) {
6
+ const recoveryPath = restored ? outputPath : parked;
7
+ const error = new AggregateError(
8
+ updateError ? [restoreError, updateError] : [restoreError],
9
+ restored
10
+ ? `planning artifact output directory failed validation after restoration at ${recoveryPath}`
11
+ : `planning artifact output restoration failed; recover the parked directory at ${recoveryPath}`,
12
+ { cause: restoreError },
13
+ );
14
+ error.code = restoreError.code;
15
+ error[RESTORATION_FAILURE] = true;
16
+ return error;
17
+ }
18
+
19
+ function isOutputRestorationFailure(error) {
20
+ return error?.[RESTORATION_FAILURE] === true;
21
+ }
22
+
23
+ function preserveFailureReportingError(originalError, failureReportingError) {
24
+ if (
25
+ (typeof originalError === "object" && originalError !== null) ||
26
+ typeof originalError === "function"
27
+ ) {
28
+ try {
29
+ Object.defineProperty(originalError, "failureReportingError", {
30
+ configurable: true,
31
+ value: failureReportingError,
32
+ });
33
+ return originalError;
34
+ } catch {
35
+ // Fall through when a caller throws a frozen or otherwise non-extensible value.
36
+ }
37
+ }
38
+ return new AggregateError(
39
+ [originalError, failureReportingError],
40
+ `planning artifact generation failed and failure reporting could not restore the output directory: ${failureReportingError.message}`,
41
+ { cause: originalError },
42
+ );
43
+ }
44
+
45
+ module.exports = {
46
+ isOutputRestorationFailure,
47
+ outputRestorationFailure,
48
+ preserveFailureReportingError,
49
+ };
@@ -0,0 +1,211 @@
1
+ "use strict";
2
+ const { lstat, open, realpath, rename, rm, stat } = require("node:fs/promises");
3
+ const { constants } = require("node:fs");
4
+ const { basename, dirname, join } = require("node:path");
5
+ const { randomUUID } = require("node:crypto");
6
+ const { outputRestorationFailure } = require("./planning-impact-artifacts-errors");
7
+ const privacy = require("./planning-impact-artifacts-privacy");
8
+ const MANIFEST_OPEN_FLAGS =
9
+ constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
10
+
11
+ async function validateOutputDirectory(outputDirectory) {
12
+ const directory = await realpath(outputDirectory);
13
+ const metadata = await stat(directory);
14
+ if (!privacy.isPrivateDirectory(metadata)) {
15
+ throw privacy.privatePermissionError("output directory", 0o700);
16
+ }
17
+ const parentMetadata = await stat(dirname(directory));
18
+ if (!parentMetadata.isDirectory() || parentMetadata.dev !== metadata.dev) {
19
+ throw new Error(
20
+ "output directory must share its parent's filesystem for atomic planning artifact publication",
21
+ );
22
+ }
23
+ let handle;
24
+ try {
25
+ handle = await open(directory, "r");
26
+ } catch (error) {
27
+ if (!["EISDIR", "EPERM"].includes(error.code)) throw error;
28
+ }
29
+ const output = { path: directory, identity: metadata, handle };
30
+ try {
31
+ await assertOutputDirectory(output);
32
+ return output;
33
+ } catch (error) {
34
+ if (handle) await handle.close().catch(() => {});
35
+ throw error;
36
+ }
37
+ }
38
+
39
+ async function validateManifest(output, manifestPath) {
40
+ const manifest = await resolveManifestPath(output, manifestPath);
41
+ const handle = await open(manifest, MANIFEST_OPEN_FLAGS);
42
+ try {
43
+ const metadata = await handle.stat();
44
+ if (!metadata.isFile()) throw new Error("changed-files manifest must be a regular file");
45
+ const pathMetadata = await lstat(manifest);
46
+ if (
47
+ metadata.nlink !== 1 ||
48
+ pathMetadata.nlink !== 1 ||
49
+ !pathMetadata.isFile() ||
50
+ !sameFileIdentity(metadata, pathMetadata)
51
+ ) {
52
+ throw new Error("changed-files manifest changed during validation");
53
+ }
54
+ await assertOutputDirectory(output);
55
+ return { path: manifest, handle };
56
+ } catch (error) {
57
+ await handle.close().catch(() => {});
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ async function resolveManifestPath(output, manifestPath) {
63
+ await assertOutputDirectory(output);
64
+ const manifest = await realpath(manifestPath);
65
+ if (dirname(manifest) !== output.path) {
66
+ throw new Error("manifest must be inside the private output directory");
67
+ }
68
+ return manifest;
69
+ }
70
+
71
+ async function publishArtifact(output, name, contents) {
72
+ await assertOutputDirectory(output);
73
+ const staged = join(output.path, `.${name}.${randomUUID()}.tmp`);
74
+ const destination = join(output.path, name);
75
+ let file;
76
+ let identity;
77
+ try {
78
+ file = await open(staged, "wx", 0o600);
79
+ try {
80
+ await file.chmod(0o600);
81
+ await file.writeFile(contents);
82
+ identity = await file.stat();
83
+ if (!privacy.isPrivateRegularFile(identity) || identity.nlink !== 1) {
84
+ throw privacy.privatePermissionError(
85
+ `staged artifact is not a private regular file: ${name}`,
86
+ 0o600,
87
+ );
88
+ }
89
+ } finally {
90
+ await file.close();
91
+ file = undefined;
92
+ }
93
+ const stagedMetadata = await lstat(staged);
94
+ if (!sameFileIdentity(identity, stagedMetadata) || stagedMetadata.nlink !== 1) {
95
+ throw new Error(`staged artifact changed before publication: ${name}`);
96
+ }
97
+ await assertOutputDirectory(output);
98
+ await rename(staged, destination);
99
+ await assertOutputDirectory(output);
100
+ const published = await lstat(destination);
101
+ if (
102
+ !sameFileIdentity(identity, published) ||
103
+ !privacy.isPrivateRegularFile(published) ||
104
+ published.nlink !== 1
105
+ ) {
106
+ await removePath(output, destination).catch(() => {});
107
+ throw new Error(`published artifact changed during publication: ${name}`);
108
+ }
109
+ } catch (error) {
110
+ if (file) await file.close().catch(() => {});
111
+ await removePath(output, staged).catch(() => {});
112
+ throw error;
113
+ }
114
+ }
115
+
116
+ async function removeArtifact(output, name) {
117
+ return removePath(output, join(output.path, name));
118
+ }
119
+
120
+ async function updateOutputDirectory(output, update, renameNoReplace) {
121
+ await assertOutputDirectory(output);
122
+ const parked = join(
123
+ dirname(output.path),
124
+ `.${basename(output.path)}.planning-impact-${randomUUID()}`,
125
+ );
126
+ await rename(output.path, parked);
127
+ const privateOutput = { ...output, path: parked };
128
+ let result;
129
+ let updateError;
130
+ try {
131
+ await assertOutputDirectory(privateOutput);
132
+ result = await update(privateOutput);
133
+ } catch (error) {
134
+ updateError = error;
135
+ }
136
+ let restoreError;
137
+ let restored = false;
138
+ try {
139
+ await assertOutputDirectory(privateOutput);
140
+ await assertPathVacant(output.path);
141
+ if (!(await renameNoReplace(parked, output.path))) {
142
+ const error = new Error("output directory path changed during planning artifact generation");
143
+ error.code = "EEXIST";
144
+ throw error;
145
+ }
146
+ restored = true;
147
+ await assertOutputDirectory(output);
148
+ } catch (error) {
149
+ restoreError = error;
150
+ }
151
+ if (restoreError)
152
+ throw outputRestorationFailure(restoreError, parked, output.path, updateError, restored);
153
+ if (updateError) throw updateError;
154
+ return result;
155
+ }
156
+
157
+ async function removePath(output, target) {
158
+ await assertOutputDirectory(output);
159
+ let metadata;
160
+ try {
161
+ metadata = await lstat(target);
162
+ } catch (error) {
163
+ if (error.code === "ENOENT") return;
164
+ throw error;
165
+ }
166
+ if (metadata.isDirectory()) return;
167
+ await rm(target, { force: true });
168
+ await assertOutputDirectory(output);
169
+ }
170
+
171
+ async function assertOutputDirectory(output) {
172
+ if ((await realpath(output.path)) !== output.path) {
173
+ throw new Error("output directory path changed during planning artifact generation");
174
+ }
175
+ const metadata = await stat(output.path);
176
+ if (!privacy.isPrivateDirectory(metadata) || !sameFileIdentity(output.identity, metadata)) {
177
+ throw new Error("output directory changed during planning artifact generation");
178
+ }
179
+ if (output.handle) {
180
+ const descriptorMetadata = await output.handle.stat();
181
+ if (
182
+ !privacy.isPrivateDirectory(descriptorMetadata) ||
183
+ !sameFileIdentity(output.identity, descriptorMetadata)
184
+ ) {
185
+ throw new Error("output directory descriptor changed during planning artifact generation");
186
+ }
187
+ }
188
+ }
189
+
190
+ async function assertPathVacant(path) {
191
+ try {
192
+ await lstat(path);
193
+ } catch (error) {
194
+ if (error.code === "ENOENT") return;
195
+ throw error;
196
+ }
197
+ throw new Error("output directory path changed during planning artifact generation");
198
+ }
199
+
200
+ function sameFileIdentity(left, right) {
201
+ return left.dev === right.dev && left.ino === right.ino;
202
+ }
203
+
204
+ module.exports = {
205
+ assertOutputDirectory,
206
+ publishArtifact,
207
+ removeArtifact,
208
+ updateOutputDirectory,
209
+ validateManifest,
210
+ validateOutputDirectory,
211
+ };
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+
3
+ const { lstat, stat } = require("node:fs/promises");
4
+ const { basename, dirname, join } = require("node:path");
5
+
6
+ const RESERVED_ARTIFACT_NAME =
7
+ /^(?:dependencies|dependents|symbols|plan)\.(?:json|stderr|status)$/u;
8
+ const RESERVED_ARTIFACT_NAMES = ["dependencies", "dependents", "symbols", "plan"].flatMap((name) =>
9
+ ["json", "stderr", "status"].map((extension) => `${name}.${extension}`),
10
+ );
11
+
12
+ async function isReservedArtifactPath(path) {
13
+ if (RESERVED_ARTIFACT_NAME.test(basename(path))) return true;
14
+ const identity = await stat(path);
15
+ for (const name of RESERVED_ARTIFACT_NAMES) {
16
+ try {
17
+ const candidate = await lstat(join(dirname(path), name));
18
+ if (candidate.dev === identity.dev && candidate.ino === identity.ino) return true;
19
+ } catch (error) {
20
+ if (!["ENOENT", "ENOTDIR"].includes(error.code)) throw error;
21
+ }
22
+ }
23
+ return false;
24
+ }
25
+
26
+ async function existingFiles(root, files) {
27
+ try {
28
+ await stat(root);
29
+ } catch (error) {
30
+ if (error.code === "ENOENT") return files;
31
+ throw error;
32
+ }
33
+ const existing = await Promise.all(
34
+ files.map(async (file) => {
35
+ try {
36
+ return (await stat(join(root, file))).isFile() ? file : undefined;
37
+ } catch (error) {
38
+ if (["ENOENT", "ENOTDIR"].includes(error.code)) return undefined;
39
+ throw error;
40
+ }
41
+ }),
42
+ );
43
+ return existing.filter((file) => file !== undefined);
44
+ }
45
+
46
+ async function buildRequest(root, changedFiles, broad) {
47
+ const structuralFiles = changedFiles.filter((file) => /\.[cm]?[jt]sx?(?:#.*)?$/u.test(file));
48
+ const traversalFiles = structuralFiles.map((file) => ({ file }));
49
+ const symbolFiles = await existingFiles(root, structuralFiles);
50
+ const relationships = broad ? {} : { relationships: ["import", "workspace"] };
51
+ const reports = structuralFiles.length
52
+ ? [
53
+ ...["dependencies", "dependents"].map((type) => ({
54
+ id: type,
55
+ type,
56
+ files: traversalFiles,
57
+ depth: 1,
58
+ ...relationships,
59
+ })),
60
+ ...(symbolFiles.length
61
+ ? [{ id: "symbols", type: "symbols", files: symbolFiles, include: "both" }]
62
+ : []),
63
+ ]
64
+ : [];
65
+ reports.push({
66
+ id: "plan",
67
+ type: "testsPlan",
68
+ framework: "vitest",
69
+ environment: "prePush",
70
+ changedFiles,
71
+ });
72
+ return { root, reports };
73
+ }
74
+
75
+ function completeResult(result, requestedReports) {
76
+ const requested = new Set(requestedReports.map((report) => report.id));
77
+ const traversal = { roots: [], files: [], diagnostics: [], tsconfig_provenance: [] };
78
+ const omitted = [
79
+ { id: "dependencies", type: "dependencies", result: traversal },
80
+ { id: "dependents", type: "dependents", result: traversal },
81
+ { id: "symbols", type: "symbols", result: { roots: [], files: [] } },
82
+ ].filter((report) => !requested.has(report.id));
83
+ if (!omitted.length) return result;
84
+ return { reports: [...omitted, ...result.reports] };
85
+ }
86
+
87
+ module.exports = { buildRequest, completeResult, isReservedArtifactPath };
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+
3
+ const { lstat, readlink, realpath } = require("node:fs/promises");
4
+ const path = require("node:path");
5
+ const { setTimeout: delay } = require("node:timers/promises");
6
+
7
+ const BUSY_LOCK = /planning artifact lock is busy/u;
8
+ const INITIAL_LOCK_RETRY_MS = 10;
9
+ const MAX_LOCK_RETRY_MS = 100;
10
+
11
+ const outputUpdates = new Map();
12
+
13
+ async function serializeOutputUpdate(outputPath, update) {
14
+ const previous = outputUpdates.get(outputPath) || Promise.resolve();
15
+ let release;
16
+ const current = new Promise((resolveCurrent) => {
17
+ release = resolveCurrent;
18
+ });
19
+ outputUpdates.set(outputPath, current);
20
+ await previous;
21
+ try {
22
+ return await update();
23
+ } finally {
24
+ release();
25
+ if (outputUpdates.get(outputPath) === current) outputUpdates.delete(outputPath);
26
+ }
27
+ }
28
+
29
+ async function canonicalOutputKey(outputPath, visited = new Set()) {
30
+ const lexicalPath = path.resolve(outputPath);
31
+ if (visited.has(lexicalPath)) {
32
+ const error = new Error("output directory contains a symbolic link cycle");
33
+ error.code = "ELOOP";
34
+ throw error;
35
+ }
36
+ visited.add(lexicalPath);
37
+ try {
38
+ const metadata = await lstat(lexicalPath);
39
+ if (metadata.isSymbolicLink()) {
40
+ const target = path.resolve(path.dirname(lexicalPath), await readlink(lexicalPath));
41
+ return canonicalOutputKey(target, visited);
42
+ }
43
+ return await realpath(lexicalPath);
44
+ } catch (error) {
45
+ if (error.code !== "ENOENT") throw error;
46
+ return path.join(await realpath(path.dirname(lexicalPath)), path.basename(lexicalPath));
47
+ }
48
+ }
49
+
50
+ function createPlanningArtifactLock(native) {
51
+ return async (outputPath) => {
52
+ const lockPath = path.join(
53
+ path.dirname(outputPath),
54
+ `.${path.basename(outputPath)}.planning-impact.lock`,
55
+ );
56
+ const token = await acquirePlanningArtifactLock(native, lockPath);
57
+ return async () => native.releasePlanningArtifactLock(token);
58
+ };
59
+ }
60
+
61
+ async function acquirePlanningArtifactLock(native, lockPath) {
62
+ for (let delayMs = INITIAL_LOCK_RETRY_MS; ; delayMs = Math.min(delayMs * 2, MAX_LOCK_RETRY_MS)) {
63
+ try {
64
+ return await native.acquirePlanningArtifactLock(lockPath);
65
+ } catch (error) {
66
+ if (!BUSY_LOCK.test(String(error && error.message))) throw error;
67
+ await delay(delayMs);
68
+ }
69
+ }
70
+ }
71
+
72
+ module.exports = { canonicalOutputKey, createPlanningArtifactLock, serializeOutputUpdate };
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+
3
+ function isPrivateDirectory(metadata, platform = process.platform) {
4
+ return metadata.isDirectory() && hasPrivateMode(metadata, 0o700, platform);
5
+ }
6
+
7
+ function isPrivateRegularFile(metadata, platform = process.platform) {
8
+ return metadata.isFile() && hasPrivateMode(metadata, 0o600, platform);
9
+ }
10
+
11
+ function hasPrivateMode(metadata, mode, platform) {
12
+ return platform !== "win32" && (metadata.mode & 0o7777) === mode;
13
+ }
14
+
15
+ function privatePermissionError(subject, mode, platform = process.platform) {
16
+ if (platform === "win32") {
17
+ return new Error(
18
+ "planning impact artifacts are unavailable on Windows because Node cannot verify private Windows ACLs",
19
+ );
20
+ }
21
+ return new Error(`${subject} must have mode ${mode.toString(8).padStart(4, "0")}`);
22
+ }
23
+
24
+ module.exports = { isPrivateDirectory, isPrivateRegularFile, privatePermissionError };
@@ -0,0 +1,25 @@
1
+ import type { DependencyResult, SymbolsResult, TestPlan } from "./types";
2
+
3
+ /** Input for writing a private, CLI-compatible planning-impact artifact set. */
4
+ export interface WritePlanningImpactArtifactsOptions {
5
+ /** Repository root passed to the prepared `analyzeProject()` request. */
6
+ root: string;
7
+ /**
8
+ * Path to a private regular manifest directly inside `outputDirectory`. Its newline-delimited
9
+ * contents are literal repository-relative changed-file paths; only empty records are ignored.
10
+ */
11
+ changedFilesManifest: string;
12
+ /** Existing private directory with exactly mode `0700` that receives artifacts; unavailable on Windows. */
13
+ outputDirectory: string;
14
+ /** Omit the import/workspace relationship filter for structural reports. */
15
+ broad?: boolean;
16
+ }
17
+
18
+ /** Structured result mirrored by `dependencies.json`, `dependents.json`, `symbols.json`, and `plan.json`. */
19
+ export interface PlanningImpactArtifacts {
20
+ outputDirectory: string;
21
+ dependencies: DependencyResult;
22
+ dependents: DependencyResult;
23
+ symbols: SymbolsResult;
24
+ plan: TestPlan;
25
+ }
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+
3
+ const {
4
+ publishArtifact,
5
+ removeArtifact,
6
+ updateOutputDirectory,
7
+ validateManifest,
8
+ validateOutputDirectory,
9
+ } = require("./planning-impact-artifacts-files");
10
+ const artifactErrors = require("./planning-impact-artifacts-errors");
11
+ const { canonicalOutputKey, serializeOutputUpdate } = require("./planning-impact-artifacts-lock");
12
+ const { realpath } = require("node:fs/promises");
13
+ const {
14
+ buildRequest,
15
+ completeResult,
16
+ isReservedArtifactPath,
17
+ } = require("./planning-impact-artifacts-inputs");
18
+ const { basename, isAbsolute, posix, resolve, win32 } = require("node:path");
19
+ const { TextDecoder } = require("node:util");
20
+
21
+ const REPORTS = ["dependencies", "dependents", "symbols", "plan"];
22
+ const REPORT_TYPES = {
23
+ dependencies: "dependencies",
24
+ dependents: "dependents",
25
+ symbols: "symbols",
26
+ plan: "testsPlan",
27
+ };
28
+ const RESERVED_ARTIFACT_NAME =
29
+ /^(?:dependencies|dependents|symbols|plan)\.(?:json|stderr|status)$/u;
30
+ const RESERVED_MANIFEST_ERROR =
31
+ "changed-files manifest must not use a reserved artifact destination";
32
+ // Node keeps U+FEFF when ignoreBOM is true; the default strips a UTF-8 BOM.
33
+ const UTF8_DECODER = new TextDecoder("utf-8", { fatal: true });
34
+
35
+ async function writePlanningImpactArtifacts(
36
+ options,
37
+ analyzeProject,
38
+ renameNoReplace,
39
+ acquireOutputLock = async () => async () => {},
40
+ ) {
41
+ const outputKey = await canonicalOutputKey(resolve(options.outputDirectory));
42
+ return serializeOutputUpdate(outputKey, async () => {
43
+ const releaseOutputLock = await acquireOutputLock(outputKey);
44
+ try {
45
+ const outputPath = await realpath(outputKey);
46
+ return await writePlanningImpactArtifactsUnlocked(
47
+ { ...options, outputDirectory: outputPath },
48
+ analyzeProject,
49
+ renameNoReplace,
50
+ );
51
+ } finally {
52
+ await releaseOutputLock();
53
+ }
54
+ });
55
+ }
56
+
57
+ async function writePlanningImpactArtifactsUnlocked(options, analyzeProject, renameNoReplace) {
58
+ const output = await validateOutputDirectory(options.outputDirectory);
59
+ let manifestHandle;
60
+ let mayWriteFailureArtifacts = false;
61
+ try {
62
+ const requestedManifestPath = resolve(options.changedFilesManifest);
63
+ mayWriteFailureArtifacts = !RESERVED_ARTIFACT_NAME.test(basename(requestedManifestPath));
64
+ if (!mayWriteFailureArtifacts) throw new Error(RESERVED_MANIFEST_ERROR);
65
+ const manifestPath = await realpath(requestedManifestPath);
66
+ mayWriteFailureArtifacts = false;
67
+ if (await isReservedArtifactPath(manifestPath)) throw new Error(RESERVED_MANIFEST_ERROR);
68
+ mayWriteFailureArtifacts = true;
69
+ const manifest = await validateManifest(output, manifestPath);
70
+ manifestHandle = manifest.handle;
71
+ mayWriteFailureArtifacts = false;
72
+ if (await isReservedArtifactPath(manifest.path)) throw new Error(RESERVED_MANIFEST_ERROR);
73
+ mayWriteFailureArtifacts = true;
74
+ const changedFiles = parseChangedFiles(decodeManifest(await manifestHandle.readFile()));
75
+ await manifestHandle.close();
76
+ manifestHandle = undefined;
77
+ await updateOutputDirectory(output, invalidateStatuses, renameNoReplace);
78
+ const request = {
79
+ ...(await buildRequest(options.root, changedFiles, options.broad === true)),
80
+ ...invocationOptions(options),
81
+ };
82
+ const result = await analyzeProject(request);
83
+ const completed = completeResult(result, request.reports);
84
+ const artifacts = reportArtifacts(completed);
85
+ await updateOutputDirectory(
86
+ output,
87
+ (privateOutput) => writeSuccess(privateOutput, artifacts),
88
+ renameNoReplace,
89
+ );
90
+ return { outputDirectory: output.path, ...artifacts };
91
+ } catch (error) {
92
+ if (manifestHandle) {
93
+ await manifestHandle.close().catch(() => {});
94
+ manifestHandle = undefined;
95
+ }
96
+ if (mayWriteFailureArtifacts && !artifactErrors.isOutputRestorationFailure(error)) {
97
+ try {
98
+ await updateOutputDirectory(
99
+ output,
100
+ (privateOutput) => writeFailure(privateOutput, error),
101
+ renameNoReplace,
102
+ );
103
+ } catch (failureReportingError) {
104
+ if (artifactErrors.isOutputRestorationFailure(failureReportingError)) {
105
+ throw artifactErrors.preserveFailureReportingError(error, failureReportingError);
106
+ }
107
+ }
108
+ }
109
+ throw error;
110
+ } finally {
111
+ if (manifestHandle) await manifestHandle.close().catch(() => {});
112
+ if (output.handle) await output.handle.close().catch(() => {});
113
+ }
114
+ }
115
+ function decodeManifest(contents) {
116
+ try {
117
+ return UTF8_DECODER.decode(contents);
118
+ } catch {
119
+ throw new Error("changed-files manifest must be valid UTF-8");
120
+ }
121
+ }
122
+ function invocationOptions(options) {
123
+ return Object.fromEntries(
124
+ ["timeout", "lockTimeout", "failOnLock", "jobs", "profile"]
125
+ .filter((name) => Object.hasOwn(options, name))
126
+ .map((name) => [name, options[name]]),
127
+ );
128
+ }
129
+ function parseChangedFiles(source) {
130
+ const files = [...new Set(source.split(/\r\n|[\r\n]/u).filter((line) => line.length > 0))];
131
+ if (!files.length) throw new Error("changed-files manifest is empty");
132
+ for (const file of files) {
133
+ if (
134
+ isAbsolute(file) ||
135
+ posix.isAbsolute(file) ||
136
+ win32.isAbsolute(file) ||
137
+ /^[A-Za-z]:/u.test(file) ||
138
+ file.split(/[\\/]/u).includes("..")
139
+ ) {
140
+ throw new Error(`changed file must be repository-relative: ${file}`);
141
+ }
142
+ }
143
+ return files;
144
+ }
145
+ function reportArtifacts(result) {
146
+ const reports = new Map(result.reports.map((report) => [report.id, report]));
147
+ return Object.fromEntries(
148
+ REPORTS.map((name) => {
149
+ const report = reports.get(name);
150
+ if (!report) throw new Error(`no-mistakes omitted the ${name} report`);
151
+ if (report.type !== REPORT_TYPES[name]) {
152
+ throw new Error(`${name} type ${report.type}; expected ${REPORT_TYPES[name]}`);
153
+ }
154
+ return [name, report.result];
155
+ }),
156
+ );
157
+ }
158
+
159
+ async function writeSuccess(output, artifacts) {
160
+ for (const name of REPORTS) {
161
+ await publishArtifact(
162
+ output,
163
+ `${name}.json`,
164
+ `${JSON.stringify(toCliValue(artifacts[name]))}\n`,
165
+ );
166
+ await publishArtifact(output, `${name}.stderr`, "");
167
+ }
168
+ for (const name of REPORTS) await publishArtifact(output, `${name}.status`, "0\n");
169
+ }
170
+
171
+ async function writeFailure(output, error) {
172
+ const diagnostic = boundedDiagnostic(error);
173
+ for (const name of REPORTS) {
174
+ await attempt(() => removeArtifact(output, `${name}.json`));
175
+ await attempt(() => publishArtifact(output, `${name}.stderr`, diagnostic));
176
+ await attempt(() => publishArtifact(output, `${name}.status`, "1\n"));
177
+ }
178
+ }
179
+
180
+ async function invalidateStatuses(output) {
181
+ for (const name of REPORTS) {
182
+ await publishArtifact(output, `${name}.status`, "1\n");
183
+ }
184
+ }
185
+
186
+ async function attempt(operation) {
187
+ try {
188
+ await operation();
189
+ } catch {
190
+ // Failure reporting must preserve the original analysis/publication error.
191
+ }
192
+ }
193
+
194
+ function toCliValue(value) {
195
+ if (Array.isArray(value)) return value.map(toCliValue);
196
+ if (value === null || typeof value !== "object") return value;
197
+ return Object.fromEntries(
198
+ Object.entries(value).map(([key, child]) => [
199
+ key.replace(/[A-Z]/gu, (letter) => `_${letter.toLowerCase()}`),
200
+ toCliValue(child),
201
+ ]),
202
+ );
203
+ }
204
+
205
+ function boundedDiagnostic(error) {
206
+ const detail = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
207
+ return Buffer.from(`${detail}\n`)
208
+ .subarray(0, 4096)
209
+ .toString("utf8")
210
+ .replace(/\ufffd+$/u, "");
211
+ }
212
+
213
+ module.exports = { writePlanningImpactArtifacts };
package/types.d.ts CHANGED
@@ -12,3 +12,4 @@ export * from "./query-types";
12
12
  export * from "./named-query-types";
13
13
  export * from "./mermaid-types";
14
14
  export * from "./resolve-config-types";
15
+ export * from "./planning-impact-artifacts-types";