@hominis/fireforge 0.18.9 → 0.18.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/dist/src/commands/doctor.js +1 -1
- package/dist/src/commands/furnace/index.js +1 -1
- package/dist/src/commands/lint.d.ts +36 -0
- package/dist/src/commands/lint.js +61 -1
- package/dist/src/commands/manifest.js +2 -0
- package/dist/src/commands/package.js +1 -1
- package/dist/src/commands/patch/index.d.ts +5 -3
- package/dist/src/commands/patch/index.js +8 -4
- package/dist/src/commands/patch/lint-ignore.d.ts +8 -0
- package/dist/src/commands/patch/lint-ignore.js +8 -4
- package/dist/src/commands/patch/rename.d.ts +36 -0
- package/dist/src/commands/patch/rename.js +244 -0
- package/dist/src/commands/test.js +8 -8
- package/dist/src/commands/typecheck.d.ts +52 -0
- package/dist/src/commands/typecheck.js +115 -0
- package/dist/src/core/config-paths.d.ts +2 -2
- package/dist/src/core/config-paths.js +5 -0
- package/dist/src/core/config-validate.js +64 -0
- package/dist/src/core/license-headers.d.ts +5 -0
- package/dist/src/core/license-headers.js +46 -5
- package/dist/src/core/mach-build-artifacts.d.ts +2 -2
- package/dist/src/core/mach-build-artifacts.js +2 -2
- package/dist/src/core/mach-error-hints.js +7 -8
- package/dist/src/core/marionette-port.js +4 -4
- package/dist/src/core/patch-export.d.ts +10 -0
- package/dist/src/core/patch-export.js +8 -2
- package/dist/src/core/patch-lint-checkjs.d.ts +14 -2
- package/dist/src/core/patch-lint-checkjs.js +40 -73
- package/dist/src/core/patch-lint-cross.js +6 -1
- package/dist/src/core/patch-lint.js +6 -4
- package/dist/src/core/typecheck-shim.d.ts +70 -0
- package/dist/src/core/typecheck-shim.js +112 -0
- package/dist/src/core/typecheck.d.ts +65 -0
- package/dist/src/core/typecheck.js +302 -0
- package/dist/src/core/xpcshell-appdir.d.ts +2 -2
- package/dist/src/core/xpcshell-appdir.js +2 -2
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +29 -1
- package/dist/src/types/config.d.ts +33 -0
- package/dist/src/types/furnace.d.ts +1 -1
- package/dist/src/types/typecheck.d.ts +51 -0
- package/dist/src/types/typecheck.js +15 -0
- package/package.json +1 -1
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge patch rename <name>` — relabels a patch's filename, manifest
|
|
4
|
+
* `name`, and (optionally) `description` without rewriting the `.patch`
|
|
5
|
+
* file body.
|
|
6
|
+
*
|
|
7
|
+
* Companion to `re-export --files <subset>`. Re-export shrinks the body
|
|
8
|
+
* + `filesAffected`, but leaves the patch's identity describing the
|
|
9
|
+
* pre-shrink scope. Before this verb existed, the only workaround for
|
|
10
|
+
* that drift was `delete` + re-export, which briefly removed the patch
|
|
11
|
+
* from the queue (any forward-import dependent would refuse the
|
|
12
|
+
* re-export until the deleted patch's siblings were rewritten).
|
|
13
|
+
*
|
|
14
|
+
* The filename rename and the manifest mutation happen under the patch
|
|
15
|
+
* directory lock so concurrent exports cannot allocate the new
|
|
16
|
+
* filename, and a filesystem rename failure rolls back before the
|
|
17
|
+
* manifest is touched.
|
|
18
|
+
*/
|
|
19
|
+
import { rename as fsRename } from 'node:fs/promises';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { getProjectPaths } from '../../core/config.js';
|
|
22
|
+
import { appendHistory, confirmDestructive } from '../../core/destructive.js';
|
|
23
|
+
import { sanitizeName } from '../../core/patch-export.js';
|
|
24
|
+
import { formatPatchNotFoundError } from '../../core/patch-identifier-suggest.js';
|
|
25
|
+
import { withPatchDirectoryLock } from '../../core/patch-lock.js';
|
|
26
|
+
import { loadPatchesManifest, resolvePatchIdentifier, savePatchesManifest, } from '../../core/patch-manifest.js';
|
|
27
|
+
import { GeneralError, InvalidArgumentError } from '../../errors/base.js';
|
|
28
|
+
import { toError } from '../../utils/errors.js';
|
|
29
|
+
import { pathExists } from '../../utils/fs.js';
|
|
30
|
+
import { info, intro, outro, warn } from '../../utils/logger.js';
|
|
31
|
+
import { pickDefined } from '../../utils/options.js';
|
|
32
|
+
/**
|
|
33
|
+
* Pulls the ordinal-string + category prefix out of a patch filename so
|
|
34
|
+
* the rename keeps the existing ordinal padding verbatim. Returning the
|
|
35
|
+
* literal substring (rather than recomputing from the parsed integer)
|
|
36
|
+
* avoids any chance of the new filename's ordinal differing from the
|
|
37
|
+
* old by a leading-zero count.
|
|
38
|
+
*/
|
|
39
|
+
function splitPatchFilename(filename) {
|
|
40
|
+
const m = /^(\d+)-([a-z]+)-(.+)\.patch$/.exec(filename);
|
|
41
|
+
if (!m?.[1] || !m[2] || !m[3])
|
|
42
|
+
return null;
|
|
43
|
+
return { ordinalStr: m[1], category: m[2], slug: m[3] };
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Performs the rename's transactional core under the patch directory
|
|
47
|
+
* lock: re-reads the manifest, re-checks for filename collisions,
|
|
48
|
+
* renames the `.patch` file on disk (when applicable), writes the
|
|
49
|
+
* updated manifest, and appends a history entry. Filesystem rename
|
|
50
|
+
* happens before the manifest save so an interrupted run never leaves
|
|
51
|
+
* the manifest pointing at a missing file; a manifest-save failure
|
|
52
|
+
* rolls the filesystem rename back.
|
|
53
|
+
*/
|
|
54
|
+
async function commitRenameUnderLock(input) {
|
|
55
|
+
const { patchesDir, target, newFilename, newName, newDescription, filenameChanging, nameChanging, descriptionChanging, } = input;
|
|
56
|
+
await withPatchDirectoryLock(patchesDir, async () => {
|
|
57
|
+
const fresh = await loadPatchesManifest(patchesDir);
|
|
58
|
+
if (!fresh) {
|
|
59
|
+
throw new GeneralError('Manifest disappeared between resolution and rename.');
|
|
60
|
+
}
|
|
61
|
+
const idx = fresh.patches.findIndex((p) => p.filename === target.filename);
|
|
62
|
+
if (idx === -1) {
|
|
63
|
+
throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during rename. Re-run after investigating.`);
|
|
64
|
+
}
|
|
65
|
+
const before = fresh.patches[idx];
|
|
66
|
+
if (!before) {
|
|
67
|
+
throw new GeneralError(`Patch ${target.filename} disappeared from the manifest during rename.`);
|
|
68
|
+
}
|
|
69
|
+
if (filenameChanging) {
|
|
70
|
+
const collisionInLock = fresh.patches.find((p) => p.filename === newFilename && p.filename !== target.filename);
|
|
71
|
+
if (collisionInLock) {
|
|
72
|
+
throw new InvalidArgumentError(`Cannot rename to "${newFilename}" — a different patch claimed that filename concurrently.`, 'patch rename');
|
|
73
|
+
}
|
|
74
|
+
const oldPath = join(patchesDir, target.filename);
|
|
75
|
+
const newPath = join(patchesDir, newFilename);
|
|
76
|
+
if (await pathExists(newPath)) {
|
|
77
|
+
throw new InvalidArgumentError(`Cannot rename: ${newFilename} already exists on disk. Resolve manually before retrying.`, 'patch rename');
|
|
78
|
+
}
|
|
79
|
+
await fsRename(oldPath, newPath);
|
|
80
|
+
fresh.patches[idx] = {
|
|
81
|
+
...before,
|
|
82
|
+
filename: newFilename,
|
|
83
|
+
name: newName,
|
|
84
|
+
...(descriptionChanging ? { description: newDescription ?? '' } : {}),
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
await savePatchesManifest(patchesDir, fresh);
|
|
88
|
+
}
|
|
89
|
+
catch (saveError) {
|
|
90
|
+
try {
|
|
91
|
+
await fsRename(newPath, oldPath);
|
|
92
|
+
}
|
|
93
|
+
catch (rollbackError) {
|
|
94
|
+
warn(`Rollback warning: could not restore ${target.filename} after manifest write failure: ${toError(rollbackError).message}`);
|
|
95
|
+
}
|
|
96
|
+
throw saveError;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
fresh.patches[idx] = {
|
|
101
|
+
...before,
|
|
102
|
+
...(nameChanging ? { name: newName } : {}),
|
|
103
|
+
...(descriptionChanging ? { description: newDescription ?? '' } : {}),
|
|
104
|
+
};
|
|
105
|
+
await savePatchesManifest(patchesDir, fresh);
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await appendHistory(patchesDir, {
|
|
109
|
+
operation: 'patch-rename',
|
|
110
|
+
args: {
|
|
111
|
+
oldFilename: target.filename,
|
|
112
|
+
newFilename,
|
|
113
|
+
oldName: target.name,
|
|
114
|
+
newName,
|
|
115
|
+
...(descriptionChanging ? { oldDescription: target.description, newDescription } : {}),
|
|
116
|
+
},
|
|
117
|
+
...(input.yes === true ? { yes: true } : {}),
|
|
118
|
+
result: 'ok',
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
catch (historyError) {
|
|
122
|
+
warn(`History log append failed after patch rename committed (${newFilename}): ${toError(historyError).message}`);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Runs the `patch rename` command: relabels filename + manifest entry
|
|
128
|
+
* for a single patch atomically.
|
|
129
|
+
*
|
|
130
|
+
* @param projectRoot - Project root directory
|
|
131
|
+
* @param identifier - Patch filename, ordinal, or manifest `name`
|
|
132
|
+
* @param options - Command options (`--to <new-name>` is required)
|
|
133
|
+
*/
|
|
134
|
+
export async function patchRenameCommand(projectRoot, identifier, options = {}) {
|
|
135
|
+
const isDryRun = options.dryRun === true;
|
|
136
|
+
intro(isDryRun ? 'FireForge patch rename (dry run)' : 'FireForge patch rename');
|
|
137
|
+
if (options.to === undefined || options.to.trim() === '') {
|
|
138
|
+
throw new InvalidArgumentError('Specify --to <new-name>. The new name is sanitised into the filename slug the same way `export --name` is.', 'patch rename');
|
|
139
|
+
}
|
|
140
|
+
const paths = getProjectPaths(projectRoot);
|
|
141
|
+
if (!(await pathExists(paths.patches))) {
|
|
142
|
+
throw new GeneralError('Patches directory not found.');
|
|
143
|
+
}
|
|
144
|
+
const manifest = await loadPatchesManifest(paths.patches);
|
|
145
|
+
if (!manifest || manifest.patches.length === 0) {
|
|
146
|
+
throw new GeneralError('No patches in manifest.');
|
|
147
|
+
}
|
|
148
|
+
const target = resolvePatchIdentifier(identifier, manifest.patches);
|
|
149
|
+
if (!target) {
|
|
150
|
+
throw new InvalidArgumentError(formatPatchNotFoundError(identifier, manifest.patches), identifier);
|
|
151
|
+
}
|
|
152
|
+
const split = splitPatchFilename(target.filename);
|
|
153
|
+
if (!split) {
|
|
154
|
+
throw new GeneralError(`Cannot rename ${target.filename}: filename does not match the expected {ordinal}-{category}-{slug}.patch convention. Re-export the patch instead.`);
|
|
155
|
+
}
|
|
156
|
+
const newSlug = sanitizeName(options.to);
|
|
157
|
+
if (newSlug === '') {
|
|
158
|
+
throw new InvalidArgumentError('--to must contain at least one alphanumeric character after sanitisation.', 'patch rename');
|
|
159
|
+
}
|
|
160
|
+
const newFilename = `${split.ordinalStr}-${split.category}-${newSlug}.patch`;
|
|
161
|
+
const filenameChanging = newFilename !== target.filename;
|
|
162
|
+
const nameChanging = options.to !== target.name;
|
|
163
|
+
const descriptionChanging = options.description !== undefined && options.description !== target.description;
|
|
164
|
+
if (!filenameChanging && !nameChanging && !descriptionChanging) {
|
|
165
|
+
info(`${target.filename}: name and description already match — nothing to change.`);
|
|
166
|
+
outro(isDryRun ? 'Dry run complete — no changes made' : 'Patch rename (no-op)');
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
// Pre-flight collision check against the manifest snapshot we already
|
|
170
|
+
// loaded. The authoritative check happens again inside the lock to
|
|
171
|
+
// close the TOCTOU window — surface a helpful error here when the
|
|
172
|
+
// collision is obvious so the operator does not get surprised by a
|
|
173
|
+
// late refusal after a confirmation prompt.
|
|
174
|
+
if (filenameChanging) {
|
|
175
|
+
const collision = manifest.patches.find((p) => p.filename === newFilename && p.filename !== target.filename);
|
|
176
|
+
if (collision) {
|
|
177
|
+
throw new InvalidArgumentError(`Cannot rename to "${newFilename}" — a different patch already uses that filename.`, 'patch rename');
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const summary = [];
|
|
181
|
+
if (filenameChanging) {
|
|
182
|
+
summary.push(`rename ${target.filename} → ${newFilename}`);
|
|
183
|
+
}
|
|
184
|
+
if (nameChanging) {
|
|
185
|
+
summary.push(`name: "${target.name}" → "${options.to}"`);
|
|
186
|
+
}
|
|
187
|
+
if (descriptionChanging) {
|
|
188
|
+
summary.push(`description: "${target.description || '(none)'}" → "${options.description ?? '(none)'}"`);
|
|
189
|
+
}
|
|
190
|
+
const decision = await confirmDestructive({
|
|
191
|
+
operation: 'patch-rename',
|
|
192
|
+
title: `Rename ${target.filename}`,
|
|
193
|
+
summary,
|
|
194
|
+
yes: options.yes === true,
|
|
195
|
+
dryRun: isDryRun,
|
|
196
|
+
conflicts: null,
|
|
197
|
+
});
|
|
198
|
+
if (decision === 'dry-run') {
|
|
199
|
+
outro('Dry run complete — no changes made');
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
if (decision === 'cancelled') {
|
|
203
|
+
outro('Rename cancelled');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
await commitRenameUnderLock({
|
|
207
|
+
patchesDir: paths.patches,
|
|
208
|
+
target,
|
|
209
|
+
newFilename,
|
|
210
|
+
newName: options.to,
|
|
211
|
+
...(options.description !== undefined ? { newDescription: options.description } : {}),
|
|
212
|
+
filenameChanging,
|
|
213
|
+
nameChanging,
|
|
214
|
+
descriptionChanging,
|
|
215
|
+
...(options.yes === true ? { yes: true } : {}),
|
|
216
|
+
});
|
|
217
|
+
if (filenameChanging) {
|
|
218
|
+
info(`${target.filename} → ${newFilename}`);
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
info(`${target.filename}: metadata updated.`);
|
|
222
|
+
}
|
|
223
|
+
outro('Patch rename complete');
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Registers the `patch rename` subcommand on the `patch` parent.
|
|
227
|
+
*
|
|
228
|
+
* @param parent - Parent Commander command
|
|
229
|
+
* @param context - Shared CLI registration context
|
|
230
|
+
*/
|
|
231
|
+
export function registerPatchRename(parent, context) {
|
|
232
|
+
const { getProjectRoot, withErrorHandling } = context;
|
|
233
|
+
parent
|
|
234
|
+
.command('rename <name>')
|
|
235
|
+
.description('Rename a patch: filename + manifest name (and optional description) update without rewriting the .patch body.')
|
|
236
|
+
.requiredOption('--to <new-name>', 'New human-readable name (sanitised into the filename slug)')
|
|
237
|
+
.option('--description <text>', 'Replacement description (omit to leave description unchanged)')
|
|
238
|
+
.option('--dry-run', 'Show what would change without writing')
|
|
239
|
+
.option('-y, --yes', 'Skip confirmation prompt (required for non-TTY)')
|
|
240
|
+
.action(withErrorHandling(async (name, options) => {
|
|
241
|
+
await patchRenameCommand(getProjectRoot(), name, pickDefined(options));
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
//# sourceMappingURL=rename.js.map
|
|
@@ -41,7 +41,7 @@ function hasStaleBuildArtifactsSignal(output) {
|
|
|
41
41
|
// that are always a stale-artifact symptom. The earlier pattern also
|
|
42
42
|
// matched `resource:///modules/distribution.sys.mjs`, which surfaced on
|
|
43
43
|
// real packaging / module-resolution failures too (e.g. a fork's
|
|
44
|
-
// `
|
|
44
|
+
// `MyBrowserStore.sys.mjs` missing from the installed app dir after a
|
|
45
45
|
// successful build). That false-positive pushed operators toward
|
|
46
46
|
// "rebuild" advice for what was actually a module-registration issue.
|
|
47
47
|
return (/chrome:\/\/branding\/locale\/brand\.properties/i.test(output) ||
|
|
@@ -49,8 +49,8 @@ function hasStaleBuildArtifactsSignal(output) {
|
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
51
|
* Fork-module-not-registered signal. 2026-04-21 eval Finding #14:
|
|
52
|
-
* a
|
|
53
|
-
*
|
|
52
|
+
* a fork's test failed with `Failed to load resource:///modules/mybrowser/
|
|
53
|
+
* MyBrowserStore.sys.mjs`. The branding pattern happened to also match
|
|
54
54
|
* because the test harness printed a branding warning during its
|
|
55
55
|
* teardown, and the stale-build branch won by precedence — telling the
|
|
56
56
|
* operator to rebuild when the real fix is to register the module in
|
|
@@ -126,8 +126,8 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
126
126
|
throw new GeneralError(buildUnknownTestMessage(normalizedPaths));
|
|
127
127
|
}
|
|
128
128
|
// Fork-owned module load failures must beat the branding stale-build
|
|
129
|
-
// branch: 2026-04-21 eval (Finding #14) saw a
|
|
130
|
-
// `Failed to load resource:///modules/
|
|
129
|
+
// branch: 2026-04-21 eval (Finding #14) saw a fork's test fail with
|
|
130
|
+
// `Failed to load resource:///modules/mybrowser/MyBrowserStore.sys.mjs`
|
|
131
131
|
// while the harness teardown printed a branding warning that the old
|
|
132
132
|
// stale-build pattern matched, so the operator was told to rebuild
|
|
133
133
|
// when the real fix is to register the missing module.
|
|
@@ -140,7 +140,7 @@ function handleNonZeroTestExit(result, normalizedPaths, appdirInjectionAttempted
|
|
|
140
140
|
// But the stale-build check is now narrower — it no longer matches
|
|
141
141
|
// `resource:///modules/distribution.sys.mjs` alone, which was producing
|
|
142
142
|
// false-positive rebuild advice on fork-custom module-load failures
|
|
143
|
-
// (the eval saw this for `
|
|
143
|
+
// (the eval saw this for `MyBrowserStore.sys.mjs`). Cases that once
|
|
144
144
|
// landed on `distribution.sys.mjs` fall through to xpcshell-appdir,
|
|
145
145
|
// which is the more useful diagnosis in practice for `Failed to load
|
|
146
146
|
// resource:///modules/…`.
|
|
@@ -253,13 +253,13 @@ export async function testCommand(projectRoot, testPaths, options = {}) {
|
|
|
253
253
|
: undefined;
|
|
254
254
|
const effectivePort = options.marionettePort ?? forwardedPort;
|
|
255
255
|
// Stale-browser probe: an interrupted earlier test run can leave a
|
|
256
|
-
// Firefox/ForgeFresh/
|
|
256
|
+
// Firefox/ForgeFresh/fork instance listening on the Marionette
|
|
257
257
|
// control port, which breaks the next mach test launch with a
|
|
258
258
|
// bind error that points nowhere near the real cause. Raise a
|
|
259
259
|
// targeted refusal up front instead of letting mach surface the
|
|
260
260
|
// generic bind failure. 2026-04-21 eval (Finding #20): a stale
|
|
261
261
|
// `-marionette` process from `fresh/` poisoned a later test run in
|
|
262
|
-
// the sibling `
|
|
262
|
+
// the sibling `mybrowser/` workspace.
|
|
263
263
|
await assertMarionettePortAvailable(effectivePort, { binaryName: projectConfig.binaryName });
|
|
264
264
|
// `--doctor` runs a short marionette handshake probe. When test paths are
|
|
265
265
|
// supplied the probe gates the mach test invocation (a FAIL bails out). When
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `fireforge typecheck` — whole-project TypeScript type checking
|
|
3
|
+
* driven by user-supplied jsconfig.json paths.
|
|
4
|
+
*
|
|
5
|
+
* Distinct from `patchLint.checkJs`: that pass is patch-hygiene
|
|
6
|
+
* (scoped to patch-owned `.sys.mjs`, run automatically by
|
|
7
|
+
* `fireforge lint`); this command is CI-grade — it runs whole
|
|
8
|
+
* projects with the user's own compiler options and is intended as
|
|
9
|
+
* a CI gate. The two share their Firefox-globals shim and the same
|
|
10
|
+
* suppressed-diagnostic set so a file that lints clean cannot fail
|
|
11
|
+
* typecheck for a reason the operator could not have inferred from
|
|
12
|
+
* the docs.
|
|
13
|
+
*
|
|
14
|
+
* Exits non-zero on any error-severity diagnostic. Warnings print
|
|
15
|
+
* but do not fail. Designed for CI use.
|
|
16
|
+
*/
|
|
17
|
+
import { Command } from 'commander';
|
|
18
|
+
import type { CommandContext } from '../types/cli.js';
|
|
19
|
+
import type { TypecheckConfig } from '../types/config.js';
|
|
20
|
+
import type { TypecheckProjectResult } from '../types/typecheck.js';
|
|
21
|
+
/** Command-line options Commander forwards from `fireforge typecheck`. */
|
|
22
|
+
export interface TypecheckCommandOptions {
|
|
23
|
+
/**
|
|
24
|
+
* Override `typecheck.projects` with a single jsconfig.json path
|
|
25
|
+
* for one-off verification. Replaces (does not augment) the config
|
|
26
|
+
* — useful to re-run a single project after fixing one of its
|
|
27
|
+
* issues without waiting for the full set.
|
|
28
|
+
*/
|
|
29
|
+
project?: string;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Resolves the project list to type-check. `--project` wins over
|
|
33
|
+
* config; if neither is set, throws a clear error pointing at both
|
|
34
|
+
* paths to a fix (add the config field or pass --project).
|
|
35
|
+
*/
|
|
36
|
+
export declare function resolveTypecheckProjects(configTypecheck: TypecheckConfig | undefined, override: string | undefined): TypecheckConfig;
|
|
37
|
+
/**
|
|
38
|
+
* Top-level entry point invoked by the registered Commander action.
|
|
39
|
+
* Loads config, resolves projects, runs typecheck, prints the result,
|
|
40
|
+
* and throws `GeneralError` to set a non-zero exit on errors.
|
|
41
|
+
*/
|
|
42
|
+
export declare function typecheckCommand(projectRoot: string, options: TypecheckCommandOptions): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Prints all issues, computes the per-project + total counts, and
|
|
45
|
+
* throws on errors. Extracted so it can be exercised directly by
|
|
46
|
+
* the CLI test without spawning a child process.
|
|
47
|
+
*/
|
|
48
|
+
export declare function reportResults(projectRoot: string, results: ReadonlyArray<TypecheckProjectResult>): void;
|
|
49
|
+
/**
|
|
50
|
+
* Registers the `typecheck` command on the CLI program.
|
|
51
|
+
*/
|
|
52
|
+
export declare function registerTypecheck(program: Command, { getProjectRoot, withErrorHandling }: CommandContext): void;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `fireforge typecheck` — whole-project TypeScript type checking
|
|
4
|
+
* driven by user-supplied jsconfig.json paths.
|
|
5
|
+
*
|
|
6
|
+
* Distinct from `patchLint.checkJs`: that pass is patch-hygiene
|
|
7
|
+
* (scoped to patch-owned `.sys.mjs`, run automatically by
|
|
8
|
+
* `fireforge lint`); this command is CI-grade — it runs whole
|
|
9
|
+
* projects with the user's own compiler options and is intended as
|
|
10
|
+
* a CI gate. The two share their Firefox-globals shim and the same
|
|
11
|
+
* suppressed-diagnostic set so a file that lints clean cannot fail
|
|
12
|
+
* typecheck for a reason the operator could not have inferred from
|
|
13
|
+
* the docs.
|
|
14
|
+
*
|
|
15
|
+
* Exits non-zero on any error-severity diagnostic. Warnings print
|
|
16
|
+
* but do not fail. Designed for CI use.
|
|
17
|
+
*/
|
|
18
|
+
import { getProjectPaths, loadConfig } from '../core/config.js';
|
|
19
|
+
import { relativeForDisplay, runTypecheck } from '../core/typecheck.js';
|
|
20
|
+
import { GeneralError } from '../errors/base.js';
|
|
21
|
+
import { info, intro, outro, success, warn } from '../utils/logger.js';
|
|
22
|
+
/**
|
|
23
|
+
* Resolves the project list to type-check. `--project` wins over
|
|
24
|
+
* config; if neither is set, throws a clear error pointing at both
|
|
25
|
+
* paths to a fix (add the config field or pass --project).
|
|
26
|
+
*/
|
|
27
|
+
export function resolveTypecheckProjects(configTypecheck, override) {
|
|
28
|
+
if (override !== undefined) {
|
|
29
|
+
if (override.trim() === '') {
|
|
30
|
+
throw new GeneralError('--project requires a non-empty path');
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
projects: [override],
|
|
34
|
+
...(configTypecheck?.extraShim !== undefined ? { extraShim: configTypecheck.extraShim } : {}),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (!configTypecheck) {
|
|
38
|
+
throw new GeneralError('No typecheck configuration found. Add a "typecheck": { "projects": [...] } block to ' +
|
|
39
|
+
'fireforge.json, or pass --project <path> for a one-off run.');
|
|
40
|
+
}
|
|
41
|
+
return configTypecheck;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Formats a single issue for CLI display. `[<project>] <file>:<line>:<col> TS<code> <message>`
|
|
45
|
+
* matches the format `tsc -p` produces with `--pretty false`, so output
|
|
46
|
+
* piped into editor jump-lists works without per-tool tweaks.
|
|
47
|
+
*/
|
|
48
|
+
function formatIssue(projectRoot, issue) {
|
|
49
|
+
const file = relativeForDisplay(projectRoot, issue.file);
|
|
50
|
+
const codeLabel = issue.code > 0 ? ` TS${String(issue.code)}` : '';
|
|
51
|
+
return `[${issue.project}] ${file}:${String(issue.line)}:${String(issue.column)}${codeLabel} ${issue.message}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Top-level entry point invoked by the registered Commander action.
|
|
55
|
+
* Loads config, resolves projects, runs typecheck, prints the result,
|
|
56
|
+
* and throws `GeneralError` to set a non-zero exit on errors.
|
|
57
|
+
*/
|
|
58
|
+
export async function typecheckCommand(projectRoot, options) {
|
|
59
|
+
intro('FireForge typecheck');
|
|
60
|
+
// Validate project is initialised. `loadConfig` throws on missing
|
|
61
|
+
// fireforge.json — withErrorHandling at the CLI layer renders the
|
|
62
|
+
// resulting `ConfigNotFoundError` cleanly, so we don't need to
|
|
63
|
+
// re-wrap.
|
|
64
|
+
getProjectPaths(projectRoot);
|
|
65
|
+
const config = await loadConfig(projectRoot);
|
|
66
|
+
const cfg = resolveTypecheckProjects(config.typecheck, options.project);
|
|
67
|
+
info(`Running typecheck across ${String(cfg.projects.length)} project(s): ${cfg.projects.join(', ')}`);
|
|
68
|
+
const results = await runTypecheck(projectRoot, cfg);
|
|
69
|
+
reportResults(projectRoot, results);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Prints all issues, computes the per-project + total counts, and
|
|
73
|
+
* throws on errors. Extracted so it can be exercised directly by
|
|
74
|
+
* the CLI test without spawning a child process.
|
|
75
|
+
*/
|
|
76
|
+
export function reportResults(projectRoot, results) {
|
|
77
|
+
let totalErrors = 0;
|
|
78
|
+
let totalWarnings = 0;
|
|
79
|
+
for (const result of results) {
|
|
80
|
+
const errors = result.issues.filter((i) => i.category === 'error');
|
|
81
|
+
const warnings = result.issues.filter((i) => i.category === 'warning');
|
|
82
|
+
totalErrors += errors.length;
|
|
83
|
+
totalWarnings += warnings.length;
|
|
84
|
+
for (const issue of warnings)
|
|
85
|
+
warn(formatIssue(projectRoot, issue));
|
|
86
|
+
for (const issue of errors)
|
|
87
|
+
warn(formatIssue(projectRoot, issue));
|
|
88
|
+
}
|
|
89
|
+
const summary = `Typecheck: ${String(totalErrors)} error(s), ${String(totalWarnings)} warning(s) across ${String(results.length)} project(s)`;
|
|
90
|
+
if (totalErrors === 0) {
|
|
91
|
+
success(summary);
|
|
92
|
+
outro(totalWarnings > 0 ? 'Typecheck passed with warnings' : 'Typecheck passed');
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
info(summary);
|
|
96
|
+
outro('Typecheck failed');
|
|
97
|
+
throw new GeneralError(`Typecheck found ${String(totalErrors)} error(s) across ${String(results.length)} project(s).`);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Registers the `typecheck` command on the CLI program.
|
|
101
|
+
*/
|
|
102
|
+
export function registerTypecheck(program, { getProjectRoot, withErrorHandling }) {
|
|
103
|
+
program
|
|
104
|
+
.command('typecheck')
|
|
105
|
+
.description('Run TypeScript type checking against project-owned jsconfig.json files (CI-grade, whole-project)')
|
|
106
|
+
.option('--project <path>', 'Override typecheck.projects with a single jsconfig.json path (one-off run)')
|
|
107
|
+
.action(withErrorHandling(async (options) => {
|
|
108
|
+
const opts = {};
|
|
109
|
+
if (options.project !== undefined) {
|
|
110
|
+
opts.project = options.project;
|
|
111
|
+
}
|
|
112
|
+
await typecheckCommand(getProjectRoot(), opts);
|
|
113
|
+
}));
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=typecheck.js.map
|
|
@@ -17,9 +17,9 @@ export declare const CONFIGS_DIR = "configs";
|
|
|
17
17
|
/** Name of the source directory */
|
|
18
18
|
export declare const SRC_DIR = "src";
|
|
19
19
|
/** Supported top-level fireforge.json keys backed by the current schema. */
|
|
20
|
-
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "markerComment"];
|
|
20
|
+
export declare const SUPPORTED_CONFIG_ROOT_KEYS: readonly ["name", "vendor", "appId", "binaryName", "firefox", "build", "license", "wire", "patchLint", "typecheck", "markerComment"];
|
|
21
21
|
/** Supported config paths that can be read or set without --force. */
|
|
22
|
-
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "markerComment"];
|
|
22
|
+
export declare const SUPPORTED_CONFIG_PATHS: readonly ["name", "vendor", "appId", "binaryName", "license", "firefox", "firefox.version", "firefox.product", "build", "build.jobs", "wire", "wire.subscriptDir", "patchLint", "patchLint.checkJs", "patchLint.checkJsExtraShim", "patchLint.rawColorAllowlist", "patchLint.jsdocClassMethods", "patchLint.testAssertionFloor", "patchLint.chromeScriptJsDoc", "typecheck", "typecheck.projects", "typecheck.extraShim", "markerComment"];
|
|
23
23
|
/**
|
|
24
24
|
* Gets all project paths based on a root directory.
|
|
25
25
|
* @param root - Root directory of the project
|
|
@@ -28,6 +28,7 @@ export const SUPPORTED_CONFIG_ROOT_KEYS = [
|
|
|
28
28
|
'license',
|
|
29
29
|
'wire',
|
|
30
30
|
'patchLint',
|
|
31
|
+
'typecheck',
|
|
31
32
|
'markerComment',
|
|
32
33
|
];
|
|
33
34
|
/** Supported config paths that can be read or set without --force. */
|
|
@@ -46,10 +47,14 @@ export const SUPPORTED_CONFIG_PATHS = [
|
|
|
46
47
|
'wire.subscriptDir',
|
|
47
48
|
'patchLint',
|
|
48
49
|
'patchLint.checkJs',
|
|
50
|
+
'patchLint.checkJsExtraShim',
|
|
49
51
|
'patchLint.rawColorAllowlist',
|
|
50
52
|
'patchLint.jsdocClassMethods',
|
|
51
53
|
'patchLint.testAssertionFloor',
|
|
52
54
|
'patchLint.chromeScriptJsDoc',
|
|
55
|
+
'typecheck',
|
|
56
|
+
'typecheck.projects',
|
|
57
|
+
'typecheck.extraShim',
|
|
53
58
|
'markerComment',
|
|
54
59
|
];
|
|
55
60
|
/**
|
|
@@ -131,6 +131,11 @@ export function validateConfig(data) {
|
|
|
131
131
|
if (patchLintRec) {
|
|
132
132
|
config.patchLint = parsePatchLintBlock(patchLintRec);
|
|
133
133
|
}
|
|
134
|
+
// Typecheck (top-level, distinct from patchLint — see TypecheckConfig docs).
|
|
135
|
+
const typecheckRec = optionalConfigObject(rec, 'typecheck');
|
|
136
|
+
if (typecheckRec) {
|
|
137
|
+
config.typecheck = parseTypecheckBlock(typecheckRec);
|
|
138
|
+
}
|
|
134
139
|
// Warn on unknown root keys
|
|
135
140
|
const knownRootKeys = new Set(SUPPORTED_CONFIG_ROOT_KEYS);
|
|
136
141
|
for (const key of rec.keys()) {
|
|
@@ -213,6 +218,10 @@ function parsePatchLintBlock(rec) {
|
|
|
213
218
|
}
|
|
214
219
|
out.checkJs = checkJs;
|
|
215
220
|
}
|
|
221
|
+
const checkJsExtraShim = rec.raw('checkJsExtraShim');
|
|
222
|
+
if (checkJsExtraShim !== undefined) {
|
|
223
|
+
out.checkJsExtraShim = parseShimPath(checkJsExtraShim, 'patchLint.checkJsExtraShim');
|
|
224
|
+
}
|
|
216
225
|
const rawColorAllowlist = rec.raw('rawColorAllowlist');
|
|
217
226
|
if (rawColorAllowlist !== undefined) {
|
|
218
227
|
if (!Array.isArray(rawColorAllowlist) ||
|
|
@@ -235,4 +244,59 @@ function parsePatchLintBlock(rec) {
|
|
|
235
244
|
}
|
|
236
245
|
return out;
|
|
237
246
|
}
|
|
247
|
+
/**
|
|
248
|
+
* Validates a path field that should point at a project-relative `.d.ts`
|
|
249
|
+
* file. Shared between `patchLint.checkJsExtraShim` and
|
|
250
|
+
* `typecheck.extraShim` so both fields reject the same absolute-path /
|
|
251
|
+
* traversal / empty-string inputs with consistent error messages. The
|
|
252
|
+
* file's existence is intentionally not checked here — that lives at
|
|
253
|
+
* the engine layer where a missing file produces a typed runtime error
|
|
254
|
+
* pointing at the actual command, rather than blocking config reads
|
|
255
|
+
* for unrelated commands.
|
|
256
|
+
*/
|
|
257
|
+
function parseShimPath(raw, label) {
|
|
258
|
+
if (typeof raw !== 'string' || raw.trim() === '') {
|
|
259
|
+
throw new ConfigError(`Config field "${label}" must be a non-empty string`);
|
|
260
|
+
}
|
|
261
|
+
if (!isContainedRelativePath(raw)) {
|
|
262
|
+
throw new ConfigError(`Config field "${label}" must be a project-relative path`);
|
|
263
|
+
}
|
|
264
|
+
return raw;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Validates the optional top-level `typecheck` block. Empty `projects`
|
|
268
|
+
* is rejected because a silent no-op for `fireforge typecheck` is a
|
|
269
|
+
* footgun — operators set the block expecting it to do something. Each
|
|
270
|
+
* project path must be a contained relative path so `--project` / CLI
|
|
271
|
+
* scripts can't escape the project root.
|
|
272
|
+
*/
|
|
273
|
+
function parseTypecheckBlock(rec) {
|
|
274
|
+
const projectsRaw = rec.raw('projects');
|
|
275
|
+
if (projectsRaw === undefined) {
|
|
276
|
+
throw new ConfigError('Config field "typecheck.projects" is required when "typecheck" is set');
|
|
277
|
+
}
|
|
278
|
+
if (!Array.isArray(projectsRaw)) {
|
|
279
|
+
throw new ConfigError('Config field "typecheck.projects" must be an array of strings');
|
|
280
|
+
}
|
|
281
|
+
if (projectsRaw.length === 0) {
|
|
282
|
+
throw new ConfigError('Config field "typecheck.projects" must not be empty');
|
|
283
|
+
}
|
|
284
|
+
const projects = [];
|
|
285
|
+
for (let i = 0; i < projectsRaw.length; i++) {
|
|
286
|
+
const entry = projectsRaw[i];
|
|
287
|
+
if (typeof entry !== 'string' || entry.trim() === '') {
|
|
288
|
+
throw new ConfigError(`Config field "typecheck.projects[${String(i)}]" must be a non-empty string`);
|
|
289
|
+
}
|
|
290
|
+
if (!isContainedRelativePath(entry)) {
|
|
291
|
+
throw new ConfigError(`Config field "typecheck.projects[${String(i)}]" must be a project-relative path`);
|
|
292
|
+
}
|
|
293
|
+
projects.push(entry);
|
|
294
|
+
}
|
|
295
|
+
const out = { projects };
|
|
296
|
+
const extraShim = rec.raw('extraShim');
|
|
297
|
+
if (extraShim !== undefined) {
|
|
298
|
+
out.extraShim = parseShimPath(extraShim, 'typecheck.extraShim');
|
|
299
|
+
}
|
|
300
|
+
return out;
|
|
301
|
+
}
|
|
238
302
|
//# sourceMappingURL=config-validate.js.map
|
|
@@ -29,6 +29,11 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
|
|
|
29
29
|
* standard MPL header — operators were forced to `--skip-lint` over a real
|
|
30
30
|
* false positive.
|
|
31
31
|
*
|
|
32
|
+
* Editor-directive block comments (`/* -*- ... -*- *\/`, `/* vim: ... *\/`)
|
|
33
|
+
* leading the file are tolerated — Mozilla's canonical layout puts those
|
|
34
|
+
* on lines 1–2 with the MPL header on lines 3+, which the raw
|
|
35
|
+
* `startsWith` check would otherwise miss.
|
|
36
|
+
*
|
|
32
37
|
* @param content - File content to check
|
|
33
38
|
* @param style - Comment syntax of the file
|
|
34
39
|
*/
|