@hominis/fireforge 0.37.0 → 0.39.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 (99) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +1 -1
  3. package/dist/src/commands/build.js +13 -8
  4. package/dist/src/commands/config.js +1 -0
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/download.js +5 -2
  7. package/dist/src/commands/export-flow.d.ts +6 -2
  8. package/dist/src/commands/export-flow.js +17 -11
  9. package/dist/src/commands/export-placement-gate.js +4 -6
  10. package/dist/src/commands/export-placement-policy.d.ts +11 -3
  11. package/dist/src/commands/export-placement-policy.js +32 -29
  12. package/dist/src/commands/export-shared.js +17 -2
  13. package/dist/src/commands/export.js +5 -5
  14. package/dist/src/commands/furnace/index.js +23 -18
  15. package/dist/src/commands/import.js +5 -0
  16. package/dist/src/commands/lint-per-patch.js +15 -5
  17. package/dist/src/commands/lint.js +1 -1
  18. package/dist/src/commands/patch/lint-ignore.js +20 -1
  19. package/dist/src/commands/patch/move-files-create.d.ts +19 -0
  20. package/dist/src/commands/patch/move-files-create.js +116 -0
  21. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  22. package/dist/src/commands/patch/move-files-into.js +245 -0
  23. package/dist/src/commands/patch/move-files.d.ts +3 -1
  24. package/dist/src/commands/patch/move-files.js +74 -15
  25. package/dist/src/commands/patch/rename.js +4 -1
  26. package/dist/src/commands/patch/split.d.ts +11 -1
  27. package/dist/src/commands/patch/split.js +6 -5
  28. package/dist/src/commands/re-export-scan.d.ts +17 -0
  29. package/dist/src/commands/re-export-scan.js +51 -0
  30. package/dist/src/commands/re-export.js +17 -6
  31. package/dist/src/commands/reset.js +5 -0
  32. package/dist/src/commands/source.js +28 -3
  33. package/dist/src/commands/status-output.d.ts +9 -0
  34. package/dist/src/commands/status-output.js +33 -1
  35. package/dist/src/commands/status.js +17 -3
  36. package/dist/src/commands/test-diagnose.d.ts +1 -1
  37. package/dist/src/commands/test-diagnose.js +9 -3
  38. package/dist/src/commands/test-modes.d.ts +21 -0
  39. package/dist/src/commands/test-modes.js +42 -0
  40. package/dist/src/commands/test-register.js +6 -5
  41. package/dist/src/commands/test-run.d.ts +2 -0
  42. package/dist/src/commands/test-run.js +8 -2
  43. package/dist/src/commands/test-stale-gate.d.ts +44 -0
  44. package/dist/src/commands/test-stale-gate.js +81 -0
  45. package/dist/src/commands/test.js +52 -81
  46. package/dist/src/core/build-audit.d.ts +12 -0
  47. package/dist/src/core/build-audit.js +14 -0
  48. package/dist/src/core/build-baseline-types.d.ts +41 -0
  49. package/dist/src/core/build-baseline.d.ts +6 -1
  50. package/dist/src/core/build-baseline.js +48 -7
  51. package/dist/src/core/build-prepare.d.ts +9 -0
  52. package/dist/src/core/build-prepare.js +24 -0
  53. package/dist/src/core/config-paths.d.ts +1 -1
  54. package/dist/src/core/config-paths.js +1 -0
  55. package/dist/src/core/config-validate.js +14 -1
  56. package/dist/src/core/discard-baseline.d.ts +60 -0
  57. package/dist/src/core/discard-baseline.js +232 -0
  58. package/dist/src/core/engine-session-lock.d.ts +10 -1
  59. package/dist/src/core/engine-session-lock.js +28 -2
  60. package/dist/src/core/file-lock.d.ts +33 -0
  61. package/dist/src/core/file-lock.js +32 -6
  62. package/dist/src/core/firefox-archive.d.ts +2 -1
  63. package/dist/src/core/firefox-archive.js +32 -8
  64. package/dist/src/core/firefox.d.ts +8 -3
  65. package/dist/src/core/firefox.js +11 -6
  66. package/dist/src/core/furnace-validate-helpers.js +28 -1
  67. package/dist/src/core/license-headers.d.ts +24 -0
  68. package/dist/src/core/license-headers.js +103 -1
  69. package/dist/src/core/patch-export.d.ts +10 -1
  70. package/dist/src/core/patch-export.js +12 -2
  71. package/dist/src/core/patch-lint-checkjs.d.ts +6 -1
  72. package/dist/src/core/patch-lint-checkjs.js +30 -14
  73. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  74. package/dist/src/core/patch-lint-cross.js +69 -9
  75. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  76. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  77. package/dist/src/core/patch-lint.d.ts +3 -0
  78. package/dist/src/core/patch-lint.js +29 -8
  79. package/dist/src/core/patch-policy.d.ts +14 -1
  80. package/dist/src/core/patch-policy.js +13 -2
  81. package/dist/src/core/status-classify.d.ts +11 -2
  82. package/dist/src/core/status-classify.js +49 -25
  83. package/dist/src/core/test-harness-crash.d.ts +10 -0
  84. package/dist/src/core/test-harness-crash.js +16 -0
  85. package/dist/src/core/test-stale-check.d.ts +56 -1
  86. package/dist/src/core/test-stale-check.js +114 -4
  87. package/dist/src/core/token-manager.js +39 -2
  88. package/dist/src/core/token-variant.d.ts +7 -0
  89. package/dist/src/core/token-variant.js +9 -0
  90. package/dist/src/core/typecheck-shim.d.ts +13 -0
  91. package/dist/src/core/typecheck-shim.js +17 -2
  92. package/dist/src/core/typecheck.js +14 -3
  93. package/dist/src/types/commands/options.d.ts +69 -3
  94. package/dist/src/types/config.d.ts +22 -0
  95. package/dist/src/utils/options.d.ts +20 -0
  96. package/dist/src/utils/options.js +39 -0
  97. package/dist/src/utils/validation.d.ts +5 -0
  98. package/dist/src/utils/validation.js +7 -0
  99. package/package.json +3 -3
@@ -0,0 +1,232 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
4
+ *
5
+ * The engine convention is: HEAD = pristine upstream Firefox, and the
6
+ * applied patch queue lives as UNCOMMITTED worktree changes on top of it
7
+ * (`git apply` without `--index` → patch-edited tracked files are unstaged
8
+ * modifications, patch-created files are untracked). The pre-0.39.0
9
+ * `discard` was purely git-mechanical — it restored to HEAD and deleted
10
+ * untracked files, silently reverting patch-backed files PAST their owning
11
+ * patch and deleting patch-created files outright.
12
+ *
13
+ * This module classifies each status entry against the patch manifest's
14
+ * ownership claims and plans the correct restore target: the PATCH-APPLIED
15
+ * baseline (HEAD content folded through every affecting patch in order) for
16
+ * claimed files, and the legacy git mechanics for unmanaged ones.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { GeneralError } from '../errors/base.js';
20
+ import { toError } from '../utils/errors.js';
21
+ import { readText, removeFile, writeText } from '../utils/fs.js';
22
+ import { discardStatusEntry, fileExistsInHead, listTrackedInHead, restoreTrackedPath, unstageFiles, } from './git-file-ops.js';
23
+ import { createPatchedContentComputer } from './patch-apply.js';
24
+ import { loadPatchesManifestState } from './patch-manifest-io.js';
25
+ import { parseDiffSections } from './patch-parse.js';
26
+ import { buildPatchClaims } from './status-classify.js';
27
+ /** Builds an all-unmanaged plan set — the `--to-upstream` (legacy) shape. */
28
+ export function planUpstreamDiscards(entries) {
29
+ return entries.map((entry) => ({ entry, kind: 'unmanaged', owners: [], conflicted: false }));
30
+ }
31
+ /**
32
+ * Plans the restore target for each status entry. Entries claimed by the
33
+ * patch manifest restore to the patch-applied baseline; unclaimed entries
34
+ * keep the legacy git mechanics. A missing patches dir or empty manifest
35
+ * degrades every plan to `unmanaged` (identical to pre-0.39.0 behavior).
36
+ *
37
+ * A corrupt manifest or an unreconstructible patch baseline REFUSES with a
38
+ * GeneralError naming `--to-upstream` — silently falling back to pristine
39
+ * HEAD is exactly the data-loss path this module exists to close.
40
+ */
41
+ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
42
+ const state = await loadPatchesManifestState(patchesDir);
43
+ if (state.exists && state.parseError) {
44
+ throw new GeneralError('patches/patches.json is unreadable — refusing to discard while patch ownership cannot be ' +
45
+ 'determined. Fix the manifest, or pass --to-upstream to explicitly revert to pristine ' +
46
+ 'upstream (HEAD).');
47
+ }
48
+ const claims = state.manifest
49
+ ? buildPatchClaims(state.manifest.patches)
50
+ : new Map();
51
+ const plans = [];
52
+ const claimedEntries = entries.filter((entry) => claims.has(entry.file) || (entry.originalPath !== undefined && claims.has(entry.originalPath)));
53
+ if (claimedEntries.length === 0) {
54
+ return planUpstreamDiscards(entries);
55
+ }
56
+ const computePatched = await createPatchedContentComputer(patchesDir, engineDir);
57
+ const claimedFiles = [
58
+ ...new Set(claimedEntries.flatMap((entry) => entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file])),
59
+ ];
60
+ const trackedInHead = await listTrackedInHead(engineDir, claimedFiles);
61
+ const computeExpected = async (path) => {
62
+ try {
63
+ return await computePatched(path);
64
+ }
65
+ catch (error) {
66
+ throw new GeneralError(`Cannot reconstruct the patch baseline for ${path} (${toError(error).message}). ` +
67
+ 'Fix the owning patch, or pass --to-upstream to explicitly revert to pristine ' +
68
+ 'upstream (HEAD).');
69
+ }
70
+ };
71
+ // `computePatchedContent` folds a deletion into an empty string (an
72
+ // apply-to-content limitation), which is indistinguishable from a
73
+ // legitimately empty file. Consult the owning patches' diff sections
74
+ // directly: the LAST section (highest-ordered owner) naming the path
75
+ // decides whether the baseline is "absent".
76
+ const sectionCache = new Map();
77
+ const isDeletedAtBaseline = async (path, owners) => {
78
+ for (let i = owners.length - 1; i >= 0; i--) {
79
+ const owner = owners[i];
80
+ if (owner === undefined)
81
+ continue;
82
+ let sections = sectionCache.get(owner);
83
+ if (!sections) {
84
+ try {
85
+ sections = parseDiffSections(await readText(join(patchesDir, owner)));
86
+ }
87
+ catch {
88
+ sections = [];
89
+ }
90
+ sectionCache.set(owner, sections);
91
+ }
92
+ for (const section of sections) {
93
+ if (section.targetPath === path || section.sourcePath === path) {
94
+ return section.isDeletedFile;
95
+ }
96
+ }
97
+ }
98
+ return false;
99
+ };
100
+ for (const entry of entries) {
101
+ const owners = [
102
+ ...new Set([
103
+ ...(claims.get(entry.file) ?? []),
104
+ ...(entry.originalPath !== undefined ? (claims.get(entry.originalPath) ?? []) : []),
105
+ ]),
106
+ ];
107
+ if (owners.length === 0) {
108
+ plans.push({ entry, kind: 'unmanaged', owners, conflicted: false });
109
+ continue;
110
+ }
111
+ let expectedContent = claims.has(entry.file) ? await computeExpected(entry.file) : null;
112
+ if (expectedContent !== null &&
113
+ (await isDeletedAtBaseline(entry.file, claims.get(entry.file) ?? []))) {
114
+ expectedContent = null;
115
+ }
116
+ const expectedOriginalContent = entry.originalPath !== undefined && claims.has(entry.originalPath)
117
+ ? await computeExpected(entry.originalPath)
118
+ : undefined;
119
+ const kind = expectedContent === null
120
+ ? 'patch-deleted'
121
+ : trackedInHead.has(entry.file)
122
+ ? 'patch-backed'
123
+ : 'patch-created';
124
+ plans.push({
125
+ entry,
126
+ kind,
127
+ owners,
128
+ conflicted: owners.length > 1,
129
+ expectedContent,
130
+ ...(expectedOriginalContent !== undefined ? { expectedOriginalContent } : {}),
131
+ });
132
+ }
133
+ return plans;
134
+ }
135
+ /**
136
+ * Applies one restore plan. Patch-claimed paths end up exactly as `import`
137
+ * leaves them: patched content as an unstaged worktree state (tracked files
138
+ * read ` M`, created files `??`), nothing staged. Unmanaged plans delegate
139
+ * to the legacy {@link discardStatusEntry} mechanics.
140
+ */
141
+ export async function applyDiscardBaseline(engineDir, plan) {
142
+ if (plan.kind === 'unmanaged') {
143
+ await discardStatusEntry(engineDir, plan.entry);
144
+ return;
145
+ }
146
+ const { entry } = plan;
147
+ // Drop any staged edits/adds/renames first so the rewritten content is a
148
+ // plain worktree state (harmless when nothing is staged; skipped for
149
+ // purely-untracked entries which have no index footprint).
150
+ if (!entry.isUntracked) {
151
+ const indexPaths = entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file];
152
+ await unstageFiles(engineDir, indexPaths);
153
+ }
154
+ if (plan.expectedContent === null || plan.expectedContent === undefined) {
155
+ await removeFile(join(engineDir, entry.file));
156
+ }
157
+ else {
158
+ await writeText(join(engineDir, entry.file), plan.expectedContent);
159
+ }
160
+ // A rename/copy's original side restores to ITS baseline too: the patch
161
+ // baseline when claimed, else the legacy HEAD restore (matching what
162
+ // discardStatusEntry does for that side).
163
+ if (entry.originalPath !== undefined) {
164
+ if (plan.expectedOriginalContent !== undefined) {
165
+ if (plan.expectedOriginalContent === null) {
166
+ await removeFile(join(engineDir, entry.originalPath));
167
+ }
168
+ else {
169
+ await writeText(join(engineDir, entry.originalPath), plan.expectedOriginalContent);
170
+ }
171
+ }
172
+ else if (await fileExistsInHead(engineDir, entry.originalPath)) {
173
+ await restoreTrackedPath(engineDir, entry.originalPath);
174
+ }
175
+ }
176
+ }
177
+ /** One-line annotation for dry-run listings and confirmation summaries. */
178
+ export function describeDiscardBaseline(plan) {
179
+ const owners = plan.owners.join(' + ');
180
+ switch (plan.kind) {
181
+ case 'unmanaged':
182
+ return plan.entry.isUntracked ? 'unmanaged — delete' : 'unmanaged — revert to upstream';
183
+ case 'patch-backed':
184
+ return `patch baseline: ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
185
+ case 'patch-created':
186
+ return `re-materialize from ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
187
+ case 'patch-deleted':
188
+ return `delete — ${owners} removes it`;
189
+ }
190
+ }
191
+ /** Single-file outro naming the baseline that was restored. */
192
+ export function describeDiscardOutcome(plan, toUpstream) {
193
+ if (toUpstream)
194
+ return 'File restored to pristine upstream (HEAD)';
195
+ const owners = plan.owners.join(' + ');
196
+ switch (plan.kind) {
197
+ case 'unmanaged':
198
+ return 'File restored to original state';
199
+ case 'patch-backed':
200
+ return `File restored to patch baseline (${owners})`;
201
+ case 'patch-created':
202
+ return `File re-materialized from patch baseline (${owners})`;
203
+ case 'patch-deleted':
204
+ return `File removed to match patch baseline (${owners} deletes it)`;
205
+ }
206
+ }
207
+ /** Batch outro summarising how many files restored to which baseline. */
208
+ export function summarizeDiscardBaselines(plans, succeeded) {
209
+ const patchBacked = plans.filter((p) => p.kind === 'patch-backed').length;
210
+ const created = plans.filter((p) => p.kind === 'patch-created').length;
211
+ const deleted = plans.filter((p) => p.kind === 'patch-deleted').length;
212
+ const unmanaged = plans.filter((p) => p.kind === 'unmanaged').length;
213
+ if (patchBacked + created + deleted === 0) {
214
+ return `${String(succeeded)} file(s) restored to upstream state`;
215
+ }
216
+ const parts = [];
217
+ if (patchBacked > 0)
218
+ parts.push(`${String(patchBacked)} to patch baseline`);
219
+ if (created > 0)
220
+ parts.push(`${String(created)} re-materialized`);
221
+ if (deleted > 0)
222
+ parts.push(`${String(deleted)} removed per patch baseline`);
223
+ if (unmanaged > 0)
224
+ parts.push(`${String(unmanaged)} to upstream state`);
225
+ return `${String(succeeded)} file(s) restored: ${parts.join(', ')}`;
226
+ }
227
+ /** Warn text for a multi-owner (conflicted) restore. */
228
+ export function describeConflictWarning(plan) {
229
+ return (`${plan.entry.file} is claimed by ${String(plan.owners.length)} patches ` +
230
+ `(${plan.owners.join(', ')}). Restored the cumulative baseline; run ` +
231
+ '"fireforge status --ownership" to resolve ownership.');
232
+ }
@@ -1,7 +1,16 @@
1
+ export interface EngineSessionLockOptions {
2
+ /**
3
+ * Wait up to this many seconds for the engine session lock instead of the
4
+ * legacy ~1 s fail-fast. Enables exponential poll backoff (100 ms → 2 s)
5
+ * and a holder-identified progress line roughly every 5 s. `undefined`
6
+ * preserves the historical fail-fast behavior exactly.
7
+ */
8
+ waitLockSeconds?: number | undefined;
9
+ }
1
10
  /**
2
11
  *
3
12
  */
4
- export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>): Promise<T>;
13
+ export declare function withEngineSessionLock<T>(projectRoot: string, command: string, operation: () => Promise<T>, options?: EngineSessionLockOptions): Promise<T>;
5
14
  /**
6
15
  *
7
16
  */
@@ -1,19 +1,45 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  import { join } from 'node:path';
3
3
  import { GeneralError } from '../errors/base.js';
4
+ import { info } from '../utils/logger.js';
4
5
  import { withFileLock } from './file-lock.js';
5
6
  import { git } from './git-base.js';
6
7
  const ENGINE_SESSION_LOCK_PATH = join('.fireforge', 'engine-session.lock');
8
+ /**
9
+ * Formats the periodic operator-facing waiting line printed while `--wait-lock`
10
+ * polls a contended engine session lock. The holder identification comes from
11
+ * the lock's owner-metadata lines (`command=…`, `started=…`); an unreadable
12
+ * owner file degrades to the anonymous form.
13
+ */
14
+ function formatWaitProgressLine(waitedMs, timeoutMs, holder) {
15
+ const progress = `${String(Math.round(waitedMs / 1000))}s of up to ${String(Math.round(timeoutMs / 1000))}s`;
16
+ if (holder === undefined) {
17
+ return `Waiting for the FireForge engine lock — ${progress}.`;
18
+ }
19
+ const details = holder.metadata.length > 0 ? ` (${holder.metadata.join(', ')})` : '';
20
+ return `Waiting for the FireForge engine lock held by PID ${String(holder.pid)}${details} — ${progress}.`;
21
+ }
7
22
  /**
8
23
  *
9
24
  */
10
- export async function withEngineSessionLock(projectRoot, command, operation) {
25
+ export async function withEngineSessionLock(projectRoot, command, operation, options = {}) {
11
26
  if (process.env['FIREFORGE_ENABLE_ENGINE_SESSION_LOCK_IN_TEST'] !== '1' &&
12
27
  (process.env['NODE_ENV'] === 'test' || process.env['VITEST'] !== undefined)) {
13
28
  return operation();
14
29
  }
30
+ const { waitLockSeconds } = options;
15
31
  return withFileLock(join(projectRoot, ENGINE_SESSION_LOCK_PATH), operation, {
16
- timeoutMs: 1000,
32
+ timeoutMs: waitLockSeconds !== undefined ? waitLockSeconds * 1000 : 1000,
33
+ ...(waitLockSeconds !== undefined
34
+ ? {
35
+ pollMs: 100,
36
+ pollMaxMs: 2000,
37
+ waitProgressMs: 5000,
38
+ onWaitProgress: ({ waitedMs, timeoutMs, holder }) => {
39
+ info(formatWaitProgressLine(waitedMs, timeoutMs, holder));
40
+ },
41
+ }
42
+ : {}),
17
43
  ownerMetadata: [`command=${command}`, `started=${new Date().toISOString()}`],
18
44
  onTimeoutMessage: `Another FireForge engine-mutating command is already running. ` +
19
45
  `Wait for it to finish, then retry \`${command}\`.`,
@@ -1,6 +1,23 @@
1
+ /**
2
+ * Snapshot of the current lock holder handed to {@link FileLockOptions.onWaitProgress}.
3
+ * `metadata` carries the pid file's human-diagnostic lines (line 3 onward,
4
+ * e.g. `command=build`, `started=…`) written via
5
+ * {@link FileLockOptions.ownerMetadata}.
6
+ */
7
+ export interface LockHolder {
8
+ pid: number;
9
+ alive: boolean;
10
+ metadata: string[];
11
+ }
1
12
  export interface FileLockOptions {
2
13
  timeoutMs?: number;
3
14
  pollMs?: number;
15
+ /**
16
+ * When set, the poll interval doubles after every contended poll (starting
17
+ * from `pollMs`) up to this cap. When absent, polling stays at the fixed
18
+ * `pollMs` interval — zero behavior change for existing callers.
19
+ */
20
+ pollMaxMs?: number;
4
21
  staleMs?: number;
5
22
  /**
6
23
  * Interval between stale-lock probes while waiting (defaults to
@@ -8,6 +25,22 @@ export interface FileLockOptions {
8
25
  * the mid-wait reaping path without multi-second sleeps.
9
26
  */
10
27
  staleReprobeMs?: number;
28
+ /**
29
+ * Interval between {@link onWaitProgress} callbacks while the lock is
30
+ * contended. Progress reporting is off unless both this and
31
+ * `onWaitProgress` are set.
32
+ */
33
+ waitProgressMs?: number;
34
+ /**
35
+ * Invoked roughly every `waitProgressMs` while waiting for a contended
36
+ * lock. `holder` is `undefined` when the owner PID file is missing or
37
+ * unreadable.
38
+ */
39
+ onWaitProgress?: (progress: {
40
+ waitedMs: number;
41
+ timeoutMs: number;
42
+ holder: LockHolder | undefined;
43
+ }) => void;
11
44
  onTimeoutMessage?: string;
12
45
  onStaleLockMessage?: (ageMs: number) => string | undefined;
13
46
  /** Extra owner-file lines for human diagnostics; ignored by lock mechanics. */
@@ -50,7 +50,8 @@ function isProcessAlive(pid) {
50
50
  }
51
51
  }
52
52
  /**
53
- * Reads the owner PID (line 1) and acquisition token (line 2) from a lock
53
+ * Reads the owner PID (line 1), acquisition token (line 2), and diagnostic
54
+ * metadata (lines 3+, see {@link FileLockOptions.ownerMetadata}) from a lock
54
55
  * directory's PID file. Returns `{ present: false }` when the PID file is
55
56
  * missing or the PID does not parse as a finite integer (caller falls back
56
57
  * to the age-only staleness heuristic).
@@ -62,14 +63,15 @@ function isProcessAlive(pid) {
62
63
  async function readLockOwner(lockPath) {
63
64
  try {
64
65
  const pidContent = await readFile(join(lockPath, LOCK_PID_FILE), 'utf-8');
65
- const [pidLine, tokenLine] = pidContent.split('\n');
66
+ const [pidLine, tokenLine, ...metadataLines] = pidContent.split('\n');
66
67
  const pid = parseInt((pidLine ?? '').trim(), 10);
67
68
  if (Number.isFinite(pid)) {
69
+ const metadata = metadataLines.map((line) => line.trim()).filter((line) => line.length > 0);
68
70
  const token = tokenLine?.trim();
69
71
  if (token !== undefined && token.length > 0) {
70
- return { present: true, pid, token };
72
+ return { present: true, pid, token, metadata };
71
73
  }
72
- return { present: true, pid };
74
+ return { present: true, pid, metadata };
73
75
  }
74
76
  }
75
77
  catch {
@@ -77,6 +79,18 @@ async function readLockOwner(lockPath) {
77
79
  }
78
80
  return { present: false };
79
81
  }
82
+ /**
83
+ * Reads the contended lock's owner and reports wait progress to the caller's
84
+ * `onWaitProgress` callback. Failure to read the owner degrades to
85
+ * `holder: undefined` — progress reporting must never break the wait loop.
86
+ */
87
+ async function reportWaitProgress(lockPath, waitedMs, timeoutMs, onWaitProgress) {
88
+ const owner = await readLockOwner(lockPath);
89
+ const holder = owner.present
90
+ ? { pid: owner.pid, alive: isProcessAlive(owner.pid), metadata: owner.metadata }
91
+ : undefined;
92
+ onWaitProgress({ waitedMs, timeoutMs, holder });
93
+ }
80
94
  /**
81
95
  * Reaps a lock directory believed to be stale, safely against concurrent
82
96
  * reapers and a concurrent legitimate re-acquisition.
@@ -216,8 +230,11 @@ export async function withFileLock(lockPath, operation, options = {}) {
216
230
  const pollMs = options.pollMs ?? DEFAULT_LOCK_POLL_MS;
217
231
  const staleMs = options.staleMs ?? DEFAULT_STALE_LOCK_MS;
218
232
  const staleReprobeMs = options.staleReprobeMs ?? STALE_REPROBE_INTERVAL_MS;
219
- const deadline = Date.now() + timeoutMs;
233
+ const startedAt = Date.now();
234
+ const deadline = startedAt + timeoutMs;
220
235
  let lastStaleProbeAt;
236
+ let currentPollMs = pollMs;
237
+ let lastProgressAt = startedAt;
221
238
  await ensureDir(dirname(lockPath));
222
239
  for (;;) {
223
240
  try {
@@ -246,7 +263,16 @@ export async function withFileLock(lockPath, operation, options = {}) {
246
263
  : ' If no other FireForge process is running, remove the lock directory and retry.';
247
264
  throw new Error(options.onTimeoutMessage ?? `Timed out waiting for file lock ${lockPath}.${holderHint}`, { cause: error });
248
265
  }
249
- await sleep(pollMs);
266
+ if (options.onWaitProgress !== undefined &&
267
+ options.waitProgressMs !== undefined &&
268
+ Date.now() - lastProgressAt >= options.waitProgressMs) {
269
+ lastProgressAt = Date.now();
270
+ await reportWaitProgress(lockPath, Date.now() - startedAt, timeoutMs, options.onWaitProgress);
271
+ }
272
+ await sleep(currentPollMs);
273
+ if (options.pollMaxMs !== undefined) {
274
+ currentPollMs = Math.min(currentPollMs * 2, options.pollMaxMs);
275
+ }
250
276
  }
251
277
  }
252
278
  // Stamp ownership (PID + per-acquisition token) into the lock directory so
@@ -39,6 +39,7 @@ export declare function validateArchiveMetadata(data: unknown): ArchiveMetadata;
39
39
  * Resolves archive identity for URL generation and cache storage.
40
40
  * @param version - Requested Firefox version
41
41
  * @param product - Firefox product type
42
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
42
43
  * @returns Resolved archive descriptor
43
44
  */
44
- export declare function resolveArchive(version: string, product?: FirefoxProduct): ResolvedArchive;
45
+ export declare function resolveArchive(version: string, product?: FirefoxProduct, candidate?: string): ResolvedArchive;
@@ -4,15 +4,26 @@
4
4
  */
5
5
  import { ConfigError } from '../errors/config.js';
6
6
  import { parseObject } from '../utils/parse.js';
7
- import { isValidFirefoxProduct } from '../utils/validation.js';
7
+ import { isValidFirefoxCandidate, isValidFirefoxProduct } from '../utils/validation.js';
8
8
  /**
9
9
  * Base URLs for Firefox source archives on archive.mozilla.org.
10
10
  */
11
- const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox/releases';
12
- const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition/releases';
11
+ const FIREFOX_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/firefox';
12
+ const DEVEDITION_ARCHIVE_BASE_URL = 'https://archive.mozilla.org/pub/devedition';
13
13
  function getArchiveBaseUrl(product) {
14
14
  return product === 'firefox-devedition' ? DEVEDITION_ARCHIVE_BASE_URL : FIREFOX_ARCHIVE_BASE_URL;
15
15
  }
16
+ /**
17
+ * Directory holding the release's artifacts: `releases/<version>/` for
18
+ * final releases, `candidates/<version>-candidates/<buildN>/` when a
19
+ * release-candidate build is requested for pre-release verification.
20
+ */
21
+ function getArchiveDirUrl(product, archiveVersion, candidate) {
22
+ const base = getArchiveBaseUrl(product);
23
+ return candidate === undefined
24
+ ? `${base}/releases/${archiveVersion}`
25
+ : `${base}/candidates/${archiveVersion}-candidates/${candidate}`;
26
+ }
16
27
  /**
17
28
  * Validates raw JSON data as ArchiveMetadata.
18
29
  * @param data - Unknown data to validate
@@ -41,13 +52,20 @@ export function validateArchiveMetadata(data) {
41
52
  * Resolves archive identity for URL generation and cache storage.
42
53
  * @param version - Requested Firefox version
43
54
  * @param product - Firefox product type
55
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
44
56
  * @returns Resolved archive descriptor
45
57
  */
46
- export function resolveArchive(version, product = 'firefox') {
58
+ export function resolveArchive(version, product = 'firefox', candidate) {
47
59
  // Reject versions containing path traversal characters
48
60
  if (version.includes('/') || version.includes('..') || version.includes('\\')) {
49
61
  throw new ConfigError(`Invalid Firefox version "${version}": contains disallowed characters`, 'firefox.version');
50
62
  }
63
+ // Defense-in-depth: config validation enforces this shape already, but a
64
+ // caller-supplied candidate becomes both a URL path segment and part of
65
+ // the cache filename, so re-validate here like the version check above.
66
+ if (candidate !== undefined && !isValidFirefoxCandidate(candidate)) {
67
+ throw new ConfigError(`Invalid Firefox candidate "${candidate}": must look like "buildN" (e.g. "build2")`, 'firefox.candidate');
68
+ }
51
69
  // ESR status is determined solely by the product field. Config validation
52
70
  // ensures product and version are consistent, so we never need to infer
53
71
  // ESR from the version string independently.
@@ -55,18 +73,24 @@ export function resolveArchive(version, product = 'firefox') {
55
73
  const isEsr = product === 'firefox-esr';
56
74
  const archiveVersion = isEsr ? `${cleanVersion}esr` : cleanVersion;
57
75
  const safeProduct = product.replace(/[^a-z0-9-]/gi, '-');
76
+ const dirUrl = getArchiveDirUrl(product, archiveVersion, candidate);
77
+ // Candidate artifacts get a `-<candidate>` filename suffix so a build2
78
+ // archive can never collide with the final release artifact (same
79
+ // archiveVersion, potentially different bytes) in .fireforge/cache.
80
+ const candidateSuffix = candidate === undefined ? '' : `-${candidate}`;
81
+ const filename = `firefox-${safeProduct}-${archiveVersion}${candidateSuffix}.source.tar.xz`;
58
82
  return {
59
83
  requestedVersion: version,
60
84
  product,
61
85
  archiveVersion,
62
- url: `${getArchiveBaseUrl(product)}/${archiveVersion}/source/firefox-${archiveVersion}.source.tar.xz`,
63
- filename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz`,
64
- metadataFilename: `firefox-${safeProduct}-${archiveVersion}.source.tar.xz.json`,
86
+ url: `${dirUrl}/source/firefox-${archiveVersion}.source.tar.xz`,
87
+ filename,
88
+ metadataFilename: `${filename}.json`,
65
89
  // Mozilla publishes one SHA256SUMS per release directory listing every
66
90
  // artifact by its release-relative path. Downloads verify against it by
67
91
  // default (fail closed on mismatch), so a compromised CDN response
68
92
  // cannot silently become the trusted git baseline.
69
- checksumsUrl: `${getArchiveBaseUrl(product)}/${archiveVersion}/SHA256SUMS`,
93
+ checksumsUrl: `${dirUrl}/SHA256SUMS`,
70
94
  pathInChecksums: `source/firefox-${archiveVersion}.source.tar.xz`,
71
95
  };
72
96
  }
@@ -13,16 +13,18 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
13
13
  * Gets the download URL for a Firefox source tarball.
14
14
  * @param version - Firefox version (e.g., "140.9.0esr")
15
15
  * @param product - Firefox product type
16
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
16
17
  * @returns Full URL to the source tarball
17
18
  */
18
- export declare function getDownloadUrl(version: string, product?: FirefoxProduct): string;
19
+ export declare function getDownloadUrl(version: string, product?: FirefoxProduct, candidate?: string): string;
19
20
  /**
20
21
  * Gets the filename for a Firefox source tarball.
21
22
  * @param version - Firefox version
22
23
  * @param product - Firefox product type
24
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
23
25
  * @returns Tarball filename
24
26
  */
25
- export declare function getTarballFilename(version: string, product?: FirefoxProduct): string;
27
+ export declare function getTarballFilename(version: string, product?: FirefoxProduct, candidate?: string): string;
26
28
  /**
27
29
  * Lifecycle phase reported by {@link downloadFirefoxSource}. The download
28
30
  * CLI command uses this to swap spinners between the bytes-on-the-wire
@@ -57,5 +59,8 @@ export declare function sweepOrphanedEngineWorkDirs(destDir: string): Promise<st
57
59
  * @param onPhase - Optional callback fired when the function transitions
58
60
  * between phases (`'download'` → `'extract'`). Fires exactly once per
59
61
  * phase even if the cached archive path skips the wire entirely.
62
+ * @param candidate - Optional release-candidate build directory (e.g.
63
+ * "build2") resolving the archive under `candidates/` instead of
64
+ * `releases/`.
60
65
  */
61
- export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback): Promise<void>;
66
+ export declare function downloadFirefoxSource(version: string, product: FirefoxProduct, destDir: string, cacheDir: string, onProgress?: ProgressCallback, onPhase?: FirefoxSourcePhaseCallback, expectedSha256?: string, onPhaseProgress?: FirefoxSourceProgressCallback, candidate?: string): Promise<void>;
@@ -19,19 +19,21 @@ export { formatBytes, getFirefoxVersion } from './firefox-extract.js';
19
19
  * Gets the download URL for a Firefox source tarball.
20
20
  * @param version - Firefox version (e.g., "140.9.0esr")
21
21
  * @param product - Firefox product type
22
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
22
23
  * @returns Full URL to the source tarball
23
24
  */
24
- export function getDownloadUrl(version, product = 'firefox') {
25
- return resolveArchive(version, product).url;
25
+ export function getDownloadUrl(version, product = 'firefox', candidate) {
26
+ return resolveArchive(version, product, candidate).url;
26
27
  }
27
28
  /**
28
29
  * Gets the filename for a Firefox source tarball.
29
30
  * @param version - Firefox version
30
31
  * @param product - Firefox product type
32
+ * @param candidate - Optional release-candidate build directory (e.g. "build2")
31
33
  * @returns Tarball filename
32
34
  */
33
- export function getTarballFilename(version, product = 'firefox') {
34
- return resolveArchive(version, product).filename;
35
+ export function getTarballFilename(version, product = 'firefox', candidate) {
36
+ return resolveArchive(version, product, candidate).filename;
35
37
  }
36
38
  /**
37
39
  * Classifies an extraction failure as archive corruption (invalidate the
@@ -100,9 +102,12 @@ export async function sweepOrphanedEngineWorkDirs(destDir) {
100
102
  * @param onPhase - Optional callback fired when the function transitions
101
103
  * between phases (`'download'` → `'extract'`). Fires exactly once per
102
104
  * phase even if the cached archive path skips the wire entirely.
105
+ * @param candidate - Optional release-candidate build directory (e.g.
106
+ * "build2") resolving the archive under `candidates/` instead of
107
+ * `releases/`.
103
108
  */
104
- export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress) {
105
- const archive = resolveArchive(version, product);
109
+ export async function downloadFirefoxSource(version, product, destDir, cacheDir, onProgress, onPhase, expectedSha256, onPhaseProgress, candidate) {
110
+ const archive = resolveArchive(version, product, candidate);
106
111
  const tarballPath = join(cacheDir, archive.filename);
107
112
  // Ensure cache directory exists
108
113
  await ensureDir(cacheDir);
@@ -27,10 +27,33 @@ export function hasPositiveTabindex(content) {
27
27
  return value !== undefined && parseInt(value, 10) > 0;
28
28
  });
29
29
  }
30
+ /**
31
+ * Collects `[start, end)` spans of `<label>…</label>` elements whose content
32
+ * includes actual label text. Labels cannot nest per HTML, so the non-greedy
33
+ * inner match is sound. A span qualifies only when the inner content minus
34
+ * tags still contains non-whitespace — an empty or tag-only wrapper provides
35
+ * no accessible name. A `${…}` Lit binding counts as label text: dynamic
36
+ * (usually localized) text is a legitimate accessible name.
37
+ */
38
+ function collectLabelledSpans(content) {
39
+ const spans = [];
40
+ const labelPattern = /<label\b[^>]*>([\s\S]*?)<\/label>/gi;
41
+ let labelMatch;
42
+ while ((labelMatch = labelPattern.exec(content)) !== null) {
43
+ const inner = labelMatch[1] ?? '';
44
+ if (/\S/.test(inner.replace(/<[^>]*>/g, ' '))) {
45
+ spans.push({ start: labelMatch.index, end: labelMatch.index + labelMatch[0].length });
46
+ }
47
+ }
48
+ return spans;
49
+ }
30
50
  /** Detects form inputs without associated labels. */
31
51
  export function hasUnlabelledFormInput(content) {
32
52
  // Look for <input> or <select> or <textarea> without aria-label, aria-labelledby, or id
33
- // (id implies an external <label for="..."> could exist)
53
+ // (id implies an external <label for="..."> could exist). A control wrapped
54
+ // in a <label> that carries actual text is implicitly associated and is
55
+ // exempt as well.
56
+ const labelledSpans = collectLabelledSpans(content);
34
57
  const inputPattern = /<(input|select|textarea)\b([^>]*)>/gi;
35
58
  let inputMatch;
36
59
  while ((inputMatch = inputPattern.exec(content)) !== null) {
@@ -41,6 +64,10 @@ export function hasUnlabelledFormInput(content) {
41
64
  /type\s*=\s*["']hidden["']/i.test(attrs)) {
42
65
  continue;
43
66
  }
67
+ const index = inputMatch.index;
68
+ if (labelledSpans.some((span) => index >= span.start && index < span.end)) {
69
+ continue;
70
+ }
44
71
  return true;
45
72
  }
46
73
  return false;