@hominis/fireforge 0.44.0 → 0.44.1

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 (33) hide show
  1. package/CHANGELOG.md +29 -2
  2. package/README.md +2 -11
  3. package/dist/build-info.json +4 -4
  4. package/dist/src/commands/doctor-check-core.d.ts +9 -0
  5. package/dist/src/commands/doctor-patch-manifest.d.ts +8 -0
  6. package/dist/src/commands/doctor-patch-manifest.js +155 -0
  7. package/dist/src/commands/doctor.d.ts +1 -1
  8. package/dist/src/commands/doctor.js +48 -62
  9. package/dist/src/commands/furnace/diff.js +6 -1
  10. package/dist/src/commands/furnace/remove-mutations.js +5 -1
  11. package/dist/src/commands/import.js +6 -2
  12. package/dist/src/core/build-audit-resolve.js +12 -3
  13. package/dist/src/core/config-mutate.js +20 -19
  14. package/dist/src/core/furnace-apply-ftl.js +4 -1
  15. package/dist/src/core/furnace-apply-helpers.js +11 -5
  16. package/dist/src/core/git-base.d.ts +14 -0
  17. package/dist/src/core/git-base.js +17 -0
  18. package/dist/src/core/git.js +3 -1
  19. package/dist/src/core/patch-lint-checkjs.js +25 -4
  20. package/dist/src/core/patch-manifest-consistency.d.ts +56 -6
  21. package/dist/src/core/patch-manifest-consistency.js +96 -23
  22. package/dist/src/core/patch-manifest-files-affected.d.ts +43 -0
  23. package/dist/src/core/patch-manifest-files-affected.js +77 -0
  24. package/dist/src/core/patch-manifest.d.ts +2 -1
  25. package/dist/src/core/patch-manifest.js +2 -1
  26. package/dist/src/core/token-category.js +61 -7
  27. package/dist/src/core/token-dark-mode.js +20 -2
  28. package/dist/src/core/token-docs.js +1 -1
  29. package/dist/src/core/token-variant.js +24 -2
  30. package/dist/src/core/tree-store.js +5 -1
  31. package/dist/src/core/typecheck.js +7 -1
  32. package/dist/src/types/commands/options.d.ts +23 -0
  33. package/package.json +2 -2
@@ -0,0 +1,77 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Narrow repair for `filesAffected` — the one manifest field a `.patch` body
4
+ * can actually yield.
5
+ *
6
+ * A `files-affected-mismatch` is pure derived-value drift: the manifest's list
7
+ * disagrees with the diff it describes, and the diff is right by definition.
8
+ * Rebuilding the whole manifest to fix it is a much larger operation than the
9
+ * failure calls for, so this repair recomputes one field on the drifted rows
10
+ * and leaves every other row byte-identical.
11
+ */
12
+ import { GeneralError } from '../errors/base.js';
13
+ import { discoverPatches, getAllTargetFilesFromPatch } from './patch-files.js';
14
+ import { withPatchDirectoryLock } from './patch-lock.js';
15
+ import { normalizeAffectedFiles } from './patch-manifest-consistency.js';
16
+ import { loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, } from './patch-manifest-io.js';
17
+ /**
18
+ * Recomputes `filesAffected` from the patch body for the named patches.
19
+ *
20
+ * @param patchesDir - Path to the patches directory
21
+ * @param filenames - Patch filenames to repair
22
+ * @param options - Lock options plus `dryRun`
23
+ * @returns The corrections applied (or projected) and the filenames skipped
24
+ */
25
+ export async function repairPatchesFilesAffected(patchesDir, filenames, options = {}) {
26
+ // Same lock contract as the full rebuild (invariant 2,
27
+ // docs/lifecycle-invariants.md): a manifest write serializes on the
28
+ // patch-directory lock, and a dry run reads under it so its projection
29
+ // cannot be computed against a queue being rewritten underneath.
30
+ return withPatchDirectoryLock(patchesDir, () => repairPatchesFilesAffectedUnderLock(patchesDir, filenames, options), options);
31
+ }
32
+ async function repairPatchesFilesAffectedUnderLock(patchesDir, filenames, options) {
33
+ // ForWrite: a corrupt manifest must abort here rather than be treated as
34
+ // empty. This repair exists precisely so that a one-field drift never
35
+ // reaches a whole-manifest rewrite, and silently writing over unparseable
36
+ // metadata would be a larger version of what it avoids.
37
+ const manifest = await loadPatchesManifestForWrite(patchesDir);
38
+ if (!manifest) {
39
+ throw new GeneralError(`Cannot repair filesAffected: ${PATCHES_MANIFEST} does not exist in ${patchesDir}.`);
40
+ }
41
+ const patchPathByFilename = new Map((await discoverPatches(patchesDir)).map((patch) => [patch.filename, patch.path]));
42
+ const rowByFilename = new Map(manifest.patches.map((entry) => [entry.filename, entry]));
43
+ const repairs = [];
44
+ const skippedFilenames = [];
45
+ for (const filename of new Set(filenames)) {
46
+ const patchPath = patchPathByFilename.get(filename);
47
+ const row = rowByFilename.get(filename);
48
+ if (patchPath === undefined || !row) {
49
+ // A missing patch file or a missing row is a different consistency
50
+ // issue with a different remedy; this repair reports it rather than
51
+ // inventing a row or deleting one.
52
+ skippedFilenames.push(filename);
53
+ continue;
54
+ }
55
+ const before = normalizeAffectedFiles(row.filesAffected);
56
+ const after = normalizeAffectedFiles(await getAllTargetFilesFromPatch(patchPath));
57
+ if (sameFiles(before, after))
58
+ continue;
59
+ repairs.push({ filename, before, after });
60
+ }
61
+ skippedFilenames.sort((left, right) => left.localeCompare(right));
62
+ if (repairs.length === 0 || options.dryRun === true) {
63
+ return { repairs, skippedFilenames, written: false };
64
+ }
65
+ const correctedByFilename = new Map(repairs.map((repair) => [repair.filename, repair.after]));
66
+ // Row-scoped mutation, so every untouched entry keeps its exact JSON shape —
67
+ // no re-sorted `filesAffected` lists and no reader-only fallback fields
68
+ // serialized into rows this repair has no business touching.
69
+ await mutatePatchRowsInManifest(patchesDir, [...correctedByFilename.keys()], (existing) => {
70
+ const corrected = correctedByFilename.get(existing.filename);
71
+ return corrected ? { set: { filesAffected: corrected } } : null;
72
+ });
73
+ return { repairs, skippedFilenames, written: true };
74
+ }
75
+ function sameFiles(left, right) {
76
+ return left.length === right.length && left.every((value, index) => value === right[index]);
77
+ }
@@ -4,7 +4,8 @@
4
4
  * Callers should continue to import from this module; the internal split
5
5
  * is an implementation detail.
6
6
  */
7
- export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
7
+ export { type PatchManifestConsistencyIssue, rebuildPatchesManifest, recommendManifestRepair, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
8
+ export { type FilesAffectedRepair, repairPatchesFilesAffected, } from './patch-manifest-files-affected.js';
8
9
  export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, type PatchRenameEntry, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
9
10
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
10
11
  export { resolvePatchIdentifier } from './patch-manifest-resolve.js';
@@ -5,7 +5,8 @@
5
5
  * Callers should continue to import from this module; the internal split
6
6
  * is an implementation detail.
7
7
  */
8
- export { rebuildPatchesManifest, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
8
+ export { rebuildPatchesManifest, recommendManifestRepair, validatePatchesManifestConsistency, } from './patch-manifest-consistency.js';
9
+ export { repairPatchesFilesAffected, } from './patch-manifest-files-affected.js';
9
10
  export { addPatchToManifest, loadPatchesManifest, loadPatchesManifestForWrite, mutatePatchRowsInManifest, PATCHES_MANIFEST, removePatchFileAndManifest, renumberPatchesInManifest, rewriteStagedDependencyOwners, savePatchesManifest, } from './patch-manifest-io.js';
10
11
  export { checkVersionCompatibility, findPatchesAffectingFile, getClaimedFiles, stampPatchVersions, validatePatchIntegrity, } from './patch-manifest-query.js';
11
12
  export { resolvePatchIdentifier } from './patch-manifest-resolve.js';
@@ -19,6 +19,63 @@ function singleLineBannerPattern(category) {
19
19
  function multiLineBlockNameMatches(blockLine, category) {
20
20
  return blockLine.replace(/^\s*\*\s*/, '').trim() === category;
21
21
  }
22
+ /**
23
+ * Body of the first `/* … *\/` comment on `line`, or `null` when the line
24
+ * carries no closed block comment.
25
+ *
26
+ * Deliberately index arithmetic rather than a regex: every regex spelling of
27
+ * "banner comment" this module used to carry was super-linear on a line that
28
+ * repeats the opening shape (CodeQL `js/polynomial-redos`), and `tokens.css`
29
+ * is a file FireForge reads out of a consumer's engine tree, so a
30
+ * pathological line needs no attacker to arrive.
31
+ */
32
+ function blockCommentBody(line) {
33
+ const open = line.indexOf('/*');
34
+ if (open === -1)
35
+ return null;
36
+ const close = line.indexOf('*/', open + 2);
37
+ if (close === -1)
38
+ return null;
39
+ return line.slice(open + 2, close);
40
+ }
41
+ /**
42
+ * True when `line` carries a single-line banner comment — a closed block
43
+ * comment whose body both opens and closes with `=`. Recognises the decorative
44
+ * all-`=` rule as well as a named `= Foo =` banner, which is what the section
45
+ * scan wants: either shape ends the preceding section.
46
+ */
47
+ function isSingleLineBannerLine(line) {
48
+ const body = blockCommentBody(line)?.trim();
49
+ return body !== undefined && body.length >= 2 && body.startsWith('=') && body.endsWith('=');
50
+ }
51
+ /**
52
+ * Name declared by a single-line banner (`/* = Foo = *\/` → `Foo`), or `null`
53
+ * when the line is not a named banner. The `=` runs on both sides are
54
+ * required; a decorative all-`=` rule carries no name and yields `null`.
55
+ */
56
+ function singleLineBannerName(line) {
57
+ const body = blockCommentBody(line);
58
+ if (body === null)
59
+ return null;
60
+ let start = 0;
61
+ let end = body.length;
62
+ while (start < end && /\s/.test(body[start] ?? ''))
63
+ start++;
64
+ const leadingEqStart = start;
65
+ while (start < end && body[start] === '=')
66
+ start++;
67
+ if (start === leadingEqStart)
68
+ return null;
69
+ while (end > start && /\s/.test(body[end - 1] ?? ''))
70
+ end--;
71
+ const trailingEqEnd = end;
72
+ while (end > start && body[end - 1] === '=')
73
+ end--;
74
+ if (end === trailingEqEnd)
75
+ return null;
76
+ const name = body.slice(start, end).trim();
77
+ return name.length > 0 ? name : null;
78
+ }
22
79
  /**
23
80
  * True when `lines` contain a category header (single-line or multi-line
24
81
  * banner shape) whose name EQUALS `category`. Shared by the pre-add
@@ -60,14 +117,11 @@ export function categoryHeaderExists(lines, category) {
60
117
  */
61
118
  function discoverCategoryHeaders(lines) {
62
119
  const categories = new Set();
63
- const singleLinePattern = /\/\*\s*=+\s*(.+?)\s*=+\s*\*\//;
64
120
  for (let i = 0; i < lines.length; i++) {
65
121
  const line = lines[i] ?? '';
66
- const singleMatch = singleLinePattern.exec(line);
67
- if (singleMatch?.[1]) {
68
- const extracted = singleMatch[1].trim();
69
- if (extracted.length > 0)
70
- categories.add(extracted);
122
+ const extracted = singleLineBannerName(line);
123
+ if (extracted !== null) {
124
+ categories.add(extracted);
71
125
  continue;
72
126
  }
73
127
  if (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) {
@@ -181,7 +235,7 @@ export function findCategorySection(lines, category, tokensCssPath) {
181
235
  let sectionEnd = lines.length;
182
236
  for (let i = scanStart; i < lines.length; i++) {
183
237
  const line = lines[i] ?? '';
184
- if (/\/\*\s*=.*=\s*\*\//.test(line) ||
238
+ if (isSingleLineBannerLine(line) ||
185
239
  (/^\s*\/\*\s*=+/.test(line) && !/\*\//.test(line)) ||
186
240
  /^\s*\}/.test(line)) {
187
241
  sectionEnd = i;
@@ -12,6 +12,24 @@
12
12
  * fallback helper returns the outer `@media` block's close so the caller can
13
13
  * materialise a fresh `:root` wrapper rather than dropping the dark value.
14
14
  */
15
+ /**
16
+ * True when `line` carries a `:root` selector whose opening brace is on the
17
+ * same line, with no intervening brace of either kind.
18
+ *
19
+ * Index arithmetic rather than `/:root[^{}]*\{/`: that pattern restarts at
20
+ * every `:root` in the line, which is quadratic on a line repeating the
21
+ * selector (CodeQL `js/polynomial-redos`). `tokens.css` comes from a
22
+ * consumer's engine tree, so such a line needs no attacker to arrive.
23
+ */
24
+ function rootOpensBraceOnLine(line) {
25
+ const brace = line.indexOf('{');
26
+ if (brace === -1)
27
+ return false;
28
+ const root = line.lastIndexOf(':root', brace - ':root'.length);
29
+ if (root === -1 || root + ':root'.length > brace)
30
+ return false;
31
+ return !line.slice(root + ':root'.length, brace).includes('}');
32
+ }
15
33
  /**
16
34
  * Strips the content of `/* ... *\/` block comments from an array of
17
35
  * CSS source lines while preserving each line's length. Indexed scans
@@ -85,9 +103,9 @@ export function findDarkRootInsertionIndex(lines) {
85
103
  let rootOpenLine = -1;
86
104
  for (let i = darkMediaLine; i < stripped.length; i++) {
87
105
  const line = stripped[i] ?? '';
88
- if (/(^|[\s,{])\s*:root\b/.test(line)) {
106
+ if (/(?:^|[\s,{]):root\b/.test(line)) {
89
107
  // Brace on the same line?
90
- if (/:root[^{}]*\{/.test(line)) {
108
+ if (rootOpensBraceOnLine(line)) {
91
109
  rootOpenLine = i;
92
110
  break;
93
111
  }
@@ -44,7 +44,7 @@ export async function addTokenToDocs(engineDir, options, annotation) {
44
44
  let unmappedAdded = false;
45
45
  let countUpdated = false;
46
46
  const isLiteral = !options.value.startsWith('var(');
47
- const mapsTo = isLiteral ? '—' : options.value.replace(/var\(([^)]+)\)/, '$1');
47
+ const mapsTo = isLiteral ? '—' : options.value.replace(/^var\(([^)]+)\)/, '$1');
48
48
  const tokenCell = `\`${options.tokenName}\``;
49
49
  const valueCell = `\`${options.value}\``;
50
50
  // --- Main token table: Category | Token | Value | Maps to | Mode ---
@@ -52,6 +52,28 @@ export function validateVariantSelector(raw) {
52
52
  function canonicalSelector(selector) {
53
53
  return selector.replace(/\s+/g, '').replace(/["']/g, '');
54
54
  }
55
+ /**
56
+ * The `:root[…]` selector fragment on `line`, spanning to the LAST `]` that
57
+ * precedes the block's opening brace, or `null` when the line carries none.
58
+ *
59
+ * Index arithmetic rather than `/:root\[[^{]*\]/`: `[^{]` also matches `]`,
60
+ * so that pattern backtracks quadratically on a line repeating `:root[`
61
+ * (CodeQL `js/polynomial-redos`). Taking the last `]` — not the first —
62
+ * preserves the greedy regex's reach, so a multi-attribute selector still
63
+ * compares as the whole fragment and does not collide with a single-attribute
64
+ * variant that happens to be its prefix.
65
+ */
66
+ function rootAttributeSelector(line) {
67
+ const start = line.indexOf(':root[');
68
+ if (start === -1)
69
+ return null;
70
+ const brace = line.indexOf('{', start);
71
+ const limit = brace === -1 ? line.length : brace;
72
+ const close = line.lastIndexOf(']', limit - 1);
73
+ if (close <= start + ':root['.length - 1)
74
+ return null;
75
+ return line.slice(start, close + 1);
76
+ }
55
77
  /**
56
78
  * Finds the top-level `:root<variant>` block whose attribute selector
57
79
  * matches `variant` (compared canonically). Returns the opening-brace and
@@ -66,8 +88,8 @@ function findVariantBlock(lines, variant) {
66
88
  const want = canonicalSelector(`:root${variant}`);
67
89
  for (let i = 0; i < stripped.length; i++) {
68
90
  const line = stripped[i] ?? '';
69
- const match = /:root\[[^{]*\]/.exec(line);
70
- if (!match || canonicalSelector(match[0]) !== want)
91
+ const selector = rootAttributeSelector(line);
92
+ if (selector === null || canonicalSelector(selector) !== want)
71
93
  continue;
72
94
  let openLine = /\{/.test(line) ? i : -1;
73
95
  if (openLine === -1) {
@@ -443,7 +443,11 @@ async function inspectTreeLock(lockPath) {
443
443
  */
444
444
  export async function removeTree(primaryRoot, name, options = {}) {
445
445
  assertValidTreeName(name);
446
- const treesDir = getTreesDir(primaryRoot);
446
+ // Both sides resolved: the containment prefix has to be in the same form as
447
+ // the path it guards. `getTreesDir` is a `join`, so on Windows it yields a
448
+ // drive-less path while `resolve` below adds the drive letter — the prefix
449
+ // test then failed for EVERY tree and refused each removal as an escape.
450
+ const treesDir = resolve(getTreesDir(primaryRoot));
447
451
  const treeRoot = resolve(treesDir, name);
448
452
  if (!treeRoot.startsWith(treesDir + sep) || basename(treeRoot) !== name) {
449
453
  throw new GeneralError(`Refusing to remove "${name}": resolved path escapes the trees directory.`);
@@ -22,6 +22,7 @@ import { dirname, isAbsolute, relative, resolve } from 'node:path';
22
22
  import { toError } from '../utils/errors.js';
23
23
  import { pathExists } from '../utils/fs.js';
24
24
  import { verbose } from '../utils/logger.js';
25
+ import { normalizePathSlashes } from '../utils/paths.js';
25
26
  import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES, UNDEFINED_IDENTIFIER_CODES, UNDEFINED_IDENTIFIER_HINT, } from './typecheck-shim.js';
26
27
  /**
27
28
  * Sentinel string surfaced as a `TypecheckIssue.message` when the
@@ -239,7 +240,12 @@ async function runTypecheckForProject(ts, projectRoot, projectPath, shimSource,
239
240
  // file — and never write it to disk. The CompilerHost below serves
240
241
  // it from `shimSource` for `fileExists`/`readFile`/`getSourceFile`.
241
242
  const projectDir = dirname(absConfig);
242
- const shimPath = resolve(projectDir, `.fireforge-${SHIM_FILENAME}`);
243
+ // Forward slashes deliberately: TypeScript normalizes every `fileName` it
244
+ // hands a CompilerHost, on every platform. A shim path carrying Windows
245
+ // separators never matches those callbacks, so the shim is not served (its
246
+ // globals report as undefined identifiers) and its own diagnostics are not
247
+ // filtered out below — the pass reports failures that do not exist.
248
+ const shimPath = normalizePathSlashes(resolve(projectDir, `.fireforge-${SHIM_FILENAME}`));
243
249
  const rootFiles = [...parsed.fileNames, shimPath];
244
250
  const defaultHost = ts.createCompilerHost(options);
245
251
  const host = {
@@ -987,7 +987,30 @@ export interface DoctorOptions {
987
987
  * the patch manifest under that lock.
988
988
  */
989
989
  waitLock?: number | boolean;
990
+ /**
991
+ * Rebuild patches.json from the on-disk patch files, preserving every
992
+ * field the patch bodies cannot express. Refused when patches.json exists
993
+ * but cannot be parsed, unless `allowMetadataLoss` is also set.
994
+ */
990
995
  repairPatchesManifest?: boolean;
996
+ /**
997
+ * Repair only the `filesAffected` lists that disagree with their patch
998
+ * body. Narrower than `repairPatchesManifest`: it rewrites one field on the
999
+ * drifted rows and leaves every other row byte-identical, and it needs no
1000
+ * project config, so it works on a project whose fireforge.json is broken.
1001
+ */
1002
+ repairFilesAffected?: boolean;
1003
+ /**
1004
+ * Accept the whole-manifest metadata reinvention that rebuilding an
1005
+ * unparseable patches.json implies. Only meaningful with
1006
+ * `repairPatchesManifest`.
1007
+ */
1008
+ allowMetadataLoss?: boolean;
1009
+ /**
1010
+ * Project what a requested repair would change and write nothing. Refused
1011
+ * without a repair flag rather than accepted as a silent no-op.
1012
+ */
1013
+ dryRun?: boolean;
991
1014
  /**
992
1015
  * Clear a stale `pendingResolution` marker, but only after the same
993
1016
  * read-only queue health checks used by `fireforge verify` report no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hominis/fireforge",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
4
4
  "description": "FireForge — a build tool for customizing Firefox",
5
5
  "type": "module",
6
6
  "main": "./dist/src/index.js",
@@ -83,7 +83,7 @@
83
83
  "vitest": "^4.0.18"
84
84
  },
85
85
  "engines": {
86
- "node": ">=22.22.1"
86
+ "node": ">=22.22.2"
87
87
  },
88
88
  "packageManager": "npm@11.12.1",
89
89
  "license": "EUPL-1.2",