@hominis/fireforge 0.39.0 → 0.40.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.
Files changed (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/export-flow.js +14 -6
  5. package/dist/src/commands/export-shared.js +14 -3
  6. package/dist/src/commands/lint-per-patch.js +83 -14
  7. package/dist/src/commands/lint-report.d.ts +21 -0
  8. package/dist/src/commands/lint-report.js +62 -0
  9. package/dist/src/commands/lint.js +22 -18
  10. package/dist/src/commands/manifest.js +2 -0
  11. package/dist/src/commands/patch/rename.js +1 -1
  12. package/dist/src/commands/patch/staged-dependency.js +102 -14
  13. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  14. package/dist/src/commands/re-export-adjacent.js +106 -0
  15. package/dist/src/commands/re-export-options.js +3 -0
  16. package/dist/src/commands/re-export.js +42 -53
  17. package/dist/src/commands/run.js +18 -2
  18. package/dist/src/commands/status-check.d.ts +35 -0
  19. package/dist/src/commands/status-check.js +88 -0
  20. package/dist/src/commands/status.js +16 -5
  21. package/dist/src/commands/test-run.js +18 -3
  22. package/dist/src/commands/token.js +4 -1
  23. package/dist/src/commands/tree.d.ts +16 -0
  24. package/dist/src/commands/tree.js +174 -0
  25. package/dist/src/core/config-validate.js +17 -0
  26. package/dist/src/core/lint-cache.d.ts +15 -5
  27. package/dist/src/core/lint-cache.js +17 -5
  28. package/dist/src/core/patch-export.d.ts +2 -2
  29. package/dist/src/core/patch-export.js +1 -1
  30. package/dist/src/core/patch-lint-checkjs.d.ts +19 -1
  31. package/dist/src/core/patch-lint-checkjs.js +56 -3
  32. package/dist/src/core/patch-lint-css.js +64 -16
  33. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  34. package/dist/src/core/patch-lint-diff.js +31 -0
  35. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  36. package/dist/src/core/patch-lint-ownership.js +30 -0
  37. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  38. package/dist/src/core/patch-lint.d.ts +23 -0
  39. package/dist/src/core/patch-lint.js +30 -2
  40. package/dist/src/core/patch-manifest.d.ts +1 -1
  41. package/dist/src/core/smoke-patterns.d.ts +24 -8
  42. package/dist/src/core/smoke-patterns.js +23 -12
  43. package/dist/src/core/status-classify.d.ts +16 -0
  44. package/dist/src/core/status-classify.js +20 -6
  45. package/dist/src/core/token-category.d.ts +40 -0
  46. package/dist/src/core/token-category.js +264 -0
  47. package/dist/src/core/token-manager.d.ts +7 -0
  48. package/dist/src/core/token-manager.js +48 -196
  49. package/dist/src/core/tree-cow.d.ts +18 -0
  50. package/dist/src/core/tree-cow.js +71 -0
  51. package/dist/src/core/tree-guard.d.ts +35 -0
  52. package/dist/src/core/tree-guard.js +124 -0
  53. package/dist/src/core/tree-store.d.ts +53 -0
  54. package/dist/src/core/tree-store.js +214 -0
  55. package/dist/src/core/typecheck-shim.d.ts +14 -0
  56. package/dist/src/core/typecheck-shim.js +41 -2
  57. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  58. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  59. package/dist/src/errors/git.d.ts +8 -0
  60. package/dist/src/errors/git.js +17 -0
  61. package/dist/src/index.d.ts +2 -0
  62. package/dist/src/index.js +1 -0
  63. package/dist/src/types/commands/options.d.ts +33 -2
  64. package/dist/src/types/config.d.ts +19 -0
  65. package/dist/src/utils/validation.d.ts +10 -0
  66. package/dist/src/utils/validation.js +29 -0
  67. package/package.json +6 -6
@@ -5,10 +5,10 @@ import { FurnaceError } from '../errors/furnace.js';
5
5
  import { toError } from '../utils/errors.js';
6
6
  import { pathExists, readText, writeText } from '../utils/fs.js';
7
7
  import { info, warn } from '../utils/logger.js';
8
- import { escapeRegex } from '../utils/regex.js';
9
8
  import { validateTokenName } from '../utils/validation.js';
10
9
  import { getProjectPaths, loadConfig } from './config.js';
11
10
  import { loadFurnaceConfig } from './furnace-config.js';
11
+ import { assertTokenCategoryExists, categoryHeaderExists, declareCategoryBanner, findCategorySection, findTokenDeclarationInRoot, } from './token-category.js';
12
12
  import { findDarkMediaCloseIndex, findDarkRootInsertionIndex } from './token-dark-mode.js';
13
13
  import { addTokenToDocs } from './token-docs.js';
14
14
  import { insertVariantDeclaration, validateVariantSelector, variantBlockExists, variantBlockHasToken, } from './token-variant.js';
@@ -83,6 +83,10 @@ function normalizeVariantOption(options) {
83
83
  throw new InvalidArgumentError('Cannot combine --variant with --mode override; author the variant declaration with ' +
84
84
  '--mode auto/static instead.', 'variant');
85
85
  }
86
+ if (options.createCategory === true) {
87
+ throw new InvalidArgumentError('--create-category cannot be combined with --variant; variant declarations are routed ' +
88
+ 'into a :root<selector> block, not a category section.', 'variant');
89
+ }
86
90
  const result = validateVariantSelector(options.variant);
87
91
  if (!result.ok) {
88
92
  throw new InvalidArgumentError(`--variant ${result.reason}.`, 'variant');
@@ -113,95 +117,26 @@ async function addVariantTokenToCSS(engineDir, options, tokensCssPath, variant)
113
117
  return { added: true };
114
118
  }
115
119
  /**
116
- * True when `lines` contain a category header (single-line or multi-line
117
- * banner shape) naming `category`. Shared by the pre-add assertion and the
118
- * in-memory banner creation path so both agree on what "exists" means.
120
+ * Evaluates base-`:root` idempotency for one add (FORGE G3): a token
121
+ * already declared in the TARGET category skips; a token declared
122
+ * anywhere else in the base block refuses loud — the pre-0.40.0
123
+ * whole-file substring check silently skipped (and discarded
124
+ * `--create-category`) in that case, exit 0.
119
125
  */
120
- function categoryHeaderExists(lines, category) {
121
- const escapedCategory = escapeRegex(category);
122
- const singleLinePattern = new RegExp(`\\/\\*\\s*=.*${escapedCategory}.*=\\s*\\*\\/`);
123
- for (let i = 0; i < lines.length; i++) {
124
- const line = lines[i] ?? '';
125
- if (singleLinePattern.test(line)) {
126
- return true;
127
- }
128
- if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
129
- for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
130
- const blockLine = lines[j] ?? '';
131
- if (new RegExp(escapedCategory).test(blockLine)) {
132
- return true;
133
- }
134
- if (/\*\//.test(blockLine))
135
- break;
136
- }
137
- }
126
+ function evaluateBaseTokenIdempotency(lines, options, tokensCssPath) {
127
+ const existing = findTokenDeclarationInRoot(lines, options.tokenName);
128
+ if (!existing)
129
+ return { skipped: false };
130
+ if (existing.category === options.category) {
131
+ return { skipped: true, existing };
138
132
  }
139
- return false;
140
- }
141
- async function assertTokenCategoryExists(engineDir, tokensCssPath, category, createCategory = false) {
142
- const filePath = join(engineDir, tokensCssPath);
143
- if (!(await pathExists(filePath))) {
144
- throw new GeneralError(`Token CSS file not found: ${tokensCssPath}`);
145
- }
146
- const content = await readText(filePath);
147
- const lines = content.split('\n');
148
- if (categoryHeaderExists(lines, category))
149
- return;
150
- // The write path declares the banner in the same edit as the token
151
- // insertion, so a missing category is fine when creation was requested.
152
- if (createCategory)
153
- return;
154
- const discoveredCategories = discoverCategoryHeaders(lines);
155
- const available = discoveredCategories.length > 0
156
- ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
157
- : 'The file currently has no category headers.';
158
- throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
159
- `${available}\n\n` +
160
- 'Categories are declared by comment headers. Single-line shape: /* = My Category = */. ' +
161
- 'Multi-line shape: /* =============\\n * My Category\\n * ============= */.\n\n' +
162
- 'Re-run with --create-category to declare the banner and insert the token in one step.');
163
- }
164
- /**
165
- * Scans a tokens CSS file for category header comments and returns the
166
- * category names in document order. Used to enrich the "category not
167
- * found" error body with concrete alternatives the operator can copy.
168
- *
169
- * Mirrors the shapes `findCategorySection` already recognises:
170
- * - Single-line: `/* = Foo = *\/`
171
- * - Multi-line: `/* =====` opening, `Foo` on any of the next ~5 lines,
172
- * closing `*\/`.
173
- *
174
- * This helper exists as a pure inspector; it never throws on malformed
175
- * headers and silently skips shapes it cannot parse.
176
- */
177
- function discoverCategoryHeaders(lines) {
178
- const categories = new Set();
179
- const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
180
- for (let i = 0; i < lines.length; i++) {
181
- const line = lines[i] ?? '';
182
- const singleMatch = singleLinePattern.exec(line);
183
- if (singleMatch?.[1]) {
184
- const extracted = singleMatch[1].trim();
185
- if (extracted.length > 0)
186
- categories.add(extracted);
187
- continue;
188
- }
189
- if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
190
- for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
191
- const blockLine = lines[j] ?? '';
192
- if (/\*\//.test(blockLine))
193
- break;
194
- const trimmed = blockLine.replace(/^\s*\*\s*/, '').trim();
195
- if (trimmed.length === 0)
196
- continue;
197
- if (/^=+$/.test(trimmed))
198
- continue;
199
- categories.add(trimmed);
200
- break;
201
- }
202
- }
203
- }
204
- return [...categories];
133
+ const sectionSuffix = existing.category === undefined ? '' : `, section "${existing.category}"`;
134
+ const remedy = existing.category === undefined
135
+ ? 'remove the stray declaration first'
136
+ : `pass --category "${existing.category}" to target the existing declaration, or remove it first`;
137
+ throw new GeneralError(`Token "${options.tokenName}" is already declared outside category "${options.category}" ` +
138
+ `(${tokensCssPath}:${String(existing.line)}${sectionSuffix}). ` +
139
+ `A token belongs to exactly one category — ${remedy}.`);
205
140
  }
206
141
  /**
207
142
  * Validates token-add inputs without mutating files.
@@ -214,10 +149,11 @@ export async function validateTokenAdd(root, options) {
214
149
  validateTokenNameSyntax(options.tokenName);
215
150
  await validateTokenPrefix(root, options);
216
151
  validateDarkValue(options);
217
- // Variant mode targets a `:root<attr>` block, not a category section, so it
218
- // only needs the tokens CSS file to exist — category checks do not apply.
152
+ // Variant mode targets a `:root<attr>` block, not a category section —
153
+ // but the required --category must still name a REAL category rather
154
+ // than being silently discarded (FORGE G3 bypass 2).
219
155
  if (normalizeVariantOption(options) !== undefined) {
220
- await assertTokensCssExists(engineDir, tokensCssPath);
156
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
221
157
  return;
222
158
  }
223
159
  await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
@@ -249,14 +185,14 @@ export async function addToken(root, options) {
249
185
  skipped,
250
186
  };
251
187
  }
252
- const stripped = content.replace(/\/\*[\s\S]*?\*\//g, '');
253
- const skipped = stripped.includes(options.tokenName + ':');
188
+ const { skipped, existing } = evaluateBaseTokenIdempotency(content.split('\n'), options, tokensCssPath);
254
189
  return {
255
190
  cssAdded: !skipped,
256
191
  docsAdded: !skipped,
257
192
  unmappedAdded: !skipped && !options.value.startsWith('var('),
258
193
  countUpdated: !skipped,
259
194
  skipped,
195
+ ...(existing !== undefined ? { skippedExisting: existing } : {}),
260
196
  };
261
197
  }
262
198
  // Variant overrides are CSS-only: route the declaration into the
@@ -264,6 +200,9 @@ export async function addToken(root, options) {
264
200
  // docs row). Done before the base-CSS path so an override of an existing
265
201
  // base token is not short-circuited by the global idempotency check.
266
202
  if (normalizedVariant !== undefined) {
203
+ // The required --category must name a real category even though the
204
+ // declaration routes into the variant block (FORGE G3 bypass 2).
205
+ await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, false);
267
206
  const { added } = await addVariantTokenToCSS(engineDir, options, tokensCssPath, normalizedVariant);
268
207
  return {
269
208
  cssAdded: added,
@@ -274,7 +213,7 @@ export async function addToken(root, options) {
274
213
  };
275
214
  }
276
215
  // --- CSS file ---
277
- const { added: cssAdded, categoryCreated } = await addTokenToCSS(engineDir, options, tokensCssPath);
216
+ const { added: cssAdded, categoryCreated, existing, } = await addTokenToCSS(engineDir, options, tokensCssPath);
278
217
  if (!cssAdded) {
279
218
  return {
280
219
  cssAdded: false,
@@ -282,6 +221,7 @@ export async function addToken(root, options) {
282
221
  unmappedAdded: false,
283
222
  countUpdated: false,
284
223
  skipped: true,
224
+ ...(existing !== undefined ? { skippedExisting: existing } : {}),
285
225
  };
286
226
  }
287
227
  // --- Documentation ---
@@ -295,100 +235,6 @@ export async function addToken(root, options) {
295
235
  categoryCreated,
296
236
  };
297
237
  }
298
- /**
299
- * Splices a new single-line category banner ("= Name =" comment shape, the
300
- * same format `discoverCategoryHeaders` recognises) just before the closing
301
- * brace of the `:root` block, making the new category the last section.
302
- * Mutates `lines` in place.
303
- */
304
- function declareCategoryBanner(lines, category, tokensCssPath) {
305
- let rootOpen = -1;
306
- for (let i = 0; i < lines.length; i++) {
307
- if (/:root\s*\{/.test(lines[i] ?? '')) {
308
- rootOpen = i;
309
- break;
310
- }
311
- }
312
- if (rootOpen === -1) {
313
- throw new GeneralError(`Cannot create category "${category}": no :root block found in ${tokensCssPath}. ` +
314
- 'Run "fireforge furnace init --force" to re-scaffold the tokens CSS file.');
315
- }
316
- let rootClose = -1;
317
- for (let i = rootOpen + 1; i < lines.length; i++) {
318
- if (/^\s*\}/.test(lines[i] ?? '')) {
319
- rootClose = i;
320
- break;
321
- }
322
- }
323
- if (rootClose === -1) {
324
- throw new GeneralError(`Cannot create category "${category}": the :root block in ${tokensCssPath} never closes.`);
325
- }
326
- lines.splice(rootClose, 0, '', ` /* = ${category} = */`);
327
- }
328
- function findCategorySection(lines, category, tokensCssPath) {
329
- const escapedCategory = escapeRegex(category);
330
- const singleLinePattern = new RegExp(`\\/\\*\\s*=.*${escapedCategory}.*=\\s*\\*\\/`);
331
- let categoryLine = -1;
332
- for (let i = 0; i < lines.length; i++) {
333
- const line = lines[i] ?? '';
334
- // Check single-line format: /* = Category = */
335
- if (singleLinePattern.test(line)) {
336
- categoryLine = i;
337
- break;
338
- }
339
- // Check multi-line format: line opens a block comment with === but does NOT close it
340
- // e.g., "/* ================================================================"
341
- // (NOT "/* ================================================= */" which closes on the same line)
342
- if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
343
- // Look ahead within the comment block (up to 5 lines) for the category text
344
- for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
345
- const blockLine = lines[j] ?? '';
346
- if (new RegExp(escapedCategory).test(blockLine)) {
347
- categoryLine = i;
348
- break;
349
- }
350
- // Stop if we've exited the comment block
351
- if (/\*\//.test(blockLine))
352
- break;
353
- }
354
- if (categoryLine !== -1)
355
- break;
356
- }
357
- }
358
- if (categoryLine === -1) {
359
- const discoveredCategories = discoverCategoryHeaders(lines);
360
- const available = discoveredCategories.length > 0
361
- ? `Available categories in the file: ${discoveredCategories.map((name) => `"${name}"`).join(', ')}.`
362
- : 'The file currently has no category headers.';
363
- throw new GeneralError(`Category "${category}" not found in ${tokensCssPath}.\n\n` +
364
- `${available}\n\n` +
365
- 'Add a header by hand inside the :root block (format: "/* = My Category = */") or re-run "fireforge furnace init --force" to re-seed the default categories.');
366
- }
367
- // Find the end of this category section (next section header or closing })
368
- // Handles both single-line (/* =...= */) and multi-line (/* ===...) section delimiters
369
- // Skip past the current header block first
370
- let scanStart = categoryLine + 1;
371
- for (let i = categoryLine + 1; i < lines.length; i++) {
372
- const line = lines[i] ?? '';
373
- // Skip lines that are part of the current header comment block
374
- if (/^\s*\/\*\s*=/.test(line) || /^\s*\*\s*=/.test(line) || /^\s*\*\//.test(line)) {
375
- scanStart = i + 1;
376
- continue;
377
- }
378
- break;
379
- }
380
- let sectionEnd = lines.length;
381
- for (let i = scanStart; i < lines.length; i++) {
382
- const line = lines[i] ?? '';
383
- if (/\/\*\s*=.*=\s*\*\//.test(line) ||
384
- (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) ||
385
- /^\s*\}/.test(line)) {
386
- sectionEnd = i;
387
- break;
388
- }
389
- }
390
- return { categoryLine, sectionEnd };
391
- }
392
238
  /**
393
239
  * Explicit theme-attribute selectors the override path keeps in sync with
394
240
  * the `@media (prefers-color-scheme: dark)` block. Consumer scaffolds that
@@ -450,13 +296,20 @@ function insertDarkModeOverride(lines, options) {
450
296
  async function addTokenToCSS(engineDir, options, tokensCssPath) {
451
297
  const filePath = join(engineDir, tokensCssPath);
452
298
  await assertTokenCategoryExists(engineDir, tokensCssPath, options.category, options.createCategory === true);
453
- let content = await readText(filePath);
454
- // Idempotency check — strip CSS block comments so we don't match inside them
455
- const stripped = content.replace(/\/\*[\s\S]*?\*\//g, '');
456
- if (stripped.includes(options.tokenName + ':')) {
457
- return { added: false, categoryCreated: false };
458
- }
299
+ const content = await readText(filePath);
459
300
  const lines = content.split('\n');
301
+ // Per-category idempotency (FORGE G3): a declaration in the TARGET
302
+ // category skips; a declaration elsewhere in the base :root block
303
+ // throws instead of silently skipping (and silently discarding
304
+ // --create-category).
305
+ const idempotency = evaluateBaseTokenIdempotency(lines, options, tokensCssPath);
306
+ if (idempotency.skipped) {
307
+ return {
308
+ added: false,
309
+ categoryCreated: false,
310
+ ...(idempotency.existing !== undefined ? { existing: idempotency.existing } : {}),
311
+ };
312
+ }
460
313
  const annotation = getModeAnnotation(options.mode, options.value);
461
314
  // Declare a missing category banner in the same in-memory edit as the
462
315
  // token insertion — the file is written exactly once, so a failure
@@ -488,7 +341,6 @@ async function addTokenToCSS(engineDir, options, tokensCssPath) {
488
341
  if (themeBlocksWritten.length > 0) {
489
342
  info(`Override also written to ${themeBlocksWritten.join(' and ')}.`);
490
343
  }
491
- content = lines.join('\n');
492
- await writeText(filePath, content);
344
+ await writeText(filePath, lines.join('\n'));
493
345
  return { added: true, categoryCreated };
494
346
  }
@@ -0,0 +1,18 @@
1
+ /** Runs a clone command; throws on non-zero exit. */
2
+ export type CloneExecutor = (command: string, args: string[]) => Promise<void>;
3
+ /** How this host can materialise a tree. */
4
+ export type CowCapability = 'clonefile' | 'reflink' | 'none';
5
+ /** Per-platform `cp` argv prefix for a CoW copy of one entry. */
6
+ export declare function cowCopyArgs(capability: Exclude<CowCapability, 'none'>): string[];
7
+ /**
8
+ * Probes CoW support in `dir` by cloning a tiny probe file there.
9
+ * Non-darwin/linux platforms report 'none' without probing.
10
+ */
11
+ export declare function detectCowSupport(dir: string, platform?: NodeJS.Platform, executor?: CloneExecutor): Promise<CowCapability>;
12
+ /**
13
+ * Copies one directory entry (file or directory, recursively) from
14
+ * `sourcePath` to `destinationPath` using the given capability —
15
+ * clonefile/reflink when available, a plain physical copy under
16
+ * 'none' (the `--force-copy` path; the CALLER gates on the flag).
17
+ */
18
+ export declare function cloneEntry(capability: CowCapability, sourcePath: string, destinationPath: string, platform?: NodeJS.Platform, executor?: CloneExecutor): Promise<void>;
@@ -0,0 +1,71 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Copy-on-write cloning for `fireforge tree` (FORGE G15).
4
+ *
5
+ * Capability is probed BY DOING — a tiny `cp -c` (APFS clonefile) or
6
+ * `cp --reflink=always` (btrfs/XFS) in the actual destination directory —
7
+ * so the verdict is truthful for the volume the trees will live on. A
8
+ * filesystem without CoW support REFUSES honestly; `--force-copy` opts
9
+ * into a full physical copy but is never implied (a Firefox tree can be
10
+ * tens of gigabytes).
11
+ *
12
+ * The exec layer is injected so unit tests assert the exact `cp` argv per
13
+ * platform without touching the filesystem.
14
+ */
15
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ import { toError } from '../utils/errors.js';
18
+ import { verbose } from '../utils/logger.js';
19
+ import { exec } from '../utils/process.js';
20
+ const defaultExecutor = async (command, args) => {
21
+ const result = await exec(command, args);
22
+ if (result.exitCode !== 0) {
23
+ throw new Error(`${command} ${args.join(' ')} failed (exit ${String(result.exitCode)}): ${result.stderr}`);
24
+ }
25
+ };
26
+ /** Per-platform `cp` argv prefix for a CoW copy of one entry. */
27
+ export function cowCopyArgs(capability) {
28
+ return capability === 'clonefile' ? ['-c', '-R', '-p'] : ['--reflink=always', '-a'];
29
+ }
30
+ /**
31
+ * Probes CoW support in `dir` by cloning a tiny probe file there.
32
+ * Non-darwin/linux platforms report 'none' without probing.
33
+ */
34
+ export async function detectCowSupport(dir, platform = process.platform, executor = defaultExecutor) {
35
+ if (platform !== 'darwin' && platform !== 'linux')
36
+ return 'none';
37
+ const capability = platform === 'darwin' ? 'clonefile' : 'reflink';
38
+ let probeDir;
39
+ try {
40
+ probeDir = await mkdtemp(join(dir, '.fireforge-cow-probe-'));
41
+ const src = join(probeDir, 'src');
42
+ const dst = join(probeDir, 'dst');
43
+ await writeFile(src, 'probe');
44
+ const flag = capability === 'clonefile' ? '-c' : '--reflink=always';
45
+ await executor('cp', [flag, src, dst]);
46
+ return capability;
47
+ }
48
+ catch (error) {
49
+ verbose(`CoW probe in ${dir} failed: ${toError(error).message}`);
50
+ return 'none';
51
+ }
52
+ finally {
53
+ if (probeDir !== undefined) {
54
+ await rm(probeDir, { recursive: true, force: true }).catch(() => undefined);
55
+ }
56
+ }
57
+ }
58
+ /**
59
+ * Copies one directory entry (file or directory, recursively) from
60
+ * `sourcePath` to `destinationPath` using the given capability —
61
+ * clonefile/reflink when available, a plain physical copy under
62
+ * 'none' (the `--force-copy` path; the CALLER gates on the flag).
63
+ */
64
+ export async function cloneEntry(capability, sourcePath, destinationPath, platform = process.platform, executor = defaultExecutor) {
65
+ const args = capability === 'none'
66
+ ? platform === 'darwin'
67
+ ? ['-R', '-p']
68
+ : ['-a']
69
+ : cowCopyArgs(capability);
70
+ await executor('cp', [...args, sourcePath, destinationPath]);
71
+ }
@@ -0,0 +1,35 @@
1
+ import { type TreeMarker } from './tree-store.js';
2
+ /**
3
+ * Per-command verdicts. `allowed` runs unconditionally; `refused` never
4
+ * runs in a tree; `conditional` consults `TREE_CONDITIONAL_CHECKS` with
5
+ * the invoked subcommand chain and parsed options.
6
+ */
7
+ export declare const TREE_COMMAND_VERDICTS: Readonly<Record<string, 'allowed' | 'refused' | 'conditional'>>;
8
+ interface ConditionalInput {
9
+ /** Subcommand chain below the top-level command (e.g. ['staged-dependency']). */
10
+ subcommands: readonly string[];
11
+ /** Merged options of the ACTION command (leaf). */
12
+ options: Readonly<Record<string, unknown>>;
13
+ /** Positional args of the action command. */
14
+ args: readonly unknown[];
15
+ }
16
+ /**
17
+ * Throws when `commandName` (with its subcommand chain and options) must
18
+ * not run inside the tree described by `marker`. Exported for the
19
+ * preAction hook in cli.ts and for the guard tests.
20
+ */
21
+ export declare function enforceTreeGuard(marker: TreeMarker, commandName: string, input: ConditionalInput): void;
22
+ /**
23
+ * Commander preAction hook body: locates the enclosing tree (if any) and
24
+ * enforces the verdict table for the invoked command chain.
25
+ */
26
+ export declare function runTreeGuardHook(rootCommandName: string | undefined, actionCommand: {
27
+ name: () => string;
28
+ optsWithGlobals: () => Record<string, unknown>;
29
+ args: unknown[];
30
+ parent: {
31
+ name: () => string;
32
+ parent: unknown;
33
+ } | null;
34
+ }): Promise<void>;
35
+ export {};
@@ -0,0 +1,124 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Read-only guard for verification trees (FORGE G15).
4
+ *
5
+ * A tree is a snapshot: its `patches/` and `components/` are copies with
6
+ * no merge-back model, so any command that mutates project or engine
7
+ * state must run in the primary tree. Enforcement is DEFAULT-DENY via an
8
+ * explicit verdict table over every top-level command — a newly added
9
+ * command is refused inside trees until its author classifies it (the
10
+ * drift test in `tree-guard.test.ts` fails otherwise) — and lives in ONE
11
+ * commander `preAction` hook installed by `createProgram()`, so the
12
+ * guard fires no matter how the cwd ended up inside a tree (`tree exec`
13
+ * or a plain `cd`).
14
+ */
15
+ import { existsSync } from 'node:fs';
16
+ import { dirname, join, resolve } from 'node:path';
17
+ import { GeneralError } from '../errors/base.js';
18
+ import { readTreeMarker } from './tree-store.js';
19
+ /**
20
+ * Per-command verdicts. `allowed` runs unconditionally; `refused` never
21
+ * runs in a tree; `conditional` consults `TREE_CONDITIONAL_CHECKS` with
22
+ * the invoked subcommand chain and parsed options.
23
+ */
24
+ export const TREE_COMMAND_VERDICTS = {
25
+ setup: 'refused',
26
+ source: 'refused',
27
+ download: 'refused',
28
+ bootstrap: 'refused',
29
+ import: 'refused',
30
+ resolve: 'refused',
31
+ build: 'refused',
32
+ run: 'refused',
33
+ status: 'allowed',
34
+ reset: 'refused',
35
+ discard: 'refused',
36
+ export: 'conditional',
37
+ 'export-all': 'conditional',
38
+ 're-export': 'refused',
39
+ patch: 'refused',
40
+ rebase: 'refused',
41
+ package: 'refused',
42
+ watch: 'refused',
43
+ // Deferred with the objdir clone: obj-* embeds absolute primary paths.
44
+ test: 'refused',
45
+ config: 'conditional',
46
+ doctor: 'conditional',
47
+ register: 'refused',
48
+ wire: 'refused',
49
+ token: 'refused',
50
+ lint: 'allowed',
51
+ typecheck: 'allowed',
52
+ verify: 'allowed',
53
+ furnace: 'refused',
54
+ // No nesting; `tree list` inside a tree is pointless but harmless — the
55
+ // subcommand check below allows only `list`.
56
+ tree: 'conditional',
57
+ };
58
+ /** The command set named in refusal messages. */
59
+ const ALLOWED_SUMMARY = 'Verification trees support: status, lint, typecheck, verify, doctor (read-only), ' +
60
+ 'config (read), export --dry-run, and export-all --dry-run.';
61
+ const TREE_CONDITIONAL_CHECKS = {
62
+ // Exports write patches/ + patches.json; only the dry-run preview is a read.
63
+ export: ({ options }) => options['dryRun'] === true,
64
+ 'export-all': ({ options }) => options['dryRun'] === true,
65
+ // `config` with no positional value prints; with a value it writes.
66
+ config: ({ args }) => args.filter((a) => typeof a === 'string').length <= 1,
67
+ // Doctor reads unless a repair flag is set.
68
+ doctor: ({ options }) => options['repairFurnace'] !== true && options['fix'] !== true,
69
+ // No tree lifecycle inside a tree; `tree list` is the only read.
70
+ tree: ({ subcommands }) => subcommands[0] === 'list',
71
+ };
72
+ /**
73
+ * Throws when `commandName` (with its subcommand chain and options) must
74
+ * not run inside the tree described by `marker`. Exported for the
75
+ * preAction hook in cli.ts and for the guard tests.
76
+ */
77
+ export function enforceTreeGuard(marker, commandName, input) {
78
+ const verdict = TREE_COMMAND_VERDICTS[commandName] ?? 'refused';
79
+ if (verdict === 'allowed')
80
+ return;
81
+ const conditional = TREE_CONDITIONAL_CHECKS[commandName];
82
+ if (verdict === 'conditional' && conditional && conditional(input))
83
+ return;
84
+ throw new GeneralError(`This is a FireForge verification tree ("${marker.name}", created from ${marker.primaryRoot}). ` +
85
+ `"${[commandName, ...input.subcommands].join(' ')}" mutates project or engine state and must run in the primary tree. ` +
86
+ ALLOWED_SUMMARY);
87
+ }
88
+ /**
89
+ * Commander preAction hook body: locates the enclosing tree (if any) and
90
+ * enforces the verdict table for the invoked command chain.
91
+ */
92
+ export async function runTreeGuardHook(rootCommandName, actionCommand) {
93
+ // Resolve the top-level command and subcommand chain from the action
94
+ // command's parent links (commander nests subcommands).
95
+ const chain = [];
96
+ let node = actionCommand;
97
+ while (node && node.parent) {
98
+ chain.unshift(node.name());
99
+ node = node.parent;
100
+ }
101
+ const [commandName, ...subcommands] = chain;
102
+ if (commandName === undefined || commandName === rootCommandName)
103
+ return;
104
+ const marker = await (async () => {
105
+ let current = resolve(process.cwd());
106
+ for (let depth = 0; depth < 50; depth++) {
107
+ if (existsSync(join(current, 'fireforge.json'))) {
108
+ return readTreeMarker(current);
109
+ }
110
+ const parent = dirname(current);
111
+ if (parent === current)
112
+ break;
113
+ current = parent;
114
+ }
115
+ return undefined;
116
+ })();
117
+ if (!marker)
118
+ return;
119
+ enforceTreeGuard(marker, commandName, {
120
+ subcommands,
121
+ options: actionCommand.optsWithGlobals(),
122
+ args: actionCommand.args,
123
+ });
124
+ }
@@ -0,0 +1,53 @@
1
+ import { type CowCapability } from './tree-cow.js';
2
+ /** `.fireforge/tree.json` — identifies a directory as a verification tree. */
3
+ export interface TreeMarker {
4
+ schemaVersion: 1;
5
+ name: string;
6
+ primaryRoot: string;
7
+ createdAt: string;
8
+ engineHead: string | null;
9
+ patchesFingerprint: string | null;
10
+ }
11
+ /** One row of `tree list`. */
12
+ export interface TreeListEntry {
13
+ name: string;
14
+ path: string;
15
+ createdAt: string;
16
+ staleness: 'fresh' | 'stale (engine advanced)' | 'stale (patches changed)' | 'unknown';
17
+ }
18
+ /** Absolute trees directory for a primary root. */
19
+ export declare function getTreesDir(primaryRoot: string): string;
20
+ /** Serialises tree create/remove against each other (primary-side sidecar lock). */
21
+ export declare function withTreeLifecycleLock<T>(primaryRoot: string, operation: () => Promise<T>): Promise<T>;
22
+ /** Reads the tree marker for a project root, or undefined when not a tree. */
23
+ export declare function readTreeMarker(root: string): Promise<TreeMarker | undefined>;
24
+ /** Current primary-side snapshot identity (engine HEAD + patches.json hash). */
25
+ export declare function computePrimaryFingerprint(primaryRoot: string): Promise<{
26
+ engineHead: string | null;
27
+ patchesFingerprint: string | null;
28
+ }>;
29
+ /** Validates a tree name: one path segment, no traversal. */
30
+ export declare function assertValidTreeName(name: string): void;
31
+ /**
32
+ * Materialises the tree at `treeRoot` from `primaryRoot` using
33
+ * `capability` ('none' = plain copy, already gated on --force-copy by
34
+ * the caller). The engine tree is cloned whole and its `obj-*` build
35
+ * directories removed afterwards — CoW removal is cheap, and per-entry
36
+ * recursion into a Firefox tree is fragile. A fresh `.fireforge/` is
37
+ * created with only `state.json` (when present) and the tree marker.
38
+ */
39
+ export declare function cloneTree(args: {
40
+ primaryRoot: string;
41
+ treeRoot: string;
42
+ name: string;
43
+ capability: CowCapability;
44
+ createdAt: string;
45
+ }): Promise<TreeMarker>;
46
+ /** Lists trees with staleness against the current primary state. */
47
+ export declare function listTrees(primaryRoot: string): Promise<TreeListEntry[]>;
48
+ /**
49
+ * Removes one tree. Refuses when a live process holds the tree's build
50
+ * or engine-session lock, and never deletes a path outside the trees
51
+ * directory (containment check before `rm -rf`).
52
+ */
53
+ export declare function removeTree(primaryRoot: string, name: string): Promise<void>;