@hominis/fireforge 0.36.0 → 0.38.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 -0
- package/README.md +1 -1
- package/dist/src/commands/build.js +6 -6
- package/dist/src/commands/config.js +1 -0
- package/dist/src/commands/doctor-orphaned-harness.d.ts +52 -0
- package/dist/src/commands/doctor-orphaned-harness.js +132 -0
- package/dist/src/commands/doctor.js +2 -0
- package/dist/src/commands/download.js +5 -2
- package/dist/src/commands/export-flow.d.ts +5 -1
- package/dist/src/commands/export-flow.js +10 -7
- package/dist/src/commands/export-placement-gate.js +3 -5
- package/dist/src/commands/export-placement-policy.d.ts +11 -3
- package/dist/src/commands/export-placement-policy.js +32 -29
- package/dist/src/commands/export.js +12 -5
- package/dist/src/commands/furnace/index.js +23 -18
- package/dist/src/commands/patch/move-files-create.d.ts +19 -0
- package/dist/src/commands/patch/move-files-create.js +116 -0
- package/dist/src/commands/patch/move-files.d.ts +3 -1
- package/dist/src/commands/patch/move-files.js +49 -4
- package/dist/src/commands/patch/split.d.ts +11 -1
- package/dist/src/commands/patch/split.js +6 -5
- package/dist/src/commands/patch/staged-dependency.d.ts +1 -1
- package/dist/src/commands/patch/staged-dependency.js +113 -51
- package/dist/src/commands/re-export-files.js +4 -0
- package/dist/src/commands/re-export-scan.d.ts +17 -0
- package/dist/src/commands/re-export-scan.js +51 -0
- package/dist/src/commands/re-export.js +25 -6
- package/dist/src/commands/source.js +28 -3
- package/dist/src/commands/test-diagnose.js +20 -1
- package/dist/src/commands/test-register.js +6 -5
- package/dist/src/commands/test-stale-gate.d.ts +44 -0
- package/dist/src/commands/test-stale-gate.js +81 -0
- package/dist/src/commands/test.js +40 -23
- package/dist/src/core/build-audit.d.ts +12 -0
- package/dist/src/core/build-audit.js +14 -0
- package/dist/src/core/build-baseline-types.d.ts +58 -0
- package/dist/src/core/build-baseline.d.ts +10 -2
- package/dist/src/core/build-baseline.js +51 -7
- package/dist/src/core/config-paths.d.ts +1 -1
- package/dist/src/core/config-paths.js +1 -0
- package/dist/src/core/config-validate.js +6 -1
- package/dist/src/core/engine-session-lock.d.ts +10 -1
- package/dist/src/core/engine-session-lock.js +28 -2
- package/dist/src/core/file-lock.d.ts +33 -0
- package/dist/src/core/file-lock.js +32 -6
- package/dist/src/core/firefox-archive.d.ts +2 -1
- package/dist/src/core/firefox-archive.js +32 -8
- package/dist/src/core/firefox.d.ts +8 -3
- package/dist/src/core/firefox.js +11 -6
- package/dist/src/core/furnace-config-custom.js +10 -0
- package/dist/src/core/furnace-stale-export.d.ts +59 -0
- package/dist/src/core/furnace-stale-export.js +123 -0
- package/dist/src/core/furnace-validate-compatibility.js +9 -2
- package/dist/src/core/furnace-validate-helpers.js +28 -1
- package/dist/src/core/furnace-validate-structure.js +3 -2
- package/dist/src/core/license-headers.d.ts +12 -0
- package/dist/src/core/license-headers.js +76 -1
- package/dist/src/core/mach-known-noise-filter.d.ts +60 -0
- package/dist/src/core/mach-known-noise-filter.js +193 -0
- package/dist/src/core/mach.d.ts +8 -0
- package/dist/src/core/mach.js +36 -3
- package/dist/src/core/patch-lint-cross.js +80 -2
- package/dist/src/core/patch-lint.d.ts +3 -0
- package/dist/src/core/patch-lint.js +25 -7
- package/dist/src/core/patch-manifest-io.d.ts +3 -2
- package/dist/src/core/patch-manifest-io.js +27 -16
- package/dist/src/core/patch-manifest-validate.js +24 -0
- package/dist/src/core/status-classify.d.ts +11 -2
- package/dist/src/core/status-classify.js +49 -25
- package/dist/src/core/test-harness-crash.d.ts +16 -0
- package/dist/src/core/test-harness-crash.js +55 -0
- package/dist/src/core/test-stale-check.d.ts +69 -1
- package/dist/src/core/test-stale-check.js +134 -1
- package/dist/src/types/commands/index.d.ts +1 -1
- package/dist/src/types/commands/options.d.ts +84 -7
- package/dist/src/types/commands/patches.d.ts +29 -0
- package/dist/src/types/config.d.ts +7 -0
- package/dist/src/types/furnace.d.ts +13 -0
- package/dist/src/utils/options.d.ts +20 -0
- package/dist/src/utils/options.js +39 -0
- package/dist/src/utils/process-group.d.ts +33 -0
- package/dist/src/utils/process-group.js +161 -0
- package/dist/src/utils/process.d.ts +15 -0
- package/dist/src/utils/process.js +88 -44
- package/dist/src/utils/validation.d.ts +5 -0
- package/dist/src/utils/validation.js +7 -0
- package/package.json +3 -3
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stale-furnace-source gate for `fireforge patch export` / `re-export`.
|
|
3
|
+
*
|
|
4
|
+
* Exports capture the DEPLOYED engine copies of furnace-managed files, not
|
|
5
|
+
* the `components/` sources. Editing a component source and re-exporting
|
|
6
|
+
* its owning patch WITHOUT an intervening `furnace deploy`/`apply` silently
|
|
7
|
+
* exports the stale deployed copy — per-patch lint then flags the old line
|
|
8
|
+
* count and the patch body lags the source. This gate detects that
|
|
9
|
+
* sequence by comparing component source directories against the checksums
|
|
10
|
+
* recorded at the last apply (`FurnaceState.appliedChecksums`) — the same
|
|
11
|
+
* signal `warnIfFurnaceStale` uses for run/watch — and refuses the export
|
|
12
|
+
* unless the operator passes `--allow-stale-furnace`.
|
|
13
|
+
*
|
|
14
|
+
* Checksum-based on purpose: git checkouts and `furnace refresh` churn
|
|
15
|
+
* mtimes without content changes, so an mtime comparison would misfire.
|
|
16
|
+
*
|
|
17
|
+
* Probe failures (broken furnace config, missing state) degrade to a
|
|
18
|
+
* verbose log and an empty result — a broken furnace setup must never
|
|
19
|
+
* block non-furnace patch work.
|
|
20
|
+
*/
|
|
21
|
+
/** One furnace component whose source drifted from its deployed copy. */
|
|
22
|
+
export interface StaleFurnaceComponent {
|
|
23
|
+
/** Component name (furnace.json key). */
|
|
24
|
+
name: string;
|
|
25
|
+
/** Component flavor — resolves the source directory root. */
|
|
26
|
+
type: 'custom' | 'override';
|
|
27
|
+
/** Engine-relative deployed prefix (`/`-terminated) the component owns. */
|
|
28
|
+
prefix: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Returns the furnace components whose deployed engine copies the given
|
|
32
|
+
* files fall under AND whose `components/` sources have changed since the
|
|
33
|
+
* last apply. Empty when furnace is not configured, never applied, or the
|
|
34
|
+
* probe fails.
|
|
35
|
+
*
|
|
36
|
+
* Files are attributed per component: the custom `targetPath` / override
|
|
37
|
+
* `basePath` prefixes, plus — for a localized non-sharedFtl custom
|
|
38
|
+
* component — its exact deployed `<ftlDir>/<name>.ftl` file (the shared
|
|
39
|
+
* FTL dir as a whole stays unclaimed; sibling files there belong to other
|
|
40
|
+
* components). `sharedFtl` bundles are NOT furnace-deployed (apply only
|
|
41
|
+
* prunes a dangling per-widget jar.mn line), so they have no deployed copy
|
|
42
|
+
* to go stale and get no candidate by design. The storybook stories prefix
|
|
43
|
+
* is likewise skipped.
|
|
44
|
+
*
|
|
45
|
+
* @param projectRoot - Project root directory
|
|
46
|
+
* @param files - Engine-relative paths the export will capture
|
|
47
|
+
*/
|
|
48
|
+
export declare function findStaleFurnaceComponentsForFiles(projectRoot: string, files: readonly string[]): Promise<StaleFurnaceComponent[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Gate helper for export/re-export: refuses (or, with `allow`, warns) when
|
|
51
|
+
* any exported file belongs to a furnace component whose source changed
|
|
52
|
+
* since the last apply — the export would capture the stale deployed copy.
|
|
53
|
+
*
|
|
54
|
+
* @param projectRoot - Project root directory
|
|
55
|
+
* @param files - Engine-relative paths the export will capture
|
|
56
|
+
* @param allow - True when the operator passed `--allow-stale-furnace`
|
|
57
|
+
* @param command - Which command runs the gate (message wording only)
|
|
58
|
+
*/
|
|
59
|
+
export declare function enforceFreshFurnaceSources(projectRoot: string, files: readonly string[], allow: boolean, command: 'export' | 're-export'): Promise<void>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Stale-furnace-source gate for `fireforge patch export` / `re-export`.
|
|
4
|
+
*
|
|
5
|
+
* Exports capture the DEPLOYED engine copies of furnace-managed files, not
|
|
6
|
+
* the `components/` sources. Editing a component source and re-exporting
|
|
7
|
+
* its owning patch WITHOUT an intervening `furnace deploy`/`apply` silently
|
|
8
|
+
* exports the stale deployed copy — per-patch lint then flags the old line
|
|
9
|
+
* count and the patch body lags the source. This gate detects that
|
|
10
|
+
* sequence by comparing component source directories against the checksums
|
|
11
|
+
* recorded at the last apply (`FurnaceState.appliedChecksums`) — the same
|
|
12
|
+
* signal `warnIfFurnaceStale` uses for run/watch — and refuses the export
|
|
13
|
+
* unless the operator passes `--allow-stale-furnace`.
|
|
14
|
+
*
|
|
15
|
+
* Checksum-based on purpose: git checkouts and `furnace refresh` churn
|
|
16
|
+
* mtimes without content changes, so an mtime comparison would misfire.
|
|
17
|
+
*
|
|
18
|
+
* Probe failures (broken furnace config, missing state) degrade to a
|
|
19
|
+
* verbose log and an empty result — a broken furnace setup must never
|
|
20
|
+
* block non-furnace patch work.
|
|
21
|
+
*/
|
|
22
|
+
import { GeneralError } from '../errors/base.js';
|
|
23
|
+
import { pathExists } from '../utils/fs.js';
|
|
24
|
+
import { verbose, warn } from '../utils/logger.js';
|
|
25
|
+
import { extractComponentChecksums, hasComponentChanged } from './furnace-apply-helpers.js';
|
|
26
|
+
import { furnaceConfigExists, getFurnacePaths, loadFurnaceConfig, loadFurnaceState, } from './furnace-config.js';
|
|
27
|
+
import { resolveFtlDir } from './furnace-constants.js';
|
|
28
|
+
/**
|
|
29
|
+
* Returns the furnace components whose deployed engine copies the given
|
|
30
|
+
* files fall under AND whose `components/` sources have changed since the
|
|
31
|
+
* last apply. Empty when furnace is not configured, never applied, or the
|
|
32
|
+
* probe fails.
|
|
33
|
+
*
|
|
34
|
+
* Files are attributed per component: the custom `targetPath` / override
|
|
35
|
+
* `basePath` prefixes, plus — for a localized non-sharedFtl custom
|
|
36
|
+
* component — its exact deployed `<ftlDir>/<name>.ftl` file (the shared
|
|
37
|
+
* FTL dir as a whole stays unclaimed; sibling files there belong to other
|
|
38
|
+
* components). `sharedFtl` bundles are NOT furnace-deployed (apply only
|
|
39
|
+
* prunes a dangling per-widget jar.mn line), so they have no deployed copy
|
|
40
|
+
* to go stale and get no candidate by design. The storybook stories prefix
|
|
41
|
+
* is likewise skipped.
|
|
42
|
+
*
|
|
43
|
+
* @param projectRoot - Project root directory
|
|
44
|
+
* @param files - Engine-relative paths the export will capture
|
|
45
|
+
*/
|
|
46
|
+
export async function findStaleFurnaceComponentsForFiles(projectRoot, files) {
|
|
47
|
+
try {
|
|
48
|
+
if (files.length === 0)
|
|
49
|
+
return [];
|
|
50
|
+
if (!(await furnaceConfigExists(projectRoot)))
|
|
51
|
+
return [];
|
|
52
|
+
const config = await loadFurnaceConfig(projectRoot);
|
|
53
|
+
const state = await loadFurnaceState(projectRoot);
|
|
54
|
+
if (!state.appliedChecksums)
|
|
55
|
+
return [];
|
|
56
|
+
const furnacePaths = getFurnacePaths(projectRoot);
|
|
57
|
+
const ftlDir = resolveFtlDir(config.ftlBasePath);
|
|
58
|
+
const ftlPrefix = ftlDir.endsWith('/') ? ftlDir : `${ftlDir}/`;
|
|
59
|
+
const candidates = [
|
|
60
|
+
...Object.entries(config.overrides).map(([name, cfg]) => ({
|
|
61
|
+
name,
|
|
62
|
+
type: 'override',
|
|
63
|
+
prefix: cfg.basePath.endsWith('/') ? cfg.basePath : `${cfg.basePath}/`,
|
|
64
|
+
exactFiles: [],
|
|
65
|
+
})),
|
|
66
|
+
...Object.entries(config.custom).map(([name, cfg]) => ({
|
|
67
|
+
name,
|
|
68
|
+
type: 'custom',
|
|
69
|
+
prefix: cfg.targetPath.endsWith('/') ? cfg.targetPath : `${cfg.targetPath}/`,
|
|
70
|
+
// A localized non-sharedFtl component also owns exactly one
|
|
71
|
+
// deployed file in the shared FTL dir (applyCustomFtlFile copies
|
|
72
|
+
// `<name>.ftl` there). sharedFtl bundles are not furnace-deployed.
|
|
73
|
+
exactFiles: cfg.localized && cfg.sharedFtl === undefined ? [`${ftlPrefix}${name}.ftl`] : [],
|
|
74
|
+
})),
|
|
75
|
+
];
|
|
76
|
+
const stale = [];
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
if (!files.some((file) => file.startsWith(candidate.prefix) || candidate.exactFiles.includes(file)))
|
|
79
|
+
continue;
|
|
80
|
+
const sourceRoot = candidate.type === 'override' ? furnacePaths.overridesDir : furnacePaths.customDir;
|
|
81
|
+
const sourceDir = `${sourceRoot}/${candidate.name}`;
|
|
82
|
+
if (!(await pathExists(sourceDir)))
|
|
83
|
+
continue;
|
|
84
|
+
const previous = extractComponentChecksums(state.appliedChecksums, candidate.type, candidate.name);
|
|
85
|
+
if (await hasComponentChanged(sourceDir, previous)) {
|
|
86
|
+
stale.push({ name: candidate.name, type: candidate.type, prefix: candidate.prefix });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return stale;
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
verbose(`Stale-furnace export gate skipped due to an error: ${error instanceof Error ? error.message : String(error)}`);
|
|
93
|
+
return [];
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Gate helper for export/re-export: refuses (or, with `allow`, warns) when
|
|
98
|
+
* any exported file belongs to a furnace component whose source changed
|
|
99
|
+
* since the last apply — the export would capture the stale deployed copy.
|
|
100
|
+
*
|
|
101
|
+
* @param projectRoot - Project root directory
|
|
102
|
+
* @param files - Engine-relative paths the export will capture
|
|
103
|
+
* @param allow - True when the operator passed `--allow-stale-furnace`
|
|
104
|
+
* @param command - Which command runs the gate (message wording only)
|
|
105
|
+
*/
|
|
106
|
+
export async function enforceFreshFurnaceSources(projectRoot, files, allow, command) {
|
|
107
|
+
const stale = await findStaleFurnaceComponentsForFiles(projectRoot, files);
|
|
108
|
+
if (stale.length === 0)
|
|
109
|
+
return;
|
|
110
|
+
const list = stale
|
|
111
|
+
.map((component) => `${component.name} (components/${component.type === 'custom' ? 'custom' : 'overrides'}/${component.name}/ → ${component.prefix})`)
|
|
112
|
+
.join(', ');
|
|
113
|
+
const message = `Component source${stale.length === 1 ? '' : 's'} changed since the last furnace apply: ${list}. ` +
|
|
114
|
+
`The deployed engine cop${stale.length === 1 ? 'y is' : 'ies are'} stale, so this ${command} would ` +
|
|
115
|
+
'capture the OLD component content into the patch. Run "fireforge furnace deploy" (or ' +
|
|
116
|
+
'"fireforge furnace apply") first so the export captures the current source, or pass ' +
|
|
117
|
+
'--allow-stale-furnace to export the deployed copy anyway.';
|
|
118
|
+
if (allow) {
|
|
119
|
+
warn(message);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
throw new GeneralError(message);
|
|
123
|
+
}
|
|
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { pathExists, readText } from '../utils/fs.js';
|
|
4
4
|
import { hasRawCssColors } from '../utils/regex.js';
|
|
5
5
|
import { classExtendsMozLitElement, collectCssVariableDeclarations, collectCssVariableReferences, createIssue, getTokenPrefixContext, hasCustomElementDefineCall, hasRelativeModuleImport, stripCssBlockComments, } from './furnace-validate-helpers.js';
|
|
6
|
-
async function validateMjsCompatibility(mjsPath, tagName) {
|
|
6
|
+
async function validateMjsCompatibility(mjsPath, tagName, isLibrary) {
|
|
7
7
|
if (!(await pathExists(mjsPath)))
|
|
8
8
|
return [];
|
|
9
9
|
const mjsContent = await readText(mjsPath);
|
|
@@ -11,6 +11,12 @@ async function validateMjsCompatibility(mjsPath, tagName) {
|
|
|
11
11
|
if (hasRelativeModuleImport(mjsContent)) {
|
|
12
12
|
issues.push(createIssue(tagName, 'error', 'relative-import', 'Imports must use chrome:// URIs, not relative paths.'));
|
|
13
13
|
}
|
|
14
|
+
// A library component (kind: "library") deliberately defines no element:
|
|
15
|
+
// it exports a base class + helpers for other components to extend, so
|
|
16
|
+
// the define/extends requirements do not apply.
|
|
17
|
+
if (isLibrary) {
|
|
18
|
+
return issues;
|
|
19
|
+
}
|
|
14
20
|
if (!hasCustomElementDefineCall(mjsContent)) {
|
|
15
21
|
issues.push(createIssue(tagName, 'error', 'no-custom-element-define', 'Missing customElements.define() call. Component will not be registered.'));
|
|
16
22
|
}
|
|
@@ -124,7 +130,8 @@ export async function validateCompatibility(componentDir, tagName, type, config,
|
|
|
124
130
|
const issues = [];
|
|
125
131
|
const mjsPath = join(componentDir, `${tagName}.mjs`);
|
|
126
132
|
const cssPath = join(componentDir, `${tagName}.css`);
|
|
127
|
-
const
|
|
133
|
+
const isLibrary = type === 'custom' && config?.custom[tagName]?.kind === 'library';
|
|
134
|
+
const mjsIssues = await validateMjsCompatibility(mjsPath, tagName, isLibrary);
|
|
128
135
|
issues.push(...mjsIssues);
|
|
129
136
|
const cssIssues = await validateCssCompatibility(cssPath, tagName, type, config, root);
|
|
130
137
|
issues.push(...cssIssues);
|
|
@@ -27,10 +27,33 @@ export function hasPositiveTabindex(content) {
|
|
|
27
27
|
return value !== undefined && parseInt(value, 10) > 0;
|
|
28
28
|
});
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* Collects `[start, end)` spans of `<label>…</label>` elements whose content
|
|
32
|
+
* includes actual label text. Labels cannot nest per HTML, so the non-greedy
|
|
33
|
+
* inner match is sound. A span qualifies only when the inner content minus
|
|
34
|
+
* tags still contains non-whitespace — an empty or tag-only wrapper provides
|
|
35
|
+
* no accessible name. A `${…}` Lit binding counts as label text: dynamic
|
|
36
|
+
* (usually localized) text is a legitimate accessible name.
|
|
37
|
+
*/
|
|
38
|
+
function collectLabelledSpans(content) {
|
|
39
|
+
const spans = [];
|
|
40
|
+
const labelPattern = /<label\b[^>]*>([\s\S]*?)<\/label>/gi;
|
|
41
|
+
let labelMatch;
|
|
42
|
+
while ((labelMatch = labelPattern.exec(content)) !== null) {
|
|
43
|
+
const inner = labelMatch[1] ?? '';
|
|
44
|
+
if (/\S/.test(inner.replace(/<[^>]*>/g, ' '))) {
|
|
45
|
+
spans.push({ start: labelMatch.index, end: labelMatch.index + labelMatch[0].length });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return spans;
|
|
49
|
+
}
|
|
30
50
|
/** Detects form inputs without associated labels. */
|
|
31
51
|
export function hasUnlabelledFormInput(content) {
|
|
32
52
|
// Look for <input> or <select> or <textarea> without aria-label, aria-labelledby, or id
|
|
33
|
-
// (id implies an external <label for="..."> could exist)
|
|
53
|
+
// (id implies an external <label for="..."> could exist). A control wrapped
|
|
54
|
+
// in a <label> that carries actual text is implicitly associated and is
|
|
55
|
+
// exempt as well.
|
|
56
|
+
const labelledSpans = collectLabelledSpans(content);
|
|
34
57
|
const inputPattern = /<(input|select|textarea)\b([^>]*)>/gi;
|
|
35
58
|
let inputMatch;
|
|
36
59
|
while ((inputMatch = inputPattern.exec(content)) !== null) {
|
|
@@ -41,6 +64,10 @@ export function hasUnlabelledFormInput(content) {
|
|
|
41
64
|
/type\s*=\s*["']hidden["']/i.test(attrs)) {
|
|
42
65
|
continue;
|
|
43
66
|
}
|
|
67
|
+
const index = inputMatch.index;
|
|
68
|
+
if (labelledSpans.some((span) => index >= span.start && index < span.end)) {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
44
71
|
return true;
|
|
45
72
|
}
|
|
46
73
|
return false;
|
|
@@ -37,8 +37,9 @@ export async function validateStructure(componentDir, tagName, type, customConfi
|
|
|
37
37
|
message: `Required file ${tagName}.mjs not found.`,
|
|
38
38
|
});
|
|
39
39
|
}
|
|
40
|
-
// .css should exist
|
|
41
|
-
|
|
40
|
+
// .css should exist — except for library components (base class + helpers,
|
|
41
|
+
// no element of their own), which render nothing and need no stylesheet.
|
|
42
|
+
if (!(await pathExists(cssPath)) && customConfig?.kind !== 'library') {
|
|
42
43
|
issues.push({
|
|
43
44
|
component: tagName,
|
|
44
45
|
severity: 'warning',
|
|
@@ -34,6 +34,11 @@ export declare function getLicenseHeader(license: ProjectLicense, style: Comment
|
|
|
34
34
|
* on lines 1–2 with the MPL header on lines 3+, which the raw
|
|
35
35
|
* `startsWith` check would otherwise miss.
|
|
36
36
|
*
|
|
37
|
+
* The MPL-2.0 header is additionally matched on normalized whitespace
|
|
38
|
+
* (see {@link normalizeCommentHead}) so upstream files using the older
|
|
39
|
+
* Mozilla wrap — breaking after "file," instead of "with this" — are
|
|
40
|
+
* accepted too; only the wrap position differs, never the wording.
|
|
41
|
+
*
|
|
37
42
|
* @param content - File content to check
|
|
38
43
|
* @param style - Comment syntax of the file
|
|
39
44
|
*/
|
|
@@ -51,6 +56,13 @@ export declare function hasAnyLicenseHeader(content: string, style: CommentStyle
|
|
|
51
56
|
* patch-lint, making it dead code for every other project license and
|
|
52
57
|
* forcing repo-side audit workarounds.
|
|
53
58
|
*
|
|
59
|
+
* Matching is wrap-agnostic: after the exact `startsWith` fast path, the
|
|
60
|
+
* leading block comment is compared on normalized whitespace (see
|
|
61
|
+
* {@link normalizeCommentHead}) so the older upstream wrap — breaking
|
|
62
|
+
* after "file," instead of "with this", as `ext-browser.js` ships —
|
|
63
|
+
* passes too. Only the line-break position may differ; altered wording
|
|
64
|
+
* still rejects.
|
|
65
|
+
*
|
|
54
66
|
* @param content - File content to check
|
|
55
67
|
*/
|
|
56
68
|
export declare function hasUpstreamMplBlockHeader(content: string): boolean;
|
|
@@ -86,6 +86,59 @@ function stripLeadingEditorDirectives(content) {
|
|
|
86
86
|
} while (result !== prev);
|
|
87
87
|
return result;
|
|
88
88
|
}
|
|
89
|
+
/**
|
|
90
|
+
* Collapses all whitespace runs to single spaces and trims, so header
|
|
91
|
+
* comparisons ignore the exact line-break/wrap positions.
|
|
92
|
+
*/
|
|
93
|
+
function normalizeText(text) {
|
|
94
|
+
return text.replace(/\s+/g, ' ').trim();
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The canonical upstream MPL-2.0 notice as a single normalized sentence
|
|
98
|
+
* (including the `http://mozilla.org/MPL/2.0/.` URL). Wrap-agnostic
|
|
99
|
+
* header checks match against this instead of an exact rendering.
|
|
100
|
+
*/
|
|
101
|
+
const MPL_HEADER_NORMALIZED = normalizeText(HEADER_LINES['MPL-2.0'].join(' '));
|
|
102
|
+
/**
|
|
103
|
+
* Extracts ONLY the leading comment span of `content` for the given
|
|
104
|
+
* comment style and returns it whitespace-normalized:
|
|
105
|
+
*
|
|
106
|
+
* - `js`: a leading run of `//` lines (markers stripped), or — when the
|
|
107
|
+
* file opens with a block comment instead — the first
|
|
108
|
+
* `/* … *\/` block;
|
|
109
|
+
* - `css`: the first `/* … *\/` block, with the ` * ` continuation-line
|
|
110
|
+
* prefixes stripped (same collapsing
|
|
111
|
+
* {@link normalizeLicenseHeadForScan} does);
|
|
112
|
+
* - `hash`: a leading run of `#` lines (markers stripped).
|
|
113
|
+
*
|
|
114
|
+
* A non-comment first line yields `''`. This lets callers accept a known
|
|
115
|
+
* header text regardless of where upstream wrapped its lines (e.g. the
|
|
116
|
+
* older Mozilla wrap that breaks after "file,"), without ever matching
|
|
117
|
+
* text beyond the leading comment.
|
|
118
|
+
*/
|
|
119
|
+
function normalizeCommentHead(content, style) {
|
|
120
|
+
const src = content.replace(/\r\n?/g, '\n');
|
|
121
|
+
const collectLineRun = (marker) => {
|
|
122
|
+
const parts = [];
|
|
123
|
+
for (const line of src.split('\n')) {
|
|
124
|
+
const m = marker.exec(line);
|
|
125
|
+
if (!m)
|
|
126
|
+
break;
|
|
127
|
+
parts.push(m[1] ?? '');
|
|
128
|
+
}
|
|
129
|
+
return normalizeText(parts.join(' '));
|
|
130
|
+
};
|
|
131
|
+
if (style === 'hash') {
|
|
132
|
+
return collectLineRun(/^[ \t]*#[ \t]?(.*)$/);
|
|
133
|
+
}
|
|
134
|
+
if (style === 'js' && /^[ \t]*\/\//.test(src)) {
|
|
135
|
+
return collectLineRun(/^[ \t]*\/\/[ \t]?(.*)$/);
|
|
136
|
+
}
|
|
137
|
+
const block = /^[ \t]*\/\*([\s\S]*?)\*\//.exec(src);
|
|
138
|
+
if (!block)
|
|
139
|
+
return '';
|
|
140
|
+
return normalizeText((block[1] ?? '').replace(/\n[ \t]*\*[ \t]*/g, ' '));
|
|
141
|
+
}
|
|
89
142
|
/**
|
|
90
143
|
* Returns true if `content` starts with any known license header for the
|
|
91
144
|
* given comment style.
|
|
@@ -103,6 +156,11 @@ function stripLeadingEditorDirectives(content) {
|
|
|
103
156
|
* on lines 1–2 with the MPL header on lines 3+, which the raw
|
|
104
157
|
* `startsWith` check would otherwise miss.
|
|
105
158
|
*
|
|
159
|
+
* The MPL-2.0 header is additionally matched on normalized whitespace
|
|
160
|
+
* (see {@link normalizeCommentHead}) so upstream files using the older
|
|
161
|
+
* Mozilla wrap — breaking after "file," instead of "with this" — are
|
|
162
|
+
* accepted too; only the wrap position differs, never the wording.
|
|
163
|
+
*
|
|
106
164
|
* @param content - File content to check
|
|
107
165
|
* @param style - Comment syntax of the file
|
|
108
166
|
*/
|
|
@@ -116,6 +174,9 @@ export function hasAnyLicenseHeader(content, style) {
|
|
|
116
174
|
if (style === 'js' && candidate.startsWith(getLicenseHeader('MPL-2.0', 'css'))) {
|
|
117
175
|
return true;
|
|
118
176
|
}
|
|
177
|
+
if (normalizeCommentHead(candidate, style).startsWith(MPL_HEADER_NORMALIZED)) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
119
180
|
}
|
|
120
181
|
return false;
|
|
121
182
|
}
|
|
@@ -132,11 +193,25 @@ export function hasAnyLicenseHeader(content, style) {
|
|
|
132
193
|
* patch-lint, making it dead code for every other project license and
|
|
133
194
|
* forcing repo-side audit workarounds.
|
|
134
195
|
*
|
|
196
|
+
* Matching is wrap-agnostic: after the exact `startsWith` fast path, the
|
|
197
|
+
* leading block comment is compared on normalized whitespace (see
|
|
198
|
+
* {@link normalizeCommentHead}) so the older upstream wrap — breaking
|
|
199
|
+
* after "file," instead of "with this", as `ext-browser.js` ships —
|
|
200
|
+
* passes too. Only the line-break position may differ; altered wording
|
|
201
|
+
* still rejects.
|
|
202
|
+
*
|
|
135
203
|
* @param content - File content to check
|
|
136
204
|
*/
|
|
137
205
|
export function hasUpstreamMplBlockHeader(content) {
|
|
138
206
|
const blockHeader = getLicenseHeader('MPL-2.0', 'css');
|
|
139
|
-
|
|
207
|
+
const candidates = [content, stripLeadingEditorDirectives(content)];
|
|
208
|
+
for (const candidate of candidates) {
|
|
209
|
+
if (candidate.startsWith(blockHeader))
|
|
210
|
+
return true;
|
|
211
|
+
if (normalizeCommentHead(candidate, 'css').startsWith(MPL_HEADER_NORMALIZED))
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
140
215
|
}
|
|
141
216
|
/**
|
|
142
217
|
* Returns true if `content` starts with any known license header in any
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal-echo filter for the KNOWN upstream mozsystemmonitor teardown
|
|
3
|
+
* traceback (0.37.0 item 8).
|
|
4
|
+
*
|
|
5
|
+
* Every headless test run against the 153-beta engine ends with an
|
|
6
|
+
* `AttributeError: 'SystemResourceMonitor' object has no attribute
|
|
7
|
+
* 'stop_time'` traceback at harness teardown — upstream noise that sits
|
|
8
|
+
* exactly where a reader looks for the failure summary. 0.36.0 already made
|
|
9
|
+
* real failure lines beat this traceback in CLASSIFICATION; this filter
|
|
10
|
+
* closes the PRESENTATION gap by collapsing the echoed traceback to one
|
|
11
|
+
* labeled line.
|
|
12
|
+
*
|
|
13
|
+
* Scope is deliberately narrow:
|
|
14
|
+
* - Only the terminal ECHO is filtered. The captured stdout/stderr strings
|
|
15
|
+
* stay raw — the classifier (`test-harness-crash.ts`) depends on the
|
|
16
|
+
* raw traceback for its green-summary override and secondary-noise
|
|
17
|
+
* detection.
|
|
18
|
+
* - Only the exact documented incident is collapsed, and every condition
|
|
19
|
+
* must hold: an `AttributeError` on `SystemResourceMonitor` naming one
|
|
20
|
+
* of the two known attributes (`stop_time`, `poll_interval` — the
|
|
21
|
+
* 0.34.0 guard family), AND a `mozsystemmonitor/resourcemonitor.py`
|
|
22
|
+
* stack frame, AND a previously-seen SUITE_END shutdown marker (shared
|
|
23
|
+
* across the run's stdout/stderr filter instances — the marker usually
|
|
24
|
+
* lands on stdout while the traceback lands on stderr). A novel
|
|
25
|
+
* attribute, a novel exception type in resourcemonitor.py, or a
|
|
26
|
+
* pre-shutdown occurrence is echoed verbatim, always.
|
|
27
|
+
* - The hold buffer is bounded; on overflow the block is flushed verbatim
|
|
28
|
+
* and the filter returns to pass-through, so output is never lost.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Shared across the stdout and stderr filter instances of ONE mach run:
|
|
32
|
+
* SUITE_END typically arrives on stdout while the teardown traceback lands
|
|
33
|
+
* on stderr, so the shutdown-seen flag must be visible to both. The two
|
|
34
|
+
* pipes are independent, so a traceback can theoretically beat the marker
|
|
35
|
+
* through — the block then prints verbatim, the correct failure direction.
|
|
36
|
+
*/
|
|
37
|
+
export interface TeardownNoiseContext {
|
|
38
|
+
shutdownSeen: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** Fresh per-run context — hand the same instance to both stream filters. */
|
|
41
|
+
export declare function createTeardownNoiseContext(): TeardownNoiseContext;
|
|
42
|
+
/** One line replaces the whole recognized traceback block in the echo. */
|
|
43
|
+
export declare const KNOWN_TEARDOWN_NOISE_ANNOTATION: string;
|
|
44
|
+
/** Chunk-safe, line-buffered echo filter. See module doc. */
|
|
45
|
+
export interface KnownTeardownNoiseFilter {
|
|
46
|
+
/** Feed a raw chunk; returns the text to echo now (possibly empty). */
|
|
47
|
+
transform(chunk: string): string;
|
|
48
|
+
/** Flush any buffered residue verbatim (call after the stream closes). */
|
|
49
|
+
flush(): string;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Creates a stateful filter for one output stream. Complete lines outside a
|
|
53
|
+
* traceback pass straight through (only the trailing partial line is held
|
|
54
|
+
* back); a `Traceback (most recent call last)` header switches to hold mode
|
|
55
|
+
* until the block ends, then either the one-line annotation (recognized
|
|
56
|
+
* signature after a seen shutdown marker) or the verbatim block (anything
|
|
57
|
+
* else) is emitted. Pass the run's shared {@link TeardownNoiseContext} so
|
|
58
|
+
* both stream filters see the same shutdown flag.
|
|
59
|
+
*/
|
|
60
|
+
export declare function createKnownTeardownNoiseFilter(context?: TeardownNoiseContext): KnownTeardownNoiseFilter;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// SPDX-License-Identifier: EUPL-1.2
|
|
2
|
+
/**
|
|
3
|
+
* Terminal-echo filter for the KNOWN upstream mozsystemmonitor teardown
|
|
4
|
+
* traceback (0.37.0 item 8).
|
|
5
|
+
*
|
|
6
|
+
* Every headless test run against the 153-beta engine ends with an
|
|
7
|
+
* `AttributeError: 'SystemResourceMonitor' object has no attribute
|
|
8
|
+
* 'stop_time'` traceback at harness teardown — upstream noise that sits
|
|
9
|
+
* exactly where a reader looks for the failure summary. 0.36.0 already made
|
|
10
|
+
* real failure lines beat this traceback in CLASSIFICATION; this filter
|
|
11
|
+
* closes the PRESENTATION gap by collapsing the echoed traceback to one
|
|
12
|
+
* labeled line.
|
|
13
|
+
*
|
|
14
|
+
* Scope is deliberately narrow:
|
|
15
|
+
* - Only the terminal ECHO is filtered. The captured stdout/stderr strings
|
|
16
|
+
* stay raw — the classifier (`test-harness-crash.ts`) depends on the
|
|
17
|
+
* raw traceback for its green-summary override and secondary-noise
|
|
18
|
+
* detection.
|
|
19
|
+
* - Only the exact documented incident is collapsed, and every condition
|
|
20
|
+
* must hold: an `AttributeError` on `SystemResourceMonitor` naming one
|
|
21
|
+
* of the two known attributes (`stop_time`, `poll_interval` — the
|
|
22
|
+
* 0.34.0 guard family), AND a `mozsystemmonitor/resourcemonitor.py`
|
|
23
|
+
* stack frame, AND a previously-seen SUITE_END shutdown marker (shared
|
|
24
|
+
* across the run's stdout/stderr filter instances — the marker usually
|
|
25
|
+
* lands on stdout while the traceback lands on stderr). A novel
|
|
26
|
+
* attribute, a novel exception type in resourcemonitor.py, or a
|
|
27
|
+
* pre-shutdown occurrence is echoed verbatim, always.
|
|
28
|
+
* - The hold buffer is bounded; on overflow the block is flushed verbatim
|
|
29
|
+
* and the filter returns to pass-through, so output is never lost.
|
|
30
|
+
*/
|
|
31
|
+
const TRACEBACK_HEADER_PATTERN = /^Traceback \(most recent call last\)/;
|
|
32
|
+
const CHAINED_EXCEPTION_CONNECTOR_PATTERN = /^(?:During handling of the above exception, another exception occurred:|The above exception was the direct cause of the following exception:)\s*$/;
|
|
33
|
+
/**
|
|
34
|
+
* Closed allowlist of the documented teardown family's attributes:
|
|
35
|
+
* `stop_time` (the 0.37.0 item-8 incident) and `poll_interval` (the same
|
|
36
|
+
* mozsystemmonitor init failure the 0.34.0 resource-guard family covers —
|
|
37
|
+
* `test-harness-crash.ts` already classifies it as recognized noise). Any
|
|
38
|
+
* other missing attribute is a NEW upstream defect and must print verbatim.
|
|
39
|
+
*/
|
|
40
|
+
const KNOWN_TEARDOWN_ATTRIBUTE_ERROR_PATTERN = /AttributeError: 'SystemResourceMonitor' object has no attribute '(?:stop_time|poll_interval)'/;
|
|
41
|
+
const RESOURCEMONITOR_FRAME_PATTERN = /mozsystemmonitor[/\\]resourcemonitor\.py/;
|
|
42
|
+
/**
|
|
43
|
+
* Shutdown marker: the harness's SUITE_END line. Matches the shape
|
|
44
|
+
* `test-harness-crash.ts` keys its summary parsing on (module-private
|
|
45
|
+
* there; duplicated here with this cross-reference rather than exported).
|
|
46
|
+
*/
|
|
47
|
+
const SHUTDOWN_MARKER_PATTERN = /\bSUITE_END\b/;
|
|
48
|
+
/** Whole-block recognition — every signal must hold (see module doc). */
|
|
49
|
+
function isRecognizedTeardownNoise(block) {
|
|
50
|
+
return (KNOWN_TEARDOWN_ATTRIBUTE_ERROR_PATTERN.test(block) && RESOURCEMONITOR_FRAME_PATTERN.test(block));
|
|
51
|
+
}
|
|
52
|
+
/** Fresh per-run context — hand the same instance to both stream filters. */
|
|
53
|
+
export function createTeardownNoiseContext() {
|
|
54
|
+
return { shutdownSeen: false };
|
|
55
|
+
}
|
|
56
|
+
/** One line replaces the whole recognized traceback block in the echo. */
|
|
57
|
+
export const KNOWN_TEARDOWN_NOISE_ANNOTATION = '[FireForge] Known upstream mozsystemmonitor teardown noise (SystemResourceMonitor ' +
|
|
58
|
+
'AttributeError at harness shutdown) — not a test failure. See FireForge docs.\n';
|
|
59
|
+
/** Bounds on the held traceback block before flushing verbatim. */
|
|
60
|
+
const HOLD_LINE_LIMIT = 100;
|
|
61
|
+
const HOLD_BYTE_LIMIT = 16 * 1024;
|
|
62
|
+
/**
|
|
63
|
+
* Creates a stateful filter for one output stream. Complete lines outside a
|
|
64
|
+
* traceback pass straight through (only the trailing partial line is held
|
|
65
|
+
* back); a `Traceback (most recent call last)` header switches to hold mode
|
|
66
|
+
* until the block ends, then either the one-line annotation (recognized
|
|
67
|
+
* signature after a seen shutdown marker) or the verbatim block (anything
|
|
68
|
+
* else) is emitted. Pass the run's shared {@link TeardownNoiseContext} so
|
|
69
|
+
* both stream filters see the same shutdown flag.
|
|
70
|
+
*/
|
|
71
|
+
export function createKnownTeardownNoiseFilter(context = createTeardownNoiseContext()) {
|
|
72
|
+
/** Partial (no trailing newline yet) input line. */
|
|
73
|
+
let partial = '';
|
|
74
|
+
/** Held traceback lines (each WITH its newline) while in hold mode. */
|
|
75
|
+
let held = [];
|
|
76
|
+
let heldBytes = 0;
|
|
77
|
+
/**
|
|
78
|
+
* State machine:
|
|
79
|
+
* 'pass' — outside any traceback; lines echo through.
|
|
80
|
+
* 'inside' — between a Traceback header and its closing exception line.
|
|
81
|
+
* 'closed' — saw the closing `SomeError: …` line; still holding in
|
|
82
|
+
* case a chained-exception connector continues the block
|
|
83
|
+
* (the real fixture chains two tracebacks — the whole
|
|
84
|
+
* chain must be evaluated as ONE block or the second half
|
|
85
|
+
* would print raw after the annotation).
|
|
86
|
+
*/
|
|
87
|
+
let state = 'pass';
|
|
88
|
+
const resetHold = () => {
|
|
89
|
+
const block = held.join('');
|
|
90
|
+
held = [];
|
|
91
|
+
heldBytes = 0;
|
|
92
|
+
state = 'pass';
|
|
93
|
+
return block;
|
|
94
|
+
};
|
|
95
|
+
const releaseHeld = () => {
|
|
96
|
+
const block = resetHold();
|
|
97
|
+
if (block.length === 0)
|
|
98
|
+
return '';
|
|
99
|
+
return context.shutdownSeen && isRecognizedTeardownNoise(block)
|
|
100
|
+
? KNOWN_TEARDOWN_NOISE_ANNOTATION
|
|
101
|
+
: block;
|
|
102
|
+
};
|
|
103
|
+
const hold = (line) => {
|
|
104
|
+
held.push(line);
|
|
105
|
+
heldBytes += line.length;
|
|
106
|
+
if (held.length > HOLD_LINE_LIMIT || heldBytes > HOLD_BYTE_LIMIT) {
|
|
107
|
+
// Pathological block: flush verbatim rather than risk withholding
|
|
108
|
+
// output; do not attempt recognition on oversized blocks.
|
|
109
|
+
return { out: resetHold(), overflowed: true };
|
|
110
|
+
}
|
|
111
|
+
return { out: '', overflowed: false };
|
|
112
|
+
};
|
|
113
|
+
const processLine = (line) => {
|
|
114
|
+
const content = line.replace(/\r?\n$/, '');
|
|
115
|
+
if (state === 'pass') {
|
|
116
|
+
if (TRACEBACK_HEADER_PATTERN.test(content)) {
|
|
117
|
+
state = 'inside';
|
|
118
|
+
return hold(line).out;
|
|
119
|
+
}
|
|
120
|
+
if (SHUTDOWN_MARKER_PATTERN.test(content))
|
|
121
|
+
context.shutdownSeen = true;
|
|
122
|
+
return line;
|
|
123
|
+
}
|
|
124
|
+
if (state === 'inside') {
|
|
125
|
+
const isContinuation = content.trim().length === 0 ||
|
|
126
|
+
/^[ \t]/.test(content) ||
|
|
127
|
+
TRACEBACK_HEADER_PATTERN.test(content);
|
|
128
|
+
const { out, overflowed } = hold(line);
|
|
129
|
+
// The first unindented line is the closing `SomeError: …` line; keep
|
|
130
|
+
// holding in case a chained connector extends the block.
|
|
131
|
+
if (!overflowed && !isContinuation) {
|
|
132
|
+
state = 'closed';
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
// state === 'closed': only a blank line, a chained-exception connector,
|
|
137
|
+
// or another Traceback header continues the block.
|
|
138
|
+
if (content.trim().length === 0 || CHAINED_EXCEPTION_CONNECTOR_PATTERN.test(content)) {
|
|
139
|
+
return hold(line).out;
|
|
140
|
+
}
|
|
141
|
+
if (TRACEBACK_HEADER_PATTERN.test(content)) {
|
|
142
|
+
const { out, overflowed } = hold(line);
|
|
143
|
+
if (!overflowed)
|
|
144
|
+
state = 'inside';
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
return releaseHeld() + processLineInPass(line);
|
|
148
|
+
};
|
|
149
|
+
// Re-dispatch a line through pass-mode handling after a block release —
|
|
150
|
+
// the line that ended the block may itself start a new traceback.
|
|
151
|
+
const processLineInPass = (line) => {
|
|
152
|
+
const content = line.replace(/\r?\n$/, '');
|
|
153
|
+
if (TRACEBACK_HEADER_PATTERN.test(content)) {
|
|
154
|
+
state = 'inside';
|
|
155
|
+
return hold(line).out;
|
|
156
|
+
}
|
|
157
|
+
if (SHUTDOWN_MARKER_PATTERN.test(content))
|
|
158
|
+
context.shutdownSeen = true;
|
|
159
|
+
return line;
|
|
160
|
+
};
|
|
161
|
+
return {
|
|
162
|
+
transform(chunk) {
|
|
163
|
+
let out = '';
|
|
164
|
+
let buffer = partial + chunk;
|
|
165
|
+
partial = '';
|
|
166
|
+
let newlineIndex = buffer.indexOf('\n');
|
|
167
|
+
while (newlineIndex !== -1) {
|
|
168
|
+
const line = buffer.slice(0, newlineIndex + 1);
|
|
169
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
170
|
+
out += processLine(line);
|
|
171
|
+
newlineIndex = buffer.indexOf('\n');
|
|
172
|
+
}
|
|
173
|
+
partial = buffer;
|
|
174
|
+
return out;
|
|
175
|
+
},
|
|
176
|
+
flush() {
|
|
177
|
+
let out = '';
|
|
178
|
+
if (state !== 'pass') {
|
|
179
|
+
// A stream that closes mid-block: fold the trailing partial line in
|
|
180
|
+
// and evaluate — the final exception line is often the last thing
|
|
181
|
+
// printed, without a trailing newline.
|
|
182
|
+
if (partial.length > 0) {
|
|
183
|
+
held.push(partial);
|
|
184
|
+
partial = '';
|
|
185
|
+
}
|
|
186
|
+
out += releaseHeld();
|
|
187
|
+
}
|
|
188
|
+
out += partial;
|
|
189
|
+
partial = '';
|
|
190
|
+
return out;
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|