@hominis/fireforge 0.27.0 → 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 (71) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +6 -6
  3. package/dist/src/cli.js +5 -1
  4. package/dist/src/commands/build.js +61 -1
  5. package/dist/src/commands/doctor/post-rebase-audit.d.ts +2 -0
  6. package/dist/src/commands/doctor/post-rebase-audit.js +86 -0
  7. package/dist/src/commands/doctor-working-tree.js +5 -1
  8. package/dist/src/commands/doctor.js +3 -0
  9. package/dist/src/commands/download.js +41 -42
  10. package/dist/src/commands/export-all.js +3 -2
  11. package/dist/src/commands/export-flow.d.ts +2 -0
  12. package/dist/src/commands/export-flow.js +2 -0
  13. package/dist/src/commands/export.js +5 -4
  14. package/dist/src/commands/import.js +2 -1
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/re-export.js +10 -8
  17. package/dist/src/commands/rebase/conflict-summary.d.ts +12 -0
  18. package/dist/src/commands/rebase/conflict-summary.js +38 -0
  19. package/dist/src/commands/rebase/continue.js +2 -0
  20. package/dist/src/commands/rebase/index.d.ts +2 -2
  21. package/dist/src/commands/rebase/index.js +9 -4
  22. package/dist/src/commands/rebase/patch-loop.js +29 -11
  23. package/dist/src/commands/rebase/summary.js +7 -2
  24. package/dist/src/commands/resolve.js +2 -1
  25. package/dist/src/commands/setup-support.js +6 -2
  26. package/dist/src/commands/setup.js +1 -0
  27. package/dist/src/commands/source.d.ts +9 -0
  28. package/dist/src/commands/source.js +92 -0
  29. package/dist/src/commands/status-output.d.ts +13 -0
  30. package/dist/src/commands/status-output.js +186 -0
  31. package/dist/src/commands/status.js +4 -247
  32. package/dist/src/commands/verify.js +32 -16
  33. package/dist/src/core/build-prepare.js +12 -4
  34. package/dist/src/core/config-validate.js +1 -1
  35. package/dist/src/core/firefox-cache.d.ts +1 -1
  36. package/dist/src/core/firefox-cache.js +10 -3
  37. package/dist/src/core/firefox-extract.d.ts +1 -1
  38. package/dist/src/core/firefox-extract.js +13 -1
  39. package/dist/src/core/firefox.d.ts +2 -1
  40. package/dist/src/core/firefox.js +3 -3
  41. package/dist/src/core/furnace-registration-validate.d.ts +7 -0
  42. package/dist/src/core/furnace-registration-validate.js +29 -12
  43. package/dist/src/core/furnace-validate-registration.js +5 -37
  44. package/dist/src/core/git.js +25 -5
  45. package/dist/src/core/ownership-table.d.ts +3 -1
  46. package/dist/src/core/ownership-table.js +31 -7
  47. package/dist/src/core/patch-artifact-normalize.d.ts +9 -0
  48. package/dist/src/core/patch-artifact-normalize.js +13 -0
  49. package/dist/src/core/patch-export-update.js +2 -1
  50. package/dist/src/core/patch-export.d.ts +4 -0
  51. package/dist/src/core/patch-export.js +7 -2
  52. package/dist/src/core/patch-manifest-consistency.d.ts +1 -1
  53. package/dist/src/core/patch-manifest-consistency.js +4 -2
  54. package/dist/src/core/patch-manifest-query.d.ts +4 -3
  55. package/dist/src/core/patch-manifest-query.js +12 -4
  56. package/dist/src/core/patch-manifest-validate.js +22 -4
  57. package/dist/src/core/patch-source-metadata.d.ts +8 -0
  58. package/dist/src/core/patch-source-metadata.js +17 -0
  59. package/dist/src/core/rebase-session.d.ts +8 -3
  60. package/dist/src/core/rebase-session.js +1 -1
  61. package/dist/src/core/status-classify.d.ts +4 -1
  62. package/dist/src/core/status-classify.js +4 -5
  63. package/dist/src/types/commands/index.d.ts +1 -1
  64. package/dist/src/types/commands/options.d.ts +16 -1
  65. package/dist/src/types/commands/patches.d.ts +9 -1
  66. package/dist/src/types/config.d.ts +1 -1
  67. package/dist/src/utils/elapsed.d.ts +4 -0
  68. package/dist/src/utils/elapsed.js +15 -0
  69. package/dist/src/utils/validation.d.ts +2 -2
  70. package/dist/src/utils/validation.js +5 -5
  71. package/package.json +1 -1
@@ -4,6 +4,7 @@
4
4
  * Used after both AST and legacy code paths to avoid duplicating logic.
5
5
  */
6
6
  import { FurnaceError } from '../errors/furnace.js';
7
+ import { stripJsComments } from '../utils/regex.js';
7
8
  /**
8
9
  * Regex for valid custom element tag names. A valid name is lowercase, starts
9
10
  * with a letter, and contains one or more hyphen-separated groups where each
@@ -36,25 +37,41 @@ export function validateTagName(tagName) {
36
37
  * @param isESModule - Whether the module uses ESM (Pattern B) or not (Pattern A)
37
38
  */
38
39
  export function validateRegistrationPlacement(result, tagName, isESModule) {
39
- const dclPattern = /document\.addEventListener\(\s*["']DOMContentLoaded["']/;
40
40
  const insertedPos = result.lastIndexOf(`"${tagName}"`);
41
41
  if (insertedPos === -1)
42
42
  return;
43
- const contentBeforeTag = result.slice(0, insertedPos);
44
- const hasDCLBefore = dclPattern.test(contentBeforeTag);
45
- if (isESModule && !hasDCLBefore && !isTagInArrayConsumedInsideDOMContentLoaded(result, tagName)) {
43
+ if (!isTagInCorrectCustomElementsPlacement(result, tagName, isESModule)) {
44
+ if (!isESModule) {
45
+ throw new FurnaceError(`${tagName} was registered in the DOMContentLoaded/importESModule block (Pattern B) instead of the loadSubScript block (Pattern A). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
46
+ }
46
47
  throw new FurnaceError(`${tagName} was registered in the loadSubScript block (Pattern A) instead of the DOMContentLoaded/importESModule block (Pattern B). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
47
48
  }
48
- if (!isESModule && hasDCLBefore) {
49
- throw new FurnaceError(`${tagName} was registered in the DOMContentLoaded/importESModule block (Pattern B) instead of the loadSubScript block (Pattern A). This will cause the component to fail at runtime. The customElements.js file structure may have changed upstream — manual intervention required.`, tagName);
49
+ }
50
+ /**
51
+ * Returns whether a tag appears in the correct customElements.js placement.
52
+ * ESM entries may either appear textually inside/after DOMContentLoaded or
53
+ * inside an array declared before DOMContentLoaded and consumed by a for-of
54
+ * loop inside the listener, as Firefox 152 Beta does for acornElements.
55
+ */
56
+ export function isTagInCorrectCustomElementsPlacement(content, tagName, isESModule) {
57
+ const stripped = stripJsComments(content);
58
+ const tagPattern = new RegExp(`["']${escapeRegex(tagName)}["']`);
59
+ const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(stripped);
60
+ if (!dclMatch) {
61
+ return !isESModule && tagPattern.test(stripped);
62
+ }
63
+ const beforeDcl = stripped.slice(0, dclMatch.index);
64
+ const afterDcl = stripped.slice(dclMatch.index);
65
+ const tagBeforeDcl = tagPattern.test(beforeDcl);
66
+ const tagAfterDcl = tagPattern.test(afterDcl);
67
+ if (!isESModule) {
68
+ return tagBeforeDcl && !tagAfterDcl;
50
69
  }
70
+ return (tagAfterDcl || isTagInArrayConsumedInsideDOMContentLoaded(stripped, dclMatch.index, tagName));
51
71
  }
52
- function isTagInArrayConsumedInsideDOMContentLoaded(content, tagName) {
53
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(content);
54
- if (!dclMatch)
55
- return false;
56
- const beforeDcl = content.slice(0, dclMatch.index);
57
- const afterDcl = content.slice(dclMatch.index);
72
+ function isTagInArrayConsumedInsideDOMContentLoaded(content, domContentLoadedIdx, tagName) {
73
+ const beforeDcl = content.slice(0, domContentLoadedIdx);
74
+ const afterDcl = content.slice(domContentLoadedIdx);
58
75
  const consumedArrays = new Set();
59
76
  const forOfPattern = /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g;
60
77
  let match;
@@ -9,6 +9,7 @@ import { stripJsComments } from '../utils/regex.js';
9
9
  import { getProjectPaths, loadConfig } from './config.js';
10
10
  import { getFurnacePaths } from './furnace-config.js';
11
11
  import { CUSTOM_ELEMENTS_JS, FTL_DIR, JAR_MN } from './furnace-constants.js';
12
+ import { isTagInCorrectCustomElementsPlacement } from './furnace-registration-validate.js';
12
13
  import { getTokensCssPath } from './token-manager.js';
13
14
  /**
14
15
  * Validates that all Furnace-managed .mjs components are registered in the
@@ -27,21 +28,13 @@ export async function validateRegistrationPatterns(root, config) {
27
28
  return issues;
28
29
  }
29
30
  const content = await readText(filePath);
30
- // Find the DOMContentLoaded block boundary (handles multi-line addEventListener)
31
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(content);
32
- if (!dclMatch) {
33
- return issues;
34
- }
35
- const domContentLoadedIdx = dclMatch.index;
36
31
  // Get all custom component tag names that use .mjs (all custom components do)
37
32
  for (const [name, customConfig] of Object.entries(config.custom)) {
38
33
  if (!customConfig.register)
39
34
  continue;
40
- // Check if this tag is referenced before the DOMContentLoaded block
41
- const contentBeforeDCL = stripJsComments(content.slice(0, domContentLoadedIdx));
42
- const tagPattern = new RegExp(`"${name}"`);
43
- if (tagPattern.test(contentBeforeDCL) &&
44
- !isArrayDeclaredBeforeDclAndConsumedInsideDcl(content, domContentLoadedIdx, name)) {
35
+ const stripped = stripJsComments(content);
36
+ const tagPattern = new RegExp(`["']${escapeRegex(name)}["']`);
37
+ if (tagPattern.test(stripped) && !isTagInCorrectCustomElementsPlacement(content, name, true)) {
45
38
  issues.push({
46
39
  component: name,
47
40
  severity: 'error',
@@ -52,25 +45,6 @@ export async function validateRegistrationPatterns(root, config) {
52
45
  }
53
46
  return issues;
54
47
  }
55
- function isArrayDeclaredBeforeDclAndConsumedInsideDcl(content, domContentLoadedIdx, tagName) {
56
- const contentBeforeDCL = stripJsComments(content.slice(0, domContentLoadedIdx));
57
- const contentAfterDCL = stripJsComments(content.slice(domContentLoadedIdx));
58
- const consumedArrays = new Set();
59
- const forOfPattern = /for\s*\(\s*(?:let|const|var)\s*\[[^)]*\]\s+of\s+([A-Za-z_$][\w$]*)\s*\)/g;
60
- let match;
61
- while ((match = forOfPattern.exec(contentAfterDCL)) !== null) {
62
- if (match[1])
63
- consumedArrays.add(match[1]);
64
- }
65
- for (const arrayName of consumedArrays) {
66
- const declarationPattern = new RegExp(`(?:const|let|var)\\s+${escapeRegex(arrayName)}\\s*=\\s*\\[([\\s\\S]*?)\\];`);
67
- const declaration = declarationPattern.exec(contentBeforeDCL);
68
- if (declaration?.[1] && new RegExp(`["']${escapeRegex(tagName)}["']`).test(declaration[1])) {
69
- return true;
70
- }
71
- }
72
- return false;
73
- }
74
48
  function escapeRegex(value) {
75
49
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
76
50
  }
@@ -173,13 +147,7 @@ export async function checkRegistrationConsistency(root, name, config, ftlDir) {
173
147
  status.customElementsPresent =
174
148
  ceContent.includes(`"${name}"`) || ceContent.includes(`'${name}'`);
175
149
  if (status.customElementsPresent) {
176
- // Check it's in the correct block (after DOMContentLoaded)
177
- const dclMatch = /document\.addEventListener\(\s*["']DOMContentLoaded["']/.exec(ceContent);
178
- if (dclMatch) {
179
- const afterDcl = ceContent.slice(dclMatch.index);
180
- status.customElementsCorrectBlock =
181
- afterDcl.includes(`"${name}"`) || afterDcl.includes(`'${name}'`);
182
- }
150
+ status.customElementsCorrectBlock = isTagInCorrectCustomElementsPlacement(ceContent, name, true);
183
151
  }
184
152
  }
185
153
  return status;
@@ -2,6 +2,7 @@
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { GitError, GitIndexingTimeoutError, GitIndexLockError, PatchApplyError, } from '../errors/git.js';
5
+ import { elapsedSince } from '../utils/elapsed.js';
5
6
  import { toError } from '../utils/errors.js';
6
7
  import { pathExists, removeFile } from '../utils/fs.js';
7
8
  import { verbose } from '../utils/logger.js';
@@ -158,6 +159,7 @@ async function stageAllFilesChunked(dir, scan, options = {}) {
158
159
  * SIGINT'd mid-way assuming the process had stalled.
159
160
  */
160
161
  const GIT_ADD_HEARTBEAT_MS = 15_000;
162
+ const GIT_COMMIT_HEARTBEAT_MS = 15_000;
161
163
  async function scanTopLevelSource(dir) {
162
164
  const entries = await readdir(dir, { withFileTypes: true });
163
165
  return {
@@ -204,9 +206,10 @@ export async function stageAllFiles(dir, options = {}) {
204
206
  heartbeatTimer?.unref();
205
207
  try {
206
208
  try {
209
+ reportProgress?.('Git phase: starting git add -A source indexing.');
207
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'}...`);
208
211
  await git(['add', '-A'], dir, { timeout, env: GIT_ADD_ENV });
209
- reportProgress?.('Monolithic git add -A completed.');
212
+ reportProgress?.('Git phase complete: git add -A source indexing finished.');
210
213
  return;
211
214
  }
212
215
  catch (error) {
@@ -231,6 +234,7 @@ export async function stageAllFiles(dir, options = {}) {
231
234
  phaseStartedAt = Date.now();
232
235
  try {
233
236
  await stageAllFilesChunked(dir, scan, options);
237
+ reportProgress?.('Git phase complete: chunked source indexing finished.');
234
238
  }
235
239
  catch (error) {
236
240
  if (error instanceof GitIndexingTimeoutError)
@@ -243,6 +247,22 @@ export async function stageAllFiles(dir, options = {}) {
243
247
  clearInterval(heartbeatTimer);
244
248
  }
245
249
  }
250
+ async function createInitialSourceCommit(dir, reportProgress) {
251
+ const startedAt = Date.now();
252
+ reportProgress('Git phase: creating initial source commit.');
253
+ reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
254
+ const heartbeat = setInterval(() => {
255
+ reportProgress(`Creating initial Firefox source commit (${elapsedSince(startedAt)} elapsed)...`);
256
+ }, GIT_COMMIT_HEARTBEAT_MS);
257
+ heartbeat.unref();
258
+ try {
259
+ await git(['commit', '-m', 'Initial Firefox source'], dir);
260
+ }
261
+ finally {
262
+ clearInterval(heartbeat);
263
+ }
264
+ reportProgress(`Git phase complete: initial source commit created (${elapsedSince(startedAt)} elapsed).`);
265
+ }
246
266
  /**
247
267
  * Initializes a new git repository with an orphan branch.
248
268
  * @param dir - Directory to initialize
@@ -252,6 +272,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
252
272
  await ensureGit();
253
273
  const reportProgress = options.onProgress ?? (() => { });
254
274
  // Initialize repository
275
+ reportProgress('Git phase: initializing source git repository.');
255
276
  reportProgress('Creating git repository...');
256
277
  await git(['init'], dir);
257
278
  // Create orphan branch
@@ -269,6 +290,7 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
269
290
  // fail. Nothing is ever fetched from or pushed to this remote.
270
291
  reportProgress('Configuring origin remote for build compatibility...');
271
292
  await git(['remote', 'add', 'origin', 'https://github.com/mozilla-firefox/firefox'], dir);
293
+ reportProgress('Git phase complete: source git repository metadata initialized.');
272
294
  // Add all files
273
295
  reportProgress('Indexing Firefox source with git add -A (this can take several minutes on large trees)...');
274
296
  await assertNoGitIndexLock(dir);
@@ -279,9 +301,8 @@ export async function initRepository(dir, branchName = 'main', options = {}) {
279
301
  throw await maybeWrapIndexLockError(dir, error);
280
302
  }
281
303
  // Create initial commit
282
- reportProgress('Creating initial Firefox source commit...');
283
304
  try {
284
- await git(['commit', '-m', 'Initial Firefox source'], dir);
305
+ await createInitialSourceCommit(dir, reportProgress);
285
306
  }
286
307
  catch (error) {
287
308
  throw await maybeWrapIndexLockError(dir, error);
@@ -316,9 +337,8 @@ export async function resumeRepository(dir, options = {}) {
316
337
  throw await maybeWrapIndexLockError(dir, error);
317
338
  }
318
339
  // Create initial commit
319
- reportProgress('Creating initial Firefox source commit...');
320
340
  try {
321
- await git(['commit', '-m', 'Initial Firefox source'], dir);
341
+ await createInitialSourceCommit(dir, reportProgress);
322
342
  }
323
343
  catch (error) {
324
344
  throw await maybeWrapIndexLockError(dir, error);
@@ -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
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Normalizes generated patch files for repository whitespace checks.
3
+ *
4
+ * Unified diffs conventionally encode a blank context line as a physical line
5
+ * containing one space. `git apply` also accepts the same hunk as an empty
6
+ * physical line, while repository-level `git diff --check` flags the
7
+ * single-space artifact as trailing whitespace in `patches/*.patch`.
8
+ */
9
+ export declare function normalizePatchArtifact(content: string): string;
@@ -0,0 +1,13 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Normalizes generated patch files for repository whitespace checks.
4
+ *
5
+ * Unified diffs conventionally encode a blank context line as a physical line
6
+ * containing one space. `git apply` also accepts the same hunk as an empty
7
+ * physical line, while repository-level `git diff --check` flags the
8
+ * single-space artifact as trailing whitespace in `patches/*.patch`.
9
+ */
10
+ export function normalizePatchArtifact(content) {
11
+ return content.replace(/^ $/gm, '');
12
+ }
13
+ //# sourceMappingURL=patch-artifact-normalize.js.map
@@ -4,6 +4,7 @@ import { toError } from '../utils/errors.js';
4
4
  import { pathExists, readText, writeText } from '../utils/fs.js';
5
5
  import { warn } from '../utils/logger.js';
6
6
  import { withPatchDirectoryLock } from './patch-apply.js';
7
+ import { normalizePatchArtifact } from './patch-artifact-normalize.js';
7
8
  import { loadPatchesManifest, savePatchesManifest } from './patch-manifest.js';
8
9
  import { buildProjectedManifest, enforcePatchPolicy } from './patch-policy.js';
9
10
  /**
@@ -38,7 +39,7 @@ export async function updatePatchAndMetadata(patchesDir, filename, newContent, u
38
39
  }
39
40
  let patchWritten = false;
40
41
  try {
41
- await writeText(patchPath, newContent);
42
+ await writeText(patchPath, normalizePatchArtifact(newContent));
42
43
  patchWritten = true;
43
44
  await savePatchesManifest(patchesDir, manifest);
44
45
  }
@@ -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
@@ -6,6 +6,7 @@ import { pathExists, readText, removeFile, writeText } from '../utils/fs.js';
6
6
  import { warn } from '../utils/logger.js';
7
7
  import { PATCH_CATEGORIES } from '../utils/validation.js';
8
8
  import { discoverPatches, withPatchDirectoryLock } from './patch-apply.js';
9
+ import { normalizePatchArtifact } from './patch-artifact-normalize.js';
9
10
  import { findAllPatchesForFilesWithDetails, } from './patch-export-coverage.js';
10
11
  import { addPatchToManifest, loadPatchesManifest, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest.js';
11
12
  import { allocatePolicyOrder, enforcePatchPolicy } from './patch-policy.js';
@@ -74,6 +75,8 @@ export async function commitExportedPatch(input) {
74
75
  description: input.description,
75
76
  filesAffected: input.filesAffected,
76
77
  sourceEsrVersion: input.sourceEsrVersion,
78
+ ...(input.sourceProduct !== undefined ? { sourceProduct: input.sourceProduct } : {}),
79
+ ...(input.sourceVersion !== undefined ? { sourceVersion: input.sourceVersion } : {}),
77
80
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
78
81
  ...(input.lintIgnore !== undefined ? { lintIgnore: input.lintIgnore } : {}),
79
82
  ...(input.config !== undefined ? { config: input.config } : {}),
@@ -95,7 +98,7 @@ export async function commitExportedPatch(input) {
95
98
  }
96
99
  }
97
100
  try {
98
- await writeText(patchPath, input.diff);
101
+ await writeText(patchPath, normalizePatchArtifact(input.diff));
99
102
  await addPatchToManifest(input.patchesDir, plan.metadata, plan.supersededPatches.map((p) => p.filename));
100
103
  for (const oldPatch of plan.supersededPatches) {
101
104
  await removeFile(oldPatch.path);
@@ -195,7 +198,7 @@ export async function findExistingPatchForFile(patchesDir, filePath) {
195
198
  * @param newContent - New patch content
196
199
  */
197
200
  export async function updatePatch(patchPath, newContent) {
198
- await writeText(patchPath, newContent);
201
+ await writeText(patchPath, normalizePatchArtifact(newContent));
199
202
  }
200
203
  /**
201
204
  * Deletes a patch file and removes it from the manifest.
@@ -261,6 +264,8 @@ async function computeExportPlanUnderLock(input) {
261
264
  description: input.description,
262
265
  createdAt: new Date().toISOString(),
263
266
  sourceEsrVersion: input.sourceEsrVersion,
267
+ ...(input.sourceProduct !== undefined ? { sourceProduct: input.sourceProduct } : {}),
268
+ sourceVersion: input.sourceVersion ?? input.sourceEsrVersion,
264
269
  filesAffected: input.filesAffected,
265
270
  ...(input.tier !== undefined ? { tier: input.tier } : {}),
266
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