@hominis/fireforge 0.38.0 → 0.40.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 (107) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +25 -1
  3. package/dist/src/cli.js +7 -0
  4. package/dist/src/commands/build.js +8 -3
  5. package/dist/src/commands/discard.js +75 -50
  6. package/dist/src/commands/export-flow.d.ts +1 -1
  7. package/dist/src/commands/export-flow.js +21 -10
  8. package/dist/src/commands/export-placement-gate.js +1 -1
  9. package/dist/src/commands/export-shared.js +31 -5
  10. package/dist/src/commands/import.js +5 -0
  11. package/dist/src/commands/lint-per-patch.js +97 -18
  12. package/dist/src/commands/lint-report.d.ts +21 -0
  13. package/dist/src/commands/lint-report.js +62 -0
  14. package/dist/src/commands/lint.js +23 -19
  15. package/dist/src/commands/manifest.js +2 -0
  16. package/dist/src/commands/patch/lint-ignore.js +20 -1
  17. package/dist/src/commands/patch/move-files-into.d.ts +47 -0
  18. package/dist/src/commands/patch/move-files-into.js +245 -0
  19. package/dist/src/commands/patch/move-files.js +27 -13
  20. package/dist/src/commands/patch/rename.js +5 -2
  21. package/dist/src/commands/patch/staged-dependency.js +102 -14
  22. package/dist/src/commands/re-export-adjacent.d.ts +34 -0
  23. package/dist/src/commands/re-export-adjacent.js +106 -0
  24. package/dist/src/commands/re-export-options.js +3 -0
  25. package/dist/src/commands/re-export.js +42 -53
  26. package/dist/src/commands/reset.js +5 -0
  27. package/dist/src/commands/run.js +18 -2
  28. package/dist/src/commands/status-check.d.ts +35 -0
  29. package/dist/src/commands/status-check.js +88 -0
  30. package/dist/src/commands/status-output.d.ts +9 -0
  31. package/dist/src/commands/status-output.js +33 -1
  32. package/dist/src/commands/status.js +33 -8
  33. package/dist/src/commands/test-diagnose.d.ts +1 -1
  34. package/dist/src/commands/test-diagnose.js +9 -3
  35. package/dist/src/commands/test-modes.d.ts +21 -0
  36. package/dist/src/commands/test-modes.js +42 -0
  37. package/dist/src/commands/test-run.d.ts +2 -0
  38. package/dist/src/commands/test-run.js +26 -5
  39. package/dist/src/commands/test.js +38 -32
  40. package/dist/src/commands/token.js +4 -1
  41. package/dist/src/commands/tree.d.ts +16 -0
  42. package/dist/src/commands/tree.js +174 -0
  43. package/dist/src/core/build-baseline-types.d.ts +7 -0
  44. package/dist/src/core/build-baseline.d.ts +1 -1
  45. package/dist/src/core/build-baseline.js +2 -1
  46. package/dist/src/core/build-prepare.d.ts +9 -0
  47. package/dist/src/core/build-prepare.js +24 -0
  48. package/dist/src/core/config-validate.js +25 -0
  49. package/dist/src/core/discard-baseline.d.ts +60 -0
  50. package/dist/src/core/discard-baseline.js +232 -0
  51. package/dist/src/core/license-headers.d.ts +12 -0
  52. package/dist/src/core/license-headers.js +27 -0
  53. package/dist/src/core/lint-cache.d.ts +15 -5
  54. package/dist/src/core/lint-cache.js +17 -5
  55. package/dist/src/core/patch-export.d.ts +12 -3
  56. package/dist/src/core/patch-export.js +13 -3
  57. package/dist/src/core/patch-lint-checkjs.d.ts +25 -2
  58. package/dist/src/core/patch-lint-checkjs.js +85 -16
  59. package/dist/src/core/patch-lint-cross.d.ts +9 -1
  60. package/dist/src/core/patch-lint-cross.js +69 -9
  61. package/dist/src/core/patch-lint-css.js +64 -16
  62. package/dist/src/core/patch-lint-diff.d.ts +8 -0
  63. package/dist/src/core/patch-lint-diff.js +31 -0
  64. package/dist/src/core/patch-lint-mozbuild.d.ts +18 -0
  65. package/dist/src/core/patch-lint-mozbuild.js +104 -0
  66. package/dist/src/core/patch-lint-ownership.d.ts +21 -0
  67. package/dist/src/core/patch-lint-ownership.js +30 -0
  68. package/dist/src/core/patch-lint-reexports.d.ts +1 -1
  69. package/dist/src/core/patch-lint.d.ts +23 -0
  70. package/dist/src/core/patch-lint.js +34 -3
  71. package/dist/src/core/patch-manifest.d.ts +1 -1
  72. package/dist/src/core/patch-policy.d.ts +14 -1
  73. package/dist/src/core/patch-policy.js +13 -2
  74. package/dist/src/core/smoke-patterns.d.ts +24 -8
  75. package/dist/src/core/smoke-patterns.js +23 -12
  76. package/dist/src/core/status-classify.d.ts +16 -0
  77. package/dist/src/core/status-classify.js +20 -6
  78. package/dist/src/core/test-harness-crash.d.ts +10 -0
  79. package/dist/src/core/test-harness-crash.js +16 -0
  80. package/dist/src/core/test-stale-check.d.ts +15 -0
  81. package/dist/src/core/test-stale-check.js +37 -1
  82. package/dist/src/core/token-category.d.ts +40 -0
  83. package/dist/src/core/token-category.js +264 -0
  84. package/dist/src/core/token-manager.d.ts +7 -0
  85. package/dist/src/core/token-manager.js +82 -193
  86. package/dist/src/core/token-variant.d.ts +7 -0
  87. package/dist/src/core/token-variant.js +9 -0
  88. package/dist/src/core/tree-cow.d.ts +18 -0
  89. package/dist/src/core/tree-cow.js +71 -0
  90. package/dist/src/core/tree-guard.d.ts +35 -0
  91. package/dist/src/core/tree-guard.js +124 -0
  92. package/dist/src/core/tree-store.d.ts +53 -0
  93. package/dist/src/core/tree-store.js +214 -0
  94. package/dist/src/core/typecheck-shim.d.ts +27 -0
  95. package/dist/src/core/typecheck-shim.js +58 -4
  96. package/dist/src/core/typecheck.js +14 -3
  97. package/dist/src/core/xpcshell-profile-dir.d.ts +12 -0
  98. package/dist/src/core/xpcshell-profile-dir.js +45 -0
  99. package/dist/src/errors/git.d.ts +8 -0
  100. package/dist/src/errors/git.js +17 -0
  101. package/dist/src/index.d.ts +2 -0
  102. package/dist/src/index.js +1 -0
  103. package/dist/src/types/commands/options.d.ts +45 -2
  104. package/dist/src/types/config.d.ts +34 -0
  105. package/dist/src/utils/validation.d.ts +10 -0
  106. package/dist/src/utils/validation.js +29 -0
  107. package/package.json +6 -6
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
3
+ *
4
+ * The engine convention is: HEAD = pristine upstream Firefox, and the
5
+ * applied patch queue lives as UNCOMMITTED worktree changes on top of it
6
+ * (`git apply` without `--index` → patch-edited tracked files are unstaged
7
+ * modifications, patch-created files are untracked). The pre-0.39.0
8
+ * `discard` was purely git-mechanical — it restored to HEAD and deleted
9
+ * untracked files, silently reverting patch-backed files PAST their owning
10
+ * patch and deleting patch-created files outright.
11
+ *
12
+ * This module classifies each status entry against the patch manifest's
13
+ * ownership claims and plans the correct restore target: the PATCH-APPLIED
14
+ * baseline (HEAD content folded through every affecting patch in order) for
15
+ * claimed files, and the legacy git mechanics for unmanaged ones.
16
+ */
17
+ import type { GitStatusEntry } from './git-base.js';
18
+ /** How one status entry's canonical path will be restored. */
19
+ export type DiscardKind = 'unmanaged' | 'patch-backed' | 'patch-created' | 'patch-deleted';
20
+ /** Restore plan for one git status entry. */
21
+ export interface DiscardBaselinePlan {
22
+ entry: GitStatusEntry;
23
+ kind: DiscardKind;
24
+ /** Claiming patch filenames for `entry.file` ∪ `entry.originalPath`. */
25
+ owners: string[];
26
+ /** True when more than one patch claims the path (ownership conflict). */
27
+ conflicted: boolean;
28
+ /** Baseline content for `entry.file`; null = absent at baseline. Unset for 'unmanaged'. */
29
+ expectedContent?: string | null;
30
+ /** Rename/copy only: baseline content for `originalPath` when that side is claimed. */
31
+ expectedOriginalContent?: string | null;
32
+ }
33
+ /** Builds an all-unmanaged plan set — the `--to-upstream` (legacy) shape. */
34
+ export declare function planUpstreamDiscards(entries: readonly GitStatusEntry[]): DiscardBaselinePlan[];
35
+ /**
36
+ * Plans the restore target for each status entry. Entries claimed by the
37
+ * patch manifest restore to the patch-applied baseline; unclaimed entries
38
+ * keep the legacy git mechanics. A missing patches dir or empty manifest
39
+ * degrades every plan to `unmanaged` (identical to pre-0.39.0 behavior).
40
+ *
41
+ * A corrupt manifest or an unreconstructible patch baseline REFUSES with a
42
+ * GeneralError naming `--to-upstream` — silently falling back to pristine
43
+ * HEAD is exactly the data-loss path this module exists to close.
44
+ */
45
+ export declare function planDiscardBaselines(patchesDir: string, engineDir: string, entries: readonly GitStatusEntry[]): Promise<DiscardBaselinePlan[]>;
46
+ /**
47
+ * Applies one restore plan. Patch-claimed paths end up exactly as `import`
48
+ * leaves them: patched content as an unstaged worktree state (tracked files
49
+ * read ` M`, created files `??`), nothing staged. Unmanaged plans delegate
50
+ * to the legacy {@link discardStatusEntry} mechanics.
51
+ */
52
+ export declare function applyDiscardBaseline(engineDir: string, plan: DiscardBaselinePlan): Promise<void>;
53
+ /** One-line annotation for dry-run listings and confirmation summaries. */
54
+ export declare function describeDiscardBaseline(plan: DiscardBaselinePlan): string;
55
+ /** Single-file outro naming the baseline that was restored. */
56
+ export declare function describeDiscardOutcome(plan: DiscardBaselinePlan, toUpstream: boolean): string;
57
+ /** Batch outro summarising how many files restored to which baseline. */
58
+ export declare function summarizeDiscardBaselines(plans: readonly DiscardBaselinePlan[], succeeded: number): string;
59
+ /** Warn text for a multi-owner (conflicted) restore. */
60
+ export declare function describeConflictWarning(plan: DiscardBaselinePlan): string;
@@ -0,0 +1,232 @@
1
+ // SPDX-License-Identifier: EUPL-1.2
2
+ /**
3
+ * Patch-aware restore planning for `fireforge discard` (FORGE F1, P0).
4
+ *
5
+ * The engine convention is: HEAD = pristine upstream Firefox, and the
6
+ * applied patch queue lives as UNCOMMITTED worktree changes on top of it
7
+ * (`git apply` without `--index` → patch-edited tracked files are unstaged
8
+ * modifications, patch-created files are untracked). The pre-0.39.0
9
+ * `discard` was purely git-mechanical — it restored to HEAD and deleted
10
+ * untracked files, silently reverting patch-backed files PAST their owning
11
+ * patch and deleting patch-created files outright.
12
+ *
13
+ * This module classifies each status entry against the patch manifest's
14
+ * ownership claims and plans the correct restore target: the PATCH-APPLIED
15
+ * baseline (HEAD content folded through every affecting patch in order) for
16
+ * claimed files, and the legacy git mechanics for unmanaged ones.
17
+ */
18
+ import { join } from 'node:path';
19
+ import { GeneralError } from '../errors/base.js';
20
+ import { toError } from '../utils/errors.js';
21
+ import { readText, removeFile, writeText } from '../utils/fs.js';
22
+ import { discardStatusEntry, fileExistsInHead, listTrackedInHead, restoreTrackedPath, unstageFiles, } from './git-file-ops.js';
23
+ import { createPatchedContentComputer } from './patch-apply.js';
24
+ import { loadPatchesManifestState } from './patch-manifest-io.js';
25
+ import { parseDiffSections } from './patch-parse.js';
26
+ import { buildPatchClaims } from './status-classify.js';
27
+ /** Builds an all-unmanaged plan set — the `--to-upstream` (legacy) shape. */
28
+ export function planUpstreamDiscards(entries) {
29
+ return entries.map((entry) => ({ entry, kind: 'unmanaged', owners: [], conflicted: false }));
30
+ }
31
+ /**
32
+ * Plans the restore target for each status entry. Entries claimed by the
33
+ * patch manifest restore to the patch-applied baseline; unclaimed entries
34
+ * keep the legacy git mechanics. A missing patches dir or empty manifest
35
+ * degrades every plan to `unmanaged` (identical to pre-0.39.0 behavior).
36
+ *
37
+ * A corrupt manifest or an unreconstructible patch baseline REFUSES with a
38
+ * GeneralError naming `--to-upstream` — silently falling back to pristine
39
+ * HEAD is exactly the data-loss path this module exists to close.
40
+ */
41
+ export async function planDiscardBaselines(patchesDir, engineDir, entries) {
42
+ const state = await loadPatchesManifestState(patchesDir);
43
+ if (state.exists && state.parseError) {
44
+ throw new GeneralError('patches/patches.json is unreadable — refusing to discard while patch ownership cannot be ' +
45
+ 'determined. Fix the manifest, or pass --to-upstream to explicitly revert to pristine ' +
46
+ 'upstream (HEAD).');
47
+ }
48
+ const claims = state.manifest
49
+ ? buildPatchClaims(state.manifest.patches)
50
+ : new Map();
51
+ const plans = [];
52
+ const claimedEntries = entries.filter((entry) => claims.has(entry.file) || (entry.originalPath !== undefined && claims.has(entry.originalPath)));
53
+ if (claimedEntries.length === 0) {
54
+ return planUpstreamDiscards(entries);
55
+ }
56
+ const computePatched = await createPatchedContentComputer(patchesDir, engineDir);
57
+ const claimedFiles = [
58
+ ...new Set(claimedEntries.flatMap((entry) => entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file])),
59
+ ];
60
+ const trackedInHead = await listTrackedInHead(engineDir, claimedFiles);
61
+ const computeExpected = async (path) => {
62
+ try {
63
+ return await computePatched(path);
64
+ }
65
+ catch (error) {
66
+ throw new GeneralError(`Cannot reconstruct the patch baseline for ${path} (${toError(error).message}). ` +
67
+ 'Fix the owning patch, or pass --to-upstream to explicitly revert to pristine ' +
68
+ 'upstream (HEAD).');
69
+ }
70
+ };
71
+ // `computePatchedContent` folds a deletion into an empty string (an
72
+ // apply-to-content limitation), which is indistinguishable from a
73
+ // legitimately empty file. Consult the owning patches' diff sections
74
+ // directly: the LAST section (highest-ordered owner) naming the path
75
+ // decides whether the baseline is "absent".
76
+ const sectionCache = new Map();
77
+ const isDeletedAtBaseline = async (path, owners) => {
78
+ for (let i = owners.length - 1; i >= 0; i--) {
79
+ const owner = owners[i];
80
+ if (owner === undefined)
81
+ continue;
82
+ let sections = sectionCache.get(owner);
83
+ if (!sections) {
84
+ try {
85
+ sections = parseDiffSections(await readText(join(patchesDir, owner)));
86
+ }
87
+ catch {
88
+ sections = [];
89
+ }
90
+ sectionCache.set(owner, sections);
91
+ }
92
+ for (const section of sections) {
93
+ if (section.targetPath === path || section.sourcePath === path) {
94
+ return section.isDeletedFile;
95
+ }
96
+ }
97
+ }
98
+ return false;
99
+ };
100
+ for (const entry of entries) {
101
+ const owners = [
102
+ ...new Set([
103
+ ...(claims.get(entry.file) ?? []),
104
+ ...(entry.originalPath !== undefined ? (claims.get(entry.originalPath) ?? []) : []),
105
+ ]),
106
+ ];
107
+ if (owners.length === 0) {
108
+ plans.push({ entry, kind: 'unmanaged', owners, conflicted: false });
109
+ continue;
110
+ }
111
+ let expectedContent = claims.has(entry.file) ? await computeExpected(entry.file) : null;
112
+ if (expectedContent !== null &&
113
+ (await isDeletedAtBaseline(entry.file, claims.get(entry.file) ?? []))) {
114
+ expectedContent = null;
115
+ }
116
+ const expectedOriginalContent = entry.originalPath !== undefined && claims.has(entry.originalPath)
117
+ ? await computeExpected(entry.originalPath)
118
+ : undefined;
119
+ const kind = expectedContent === null
120
+ ? 'patch-deleted'
121
+ : trackedInHead.has(entry.file)
122
+ ? 'patch-backed'
123
+ : 'patch-created';
124
+ plans.push({
125
+ entry,
126
+ kind,
127
+ owners,
128
+ conflicted: owners.length > 1,
129
+ expectedContent,
130
+ ...(expectedOriginalContent !== undefined ? { expectedOriginalContent } : {}),
131
+ });
132
+ }
133
+ return plans;
134
+ }
135
+ /**
136
+ * Applies one restore plan. Patch-claimed paths end up exactly as `import`
137
+ * leaves them: patched content as an unstaged worktree state (tracked files
138
+ * read ` M`, created files `??`), nothing staged. Unmanaged plans delegate
139
+ * to the legacy {@link discardStatusEntry} mechanics.
140
+ */
141
+ export async function applyDiscardBaseline(engineDir, plan) {
142
+ if (plan.kind === 'unmanaged') {
143
+ await discardStatusEntry(engineDir, plan.entry);
144
+ return;
145
+ }
146
+ const { entry } = plan;
147
+ // Drop any staged edits/adds/renames first so the rewritten content is a
148
+ // plain worktree state (harmless when nothing is staged; skipped for
149
+ // purely-untracked entries which have no index footprint).
150
+ if (!entry.isUntracked) {
151
+ const indexPaths = entry.originalPath !== undefined ? [entry.file, entry.originalPath] : [entry.file];
152
+ await unstageFiles(engineDir, indexPaths);
153
+ }
154
+ if (plan.expectedContent === null || plan.expectedContent === undefined) {
155
+ await removeFile(join(engineDir, entry.file));
156
+ }
157
+ else {
158
+ await writeText(join(engineDir, entry.file), plan.expectedContent);
159
+ }
160
+ // A rename/copy's original side restores to ITS baseline too: the patch
161
+ // baseline when claimed, else the legacy HEAD restore (matching what
162
+ // discardStatusEntry does for that side).
163
+ if (entry.originalPath !== undefined) {
164
+ if (plan.expectedOriginalContent !== undefined) {
165
+ if (plan.expectedOriginalContent === null) {
166
+ await removeFile(join(engineDir, entry.originalPath));
167
+ }
168
+ else {
169
+ await writeText(join(engineDir, entry.originalPath), plan.expectedOriginalContent);
170
+ }
171
+ }
172
+ else if (await fileExistsInHead(engineDir, entry.originalPath)) {
173
+ await restoreTrackedPath(engineDir, entry.originalPath);
174
+ }
175
+ }
176
+ }
177
+ /** One-line annotation for dry-run listings and confirmation summaries. */
178
+ export function describeDiscardBaseline(plan) {
179
+ const owners = plan.owners.join(' + ');
180
+ switch (plan.kind) {
181
+ case 'unmanaged':
182
+ return plan.entry.isUntracked ? 'unmanaged — delete' : 'unmanaged — revert to upstream';
183
+ case 'patch-backed':
184
+ return `patch baseline: ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
185
+ case 'patch-created':
186
+ return `re-materialize from ${owners}${plan.conflicted ? ' (conflict)' : ''}`;
187
+ case 'patch-deleted':
188
+ return `delete — ${owners} removes it`;
189
+ }
190
+ }
191
+ /** Single-file outro naming the baseline that was restored. */
192
+ export function describeDiscardOutcome(plan, toUpstream) {
193
+ if (toUpstream)
194
+ return 'File restored to pristine upstream (HEAD)';
195
+ const owners = plan.owners.join(' + ');
196
+ switch (plan.kind) {
197
+ case 'unmanaged':
198
+ return 'File restored to original state';
199
+ case 'patch-backed':
200
+ return `File restored to patch baseline (${owners})`;
201
+ case 'patch-created':
202
+ return `File re-materialized from patch baseline (${owners})`;
203
+ case 'patch-deleted':
204
+ return `File removed to match patch baseline (${owners} deletes it)`;
205
+ }
206
+ }
207
+ /** Batch outro summarising how many files restored to which baseline. */
208
+ export function summarizeDiscardBaselines(plans, succeeded) {
209
+ const patchBacked = plans.filter((p) => p.kind === 'patch-backed').length;
210
+ const created = plans.filter((p) => p.kind === 'patch-created').length;
211
+ const deleted = plans.filter((p) => p.kind === 'patch-deleted').length;
212
+ const unmanaged = plans.filter((p) => p.kind === 'unmanaged').length;
213
+ if (patchBacked + created + deleted === 0) {
214
+ return `${String(succeeded)} file(s) restored to upstream state`;
215
+ }
216
+ const parts = [];
217
+ if (patchBacked > 0)
218
+ parts.push(`${String(patchBacked)} to patch baseline`);
219
+ if (created > 0)
220
+ parts.push(`${String(created)} re-materialized`);
221
+ if (deleted > 0)
222
+ parts.push(`${String(deleted)} removed per patch baseline`);
223
+ if (unmanaged > 0)
224
+ parts.push(`${String(unmanaged)} to upstream state`);
225
+ return `${String(succeeded)} file(s) restored: ${parts.join(', ')}`;
226
+ }
227
+ /** Warn text for a multi-owner (conflicted) restore. */
228
+ export function describeConflictWarning(plan) {
229
+ return (`${plan.entry.file} is claimed by ${String(plan.owners.length)} patches ` +
230
+ `(${plan.owners.join(', ')}). Restored the cumulative baseline; run ` +
231
+ '"fireforge status --ownership" to resolve ownership.');
232
+ }
@@ -81,6 +81,18 @@ export declare function hasAnyLicenseHeaderAnyStyle(content: string): boolean;
81
81
  * @param maxLines - Number of leading lines to inspect (default 10)
82
82
  */
83
83
  export declare function containsUpstreamLicenseText(content: string, maxLines?: number): boolean;
84
+ /**
85
+ * Returns true when the head of `content` carries a recognized THIRD-PARTY
86
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
87
+ * comment style. Used by `export` to treat such files as vendored: offering
88
+ * to prepend the project's own license header onto a byte-identity-pinned
89
+ * upstream bundle would mislicense third-party code (FORGE F15).
90
+ *
91
+ * @param content - File content to check
92
+ * @param maxLines - Number of leading lines to inspect (default 30 — full
93
+ * MIT/BSD license texts run longer than the 10-line project-header scan)
94
+ */
95
+ export declare function hasThirdPartyPermissiveBanner(content: string, maxLines?: number): boolean;
84
96
  /**
85
97
  * Prepends the license header to a file on disk if it is not already present.
86
98
  *
@@ -252,6 +252,33 @@ export function containsUpstreamLicenseText(content, maxLines = 10) {
252
252
  ];
253
253
  return markers.some((marker) => scanText.includes(marker));
254
254
  }
255
+ /**
256
+ * Returns true when the head of `content` carries a recognized THIRD-PARTY
257
+ * permissive license banner (MIT / ISC / BSD-2 / BSD-3 / Apache-2.0), in any
258
+ * comment style. Used by `export` to treat such files as vendored: offering
259
+ * to prepend the project's own license header onto a byte-identity-pinned
260
+ * upstream bundle would mislicense third-party code (FORGE F15).
261
+ *
262
+ * @param content - File content to check
263
+ * @param maxLines - Number of leading lines to inspect (default 30 — full
264
+ * MIT/BSD license texts run longer than the 10-line project-header scan)
265
+ */
266
+ export function hasThirdPartyPermissiveBanner(content, maxLines = 30) {
267
+ const head = content.split('\n').slice(0, maxLines).join('\n');
268
+ const scanText = normalizeLicenseHeadForScan(head).toLowerCase();
269
+ const markers = [
270
+ 'mit license',
271
+ 'permission is hereby granted, free of charge',
272
+ 'isc license',
273
+ 'permission to use, copy, modify',
274
+ 'redistribution and use in source and binary forms',
275
+ 'apache license',
276
+ 'licensed under the apache license',
277
+ ];
278
+ if (markers.some((marker) => scanText.includes(marker)))
279
+ return true;
280
+ return /spdx-license-identifier:\s*(mit|isc|bsd-2-clause|bsd-3-clause|apache-2\.0)\b/.test(scanText);
281
+ }
255
282
  /**
256
283
  * Prepends the license header to a file on disk if it is not already present.
257
284
  *
@@ -1,11 +1,15 @@
1
1
  import type { PatchLintIssue, PatchMetadata } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type PatchQueueContext } from './patch-lint.js';
4
- export declare const LINT_CACHE_SCHEMA_VERSION = 1;
4
+ export declare const LINT_CACHE_SCHEMA_VERSION = 2;
5
5
  export interface PerPatchLintCacheEntry {
6
6
  key: string;
7
7
  patchFilename: string;
8
8
  issues: PatchLintIssue[];
9
+ /** Issues dropped by the patch's lintIgnore waivers (FORGE G10). */
10
+ suppressed: PatchLintIssue[];
11
+ /** Non-binary diff line count measured by the cached lint run. */
12
+ lineCount: number;
9
13
  updatedAt: string;
10
14
  }
11
15
  export interface PerPatchLintCacheFile {
@@ -36,7 +40,13 @@ export declare function loadPerPatchLintCache(projectRoot: string): Promise<PerP
36
40
  export declare function savePerPatchLintCache(projectRoot: string, cache: PerPatchLintCacheFile): Promise<void>;
37
41
  /** Clears the per-patch lint cache by replacing it with an empty document. */
38
42
  export declare function clearPerPatchLintCache(projectRoot: string): Promise<void>;
39
- /** Returns cached issues for a patch when the stored key still matches. */
40
- export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): PatchLintIssue[] | undefined;
41
- /** Stores per-patch lint issues after a successful lint calculation. */
42
- export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[]): void;
43
+ /** Cached per-patch lint payload returned on a key match. */
44
+ export interface CachedPerPatchLint {
45
+ issues: PatchLintIssue[];
46
+ suppressed: PatchLintIssue[];
47
+ lineCount: number;
48
+ }
49
+ /** Returns the cached lint payload for a patch when the stored key still matches. */
50
+ export declare function getCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string): CachedPerPatchLint | undefined;
51
+ /** Stores the per-patch lint payload after a successful lint calculation. */
52
+ export declare function setCachedPerPatchLintIssues(cache: PerPatchLintCacheFile, patchFilename: string, key: string, issues: PatchLintIssue[], suppressed: PatchLintIssue[], lineCount: number): void;
@@ -7,7 +7,11 @@ import { getPackageVersion } from '../utils/package-root.js';
7
7
  import { getFurnacePaths } from './furnace-config.js';
8
8
  import { git } from './git-base.js';
9
9
  import { collectNewFileCreatorsByPath } from './patch-lint.js';
10
- export const LINT_CACHE_SCHEMA_VERSION = 1;
10
+ // Schema 2 (FORGE G10): entries additionally carry the lintIgnore-suppressed
11
+ // issues and the measured non-binary line count, so a warm run can report
12
+ // waived size measurements identically to a cold one (the F5-class hazard:
13
+ // a cache hit must never surface LESS than a fresh lint).
14
+ export const LINT_CACHE_SCHEMA_VERSION = 2;
11
15
  const LINT_IMPLEMENTATION_VERSION = 1;
12
16
  const LINT_CACHE_DIRNAME = 'lint-cache';
13
17
  const PER_PATCH_CACHE_FILENAME = 'per-patch-v1.json';
@@ -118,6 +122,8 @@ function isCacheEntry(value) {
118
122
  return (typeof entry.key === 'string' &&
119
123
  typeof entry.patchFilename === 'string' &&
120
124
  Array.isArray(entry.issues) &&
125
+ Array.isArray(entry.suppressed) &&
126
+ typeof entry.lineCount === 'number' &&
121
127
  typeof entry.updatedAt === 'string');
122
128
  }
123
129
  /** Loads the per-patch lint cache, treating missing or invalid files as empty. */
@@ -158,19 +164,25 @@ export async function savePerPatchLintCache(projectRoot, cache) {
158
164
  export async function clearPerPatchLintCache(projectRoot) {
159
165
  await writeJson(getPerPatchLintCachePath(projectRoot), createEmptyPerPatchLintCache());
160
166
  }
161
- /** Returns cached issues for a patch when the stored key still matches. */
167
+ /** Returns the cached lint payload for a patch when the stored key still matches. */
162
168
  export function getCachedPerPatchLintIssues(cache, patchFilename, key) {
163
169
  const entry = cache.entries[patchFilename];
164
170
  if (!entry || entry.key !== key)
165
171
  return undefined;
166
- return entry.issues.map((issue) => ({ ...issue }));
172
+ return {
173
+ issues: entry.issues.map((issue) => ({ ...issue })),
174
+ suppressed: entry.suppressed.map((issue) => ({ ...issue })),
175
+ lineCount: entry.lineCount,
176
+ };
167
177
  }
168
- /** Stores per-patch lint issues after a successful lint calculation. */
169
- export function setCachedPerPatchLintIssues(cache, patchFilename, key, issues) {
178
+ /** Stores the per-patch lint payload after a successful lint calculation. */
179
+ export function setCachedPerPatchLintIssues(cache, patchFilename, key, issues, suppressed, lineCount) {
170
180
  cache.entries[patchFilename] = {
171
181
  key,
172
182
  patchFilename,
173
183
  issues: issues.map((issue) => ({ ...issue })),
184
+ suppressed: suppressed.map((issue) => ({ ...issue })),
185
+ lineCount,
174
186
  updatedAt: new Date().toISOString(),
175
187
  };
176
188
  }
@@ -1,8 +1,8 @@
1
1
  import type { PatchCategory, PatchesManifest, PatchInfo, PatchMetadata } from '../types/commands/index.js';
2
2
  import type { FireForgeConfig } from '../types/config.js';
3
3
  import { type SupersedeCoverageDetail } from './patch-export-coverage.js';
4
- export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, type PatchCoverage, type SupersedeCoverageDetail, } from './patch-export-coverage.js';
5
- export { type ClearablePatchMetadataField, mutatePatchMetadata, type PatchMetadataMutation, type PatchMetadataMutationResult, updatePatchMetadata, } from './patch-export-metadata.js';
4
+ export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, type SupersedeCoverageDetail, } from './patch-export-coverage.js';
5
+ export { mutatePatchMetadata, updatePatchMetadata } from './patch-export-metadata.js';
6
6
  export { updatePatchAndMetadata } from './patch-export-update.js';
7
7
  /**
8
8
  * Gets the next patch number for a new patch.
@@ -30,7 +30,16 @@ export declare function sanitizeName(name: string): string;
30
30
  * double-prefix incident) also collapses. Exported for direct testing.
31
31
  */
32
32
  export declare function stripRedundantCategoryPrefix(sanitizedName: string, category: string): string;
33
- /** Sanitizes a patch name and drops redundant category/full-stem prefixes. */
33
+ /**
34
+ * Sanitizes a patch name and drops redundant category/full-stem prefixes.
35
+ *
36
+ * A single trailing `.patch` extension is stripped before sanitisation:
37
+ * operators frequently pass a full patch FILENAME where a name is expected
38
+ * (`move-files <from> 348-ui-foo.patch --create`, `rename --to foo.patch`),
39
+ * and 0.38.0 slugged the extension into `-patch`, producing double-suffixed
40
+ * `...-patch.patch` files. A name that deliberately ends in a literal
41
+ * `-patch` slug segment must now be written without the dot form.
42
+ */
34
43
  export declare function patchNameSlug(name: string, category: string): string;
35
44
  /**
36
45
  * Generates the next patch filename with category.
@@ -10,7 +10,7 @@ import { findAllPatchesForFilesWithDetails, } from './patch-export-coverage.js';
10
10
  import { addPatchToManifest, loadPatchesManifestForWrite, PATCHES_MANIFEST, savePatchesManifest, } from './patch-manifest.js';
11
11
  import { allocatePolicyOrder, enforcePatchPolicy } from './patch-policy.js';
12
12
  export { findAllPatchesForFiles, findAllPatchesForFilesWithDetails, findSupersededPatches, isPatchFullyCovered, } from './patch-export-coverage.js';
13
- export { mutatePatchMetadata, updatePatchMetadata, } from './patch-export-metadata.js';
13
+ export { mutatePatchMetadata, updatePatchMetadata } from './patch-export-metadata.js';
14
14
  export { updatePatchAndMetadata } from './patch-export-update.js';
15
15
  /**
16
16
  * Gets the next patch number for a new patch.
@@ -75,9 +75,19 @@ export function stripRedundantCategoryPrefix(sanitizedName, category) {
75
75
  }
76
76
  return stripped.length > 0 ? stripped : sanitizedName;
77
77
  }
78
- /** Sanitizes a patch name and drops redundant category/full-stem prefixes. */
78
+ /**
79
+ * Sanitizes a patch name and drops redundant category/full-stem prefixes.
80
+ *
81
+ * A single trailing `.patch` extension is stripped before sanitisation:
82
+ * operators frequently pass a full patch FILENAME where a name is expected
83
+ * (`move-files <from> 348-ui-foo.patch --create`, `rename --to foo.patch`),
84
+ * and 0.38.0 slugged the extension into `-patch`, producing double-suffixed
85
+ * `...-patch.patch` files. A name that deliberately ends in a literal
86
+ * `-patch` slug segment must now be written without the dot form.
87
+ */
79
88
  export function patchNameSlug(name, category) {
80
- return stripRedundantCategoryPrefix(sanitizeName(name), category);
89
+ const stem = name.replace(/\.patch$/i, '');
90
+ return stripRedundantCategoryPrefix(sanitizeName(stem), category);
81
91
  }
82
92
  /**
83
93
  * Generates the next patch filename with category.
@@ -25,11 +25,16 @@
25
25
  * while reporting only the patch under export.
26
26
  */
27
27
  import type { PatchLintIssue } from '../types/commands/index.js';
28
- import type { PatchLintCheckJsCompilerOptions, PatchLintConfig } from '../types/config.js';
28
+ import type { PatchLintCheckJsCompilerOptions, PatchLintConfig, PatchLintSeverityGate } from '../types/config.js';
29
29
  /** How a checkJs run controls reporting and resolution; see module docs. */
30
30
  export interface CheckJsMode {
31
31
  strict: boolean;
32
32
  compilerOptions?: PatchLintCheckJsCompilerOptions;
33
+ /**
34
+ * How to report undefined free identifiers (TS2304/TS2552). Default
35
+ * 'warning' — see `patchLint.undefinedIdentifiers` (FORGE F12).
36
+ */
37
+ undefinedIdentifiers?: PatchLintSeverityGate;
33
38
  }
34
39
  /**
35
40
  * Result of {@link runCheckJsGrouped}: diagnostics attributed to the
@@ -58,7 +63,7 @@ export interface GroupedCheckJsResult {
58
63
  * @param mode - Strictness preset plus allowlisted compiler-option overrides
59
64
  * @returns Diagnostics grouped per owning file plus run-level errors
60
65
  */
61
- export declare function runCheckJsGrouped(repoDir: string, resolutionOwned: Set<string>, extraShimPath?: string, projectRoot?: string, mode?: CheckJsMode): Promise<GroupedCheckJsResult>;
66
+ export declare function runCheckJsGrouped(repoDir: string, resolutionOwned: Set<string>, extraShimPath?: string, projectRoot?: string, mode?: CheckJsMode, builtinShimSuffix?: string): Promise<GroupedCheckJsResult>;
62
67
  /**
63
68
  * Flattens a {@link runCheckJsGrouped} run into a single issue list. When
64
69
  * `reportScope` is supplied, only diagnostics from files in that set are
@@ -88,6 +93,24 @@ export declare function runCheckJs(repoDir: string, patchOwnedFiles: Set<string>
88
93
  * not surface other patches' diagnostics)
89
94
  */
90
95
  export declare function invokePatchLintCheckJs(repoDir: string, patchOwnedFiles: Set<string>, patchLint: PatchLintConfig, projectRoot: string, reportScope?: ReadonlySet<string>): Promise<PatchLintIssue[]>;
96
+ /**
97
+ * Runs the checkJs pass over patch-owned test `.js` files (FORGE G5,
98
+ * `patchLint.checkJsTestFiles`). Each test file gets its OWN small
99
+ * program — mochitest scripts share top-level scope only with their
100
+ * directory's `head*.js` helpers, so one multi-script program would emit
101
+ * false cross-file redeclaration errors — with roots = the test file plus
102
+ * any same-directory patch-owned `head*.js`, checked against the built-in
103
+ * Firefox shim + {@link TEST_HARNESS_SHIM} + the optional consumer
104
+ * `checkJsTestShim`. Only the target file's diagnostics are reported per
105
+ * program (head helpers report from their own program), and run-level
106
+ * errors are de-duplicated across programs.
107
+ *
108
+ * @param repoDir - Absolute engine (repository) directory
109
+ * @param testFiles - Patch-owned test-script paths (repo-relative)
110
+ * @param patchLint - The resolved `patchLint` config block
111
+ * @param projectRoot - FireForge project root for shim resolution
112
+ */
113
+ export declare function runCheckJsTestFilesGrouped(repoDir: string, testFiles: Set<string>, patchLint: PatchLintConfig, projectRoot: string): Promise<GroupedCheckJsResult>;
91
114
  /**
92
115
  * Grouped variant of {@link invokePatchLintCheckJs}: builds one queue-wide
93
116
  * checkJs program over `patchOwnedFiles` and returns its findings grouped by