@hominis/fireforge 0.30.0 → 0.31.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.
- package/CHANGELOG.md +26 -1
- package/README.md +22 -5
- package/dist/src/commands/export-all.js +5 -15
- package/dist/src/commands/export-flow.d.ts +6 -0
- package/dist/src/commands/export-flow.js +6 -1
- package/dist/src/commands/export-placement-gate.d.ts +38 -0
- package/dist/src/commands/export-placement-gate.js +105 -0
- package/dist/src/commands/export-shared.d.ts +28 -0
- package/dist/src/commands/export-shared.js +36 -0
- package/dist/src/commands/export.js +47 -112
- package/dist/src/commands/furnace/chrome-doc-templates.d.ts +0 -13
- package/dist/src/commands/furnace/chrome-doc-templates.js +1 -1
- package/dist/src/commands/furnace/create-dry-run.d.ts +1 -1
- package/dist/src/commands/furnace/create.d.ts +1 -2
- package/dist/src/commands/furnace/deploy.js +36 -114
- package/dist/src/commands/furnace/refresh.js +52 -32
- package/dist/src/commands/furnace/sync.js +2 -0
- package/dist/src/commands/import.js +108 -73
- package/dist/src/commands/lint-per-patch.d.ts +1 -1
- package/dist/src/commands/lint-per-patch.js +119 -78
- package/dist/src/commands/lint.d.ts +1 -58
- package/dist/src/commands/lint.js +96 -84
- package/dist/src/commands/patch/compact.d.ts +5 -2
- package/dist/src/commands/patch/compact.js +85 -25
- package/dist/src/commands/patch/delete.js +17 -17
- package/dist/src/commands/patch/index.js +2 -0
- package/dist/src/commands/patch/lint-ignore.js +3 -16
- package/dist/src/commands/patch/move-files.js +2 -0
- package/dist/src/commands/patch/patch-context.d.ts +41 -0
- package/dist/src/commands/patch/patch-context.js +53 -0
- package/dist/src/commands/patch/rename.js +10 -15
- package/dist/src/commands/patch/reorder.d.ts +0 -2
- package/dist/src/commands/patch/reorder.js +18 -19
- package/dist/src/commands/patch/split-plan.d.ts +66 -0
- package/dist/src/commands/patch/split-plan.js +178 -0
- package/dist/src/commands/patch/split.d.ts +30 -0
- package/dist/src/commands/patch/split.js +283 -0
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -7
- package/dist/src/commands/patch/staged-dependency.js +4 -17
- package/dist/src/commands/patch/tier.js +4 -17
- package/dist/src/commands/re-export-scan.js +8 -1
- package/dist/src/commands/rebase/summary.d.ts +1 -5
- package/dist/src/commands/rebase/summary.js +1 -1
- package/dist/src/commands/status-output.js +77 -68
- package/dist/src/commands/test-diagnose.d.ts +23 -0
- package/dist/src/commands/test-diagnose.js +210 -0
- package/dist/src/commands/test-run.d.ts +58 -0
- package/dist/src/commands/test-run.js +88 -0
- package/dist/src/commands/test.js +169 -257
- package/dist/src/commands/token.js +15 -1
- package/dist/src/commands/wire.js +109 -78
- package/dist/src/core/build-audit.d.ts +1 -1
- package/dist/src/core/build-audit.js +2 -46
- package/dist/src/core/build-baseline-types.d.ts +38 -0
- package/dist/src/core/build-baseline-types.js +10 -0
- package/dist/src/core/build-baseline.d.ts +1 -31
- package/dist/src/core/build-prepare.d.ts +1 -1
- package/dist/src/core/build-prepare.js +2 -45
- package/dist/src/core/config-paths.d.ts +0 -8
- package/dist/src/core/config-paths.js +4 -4
- package/dist/src/core/config-state.d.ts +0 -6
- package/dist/src/core/config-state.js +1 -1
- package/dist/src/core/config-validate-patch-policy.js +12 -13
- package/dist/src/core/config-validate.js +48 -28
- package/dist/src/core/engine-changes.d.ts +24 -0
- package/dist/src/core/engine-changes.js +64 -0
- package/dist/src/core/firefox-cache.d.ts +0 -5
- package/dist/src/core/firefox-cache.js +1 -1
- package/dist/src/core/firefox-download.d.ts +0 -6
- package/dist/src/core/firefox-download.js +1 -1
- package/dist/src/core/furnace-apply-helpers.d.ts +1 -8
- package/dist/src/core/furnace-apply-helpers.js +11 -20
- package/dist/src/core/furnace-apply.d.ts +1 -1
- package/dist/src/core/furnace-apply.js +1 -1
- package/dist/src/core/furnace-checksum-utils.d.ts +7 -0
- package/dist/src/core/furnace-checksum-utils.js +15 -0
- package/dist/src/core/furnace-config-validate.d.ts +31 -0
- package/dist/src/core/furnace-config-validate.js +133 -0
- package/dist/src/core/furnace-config.d.ts +4 -32
- package/dist/src/core/furnace-config.js +15 -111
- package/dist/src/core/furnace-constants.d.ts +0 -10
- package/dist/src/core/furnace-constants.js +2 -2
- package/dist/src/core/furnace-css-fragments.d.ts +79 -0
- package/dist/src/core/furnace-css-fragments.js +243 -0
- package/dist/src/core/furnace-jsconfig.d.ts +63 -0
- package/dist/src/core/furnace-jsconfig.js +171 -0
- package/dist/src/core/furnace-validate-helpers.d.ts +16 -14
- package/dist/src/core/furnace-validate-helpers.js +40 -1
- package/dist/src/core/furnace-validate-registration.js +16 -1
- package/dist/src/core/furnace-validate.js +54 -2
- package/dist/src/core/git-file-ops.d.ts +0 -12
- package/dist/src/core/git-file-ops.js +2 -2
- package/dist/src/core/lint-cache.d.ts +3 -13
- package/dist/src/core/lint-cache.js +11 -5
- package/dist/src/core/mach.d.ts +5 -1
- package/dist/src/core/mach.js +6 -2
- package/dist/src/core/manifest-register.d.ts +5 -16
- package/dist/src/core/manifest-register.js +3 -1
- package/dist/src/core/patch-lint-checkjs.js +53 -7
- package/dist/src/core/patch-lint-jsdoc.js +63 -4
- package/dist/src/core/patch-lint-observer.d.ts +37 -0
- package/dist/src/core/patch-lint-observer.js +168 -0
- package/dist/src/core/patch-lint.js +132 -125
- package/dist/src/core/patch-manifest-io.d.ts +16 -0
- package/dist/src/core/patch-manifest-io.js +44 -2
- package/dist/src/core/patch-manifest-validate.d.ts +1 -8
- package/dist/src/core/patch-manifest-validate.js +1 -1
- package/dist/src/core/patch-manifest.d.ts +1 -1
- package/dist/src/core/patch-manifest.js +1 -1
- package/dist/src/core/patch-policy.d.ts +0 -4
- package/dist/src/core/patch-policy.js +10 -4
- package/dist/src/core/register-browser-content.d.ts +1 -1
- package/dist/src/core/register-module.d.ts +1 -1
- package/dist/src/core/register-result.d.ts +21 -0
- package/dist/src/core/register-result.js +9 -0
- package/dist/src/core/register-shared-css.d.ts +1 -1
- package/dist/src/core/register-test-manifest.d.ts +1 -1
- package/dist/src/core/test-harness-crash.d.ts +61 -0
- package/dist/src/core/test-harness-crash.js +140 -0
- package/dist/src/core/test-stale-check.d.ts +1 -1
- package/dist/src/core/test-stale-check.js +2 -46
- package/dist/src/core/test-xpcshell-retry.d.ts +1 -1
- package/dist/src/core/test-xpcshell-retry.js +4 -2
- package/dist/src/core/token-dark-mode.js +14 -26
- package/dist/src/core/token-manager.d.ts +4 -0
- package/dist/src/core/token-manager.js +70 -16
- package/dist/src/core/typecheck-shim.d.ts +0 -21
- package/dist/src/core/typecheck-shim.js +26 -4
- package/dist/src/core/wire-utils.js +37 -44
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +105 -0
- package/dist/src/types/furnace.d.ts +12 -1
- package/dist/src/utils/elapsed.d.ts +0 -2
- package/dist/src/utils/elapsed.js +1 -1
- package/dist/src/utils/fs.d.ts +0 -5
- package/dist/src/utils/fs.js +1 -1
- package/dist/src/utils/regex.d.ts +0 -6
- package/dist/src/utils/regex.js +3 -3
- package/dist/src/utils/validation.d.ts +0 -8
- package/dist/src/utils/validation.js +2 -2
- package/package.json +6 -4
|
@@ -17,13 +17,51 @@
|
|
|
17
17
|
* and optional allowlisted `checkJsCompilerOptions`; it does not change
|
|
18
18
|
* shim composition or suppressed diagnostic codes.
|
|
19
19
|
*/
|
|
20
|
-
import { resolve } from 'node:path';
|
|
20
|
+
import { basename, resolve } from 'node:path';
|
|
21
21
|
import { pathExists } from '../utils/fs.js';
|
|
22
22
|
import { verbose } from '../utils/logger.js';
|
|
23
23
|
import { composeShimSource, SHIM_FILENAME, SUPPRESSED_DIAGNOSTIC_CODES } from './typecheck-shim.js';
|
|
24
24
|
// ---------------------------------------------------------------------------
|
|
25
25
|
// Public API
|
|
26
26
|
// ---------------------------------------------------------------------------
|
|
27
|
+
/**
|
|
28
|
+
* Builds the host-side module resolver for the checkJs pass: maps an import
|
|
29
|
+
* specifier to a patch-owned absolute path when the specifier's final
|
|
30
|
+
* segment uniquely matches an owned file. URL specifiers
|
|
31
|
+
* (chrome://browser/content/Foo.sys.mjs, resource:///modules/Foo.sys.mjs)
|
|
32
|
+
* are matched by basename, with a `.mjs` → `.sys.mjs` fallback for deployed
|
|
33
|
+
* widget URLs. Ambiguous or unknown basenames stay unresolved — loose
|
|
34
|
+
* wildcard typing beats guessing the wrong module — and relative specifiers
|
|
35
|
+
* are left to fail resolution (the relative-import lint rule bans them).
|
|
36
|
+
*/
|
|
37
|
+
function createOwnedSpecifierResolver(ts, ownedAbsolute) {
|
|
38
|
+
const ownedByBasename = new Map();
|
|
39
|
+
for (const abs of ownedAbsolute) {
|
|
40
|
+
const base = basename(abs);
|
|
41
|
+
const list = ownedByBasename.get(base) ?? [];
|
|
42
|
+
list.push(abs);
|
|
43
|
+
ownedByBasename.set(base, list);
|
|
44
|
+
}
|
|
45
|
+
return (specifier) => {
|
|
46
|
+
if (specifier.startsWith('.'))
|
|
47
|
+
return undefined;
|
|
48
|
+
const cleaned = specifier.split(/[?#]/)[0] ?? specifier;
|
|
49
|
+
const segment = cleaned.slice(cleaned.lastIndexOf('/') + 1);
|
|
50
|
+
if (!segment)
|
|
51
|
+
return undefined;
|
|
52
|
+
const candidates = [...(ownedByBasename.get(segment) ?? [])];
|
|
53
|
+
if (segment.endsWith('.mjs') && !segment.endsWith('.sys.mjs')) {
|
|
54
|
+
candidates.push(...(ownedByBasename.get(segment.replace(/\.mjs$/, '.sys.mjs')) ?? []));
|
|
55
|
+
}
|
|
56
|
+
if (candidates.length !== 1)
|
|
57
|
+
return undefined;
|
|
58
|
+
return {
|
|
59
|
+
resolvedFileName: candidates[0],
|
|
60
|
+
extension: ts.Extension.Mjs,
|
|
61
|
+
isExternalLibraryImport: false,
|
|
62
|
+
};
|
|
63
|
+
};
|
|
64
|
+
}
|
|
27
65
|
/**
|
|
28
66
|
* Runs TypeScript's checkJs pass on patch-owned `.sys.mjs` files.
|
|
29
67
|
*
|
|
@@ -126,15 +164,18 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
|
|
|
126
164
|
module: ts.ModuleKind.ESNext,
|
|
127
165
|
moduleResolution: ts.ModuleResolutionKind.Bundler,
|
|
128
166
|
skipLibCheck: true,
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
|
|
167
|
+
// Module resolution is host-controlled (see resolveOwnedSpecifier
|
|
168
|
+
// below): imports that match a patch-owned file resolve to the real
|
|
169
|
+
// source so JSDoc type-guard predicates and @template generics
|
|
170
|
+
// survive the module boundary; everything else deliberately fails
|
|
171
|
+
// resolution, falling back to the chrome:*/resource:* ambient
|
|
172
|
+
// wildcards plus the suppressed "cannot find module" codes. The
|
|
173
|
+
// host resolver is authoritative — TS never crawls the Firefox
|
|
174
|
+
// tree looking for upstream modules.
|
|
135
175
|
...strictness,
|
|
136
176
|
...overrides,
|
|
137
177
|
};
|
|
178
|
+
const resolveOwnedSpecifier = createOwnedSpecifierResolver(ts, ownedAbsolute);
|
|
138
179
|
// Custom compiler host: reads patch-owned files from disk, returns
|
|
139
180
|
// the shim for the shim path, and returns empty content for
|
|
140
181
|
// anything else to avoid reading the full Firefox tree.
|
|
@@ -169,6 +210,11 @@ export async function runCheckJs(repoDir, patchOwnedFiles, extraShimPath, projec
|
|
|
169
210
|
return shimSource;
|
|
170
211
|
return defaultHost.readFile(fileName);
|
|
171
212
|
},
|
|
213
|
+
resolveModuleNameLiterals(moduleLiterals) {
|
|
214
|
+
return moduleLiterals.map((literal) => ({
|
|
215
|
+
resolvedModule: resolveOwnedSpecifier(literal.text),
|
|
216
|
+
}));
|
|
217
|
+
},
|
|
172
218
|
};
|
|
173
219
|
const program = ts.createProgram(rootFiles, options, host);
|
|
174
220
|
const allDiagnostics = [
|
|
@@ -37,13 +37,72 @@ function findAttachedJsDoc(comments, declStart, source) {
|
|
|
37
37
|
// ---------------------------------------------------------------------------
|
|
38
38
|
// JSDoc tag parsing
|
|
39
39
|
// ---------------------------------------------------------------------------
|
|
40
|
+
/**
|
|
41
|
+
* Skips a balanced `{ … }` JSDoc type expression starting at `start`
|
|
42
|
+
* (which must point at the opening brace). Braces nest in inline object
|
|
43
|
+
* types (`{{ id: string, args?: Record<string, boolean> }}`), so a flat
|
|
44
|
+
* "anything but `}`" regex truncates at the first inner close brace and
|
|
45
|
+
* loses the param name. String literal types may contain braces too, so
|
|
46
|
+
* quoted runs are skipped verbatim.
|
|
47
|
+
*
|
|
48
|
+
* @returns Index just past the matching close brace, or -1 when the type
|
|
49
|
+
* expression never closes (malformed doc — caller skips the tag).
|
|
50
|
+
*/
|
|
51
|
+
function skipBalancedTypeBraces(jsDoc, start) {
|
|
52
|
+
let depth = 0;
|
|
53
|
+
let quote = null;
|
|
54
|
+
for (let i = start; i < jsDoc.length; i++) {
|
|
55
|
+
const ch = jsDoc[i];
|
|
56
|
+
if (quote !== null) {
|
|
57
|
+
if (ch === '\\')
|
|
58
|
+
i++;
|
|
59
|
+
else if (ch === quote)
|
|
60
|
+
quote = null;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
64
|
+
quote = ch;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (ch === '{')
|
|
68
|
+
depth++;
|
|
69
|
+
else if (ch === '}') {
|
|
70
|
+
depth--;
|
|
71
|
+
if (depth === 0)
|
|
72
|
+
return i + 1;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return -1;
|
|
76
|
+
}
|
|
40
77
|
function extractParamNames(jsDoc) {
|
|
41
78
|
const names = [];
|
|
42
|
-
const
|
|
79
|
+
const tagPattern = /@param\b/g;
|
|
43
80
|
let m;
|
|
44
|
-
while ((m =
|
|
45
|
-
|
|
46
|
-
|
|
81
|
+
while ((m = tagPattern.exec(jsDoc)) !== null) {
|
|
82
|
+
let i = m.index + m[0].length;
|
|
83
|
+
while (i < jsDoc.length && /\s/.test(jsDoc[i] ?? ''))
|
|
84
|
+
i++;
|
|
85
|
+
// Optional `{type}` expression — depth-aware so nested braces inside
|
|
86
|
+
// inline object types or Record<…> generics don't truncate the scan.
|
|
87
|
+
if (jsDoc[i] === '{') {
|
|
88
|
+
const afterType = skipBalancedTypeBraces(jsDoc, i);
|
|
89
|
+
if (afterType === -1)
|
|
90
|
+
continue;
|
|
91
|
+
i = afterType;
|
|
92
|
+
while (i < jsDoc.length && /\s/.test(jsDoc[i] ?? ''))
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
// Name token: bare `name`, optional `[name]`, or defaulted `[name=x]`.
|
|
96
|
+
// Dotted property docs (`opts.id`) record the base object name once.
|
|
97
|
+
const optional = jsDoc[i] === '[';
|
|
98
|
+
if (optional)
|
|
99
|
+
i++;
|
|
100
|
+
const nameMatch = /^[A-Za-z_$][\w$]*/.exec(jsDoc.slice(i));
|
|
101
|
+
if (!nameMatch)
|
|
102
|
+
continue;
|
|
103
|
+
const name = nameMatch[0];
|
|
104
|
+
if (!names.includes(name))
|
|
105
|
+
names.push(name);
|
|
47
106
|
}
|
|
48
107
|
return names;
|
|
49
108
|
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `observer-topic-naming` rule body, extracted from `patch-lint.ts`.
|
|
3
|
+
*
|
|
4
|
+
* The historical implementation captured the first string literal after
|
|
5
|
+
* the call's opening paren on a single line. That mis-attributed string
|
|
6
|
+
* literals in complex subject arguments and silently skipped multi-line
|
|
7
|
+
* call sites. This version scans the balanced argument list (across
|
|
8
|
+
* newlines, string-aware), takes the *topic* argument by position, and
|
|
9
|
+
* allowlists well-known Firefox-owned topics so tests that simulate
|
|
10
|
+
* upstream notifications are not pushed toward renaming real topics.
|
|
11
|
+
*/
|
|
12
|
+
import type { PatchLintIssue } from '../types/commands/index.js';
|
|
13
|
+
/**
|
|
14
|
+
* Firefox-owned observer topics a fork legitimately observes or simulates.
|
|
15
|
+
* These must never be flagged for fork-prefix naming, even when a fork's
|
|
16
|
+
* binaryName happens to be a substring of one. `quit-application*` is
|
|
17
|
+
* handled as a prefix family in {@link isKnownFirefoxTopic}.
|
|
18
|
+
*
|
|
19
|
+
* The list is deliberately conservative: it only needs to cover topics
|
|
20
|
+
* whose text could plausibly contain a fork's binaryName, plus the
|
|
21
|
+
* high-traffic lifecycle topics seen in downstream test simulations.
|
|
22
|
+
*/
|
|
23
|
+
export declare const KNOWN_FIREFOX_OBSERVER_TOPICS: ReadonlySet<string>;
|
|
24
|
+
/** True for allowlisted Firefox topics, including the quit-application family. */
|
|
25
|
+
export declare function isKnownFirefoxTopic(topic: string): boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Lints observer-service call sites in `strippedContent` (comments already
|
|
28
|
+
* removed) for fork topic naming. Only topics that embed `binaryName` and
|
|
29
|
+
* do not follow the `<binary>-<noun>-<verb>` convention are flagged;
|
|
30
|
+
* allowlisted Firefox topics and constant-named topics are skipped.
|
|
31
|
+
*
|
|
32
|
+
* @param strippedContent - Source with comments stripped
|
|
33
|
+
* @param file - File path for issue attribution
|
|
34
|
+
* @param binaryName - Lowercased fork binary name
|
|
35
|
+
* @returns Observer-topic naming issues
|
|
36
|
+
*/
|
|
37
|
+
export declare function lintObserverTopics(strippedContent: string, file: string, binaryName: string): PatchLintIssue[];
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* `observer-topic-naming` rule body, extracted from `patch-lint.ts`.
|
|
4
|
+
*
|
|
5
|
+
* The historical implementation captured the first string literal after
|
|
6
|
+
* the call's opening paren on a single line. That mis-attributed string
|
|
7
|
+
* literals in complex subject arguments and silently skipped multi-line
|
|
8
|
+
* call sites. This version scans the balanced argument list (across
|
|
9
|
+
* newlines, string-aware), takes the *topic* argument by position, and
|
|
10
|
+
* allowlists well-known Firefox-owned topics so tests that simulate
|
|
11
|
+
* upstream notifications are not pushed toward renaming real topics.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* Firefox-owned observer topics a fork legitimately observes or simulates.
|
|
15
|
+
* These must never be flagged for fork-prefix naming, even when a fork's
|
|
16
|
+
* binaryName happens to be a substring of one. `quit-application*` is
|
|
17
|
+
* handled as a prefix family in {@link isKnownFirefoxTopic}.
|
|
18
|
+
*
|
|
19
|
+
* The list is deliberately conservative: it only needs to cover topics
|
|
20
|
+
* whose text could plausibly contain a fork's binaryName, plus the
|
|
21
|
+
* high-traffic lifecycle topics seen in downstream test simulations.
|
|
22
|
+
*/
|
|
23
|
+
export const KNOWN_FIREFOX_OBSERVER_TOPICS = new Set([
|
|
24
|
+
'idle-daily',
|
|
25
|
+
'profile-after-change',
|
|
26
|
+
'profile-before-change',
|
|
27
|
+
'xpcom-shutdown',
|
|
28
|
+
'xpcom-will-shutdown',
|
|
29
|
+
'final-ui-startup',
|
|
30
|
+
'browser-delayed-startup-finished',
|
|
31
|
+
'sessionstore-windows-restored',
|
|
32
|
+
'document-element-inserted',
|
|
33
|
+
'content-document-global-created',
|
|
34
|
+
'http-on-modify-request',
|
|
35
|
+
'http-on-examine-response',
|
|
36
|
+
'nsPref:changed',
|
|
37
|
+
'browser-window-before-show',
|
|
38
|
+
'domwindowopened',
|
|
39
|
+
'domwindowclosed',
|
|
40
|
+
]);
|
|
41
|
+
/** True for allowlisted Firefox topics, including the quit-application family. */
|
|
42
|
+
export function isKnownFirefoxTopic(topic) {
|
|
43
|
+
return KNOWN_FIREFOX_OBSERVER_TOPICS.has(topic) || topic.startsWith('quit-application');
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Scans a balanced `( … )` argument span starting at `openParen` (which
|
|
47
|
+
* must point at the opening paren) and splits it into top-level argument
|
|
48
|
+
* strings. Tracks paren/brace/bracket depth and skips quoted runs, so
|
|
49
|
+
* commas inside nested calls, object literals, or strings do not split.
|
|
50
|
+
*
|
|
51
|
+
* @returns The argument texts, or null when the span never closes within
|
|
52
|
+
* `maxLength` characters (malformed or truncated source — caller skips).
|
|
53
|
+
*/
|
|
54
|
+
function extractCallArguments(content, openParen, maxLength = 2000) {
|
|
55
|
+
const args = [];
|
|
56
|
+
let current = '';
|
|
57
|
+
let depth = 0;
|
|
58
|
+
let quote = null;
|
|
59
|
+
const end = Math.min(content.length, openParen + maxLength);
|
|
60
|
+
for (let i = openParen; i < end; i++) {
|
|
61
|
+
const ch = content[i] ?? '';
|
|
62
|
+
if (quote !== null) {
|
|
63
|
+
current += ch;
|
|
64
|
+
if (ch === '\\') {
|
|
65
|
+
current += content[i + 1] ?? '';
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
else if (ch === quote) {
|
|
69
|
+
quote = null;
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ch === "'" || ch === '"' || ch === '`') {
|
|
74
|
+
quote = ch;
|
|
75
|
+
current += ch;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (ch === '(' || ch === '{' || ch === '[') {
|
|
79
|
+
depth++;
|
|
80
|
+
if (depth === 1 && ch === '(')
|
|
81
|
+
continue; // the call's own paren
|
|
82
|
+
current += ch;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (ch === ')' || ch === '}' || ch === ']') {
|
|
86
|
+
depth--;
|
|
87
|
+
if (depth === 0 && ch === ')') {
|
|
88
|
+
args.push(current.trim());
|
|
89
|
+
return args;
|
|
90
|
+
}
|
|
91
|
+
current += ch;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ch === ',' && depth === 1) {
|
|
95
|
+
args.push(current.trim());
|
|
96
|
+
current = '';
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
current += ch;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Returns the literal string value when `arg` is exactly one plain string
|
|
105
|
+
* literal (no concatenation, no `${}` interpolation), otherwise null.
|
|
106
|
+
* Constant-named topics (identifiers, member expressions) intentionally
|
|
107
|
+
* return null — hoisting a literal into a named constant is a supported
|
|
108
|
+
* way to mark a topic as deliberate.
|
|
109
|
+
*/
|
|
110
|
+
function asStringLiteral(arg) {
|
|
111
|
+
const trimmed = arg.trim();
|
|
112
|
+
if (trimmed.length < 2)
|
|
113
|
+
return null;
|
|
114
|
+
const quoteChar = trimmed[0];
|
|
115
|
+
if (quoteChar !== "'" && quoteChar !== '"' && quoteChar !== '`')
|
|
116
|
+
return null;
|
|
117
|
+
if (trimmed[trimmed.length - 1] !== quoteChar)
|
|
118
|
+
return null;
|
|
119
|
+
const inner = trimmed.slice(1, -1);
|
|
120
|
+
if (inner.includes(quoteChar))
|
|
121
|
+
return null; // concatenation like "a" + "b"
|
|
122
|
+
if (quoteChar === '`' && inner.includes('${'))
|
|
123
|
+
return null;
|
|
124
|
+
return inner;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Lints observer-service call sites in `strippedContent` (comments already
|
|
128
|
+
* removed) for fork topic naming. Only topics that embed `binaryName` and
|
|
129
|
+
* do not follow the `<binary>-<noun>-<verb>` convention are flagged;
|
|
130
|
+
* allowlisted Firefox topics and constant-named topics are skipped.
|
|
131
|
+
*
|
|
132
|
+
* @param strippedContent - Source with comments stripped
|
|
133
|
+
* @param file - File path for issue attribution
|
|
134
|
+
* @param binaryName - Lowercased fork binary name
|
|
135
|
+
* @returns Observer-topic naming issues
|
|
136
|
+
*/
|
|
137
|
+
export function lintObserverTopics(strippedContent, file, binaryName) {
|
|
138
|
+
const issues = [];
|
|
139
|
+
const callPattern = /\b(?:addObserver|removeObserver|notifyObservers)\s*\(/g;
|
|
140
|
+
let callMatch;
|
|
141
|
+
while ((callMatch = callPattern.exec(strippedContent)) !== null) {
|
|
142
|
+
const openParen = callMatch.index + callMatch[0].length - 1;
|
|
143
|
+
const args = extractCallArguments(strippedContent, openParen);
|
|
144
|
+
if (!args)
|
|
145
|
+
continue;
|
|
146
|
+
// Topic is the second argument for all three observer-service methods:
|
|
147
|
+
// addObserver(observer, topic[, weak]), removeObserver(observer, topic),
|
|
148
|
+
// notifyObservers(subject, topic[, data]).
|
|
149
|
+
const topicArg = args[1];
|
|
150
|
+
if (topicArg === undefined)
|
|
151
|
+
continue;
|
|
152
|
+
const topic = asStringLiteral(topicArg);
|
|
153
|
+
if (topic === null)
|
|
154
|
+
continue;
|
|
155
|
+
if (isKnownFirefoxTopic(topic))
|
|
156
|
+
continue;
|
|
157
|
+
if (topic.toLowerCase().includes(binaryName) && !/^[\w]+-[a-z]+-[a-z]+/.test(topic)) {
|
|
158
|
+
issues.push({
|
|
159
|
+
file,
|
|
160
|
+
check: 'observer-topic-naming',
|
|
161
|
+
message: `Observer topic "${topic}" should follow "${binaryName}-<noun>-<verb>" naming convention.`,
|
|
162
|
+
severity: 'warning',
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return issues;
|
|
167
|
+
}
|
|
168
|
+
//# sourceMappingURL=patch-lint-observer.js.map
|
|
@@ -12,6 +12,7 @@ import { detectNewFilesInDiff, extractAddedLinesPerFile } from './patch-lint-dif
|
|
|
12
12
|
import { AGGREGATE_PATCH_FILE } from './patch-lint-diff-tag.js';
|
|
13
13
|
import { hasRelativeImport } from './patch-lint-imports.js';
|
|
14
14
|
import { validateExportJsDoc } from './patch-lint-jsdoc.js';
|
|
15
|
+
import { lintObserverTopics } from './patch-lint-observer.js';
|
|
15
16
|
import { resolvePatchOwnedChromeScripts, resolvePatchOwnedSysMjs } from './patch-lint-ownership.js';
|
|
16
17
|
// ---------------------------------------------------------------------------
|
|
17
18
|
// Cross-patch lint re-exports
|
|
@@ -204,9 +205,130 @@ export function commentStyleForFile(file) {
|
|
|
204
205
|
return 'js';
|
|
205
206
|
return null;
|
|
206
207
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Loads the furnace token-prefix lint inputs gracefully — returns
|
|
210
|
+
* undefined (skipping the token-prefix check) when furnace.json cannot
|
|
211
|
+
* be loaded or no tokenPrefix is configured.
|
|
212
|
+
*/
|
|
213
|
+
async function loadCssTokenContext(repoDir) {
|
|
214
|
+
try {
|
|
215
|
+
const root = join(repoDir, '..');
|
|
216
|
+
const furnaceConfig = await loadFurnaceConfig(root);
|
|
217
|
+
if (furnaceConfig.tokenPrefix) {
|
|
218
|
+
return {
|
|
219
|
+
tokenPrefix: furnaceConfig.tokenPrefix,
|
|
220
|
+
tokenAllowlist: new Set(furnaceConfig.tokenAllowlist ?? []),
|
|
221
|
+
runtimeVariables: new Set(furnaceConfig.runtimeVariables ?? []),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
verbose(`Skipping furnace token-prefix lint hints because furnace.json could not be loaded: ${toError(error).message}`);
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Raw-color check for one patched CSS file, scoped to introduced lines
|
|
232
|
+
* when diff context is available. Pushes onto `issues`.
|
|
233
|
+
*/
|
|
234
|
+
function checkRawColorValues(file, rawCss, addedLinesByFile, config, issues) {
|
|
235
|
+
// Check only introduced raw color values when diff context is available.
|
|
236
|
+
// Skip files on the raw-color allowlist (exact path or basename match) and
|
|
237
|
+
// auto-exempt files under `browser/branding/` — those are the fork's
|
|
238
|
+
// visual identity assets (app-about dialogs, installer pages, branded
|
|
239
|
+
// CSS copied from Firefox's `unofficial` template) and belong to the
|
|
240
|
+
// design-decision layer the design-token system does not govern.
|
|
241
|
+
// Without this auto-exemption, every first-time setup's copied CSS
|
|
242
|
+
// failed `raw-color-value` with no actionable fix other than manually
|
|
243
|
+
// listing each path in `rawColorAllowlist`.
|
|
244
|
+
const allowlist = config?.patchLint?.rawColorAllowlist;
|
|
245
|
+
const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
|
|
246
|
+
const isBranding = file.startsWith('browser/branding/');
|
|
247
|
+
if (!isAllowlisted && !isBranding) {
|
|
248
|
+
// Strip lines with inline fireforge-ignore: raw-color-value suppression.
|
|
249
|
+
// Check against rawCss (before comment stripping) so the CSS comment marker is still present.
|
|
250
|
+
const sourceForSuppression = addedLinesByFile
|
|
251
|
+
? (addedLinesByFile.get(file) ?? []).join('\n')
|
|
252
|
+
: rawCss;
|
|
253
|
+
const suppressedContent = sourceForSuppression
|
|
254
|
+
.split('\n')
|
|
255
|
+
.filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
|
|
256
|
+
.join('\n')
|
|
257
|
+
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
258
|
+
if (hasRawCssColors(suppressedContent)) {
|
|
259
|
+
issues.push({
|
|
260
|
+
file,
|
|
261
|
+
check: 'raw-color-value',
|
|
262
|
+
message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
|
|
263
|
+
severity: 'error',
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Token-prefix check for one patched CSS file: flags `var(--x)` references
|
|
270
|
+
* that match neither the configured prefix, the allowlist, the runtime
|
|
271
|
+
* variables, nor a same-file declaration. Pushes onto `issues`.
|
|
272
|
+
*/
|
|
273
|
+
function checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues) {
|
|
274
|
+
// Check for non-tokenized custom properties. A variable that is both
|
|
275
|
+
// declared and consumed inside the same file is auto-exempted as a
|
|
276
|
+
// runtime state channel (see furnace.json → runtimeVariables).
|
|
277
|
+
//
|
|
278
|
+
// When diff context is available, scope the `var(...)` scan to
|
|
279
|
+
// added/modified lines only. `cssContent` (full-file) is still the
|
|
280
|
+
// source of `localDeclarations` so vars declared anywhere in the file
|
|
281
|
+
// are recognised as same-file refs regardless of where the consuming
|
|
282
|
+
// `var(...)` appears. Before this scoping change, a small edit to a
|
|
283
|
+
// Furnace override of a stock component (e.g. moz-card) produced a
|
|
284
|
+
// `token-prefix-violation` for every stock `var(--moz-card-*)` the
|
|
285
|
+
// upstream file already carried, because the scanner saw the full
|
|
286
|
+
// applied file and flagged each inherited reference as if the fork
|
|
287
|
+
// had introduced it.
|
|
288
|
+
if (tokenContext) {
|
|
289
|
+
const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
|
|
290
|
+
const localDeclarations = new Set();
|
|
291
|
+
let declMatch;
|
|
292
|
+
while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
|
|
293
|
+
const name = declMatch[1];
|
|
294
|
+
if (name)
|
|
295
|
+
localDeclarations.add(name);
|
|
296
|
+
}
|
|
297
|
+
const prefixScanSource = addedLinesByFile
|
|
298
|
+
? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
299
|
+
: cssContent;
|
|
300
|
+
if (prefixScanSource.length > 0) {
|
|
301
|
+
const varPattern = /var\(\s*(--[\w-]+)/g;
|
|
302
|
+
const flaggedProps = new Set();
|
|
303
|
+
let match;
|
|
304
|
+
while ((match = varPattern.exec(prefixScanSource)) !== null) {
|
|
305
|
+
const prop = match[1];
|
|
306
|
+
if (!prop)
|
|
307
|
+
continue;
|
|
308
|
+
if (prop.startsWith(tokenContext.tokenPrefix))
|
|
309
|
+
continue;
|
|
310
|
+
if (tokenContext.tokenAllowlist.has(prop))
|
|
311
|
+
continue;
|
|
312
|
+
if (tokenContext.runtimeVariables.has(prop))
|
|
313
|
+
continue;
|
|
314
|
+
if (localDeclarations.has(prop))
|
|
315
|
+
continue;
|
|
316
|
+
// De-duplicate per (file, prop) pair so the same introduced var
|
|
317
|
+
// used five times in the added hunk doesn't produce five
|
|
318
|
+
// identical issue entries.
|
|
319
|
+
if (flaggedProps.has(prop))
|
|
320
|
+
continue;
|
|
321
|
+
flaggedProps.add(prop);
|
|
322
|
+
issues.push({
|
|
323
|
+
file,
|
|
324
|
+
check: 'token-prefix-violation',
|
|
325
|
+
message: `CSS references var(${prop}) which does not match the required token prefix "${tokenContext.tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
|
|
326
|
+
severity: 'error',
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
210
332
|
/**
|
|
211
333
|
* Lints patched CSS files for introduced raw color values and non-tokenized
|
|
212
334
|
* custom properties.
|
|
@@ -220,22 +342,7 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
|
|
|
220
342
|
const cssFiles = affectedFiles.filter((f) => f.endsWith('.css'));
|
|
221
343
|
if (cssFiles.length === 0)
|
|
222
344
|
return [];
|
|
223
|
-
|
|
224
|
-
let tokenPrefix;
|
|
225
|
-
let tokenAllowlist;
|
|
226
|
-
let runtimeVariables;
|
|
227
|
-
try {
|
|
228
|
-
const root = join(repoDir, '..');
|
|
229
|
-
const furnaceConfig = await loadFurnaceConfig(root);
|
|
230
|
-
if (furnaceConfig.tokenPrefix) {
|
|
231
|
-
tokenPrefix = furnaceConfig.tokenPrefix;
|
|
232
|
-
tokenAllowlist = new Set(furnaceConfig.tokenAllowlist ?? []);
|
|
233
|
-
runtimeVariables = new Set(furnaceConfig.runtimeVariables ?? []);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
catch (error) {
|
|
237
|
-
verbose(`Skipping furnace token-prefix lint hints because furnace.json could not be loaded: ${toError(error).message}`);
|
|
238
|
-
}
|
|
345
|
+
const tokenContext = await loadCssTokenContext(repoDir);
|
|
239
346
|
const issues = [];
|
|
240
347
|
const addedLinesByFile = diffContent ? extractAddedLinesPerFile(diffContent) : undefined;
|
|
241
348
|
for (const file of cssFiles) {
|
|
@@ -245,95 +352,8 @@ export async function lintPatchedCss(repoDir, affectedFiles, diffContent, config
|
|
|
245
352
|
const rawCss = await readText(filePath);
|
|
246
353
|
// Strip block comments before scanning
|
|
247
354
|
const cssContent = rawCss.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// auto-exempt files under `browser/branding/` — those are the fork's
|
|
251
|
-
// visual identity assets (app-about dialogs, installer pages, branded
|
|
252
|
-
// CSS copied from Firefox's `unofficial` template) and belong to the
|
|
253
|
-
// design-decision layer the design-token system does not govern.
|
|
254
|
-
// Without this auto-exemption, every first-time setup's copied CSS
|
|
255
|
-
// failed `raw-color-value` with no actionable fix other than manually
|
|
256
|
-
// listing each path in `rawColorAllowlist`.
|
|
257
|
-
const allowlist = config?.patchLint?.rawColorAllowlist;
|
|
258
|
-
const isAllowlisted = allowlist?.some((entry) => file === entry || file.endsWith('/' + entry));
|
|
259
|
-
const isBranding = file.startsWith('browser/branding/');
|
|
260
|
-
if (!isAllowlisted && !isBranding) {
|
|
261
|
-
// Strip lines with inline fireforge-ignore: raw-color-value suppression.
|
|
262
|
-
// Check against rawCss (before comment stripping) so the CSS comment marker is still present.
|
|
263
|
-
const sourceForSuppression = addedLinesByFile
|
|
264
|
-
? (addedLinesByFile.get(file) ?? []).join('\n')
|
|
265
|
-
: rawCss;
|
|
266
|
-
const suppressedContent = sourceForSuppression
|
|
267
|
-
.split('\n')
|
|
268
|
-
.filter((line) => !line.includes('fireforge-ignore: raw-color-value'))
|
|
269
|
-
.join('\n')
|
|
270
|
-
.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
271
|
-
if (hasRawCssColors(suppressedContent)) {
|
|
272
|
-
issues.push({
|
|
273
|
-
file,
|
|
274
|
-
check: 'raw-color-value',
|
|
275
|
-
message: 'Raw color value found. Use CSS custom properties (var(--...)) for design token consistency.',
|
|
276
|
-
severity: 'error',
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// Check for non-tokenized custom properties. A variable that is both
|
|
281
|
-
// declared and consumed inside the same file is auto-exempted as a
|
|
282
|
-
// runtime state channel (see furnace.json → runtimeVariables).
|
|
283
|
-
//
|
|
284
|
-
// When diff context is available, scope the `var(...)` scan to
|
|
285
|
-
// added/modified lines only. `cssContent` (full-file) is still the
|
|
286
|
-
// source of `localDeclarations` so vars declared anywhere in the file
|
|
287
|
-
// are recognised as same-file refs regardless of where the consuming
|
|
288
|
-
// `var(...)` appears. Before this scoping change, a small edit to a
|
|
289
|
-
// Furnace override of a stock component (e.g. moz-card) produced a
|
|
290
|
-
// `token-prefix-violation` for every stock `var(--moz-card-*)` the
|
|
291
|
-
// upstream file already carried, because the scanner saw the full
|
|
292
|
-
// applied file and flagged each inherited reference as if the fork
|
|
293
|
-
// had introduced it.
|
|
294
|
-
if (tokenPrefix) {
|
|
295
|
-
const declarationPattern = /(?:^|[{;,\s])(--[\w-]+)\s*:/g;
|
|
296
|
-
const localDeclarations = new Set();
|
|
297
|
-
let declMatch;
|
|
298
|
-
while ((declMatch = declarationPattern.exec(cssContent)) !== null) {
|
|
299
|
-
const name = declMatch[1];
|
|
300
|
-
if (name)
|
|
301
|
-
localDeclarations.add(name);
|
|
302
|
-
}
|
|
303
|
-
const prefixScanSource = addedLinesByFile
|
|
304
|
-
? (addedLinesByFile.get(file) ?? []).join('\n').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
305
|
-
: cssContent;
|
|
306
|
-
if (prefixScanSource.length > 0) {
|
|
307
|
-
const varPattern = /var\(\s*(--[\w-]+)/g;
|
|
308
|
-
const flaggedProps = new Set();
|
|
309
|
-
let match;
|
|
310
|
-
while ((match = varPattern.exec(prefixScanSource)) !== null) {
|
|
311
|
-
const prop = match[1];
|
|
312
|
-
if (!prop)
|
|
313
|
-
continue;
|
|
314
|
-
if (prop.startsWith(tokenPrefix))
|
|
315
|
-
continue;
|
|
316
|
-
if (tokenAllowlist?.has(prop))
|
|
317
|
-
continue;
|
|
318
|
-
if (runtimeVariables?.has(prop))
|
|
319
|
-
continue;
|
|
320
|
-
if (localDeclarations.has(prop))
|
|
321
|
-
continue;
|
|
322
|
-
// De-duplicate per (file, prop) pair so the same introduced var
|
|
323
|
-
// used five times in the added hunk doesn't produce five
|
|
324
|
-
// identical issue entries.
|
|
325
|
-
if (flaggedProps.has(prop))
|
|
326
|
-
continue;
|
|
327
|
-
flaggedProps.add(prop);
|
|
328
|
-
issues.push({
|
|
329
|
-
file,
|
|
330
|
-
check: 'token-prefix-violation',
|
|
331
|
-
message: `CSS references var(${prop}) which does not match the required token prefix "${tokenPrefix}". Use a design token, add to tokenAllowlist, or (for runtime state channels) list the variable in runtimeVariables.`,
|
|
332
|
-
severity: 'error',
|
|
333
|
-
});
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
355
|
+
checkRawColorValues(file, rawCss, addedLinesByFile, config, issues);
|
|
356
|
+
checkTokenPrefixViolations(file, cssContent, addedLinesByFile, tokenContext, issues);
|
|
337
357
|
}
|
|
338
358
|
return issues;
|
|
339
359
|
}
|
|
@@ -503,23 +523,10 @@ export async function lintPatchedJs(repoDir, affectedFiles, newFiles, config, pa
|
|
|
503
523
|
});
|
|
504
524
|
}
|
|
505
525
|
}
|
|
506
|
-
// 4. Observer topic naming
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
const topic = topicMatch[1];
|
|
511
|
-
if (!topic)
|
|
512
|
-
continue;
|
|
513
|
-
// Only flag topics that contain the binaryName but don't follow convention
|
|
514
|
-
if (topic.toLowerCase().includes(binaryName) && !/^[\w]+-[a-z]+-[a-z]+/.test(topic)) {
|
|
515
|
-
issues.push({
|
|
516
|
-
file,
|
|
517
|
-
check: 'observer-topic-naming',
|
|
518
|
-
message: `Observer topic "${topic}" should follow "${binaryName}-<noun>-<verb>" naming convention.`,
|
|
519
|
-
severity: 'warning',
|
|
520
|
-
});
|
|
521
|
-
}
|
|
522
|
-
}
|
|
526
|
+
// 4. Observer topic naming. Rule body lives in `patch-lint-observer.ts`:
|
|
527
|
+
// argument-position-aware, multi-line-safe, and allowlists Firefox-owned
|
|
528
|
+
// topics so simulated upstream notifications are not flagged.
|
|
529
|
+
issues.push(...lintObserverTopics(strippedContent, file, binaryName));
|
|
523
530
|
}
|
|
524
531
|
return issues;
|
|
525
532
|
}
|