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.
- package/README.md +355 -0
- package/dist/build/BuildContext.d.ts +1 -0
- package/dist/build/BuildContext.js +10 -0
- package/dist/build/BuildContext.js.map +1 -1
- package/dist/build/BuildMeta.js +21 -4
- package/dist/build/BuildMeta.js.map +1 -1
- package/dist/build/FileHandler.d.ts +11 -4
- package/dist/build/FileHandler.js +468 -92
- package/dist/build/FileHandler.js.map +1 -1
- package/dist/cmp/Content.js.map +1 -1
- package/dist/cmp/Copy.js +5 -1
- package/dist/cmp/Copy.js.map +1 -1
- package/dist/cmp/File.js +2 -0
- package/dist/cmp/File.js.map +1 -1
- package/dist/cmp/Folder.js.map +1 -1
- package/dist/cmp/Fragment.js +36 -7
- package/dist/cmp/Fragment.js.map +1 -1
- package/dist/cmp/Inject.js +30 -1
- package/dist/cmp/Inject.js.map +1 -1
- package/dist/cmp/Line.js.map +1 -1
- package/dist/cmp/List.js.map +1 -1
- package/dist/cmp/None.js.map +1 -1
- package/dist/cmp/Project.js.map +1 -1
- package/dist/cmp/Slot.js.map +1 -1
- package/dist/diff.d.ts +36 -0
- package/dist/diff.js +446 -0
- package/dist/diff.js.map +1 -0
- package/dist/jostraca.d.ts +19 -18
- package/dist/jostraca.js +83 -24
- package/dist/jostraca.js.map +1 -1
- package/dist/op/ContentOp.js.map +1 -1
- package/dist/op/CopyOp.js +158 -22
- package/dist/op/CopyOp.js.map +1 -1
- package/dist/op/FileOp.js +26 -27
- package/dist/op/FileOp.js.map +1 -1
- package/dist/op/FolderOp.js +3 -0
- package/dist/op/FolderOp.js.map +1 -1
- package/dist/op/FragmentOp.js.map +1 -1
- package/dist/op/InjectOp.js +29 -7
- package/dist/op/InjectOp.js.map +1 -1
- package/dist/op/NoneOp.js.map +1 -1
- package/dist/op/ProjectOp.js.map +1 -1
- package/dist/op/SlotOp.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +1 -0
- package/dist/util/basic.d.ts +5 -1
- package/dist/util/basic.js +218 -9
- package/dist/util/basic.js.map +1 -1
- package/dist/util/point.d.ts +8 -7
- package/dist/util/point.js.map +1 -1
- package/package.json +19 -11
- package/src/build/BuildContext.ts +12 -0
- package/src/build/BuildMeta.ts +24 -5
- package/src/build/FileHandler.ts +489 -121
- package/src/cmp/Copy.ts +5 -1
- package/src/cmp/File.ts +3 -0
- package/src/cmp/Fragment.ts +41 -7
- package/src/cmp/Inject.ts +38 -1
- package/src/diff.ts +657 -0
- package/src/jostraca.ts +83 -20
- package/src/op/CopyOp.ts +173 -22
- package/src/op/FileOp.ts +29 -31
- package/src/op/FolderOp.ts +6 -0
- package/src/op/InjectOp.ts +39 -7
- package/src/tsconfig.json +3 -1
- package/src/types.ts +5 -0
- 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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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 =
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
//
|
|
230
|
-
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
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
|
-
|
|
381
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|