jostraca 0.31.0 → 0.31.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.
Files changed (67) hide show
  1. package/README.md +355 -0
  2. package/dist/build/BuildContext.d.ts +1 -0
  3. package/dist/build/BuildContext.js +10 -0
  4. package/dist/build/BuildContext.js.map +1 -1
  5. package/dist/build/BuildMeta.js +21 -4
  6. package/dist/build/BuildMeta.js.map +1 -1
  7. package/dist/build/FileHandler.d.ts +11 -4
  8. package/dist/build/FileHandler.js +468 -92
  9. package/dist/build/FileHandler.js.map +1 -1
  10. package/dist/cmp/Content.js.map +1 -1
  11. package/dist/cmp/Copy.js +5 -1
  12. package/dist/cmp/Copy.js.map +1 -1
  13. package/dist/cmp/File.js +2 -0
  14. package/dist/cmp/File.js.map +1 -1
  15. package/dist/cmp/Folder.js.map +1 -1
  16. package/dist/cmp/Fragment.js +36 -7
  17. package/dist/cmp/Fragment.js.map +1 -1
  18. package/dist/cmp/Inject.js +30 -1
  19. package/dist/cmp/Inject.js.map +1 -1
  20. package/dist/cmp/Line.js.map +1 -1
  21. package/dist/cmp/List.js.map +1 -1
  22. package/dist/cmp/None.js.map +1 -1
  23. package/dist/cmp/Project.js.map +1 -1
  24. package/dist/cmp/Slot.js.map +1 -1
  25. package/dist/diff.d.ts +36 -0
  26. package/dist/diff.js +446 -0
  27. package/dist/diff.js.map +1 -0
  28. package/dist/jostraca.d.ts +19 -18
  29. package/dist/jostraca.js +83 -24
  30. package/dist/jostraca.js.map +1 -1
  31. package/dist/op/ContentOp.js.map +1 -1
  32. package/dist/op/CopyOp.js +158 -22
  33. package/dist/op/CopyOp.js.map +1 -1
  34. package/dist/op/FileOp.js +26 -27
  35. package/dist/op/FileOp.js.map +1 -1
  36. package/dist/op/FolderOp.js +3 -0
  37. package/dist/op/FolderOp.js.map +1 -1
  38. package/dist/op/FragmentOp.js.map +1 -1
  39. package/dist/op/InjectOp.js +29 -7
  40. package/dist/op/InjectOp.js.map +1 -1
  41. package/dist/op/NoneOp.js.map +1 -1
  42. package/dist/op/ProjectOp.js.map +1 -1
  43. package/dist/op/SlotOp.js.map +1 -1
  44. package/dist/tsconfig.tsbuildinfo +1 -1
  45. package/dist/types.d.ts +1 -0
  46. package/dist/util/basic.d.ts +5 -1
  47. package/dist/util/basic.js +218 -9
  48. package/dist/util/basic.js.map +1 -1
  49. package/dist/util/point.d.ts +8 -7
  50. package/dist/util/point.js.map +1 -1
  51. package/package.json +19 -11
  52. package/src/build/BuildContext.ts +12 -0
  53. package/src/build/BuildMeta.ts +24 -5
  54. package/src/build/FileHandler.ts +489 -121
  55. package/src/cmp/Copy.ts +5 -1
  56. package/src/cmp/File.ts +3 -0
  57. package/src/cmp/Fragment.ts +41 -7
  58. package/src/cmp/Inject.ts +38 -1
  59. package/src/diff.ts +657 -0
  60. package/src/jostraca.ts +83 -20
  61. package/src/op/CopyOp.ts +173 -22
  62. package/src/op/FileOp.ts +29 -31
  63. package/src/op/FolderOp.ts +6 -0
  64. package/src/op/InjectOp.ts +39 -7
  65. package/src/tsconfig.json +3 -1
  66. package/src/types.ts +5 -0
  67. package/src/util/basic.ts +250 -10
@@ -1,13 +1,47 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
37
  };
5
38
  Object.defineProperty(exports, "__esModule", { value: true });
6
39
  exports.FileHandler = void 0;
40
+ exports.annotatedPath = annotatedPath;
41
+ exports.validName = validName;
7
42
  exports.validPath = validPath;
8
- const Diff = require('diff');
9
- const Diff3 = require('node-diff3');
10
43
  const node_path_1 = __importDefault(require("node:path"));
44
+ const DiffUtil = __importStar(require("../diff"));
11
45
  const basic_1 = require("../util/basic");
12
46
  const CN = 'FileHandler:';
13
47
  // Normalize path separators to forward slashes for cross-platform consistency.
@@ -17,6 +51,33 @@ function fwd(p) {
17
51
  return p.includes('\\') ? p.replace(/\\/g, '/') : p;
18
52
  }
19
53
  const JOSTRACA_PROTECT = 'JOSTRACA_PROTECT';
54
+ // Audit breadcrumb per merge outcome, so the `why` trail says which fast
55
+ // path (if any) the merge took.
56
+ const MERGE_WHY = {
57
+ same: 'merge-same-0',
58
+ clean: 'merge-clean-0',
59
+ unresolved: 'merge-unresolved-0',
60
+ merged: 'merge-run-0',
61
+ };
62
+ // Suffix for the sibling temp file used by the atomic write-then-rename.
63
+ const TMP_SUFFIX = '.jostraca-tmp';
64
+ // How many candidate temp paths an atomic write may try before giving up.
65
+ //
66
+ // MUST match tmpPathAttempts in go/filehandler.go — an identical collision
67
+ // schedule has to succeed or fail identically in both stacks.
68
+ const TMP_PATH_ATTEMPTS = 9;
69
+ // A unique sibling temp path for an atomic write.
70
+ //
71
+ // Never a fixed name: a fixed one both destroys a user file that happens to
72
+ // sit at it, and lets two concurrent runs sharing an output folder publish
73
+ // each other's bytes. pid + counter + random keeps it unique across
74
+ // processes, across writes within a process, and across retries.
75
+ let tmpseq = 0;
76
+ function tmppathFor(path) {
77
+ const pid = 'undefined' === typeof process ? 0 : process.pid;
78
+ const rnd = Math.floor(Math.random() * 0x100000000).toString(36);
79
+ return path + TMP_SUFFIX + '-' + pid + '-' + (tmpseq++).toString(36) + '-' + rnd;
80
+ }
20
81
  // TODO: if EOL != '\n', normalize to '\n' in load,save
21
82
  // Log non-fatal wierdness.
22
83
  const dlog = (0, basic_1.getdlog)('jostraca', __filename);
@@ -35,6 +96,7 @@ class FileHandler {
35
96
  metafile;
36
97
  files;
37
98
  createdDirs;
99
+ savedPaths;
38
100
  constructor(bctx, existing, control) {
39
101
  this.fs = bctx.fs;
40
102
  this.now = bctx.now;
@@ -54,6 +116,7 @@ class FileHandler {
54
116
  unchanged: [],
55
117
  };
56
118
  this.createdDirs = new Set();
119
+ this.savedPaths = new Set();
57
120
  // Yikes!
58
121
  this.duplicateFolder = bctx.duplicateFolder.bind(bctx);
59
122
  this.last = () => bctx.bmeta.prev.last;
@@ -69,12 +132,90 @@ class FileHandler {
69
132
  if ('string' !== typeof path) {
70
133
  throw new Error(CN + FN + wstr + ' invalid path, path=' + path);
71
134
  }
72
- const withinFolder = path.startsWith(this.folder);
73
- const rpath = withinFolder ? path.substring(this.folder.length).replace(/^[/\\]+/, '') : path;
135
+ // Strip the folder prefix only when it is ACTUALLY there.
136
+ //
137
+ // `save` normalizes first, so with the default folder `.` a path comes
138
+ // in as `a.txt`, not `./a.txt` — the leading `.` this is meant to
139
+ // consume is already gone. Cutting `this.folder.length` regardless then
140
+ // ate the first real character: `a.txt` and `b.txt` both became `.txt`,
141
+ // so their merge baselines and meta keys collided and a later merge
142
+ // loaded the wrong ancestor. (The ternary this replaced had the same
143
+ // expression in both branches — a half-finished special case.)
144
+ //
145
+ // The prefix must match on a PATH BOUNDARY, not as a raw string.
146
+ //
147
+ // A bare `startsWith` looked right and was not: with folder `.`, the
148
+ // folder is the single character `.`, so `.env` "starts with" it and
149
+ // the strip produced `env` — the same relative key as a sibling file
150
+ // literally named `env`. Their merge baselines and meta entries then
151
+ // collided, and the next run merged each against the OTHER's ancestor,
152
+ // writing whole-file conflict markers into the user's real `.env`.
153
+ // That is the very collision this function was fixed to prevent, one
154
+ // case narrower. `.gitignore`, `.npmrc` and friends are all affected.
155
+ //
156
+ // So `.` strips only an explicit `./`, `/` strips only separators, and
157
+ // everything else defers to `withinFolder`, which already does
158
+ // boundary matching properly.
159
+ const stripFolder = (s) => (s.startsWith(this.folder) ? s.substring(this.folder.length) : s)
160
+ .replace(/^[/\\]+/, '');
161
+ const rpath = '.' === this.folder ? path.replace(/^\.[/\\]+/, '') :
162
+ '/' === this.folder ? path.replace(/^[/\\]+/, '') :
163
+ this.withinFolder(path) ? stripFolder(path) :
164
+ path;
74
165
  // Canonical paths use forward slashes, NOT Path.sep
75
166
  return rpath.replace(/\\/g, '/');
76
167
  }
77
- save(path, newContentSource, write, whence) {
168
+ // Whether `path` resolves inside the configured output folder.
169
+ //
170
+ // Matched on a separator boundary, not a raw string prefix: with folder
171
+ // `/out`, the path `/output/x.txt` is NOT inside it. The plain
172
+ // `startsWith` this replaced yielded the relative path `put/x.txt`, and
173
+ // from there a bogus duplicate-baseline location and meta key.
174
+ withinFolder(path) {
175
+ if ('.' === this.folder) {
176
+ if (node_path_1.default.isAbsolute(path)) {
177
+ return false;
178
+ }
179
+ // "relative" is not the same as "inside". A `..` segment walks OUT
180
+ // of the output folder, and this returning true for it let the merge
181
+ // baseline — `.jostraca/generated` joined to the relative path — nor-
182
+ // malize to a location outside the baseline directory entirely, and
183
+ // silently overwrite whatever was there.
184
+ const norm = fwd(node_path_1.default.normalize(path));
185
+ return '..' !== norm && !norm.startsWith('../');
186
+ }
187
+ if ('/' === this.folder) {
188
+ return path.startsWith('/');
189
+ }
190
+ return path === this.folder ||
191
+ path.startsWith(this.folder + '/') ||
192
+ path.startsWith(this.folder + '\\');
193
+ }
194
+ // save for content already KNOWN to be binary, whatever its extension
195
+ // says.
196
+ //
197
+ // The copy paths sniff for NUL bytes so an unlisted extension (`.wasm`
198
+ // and friends) is still treated as binary; `save` otherwise re-derives
199
+ // the classification from the destination extension alone and throws
200
+ // that knowledge away. With `txt.diff` on, a diff render would then
201
+ // write textual conflict markers into binary data, and `bin.preserve`
202
+ // would be ignored entirely.
203
+ //
204
+ // Mirrors saveBinary in go/filehandler.go.
205
+ saveBinary(path, newContentSource, whence) {
206
+ this.save(path, newContentSource, false, whence, undefined, false);
207
+ }
208
+ // `mode` sets POSIX permission bits on the generated file (e.g. 0o755
209
+ // for a script). It applies to the target only — the `.old`/`.new`
210
+ // sidecars and the merge baseline stay at the platform default, since
211
+ // they are jostraca's bookkeeping rather than the user's output.
212
+ //
213
+ // `isText` overrides the extension-derived classification; only
214
+ // `saveBinary` passes it.
215
+ save(path, newContentSource, write, whence, mode, isText) {
216
+ // Only ever set the key when a mode was actually given: a literal
217
+ // `mode: undefined` is rejected by some fs providers.
218
+ const modeopts = () => (null == mode ? {} : { mode });
78
219
  const wstr = null == whence ? '' : whence + ':';
79
220
  const fs = this.fs();
80
221
  const FN = 'save:';
@@ -87,11 +228,37 @@ class FileHandler {
87
228
  write = false;
88
229
  }
89
230
  whence = null == whence ? '' : whence;
90
- const existing = 'string' === typeof newContentSource ? this.existing.txt : this.existing.bin;
91
231
  path = fwd(node_path_1.default.normalize(path));
92
- const folder = fwd(node_path_1.default.dirname(path));
93
- const withinFolder = path.startsWith(this.folder) || ('.' === this.folder && !node_path_1.default.isAbsolute(path));
232
+ // Which of `existing.txt` / `existing.bin` governs is decided by the
233
+ // DESTINATION EXTENSION, with the caller able to promote a file the
234
+ // list does not know about (`saveBinary`) — never to demote a listed
235
+ // one.
236
+ //
237
+ // This used to be decided by the TYPE OF THE VALUE passed in: a string
238
+ // took `existing.txt`, a Buffer `existing.bin`. That made the mode set
239
+ // an artifact of how the content happened to reach save rather than of
240
+ // what the file is, and inverted the two stacks against each other for
241
+ // any binary-extension file whose bytes are plain text — an `a.png`
242
+ // holding ASCII took `txt.preserve` here and `bin.preserve` in Go. It
243
+ // also split TS against itself: the same `a.png` took `existing.txt`
244
+ // through the single-file copy route (which sniffed content) and
245
+ // `existing.bin` through the tree copy route (which consulted the
246
+ // extension).
247
+ const isTextFile = null == isText ? !(0, basic_1.isbinext)(path) : isText;
248
+ const existing = isTextFile ? this.existing.txt : this.existing.bin;
249
+ const withinFolder = this.withinFolder(path);
94
250
  const rpath = this.relative(path, FN + wstr);
251
+ // Two components resolving to the same output path is almost always a
252
+ // mistake (the second silently wins). Detect it here rather than
253
+ // inferring it from adjacent entries in one of the `files` lists —
254
+ // that only caught the case where both saves happened to take the same
255
+ // branch, so it stopped firing as soon as one of them became a no-op.
256
+ if (this.savedPaths.has(path)) {
257
+ dlog('save', 'duplicate save, later content wins: ' + path);
258
+ }
259
+ else {
260
+ this.savedPaths.add(path);
261
+ }
95
262
  const exists = fs.existsSync(path);
96
263
  write = write || !exists;
97
264
  why.push(`start<${write ? 'w' : 'W'}${exists ? 'x' : 'X'}>`);
@@ -103,11 +270,16 @@ class FileHandler {
103
270
  protect: false,
104
271
  conflict: false,
105
272
  };
273
+ // Tracks whether the generated content actually differs from what is
274
+ // already on disk, so the write path can skip a no-op rewrite.
275
+ let unchanged = false;
106
276
  if (exists) {
107
277
  why.push('exists-0');
108
278
  let currentContent = this.loadFile(path);
109
279
  const protect = 0 <= currentContent.indexOf(JOSTRACA_PROTECT);
110
280
  meta.protect = protect;
281
+ unchanged = currentContent.length === newContentSource.length &&
282
+ currentContent === newContentSource;
111
283
  if (existing.preserve) {
112
284
  why.push('preserve-0');
113
285
  if (protect) {
@@ -117,8 +289,7 @@ class FileHandler {
117
289
  else if (currentContent.length !== newContentSource.length ||
118
290
  currentContent !== newContentSource) {
119
291
  why.push('content-0');
120
- let oldpath = fwd(node_path_1.default.join(folder, node_path_1.default.basename(path).replace(/\.[^.]+$/, '') +
121
- '.old' + node_path_1.default.extname(path)));
292
+ let oldpath = annotatedPath(path, 'old');
122
293
  this.copyFile(path, oldpath, whence + 'preserve:');
123
294
  // this.files.preserved.push(path)
124
295
  this.filelog('preserved', path);
@@ -137,8 +308,7 @@ class FileHandler {
137
308
  why.push('present-0');
138
309
  if (currentContent.length !== newContentSource.length || currentContent !== newContentSource) {
139
310
  why.push('content-1');
140
- let newpath = fwd(node_path_1.default.join(folder, node_path_1.default.basename(path).replace(/\.[^.]+$/, '') +
141
- '.new' + node_path_1.default.extname(path)));
311
+ let newpath = annotatedPath(path, 'new');
142
312
  this.saveFile(newpath, newContentSource, { flush: true }, whence + 'present:');
143
313
  this.filelog('presented', path);
144
314
  meta.action = 'present';
@@ -160,7 +330,7 @@ class FileHandler {
160
330
  const newContent = 'string' === typeof newContentSource ? newContentSource :
161
331
  newContentSource.toString('utf8');
162
332
  const diffContent = this.diff(newContent, currentContent.toString());
163
- this.saveFile(path, diffContent, { encoding: 'utf8' }, whence + meta.action);
333
+ this.saveFile(path, diffContent, { encoding: 'utf8', ...modeopts() }, whence + meta.action);
164
334
  // this.files.diffed.push(path)
165
335
  this.filelog('diffed', path);
166
336
  const conflict = newContent !== diffContent;
@@ -175,6 +345,12 @@ class FileHandler {
175
345
  { ...meta, why, action: meta.action, path }]);
176
346
  }
177
347
  else {
348
+ // Equal content is still not a no-op when an explicit mode was
349
+ // asked for — and `write` was already cleared above, so the
350
+ // chmod on the plain-write path below is unreachable from here.
351
+ if (this.chmodUnchanged(path, mode)) {
352
+ why.push('chmod-0');
353
+ }
178
354
  // this.files.unchanged.push(path)
179
355
  this.filelog('unchanged', path);
180
356
  }
@@ -195,10 +371,13 @@ class FileHandler {
195
371
  const newContent = 'string' === typeof newContentSource ? newContentSource :
196
372
  newContentSource.toString('utf8');
197
373
  const prevGenContent = this.loadFile(dpath, { encoding: 'utf8' });
198
- const mergeres = this.merge(newContent, prevGenContent, currentContent.toString(), why);
374
+ const mergeres = this.merge(newContent, // generated
375
+ prevGenContent, // baseline (last generate)
376
+ currentContent.toString(), // existing (on disk)
377
+ why);
199
378
  const diffcontent = mergeres.content;
200
379
  const conflict = mergeres.conflict;
201
- this.saveFile(path, diffcontent, { encoding: 'utf8' }, whence + meta.action);
380
+ this.saveFile(path, diffcontent, { encoding: 'utf8', ...modeopts() }, whence + meta.action);
202
381
  // this.files.merged.push(path)
203
382
  this.filelog('merged', path);
204
383
  if (conflict) {
@@ -216,6 +395,11 @@ class FileHandler {
216
395
  else {
217
396
  why.push('unchanged-0');
218
397
  write = false;
398
+ // As in the diff branch: `write` is cleared here, so an
399
+ // explicit mode has to be applied on this path too.
400
+ if (this.chmodUnchanged(path, mode)) {
401
+ why.push('chmod-0');
402
+ }
219
403
  // this.files.unchanged.push(path)
220
404
  this.filelog('unchanged', path);
221
405
  }
@@ -223,11 +407,32 @@ class FileHandler {
223
407
  }
224
408
  }
225
409
  if (write) {
226
- why.push('write-1');
227
- meta.action = 'write';
228
- this.saveFile(path, newContentSource, whence + meta.action);
229
- // this.files.written.push(path)
230
- this.filelog('written', path);
410
+ // A byte-identical rewrite is a no-op that still bumps mtime, which
411
+ // re-triggers every watcher, bundler and incremental compiler
412
+ // downstream — and costs a full write per file on every run. Record
413
+ // the intent (so meta still says `write`, and the duplicate baseline
414
+ // below is still refreshed) but skip touching the file. Matches the
415
+ // Go port, which already did this.
416
+ if (unchanged) {
417
+ why.push('unchanged-0');
418
+ meta.action = 'write';
419
+ // Identical bytes are not a complete no-op when an explicit mode
420
+ // was asked for. Making an already-correct script executable has
421
+ // to work, and it only ever runs once — after that the content
422
+ // always matches, so skipping here meant `mode` never applied
423
+ // again. Chmod without rewriting, so mtime still is not bumped.
424
+ if (this.chmodUnchanged(path, mode)) {
425
+ why.push('chmod-0');
426
+ }
427
+ this.filelog('unchanged', path);
428
+ }
429
+ else {
430
+ why.push('write-1');
431
+ meta.action = 'write';
432
+ this.saveFile(path, newContentSource, modeopts(), whence + meta.action);
433
+ // this.files.written.push(path)
434
+ this.filelog('written', path);
435
+ }
231
436
  meta.actions.push(meta.action);
232
437
  whenify(meta, this.now());
233
438
  this.audit.push([CN + FN + wstr + meta.action,
@@ -248,8 +453,7 @@ class FileHandler {
248
453
  const dpath = fwd(node_path_1.default.join(dfolder, rpath));
249
454
  if (!this.control.dryrun) {
250
455
  this.ensureDir(fwd(node_path_1.default.dirname(dpath)));
251
- const dopts = { flush: true };
252
- fs.writeFileSync(dpath, newContentSource, dopts);
456
+ this.writeFileAtomic(dpath, newContentSource, { flush: true });
253
457
  }
254
458
  if (null == meta.when) {
255
459
  whenify(meta, this.now());
@@ -271,74 +475,65 @@ class FileHandler {
271
475
  }
272
476
  whence = wstr + FN;
273
477
  const isBinary = (0, basic_1.isbinext)(frompath);
274
- const content = this.loadFile(frompath, { encoding: isBinary ? null : 'utf8' }, whence);
275
- this.save(topath, content, whence);
276
- }
277
- merge(editA, orig, editB, why) {
278
- const out = { content: editB, conflict: false };
279
- let done = false;
280
- // Only merge if needed
281
- // if (origcontent.length === newcontent.length &&
282
- // origcontent === newcontent
283
- // ) {
284
- // done = true
285
- // }
286
- // Don't stack conflicts
287
- if (editB.includes('>>>>>>> EXISTING:')) {
288
- why.push('merge-unresolved-0');
289
- done = true;
290
- // TODO: should this be a error, or collected?
291
- }
292
- if (!done) {
293
- why.push('merge-run-0');
294
- const isowhen = new Date(this.when).toISOString();
295
- const isolast = new Date(this.last()).toISOString();
296
- // Consider the previously generated pure version, stored in
297
- // .jostraca/generated to be the "original". That preserves
298
- // manual edits in the main generated output.
299
- const diffres = Diff3.merge(editA, orig, editB, {
300
- // stringSeparator: '\n',
301
- stringSeparator: /\r?\n/,
302
- excludeFalseConflicts: true,
303
- label: {
304
- a: 'GENERATED: ' + isowhen + '/merge',
305
- b: 'EXISTING: ' + isolast + '/merge',
306
- }
307
- });
308
- const conflict = diffres.conflict;
309
- const content = diffres.result.join('\n');
310
- out.content = content;
311
- out.conflict = conflict;
478
+ // Read bytes and classify BEFORE decoding, never the other way round.
479
+ //
480
+ // This used to load with `encoding: isBinary ? null : 'utf8'` — decode
481
+ // first, sniff second. That order is a trap: for an unlisted extension
482
+ // holding binary (`.wasm`, `.zst`, anything extensionless) the utf8
483
+ // decode replaces every invalid sequence with U+FFFD before the sniff
484
+ // ever runs, and `saveBinary` would then write the damaged text. The
485
+ // sniff still says "binary", because NUL survives a utf8 round-trip;
486
+ // the content does not.
487
+ //
488
+ // It was never reached. The only caller is CopyOp's tree walk, which
489
+ // routes on `isTemplate(name)` — defined as `!isbinext(name)` — so a
490
+ // file arrives here only when its extension IS listed, making
491
+ // `isBinary` true and the utf8 branch dead. Removing the branch rather
492
+ // than trusting that invariant to hold: `copy` reads as a general
493
+ // method, and the next caller has no reason to know it must pre-filter
494
+ // by extension.
495
+ //
496
+ // CopyOp's own copyFile reads a Buffer and go/build.go reads bytes;
497
+ // this is now the same shape as both, with no order to get wrong.
498
+ const raw = this.loadFile(frompath, { encoding: null }, whence);
499
+ // The SOURCE decides: a binary source stays governed by `existing.bin`
500
+ // even when copied to a destination whose extension is not on the
501
+ // list. Same rule as CopyOp's own copy paths and go/build.go's
502
+ // `IsBinExt(src) || IsBinContent(body)`.
503
+ if (isBinary || (0, basic_1.isbincontent)(raw)) {
504
+ this.saveBinary(topath, raw, whence);
505
+ }
506
+ else {
507
+ // Decode only once the bytes are known to be text. Identical to what
508
+ // `readFileSync(..., 'utf8')` produced on this path before.
509
+ this.save(topath, raw.toString('utf8'), whence);
312
510
  }
313
- return out;
314
511
  }
315
- diff(oldcontent, newcontent) {
316
- // Only diff if needed
317
- if (oldcontent.length === newcontent.length &&
318
- oldcontent === newcontent) {
319
- return newcontent;
320
- }
321
- const isowhen = new Date(this.when).toISOString();
322
- const isolast = new Date(this.last()).toISOString();
323
- const difflines = Diff.diffLines(newcontent, oldcontent);
324
- const out = [];
325
- difflines.forEach((part) => {
326
- if (part.added) {
327
- out.push('<<<<<<< GENERATED: ' + isowhen + '/diff\n');
328
- out.push(part.value);
329
- out.push('>>>>>>> GENERATED: ' + isowhen + '/diff\n');
330
- }
331
- else if (part.removed) {
332
- out.push('<<<<<<< EXISTING: ' + isolast + '/diff\n');
333
- out.push(part.value);
334
- out.push('>>>>>>> EXISTING: ' + isolast + '/diff\n');
335
- }
336
- else {
337
- out.push(part.value);
338
- }
512
+ // Three-way merge of the new generate against what is on disk, using
513
+ // the previous generate (kept under .jostraca/generated) as the common
514
+ // ancestor. That ancestor choice is what preserves the user's manual
515
+ // edits.
516
+ //
517
+ // The fast paths and the decision of which applied now live in the diff
518
+ // engine, which reports an `outcome`; this just records it as a
519
+ // breadcrumb.
520
+ merge(generated, baseline, existing, why) {
521
+ const res = DiffUtil.merge(generated, baseline, existing, {
522
+ when: this.when,
523
+ last: this.last(),
524
+ kind: 'merge',
339
525
  });
340
- const content = out.join('');
341
- return content;
526
+ why.push(MERGE_WHY[res.outcome]);
527
+ return { content: res.content, conflict: res.conflict };
528
+ }
529
+ // Annotated two-way view of the difference between the new generate and
530
+ // what is on disk.
531
+ diff(generated, existing) {
532
+ return DiffUtil.diff(generated, existing, {
533
+ when: this.when,
534
+ last: this.last(),
535
+ kind: 'diff',
536
+ }).content;
342
537
  }
343
538
  existsFile(path, whence) {
344
539
  const when = this.now();
@@ -371,16 +566,30 @@ class FileHandler {
371
566
  const FN = 'copyFile:';
372
567
  validPath(frompath, this.maxdepth, CN + FN + 'from:' + wstr);
373
568
  validPath(topath, this.maxdepth, CN + FN + 'to:' + wstr);
374
- const isBinary = (0, basic_1.isbinext)(frompath);
375
569
  // Canonical paths: use directly, do not re-join `this.folder` (see existsFile).
376
570
  const fulltopath = fwd(node_path_1.default.normalize(topath));
377
571
  const fullfrompath = fwd(node_path_1.default.normalize(frompath));
378
572
  try {
379
573
  const existed = fs.existsSync(fulltopath);
380
- this.ensureDir(fwd(node_path_1.default.dirname(fulltopath)));
381
- const content = fs.readFileSync(fullfrompath, isBinary ? undefined : 'utf8');
574
+ // Copy BYTES, always — never decode as UTF-8 first.
575
+ //
576
+ // This used to pick the encoding from `isbinext(frompath)`, i.e. from
577
+ // the extension alone. Content-sniffed binaries (T5) whose extension
578
+ // is not on the list therefore reached the `preserve` branch, which
579
+ // backs up via this method, and were decoded as UTF-8 on the way to
580
+ // the `.old` file: bytes `00 ff 02` were written as `00 ef bf bd 02`.
581
+ // The preserve option silently corrupted the only backup it existed
582
+ // to make.
583
+ //
584
+ // A copy has no reason to know the file type — bytes round-trip for
585
+ // text too — so the classification is simply gone.
586
+ const content = fs.readFileSync(fullfrompath);
587
+ // ensureDir must stay inside the dryrun guard: a dry run must not
588
+ // mutate the tree, and creating the destination folder is a mutation
589
+ // (saveFile already gets this right).
382
590
  if (!this.control.dryrun) {
383
- fs.writeFileSync(fulltopath, content, { flush: true });
591
+ this.ensureDir(fwd(node_path_1.default.dirname(fulltopath)));
592
+ this.writeFileAtomic(fulltopath, content, { flush: true });
384
593
  }
385
594
  this.audit.push([CN + FN + wstr,
386
595
  { topath, frompath, when, existed, size: content.length }]);
@@ -486,6 +695,137 @@ class FileHandler {
486
695
  this.createdDirs.add(dir);
487
696
  }
488
697
  }
698
+ // Apply an explicit mode to a file whose content did not change.
699
+ //
700
+ // Best-effort and returns whether it did anything, so the caller can add
701
+ // an audit breadcrumb. A provider without chmod/stat, or a target that
702
+ // vanished, is not worth failing the build over.
703
+ chmodUnchanged(path, mode) {
704
+ if (null == mode || this.control.dryrun) {
705
+ return false;
706
+ }
707
+ const fs = this.fs();
708
+ if ('function' !== typeof fs.chmodSync) {
709
+ return false;
710
+ }
711
+ try {
712
+ if ('function' === typeof fs.statSync) {
713
+ const stat = fs.statSync(path);
714
+ if (stat && (stat.mode & 0o7777) === (mode & 0o7777)) {
715
+ return false;
716
+ }
717
+ }
718
+ fs.chmodSync(path, mode);
719
+ return true;
720
+ }
721
+ catch (err) {
722
+ dlog('save', 'chmod of unchanged file failed: ' + path);
723
+ return false;
724
+ }
725
+ }
726
+ // Replace `path` atomically: write a sibling temp file, then rename it
727
+ // over the target. Rename within a directory is atomic, so a crash, a
728
+ // SIGINT, or a full disk leaves the user's existing file intact rather
729
+ // than truncated or half-written. That matters most in `merge` and
730
+ // `diff` mode, where the file being rewritten is the one holding the
731
+ // user's hand edits.
732
+ //
733
+ // Rename replaces the inode, so a hard link to the target is broken and
734
+ // the new file would otherwise take default permissions — hence the
735
+ // mode copy below. This is the same trade-off git and npm make.
736
+ //
737
+ // The `fs` provider is pluggable; fall back to a direct write when it
738
+ // has no rename.
739
+ writeFileAtomic(path, content, opts, mode) {
740
+ const fs = this.fs();
741
+ if ('function' !== typeof fs.renameSync) {
742
+ fs.writeFileSync(path, content, opts);
743
+ if (null != mode && 'function' === typeof fs.chmodSync) {
744
+ fs.chmodSync(path, mode);
745
+ }
746
+ return;
747
+ }
748
+ // A UNIQUE temp path, not a fixed one.
749
+ //
750
+ // The fixed `<target>.jostraca-tmp` had two failure modes. A user file
751
+ // that happened to sit at that name was overwritten and then renamed
752
+ // away — destroyed. Worse, two jostraca runs sharing an output folder
753
+ // (a watcher plus a manual run, a CI matrix, a shared mount) collided
754
+ // on the same temp path: one run's rename could publish the other
755
+ // run's bytes onto the target while both reported success. The rename
756
+ // is atomic, but atomicity is worthless if the source is shared.
757
+ //
758
+ // `wx` fails rather than clobbering, so a collision can never destroy
759
+ // an existing file; retry with a fresh name. Providers that do not
760
+ // support the flag simply ignore it, and the randomised name still
761
+ // fixes the concurrent-run case.
762
+ let tmppath = tmppathFor(path);
763
+ let tmpopts = { ...opts, flag: 'wx' };
764
+ // Whether THIS invocation created the temp file. The cleanup below must
765
+ // not delete a path we only ever failed to create: on EEXIST exhaustion
766
+ // that path holds someone else's file, and unlinking it would undo
767
+ // exactly what the `wx` flag is here to guarantee.
768
+ let created = false;
769
+ try {
770
+ for (let attempt = 0;; attempt++) {
771
+ try {
772
+ fs.writeFileSync(tmppath, content, tmpopts);
773
+ created = true;
774
+ break;
775
+ }
776
+ catch (err) {
777
+ // EEXIST means `wx` refused and this call created NOTHING — that
778
+ // is the R12 case, and `created` must stay false so the cleanup
779
+ // does not delete somebody else's file.
780
+ //
781
+ // Any OTHER error (ENOSPC, EIO) happened AFTER the create
782
+ // succeeded, so a partial temp file is on disk and is ours to
783
+ // remove. Without this it survived the failed build.
784
+ if ('EEXIST' !== err?.code) {
785
+ created = true;
786
+ }
787
+ if ('EEXIST' !== err?.code || TMP_PATH_ATTEMPTS - 1 <= attempt) {
788
+ throw err;
789
+ }
790
+ tmppath = tmppathFor(path);
791
+ }
792
+ }
793
+ // An explicit mode wins; otherwise preserve whatever the target
794
+ // already had, since rename replaces the inode.
795
+ //
796
+ // Best-effort: a provider may stat but not chmod, and losing a
797
+ // permission bit is not worth failing the write over.
798
+ if ('function' === typeof fs.chmodSync) {
799
+ if (null != mode) {
800
+ fs.chmodSync(tmppath, mode);
801
+ }
802
+ else if ('function' === typeof fs.statSync) {
803
+ try {
804
+ const stat = fs.statSync(path);
805
+ if (stat && !stat.isDirectory()) {
806
+ fs.chmodSync(tmppath, stat.mode);
807
+ }
808
+ }
809
+ catch (err) {
810
+ // Target absent (the common case for a new file): keep the
811
+ // default mode.
812
+ }
813
+ }
814
+ }
815
+ fs.renameSync(tmppath, path);
816
+ }
817
+ catch (err) {
818
+ try {
819
+ if (created && 'function' === typeof fs.unlinkSync) {
820
+ fs.unlinkSync(tmppath);
821
+ }
822
+ }
823
+ catch (cleanuperr) {
824
+ dlog('writeFileAtomic', 'temp cleanup failed: ' + tmppath);
825
+ }
826
+ throw err;
827
+ }
828
+ }
489
829
  saveFile(path, content, opts, whence) {
490
830
  const when = this.now();
491
831
  const wstr = null == whence ? '' : whence + ':';
@@ -512,7 +852,7 @@ class FileHandler {
512
852
  const existed = fs.existsSync(fullpath);
513
853
  if (!this.control.dryrun) {
514
854
  this.ensureDir(parentfolder);
515
- fs.writeFileSync(fullpath, content, opts);
855
+ this.writeFileAtomic(fullpath, content, opts, opts.mode);
516
856
  }
517
857
  this.audit.push([CN + FN + wstr,
518
858
  { path, when, existed, size: content.length }]);
@@ -546,6 +886,42 @@ function whenify(meta, now) {
546
886
  meta.when = now;
547
887
  meta.hwhen = (0, basic_1.humanify)(now);
548
888
  }
889
+ // Rewrite `foo/bar.txt` to `foo/bar.<kind>.txt`, used for the `.old`
890
+ // (preserve) and `.new` (present) annotations.
891
+ //
892
+ // Node's `Path.extname('.env')` is '', so a leading-dot name has no
893
+ // extension to split off and the whole basename is the stem. The previous
894
+ // implementation stripped the final `.`-suffix with a regex, which for a
895
+ // dotfile removed the entire name: every dotfile in a folder collapsed to
896
+ // the same `.old` path, so a second dotfile silently destroyed the first
897
+ // one's backup.
898
+ function annotatedPath(target, kind) {
899
+ const dir = node_path_1.default.dirname(target);
900
+ const base = node_path_1.default.basename(target);
901
+ const ext = node_path_1.default.extname(base);
902
+ const stem = 0 === ext.length ? base : base.substring(0, base.length - ext.length);
903
+ return fwd(node_path_1.default.join(dir, stem + '.' + kind + ext));
904
+ }
905
+ // Reject path traversal in a component name. Names compose directly into
906
+ // output paths, and models are routinely third-party data, so a name
907
+ // containing a `..` segment is an arbitrary-file-write primitive: it
908
+ // escapes not just the project folder but the output folder entirely.
909
+ //
910
+ // A leading `/` is deliberately still allowed — an absolute Folder name
911
+ // composes with the Project folder (see the `absolute_paths` parity
912
+ // scenario). `Project.folder` is likewise not checked here: it is
913
+ // developer-authored top-level configuration rather than model-derived,
914
+ // and `Project({folder: '../sibling'})` is a legitimate pattern.
915
+ function validName(name, kind, errmark) {
916
+ if (null == name) {
917
+ return;
918
+ }
919
+ const segments = ('' + name).split(/[/\\]/);
920
+ if (segments.includes('..')) {
921
+ throw new Error('ERROR:' + errmark + ' ' + kind +
922
+ ' name must not contain a ".." path segment, name=' + name);
923
+ }
924
+ }
549
925
  function validPath(path, maxdepth, errmark) {
550
926
  if (null == path || '' == path || 'string' !== typeof path) {
551
927
  throw new Error('ERROR:' + errmark + ' invalid path, path=' + path);