javi-forge 1.39.4 → 1.39.5

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.
@@ -13,6 +13,8 @@ interface FakeFile {
13
13
  }
14
14
  /** Per-call fault predicates; every one defaults to "no fault". */
15
15
  export interface FakeFaults {
16
+ closeRefuse?: (dirPath: string) => boolean;
17
+ reuseFirstHandle?: (dirPath: string) => boolean;
16
18
  /** Refuse revalidateIdentity for a path on its Nth (1-based) call. */
17
19
  revalidateRefuse?: (target: string, callIndex: number) => boolean;
18
20
  /** Refuse proveOwnershipAndMode for a path on its Nth call. */
@@ -57,8 +59,11 @@ export interface FakeSecureFs extends PlatformSecureFs {
57
59
  readonly dirs: Set<string>;
58
60
  readonly files: Map<string, FakeFile>;
59
61
  readonly dirModes: Map<string, number>;
62
+ readonly events: string[];
63
+ readonly closeAttempts: string[];
60
64
  faults: FakeFaults;
61
65
  seedDir(dirPath: string): void;
66
+ shareDirIdentity(target: string, source: string): void;
62
67
  seedFile(filePath: string, bytes: Buffer, mode?: number): void;
63
68
  fileText(filePath: string): string | undefined;
64
69
  hasBackup(dirPath: string): boolean;
@@ -34,6 +34,9 @@ export function makeFakeSecureFs() {
34
34
  const endangeringCounts = new Map();
35
35
  const writeCounts = new Map();
36
36
  const managedCounts = new Map();
37
+ const events = [];
38
+ const closeAttempts = [];
39
+ let handleSeq = 0, firstHandle;
37
40
  const inoFor = (p) => {
38
41
  let ino = inos.get(p);
39
42
  if (ino === undefined) {
@@ -47,11 +50,23 @@ export function makeFakeSecureFs() {
47
50
  m.set(key, next);
48
51
  return next;
49
52
  };
50
- const handleFor = (p) => ({
51
- path: p,
52
- identity: { dev: 1, ino: inoFor(p) },
53
- close: async () => { },
54
- });
53
+ const handleFor = (p) => {
54
+ if (firstHandle && fake.faults.reuseFirstHandle?.(p))
55
+ return firstHandle;
56
+ const token = `${p}#${++handleSeq}`;
57
+ const handle = {
58
+ path: p,
59
+ identity: { dev: 1, ino: inoFor(p) },
60
+ close: async () => {
61
+ events.push(`close ${token}`);
62
+ closeAttempts.push(token);
63
+ if (fake.faults.closeRefuse?.(p))
64
+ throw new Error(`close failed ${p}`);
65
+ },
66
+ };
67
+ firstHandle ??= handle;
68
+ return handle;
69
+ };
55
70
  const isEmptyDir = (p) => {
56
71
  const prefix = `${p}/`;
57
72
  for (const f of files.keys())
@@ -66,10 +81,15 @@ export function makeFakeSecureFs() {
66
81
  dirs,
67
82
  files,
68
83
  dirModes,
84
+ events,
85
+ closeAttempts,
69
86
  faults: {},
70
87
  seedDir(dirPath) {
71
88
  dirs.add(dirPath);
72
89
  },
90
+ shareDirIdentity(target, source) {
91
+ inos.set(target, inoFor(source));
92
+ },
73
93
  seedFile(filePath, bytes, mode = 0o644) {
74
94
  files.set(filePath, { bytes, mode });
75
95
  },
@@ -94,6 +114,7 @@ export function makeFakeSecureFs() {
94
114
  return okValue(handleFor(dirPath));
95
115
  },
96
116
  async revalidateIdentity(target, held) {
117
+ events.push(`revalidate ${target}`);
97
118
  const idx = bump(revalidateCounts, target);
98
119
  if (fake.faults.revalidateRefuse?.(target, idx)) {
99
120
  return unsafe(`identity drift ${target}`);
@@ -135,6 +156,7 @@ export function makeFakeSecureFs() {
135
156
  },
136
157
  async createDirExclusive(parent, name, mode) {
137
158
  const full = path.join(parent.path, name);
159
+ events.push(`create ${full}`);
138
160
  if (dirs.has(full) || files.has(full))
139
161
  return unsafe(`EEXIST ${full}`);
140
162
  dirs.add(full);
@@ -199,6 +221,7 @@ export function makeFakeSecureFs() {
199
221
  return ok();
200
222
  },
201
223
  async rmdirIfIdentityEmpty(handle) {
224
+ events.push(`rmdir ${handle.path}`);
202
225
  if (!dirs.has(handle.path))
203
226
  return unsafe(`rmdir enoent ${handle.path}`);
204
227
  if (inoFor(handle.path) !== handle.identity.ino) {
@@ -120,11 +120,15 @@ export async function runTransaction(input) {
120
120
  // refuse ANY extended entry (lenient-gated THEN strict-managed); only ancestor-
121
121
  // only segments loosen. No `process.platform` here — role is expressed by which
122
122
  // dirs get proveManagedContainer'd (the managedContainers set).
123
+ function hold(dirPath, handle) {
124
+ heldByPath.set(dirPath, handle);
125
+ if (!heldOrder.includes(handle))
126
+ heldOrder.push(handle);
127
+ }
123
128
  async function gate(dirPath, handle) {
124
129
  // Transfer ownership before refusal-capable proofs so `finally` closes a
125
130
  // successfully opened handle even when either proof fails.
126
- heldByPath.set(dirPath, handle);
127
- heldOrder.push(handle);
131
+ hold(dirPath, handle);
128
132
  must(`ownership ${dirPath}`, await secureFs.proveOwnershipAndMode(dirPath));
129
133
  must(`acl ${dirPath}`, await secureFs.proveNoEndangeringAcl(dirPath));
130
134
  }
@@ -164,6 +168,7 @@ export async function runTransaction(input) {
164
168
  // Own the created segment before post-create validation so any refusal rolls
165
169
  // it back and closes its handle.
166
170
  createdDirs.push(created);
171
+ hold(fullPath, created);
167
172
  // Post-create identity revalidation + full gate on the new segment.
168
173
  must(`revalidate-created ${fullPath}`, await secureFs.revalidateIdentity(fullPath, created.identity));
169
174
  await gate(fullPath, created);
@@ -191,6 +196,8 @@ export async function runTransaction(input) {
191
196
  }
192
197
  return true;
193
198
  }
199
+ let workSucceeded = false;
200
+ const errors = [];
194
201
  try {
195
202
  // --- PREFLIGHT: gate the existing chain root..projectDir ---
196
203
  for (const dirPath of ancestorChain(projectDir)) {
@@ -240,7 +247,14 @@ export async function runTransaction(input) {
240
247
  must(`stage-mode ${tName}`, await secureFs.applyExactMode(path.join(dir.path, tName), prior ? prior.mode : 0o600));
241
248
  // Identity revalidation after each writeExclusive (JD-B-005).
242
249
  must(`revalidate-staged ${parentPath}`, await secureFs.revalidateIdentity(dir.path, dir.identity));
243
- staged.push({ dir, tempName: tName, target: component, prior });
250
+ const stagedEntry = {
251
+ dir,
252
+ tempName: tName,
253
+ target: component,
254
+ prior,
255
+ };
256
+ staged.push(stagedEntry);
257
+ stagedEntry.identity = must(`capture-staged ${tName}`, await secureFs.captureFile(path.join(dir.path, tName))).identity;
244
258
  }
245
259
  // --- PRE-FIRST-RENAME FULL-GATE RE-PROVE (JD-007) ---
246
260
  for (const handle of heldOrder) {
@@ -259,7 +273,8 @@ export async function runTransaction(input) {
259
273
  }
260
274
  }
261
275
  // --- COMMIT: asset first, settings second ---
262
- for (const entry of staged) {
276
+ while (staged.length > 0) {
277
+ const entry = staged[0];
263
278
  const base = path.basename(entry.target.path);
264
279
  must(`pre-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
265
280
  const wroteHash = sha256(entry.target.desired);
@@ -273,6 +288,7 @@ export async function runTransaction(input) {
273
288
  wasAbsent: entry.target.wasAbsent,
274
289
  prior: entry.prior,
275
290
  });
291
+ staged.shift();
276
292
  }
277
293
  else if (renamed.mutation === "unknown") {
278
294
  unknownRenameTarget = entry.target.path;
@@ -280,35 +296,38 @@ export async function runTransaction(input) {
280
296
  must(`rename ${base}`, renamed);
281
297
  must(`post-rename ${entry.dir.path}`, await secureFs.revalidateIdentity(entry.dir.path, entry.dir.identity));
282
298
  }
283
- return {
284
- ok: true,
285
- committed: committed.map((c) => c.path),
286
- backups,
287
- errors: [],
288
- };
299
+ workSucceeded = true;
289
300
  }
290
301
  catch (error) {
291
- const errors = [
292
- error instanceof TxAbort ? error.message : String(error),
293
- ];
302
+ errors.push(error instanceof TxAbort ? error.message : String(error));
294
303
  if (unknownRenameTarget) {
295
304
  errors.push(`STOP: unknown rename outcome at ${unknownRenameTarget}; manual recovery; no rollback or directory cleanup`);
296
305
  }
297
306
  else {
298
- await rollback(committed, createdDirs, errors);
307
+ try {
308
+ await rollback(committed, staged, createdDirs, errors);
309
+ }
310
+ catch (rollbackError) {
311
+ errors.push(`rollback: ${String(rollbackError)}`);
312
+ }
299
313
  }
300
- return {
301
- ok: false,
302
- committed: committed.map((c) => c.path),
303
- backups,
304
- errors,
305
- };
306
314
  }
307
315
  finally {
308
- for (const handle of new Set([...createdDirs, ...heldOrder])) {
309
- await handle.close().catch(() => { });
316
+ for (const handle of heldOrder) {
317
+ try {
318
+ await handle.close();
319
+ }
320
+ catch (error) {
321
+ errors.push(`close ${handle.path}: ${String(error)}`);
322
+ }
310
323
  }
311
324
  }
325
+ return {
326
+ ok: workSucceeded && errors.length === 0,
327
+ committed: committed.map((c) => c.path),
328
+ backups,
329
+ errors,
330
+ };
312
331
  /** A rejected or malformed rename reply never proves absence of mutation. */
313
332
  async function observeRename(dir, from, to) {
314
333
  try {
@@ -340,7 +359,7 @@ export async function runTransaction(input) {
340
359
  }
341
360
  throw new TxAbort("backup", `no exclusive backup name after ${BACKUP_NONCE_CANDIDATES}`);
342
361
  }
343
- async function rollback(done, created, errors) {
362
+ async function rollback(done, pending, created, errors) {
344
363
  for (const entry of [...done].reverse()) {
345
364
  if (!(await gateStillValid())) {
346
365
  errors.push(`STOP: lost parent-chain proof; manual recovery at ${entry.path}`);
@@ -393,6 +412,14 @@ export async function runTransaction(input) {
393
412
  }
394
413
  }
395
414
  }
415
+ for (const entry of pending) {
416
+ const identity = entry.identity ??
417
+ must(`recapture-staged ${entry.tempName}`, await secureFs.captureFile(path.join(entry.dir.path, entry.tempName))).identity;
418
+ const removed = await secureFs.unlinkIfIdentity(entry.dir, entry.tempName, identity);
419
+ const detail = removed.detail ?? removed.refusal;
420
+ if (!removed.ok)
421
+ errors.push(`cleanup ${entry.tempName}: ${detail}`);
422
+ }
396
423
  // Remove only tx-created, identity-matched, still-empty segments, child-first.
397
424
  for (const handle of [...created].reverse()) {
398
425
  await secureFs.rmdirIfIdentityEmpty(handle);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javi-forge",
3
- "version": "1.39.4",
3
+ "version": "1.39.5",
4
4
  "description": "Project scaffolding and AI-ready CI bootstrap",
5
5
  "type": "module",
6
6
  "bin": {