@hominis/fireforge 0.27.1 → 0.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +5 -5
  3. package/dist/src/cli.js +5 -1
  4. package/dist/src/commands/build.js +61 -1
  5. package/dist/src/commands/doctor-working-tree.js +5 -1
  6. package/dist/src/commands/download.js +41 -45
  7. package/dist/src/commands/export-all.js +3 -2
  8. package/dist/src/commands/export-flow.d.ts +2 -0
  9. package/dist/src/commands/export-flow.js +2 -0
  10. package/dist/src/commands/export.js +5 -4
  11. package/dist/src/commands/import.js +2 -1
  12. package/dist/src/commands/re-export.js +6 -6
  13. package/dist/src/commands/rebase/continue.js +2 -0
  14. package/dist/src/commands/rebase/index.d.ts +2 -2
  15. package/dist/src/commands/rebase/index.js +9 -4
  16. package/dist/src/commands/rebase/patch-loop.js +5 -5
  17. package/dist/src/commands/rebase/summary.js +7 -2
  18. package/dist/src/commands/resolve.js +2 -1
  19. package/dist/src/commands/status-output.d.ts +13 -0
  20. package/dist/src/commands/status-output.js +186 -0
  21. package/dist/src/commands/status.js +4 -247
  22. package/dist/src/commands/verify.js +32 -16
  23. package/dist/src/core/build-prepare.js +12 -4
  24. package/dist/src/core/firefox-cache.d.ts +1 -1
  25. package/dist/src/core/firefox-cache.js +10 -3
  26. package/dist/src/core/firefox.js +1 -1
  27. package/dist/src/core/git.js +7 -2
  28. package/dist/src/core/ownership-table.d.ts +3 -1
  29. package/dist/src/core/ownership-table.js +31 -7
  30. package/dist/src/core/patch-export.d.ts +4 -0
  31. package/dist/src/core/patch-export.js +4 -0
  32. package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
  33. package/dist/src/core/patch-manifest-consistency.js +4 -2
  34. package/dist/src/core/patch-manifest-query.d.ts +4 -3
  35. package/dist/src/core/patch-manifest-query.js +12 -4
  36. package/dist/src/core/patch-manifest-validate.js +22 -4
  37. package/dist/src/core/patch-source-metadata.d.ts +8 -0
  38. package/dist/src/core/patch-source-metadata.js +17 -0
  39. package/dist/src/core/rebase-session.d.ts +8 -3
  40. package/dist/src/core/rebase-session.js +1 -1
  41. package/dist/src/core/status-classify.d.ts +4 -1
  42. package/dist/src/core/status-classify.js +4 -5
  43. package/dist/src/types/commands/patches.d.ts +9 -1
  44. package/package.json +1 -1
@@ -30,19 +30,22 @@ export async function sha256File(filePath) {
30
30
  * @param cacheDir - Cache directory
31
31
  * @param onProgress - Optional progress callback
32
32
  */
33
- export async function ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256) {
33
+ export async function ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256, onCacheProgress) {
34
34
  const lockPath = createSiblingLockPath(join(cacheDir, archive.filename), '.fireforge-cache.lock');
35
35
  await withFileLock(lockPath, async () => {
36
+ onCacheProgress?.(`Validating source archive cache metadata for ${archive.filename}...`);
36
37
  if (await validateCachedArchive(archive, cacheDir, expectedSha256)) {
38
+ onCacheProgress?.(`Using validated cached source archive ${archive.filename}`);
37
39
  return;
38
40
  }
39
41
  if (await cacheEntryExists(archive, cacheDir)) {
42
+ onCacheProgress?.(`Invalid cached source archive metadata; refreshing ${archive.filename}`);
40
43
  await invalidateArchiveCache(archive, cacheDir);
41
44
  }
42
45
  else {
43
46
  await removeArchivePartFiles(archive, cacheDir);
44
47
  }
45
- await downloadToCache(archive, cacheDir, onProgress, expectedSha256);
48
+ await downloadToCache(archive, cacheDir, onProgress, expectedSha256, onCacheProgress);
46
49
  });
47
50
  }
48
51
  async function cacheEntryExists(archive, cacheDir) {
@@ -97,7 +100,7 @@ async function validateCachedArchive(archive, cacheDir, expectedSha256) {
97
100
  * @param cacheDir - Cache directory
98
101
  * @param onProgress - Optional progress callback
99
102
  */
100
- async function downloadToCache(archive, cacheDir, onProgress, expectedSha256) {
103
+ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256, onCacheProgress) {
101
104
  const tarballPath = join(cacheDir, archive.filename);
102
105
  // Use a unique .part path so concurrent downloads for the same archive
103
106
  // do not clobber each other's partial files.
@@ -105,13 +108,16 @@ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256) {
105
108
  const metadataPath = join(cacheDir, archive.metadataFilename);
106
109
  let promotedTarball = false;
107
110
  try {
111
+ onCacheProgress?.(`Downloading source archive to cache: ${archive.filename}`);
108
112
  const contentLength = await downloadFile(archive.url, partPath, onProgress);
109
113
  await rename(partPath, tarballPath);
110
114
  promotedTarball = true;
115
+ onCacheProgress?.(`Calculating source archive SHA-256 for ${archive.filename}...`);
111
116
  const sha256 = await sha256File(tarballPath);
112
117
  if (expectedSha256 && sha256 !== expectedSha256) {
113
118
  throw new DownloadError(`Downloaded archive SHA-256 mismatch: expected ${expectedSha256}, got ${sha256}`, archive.url);
114
119
  }
120
+ onCacheProgress?.(`Writing source archive cache metadata for ${archive.metadataFilename}...`);
115
121
  await writeJson(metadataPath, {
116
122
  requestedVersion: archive.requestedVersion,
117
123
  product: archive.product,
@@ -121,6 +127,7 @@ async function downloadToCache(archive, cacheDir, onProgress, expectedSha256) {
121
127
  sha256,
122
128
  downloadedAt: new Date().toISOString(),
123
129
  });
130
+ onCacheProgress?.(`Source archive cache metadata written: ${archive.metadataFilename}`);
124
131
  }
125
132
  catch (error) {
126
133
  await removeFile(partPath);
@@ -50,7 +50,7 @@ export async function downloadFirefoxSource(version, product, destDir, cacheDir,
50
50
  // Ensure cache directory exists
51
51
  await ensureDir(cacheDir);
52
52
  onPhase?.('download');
53
- await ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256);
53
+ await ensureCachedArchive(archive, cacheDir, onProgress, expectedSha256, onPhaseProgress);
54
54
  // Extract to a unique temporary directory so concurrent downloads for
55
55
  // the same destination do not clobber each other.
56
56
  onPhase?.('extract');
@@ -206,9 +206,10 @@ export async function stageAllFiles(dir, options = {}) {
206
206
  heartbeatTimer?.unref();
207
207
  try {
208
208
  try {
209
+ reportProgress?.('Git phase: starting git add -A source indexing.');
209
210
  reportProgress?.(`Starting monolithic git add -A for ${scan.directories.length} director${scan.directories.length === 1 ? 'y' : 'ies'} and ${scan.topLevelFiles.length} top-level file${scan.topLevelFiles.length === 1 ? '' : 's'}...`);
210
211
  await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
211
- reportProgress?.('Monolithic git add -A completed.');
212
+ reportProgress?.('Git phase complete: git add -A source indexing finished.');
212
213
  return;
213
214
  }
214
215
  catch (error) {
@@ -233,6 +234,7 @@ export async function stageAllFiles(dir, options = {}) {
233
234
  phaseStartedAt = Date.now();
234
235
  try {
235
236
  await stageAllFilesChunked(dir, scan, options);
237
+ reportProgress?.('Git phase complete: chunked source indexing finished.');
236
238
  }
237
239
  catch (error) {
238
240
  if (error instanceof GitIndexingTimeoutError)
@@ -247,6 +249,7 @@ export async function stageAllFiles(dir, options = {}) {
247
249
  }
248
250
  async function createInitialSourceCommit(dir, reportProgress) {
249
251
  const startedAt = Date.now();
252
+ reportProgress('Git phase: creating initial source commit.');
250
253
  reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
251
254
  const heartbeat = setInterval(() => {
252
255
  reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
@@ -258,7 +261,7 @@ async function createInitialSourceCommit(dir, reportProgress) {
258
261
  finally {
259
262
  clearInterval(heartbeat);
260
263
  }
261
- reportProgress(`Initial Firefox source commit created (${elapsedSince(startedAt)} elapsed).`);
264
+ reportProgress(`Git phase complete: initial source commit created (${elapsedSince(startedAt)} elapsed).`);
262
265
  }
263
266
  /**
264
267
  * Initializes a new git repository with an orphan branch.
@@ -269,6 +272,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
269
272
  await ensureGit();
270
273
  const reportProgress = options.onProgress ?? (() => { });
271
274
  // Initialize repository
275
+ reportProgress('Git phase: initializing source git repository.');
272
276
  reportProgress('Creating git repository...');
273
277
  await git(['init'], dir);
274
278
  // Create orphan branch
@@ -286,6 +290,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
286
290
  // fail. Nothing is ever fetched from or pushed to this remote.
287
291
  reportProgress('Configuring origin remote for build compatibility...');
288
292
  await git(['remote', 'add', 'origin', 'https://github.com/mozilla-firefox/firefox'], dir);
293
+ reportProgress('Git phase complete: source git repository metadata initialized.');
289
294
  // Add all files
290
295
  reportProgress('Indexing Firefox source with git add -A (this can take several minutes on large trees)...');
291
296
  await assertNoGitIndexLock(dir);
@@ -1,4 +1,5 @@
1
1
  import type { PatchMetadata } from '../types/commands/index.js';
2
+ import type { FileClassification } from './status-classify.js';
2
3
  /**
3
4
  * A row in the flat path → owning-patch ownership table.
4
5
  */
@@ -8,6 +9,7 @@ export interface OwnershipRow {
8
9
  conflict: boolean;
9
10
  conflictReason: 'files-affected' | 'duplicate-create' | null;
10
11
  unmanaged: boolean;
12
+ state: 'owned' | 'patch-backed' | 'patch-owned-drift' | 'unmanaged' | 'conflict';
11
13
  }
12
14
  interface StatusFile {
13
15
  status: string;
@@ -42,7 +44,7 @@ interface StatusFile {
42
44
  * {@link import('../core/patch-lint.js').collectNewFileCreatorsByPath};
43
45
  * paths with a `.length > 1` owner list become duplicate-create conflicts
44
46
  */
45
- export declare function buildOwnershipTable(manifestPatches: PatchMetadata[], worktreeFiles: StatusFile[], newFileCreatorsByPath: Map<string, string[]>): OwnershipRow[];
47
+ export declare function buildOwnershipTable(manifestPatches: PatchMetadata[], worktreeFiles: StatusFile[], newFileCreatorsByPath: Map<string, string[]>, classifications?: Map<string, FileClassification>): OwnershipRow[];
46
48
  /**
47
49
  * Renders the ownership table as a GitHub-flavored Markdown pipe table.
48
50
  * Using markdown-table's own serializer would require a seed document to
@@ -28,7 +28,7 @@ import { info } from '../utils/logger.js';
28
28
  * {@link import('../core/patch-lint.js').collectNewFileCreatorsByPath};
29
29
  * paths with a `.length > 1` owner list become duplicate-create conflicts
30
30
  */
31
- export function buildOwnershipTable(manifestPatches, worktreeFiles, newFileCreatorsByPath) {
31
+ export function buildOwnershipTable(manifestPatches, worktreeFiles, newFileCreatorsByPath, classifications = new Map()) {
32
32
  const ownersByPath = new Map();
33
33
  for (const patch of manifestPatches) {
34
34
  for (const file of patch.filesAffected) {
@@ -86,6 +86,13 @@ export function buildOwnershipTable(manifestPatches, worktreeFiles, newFileCreat
86
86
  conflict: isFilesAffectedConflict || isDuplicateCreateConflict,
87
87
  conflictReason,
88
88
  unmanaged: false,
89
+ state: isFilesAffectedConflict || isDuplicateCreateConflict
90
+ ? 'conflict'
91
+ : classifications.get(path) === 'patch-backed'
92
+ ? 'patch-backed'
93
+ : classifications.get(path) === 'patch-owned-drift'
94
+ ? 'patch-owned-drift'
95
+ : 'owned',
89
96
  });
90
97
  }
91
98
  for (const path of unmanagedOnly) {
@@ -95,6 +102,7 @@ export function buildOwnershipTable(manifestPatches, worktreeFiles, newFileCreat
95
102
  conflict: false,
96
103
  conflictReason: null,
97
104
  unmanaged: true,
105
+ state: 'unmanaged',
98
106
  });
99
107
  }
100
108
  rows.sort((a, b) => a.path.localeCompare(b.path));
@@ -115,6 +123,22 @@ function renderConflictCell(row) {
115
123
  return 'CONFLICT (dup-create)';
116
124
  return 'CONFLICT';
117
125
  }
126
+ function renderStateCell(row) {
127
+ if (row.conflict)
128
+ return renderConflictCell(row);
129
+ switch (row.state) {
130
+ case 'owned':
131
+ return 'owned';
132
+ case 'patch-backed':
133
+ return 'patch-backed';
134
+ case 'patch-owned-drift':
135
+ return 'patch-owned drift';
136
+ case 'unmanaged':
137
+ return 'unmanaged';
138
+ case 'conflict':
139
+ return renderConflictCell(row);
140
+ }
141
+ }
118
142
  /**
119
143
  * Renders the ownership table as a GitHub-flavored Markdown pipe table.
120
144
  * Using markdown-table's own serializer would require a seed document to
@@ -128,17 +152,17 @@ export function renderOwnershipTable(rows) {
128
152
  }
129
153
  const pathHeader = 'path';
130
154
  const ownerHeader = 'owning patch';
131
- const conflictHeader = 'conflict';
155
+ const stateHeader = 'state';
132
156
  const pathWidth = Math.max(pathHeader.length, ...rows.map((r) => r.path.length));
133
157
  const ownerWidth = Math.max(ownerHeader.length, ...rows.map((r) => (r.unmanaged ? 1 : r.owners.join(', ').length)));
134
- const conflictWidth = Math.max(conflictHeader.length, ...rows.map((r) => renderConflictCell(r).length), 8);
158
+ const stateWidth = Math.max(stateHeader.length, ...rows.map((r) => renderStateCell(r).length), 8);
135
159
  const pad = (text, width) => text + ' '.repeat(width - text.length);
136
- info(`| ${pad(pathHeader, pathWidth)} | ${pad(ownerHeader, ownerWidth)} | ${pad(conflictHeader, conflictWidth)} |`);
137
- info(`| ${'-'.repeat(pathWidth)} | ${'-'.repeat(ownerWidth)} | ${'-'.repeat(conflictWidth)} |`);
160
+ info(`| ${pad(pathHeader, pathWidth)} | ${pad(ownerHeader, ownerWidth)} | ${pad(stateHeader, stateWidth)} |`);
161
+ info(`| ${'-'.repeat(pathWidth)} | ${'-'.repeat(ownerWidth)} | ${'-'.repeat(stateWidth)} |`);
138
162
  for (const row of rows) {
139
163
  const ownerCell = row.unmanaged ? '-' : row.owners.join(', ');
140
- const conflictCell = renderConflictCell(row);
141
- info(`| ${pad(row.path, pathWidth)} | ${pad(ownerCell, ownerWidth)} | ${pad(conflictCell, conflictWidth)} |`);
164
+ const stateCell = renderStateCell(row);
165
+ info(`| ${pad(row.path, pathWidth)} | ${pad(ownerCell, ownerWidth)} | ${pad(stateCell, stateWidth)} |`);
142
166
  }
143
167
  }
144
168
  //# sourceMappingURL=ownership-table.js.map
@@ -36,6 +36,8 @@ export interface CommitExportedPatchInput {
36
36
  diff: string;
37
37
  filesAffected: string[];
38
38
  sourceEsrVersion: string;
39
+ sourceProduct?: FireForgeConfig['firefox']['product'];
40
+ sourceVersion?: string;
39
41
  /** Optional `PatchMetadata.tier` opt-in (only `"branding"` recognised). */
40
42
  tier?: 'branding';
41
43
  /** Optional `PatchMetadata.lintIgnore` (empty array treated as absent). */
@@ -126,6 +128,8 @@ export interface PlanExportInput {
126
128
  description: string;
127
129
  filesAffected: string[];
128
130
  sourceEsrVersion: string;
131
+ sourceProduct?: FireForgeConfig['firefox']['product'];
132
+ sourceVersion?: string;
129
133
  /**
130
134
  * Optional `PatchMetadata.tier` opt-in carried from the CLI flag.
131
135
  * Only `"branding"` is currently recognised. When provided the field
@@ -75,6 +75,8 @@ export async function commitExportedPatch(input) {
75
75
  description: input.description,
76
76
  filesAffected: input.filesAffected,
77
77
  sourceEsrVersion: input.sourceEsrVersion,
78
+ ...(input.sourceProduct !== undefined ? { sourceProduct: input.sourceProduct } : {}),
79
+ ...(input.sourceVersion !== undefined ? { sourceVersion: input.sourceVersion } : {}),
78
80
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
79
81
  ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
80
82
  ...(input.config !== undefined ? { config: input.config } : {}),
@@ -262,6 +264,8 @@ async function computeExportPlanUnderLock(input) {
262
264
  description: input.description,
263
265
  createdAt: new Date().toISOString(),
264
266
  sourceEsrVersion: input.sourceEsrVersion,
267
+ ...(input.sourceProduct !== undefined ? { sourceProduct: input.sourceProduct } : {}),
268
+ sourceVersion: input.sourceVersion ?? input.sourceEsrVersion,
265
269
  filesAffected: input.filesAffected,
266
270
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
267
271
  ...(input.lintIgnore !== undefined && input.lintIgnore.length > 0
@@ -37,7 +37,7 @@ export interface RebuildPatchesManifestResult {
37
37
  * Existing metadata is preserved when possible; missing entries are recovered
38
38
  * from filename structure, patch contents, and file mtimes.
39
39
  * @param patchesDir - Path to the patches directory
40
- * @param fallbackSourceEsrVersion - ESR version to use for recovered entries
40
+ * @param fallbackSourceEsrVersion - source version to use for recovered legacy entries
41
41
  * @returns {@link RebuildPatchesManifestResult} — the persisted manifest
42
42
  * plus the filenames that were reconstructed from generic defaults.
43
43
  */
@@ -84,7 +84,7 @@ export async function validatePatchesManifestConsistency(patchesDir) {
84
84
  * Existing metadata is preserved when possible; missing entries are recovered
85
85
  * from filename structure, patch contents, and file mtimes.
86
86
  * @param patchesDir - Path to the patches directory
87
- * @param fallbackSourceEsrVersion - ESR version to use for recovered entries
87
+ * @param fallbackSourceEsrVersion - source version to use for recovered legacy entries
88
88
  * @returns {@link RebuildPatchesManifestResult} — the persisted manifest
89
89
  * plus the filenames that were reconstructed from generic defaults.
90
90
  */
@@ -135,9 +135,11 @@ export async function rebuildPatchesManifest(patchesDir, fallbackSourceEsrVersio
135
135
  category: existing?.category ?? inferred.category,
136
136
  name: existing?.name ?? inferred.name,
137
137
  description: existing?.description ??
138
- `Recovered manifest entry for ${patch.filename}. Review description and ESR version.`,
138
+ `Recovered manifest entry for ${patch.filename}. Review description and source version.`,
139
139
  createdAt: existing?.createdAt ?? new Date(patchStats.mtimeMs).toISOString(),
140
140
  sourceEsrVersion: existing?.sourceEsrVersion ?? fallbackSourceEsrVersion,
141
+ sourceVersion: existing?.sourceVersion ?? existing?.sourceEsrVersion ?? fallbackSourceEsrVersion,
142
+ ...(existing?.sourceProduct !== undefined ? { sourceProduct: existing.sourceProduct } : {}),
141
143
  filesAffected,
142
144
  };
143
145
  if (existing?.lintIgnore !== undefined)
@@ -2,6 +2,7 @@
2
2
  * Query helpers: finding patches by file, integrity checks, version compat, stamping.
3
3
  */
4
4
  import type { PatchesManifest, PatchInfo, PatchMetadata } from '../types/commands/index.js';
5
+ import type { FirefoxProduct } from '../types/config.js';
5
6
  /**
6
7
  * Gets all file paths claimed by patches other than the excluded one.
7
8
  * @param manifest - The patches manifest
@@ -10,7 +11,7 @@ import type { PatchesManifest, PatchInfo, PatchMetadata } from '../types/command
10
11
  */
11
12
  export declare function getClaimedFiles(manifest: PatchesManifest, excludeFilename: string): Set<string>;
12
13
  /**
13
- * Checks ESR version compatibility.
14
+ * Checks Firefox source version compatibility.
14
15
  * @param patchVersion - Version the patch was created for
15
16
  * @param currentVersion - Current project version
16
17
  * @returns Warning message if versions differ, null if compatible
@@ -39,10 +40,10 @@ export declare function validatePatchIntegrity(patchesDir: string, engineDir: st
39
40
  targetFile: string | null;
40
41
  }>>;
41
42
  /**
42
- * Stamps multiple patches with a new `sourceEsrVersion` in a single
43
+ * Stamps multiple patches with a new source version in a single
43
44
  * manifest read-modify-write cycle.
44
45
  * @param patchesDir - Path to the patches directory
45
46
  * @param filenames - Patch filenames to update
46
47
  * @param newVersion - Version string to set (e.g. "140.9.0esr")
47
48
  */
48
- export declare function stampPatchVersions(patchesDir: string, filenames: string[], newVersion: string): Promise<void>;
49
+ export declare function stampPatchVersions(patchesDir: string, filenames: string[], newVersion: string, newProduct?: FirefoxProduct): Promise<void>;
@@ -25,7 +25,7 @@ export function getClaimedFiles(manifest, excludeFilename) {
25
25
  return claimed;
26
26
  }
27
27
  /**
28
- * Checks ESR version compatibility.
28
+ * Checks Firefox source version compatibility.
29
29
  * @param patchVersion - Version the patch was created for
30
30
  * @param currentVersion - Current project version
31
31
  * @returns Warning message if versions differ, null if compatible
@@ -99,21 +99,29 @@ export async function validatePatchIntegrity(patchesDir, engineDir) {
99
99
  return issues;
100
100
  }
101
101
  /**
102
- * Stamps multiple patches with a new `sourceEsrVersion` in a single
102
+ * Stamps multiple patches with a new source version in a single
103
103
  * manifest read-modify-write cycle.
104
104
  * @param patchesDir - Path to the patches directory
105
105
  * @param filenames - Patch filenames to update
106
106
  * @param newVersion - Version string to set (e.g. "140.9.0esr")
107
107
  */
108
- export async function stampPatchVersions(patchesDir, filenames, newVersion) {
108
+ export async function stampPatchVersions(patchesDir, filenames, newVersion, newProduct) {
109
109
  const manifest = await loadPatchesManifest(patchesDir);
110
110
  if (!manifest)
111
111
  return;
112
112
  const filenameSet = new Set(filenames);
113
113
  let modified = false;
114
114
  for (const patch of manifest.patches) {
115
- if (filenameSet.has(patch.filename) && patch.sourceEsrVersion !== newVersion) {
115
+ if (!filenameSet.has(patch.filename))
116
+ continue;
117
+ if (patch.sourceEsrVersion !== newVersion ||
118
+ patch.sourceVersion !== newVersion ||
119
+ (newProduct !== undefined && patch.sourceProduct !== newProduct)) {
116
120
  patch.sourceEsrVersion = newVersion;
121
+ patch.sourceVersion = newVersion;
122
+ if (newProduct !== undefined) {
123
+ patch.sourceProduct = newProduct;
124
+ }
117
125
  modified = true;
118
126
  }
119
127
  }
@@ -3,7 +3,7 @@
3
3
  * Schema validation for patches.json manifest data.
4
4
  */
5
5
  import { parseObject } from '../utils/parse.js';
6
- import { isArray, isObject, isValidFirefoxVersion, PATCH_CATEGORIES } from '../utils/validation.js';
6
+ import { isArray, isObject, isValidFirefoxProduct, isValidFirefoxVersion, PATCH_CATEGORIES, } from '../utils/validation.js';
7
7
  function parseForwardImports(data, label) {
8
8
  if (!isArray(data)) {
9
9
  throw new Error(`${label} must be an array`);
@@ -45,10 +45,25 @@ export function validatePatchMetadata(data, index) {
45
45
  const name = rec.string('name');
46
46
  const description = rec.string('description');
47
47
  const createdAt = rec.string('createdAt');
48
- const sourceEsrVersion = rec.string('sourceEsrVersion');
48
+ const sourceEsrVersion = rec.optionalString('sourceEsrVersion');
49
+ const sourceVersion = rec.optionalString('sourceVersion') ?? sourceEsrVersion;
50
+ if (sourceVersion === undefined) {
51
+ throw new Error(`patches[${index}] must include sourceVersion or legacy sourceEsrVersion`);
52
+ }
53
+ const sourceProductRaw = rec.optionalString('sourceProduct');
54
+ let sourceProduct;
55
+ if (sourceProductRaw !== undefined) {
56
+ if (!isValidFirefoxProduct(sourceProductRaw)) {
57
+ throw new Error(`patches[${index}].sourceProduct must be one of: firefox, firefox-esr, firefox-beta, firefox-devedition`);
58
+ }
59
+ sourceProduct = sourceProductRaw;
60
+ }
49
61
  const order = rec.nonNegativeInteger('order');
50
62
  const category = rec.validatedString('category', (value) => /^[a-z][a-z0-9-]*$/.test(value), 'a lowercase category identifier (letters, numbers, hyphens)');
51
- if (!isValidFirefoxVersion(sourceEsrVersion)) {
63
+ if (!isValidFirefoxVersion(sourceVersion)) {
64
+ throw new Error(`patches[${index}].sourceVersion must be a valid Firefox version string`);
65
+ }
66
+ if (sourceEsrVersion !== undefined && !isValidFirefoxVersion(sourceEsrVersion)) {
52
67
  throw new Error(`patches[${index}].sourceEsrVersion must be a valid Firefox version string`);
53
68
  }
54
69
  const filesAffected = rec.stringArray('filesAffected');
@@ -78,9 +93,12 @@ export function validatePatchMetadata(data, index) {
78
93
  name,
79
94
  description,
80
95
  createdAt,
81
- sourceEsrVersion,
96
+ sourceEsrVersion: sourceEsrVersion ?? sourceVersion,
97
+ sourceVersion,
82
98
  filesAffected,
83
99
  };
100
+ if (sourceProduct !== undefined)
101
+ result.sourceProduct = sourceProduct;
84
102
  if (lintIgnore !== undefined)
85
103
  result.lintIgnore = lintIgnore;
86
104
  if (tier !== undefined)
@@ -0,0 +1,8 @@
1
+ import type { PatchMetadata } from '../types/commands/index.js';
2
+ import type { FirefoxConfig, FirefoxProduct } from '../types/config.js';
3
+ /** Metadata fields stamped on new or refreshed patch entries. */
4
+ export declare function buildPatchSourceMetadata(firefox: Pick<FirefoxConfig, 'product' | 'version'>): Pick<PatchMetadata, 'sourceEsrVersion' | 'sourceProduct' | 'sourceVersion'>;
5
+ /** Backward-compatible source version reader for legacy manifests. */
6
+ export declare function getPatchSourceVersion(patch: Pick<PatchMetadata, 'sourceEsrVersion' | 'sourceVersion'>): string;
7
+ /** Backward-compatible source product reader for legacy manifests. */
8
+ export declare function getPatchSourceProduct(patch: Pick<PatchMetadata, 'sourceProduct'>): FirefoxProduct | undefined;
@@ -0,0 +1,17 @@
1
+ /** Metadata fields stamped on new or refreshed patch entries. */
2
+ export function buildPatchSourceMetadata(firefox) {
3
+ return {
4
+ sourceEsrVersion: firefox.version,
5
+ sourceProduct: firefox.product,
6
+ sourceVersion: firefox.version,
7
+ };
8
+ }
9
+ /** Backward-compatible source version reader for legacy manifests. */
10
+ export function getPatchSourceVersion(patch) {
11
+ return patch.sourceVersion ?? patch.sourceEsrVersion;
12
+ }
13
+ /** Backward-compatible source product reader for legacy manifests. */
14
+ export function getPatchSourceProduct(patch) {
15
+ return patch.sourceProduct;
16
+ }
17
+ //# sourceMappingURL=patch-source-metadata.js.map
@@ -1,9 +1,10 @@
1
1
  /**
2
- * Rebase session persistence for multi-patch ESR version upgrades.
2
+ * Rebase session persistence for multi-patch Firefox source upgrades.
3
3
  * Session state is stored at `.fireforge/rebase-session.json` and
4
4
  * survives across CLI invocations so the user can fix conflicts and
5
5
  * resume with `fireforge rebase --continue`.
6
6
  */
7
+ import type { FirefoxProduct } from '../types/config.js';
7
8
  export type RebasePatchStatus = 'pending' | 'applied-clean' | 'applied-fuzz' | 'failed' | 'resolved' | 'skipped';
8
9
  export interface RebasePatchEntry {
9
10
  filename: string;
@@ -18,9 +19,13 @@ export interface RebasePatchEntry {
18
19
  export interface RebaseSession {
19
20
  /** ISO timestamp when the rebase started. */
20
21
  startedAt: string;
21
- /** ESR version being rebased FROM. */
22
+ /** Source product being rebased FROM. */
23
+ fromProduct?: FirefoxProduct;
24
+ /** Source product being rebased TO. */
25
+ toProduct?: FirefoxProduct;
26
+ /** Source version being rebased FROM. */
22
27
  fromVersion: string;
23
- /** ESR version being rebased TO. */
28
+ /** Source version being rebased TO. */
24
29
  toVersion: string;
25
30
  /** Commit hash recorded before the rebase started (for --abort). */
26
31
  preRebaseCommit: string;
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: EUPL-1.2
2
2
  /**
3
- * Rebase session persistence for multi-patch ESR version upgrades.
3
+ * Rebase session persistence for multi-patch Firefox source upgrades.
4
4
  * Session state is stored at `.fireforge/rebase-session.json` and
5
5
  * survives across CLI invocations so the user can fix conflicts and
6
6
  * resume with `fireforge rebase --continue`.
@@ -9,6 +9,9 @@
9
9
  * Classification buckets for engine file changes:
10
10
  * - `patch-backed`: content matches the expected post-patch state —
11
11
  * normal after `fireforge import`.
12
+ * - `patch-owned-drift`: the file is claimed by exactly one patch, but
13
+ * the live engine content no longer matches that patch's expected
14
+ * post-apply content.
12
15
  * - `unmanaged`: edits not explained by any patch or tool — local
13
16
  * drift to export or discard.
14
17
  * - `branding`: files under tool-managed branding paths, written by
@@ -23,7 +26,7 @@
23
26
  * in `--json`, which misled scripts built on top of the JSON view
24
27
  * into treating the file as routine local drift.
25
28
  */
26
- export type FileClassification = 'patch-backed' | 'unmanaged' | 'branding' | 'furnace' | 'conflict';
29
+ export type FileClassification = 'patch-backed' | 'patch-owned-drift' | 'unmanaged' | 'branding' | 'furnace' | 'conflict';
27
30
  export interface StatusFile {
28
31
  status: string;
29
32
  file: string;
@@ -123,7 +123,7 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
123
123
  const expected = await computePatchedContent(patchesDir, engineDir, entry.file);
124
124
  results.push({
125
125
  ...entry,
126
- classification: expected === null ? 'patch-backed' : 'unmanaged',
126
+ classification: expected === null ? 'patch-backed' : 'patch-owned-drift',
127
127
  });
128
128
  continue;
129
129
  }
@@ -135,13 +135,12 @@ export async function classifyFiles(files, engineDir, patchesDir, binaryName, fu
135
135
  ]);
136
136
  results.push({
137
137
  ...entry,
138
- classification: actual === expected ? 'patch-backed' : 'unmanaged',
138
+ classification: actual === expected ? 'patch-backed' : 'patch-owned-drift',
139
139
  });
140
140
  }
141
141
  catch (error) {
142
- verbose(`Treating ${entry.file} as unmanaged because patch-backed classification failed: ${toError(error).message}`);
143
- // If we can't read the file, treat as unmanaged
144
- results.push({ ...entry, classification: 'unmanaged' });
142
+ verbose(`Treating ${entry.file} as patch-owned drift because patch-backed classification failed: ${toError(error).message}`);
143
+ results.push({ ...entry, classification: 'patch-owned-drift' });
145
144
  }
146
145
  }
147
146
  return results;
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Types for the patch system: patch metadata, manifests, lint, and import results.
3
3
  */
4
+ import type { FirefoxProduct } from '../config.js';
4
5
  /**
5
6
  * Patch categories for organizational classification.
6
7
  */
@@ -47,8 +48,15 @@ export interface PatchMetadata {
47
48
  description: string;
48
49
  /** ISO timestamp of when the patch was created */
49
50
  createdAt: string;
50
- /** ESR version the patch was created against (e.g., "140.9.0esr") */
51
+ /**
52
+ * Deprecated compatibility alias for the source version the patch was
53
+ * created against. New writes also include `sourceVersion`.
54
+ */
51
55
  sourceEsrVersion: string;
56
+ /** Firefox source product the patch was created against. */
57
+ sourceProduct?: FirefoxProduct;
58
+ /** Firefox source version the patch was created against. */
59
+ sourceVersion?: string;
52
60
  /** Array of file paths affected by this patch */
53
61
  filesAffected: string[];
54
62
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.27.1",
3
+ "version": "0.27.2",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",