@hominis/fireforge 0.13.0 → 0.13.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.
package/CHANGELOG.md CHANGED
@@ -12,6 +12,17 @@
12
12
  - **`observer-topic-naming` no longer matches across newlines.** The regex that extracts topic strings from `notifyObservers`/`addObserver`/`removeObserver` calls now anchors to a single line, preventing false positives when the call spans multiple lines and an unrelated string literal appears later.
13
13
  - **`raw-color-value` now supports a file allowlist and inline suppression.** New `patchLint.rawColorAllowlist` config array in `fireforge.json` exempts file paths (exact or basename match) from the raw-color check — intended for design token files that must contain raw color values. Individual declarations can also be suppressed with an inline `/* fireforge-ignore: raw-color-value */` comment.
14
14
  - **`large-patch-lines` now uses tiered severity thresholds.** The old single >300-line warning is replaced with a three-tier system matching the `file-too-large` pattern. General patches: 800+ lines notice, 1500+ warning, 3000+ error. Test-only patches (all files match test patterns): 1500+ notice, 3000+ warning, 6000+ error. The previous threshold was too restrictive relative to file LOC limits — creating a single new file at the `file-too-large` notice tier (500 LOC) already exceeded it. Messages now include the applicable soft and hard limits.
15
+ - **`large-patch-lines` now ignores binary content.** Patches whose diff contains GIT binary patch hunks (PNG, ICO, ICNS, BMP, etc.) no longer count base85-encoded data toward the line limit. This removes the need for `--skip-lint` on branding asset patches that are predominantly binary.
16
+ - **`modified-file-missing-header` no longer false-positives on upstream files.** Modified upstream files (e.g. `BrowserGlue.sys.mjs`) that carry an MPL-2.0 header in `/* */` block-comment style were incorrectly flagged because the check only tried the comment style inferred from the file extension. The check now cascades through all comment styles and falls back to scanning leading lines for raw license identifier strings (MPL, Apache, MIT, GPL, SPDX).
17
+
18
+ ### New commands
19
+
20
+ - **`fireforge patch compact`** — closes ordinal gaps in the patch queue in a single atomic operation. After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7); `compact` renumbers them sequentially (1, 2, 3). Previously this required N sequential `patch reorder` calls. Supports `--dry-run` and `--yes`.
21
+
22
+ ### Register improvements
23
+
24
+ - **`register` now supports `.xhtml` and `.css` files in `browser/base/content/`.** Previously only `.js` and `.mjs` files were accepted; XHTML and CSS files required manual `jar.mn` edits.
25
+ - **`register` now gives actionable advice for unregistrable file types.** Attempting to register a `.ftl` locale file explains that FTL files are auto-discovered via `jar.mn` glob patterns. Attempting to register an individual test file explains that it should be added to the corresponding `browser.toml` and suggests the correct `register` invocation for the test directory manifest.
15
26
 
16
27
  ### General Improvements
17
28
 
package/README.md CHANGED
@@ -188,30 +188,30 @@ If the manifest drifts after an interrupted export or manual edits, `fireforge i
188
188
 
189
189
  `fireforge lint` runs automatically during export, export-all and re-export. Use `--skip-lint` to downgrade errors to warnings. Errors block the export; warnings are printed but do not block.
190
190
 
191
- | Check | Scope | Severity |
192
- | ------------------------------ | --------------------------------------------------------------------- | ------------------------ |
193
- | `missing-license-header` | New files (JS/CSS/FTL) | error |
194
- | `relative-import` | JS/MJS files | error |
195
- | `token-prefix-violation` | CSS files (with furnace) | error |
196
- | `raw-color-value` | Introduced CSS color values | error |
197
- | `duplicate-new-file-creation` | Same path created by multiple patches | error |
198
- | `forward-import` | Patch imports from a later-patch file | error |
199
- | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
200
- | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
201
- | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
202
- | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
203
- | `missing-modification-comment` | Modified upstream JS/MJS | warning |
204
- | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
205
- | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
206
- | `observer-topic-naming` | Observer topics with binaryName | warning |
207
- | `large-patch-files` | Patches affecting >5 files | warning |
208
- | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test) | notice / warning / error |
191
+ | Check | Scope | Severity |
192
+ | ------------------------------ | ------------------------------------------------------------------------- | ------------------------ |
193
+ | `missing-license-header` | New files (JS/CSS/FTL) | error |
194
+ | `relative-import` | JS/MJS files | error |
195
+ | `token-prefix-violation` | CSS files (with furnace) | error |
196
+ | `raw-color-value` | Introduced CSS color values (allowlist via `patchLint.rawColorAllowlist`) | error |
197
+ | `duplicate-new-file-creation` | Same path created by multiple patches | error |
198
+ | `forward-import` | Patch imports from a later-patch file | error |
199
+ | `missing-jsdoc` | Exports in patch-owned `.sys.mjs` | error |
200
+ | `jsdoc-param-mismatch` | Exports in patch-owned `.sys.mjs` | error |
201
+ | `jsdoc-missing-returns` | Exports in patch-owned `.sys.mjs` | error |
202
+ | `checkjs-type-error` | Patch-owned `.sys.mjs` (opt-in) | error |
203
+ | `missing-modification-comment` | Modified upstream JS/MJS | warning |
204
+ | `modified-file-missing-header` | Modified upstream files (JS/CSS/FTL) | warning |
205
+ | `file-too-large` | New files (tiered: 500/750/900 general, 1200/1400/1600 test) | notice / warning / error |
206
+ | `observer-topic-naming` | Observer topics with binaryName | warning |
207
+ | `large-patch-files` | Patches affecting >5 files | warning |
208
+ | `large-patch-lines` | Patch line count (tiered: 800/1500/3000 general, 1500/3000/6000 test) | notice / warning / error |
209
209
 
210
210
  **JSDoc validation** uses AST-based analysis (Acorn) to validate exported APIs in patch-owned `.sys.mjs` files. A file is "patch-owned" if it was newly created by the current diff or by an existing patch in the queue. Functions must document every `@param` (names must match) and include `@returns` when the function returns a value. Exported constants and classes require a JSDoc block.
211
211
 
212
212
  **Optional `checkJs` pass.** Enable a TypeScript-esque bastardization of type checking for patch-owned `.sys.mjs` files by adding `"patchLint": { "checkJs": true }` to `fireforge.json`. This uses the TypeScript compiler API with `allowJs + checkJs + noEmit`, scoped only to patch-owned files. Firefox globals (`Services`, `ChromeUtils`, `lazy`, etc.) are shimmed automatically. Module-resolution errors from Firefox's `resource://` and `chrome://` URL schemes are suppressed since TypeScript cannot follow these. This pass solely focuses on type errors within the patch-owned code itself (mismatched JSDoc types, wrong argument counts, unreachable code, etc.).
213
213
 
214
- The two cross-patch rules (`duplicate-new-file-creation` and `forward-import`) run over the whole patch queue rather than a single diff, catching ordering issues that only surface during `import`. Forward-import detection compares leaf filenames, so a false positive is theoretically possible when two patches create files with the same basename in different directories. Suppress with an inline `// fireforge-ignore: forward-import` comment on or above the import line. This is currently the only lint rule that supports inline suppression.
214
+ The two cross-patch rules (`duplicate-new-file-creation` and `forward-import`) run over the whole patch queue rather than a single diff, catching ordering issues that only surface during `import`. Forward-import detection compares leaf filenames, so a false positive is theoretically possible when two patches create files with the same basename in different directories. Suppress with an inline `// fireforge-ignore: forward-import` comment on or above the import line. Both `forward-import` and `raw-color-value` support inline suppression comments (`// fireforge-ignore: forward-import` and `/* fireforge-ignore: raw-color-value */` respectively).
215
215
 
216
216
  </details>
217
217
 
@@ -233,6 +233,7 @@ Then fix with the appropriate primitive:
233
233
  | Two patches each creating the same file | `fireforge patch delete <duplicate>` or `fireforge re-export --files` |
234
234
  | A patch imports from a module in a later patch | `fireforge patch reorder <later> --before <importer>` |
235
235
  | Wrong patch ordering | `fireforge patch reorder <patch> --to <N>` |
236
+ | Ordinal gaps after deletes/splits | `fireforge patch compact` |
236
237
  | A patch claims files that belong elsewhere | `fireforge re-export --files <subset> <patch>` |
237
238
  | Manifest references a missing patch file | `fireforge doctor --repair-patches-manifest` |
238
239
  | Unmanaged changes you want to discard | `fireforge discard <file>` or `fireforge reset` |
@@ -266,13 +267,13 @@ fireforge register browser/modules/mybrowser/MyStore.sys.mjs
266
267
  <details>
267
268
  <summary>Supported register patterns</summary>
268
269
 
269
- | File pattern | Manifest | Entry format |
270
- | ------------------------------------------ | ------------------------------------- | ----------------------------------- |
271
- | `browser/themes/shared/*.css` | `browser/themes/shared/jar.inc.mn` | `skin/classic/browser/{name}.css` |
272
- | `browser/base/content/*.{js,mjs}` | `browser/base/jar.mn` | `content/browser/{file}` |
273
- | `browser/base/content/test/*/browser.toml` | `browser/base/moz.build` | `"content/test/{dir}/browser.toml"` |
274
- | `browser/modules/mybrowser/*.sys.mjs` | `browser/modules/mybrowser/moz.build` | `"{name}.sys.mjs"` |
275
- | `toolkit/content/widgets/*/*.{mjs,css}` | `toolkit/content/jar.mn` | `content/global/elements/{file}` |
270
+ | File pattern | Manifest | Entry format |
271
+ | ------------------------------------------- | ------------------------------------- | ----------------------------------- |
272
+ | `browser/themes/shared/*.css` | `browser/themes/shared/jar.inc.mn` | `skin/classic/browser/{name}.css` |
273
+ | `browser/base/content/*.{js,mjs,xhtml,css}` | `browser/base/jar.mn` | `content/browser/{file}` |
274
+ | `browser/base/content/test/*/browser.toml` | `browser/base/moz.build` | `"content/test/{dir}/browser.toml"` |
275
+ | `browser/modules/mybrowser/*.sys.mjs` | `browser/modules/mybrowser/moz.build` | `"{name}.sys.mjs"` |
276
+ | `toolkit/content/widgets/*/*.{mjs,css}` | `toolkit/content/jar.mn` | `content/global/elements/{file}` |
276
277
 
277
278
  </details>
278
279
 
@@ -327,9 +328,12 @@ fireforge patch reorder 003-ui-sidebar-tweaks.patch --to 1
327
328
 
328
329
  # Move a patch before or after another
329
330
  fireforge patch reorder 003-ui-sidebar.patch --before 001-branding-logo.patch
331
+
332
+ # Close ordinal gaps after deletes or splits (e.g. 1, 3, 7 → 1, 2, 3)
333
+ fireforge patch compact
330
334
  ```
331
335
 
332
- Both subcommands support `--dry-run` and `--yes`.
336
+ All subcommands support `--dry-run` and `--yes`.
333
337
 
334
338
  ### Additional workflow commands
335
339
 
@@ -360,7 +364,11 @@ fireforge token --name "--my-color" --value "light-dark(#fff, #000)"
360
364
  "product": "firefox-esr"
361
365
  },
362
366
  "build": { "jobs": 8 },
363
- "wire": { "subscriptDir": "browser/components/mybrowser" }
367
+ "wire": { "subscriptDir": "browser/components/mybrowser" },
368
+ "patchLint": {
369
+ "checkJs": true,
370
+ "rawColorAllowlist": ["hominis-tokens.css"]
371
+ }
364
372
  }
365
373
  ```
366
374
 
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `fireforge patch compact` — closes ordinal gaps in the patch queue.
3
+ *
4
+ * After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
5
+ * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
6
+ * in a single atomic operation, preserving relative order.
7
+ */
8
+ import { Command } from 'commander';
9
+ import type { CommandContext } from '../../types/cli.js';
10
+ import type { PatchCompactOptions } from '../../types/commands/index.js';
11
+ /**
12
+ * Runs the `patch compact` command: renumbers all patches to close ordinal
13
+ * gaps in a single atomic operation.
14
+ *
15
+ * @param projectRoot - Project root directory
16
+ * @param options - Command options
17
+ */
18
+ export declare function patchCompactCommand(projectRoot: string, options?: PatchCompactOptions): Promise<void>;
19
+ /**
20
+ * Registers the `patch compact` subcommand on the `patch` parent.
21
+ *
22
+ * @param parent - Parent Commander command
23
+ * @param context - Shared CLI registration context
24
+ */
25
+ export declare function registerPatchCompact(parent: Command, context: CommandContext): void;
@@ -0,0 +1,132 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * `fireforge patch compact` — closes ordinal gaps in the patch queue.
4
+ *
5
+ * After deletes or splits, patch ordinals may have gaps (e.g. 1, 3, 7).
6
+ * This command renumbers all patches to sequential ordinals (1, 2, 3, …)
7
+ * in a single atomic operation, preserving relative order.
8
+ */
9
+ import { getProjectPaths } from '../../core/config.js';
10
+ import { appendHistory, confirmDestructive } from '../../core/destructive.js';
11
+ import { withPatchDirectoryLock } from '../../core/patch-lock.js';
12
+ import { loadPatchesManifest, renumberPatchesInManifest, } from '../../core/patch-manifest.js';
13
+ import { GeneralError } from '../../errors/base.js';
14
+ import { toError } from '../../utils/errors.js';
15
+ import { pathExists } from '../../utils/fs.js';
16
+ import { info, intro, outro, warn } from '../../utils/logger.js';
17
+ import { pickDefined } from '../../utils/options.js';
18
+ import { rebuildFilenameForOrder } from './reorder.js';
19
+ /**
20
+ * Computes a rename map that assigns sequential ordinals (1, 2, 3, …)
21
+ * to all patches, sorted by their current order.
22
+ */
23
+ function computeCompactRenameMap(patches) {
24
+ const sorted = [...patches].sort((a, b) => a.order - b.order);
25
+ const renames = new Map();
26
+ for (const [i, patch] of sorted.entries()) {
27
+ const newOrder = i + 1;
28
+ if (patch.order !== newOrder) {
29
+ renames.set(patch.filename, {
30
+ newOrder,
31
+ newFilename: rebuildFilenameForOrder(patch, newOrder),
32
+ });
33
+ }
34
+ }
35
+ return renames;
36
+ }
37
+ /**
38
+ * Runs the `patch compact` command: renumbers all patches to close ordinal
39
+ * gaps in a single atomic operation.
40
+ *
41
+ * @param projectRoot - Project root directory
42
+ * @param options - Command options
43
+ */
44
+ export async function patchCompactCommand(projectRoot, options = {}) {
45
+ intro(options.dryRun ? 'FireForge patch compact (dry run)' : 'FireForge patch compact');
46
+ const paths = getProjectPaths(projectRoot);
47
+ if (!(await pathExists(paths.patches))) {
48
+ throw new GeneralError('Patches directory not found.');
49
+ }
50
+ const manifest = await loadPatchesManifest(paths.patches);
51
+ if (!manifest || manifest.patches.length === 0) {
52
+ throw new GeneralError('No patches in manifest.');
53
+ }
54
+ const renameMap = computeCompactRenameMap(manifest.patches);
55
+ if (renameMap.size === 0) {
56
+ info('Patch queue is already compact. Nothing to do.');
57
+ outro('Compact complete (no-op)');
58
+ return;
59
+ }
60
+ const sorted = [...renameMap.entries()].sort((a, b) => a[1].newOrder - b[1].newOrder);
61
+ const summary = [`${renameMap.size} patch(es) would be renumbered:`];
62
+ for (const [oldFilename, entry] of sorted) {
63
+ summary.push(` ${oldFilename} → ${entry.newFilename} (order ${entry.newOrder})`);
64
+ }
65
+ const decision = await confirmDestructive({
66
+ operation: 'patch-compact',
67
+ title: `Compact ${manifest.patches.length} patches (${renameMap.size} rename(s))`,
68
+ summary,
69
+ yes: options.yes === true,
70
+ dryRun: options.dryRun === true,
71
+ });
72
+ if (decision === 'dry-run') {
73
+ outro('Dry run complete — no changes made');
74
+ return;
75
+ }
76
+ if (decision === 'cancelled') {
77
+ outro('Compact cancelled');
78
+ return;
79
+ }
80
+ await withPatchDirectoryLock(paths.patches, async () => {
81
+ const currentManifest = await loadPatchesManifest(paths.patches);
82
+ if (!currentManifest) {
83
+ throw new GeneralError('Manifest disappeared while waiting for lock.');
84
+ }
85
+ const currentRenameMap = computeCompactRenameMap(currentManifest.patches);
86
+ if (currentRenameMap.size === 0) {
87
+ info('Patch queue was compacted by another process. Nothing to do.');
88
+ return;
89
+ }
90
+ await renumberPatchesInManifest(paths.patches, currentRenameMap);
91
+ const historyEntry = {
92
+ operation: 'patch-compact',
93
+ args: {
94
+ renames: [...currentRenameMap.entries()]
95
+ .sort((a, b) => a[1].newOrder - b[1].newOrder)
96
+ .map(([from, entry]) => ({
97
+ from,
98
+ to: entry.newFilename,
99
+ order: entry.newOrder,
100
+ })),
101
+ },
102
+ ...(options.yes === true ? { yes: true } : {}),
103
+ result: 'ok',
104
+ };
105
+ try {
106
+ await appendHistory(paths.patches, historyEntry);
107
+ }
108
+ catch (historyError) {
109
+ warn(`History log append failed after patch compact committed: ${toError(historyError).message}`);
110
+ }
111
+ });
112
+ info(`Compacted ${renameMap.size} patch(es).`);
113
+ outro('Compact complete');
114
+ }
115
+ /**
116
+ * Registers the `patch compact` subcommand on the `patch` parent.
117
+ *
118
+ * @param parent - Parent Commander command
119
+ * @param context - Shared CLI registration context
120
+ */
121
+ export function registerPatchCompact(parent, context) {
122
+ const { getProjectRoot, withErrorHandling } = context;
123
+ parent
124
+ .command('compact')
125
+ .description('Close ordinal gaps in the patch queue (renumber sequentially)')
126
+ .option('--dry-run', 'Show what would happen without writing')
127
+ .option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
128
+ .action(withErrorHandling(async (options) => {
129
+ await patchCompactCommand(getProjectRoot(), pickDefined(options));
130
+ }));
131
+ }
132
+ //# sourceMappingURL=compact.js.map
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { Command } from 'commander';
8
8
  import type { CommandContext } from '../../types/cli.js';
9
+ export { patchCompactCommand } from './compact.js';
9
10
  export { patchDeleteCommand } from './delete.js';
10
11
  export { patchReorderCommand } from './reorder.js';
11
12
  /**
@@ -5,8 +5,10 @@
5
5
  * command list. Queue-level verbs like `lint`, `export`, `verify`, and
6
6
  * `status` stay flat.
7
7
  */
8
+ import { registerPatchCompact } from './compact.js';
8
9
  import { registerPatchDelete } from './delete.js';
9
10
  import { registerPatchReorder } from './reorder.js';
11
+ export { patchCompactCommand } from './compact.js';
10
12
  export { patchDeleteCommand } from './delete.js';
11
13
  export { patchReorderCommand } from './reorder.js';
12
14
  /**
@@ -18,7 +20,8 @@ export { patchReorderCommand } from './reorder.js';
18
20
  export function registerPatch(program, context) {
19
21
  const patch = program
20
22
  .command('patch')
21
- .description('Manage individual patches in the queue (delete, reorder)');
23
+ .description('Manage individual patches in the queue (compact, delete, reorder)');
24
+ registerPatchCompact(patch, context);
22
25
  registerPatchDelete(patch, context);
23
26
  registerPatchReorder(patch, context);
24
27
  }
@@ -9,7 +9,11 @@
9
9
  */
10
10
  import { Command } from 'commander';
11
11
  import type { CommandContext } from '../../types/cli.js';
12
- import type { PatchReorderOptions } from '../../types/commands/index.js';
12
+ import type { PatchMetadata, PatchReorderOptions } from '../../types/commands/index.js';
13
+ /** Zero-pads an ordinal number to the given width. */
14
+ export declare function padOrder(value: number, width: number): string;
15
+ /** Builds a new patch filename by replacing the numeric prefix with `newOrder`. */
16
+ export declare function rebuildFilenameForOrder(existing: PatchMetadata, newOrder: number): string;
13
17
  /**
14
18
  * Runs the `patch reorder` command: computes a rename map moving the
15
19
  * target patch to the requested slot, projects the new order through
@@ -20,10 +20,12 @@ import { pathExists } from '../../utils/fs.js';
20
20
  import { info, intro, outro, warn } from '../../utils/logger.js';
21
21
  import { pickDefined } from '../../utils/options.js';
22
22
  import { parsePositiveIntegerFlag } from '../../utils/validation.js';
23
- function padOrder(value, width) {
23
+ /** Zero-pads an ordinal number to the given width. */
24
+ export function padOrder(value, width) {
24
25
  return String(value).padStart(width, '0');
25
26
  }
26
- function rebuildFilenameForOrder(existing, newOrder) {
27
+ /** Builds a new patch filename by replacing the numeric prefix with `newOrder`. */
28
+ export function rebuildFilenameForOrder(existing, newOrder) {
27
29
  const currentPrefixMatch = /^(\d+)-/.exec(existing.filename);
28
30
  const currentPrefix = currentPrefixMatch?.[1] ?? '001';
29
31
  const width = Math.max(3, currentPrefix.length, String(newOrder).length);
@@ -19,7 +19,7 @@ export declare const SRC_DIR = "src";
19
19
  /** Supported top-level fireforge.json keys backed by the current schema. */
20
20
  export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint"];
21
21
  /** Supported config paths that can be read or set without --force. */
22
- export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs"];
22
+ export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist"];
23
23
  /**
24
24
  * Gets all project paths based on a root directory.
25
25
  * @param root - Root directory of the project
@@ -45,6 +45,7 @@ export const SUPPORTED_CONFIG_PATHS = [
45
45
  'wire.subscriptDir',
46
46
  'patchLint',
47
47
  'patchLint.checkJs',
48
+ 'patchLint.rawColorAllowlist',
48
49
  ];
49
50
  /**
50
51
  * Gets all project paths based on a root directory.
@@ -118,6 +118,14 @@ export function validateConfig(data) {
118
118
  }
119
119
  config.patchLint.checkJs = checkJs;
120
120
  }
121
+ const rawColorAllowlist = patchLintRec.raw('rawColorAllowlist');
122
+ if (rawColorAllowlist !== undefined) {
123
+ if (!Array.isArray(rawColorAllowlist) ||
124
+ rawColorAllowlist.some((v) => typeof v !== 'string')) {
125
+ throw new ConfigError('Config field "patchLint.rawColorAllowlist" must be an array of strings');
126
+ }
127
+ config.patchLint.rawColorAllowlist = rawColorAllowlist;
128
+ }
121
129
  }
122
130
  // Warn on unknown root keys
123
131
  const knownRootKeys = new Set(SUPPORTED_CONFIG_ROOT_KEYS);
@@ -25,6 +25,21 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
25
25
  * @param style - Comment syntax of the file
26
26
  */
27
27
  export declare function hasAnyLicenseHeader(content: string, style: CommentStyle): boolean;
28
+ /**
29
+ * Returns true if `content` starts with any known license header in any
30
+ * comment style (js, css, hash).
31
+ *
32
+ * @param content - File content to check
33
+ */
34
+ export declare function hasAnyLicenseHeaderAnyStyle(content: string): boolean;
35
+ /**
36
+ * Returns true if the first few lines of `content` contain a recognized
37
+ * upstream license identifier string.
38
+ *
39
+ * @param content - File content to check
40
+ * @param maxLines - Number of leading lines to inspect (default 10)
41
+ */
42
+ export declare function containsUpstreamLicenseText(content: string, maxLines?: number): boolean;
28
43
  /**
29
44
  * Prepends the license header to a file on disk if it is not already present.
30
45
  *
@@ -64,6 +64,34 @@ export function hasAnyLicenseHeader(content, style) {
64
64
  const licenses = Object.keys(HEADER_LINES);
65
65
  return licenses.some((license) => content.startsWith(getLicenseHeader(license, style)));
66
66
  }
67
+ /**
68
+ * Returns true if `content` starts with any known license header in any
69
+ * comment style (js, css, hash).
70
+ *
71
+ * @param content - File content to check
72
+ */
73
+ export function hasAnyLicenseHeaderAnyStyle(content) {
74
+ const styles = ['js', 'css', 'hash'];
75
+ return styles.some((style) => hasAnyLicenseHeader(content, style));
76
+ }
77
+ /**
78
+ * Returns true if the first few lines of `content` contain a recognized
79
+ * upstream license identifier string.
80
+ *
81
+ * @param content - File content to check
82
+ * @param maxLines - Number of leading lines to inspect (default 10)
83
+ */
84
+ export function containsUpstreamLicenseText(content, maxLines = 10) {
85
+ const head = content.split('\n').slice(0, maxLines).join('\n');
86
+ const markers = [
87
+ 'Mozilla Public License',
88
+ 'SPDX-License-Identifier',
89
+ 'Apache License',
90
+ 'MIT License',
91
+ 'GNU General Public License',
92
+ ];
93
+ return markers.some((marker) => head.includes(marker));
94
+ }
67
95
  /**
68
96
  * Prepends the license header to a file on disk if it is not already present.
69
97
  *
@@ -16,7 +16,7 @@ export function getRules(binaryName) {
16
16
  extractArgs: (m) => [m[1] ?? ''],
17
17
  },
18
18
  {
19
- pattern: /^browser\/base\/content\/(.+\.(?:js|mjs))$/,
19
+ pattern: /^browser\/base\/content\/(.+\.(?:js|mjs|xhtml|css))$/,
20
20
  isRegistered: (engineDir, fileName) => isBrowserContentRegistered(engineDir, fileName),
21
21
  register: (engineDir, after, dryRun, fileName) => registerBrowserContent(engineDir, fileName, after, undefined, dryRun),
22
22
  extractArgs: (m) => [m[1] ?? ''],
@@ -93,6 +93,19 @@ export function matchesRegistrablePattern(filePath, binaryName) {
93
93
  const rules = getRules(binaryName);
94
94
  return rules.some((rule) => rule.pattern.test(normalized));
95
95
  }
96
+ /** Returns advice for files that cannot be registered, or null if no advice applies. */
97
+ function getUnregistrableAdvice(filePath) {
98
+ if (filePath.endsWith('.ftl')) {
99
+ return "FTL locale files are auto-discovered via jar.mn glob patterns and don't need manual registration.";
100
+ }
101
+ const testMatch = filePath.match(/^browser\/base\/content\/test\/([^/]+)\/(?!browser\.toml$).+$/);
102
+ if (testMatch) {
103
+ const dir = testMatch[1];
104
+ return ('Individual test files should be added directly to the corresponding browser.toml manifest. ' +
105
+ `Use 'fireforge register browser/base/content/test/${dir}/browser.toml' to register new test directories.`);
106
+ }
107
+ return null;
108
+ }
96
109
  /**
97
110
  * Checks whether a supported registrable file is already present in its manifest.
98
111
  *
@@ -112,9 +125,13 @@ export async function isFileRegistered(root, filePath) {
112
125
  return rule.isRegistered(engineDir, ...args);
113
126
  }
114
127
  }
128
+ const advice = getUnregistrableAdvice(normalizedPath);
129
+ if (advice) {
130
+ throw new InvalidArgumentError(advice, 'path');
131
+ }
115
132
  throw new InvalidArgumentError(`Unknown file pattern: "${normalizedPath}". Supported patterns:\n` +
116
133
  ' browser/themes/shared/*.css\n' +
117
- ' browser/base/content/*.js\n' +
134
+ ' browser/base/content/*.{js,mjs,xhtml,css}\n' +
118
135
  ' browser/base/content/test/*/browser.toml\n' +
119
136
  ` browser/modules/${config.binaryName}/*.sys.mjs\n` +
120
137
  ' toolkit/content/widgets/*/*.{mjs,css}', 'path');
@@ -141,9 +158,13 @@ export async function registerFile(root, filePath, dryRun = false, after) {
141
158
  return rule.register(engineDir, after, dryRun, ...args);
142
159
  }
143
160
  }
161
+ const advice = getUnregistrableAdvice(normalizedPath);
162
+ if (advice) {
163
+ throw new InvalidArgumentError(advice, 'path');
164
+ }
144
165
  throw new InvalidArgumentError(`Unknown file pattern: "${normalizedPath}". Supported patterns:\n` +
145
166
  ' browser/themes/shared/*.css\n' +
146
- ' browser/base/content/*.js\n' +
167
+ ' browser/base/content/*.{js,mjs,xhtml,css}\n' +
147
168
  ' browser/base/content/test/*/browser.toml\n' +
148
169
  ` browser/modules/${config.binaryName}/*.sys.mjs\n` +
149
170
  ' toolkit/content/widgets/*/*.{mjs,css}', 'path');
@@ -6,6 +6,17 @@ export { buildPatchQueueContext, collectNewFileCreatorsByPath, type ExtractedSpe
6
6
  export { buildModifiedFileAdditionsFromDiff, detectNewFilesInDiff } from './patch-lint-diff.js';
7
7
  export { type JsDocCheck, type JsDocIssue, validateExportJsDoc } from './patch-lint-jsdoc.js';
8
8
  export { resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
9
+ /**
10
+ * Counts the total lines in a unified diff and the number of non-binary
11
+ * text lines, so binary hunks do not inflate patch size checks.
12
+ *
13
+ * @param diffContent - Raw unified diff string
14
+ * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
15
+ */
16
+ export declare function countNonBinaryDiffLines(diffContent: string): {
17
+ total: number;
18
+ textLines: number;
19
+ };
9
20
  /**
10
21
  * Returns true if the file path looks like a test file.
11
22
  * Matches paths containing `/test/` or filenames starting with
@@ -5,7 +5,7 @@ import { pathExists, readText } from '../utils/fs.js';
5
5
  import { verbose } from '../utils/logger.js';
6
6
  import { hasRawCssColors, stripJsComments } from '../utils/regex.js';
7
7
  import { loadFurnaceConfig } from './furnace-config.js';
8
- import { getLicenseHeader, hasAnyLicenseHeader } from './license-headers.js';
8
+ import { containsUpstreamLicenseText, getLicenseHeader, hasAnyLicenseHeader, hasAnyLicenseHeaderAnyStyle, } from './license-headers.js';
9
9
  import { runCheckJs } from './patch-lint-checkjs.js';
10
10
  import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
11
11
  import { validateExportJsDoc } from './patch-lint-jsdoc.js';
@@ -32,6 +32,31 @@ const FILE_SIZE_THRESHOLDS = {
32
32
  general: { notice: 500, warning: 750, error: 900 },
33
33
  test: { notice: 1200, warning: 1400, error: 1600 },
34
34
  };
35
+ /**
36
+ * Counts the total lines in a unified diff and the number of non-binary
37
+ * text lines, so binary hunks do not inflate patch size checks.
38
+ *
39
+ * @param diffContent - Raw unified diff string
40
+ * @returns Object with `total` line count and `textLines` (total minus binary hunk lines)
41
+ */
42
+ export function countNonBinaryDiffLines(diffContent) {
43
+ const lines = diffContent.split('\n');
44
+ const total = lines.length;
45
+ let binaryLines = 0;
46
+ let inBinaryHunk = false;
47
+ for (const line of lines) {
48
+ if (line === 'GIT binary patch' || line.startsWith('GIT binary patch')) {
49
+ inBinaryHunk = true;
50
+ }
51
+ else if (line.startsWith('diff --git ')) {
52
+ inBinaryHunk = false;
53
+ }
54
+ if (inBinaryHunk) {
55
+ binaryLines++;
56
+ }
57
+ }
58
+ return { total, textLines: total - binaryLines };
59
+ }
35
60
  const PATCH_LINE_THRESHOLDS = {
36
61
  general: { notice: 800, warning: 1500, error: 3000 },
37
62
  test: { notice: 1500, warning: 3000, error: 6000 },
@@ -87,10 +112,10 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
87
112
  let tokenAllowlist;
88
113
  try {
89
114
  const root = join(repoDir, '..');
90
- const config = await loadFurnaceConfig(root);
91
- if (config.tokenPrefix) {
92
- tokenPrefix = config.tokenPrefix;
93
- tokenAllowlist = new Set(config.tokenAllowlist ?? []);
115
+ const furnaceConfig = await loadFurnaceConfig(root);
116
+ if (furnaceConfig.tokenPrefix) {
117
+ tokenPrefix = furnaceConfig.tokenPrefix;
118
+ tokenAllowlist = new Set(furnaceConfig.tokenAllowlist ?? []);
94
119
  }
95
120
  }
96
121
  catch (error) {
@@ -386,7 +411,9 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
386
411
  if (!(await pathExists(filePath)))
387
412
  continue;
388
413
  const content = await readText(filePath);
389
- if (!hasAnyLicenseHeader(content, style)) {
414
+ if (!hasAnyLicenseHeader(content, style) &&
415
+ !hasAnyLicenseHeaderAnyStyle(content) &&
416
+ !containsUpstreamLicenseText(content)) {
390
417
  issues.push({
391
418
  file,
392
419
  check: 'modified-file-missing-header',
@@ -412,7 +439,7 @@ export async function lintModifiedFileHeaders(repoDir, affectedFiles, newFiles)
412
439
  */
413
440
  export async function lintExportedPatch(repoDir, affectedFiles, diffContent, config, patchQueueCtx) {
414
441
  const newFiles = detectNewFilesInDiff(diffContent);
415
- const lineCount = diffContent.split('\n').length;
442
+ const { textLines: lineCount } = countNonBinaryDiffLines(diffContent);
416
443
  const patchOwnedFiles = resolvePatchOwnedSysMjs(newFiles, patchQueueCtx);
417
444
  const [cssIssues, headerIssues, jsIssues, modifiedHeaderIssues] = await Promise.all([
418
445
  lintPatchedCss(repoDir, affectedFiles, diffContent, config),
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Re-exports all command-related types from focused sub-modules.
3
3
  */
4
- export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchDeleteOptions, PatchReorderOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
4
+ export type { BuildOptions, DiscardOptions, DoctorOptions, DownloadOptions, ExportOptions, FurnaceApplyOptions, FurnaceCreateOptions, FurnaceDeployOptions, FurnaceOverrideOptions, FurnacePreviewOptions, FurnaceRefreshOptions, FurnaceRemoveOptions, FurnaceSyncOptions, FurnaceValidateOptions, GlobalOptions, ImportOptions, PackageOptions, PatchCompactOptions, PatchDeleteOptions, PatchReorderOptions, RebaseOptions, ReExportOptions, RegisterOptions, ResetOptions, RunOptions, SetupOptions, StatusOptions, TestOptions, TokenAddOptions, WireOptions, } from './options.js';
5
5
  export type { ImportSummary, PatchCategory, PatchesManifest, PatchInfo, PatchLintIssue, PatchMetadata, PatchResult, } from './patches.js';
6
6
  export type { DoctorCheck, ProjectStatus, TokenCoverageFileEntry, TokenCoverageReport, } from './project.js';
@@ -308,6 +308,15 @@ export interface PatchReorderOptions {
308
308
  dryRun?: boolean;
309
309
  forceUnsafe?: boolean;
310
310
  }
311
+ /**
312
+ * Options for the patch compact command.
313
+ */
314
+ export interface PatchCompactOptions {
315
+ /** Skip confirmation prompt; required for non-TTY runs. */
316
+ yes?: boolean;
317
+ /** Print what would happen without writing anything. */
318
+ dryRun?: boolean;
319
+ }
311
320
  /**
312
321
  * Options for the status command.
313
322
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.13.0",
3
+ "version": "0.13.2",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",