@hominis/fireforge 0.16.5 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +46 -24
  3. package/dist/src/commands/build.js +33 -10
  4. package/dist/src/commands/config.js +32 -20
  5. package/dist/src/commands/doctor-furnace-manifest-sync.d.ts +18 -0
  6. package/dist/src/commands/doctor-furnace-manifest-sync.js +159 -0
  7. package/dist/src/commands/doctor-furnace.js +2 -0
  8. package/dist/src/commands/doctor-working-tree.d.ts +29 -0
  9. package/dist/src/commands/doctor-working-tree.js +93 -0
  10. package/dist/src/commands/doctor.js +23 -12
  11. package/dist/src/commands/export-all.js +11 -3
  12. package/dist/src/commands/export-shared.d.ts +7 -1
  13. package/dist/src/commands/export-shared.js +21 -3
  14. package/dist/src/commands/furnace/chrome-doc-tests.js +9 -2
  15. package/dist/src/commands/furnace/create-templates.d.ts +11 -0
  16. package/dist/src/commands/furnace/create-templates.js +11 -2
  17. package/dist/src/commands/furnace/init.js +97 -9
  18. package/dist/src/commands/furnace/override.js +23 -13
  19. package/dist/src/commands/furnace/remove.js +8 -0
  20. package/dist/src/commands/furnace/rename.js +133 -4
  21. package/dist/src/commands/lint.js +70 -6
  22. package/dist/src/commands/patch/delete.js +4 -1
  23. package/dist/src/commands/patch/reorder.js +4 -1
  24. package/dist/src/commands/re-export-files.js +3 -1
  25. package/dist/src/commands/re-export.js +4 -1
  26. package/dist/src/commands/register.js +11 -0
  27. package/dist/src/commands/resolve.d.ts +25 -1
  28. package/dist/src/commands/resolve.js +25 -15
  29. package/dist/src/commands/status.js +100 -122
  30. package/dist/src/commands/test.js +68 -14
  31. package/dist/src/commands/token-coverage.js +10 -3
  32. package/dist/src/commands/wire.js +50 -8
  33. package/dist/src/core/browser-wire.js +21 -4
  34. package/dist/src/core/build-audit.js +10 -0
  35. package/dist/src/core/config.d.ts +33 -0
  36. package/dist/src/core/config.js +43 -0
  37. package/dist/src/core/furnace-config.d.ts +23 -2
  38. package/dist/src/core/furnace-config.js +26 -3
  39. package/dist/src/core/git-diff.js +21 -2
  40. package/dist/src/core/mach.d.ts +43 -6
  41. package/dist/src/core/mach.js +57 -7
  42. package/dist/src/core/manifest-rules.js +10 -1
  43. package/dist/src/core/manifest-tokenizers.d.ts +6 -0
  44. package/dist/src/core/manifest-tokenizers.js +28 -0
  45. package/dist/src/core/marionette-port.d.ts +50 -0
  46. package/dist/src/core/marionette-port.js +215 -0
  47. package/dist/src/core/patch-lint.d.ts +47 -2
  48. package/dist/src/core/patch-lint.js +89 -14
  49. package/dist/src/core/patch-manifest-consistency.d.ts +21 -1
  50. package/dist/src/core/patch-manifest-consistency.js +31 -3
  51. package/dist/src/core/patch-manifest-io.js +10 -0
  52. package/dist/src/core/patch-manifest-resolve.d.ts +20 -1
  53. package/dist/src/core/patch-manifest-resolve.js +29 -2
  54. package/dist/src/core/patch-manifest-validate.js +25 -1
  55. package/dist/src/core/status-classify.d.ts +54 -0
  56. package/dist/src/core/status-classify.js +134 -0
  57. package/dist/src/core/token-coverage.js +24 -0
  58. package/dist/src/core/token-dark-mode.d.ts +49 -0
  59. package/dist/src/core/token-dark-mode.js +182 -0
  60. package/dist/src/core/token-manager.js +17 -33
  61. package/dist/src/core/wire-destroy.d.ts +7 -3
  62. package/dist/src/core/wire-destroy.js +11 -6
  63. package/dist/src/core/wire-dom-fragment.d.ts +17 -0
  64. package/dist/src/core/wire-dom-fragment.js +40 -0
  65. package/dist/src/core/wire-init.d.ts +9 -3
  66. package/dist/src/core/wire-init.js +18 -6
  67. package/dist/src/core/wire-subscript.d.ts +7 -3
  68. package/dist/src/core/wire-subscript.js +11 -4
  69. package/dist/src/types/commands/patches.d.ts +23 -0
  70. package/dist/src/types/furnace.d.ts +9 -0
  71. package/dist/src/utils/parse.d.ts +7 -0
  72. package/dist/src/utils/parse.js +15 -0
  73. package/package.json +1 -1
@@ -75,6 +75,46 @@ export function legacyAddDomFragment(content, includeDirective) {
75
75
  lines.splice(insertIndex, 0, includeDirective);
76
76
  return lines.join('\n');
77
77
  }
78
+ /**
79
+ * Dry-run precheck for `addDomFragment`. Reads the resolved chrome
80
+ * document and verifies it either already contains the `#include`
81
+ * directive (the idempotent-skip case) OR offers a locatable insertion
82
+ * point via {@link addDomFragmentTokenized} / {@link legacyAddDomFragment}.
83
+ * Throws the same `Could not find insertion point in chrome document`
84
+ * error the real run would throw when neither condition holds.
85
+ *
86
+ * Motivating case (2026-04-21 eval, Finding #12): `fireforge wire ...
87
+ * --dry-run` previewed a plausible mutation plan against
88
+ * `tokenHostDocuments[0]`, then `fireforge wire ...` without
89
+ * `--dry-run` threw `Could not find insertion point in chrome document`
90
+ * on the same arguments. The real run had always called the insertion
91
+ * helpers; dry-run did not. This helper runs the same check in the
92
+ * preview pass so plan and execution disagree less.
93
+ */
94
+ export async function probeDomFragmentInsertionPoint(engineDir, domFilePath, targetPath = DEFAULT_DOM_TARGET) {
95
+ const targetAbsPath = join(engineDir, targetPath);
96
+ if (!(await pathExists(targetAbsPath))) {
97
+ // The callers in `wire.ts` run their own existence probe before
98
+ // invoking this helper, but a well-behaved probe is paranoid — if
99
+ // something changed between the two checks, fail with the same
100
+ // error the real run would surface.
101
+ throw new GeneralError(`${targetPath} not found in engine`);
102
+ }
103
+ const safeDomFilePath = toRootRelativePath(engineDir, domFilePath);
104
+ const targetDir = dirname(targetPath);
105
+ const includePath = relative(targetDir, safeDomFilePath).replace(/\\/g, '/');
106
+ const includeDirective = `#include ${includePath}`;
107
+ const content = await readText(targetAbsPath);
108
+ if (new RegExp(`^${escapeRegex(includeDirective)}$`, 'm').test(content)) {
109
+ // Already wired — the real run would idempotent-skip here, so
110
+ // dry-run is allowed to proceed too.
111
+ return;
112
+ }
113
+ // Check the tokenised and legacy insertion paths symmetrically with
114
+ // the real run. Either helper returning without throwing is sufficient
115
+ // evidence that the real run can land the directive.
116
+ withParserFallback(() => addDomFragmentTokenized(content, includeDirective), () => legacyAddDomFragment(content, includeDirective), targetPath);
117
+ }
78
118
  /**
79
119
  * Inserts a `#include` directive for an `.inc.xhtml` file into the top-level
80
120
  * chrome document (default: `browser/base/content/browser.xhtml`), before
@@ -5,12 +5,18 @@
5
5
  * AST-based implementation: finds onLoad() method body, locates existing
6
6
  * fireforge init blocks (TryStatements containing typeof guards), and inserts
7
7
  * after the correct position.
8
+ *
9
+ * `marker` is prepended (uppercased) to the generated comment line so the
10
+ * emitted block carries the patch-lint `// <MARKER>:` signature that
11
+ * `lintModificationComments` looks for. Otherwise the first export after
12
+ * `wire` trips `missing-modification-comment` on wire-generated edits —
13
+ * exactly the eval 1 Finding #9 regression.
8
14
  */
9
- export declare function addInitAST(content: string, expression: string, after?: string): string;
15
+ export declare function addInitAST(content: string, expression: string, after?: string, marker?: string): string;
10
16
  /**
11
17
  * Legacy regex/line-based implementation preserved as fallback.
12
18
  */
13
- export declare function legacyAddInit(content: string, expression: string, after?: string): string;
19
+ export declare function legacyAddInit(content: string, expression: string, after?: string, marker?: string): string;
14
20
  /**
15
21
  * Adds an init expression as the first statement(s) in gBrowserInit.onLoad()
16
22
  * in browser-init.js, after any previously-wired fireforge init blocks.
@@ -20,4 +26,4 @@ export declare function legacyAddInit(content: string, expression: string, after
20
26
  * @param after - Optional name to insert after (e.g., "MyComponent" to insert after its block)
21
27
  * @returns true if added, false if already present
22
28
  */
23
- export declare function addInitToBrowserInit(engineDir: string, expression: string, after?: string): Promise<boolean>;
29
+ export declare function addInitToBrowserInit(engineDir: string, expression: string, after?: string, marker?: string): Promise<boolean>;
@@ -12,12 +12,24 @@ import { detectIndent, getNodeSource, parseScript } from './ast-utils.js';
12
12
  import { withParserFallback } from './parser-fallback.js';
13
13
  import { assertBraceBalancePreserved, coerceToCall, extractNameFromExpression, findInsertionAfterFireforgeBlocks, findMethodBody, findMethodBraceIndex, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
14
14
  const BROWSER_INIT_JS = 'browser/base/content/browser-init.js';
15
+ /**
16
+ * Default patch-lint marker used when a caller does not supply a
17
+ * project-specific one. Kept as a constant so test fixtures and
18
+ * fallback code paths agree on the shape.
19
+ */
20
+ const DEFAULT_MARKER = 'FIREFORGE:';
15
21
  /**
16
22
  * AST-based implementation: finds onLoad() method body, locates existing
17
23
  * fireforge init blocks (TryStatements containing typeof guards), and inserts
18
24
  * after the correct position.
25
+ *
26
+ * `marker` is prepended (uppercased) to the generated comment line so the
27
+ * emitted block carries the patch-lint `// <MARKER>:` signature that
28
+ * `lintModificationComments` looks for. Otherwise the first export after
29
+ * `wire` trips `missing-modification-comment` on wire-generated edits —
30
+ * exactly the eval 1 Finding #9 regression.
19
31
  */
20
- export function addInitAST(content, expression, after) {
32
+ export function addInitAST(content, expression, after, marker = DEFAULT_MARKER) {
21
33
  const name = extractNameFromExpression(expression);
22
34
  // `validateWireName` accepts both `Foo.bar` and `Foo.bar()` shapes. The
23
35
  // template below interpolates the value verbatim, so a bare property
@@ -99,7 +111,7 @@ export function addInitAST(content, expression, after) {
99
111
  }
100
112
  }
101
113
  const block = [
102
- `${indent}// ${name} init — must be first, before Firefox subsystem`,
114
+ `${indent}// ${marker} wire-init ${name} — must be first, before Firefox subsystem`,
103
115
  `${indent}// inits that reference native UI elements we hide.`,
104
116
  `${indent}try {`,
105
117
  `${indent} if (typeof ${name} !== "undefined") {`,
@@ -115,7 +127,7 @@ export function addInitAST(content, expression, after) {
115
127
  /**
116
128
  * Legacy regex/line-based implementation preserved as fallback.
117
129
  */
118
- export function legacyAddInit(content, expression, after) {
130
+ export function legacyAddInit(content, expression, after, marker = DEFAULT_MARKER) {
119
131
  const name = extractNameFromExpression(expression);
120
132
  // See `addInitAST` for the rationale — the AST and fallback paths must
121
133
  // agree on whether the emitted block is a function call, otherwise
@@ -174,7 +186,7 @@ export function legacyAddInit(content, expression, after) {
174
186
  const inner = baseIndent + ' ';
175
187
  const inner2 = inner + ' ';
176
188
  const block = [
177
- `${baseIndent}// ${name} init — must be first, before Firefox subsystem`,
189
+ `${baseIndent}// ${marker} wire-init ${name} — must be first, before Firefox subsystem`,
178
190
  `${baseIndent}// inits that reference native UI elements we hide.`,
179
191
  `${baseIndent}try {`,
180
192
  `${inner}if (typeof ${name} !== "undefined") {`,
@@ -196,7 +208,7 @@ export function legacyAddInit(content, expression, after) {
196
208
  * @param after - Optional name to insert after (e.g., "MyComponent" to insert after its block)
197
209
  * @returns true if added, false if already present
198
210
  */
199
- export async function addInitToBrowserInit(engineDir, expression, after) {
211
+ export async function addInitToBrowserInit(engineDir, expression, after, marker = DEFAULT_MARKER) {
200
212
  validateWireName(expression, 'init expression');
201
213
  const filePath = join(engineDir, BROWSER_INIT_JS);
202
214
  if (!(await pathExists(filePath))) {
@@ -212,7 +224,7 @@ export async function addInitToBrowserInit(engineDir, expression, after) {
212
224
  if (initPattern.test(content)) {
213
225
  return false;
214
226
  }
215
- const { value, usedFallback } = withParserFallback(() => addInitAST(content, expression, after), () => legacyAddInit(content, expression, after), BROWSER_INIT_JS);
227
+ const { value, usedFallback } = withParserFallback(() => addInitAST(content, expression, after, marker), () => legacyAddInit(content, expression, after, marker), BROWSER_INIT_JS);
216
228
  if (usedFallback) {
217
229
  assertBraceBalancePreserved(content, value, BROWSER_INIT_JS);
218
230
  }
@@ -4,12 +4,16 @@
4
4
  /**
5
5
  * AST-based implementation: finds the last try/catch containing
6
6
  * `loadSubScript` and inserts a new try/catch block after it.
7
+ *
8
+ * The inserted block carries a `// <MARKER>: wire-subscript ...` comment
9
+ * so the emitted edit satisfies `lintModificationComments` (eval 1
10
+ * Finding #9).
7
11
  */
8
- export declare function addSubscriptAST(content: string, name: string): string;
12
+ export declare function addSubscriptAST(content: string, name: string, marker?: string): string;
9
13
  /**
10
14
  * Legacy regex/line-based implementation preserved as fallback.
11
15
  */
12
- export declare function legacyAddSubscript(content: string, name: string): string;
16
+ export declare function legacyAddSubscript(content: string, name: string, marker?: string): string;
13
17
  /**
14
18
  * Adds a loadSubScript entry to browser-main.js with try/catch error handling.
15
19
  *
@@ -17,4 +21,4 @@ export declare function legacyAddSubscript(content: string, name: string): strin
17
21
  * @param name - Subscript name (without .js extension)
18
22
  * @returns true if added, false if already present
19
23
  */
20
- export declare function addSubscriptToBrowserMain(engineDir: string, name: string): Promise<boolean>;
24
+ export declare function addSubscriptToBrowserMain(engineDir: string, name: string, marker?: string): Promise<boolean>;
@@ -11,11 +11,16 @@ import { detectIndent, getNodeSource, parseScript, walkAST, } from './ast-utils.
11
11
  import { withParserFallback } from './parser-fallback.js';
12
12
  import { assertBraceBalancePreserved, findNearestTryLine, validateWireName, walkToTryBlockEnd, } from './wire-utils.js';
13
13
  const BROWSER_MAIN_JS = 'browser/base/content/browser-main.js';
14
+ const DEFAULT_MARKER = 'FIREFORGE:';
14
15
  /**
15
16
  * AST-based implementation: finds the last try/catch containing
16
17
  * `loadSubScript` and inserts a new try/catch block after it.
18
+ *
19
+ * The inserted block carries a `// <MARKER>: wire-subscript ...` comment
20
+ * so the emitted edit satisfies `lintModificationComments` (eval 1
21
+ * Finding #9).
17
22
  */
18
- export function addSubscriptAST(content, name) {
23
+ export function addSubscriptAST(content, name, marker = DEFAULT_MARKER) {
19
24
  const ast = parseScript(content);
20
25
  const ms = new MagicString(content);
21
26
  // Collect all TryStatements containing loadSubScript
@@ -58,6 +63,7 @@ export function addSubscriptAST(content, name) {
58
63
  indent = detectIndent(content, lastBrace);
59
64
  }
60
65
  const block = [
66
+ `${indent}// ${marker} wire-subscript ${name}`,
61
67
  `${indent}try {`,
62
68
  `${indent} Services.scriptloader.loadSubScript("chrome://browser/content/${name}.js", this);`,
63
69
  `${indent}} catch (e) {`,
@@ -70,7 +76,7 @@ export function addSubscriptAST(content, name) {
70
76
  /**
71
77
  * Legacy regex/line-based implementation preserved as fallback.
72
78
  */
73
- export function legacyAddSubscript(content, name) {
79
+ export function legacyAddSubscript(content, name, marker = DEFAULT_MARKER) {
74
80
  const lines = content.split('\n');
75
81
  let lastSubScriptLine = -1;
76
82
  for (let i = 0; i < lines.length; i++) {
@@ -103,6 +109,7 @@ export function legacyAddSubscript(content, name) {
103
109
  const ind = refLine?.match(/^(\s*)/)?.[1] ?? ' ';
104
110
  const inner = ind + ' ';
105
111
  const block = [
112
+ `${ind}// ${marker} wire-subscript ${name}`,
106
113
  `${ind}try {`,
107
114
  `${inner}Services.scriptloader.loadSubScript("chrome://browser/content/${name}.js", this);`,
108
115
  `${ind}} catch (e) {`,
@@ -119,7 +126,7 @@ export function legacyAddSubscript(content, name) {
119
126
  * @param name - Subscript name (without .js extension)
120
127
  * @returns true if added, false if already present
121
128
  */
122
- export async function addSubscriptToBrowserMain(engineDir, name) {
129
+ export async function addSubscriptToBrowserMain(engineDir, name, marker = DEFAULT_MARKER) {
123
130
  validateWireName(name, 'subscript name');
124
131
  const filePath = join(engineDir, BROWSER_MAIN_JS);
125
132
  if (!(await pathExists(filePath))) {
@@ -130,7 +137,7 @@ export async function addSubscriptToBrowserMain(engineDir, name) {
130
137
  if (content.includes(`content/${name}.js"`)) {
131
138
  return false;
132
139
  }
133
- const { value, usedFallback } = withParserFallback(() => addSubscriptAST(content, name), () => legacyAddSubscript(content, name), BROWSER_MAIN_JS);
140
+ const { value, usedFallback } = withParserFallback(() => addSubscriptAST(content, name, marker), () => legacyAddSubscript(content, name, marker), BROWSER_MAIN_JS);
134
141
  if (usedFallback) {
135
142
  assertBraceBalancePreserved(content, value, BROWSER_MAIN_JS);
136
143
  }
@@ -73,6 +73,29 @@ export interface PatchMetadata {
73
73
  * renamed or removed.
74
74
  */
75
75
  lintIgnore?: string[];
76
+ /**
77
+ * Optional per-patch threshold-tier override for the `large-patch-lines`
78
+ * rule. Exists for branding patches that must touch a small number of
79
+ * cross-cutting registration files alongside `browser/branding/<name>/`
80
+ * (notably `browser/moz.configure` to register the new branding flavor
81
+ * with the top-level configure). The narrow auto-detect allowlist in
82
+ * `isBrandingOnlyPatch` covers the canonical shape, but a fork whose
83
+ * branding patch also touches an unlisted sibling (for example a
84
+ * `browser/themes/<name>/` override or a vendor-specific icon
85
+ * resource) falls through to the general tier and trips the hard
86
+ * limit on what is legitimately one branding diff.
87
+ *
88
+ * Declaring `tier: "branding"` here forces the branding thresholds
89
+ * (notice 3000 / warning 8000 / error 20000) regardless of
90
+ * `filesAffected`. The tier is the weaker claim than test — a patch
91
+ * of all-tests still lands in the test tier even if this field is
92
+ * set, because the test-tier thresholds are already more permissive
93
+ * and a test that is also branding-shaped is vanishingly rare.
94
+ *
95
+ * Only `"branding"` is currently recognised. Unknown values are
96
+ * rejected by the manifest validator, not silently stripped.
97
+ */
98
+ tier?: 'branding';
76
99
  }
77
100
  /**
78
101
  * Schema for patches/patches.json file.
@@ -102,6 +102,15 @@ export interface FurnaceConfig {
102
102
  tokenPrefix?: string;
103
103
  /** Custom properties allowed even though they don't match tokenPrefix (e.g. ["--background-color-box"]) */
104
104
  tokenAllowlist?: string[];
105
+ /**
106
+ * CSS custom-property prefixes that identify upstream / platform
107
+ * variables the fork does not own. `token coverage` counts matches
108
+ * as `allowlisted` rather than `unknown` so a copied upstream
109
+ * baseline doesn't drag fork-owned coverage percentages down.
110
+ * Defaults to `['--moz-']` when unset. Pass an explicit empty array
111
+ * to restore the pre-0.18.0 strict contract.
112
+ */
113
+ platformPrefixes?: string[];
105
114
  /**
106
115
  * Custom properties used as runtime state channels — written and read by the
107
116
  * component itself (e.g. per-frame camera/tile positions) rather than
@@ -76,6 +76,13 @@ export declare class ParsedRecord {
76
76
  * @throws Error if the field is missing or not an array of strings
77
77
  */
78
78
  stringArray(key: string): string[];
79
+ /**
80
+ * Extracts an optional array-of-strings field.
81
+ * @param key - Field name
82
+ * @returns The string array (fresh copy) or undefined when absent
83
+ * @throws Error if the field is present but not an array of strings
84
+ */
85
+ optionalStringArray(key: string): string[] | undefined;
79
86
  /**
80
87
  * Extracts a required nested object field.
81
88
  * @param key - Field name
@@ -142,6 +142,21 @@ export class ParsedRecord {
142
142
  }
143
143
  return [...value];
144
144
  }
145
+ /**
146
+ * Extracts an optional array-of-strings field.
147
+ * @param key - Field name
148
+ * @returns The string array (fresh copy) or undefined when absent
149
+ * @throws Error if the field is present but not an array of strings
150
+ */
151
+ optionalStringArray(key) {
152
+ const value = this.#data[key];
153
+ if (value === undefined)
154
+ return undefined;
155
+ if (!isArray(value) || !value.every(isString)) {
156
+ throw new Error(`${this.#label}.${key} must be an array of strings`);
157
+ }
158
+ return [...value];
159
+ }
145
160
  /**
146
161
  * Extracts a required nested object field.
147
162
  * @param key - Field name
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.16.5",
3
+ "version": "0.18.0",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",