@hominis/fireforge 0.10.1 → 0.11.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 (174) hide show
  1. package/CHANGELOG.md +93 -1
  2. package/README.md +125 -238
  3. package/dist/bin/fireforge.js +26 -0
  4. package/dist/src/cli.d.ts +1 -1
  5. package/dist/src/cli.js +131 -52
  6. package/dist/src/commands/bootstrap.js +6 -2
  7. package/dist/src/commands/build.js +4 -2
  8. package/dist/src/commands/discard.js +16 -4
  9. package/dist/src/commands/doctor-furnace.d.ts +8 -0
  10. package/dist/src/commands/doctor-furnace.js +422 -0
  11. package/dist/src/commands/doctor.d.ts +115 -0
  12. package/dist/src/commands/doctor.js +327 -258
  13. package/dist/src/commands/download.js +16 -1
  14. package/dist/src/commands/export-all.js +15 -0
  15. package/dist/src/commands/export-flow.d.ts +91 -0
  16. package/dist/src/commands/export-flow.js +344 -0
  17. package/dist/src/commands/export.js +151 -5
  18. package/dist/src/commands/furnace/apply.d.ts +3 -2
  19. package/dist/src/commands/furnace/apply.js +169 -36
  20. package/dist/src/commands/furnace/create.js +162 -52
  21. package/dist/src/commands/furnace/deploy.js +156 -144
  22. package/dist/src/commands/furnace/diff.d.ts +8 -4
  23. package/dist/src/commands/furnace/diff.js +142 -73
  24. package/dist/src/commands/furnace/index.d.ts +6 -2
  25. package/dist/src/commands/furnace/index.js +76 -25
  26. package/dist/src/commands/furnace/init.d.ts +11 -0
  27. package/dist/src/commands/furnace/init.js +76 -0
  28. package/dist/src/commands/furnace/list.d.ts +4 -1
  29. package/dist/src/commands/furnace/list.js +35 -3
  30. package/dist/src/commands/furnace/override.d.ts +8 -0
  31. package/dist/src/commands/furnace/override.js +216 -26
  32. package/dist/src/commands/furnace/preview.js +184 -30
  33. package/dist/src/commands/furnace/refresh.d.ts +10 -0
  34. package/dist/src/commands/furnace/refresh.js +268 -0
  35. package/dist/src/commands/furnace/remove.js +285 -89
  36. package/dist/src/commands/furnace/rename.d.ts +5 -0
  37. package/dist/src/commands/furnace/rename.js +308 -0
  38. package/dist/src/commands/furnace/scan.d.ts +4 -1
  39. package/dist/src/commands/furnace/scan.js +72 -11
  40. package/dist/src/commands/furnace/status.js +85 -20
  41. package/dist/src/commands/furnace/sync.d.ts +12 -0
  42. package/dist/src/commands/furnace/sync.js +77 -0
  43. package/dist/src/commands/furnace/validate.d.ts +4 -1
  44. package/dist/src/commands/furnace/validate.js +99 -3
  45. package/dist/src/commands/furnace/validation-output.d.ts +24 -1
  46. package/dist/src/commands/furnace/validation-output.js +93 -1
  47. package/dist/src/commands/import.js +37 -4
  48. package/dist/src/commands/lint.js +11 -2
  49. package/dist/src/commands/manifest.d.ts +39 -0
  50. package/dist/src/commands/manifest.js +59 -0
  51. package/dist/src/commands/patch/delete.d.ts +28 -0
  52. package/dist/src/commands/patch/delete.js +209 -0
  53. package/dist/src/commands/patch/index.d.ts +17 -0
  54. package/dist/src/commands/patch/index.js +25 -0
  55. package/dist/src/commands/patch/reorder.d.ts +30 -0
  56. package/dist/src/commands/patch/reorder.js +377 -0
  57. package/dist/src/commands/re-export-files.d.ts +17 -0
  58. package/dist/src/commands/re-export-files.js +177 -0
  59. package/dist/src/commands/re-export.js +44 -0
  60. package/dist/src/commands/rebase/abort.d.ts +1 -1
  61. package/dist/src/commands/rebase/abort.js +12 -3
  62. package/dist/src/commands/rebase/confirm.d.ts +3 -3
  63. package/dist/src/commands/rebase/confirm.js +4 -4
  64. package/dist/src/commands/rebase/index.js +13 -4
  65. package/dist/src/commands/reset.js +20 -4
  66. package/dist/src/commands/run.js +46 -1
  67. package/dist/src/commands/setup-support.js +6 -5
  68. package/dist/src/commands/status.js +97 -6
  69. package/dist/src/commands/test.js +5 -37
  70. package/dist/src/commands/verify.d.ts +31 -0
  71. package/dist/src/commands/verify.js +126 -0
  72. package/dist/src/core/build-prepare.js +40 -16
  73. package/dist/src/core/destructive.d.ts +96 -0
  74. package/dist/src/core/destructive.js +137 -0
  75. package/dist/src/core/diff-hunks.d.ts +73 -0
  76. package/dist/src/core/diff-hunks.js +268 -0
  77. package/dist/src/core/firefox.d.ts +1 -1
  78. package/dist/src/core/firefox.js +1 -1
  79. package/dist/src/core/furnace-apply-helpers.d.ts +89 -6
  80. package/dist/src/core/furnace-apply-helpers.js +302 -57
  81. package/dist/src/core/furnace-apply-output.d.ts +16 -0
  82. package/dist/src/core/furnace-apply-output.js +57 -0
  83. package/dist/src/core/furnace-apply.d.ts +21 -3
  84. package/dist/src/core/furnace-apply.js +260 -29
  85. package/dist/src/core/furnace-checksum-utils.d.ts +4 -0
  86. package/dist/src/core/furnace-checksum-utils.js +24 -0
  87. package/dist/src/core/furnace-config.d.ts +28 -1
  88. package/dist/src/core/furnace-config.js +180 -17
  89. package/dist/src/core/furnace-constants.d.ts +22 -0
  90. package/dist/src/core/furnace-constants.js +36 -0
  91. package/dist/src/core/furnace-graph-utils.d.ts +11 -0
  92. package/dist/src/core/furnace-graph-utils.js +94 -0
  93. package/dist/src/core/furnace-operation.d.ts +108 -0
  94. package/dist/src/core/furnace-operation.js +220 -0
  95. package/dist/src/core/furnace-refresh.d.ts +20 -0
  96. package/dist/src/core/furnace-refresh.js +118 -0
  97. package/dist/src/core/furnace-registration-ast.d.ts +5 -0
  98. package/dist/src/core/furnace-registration-ast.js +134 -4
  99. package/dist/src/core/furnace-registration-remove.d.ts +25 -3
  100. package/dist/src/core/furnace-registration-remove.js +196 -62
  101. package/dist/src/core/furnace-registration-validate.d.ts +13 -1
  102. package/dist/src/core/furnace-registration-validate.js +15 -3
  103. package/dist/src/core/furnace-registration.d.ts +27 -4
  104. package/dist/src/core/furnace-registration.js +93 -11
  105. package/dist/src/core/furnace-rollback.d.ts +11 -0
  106. package/dist/src/core/furnace-rollback.js +78 -7
  107. package/dist/src/core/furnace-scanner.d.ts +8 -2
  108. package/dist/src/core/furnace-scanner.js +152 -55
  109. package/dist/src/core/furnace-stories.js +7 -5
  110. package/dist/src/core/furnace-validate-accessibility.js +7 -1
  111. package/dist/src/core/furnace-validate-compatibility.d.ts +1 -1
  112. package/dist/src/core/furnace-validate-compatibility.js +85 -1
  113. package/dist/src/core/furnace-validate-helpers.d.ts +4 -0
  114. package/dist/src/core/furnace-validate-helpers.js +31 -0
  115. package/dist/src/core/furnace-validate-registration.d.ts +17 -2
  116. package/dist/src/core/furnace-validate-registration.js +73 -3
  117. package/dist/src/core/furnace-validate-structure.d.ts +10 -2
  118. package/dist/src/core/furnace-validate-structure.js +45 -3
  119. package/dist/src/core/furnace-validate.d.ts +10 -1
  120. package/dist/src/core/furnace-validate.js +80 -6
  121. package/dist/src/core/furnace-version-drift.d.ts +55 -0
  122. package/dist/src/core/furnace-version-drift.js +101 -0
  123. package/dist/src/core/git-file-ops.d.ts +8 -0
  124. package/dist/src/core/git-file-ops.js +19 -6
  125. package/dist/src/core/lint-projection.d.ts +25 -0
  126. package/dist/src/core/lint-projection.js +44 -0
  127. package/dist/src/core/mach.d.ts +4 -2
  128. package/dist/src/core/mach.js +17 -2
  129. package/dist/src/core/markdown-table.d.ts +104 -0
  130. package/dist/src/core/markdown-table.js +266 -0
  131. package/dist/src/core/ownership-table.d.ts +53 -0
  132. package/dist/src/core/ownership-table.js +144 -0
  133. package/dist/src/core/patch-apply.d.ts +17 -3
  134. package/dist/src/core/patch-apply.js +86 -8
  135. package/dist/src/core/patch-export.d.ts +119 -5
  136. package/dist/src/core/patch-export.js +183 -25
  137. package/dist/src/core/patch-lint-cross.d.ts +195 -0
  138. package/dist/src/core/patch-lint-cross.js +428 -0
  139. package/dist/src/core/patch-lint-diff.d.ts +33 -0
  140. package/dist/src/core/patch-lint-diff.js +84 -0
  141. package/dist/src/core/patch-lint.d.ts +2 -4
  142. package/dist/src/core/patch-lint.js +12 -50
  143. package/dist/src/core/patch-lock.js +2 -1
  144. package/dist/src/core/patch-manifest-io.d.ts +102 -1
  145. package/dist/src/core/patch-manifest-io.js +270 -2
  146. package/dist/src/core/patch-manifest-query.d.ts +1 -1
  147. package/dist/src/core/patch-manifest-query.js +1 -1
  148. package/dist/src/core/patch-manifest.d.ts +1 -1
  149. package/dist/src/core/patch-manifest.js +1 -1
  150. package/dist/src/core/patch-transform.d.ts +12 -0
  151. package/dist/src/core/patch-transform.js +21 -7
  152. package/dist/src/core/token-manager.js +67 -69
  153. package/dist/src/core/wire-destroy.js +6 -3
  154. package/dist/src/core/wire-init.js +10 -4
  155. package/dist/src/core/wire-subscript.js +9 -3
  156. package/dist/src/core/wire-utils.d.ts +52 -5
  157. package/dist/src/core/wire-utils.js +69 -6
  158. package/dist/src/errors/base.d.ts +20 -0
  159. package/dist/src/errors/base.js +24 -0
  160. package/dist/src/errors/furnace.js +7 -1
  161. package/dist/src/errors/rebase.js +6 -1
  162. package/dist/src/types/commands/index.d.ts +1 -1
  163. package/dist/src/types/commands/options.d.ts +125 -4
  164. package/dist/src/types/commands/patches.d.ts +11 -1
  165. package/dist/src/types/config.d.ts +1 -1
  166. package/dist/src/types/furnace.d.ts +55 -1
  167. package/dist/src/utils/fs.d.ts +12 -0
  168. package/dist/src/utils/fs.js +30 -1
  169. package/dist/src/utils/package-root.d.ts +5 -0
  170. package/dist/src/utils/package-root.js +12 -0
  171. package/dist/src/utils/process.js +9 -4
  172. package/dist/src/utils/validation.d.ts +20 -2
  173. package/dist/src/utils/validation.js +26 -3
  174. package/package.json +1 -1
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Cross-patch lint infrastructure: the queue context builder, the
3
+ * duplicate-new-file-creation and forward-import rules, the
4
+ * forward-import ignore-marker, and the per-specifier extractor that
5
+ * powers the forward-import rule.
6
+ *
7
+ * Separated from `patch-lint.ts` so the per-patch and cross-patch rule
8
+ * bodies stay within the project's per-file line budget. `patch-lint.ts`
9
+ * re-exports the public surface so callers continue to import from a
10
+ * single module.
11
+ */
12
+ import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
13
+ /**
14
+ * One patch's contribution to {@link PatchQueueContext}.
15
+ *
16
+ * Rules receive a flat view of every patch's metadata, raw diff, and the
17
+ * content of any files the patch newly creates. Reading each patch once up
18
+ * front lets rules operate in O(patches) without re-reading .patch files.
19
+ */
20
+ export interface PatchQueueEntry {
21
+ /** Filename on disk and in the manifest. */
22
+ filename: string;
23
+ /** Order number from the manifest (or filename prefix fallback). */
24
+ order: number;
25
+ /** Manifest metadata. Null when the patch file exists but has no entry. */
26
+ metadata: PatchMetadata | null;
27
+ /** Raw unified-diff content of the patch body. */
28
+ diff: string;
29
+ /**
30
+ * Map from newly-created file path → the file content the patch would
31
+ * produce. Populated only for files the patch creates with
32
+ * `new file mode` + `--- /dev/null`. Modifications to existing files
33
+ * are not indexed here.
34
+ */
35
+ newFiles: Map<string, string>;
36
+ /**
37
+ * Map from existing-file path → concatenated added lines from the patch's
38
+ * hunks against that file (joined with `\n`). Populated for files the
39
+ * patch *modifies* (i.e. paths that show up in the diff without a
40
+ * `new file mode` marker). The forward-import rule and `patch delete`
41
+ * dependency scan use this to detect imports added into pre-existing
42
+ * files — a failure mode the newFiles map cannot represent because it
43
+ * only tracks creations.
44
+ */
45
+ modifiedFileAdditions: Map<string, string>;
46
+ }
47
+ /**
48
+ * Queue-wide context passed to cross-patch lint rules.
49
+ *
50
+ * "Projected" means rules receive a potentially hypothetical view of the
51
+ * queue — the caller may have already applied a planned delete, reorder, or
52
+ * re-export to the entries before calling the rule. This lets
53
+ * `patch reorder`, `patch delete`, `export --order`, and `re-export --files`
54
+ * run the same cross-patch checks they would hit on a real run, without
55
+ * mutating disk first.
56
+ */
57
+ export interface PatchQueueContext {
58
+ /** Entries in application order (lowest `order` first). */
59
+ entries: PatchQueueEntry[];
60
+ }
61
+ /**
62
+ * Builds a {@link PatchQueueContext} by reading every .patch file in the
63
+ * directory and extracting new-file content for each creation.
64
+ *
65
+ * Reads manifest metadata best-effort: if the manifest is missing or a patch
66
+ * file has no metadata entry, the context still populates the entry from the
67
+ * filename prefix so cross-patch rules can operate on a drift state. (A
68
+ * separate consistency check — see `patches verify` — is responsible for
69
+ * reporting the drift as its own error.)
70
+ *
71
+ * @param patchesDir - Path to the patches directory
72
+ */
73
+ export declare function buildPatchQueueContext(patchesDir: string): Promise<PatchQueueContext>;
74
+ /**
75
+ * Returns the raw `path → patches[]` map of files created in `new file
76
+ * mode` by at least one patch in the queue. Paths created by only one
77
+ * patch are also included so callers can distinguish "no creator" from
78
+ * "exactly one creator" without re-scanning the diffs.
79
+ *
80
+ * Split out from {@link lintPatchQueueDuplicateCreations} so
81
+ * `status --ownership` (and any future caller that wants ownership
82
+ * without a rendered PatchLintIssue) can consume the same structured
83
+ * data the rule itself relies on. Previously status had to parse the
84
+ * rule's human-readable message to recover the patch list, which was
85
+ * both fragile and made the lint message format part of an implicit
86
+ * contract.
87
+ *
88
+ * @param ctx - Pre-built queue context
89
+ */
90
+ export declare function collectNewFileCreatorsByPath(ctx: PatchQueueContext): Map<string, string[]>;
91
+ /**
92
+ * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
93
+ * +++ b/path`) by more than one patch. This is the failure mode that
94
+ * motivated the rule — Hominis landed three patches each trying to create
95
+ * the same file, and the error surfaced only when import rolled back
96
+ * mid-apply.
97
+ *
98
+ * Reports one error per conflicting path, naming every patch that creates
99
+ * the path so the operator can pick the correct fix (`patch delete` or
100
+ * `re-export --files`).
101
+ */
102
+ export declare function lintPatchQueueDuplicateCreations(ctx: PatchQueueContext): PatchLintIssue[];
103
+ /**
104
+ * Returns true when a path looks like a JS module/subscript the
105
+ * forward-import rule should scan.
106
+ */
107
+ export declare function isForwardImportableFile(path: string): boolean;
108
+ /**
109
+ * Regex-level extractor for module specifiers in ES module / subscript files.
110
+ *
111
+ * Intentionally conservative. Catches:
112
+ * - `import ... from "specifier"` (ES module static imports)
113
+ * - `import "specifier"` (side-effect imports — the `from`
114
+ * clause is optional in the regex)
115
+ * - `import("specifier")` (dynamic imports)
116
+ * - ChromeUtils.defineESModuleGetters(obj, { Name: "specifier", ... })
117
+ *
118
+ * Returns the raw specifier strings — callers should take the leaf basename
119
+ * to match against the newFileIndex, because we do not resolve `resource://`
120
+ * URLs to engine file paths.
121
+ */
122
+ export declare function extractImportSpecifiers(source: string): string[];
123
+ /**
124
+ * Internal form of {@link extractImportSpecifiers} that also returns the
125
+ * (0-indexed) line number where each specifier was found. Used by the
126
+ * forward-import rule so it can correlate specifiers against the
127
+ * ignore-marker line set and skip suppressed matches.
128
+ */
129
+ export interface ExtractedSpecifier {
130
+ specifier: string;
131
+ line: number;
132
+ }
133
+ /**
134
+ * Returns import specifiers plus 0-indexed line numbers, preserving the
135
+ * same matching behavior as {@link extractImportSpecifiers}.
136
+ */
137
+ export declare function extractImportSpecifiersWithLines(source: string): ExtractedSpecifier[];
138
+ /**
139
+ * Marker comment operators can use to suppress the forward-import rule
140
+ * for imports that resolve to a basename false positive (two unrelated
141
+ * files with the same leaf name) or for any other situation where the
142
+ * regex-level resolution lands on the wrong patch.
143
+ *
144
+ * Usage: place the comment on the same line as the import, or on the
145
+ * line immediately above it:
146
+ *
147
+ * ```js
148
+ * // fireforge-ignore: forward-import
149
+ * import { Helper } from "resource:///modules/Helper.sys.mjs";
150
+ *
151
+ * import { Helper } from "resource:///modules/Helper.sys.mjs"; // fireforge-ignore: forward-import
152
+ * ```
153
+ */
154
+ export declare const FORWARD_IMPORT_IGNORE_MARKER = "fireforge-ignore: forward-import";
155
+ /**
156
+ * Returns a Set of 0-indexed line numbers on which the forward-import
157
+ * rule should be suppressed. A marker on line N suppresses matches on
158
+ * line N and N+1 so users can write the marker above the line it
159
+ * describes. Matches on any line past N+1 are not affected.
160
+ */
161
+ export declare function findForwardImportIgnoreLines(source: string): Set<number>;
162
+ /**
163
+ * Cross-patch lint rule: a patch imports a module that a later patch is
164
+ * responsible for creating.
165
+ *
166
+ * Approach is deliberately conservative — we do not resolve `resource://`
167
+ * URLs to engine file paths. Instead we build a cross-queue index of
168
+ * newly-created files keyed by their basename, and flag imports whose leaf
169
+ * matches an entry owned by a later-ordered patch. False positives from
170
+ * unrelated basename collisions (two different directories happening to
171
+ * create files named `Helper.sys.mjs`) are possible; the README documents
172
+ * the limitation and the inline ignore marker above provides an escape
173
+ * hatch.
174
+ *
175
+ * Rules out:
176
+ * - Imports whose leaf matches a newly-created file in the *same* or an
177
+ * *earlier* patch (legitimate use).
178
+ * - Imports whose leaf is not in the new-file index at all (pre-existing
179
+ * engine file — not our concern).
180
+ * - Imports on a line suppressed by the ignore marker.
181
+ */
182
+ export declare function lintPatchQueueForwardImports(ctx: PatchQueueContext): PatchLintIssue[];
183
+ /**
184
+ * Cross-patch lint orchestrator. Runs every cross-patch rule against the
185
+ * provided context and returns combined issues.
186
+ *
187
+ * Separate from `lintExportedPatch` because cross-patch rules operate
188
+ * over the whole queue, not a single patch. Callers integrating both
189
+ * orchestrators (e.g. `fireforge lint`) should concatenate results.
190
+ *
191
+ * @param ctx - Pre-built queue context (use
192
+ * {@link buildPatchQueueContext} for the default path, or construct
193
+ * manually for projected/hypothetical states)
194
+ */
195
+ export declare function lintPatchQueue(ctx: PatchQueueContext): PatchLintIssue[];
@@ -0,0 +1,428 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Cross-patch lint infrastructure: the queue context builder, the
4
+ * duplicate-new-file-creation and forward-import rules, the
5
+ * forward-import ignore-marker, and the per-specifier extractor that
6
+ * powers the forward-import rule.
7
+ *
8
+ * Separated from `patch-lint.ts` so the per-patch and cross-patch rule
9
+ * bodies stay within the project's per-file line budget. `patch-lint.ts`
10
+ * re-exports the public surface so callers continue to import from a
11
+ * single module.
12
+ */
13
+ import { basename } from 'node:path';
14
+ import { toError } from '../utils/errors.js';
15
+ import { readText } from '../utils/fs.js';
16
+ import { verbose } from '../utils/logger.js';
17
+ import { stripJsComments } from '../utils/regex.js';
18
+ import { discoverPatches } from './patch-files.js';
19
+ import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-diff.js';
20
+ import { loadPatchesManifest } from './patch-manifest-io.js';
21
+ import { extractNewFileContent } from './patch-transform.js';
22
+ /**
23
+ * Builds a {@link PatchQueueContext} by reading every .patch file in the
24
+ * directory and extracting new-file content for each creation.
25
+ *
26
+ * Reads manifest metadata best-effort: if the manifest is missing or a patch
27
+ * file has no metadata entry, the context still populates the entry from the
28
+ * filename prefix so cross-patch rules can operate on a drift state. (A
29
+ * separate consistency check — see `patches verify` — is responsible for
30
+ * reporting the drift as its own error.)
31
+ *
32
+ * @param patchesDir - Path to the patches directory
33
+ */
34
+ export async function buildPatchQueueContext(patchesDir) {
35
+ const patches = await discoverPatches(patchesDir);
36
+ const manifest = await loadPatchesManifest(patchesDir);
37
+ const metadataByFilename = new Map();
38
+ if (manifest) {
39
+ for (const entry of manifest.patches) {
40
+ metadataByFilename.set(entry.filename, entry);
41
+ }
42
+ }
43
+ const entries = [];
44
+ for (const patch of patches) {
45
+ const diff = await readText(patch.path);
46
+ const newFilePaths = detectNewFilesInDiff(diff);
47
+ const newFiles = new Map();
48
+ for (const newFile of newFilePaths) {
49
+ try {
50
+ const content = await extractNewFileContent(patch.path, newFile);
51
+ newFiles.set(newFile, content);
52
+ }
53
+ catch (error) {
54
+ verbose(`Skipping forward-import scan for ${newFile} in ${patch.filename}: ${toError(error).message}`);
55
+ }
56
+ }
57
+ // Added-line content for every file the patch modifies but does not
58
+ // create. Fed to the forward-import rule so imports introduced into
59
+ // pre-existing files are checked too, not only imports in brand-new
60
+ // files. We deliberately skip paths in newFilePaths — those are
61
+ // already covered by the newFiles map, which carries full content
62
+ // rather than only the added lines.
63
+ const addedLinesByFile = extractAddedLinesPerFile(diff);
64
+ const modifiedFileAdditions = new Map();
65
+ for (const [file, lines] of addedLinesByFile) {
66
+ if (newFilePaths.has(file))
67
+ continue;
68
+ modifiedFileAdditions.set(file, lines.join('\n'));
69
+ }
70
+ entries.push({
71
+ filename: patch.filename,
72
+ order: Number.isFinite(patch.order) ? patch.order : entries.length + 1,
73
+ metadata: metadataByFilename.get(patch.filename) ?? null,
74
+ diff,
75
+ newFiles,
76
+ modifiedFileAdditions,
77
+ });
78
+ }
79
+ // Sort by order so rules can rely on entries being in apply order.
80
+ entries.sort((a, b) => a.order - b.order || a.filename.localeCompare(b.filename));
81
+ return { entries };
82
+ }
83
+ /**
84
+ * Returns the raw `path → patches[]` map of files created in `new file
85
+ * mode` by at least one patch in the queue. Paths created by only one
86
+ * patch are also included so callers can distinguish "no creator" from
87
+ * "exactly one creator" without re-scanning the diffs.
88
+ *
89
+ * Split out from {@link lintPatchQueueDuplicateCreations} so
90
+ * `status --ownership` (and any future caller that wants ownership
91
+ * without a rendered PatchLintIssue) can consume the same structured
92
+ * data the rule itself relies on. Previously status had to parse the
93
+ * rule's human-readable message to recover the patch list, which was
94
+ * both fragile and made the lint message format part of an implicit
95
+ * contract.
96
+ *
97
+ * @param ctx - Pre-built queue context
98
+ */
99
+ export function collectNewFileCreatorsByPath(ctx) {
100
+ const creators = new Map();
101
+ for (const entry of ctx.entries) {
102
+ const newFiles = detectNewFilesInDiff(entry.diff);
103
+ for (const file of newFiles) {
104
+ let owners = creators.get(file);
105
+ if (!owners) {
106
+ owners = [];
107
+ creators.set(file, owners);
108
+ }
109
+ owners.push(entry.filename);
110
+ }
111
+ }
112
+ return creators;
113
+ }
114
+ /**
115
+ * Cross-patch lint rule: the same path is newly created (`--- /dev/null →
116
+ * +++ b/path`) by more than one patch. This is the failure mode that
117
+ * motivated the rule — Hominis landed three patches each trying to create
118
+ * the same file, and the error surfaced only when import rolled back
119
+ * mid-apply.
120
+ *
121
+ * Reports one error per conflicting path, naming every patch that creates
122
+ * the path so the operator can pick the correct fix (`patch delete` or
123
+ * `re-export --files`).
124
+ */
125
+ export function lintPatchQueueDuplicateCreations(ctx) {
126
+ const creators = collectNewFileCreatorsByPath(ctx);
127
+ const issues = [];
128
+ for (const [file, owners] of creators) {
129
+ if (owners.length > 1) {
130
+ issues.push({
131
+ file,
132
+ check: 'duplicate-new-file-creation',
133
+ fingerprint: `duplicate-new-file-creation|${file}|${[...owners].sort((a, b) => a.localeCompare(b)).join(',')}`,
134
+ message: `File "${file}" is created (new file mode) by multiple patches: ${owners.join(', ')}. ` +
135
+ 'Only one patch may create a given path. Use "patch delete" or ' +
136
+ '"re-export --files" to remove the duplicate.',
137
+ severity: 'error',
138
+ });
139
+ }
140
+ }
141
+ return issues;
142
+ }
143
+ const FORWARD_IMPORTABLE_EXTENSIONS = ['.mjs', '.sys.mjs', '.js', '.jsm'];
144
+ /**
145
+ * Returns true when a path looks like a JS module/subscript the
146
+ * forward-import rule should scan.
147
+ */
148
+ export function isForwardImportableFile(path) {
149
+ return FORWARD_IMPORTABLE_EXTENSIONS.some((ext) => path.endsWith(ext));
150
+ }
151
+ /**
152
+ * Regex-level extractor for module specifiers in ES module / subscript files.
153
+ *
154
+ * Intentionally conservative. Catches:
155
+ * - `import ... from "specifier"` (ES module static imports)
156
+ * - `import "specifier"` (side-effect imports — the `from`
157
+ * clause is optional in the regex)
158
+ * - `import("specifier")` (dynamic imports)
159
+ * - ChromeUtils.defineESModuleGetters(obj, { Name: "specifier", ... })
160
+ *
161
+ * Returns the raw specifier strings — callers should take the leaf basename
162
+ * to match against the newFileIndex, because we do not resolve `resource://`
163
+ * URLs to engine file paths.
164
+ */
165
+ export function extractImportSpecifiers(source) {
166
+ return extractImportSpecifiersWithLines(source).map((item) => item.specifier);
167
+ }
168
+ function buildLineOffsets(source) {
169
+ const offsets = [0];
170
+ for (let i = 0; i < source.length; i++) {
171
+ if (source[i] === '\n')
172
+ offsets.push(i + 1);
173
+ }
174
+ return offsets;
175
+ }
176
+ function makeOffsetToLine(lineOffsets) {
177
+ return (offset) => {
178
+ let lo = 0;
179
+ let hi = lineOffsets.length - 1;
180
+ while (lo < hi) {
181
+ const mid = (lo + hi + 1) >>> 1;
182
+ const candidate = lineOffsets[mid];
183
+ if (candidate === undefined || candidate > offset) {
184
+ hi = mid - 1;
185
+ }
186
+ else {
187
+ lo = mid;
188
+ }
189
+ }
190
+ return lo;
191
+ };
192
+ }
193
+ /**
194
+ * Walks `defineESModuleGetters(obj, { ... })` calls using a balanced
195
+ * brace walker so nested object literals and multi-line shapes do not
196
+ * terminate the parse early. Appends the string literals found inside
197
+ * the getter map to `results`.
198
+ */
199
+ function collectGetterSpecifiers(stripped, results, offsetToLine) {
200
+ const gettersOpenPattern = /defineESModuleGetters\s*\(/g;
201
+ let match;
202
+ while ((match = gettersOpenPattern.exec(stripped)) !== null) {
203
+ const openParen = stripped.indexOf('(', match.index);
204
+ if (openParen === -1)
205
+ continue;
206
+ // Walk to the first top-level `{` inside the call — the start of
207
+ // the getter-map object literal. Bail if we reach the closing `)`
208
+ // first (no object literal argument given).
209
+ let depthParen = 1;
210
+ let openBrace = -1;
211
+ for (let i = openParen + 1; i < stripped.length; i++) {
212
+ const char = stripped[i];
213
+ if (char === '(')
214
+ depthParen += 1;
215
+ else if (char === ')') {
216
+ depthParen -= 1;
217
+ if (depthParen === 0)
218
+ break;
219
+ }
220
+ else if (char === '{' && depthParen === 1) {
221
+ openBrace = i;
222
+ break;
223
+ }
224
+ }
225
+ if (openBrace === -1)
226
+ continue;
227
+ // Walk the object-literal body with balanced braces so nested
228
+ // `{ ... }` inside a value does not terminate the walk early.
229
+ let depthBrace = 1;
230
+ let closeBrace = -1;
231
+ for (let i = openBrace + 1; i < stripped.length; i++) {
232
+ const char = stripped[i];
233
+ if (char === '{')
234
+ depthBrace += 1;
235
+ else if (char === '}') {
236
+ depthBrace -= 1;
237
+ if (depthBrace === 0) {
238
+ closeBrace = i;
239
+ break;
240
+ }
241
+ }
242
+ }
243
+ if (closeBrace === -1)
244
+ continue;
245
+ const body = stripped.slice(openBrace + 1, closeBrace);
246
+ const bodyStart = openBrace + 1;
247
+ const stringLiteralPattern = /["']([^"']+)["']/g;
248
+ let strMatch;
249
+ while ((strMatch = stringLiteralPattern.exec(body)) !== null) {
250
+ if (strMatch[1]) {
251
+ results.push({
252
+ specifier: strMatch[1],
253
+ line: offsetToLine(bodyStart + strMatch.index),
254
+ });
255
+ }
256
+ }
257
+ }
258
+ }
259
+ /**
260
+ * Returns import specifiers plus 0-indexed line numbers, preserving the
261
+ * same matching behavior as {@link extractImportSpecifiers}.
262
+ */
263
+ export function extractImportSpecifiersWithLines(source) {
264
+ // stripJsComments replaces comment bodies with space runs of equal
265
+ // length, preserving character offsets. That lets us match against
266
+ // the stripped source (so we do not match `import` tokens inside
267
+ // block comments or string literals) while still reporting line
268
+ // numbers based on the ORIGINAL source, which is what the
269
+ // ignore-marker scan walks.
270
+ const stripped = stripJsComments(source);
271
+ const results = [];
272
+ const lineOffsets = buildLineOffsets(source);
273
+ const offsetToLine = makeOffsetToLine(lineOffsets);
274
+ const importFromPattern = /\bimport\s+(?:[^'"]*?\s+from\s+)?["']([^"']+)["']/g;
275
+ let match;
276
+ while ((match = importFromPattern.exec(stripped)) !== null) {
277
+ if (match[1])
278
+ results.push({ specifier: match[1], line: offsetToLine(match.index) });
279
+ }
280
+ const dynamicImportPattern = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g;
281
+ while ((match = dynamicImportPattern.exec(stripped)) !== null) {
282
+ if (match[1])
283
+ results.push({ specifier: match[1], line: offsetToLine(match.index) });
284
+ }
285
+ collectGetterSpecifiers(stripped, results, offsetToLine);
286
+ return results;
287
+ }
288
+ /**
289
+ * Marker comment operators can use to suppress the forward-import rule
290
+ * for imports that resolve to a basename false positive (two unrelated
291
+ * files with the same leaf name) or for any other situation where the
292
+ * regex-level resolution lands on the wrong patch.
293
+ *
294
+ * Usage: place the comment on the same line as the import, or on the
295
+ * line immediately above it:
296
+ *
297
+ * ```js
298
+ * // fireforge-ignore: forward-import
299
+ * import { Helper } from "resource:///modules/Helper.sys.mjs";
300
+ *
301
+ * import { Helper } from "resource:///modules/Helper.sys.mjs"; // fireforge-ignore: forward-import
302
+ * ```
303
+ */
304
+ export const FORWARD_IMPORT_IGNORE_MARKER = 'fireforge-ignore: forward-import';
305
+ /**
306
+ * Returns a Set of 0-indexed line numbers on which the forward-import
307
+ * rule should be suppressed. A marker on line N suppresses matches on
308
+ * line N and N+1 so users can write the marker above the line it
309
+ * describes. Matches on any line past N+1 are not affected.
310
+ */
311
+ export function findForwardImportIgnoreLines(source) {
312
+ const lines = source.split('\n');
313
+ const ignored = new Set();
314
+ for (let i = 0; i < lines.length; i++) {
315
+ const line = lines[i];
316
+ if (line && line.includes(FORWARD_IMPORT_IGNORE_MARKER)) {
317
+ ignored.add(i);
318
+ ignored.add(i + 1);
319
+ }
320
+ }
321
+ return ignored;
322
+ }
323
+ /**
324
+ * Cross-patch lint rule: a patch imports a module that a later patch is
325
+ * responsible for creating.
326
+ *
327
+ * Approach is deliberately conservative — we do not resolve `resource://`
328
+ * URLs to engine file paths. Instead we build a cross-queue index of
329
+ * newly-created files keyed by their basename, and flag imports whose leaf
330
+ * matches an entry owned by a later-ordered patch. False positives from
331
+ * unrelated basename collisions (two different directories happening to
332
+ * create files named `Helper.sys.mjs`) are possible; the README documents
333
+ * the limitation and the inline ignore marker above provides an escape
334
+ * hatch.
335
+ *
336
+ * Rules out:
337
+ * - Imports whose leaf matches a newly-created file in the *same* or an
338
+ * *earlier* patch (legitimate use).
339
+ * - Imports whose leaf is not in the new-file index at all (pre-existing
340
+ * engine file — not our concern).
341
+ * - Imports on a line suppressed by the ignore marker.
342
+ */
343
+ export function lintPatchQueueForwardImports(ctx) {
344
+ const newFileIndex = new Map();
345
+ for (const entry of ctx.entries) {
346
+ for (const fullPath of entry.newFiles.keys()) {
347
+ const leaf = basename(fullPath);
348
+ let owners = newFileIndex.get(leaf);
349
+ if (!owners) {
350
+ owners = [];
351
+ newFileIndex.set(leaf, owners);
352
+ }
353
+ owners.push({ filename: entry.filename, order: entry.order, fullPath });
354
+ }
355
+ }
356
+ const issues = [];
357
+ // Runs the forward-import check against one source site — either a file
358
+ // the patch creates (`content` = full file) or a file the patch modifies
359
+ // (`content` = concatenated added lines only). We deliberately scan added
360
+ // lines rather than the full resulting file for modifications: we only
361
+ // want to flag imports *this patch introduces*, not imports that already
362
+ // exist on HEAD and happen to match a later-created file by coincidence.
363
+ const checkSite = (entry, sitePath, content) => {
364
+ if (!isForwardImportableFile(sitePath))
365
+ return;
366
+ const ignoreLines = findForwardImportIgnoreLines(content);
367
+ const extracted = extractImportSpecifiersWithLines(content);
368
+ for (const { specifier, line } of extracted) {
369
+ if (ignoreLines.has(line))
370
+ continue;
371
+ // Take the leaf and strip query/hash if any.
372
+ const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
373
+ const leaf = basename(cleaned);
374
+ if (!leaf || !isForwardImportableFile(leaf))
375
+ continue;
376
+ const owners = newFileIndex.get(leaf);
377
+ if (!owners)
378
+ continue;
379
+ // Is the owner a later-ordered patch (or one ordered equal but
380
+ // lexicographically later as a tiebreaker)?
381
+ const laterOwners = owners.filter((owner) => owner.order > entry.order ||
382
+ (owner.order === entry.order && owner.filename > entry.filename));
383
+ if (laterOwners.length === 0)
384
+ continue;
385
+ const ownersSummary = laterOwners
386
+ .map((o) => `${o.filename} (creates ${o.fullPath})`)
387
+ .join(', ');
388
+ const fingerprintOwners = [...laterOwners]
389
+ .map((o) => `${o.filename}:${o.fullPath}`)
390
+ .sort((a, b) => a.localeCompare(b))
391
+ .join(',');
392
+ issues.push({
393
+ file: sitePath,
394
+ check: 'forward-import',
395
+ fingerprint: `forward-import|${sitePath}|${cleaned}|${fingerprintOwners}`,
396
+ message: `${sitePath} in ${entry.filename} imports "${specifier}", ` +
397
+ `but the matching new file is created by a later patch: ${ownersSummary}. ` +
398
+ 'Reorder the patches so the dependency is created first, move the import ' +
399
+ 'into the later patch, or mark the import with ' +
400
+ `"// ${FORWARD_IMPORT_IGNORE_MARKER}" if the basename collision is a false positive.`,
401
+ severity: 'error',
402
+ });
403
+ }
404
+ };
405
+ for (const entry of ctx.entries) {
406
+ for (const [path, content] of entry.newFiles)
407
+ checkSite(entry, path, content);
408
+ for (const [path, added] of entry.modifiedFileAdditions)
409
+ checkSite(entry, path, added);
410
+ }
411
+ return issues;
412
+ }
413
+ /**
414
+ * Cross-patch lint orchestrator. Runs every cross-patch rule against the
415
+ * provided context and returns combined issues.
416
+ *
417
+ * Separate from `lintExportedPatch` because cross-patch rules operate
418
+ * over the whole queue, not a single patch. Callers integrating both
419
+ * orchestrators (e.g. `fireforge lint`) should concatenate results.
420
+ *
421
+ * @param ctx - Pre-built queue context (use
422
+ * {@link buildPatchQueueContext} for the default path, or construct
423
+ * manually for projected/hypothetical states)
424
+ */
425
+ export function lintPatchQueue(ctx) {
426
+ return [...lintPatchQueueDuplicateCreations(ctx), ...lintPatchQueueForwardImports(ctx)];
427
+ }
428
+ //# sourceMappingURL=patch-lint-cross.js.map
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Unified-diff walking helpers shared between per-patch lint rules,
3
+ * cross-patch lint rules, and the export / re-export projection paths.
4
+ *
5
+ * Factored out of `patch-lint.ts` so the per-patch lint body and
6
+ * cross-patch lint body (in `patch-lint-cross.ts`) can both depend on
7
+ * the same diff walkers without inducing a circular import. Callers
8
+ * should keep importing these through `patch-lint.ts` — this file is
9
+ * an implementation detail.
10
+ */
11
+ /**
12
+ * Extracts new-file paths from a unified diff by scanning for `new file mode` markers.
13
+ */
14
+ export declare function detectNewFilesInDiff(diffContent: string): Set<string>;
15
+ /**
16
+ * Extracts added lines per file from a unified diff.
17
+ * Returns a map of file path → array of added line contents (without the leading `+`).
18
+ */
19
+ export declare function extractAddedLinesPerFile(diffContent: string): Map<string, string[]>;
20
+ /**
21
+ * Builds the `modifiedFileAdditions` map the cross-patch lint expects for
22
+ * a given unified diff. Exposed so callers that construct synthetic /
23
+ * projected `PatchQueueEntry` values (notably `re-export --files`
24
+ * and `export --order`) can populate the field identically to
25
+ * `buildPatchQueueContext`.
26
+ *
27
+ * Matches buildPatchQueueContext's algorithm exactly: skip paths that are
28
+ * created by the diff — those are already covered by the `newFiles` map,
29
+ * which carries full content rather than only the added lines.
30
+ *
31
+ * @param diff - Unified diff content
32
+ */
33
+ export declare function buildModifiedFileAdditionsFromDiff(diff: string): Map<string, string>;