json-sort-cli 4.2.2 → 4.3.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/process-files.js CHANGED
@@ -1,87 +1,144 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, open, realpath, rename, unlink } from "node:fs/promises";
2
4
  import path from "node:path";
3
- import { traverse } from "ast-monkey-traverse";
4
- import { isPlainObject, resolveEolSetting } from "codsen-utils";
5
- import sortPackageJson, { sortOrder } from "sort-package-json";
6
- import { writeJson } from "./json-file.js";
5
+ import { decodeJson, formatParsedJson, parseJson } from "./json-formatter.js";
7
6
 
8
7
  function asError(error) {
9
- return error instanceof Error ? error : new Error(String(error));
10
- }
8
+ if (error instanceof Error) {
9
+ return error;
10
+ }
11
11
 
12
- function sortObject(object) {
13
- const result = {};
14
- for (const key of Object.keys(object).sort()) {
15
- result[key] = object[key];
12
+ let description;
13
+ try {
14
+ description = String(error);
15
+ } catch {
16
+ description = "Unknown non-Error value";
16
17
  }
17
- return result;
18
+ return new Error(description, { cause: error });
18
19
  }
19
20
 
20
- function formatPackageJson(object) {
21
- if (typeof object !== "object") {
22
- return object;
23
- }
24
- const customSortOrder = sortOrder.filter(
25
- (field) => !["lect", "tap"].includes(field),
26
- );
27
- const resolutionsIndex = customSortOrder.indexOf("resolutions");
28
- customSortOrder.splice(resolutionsIndex, 0, "tap", "lect");
29
- return sortPackageJson(object, { sortOrder: customSortOrder });
21
+ async function openWithoutFollowing(filePath) {
22
+ const noFollow = constants.O_NOFOLLOW ?? 0;
23
+ return open(filePath, constants.O_RDONLY | noFollow);
30
24
  }
31
25
 
32
- function normalizeLineEndings(stringified, eolChar) {
33
- if (eolChar === "\r\n") {
34
- return stringified
35
- .replaceAll(/(?<!\r)\n/g, "\r\n")
36
- .replaceAll(/\r(?!\n)/g, "\n");
26
+ async function readFileSnapshot(filePath) {
27
+ const realPath = await realpath(filePath);
28
+ const pathStat = await lstat(filePath, { bigint: true });
29
+ if (pathStat.isSymbolicLink()) {
30
+ throw new Error(`Refusing to process symbolic link: ${filePath}`);
31
+ }
32
+
33
+ const handle = await openWithoutFollowing(filePath);
34
+ try {
35
+ const stat = await handle.stat({ bigint: true });
36
+ if (!stat.isFile()) {
37
+ throw new Error(`Refusing to process a non-file: ${filePath}`);
38
+ }
39
+ if (!sameIdentity(pathStat, stat)) {
40
+ throw new Error(
41
+ `The file changed while it was being opened: ${filePath}`,
42
+ );
43
+ }
44
+ if ((await realpath(filePath)) !== realPath) {
45
+ throw new Error(`The file route changed while opening: ${filePath}`);
46
+ }
47
+ return { contents: await handle.readFile(), realPath, stat };
48
+ } finally {
49
+ await handle.close();
37
50
  }
38
- return stringified.replaceAll(/(?:\r?\n)|\r/g, eolChar);
39
51
  }
40
52
 
41
- function prepareJson(
42
- parsedJson,
43
- { arrays, contents, filePath, indentationCount, lineEnding, pack, tabs },
44
- ) {
45
- const eol = resolveEolSetting(contents, lineEnding);
46
- let result = isPlainObject(parsedJson) ? sortObject(parsedJson) : parsedJson;
53
+ function sameIdentity(left, right) {
54
+ return (
55
+ left.dev === right.dev &&
56
+ left.ino === right.ino &&
57
+ left.size === right.size &&
58
+ left.mtimeNs === right.mtimeNs &&
59
+ left.ctimeNs === right.ctimeNs
60
+ );
61
+ }
62
+
63
+ async function commitFile(filePath, output, snapshot) {
47
64
  if (
48
- arrays &&
49
- Array.isArray(result) &&
50
- result.length &&
51
- result.every((item) => typeof item === "string")
65
+ !snapshot?.stat ||
66
+ !Buffer.isBuffer(snapshot.contents) ||
67
+ typeof snapshot.realPath !== "string"
52
68
  ) {
53
- result.sort((a, b) => a.localeCompare(b));
54
- } else if (!pack && path.basename(filePath) === "package.json") {
55
- result = formatPackageJson(result);
69
+ throw new Error("Cannot safely commit without the original file snapshot");
56
70
  }
57
71
 
58
- const value = traverse(result, (key, val) => {
59
- const current = val !== undefined ? val : key;
60
- if (isPlainObject(current)) {
61
- return sortObject(current);
72
+ const commitPath = snapshot.realPath;
73
+ const directory = path.dirname(commitPath);
74
+ const temporaryPath = path.join(
75
+ directory,
76
+ `.${path.basename(commitPath)}.${process.pid}.${randomUUID()}.tmp`,
77
+ );
78
+ let temporaryHandle;
79
+
80
+ try {
81
+ if ((await realpath(filePath)) !== snapshot.realPath) {
82
+ throw new Error(
83
+ "The file route changed after it was read; refusing to overwrite it",
84
+ );
62
85
  }
86
+ temporaryHandle = await open(
87
+ temporaryPath,
88
+ "wx",
89
+ Number(snapshot.stat.mode & 0o7777n),
90
+ );
91
+ await temporaryHandle.writeFile(output, "utf8");
92
+ await temporaryHandle.chmod(Number(snapshot.stat.mode & 0o7777n));
93
+
94
+ const temporaryStat = await temporaryHandle.stat({ bigint: true });
63
95
  if (
64
- arrays &&
65
- Array.isArray(current) &&
66
- current.length > 1 &&
67
- current.every((item) => typeof item === "string")
96
+ temporaryStat.uid !== snapshot.stat.uid ||
97
+ temporaryStat.gid !== snapshot.stat.gid
68
98
  ) {
69
- return current.sort((a, b) => a.localeCompare(b));
99
+ await temporaryHandle.chown(
100
+ Number(snapshot.stat.uid),
101
+ Number(snapshot.stat.gid),
102
+ );
70
103
  }
71
- return current;
72
- });
73
- const spaces = tabs ? "\t".repeat(indentationCount) : indentationCount;
74
- const stringified = normalizeLineEndings(
75
- JSON.stringify(value, null, spaces),
76
- eol,
77
- );
104
+ await temporaryHandle.sync();
105
+ await temporaryHandle.close();
106
+ temporaryHandle = undefined;
78
107
 
79
- return {
80
- changed: stringified.trimEnd() !== contents.trimEnd(),
81
- eol,
82
- spaces,
83
- value,
84
- };
108
+ const current = await readFileSnapshot(filePath);
109
+ if (
110
+ !sameIdentity(snapshot.stat, current.stat) ||
111
+ !snapshot.contents.equals(current.contents)
112
+ ) {
113
+ throw new Error(
114
+ "The file changed after it was read; refusing to overwrite it",
115
+ );
116
+ }
117
+
118
+ if ((await realpath(filePath)) !== snapshot.realPath) {
119
+ throw new Error(
120
+ "The file route changed before commit; refusing to overwrite it",
121
+ );
122
+ }
123
+ await rename(temporaryPath, commitPath);
124
+
125
+ // Directory syncing is not supported by every platform. The file is
126
+ // already durable and atomically visible when this best-effort step runs.
127
+ try {
128
+ const directoryHandle = await open(directory, "r");
129
+ try {
130
+ await directoryHandle.sync();
131
+ } finally {
132
+ await directoryHandle.close();
133
+ }
134
+ } catch {}
135
+ } catch (error) {
136
+ if (temporaryHandle) {
137
+ await temporaryHandle.close().catch(() => {});
138
+ }
139
+ await unlink(temporaryPath).catch(() => {});
140
+ throw error;
141
+ }
85
142
  }
86
143
 
87
144
  export class FileProcessingError extends Error {
@@ -113,33 +170,43 @@ async function processFile(
113
170
  options,
114
171
  { parse, read, transform, write },
115
172
  ) {
116
- let contents;
173
+ let snapshot;
117
174
  try {
118
- contents = await read(filePath, "utf8");
175
+ const received = await read(filePath);
176
+ snapshot =
177
+ received && typeof received === "object" && "contents" in received
178
+ ? received
179
+ : { contents: received };
119
180
  } catch (error) {
120
181
  throw new FileProcessingError(filePath, "read", error);
121
182
  }
122
183
 
123
- let parsedJson;
184
+ let decoded;
124
185
  try {
125
- parsedJson = parse(contents);
186
+ decoded = decodeJson(snapshot.contents);
187
+ } catch (error) {
188
+ throw new FileProcessingError(filePath, "decode", error);
189
+ }
190
+
191
+ let parsed;
192
+ try {
193
+ parsed = (parse ?? parseJson)(decoded);
126
194
  } catch (error) {
127
195
  throw new FileProcessingError(filePath, "parse", error);
128
196
  }
129
197
 
130
198
  let prepared;
131
199
  try {
132
- prepared = transform(parsedJson, { contents, filePath, ...options });
200
+ prepared = transform
201
+ ? transform(parsed, { contents: decoded, filePath, ...options })
202
+ : formatParsedJson(parsed, decoded, { filePath, ...options });
133
203
  } catch (error) {
134
204
  throw new FileProcessingError(filePath, "transform", error);
135
205
  }
136
206
 
137
- if (!options.ci) {
207
+ if (!options.ci && prepared.changed) {
138
208
  try {
139
- await write(filePath, prepared.value, {
140
- EOL: prepared.eol,
141
- spaces: prepared.spaces,
142
- });
209
+ await write(filePath, prepared.output, snapshot);
143
210
  } catch (error) {
144
211
  throw new FileProcessingError(filePath, "write", error);
145
212
  }
@@ -177,11 +244,11 @@ export async function processFiles(
177
244
  lineEnding,
178
245
  onOutcome = () => {},
179
246
  pack = false,
180
- parse = JSON.parse,
181
- read = readFile,
247
+ parse,
248
+ read = readFileSnapshot,
182
249
  tabs = false,
183
- transform = prepareJson,
184
- write = writeJson,
250
+ transform,
251
+ write = commitFile,
185
252
  } = {},
186
253
  ) {
187
254
  const failures = [];
@@ -195,21 +262,33 @@ export async function processFiles(
195
262
  pack,
196
263
  tabs,
197
264
  };
265
+ let callbackError;
266
+
267
+ function report(outcome) {
268
+ try {
269
+ onOutcome(outcome);
270
+ } catch (error) {
271
+ callbackError ??= asError(error);
272
+ }
273
+ }
198
274
 
199
275
  async function captureOutcome(filePath) {
276
+ let outcome;
200
277
  try {
201
- const outcome = await processFile(filePath, options, {
202
- parse,
203
- read,
204
- transform,
205
- write,
206
- });
207
- return { ...outcome, status: "success" };
278
+ outcome = {
279
+ ...(await processFile(filePath, options, {
280
+ parse,
281
+ read,
282
+ transform,
283
+ write,
284
+ })),
285
+ status: "success",
286
+ };
208
287
  } catch (error) {
209
288
  if (!(error instanceof FileProcessingError)) {
210
289
  throw error;
211
290
  }
212
- return {
291
+ outcome = {
213
292
  error: error.error,
214
293
  failure: error,
215
294
  path: error.path,
@@ -217,6 +296,8 @@ export async function processFiles(
217
296
  status: "failure",
218
297
  };
219
298
  }
299
+ report(outcome);
300
+ return outcome;
220
301
  }
221
302
 
222
303
  const outcomes = ci
@@ -237,11 +318,13 @@ export async function processFiles(
237
318
  unsorted.push(outcome.path);
238
319
  }
239
320
  }
240
- onOutcome(outcome);
241
321
  }
242
322
 
243
323
  if (failures.length) {
244
324
  throw new ProcessingError(failures, successful, unsorted);
245
325
  }
326
+ if (callbackError) {
327
+ throw callbackError;
328
+ }
246
329
  return { failures, successful, unsorted };
247
330
  }
package/json-file.js DELETED
@@ -1,42 +0,0 @@
1
- import { readFile, writeFile } from "node:fs/promises";
2
-
3
- export async function readJson(file, options = {}) {
4
- if (typeof options === "string") {
5
- options = { encoding: options };
6
- }
7
-
8
- const shouldThrow = "throws" in options ? options.throws : true;
9
- let content = await readFile(file, options);
10
-
11
- if (Buffer.isBuffer(content)) {
12
- content = content.toString("utf8");
13
- }
14
-
15
- try {
16
- return JSON.parse(content.replace(/^\uFEFF/, ""), options.reviver);
17
- } catch (error) {
18
- if (!shouldThrow) {
19
- return null;
20
- }
21
-
22
- error.message = `${file}: ${error.message}`;
23
- throw error;
24
- }
25
- }
26
-
27
- export async function writeJson(file, value, options = {}) {
28
- const { EOL = "\n", finalEOL = true, replacer = null, spaces } = options;
29
- const stringified = JSON.stringify(value, replacer, spaces);
30
-
31
- if (stringified === undefined) {
32
- throw new TypeError(
33
- `json-sort-cli/writeJson(): [THROW_ID_01] Converting ${typeof value} value to JSON is not supported`,
34
- );
35
- }
36
-
37
- await writeFile(
38
- file,
39
- stringified.replaceAll("\n", EOL) + (finalEOL ? EOL : ""),
40
- options,
41
- );
42
- }