@boyingliu01/xp-gate 0.31.4 → 0.32.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/adapters/java.sh +256 -4
- package/adapters/powershell.sh +45 -6
- package/bin/xp-gate.js +8 -2
- package/build-integrity/__tests__/gate-10.test.ts +88 -1
- package/build-integrity/gate-10.ts +51 -16
- package/dsh.bundle.patch.yml +15 -0
- package/gate-13-compiler-strictness.sh +1031 -0
- package/gate-3.sh +65 -16
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +65 -16
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +101 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +259 -57
- package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
- package/lib/__tests__/gate-runner.test.js +29 -2
- package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
- package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/check.js +6 -2
- package/lib/detect-deps.js +42 -10
- package/lib/doctor-tui.js +111 -21
- package/lib/gate-runner.js +111 -37
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +48 -6
- package/lib/shared-utils.js +8 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mock-policy/gate-m3.ts +57 -23
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
- package/mutation/__tests__/test-filter-parity.test.ts +5 -2
- package/mutation/gate-m.ts +30 -24
- package/mutation/runners/mutmut-runner.ts +80 -30
- package/mutation/runners/pitest-runner.ts +60 -42
- package/package.json +14 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/command.d.ts +38 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +87 -0
- package/plugins/dsh/lib/credentials.d.ts +39 -0
- package/plugins/dsh/lib/credentials.d.ts.map +1 -0
- package/plugins/dsh/lib/credentials.js +57 -0
- package/plugins/dsh/lib/delphi-config.d.ts +129 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +369 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +113 -0
- package/plugins/dsh/lib/delphi.d.ts +94 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +207 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/evidence.d.ts +70 -0
- package/plugins/dsh/lib/evidence.d.ts.map +1 -0
- package/plugins/dsh/lib/evidence.js +92 -0
- package/plugins/dsh/lib/gate-runner.d.ts +49 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +95 -0
- package/plugins/dsh/lib/git-context.d.ts +26 -0
- package/plugins/dsh/lib/git-context.d.ts.map +1 -0
- package/plugins/dsh/lib/git-context.js +30 -0
- package/plugins/dsh/lib/hooks.d.ts +42 -0
- package/plugins/dsh/lib/hooks.d.ts.map +1 -0
- package/plugins/dsh/lib/hooks.js +113 -0
- package/plugins/dsh/lib/index.d.ts +20 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +430 -0
- package/plugins/dsh/lib/skill-provider.d.ts +15 -0
- package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
- package/plugins/dsh/lib/skill-provider.js +205 -0
- package/plugins/dsh/lib/sprint.d.ts +60 -0
- package/plugins/dsh/lib/sprint.d.ts.map +1 -0
- package/plugins/dsh/lib/sprint.js +92 -0
- package/plugins/dsh/package.json +14 -8
- package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/command-single-form.test.ts +67 -0
- package/plugins/dsh/src/command.test.ts +21 -17
- package/plugins/dsh/src/command.ts +19 -21
- package/plugins/dsh/src/compat.test.ts +200 -0
- package/plugins/dsh/src/contract.test.ts +7 -1
- package/plugins/dsh/src/credentials.test.ts +93 -0
- package/plugins/dsh/src/credentials.ts +71 -0
- package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
- package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
- package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
- package/plugins/dsh/src/delphi-config.ts +146 -53
- package/plugins/dsh/src/delphi-run.ts +27 -4
- package/plugins/dsh/src/delphi.ts +133 -65
- package/plugins/dsh/src/evidence.test.ts +74 -0
- package/plugins/dsh/src/evidence.ts +141 -0
- package/plugins/dsh/src/gate-runner.test.ts +14 -6
- package/plugins/dsh/src/gate-runner.ts +68 -5
- package/plugins/dsh/src/git-context.test.ts +39 -0
- package/plugins/dsh/src/git-context.ts +38 -0
- package/plugins/dsh/src/hooks.test.ts +87 -0
- package/plugins/dsh/src/hooks.ts +126 -0
- package/plugins/dsh/src/index.test.ts +63 -6
- package/plugins/dsh/src/index.ts +340 -13
- package/plugins/dsh/src/skill-provider.test.ts +194 -0
- package/plugins/dsh/src/skill-provider.ts +231 -0
- package/plugins/dsh/src/sprint.test.ts +64 -0
- package/plugins/dsh/src/sprint.ts +122 -0
- package/plugins/dsh/src/tri-state.test.ts +123 -0
- package/plugins/opencode/index.ts +192 -58
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/analyze-read-failure.test.ts +70 -0
- package/principles/__tests__/baseline-prune.test.ts +61 -0
- package/principles/__tests__/cli-files-separators.test.ts +69 -0
- package/principles/__tests__/config-merge-warnings.test.ts +148 -0
- package/principles/adapters/java.ts +22 -3
- package/principles/adapters/typescript.ts +70 -29
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- package/principles/config.ts +56 -35
- package/principles/index.ts +122 -40
- package/principles/rules/clean-code/deep-nesting.ts +36 -22
- package/principles/rules/clean-code/unused-imports.ts +30 -19
- package/principles/rules/solid/dip.ts +30 -20
- package/principles/rules/solid/lsp.ts +17 -2
- package/principles/rules/solid/ocp.ts +33 -23
- package/principles/rules/solid/srp.ts +65 -23
- package/scripts/delphi-external-review.cjs +25 -2
- package/scripts/sync-package-content.js +28 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -336,3 +336,64 @@ describe('#452 REQ-3 stale baseline entries are pruned', () => {
|
|
|
336
336
|
}
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* @test REQ-628
|
|
342
|
+
* @intent `parseBaselineFiles` accepts two shapes: the FLAT map that
|
|
343
|
+
* `saveBaseline` writes, and a legacy `{files: {...}}` wrapper kept for
|
|
344
|
+
* forwards compatibility. The wrapper branch had no test, so deleting it
|
|
345
|
+
* left the whole suite green — a silent regression that would make every
|
|
346
|
+
* real baseline look empty (all entries filtered out as non-objects,
|
|
347
|
+
* since the only top-level key is `files`). These cases pin both shapes
|
|
348
|
+
* plus the scalar-filtering rule that follows from them.
|
|
349
|
+
* @covers AC-628-01, AC-628-02, AC-628-03
|
|
350
|
+
*/
|
|
351
|
+
describe('#628 REQ-3 parseBaselineFiles shape handling', () => {
|
|
352
|
+
it('AC-628-01: a flat map is read entry-for-entry', () => {
|
|
353
|
+
const { root } = makeRepoWithCommit();
|
|
354
|
+
const flat = JSON.stringify({
|
|
355
|
+
'src/a.ts': { totalWarnings: 2, lastAnalyzed: '2026-01-01T00:00:00Z' },
|
|
356
|
+
'src/gone.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00Z' },
|
|
357
|
+
});
|
|
358
|
+
stageIndexOnly(root, ['src/a.ts']);
|
|
359
|
+
|
|
360
|
+
const result = pruneBaselineEntries(flat, root);
|
|
361
|
+
expect(result.kept).toContain('src/a.ts');
|
|
362
|
+
expect(result.removed).toContain('src/gone.ts');
|
|
363
|
+
expect(result.error).toBeUndefined();
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
it('AC-628-02: a {files:{...}} wrapper is unwrapped, not treated as one entry', () => {
|
|
367
|
+
const { root } = makeRepoWithCommit();
|
|
368
|
+
// Without the unwrap, the sole top-level key `files` is an object, so the
|
|
369
|
+
// flat path would yield ONE entry called "files" and silently lose every
|
|
370
|
+
// real path. Assert the real paths surface.
|
|
371
|
+
const wrapped = JSON.stringify({
|
|
372
|
+
files: {
|
|
373
|
+
'src/a.ts': { totalWarnings: 2, lastAnalyzed: '2026-01-01T00:00:00Z' },
|
|
374
|
+
'src/gone.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00Z' },
|
|
375
|
+
},
|
|
376
|
+
});
|
|
377
|
+
stageIndexOnly(root, ['src/a.ts']);
|
|
378
|
+
|
|
379
|
+
const result = pruneBaselineEntries(wrapped, root);
|
|
380
|
+
expect(result.kept).toContain('src/a.ts');
|
|
381
|
+
expect(result.removed).toContain('src/gone.ts');
|
|
382
|
+
expect(result.kept).not.toContain('files');
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it('AC-628-03: scalar top-level keys are metadata and are dropped, not kept as entries', () => {
|
|
386
|
+
const { root } = makeRepoWithCommit();
|
|
387
|
+
const withMeta = JSON.stringify({
|
|
388
|
+
version: 2,
|
|
389
|
+
generatedAt: '2026-01-01T00:00:00Z',
|
|
390
|
+
'src/a.ts': { totalWarnings: 2, lastAnalyzed: '2026-01-01T00:00:00Z' },
|
|
391
|
+
});
|
|
392
|
+
stageIndexOnly(root, ['src/a.ts']);
|
|
393
|
+
|
|
394
|
+
const result = pruneBaselineEntries(withMeta, root);
|
|
395
|
+
expect(result.kept).toEqual(['src/a.ts']);
|
|
396
|
+
expect(result.kept).not.toContain('version');
|
|
397
|
+
expect(result.kept).not.toContain('generatedAt');
|
|
398
|
+
});
|
|
399
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-623
|
|
3
|
+
* @intent `--files` must accept the shapes a shell actually produces. CI passed
|
|
4
|
+
* `--files "$SRC_FILES"` -- one argument containing newline-separated
|
|
5
|
+
* paths -- and parseArgs split on spaces only, so the whole blob became
|
|
6
|
+
* a single impossible path. The run then had nothing to analyse and fell
|
|
7
|
+
* through to the console/empty path, writing "✓ No violations found"
|
|
8
|
+
* into a file named principles.sarif. The step exited 0, so for months
|
|
9
|
+
* the GitHub Security tab received a text file where SARIF was expected.
|
|
10
|
+
*
|
|
11
|
+
* These tests pin the separator handling, not one call site: a fix that
|
|
12
|
+
* only changes the workflow leaves the next caller to hit the same trap.
|
|
13
|
+
* @covers AC-623-01, AC-623-02, AC-623-03
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest';
|
|
17
|
+
import { parseArgs } from '../index';
|
|
18
|
+
|
|
19
|
+
describe('AC-623-01: --files separates on any whitespace, not just spaces', () => {
|
|
20
|
+
it('splits a newline-separated list (the CI shape)', () => {
|
|
21
|
+
const opts = parseArgs(['--files', 'a.ts\nb.ts\nc.ts']);
|
|
22
|
+
expect(opts.files).toEqual(['a.ts', 'b.ts', 'c.ts']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('splits a space-separated list', () => {
|
|
26
|
+
const opts = parseArgs(['--files', 'a.ts b.ts']);
|
|
27
|
+
expect(opts.files).toEqual(['a.ts', 'b.ts']);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('splits a tab-separated list', () => {
|
|
31
|
+
const opts = parseArgs(['--files', 'a.ts\tb.ts']);
|
|
32
|
+
expect(opts.files).toEqual(['a.ts', 'b.ts']);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('drops the blank entries a trailing separators leave behind', () => {
|
|
36
|
+
const opts = parseArgs(['--files', 'a.ts\n\nb.ts\n']);
|
|
37
|
+
expect(opts.files).toEqual(['a.ts', 'b.ts']);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('keeps a single path intact', () => {
|
|
41
|
+
const opts = parseArgs(['--files', 'src/one.ts']);
|
|
42
|
+
expect(opts.files).toEqual(['src/one.ts']);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('AC-623-02: a flag after --files is still parsed', () => {
|
|
47
|
+
it('reads --format sarif that follows a multi-line --files value', () => {
|
|
48
|
+
const opts = parseArgs(['--files', 'a.ts\nb.ts', '--format', 'sarif']);
|
|
49
|
+
expect(opts.format).toBe('sarif');
|
|
50
|
+
expect(opts.files).toEqual(['a.ts', 'b.ts']);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reads --format when it comes before --files', () => {
|
|
54
|
+
const opts = parseArgs(['--format', 'sarif', '--files', 'a.ts\nb.ts']);
|
|
55
|
+
expect(opts.format).toBe('sarif');
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('AC-623-03: positional paths still accumulate', () => {
|
|
60
|
+
it('collects bare paths given without --files', () => {
|
|
61
|
+
const opts = parseArgs(['a.ts', 'b.ts']);
|
|
62
|
+
expect(opts.files).toEqual(['a.ts', 'b.ts']);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('accepts --files alongside positional paths', () => {
|
|
66
|
+
const opts = parseArgs(['--files', 'a.ts\nb.ts', 'c.ts']);
|
|
67
|
+
expect(opts.files).toEqual(['a.ts', 'b.ts', 'c.ts']);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-620
|
|
3
|
+
* @intent Pin mergeRule's mistyped-override handling, which had NO coverage
|
|
4
|
+
* before this change: nothing asserted the warning text or the
|
|
5
|
+
* keep-the-default outcome for a bad .principlesrc value. That gap
|
|
6
|
+
* meant the four per-key loops could be consolidated into one guarded
|
|
7
|
+
* loop without any test noticing, so these tests are what makes the
|
|
8
|
+
* consolidation verifiable rather than merely plausible.
|
|
9
|
+
*
|
|
10
|
+
* Two properties per key category:
|
|
11
|
+
* 1. a correctly-typed override is applied;
|
|
12
|
+
* 2. a mistyped one is REJECTED and the default survives, with a
|
|
13
|
+
* warning naming the rule and the key.
|
|
14
|
+
* Property 2 is the one with teeth: silently accepting a bad value
|
|
15
|
+
* would let a project believe it tightened a gate.
|
|
16
|
+
*
|
|
17
|
+
* .principlesrc is JSON (config.ts parses it with JSON.parse), so the
|
|
18
|
+
* fixtures are JSON objects rather than YAML text.
|
|
19
|
+
* @covers AC-620-05
|
|
20
|
+
*/
|
|
21
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { loadConfig, getDefaultConfig } from '../config';
|
|
26
|
+
|
|
27
|
+
let tmpDirs: string[] = [];
|
|
28
|
+
let warnings: string[];
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
tmpDirs = [];
|
|
32
|
+
warnings = [];
|
|
33
|
+
// config.ts reports through process.stderr.write, not console.warn, so the
|
|
34
|
+
// spy has to sit on the stream. (Mocking console.warn instead silently
|
|
35
|
+
// captured nothing and made every "must warn" assertion look like a bug.)
|
|
36
|
+
vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown) => {
|
|
37
|
+
warnings.push(String(chunk));
|
|
38
|
+
return true;
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
vi.restoreAllMocks();
|
|
44
|
+
for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function warnText(): string {
|
|
48
|
+
return warnings.join('\n');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Default value of one key on `clean-code.long-function`. */
|
|
52
|
+
function defaultFor(key: string): unknown {
|
|
53
|
+
const rule = getDefaultConfig().rules['clean-code']['long-function'] as unknown as Record<string, unknown>;
|
|
54
|
+
return rule[key];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Write a .principlesrc from RAW JSON text and load it unchanged. */
|
|
58
|
+
async function loadRaw(jsonText: string) {
|
|
59
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-cfg-'));
|
|
60
|
+
tmpDirs.push(dir);
|
|
61
|
+
const cfgPath = path.join(dir, '.principlesrc');
|
|
62
|
+
fs.writeFileSync(cfgPath, jsonText, 'utf8');
|
|
63
|
+
return loadConfig(cfgPath);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Write a .principlesrc overriding `clean-code.long-function` and load it. */
|
|
67
|
+
async function loadWithRule(overrides: Record<string, unknown>) {
|
|
68
|
+
return loadRaw(JSON.stringify({ rules: { 'clean-code': { 'long-function': overrides } } }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('AC-620-05: a mistyped rule override keeps the default and warns', () => {
|
|
72
|
+
it('accepts a well-typed numeric override (threshold)', async () => {
|
|
73
|
+
const merged = await loadWithRule({ threshold: 12 });
|
|
74
|
+
expect(merged.rules['clean-code']['long-function'].threshold).toBe(12);
|
|
75
|
+
expect(warnText()).not.toMatch(/threshold/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('rejects a non-numeric threshold and keeps the default', async () => {
|
|
79
|
+
const merged = await loadWithRule({ threshold: 'not-a-number' });
|
|
80
|
+
expect(merged.rules['clean-code']['long-function'].threshold).toBe(defaultFor('threshold'));
|
|
81
|
+
expect(warnText()).toMatch(/threshold/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('rejects a non-finite threshold and keeps the default', async () => {
|
|
85
|
+
// A guard that only checks `typeof value === 'number'` would accept these
|
|
86
|
+
// and compare every function against NaN (or Infinity, which nothing ever
|
|
87
|
+
// exceeds -- silently disabling the rule).
|
|
88
|
+
//
|
|
89
|
+
// These literals have to be written as raw JSON TEXT: JSON.stringify turns
|
|
90
|
+
// NaN and Infinity into `null`, so handing them to the object-based helper
|
|
91
|
+
// would inject null and the test would pass against a broken guard. That
|
|
92
|
+
// exact mistake made the first version of this test vacuous.
|
|
93
|
+
const fallback = defaultFor('threshold');
|
|
94
|
+
for (const literal of ['1e999', '-1e999']) {
|
|
95
|
+
warnings = [];
|
|
96
|
+
const merged = await loadRaw(
|
|
97
|
+
`{"rules":{"clean-code":{"long-function":{"threshold":${literal}}}}}`,
|
|
98
|
+
);
|
|
99
|
+
expect(merged.rules['clean-code']['long-function'].threshold).toBe(fallback);
|
|
100
|
+
expect(warnText()).toMatch(/threshold/);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('accepts a well-typed array override (exclude)', async () => {
|
|
105
|
+
const merged = await loadWithRule({ exclude: ['**/skip.ts'] });
|
|
106
|
+
expect(merged.rules['clean-code']['long-function'].exclude).toEqual(['**/skip.ts']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('rejects a scalar where an array is expected and keeps the default', async () => {
|
|
110
|
+
const merged = await loadWithRule({ exclude: '**/skip.ts' });
|
|
111
|
+
expect(merged.rules['clean-code']['long-function'].exclude).toEqual(defaultFor('exclude'));
|
|
112
|
+
expect(warnText()).toMatch(/exclude/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('copies the accepted array instead of aliasing the parsed one', async () => {
|
|
116
|
+
// mergeRule spreads array candidates; sharing the reference would let a
|
|
117
|
+
// later mutation of the caller's array leak into the active config.
|
|
118
|
+
const source = ['**/skip.ts'];
|
|
119
|
+
const merged = await loadWithRule({ exclude: source });
|
|
120
|
+
expect(merged.rules['clean-code']['long-function'].exclude).not.toBe(source);
|
|
121
|
+
expect(merged.rules['clean-code']['long-function'].exclude).toEqual(source);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('accepts a well-typed enum override (severity)', async () => {
|
|
125
|
+
const merged = await loadWithRule({ severity: 'error' });
|
|
126
|
+
expect(merged.rules['clean-code']['long-function'].severity).toBe('error');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('rejects an out-of-vocabulary severity and keeps the default', async () => {
|
|
130
|
+
// The dangerous case: a project writes a severity it believes is blocking
|
|
131
|
+
// but misspells it. Silently keeping the default would let that belief stand.
|
|
132
|
+
const merged = await loadWithRule({ severity: 'fatal' });
|
|
133
|
+
expect(merged.rules['clean-code']['long-function'].severity).toBe(defaultFor('severity'));
|
|
134
|
+
expect(warnText()).toMatch(/severity/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('rejects a non-boolean enabled flag and keeps the default', async () => {
|
|
138
|
+
const merged = await loadWithRule({ enabled: 'yes' });
|
|
139
|
+
expect(typeof merged.rules['clean-code']['long-function'].enabled).toBe('boolean');
|
|
140
|
+
expect(warnText()).toMatch(/enabled/);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('names the offending rule and key in the warning', async () => {
|
|
144
|
+
await loadWithRule({ severity: 'fatal' });
|
|
145
|
+
expect(warnText()).toContain('long-function');
|
|
146
|
+
expect(warnText()).toContain('severity');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import { BaseAdapter } from './base';
|
|
2
2
|
import { Adapter } from '../types';
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Depth change per nesting delimiter in a Java parameter list.
|
|
6
|
+
*
|
|
7
|
+
* Angular and square brackets nest exactly like parentheses here: the only thing
|
|
8
|
+
* that matters is whether a comma sits at depth 0, because that is the only
|
|
9
|
+
* place it separates parameters.
|
|
10
|
+
*/
|
|
11
|
+
const PARAM_NESTING: Record<string, number> = {
|
|
12
|
+
'(': 1, ')': -1,
|
|
13
|
+
'<': 1, '>': -1,
|
|
14
|
+
'[': 1, ']': -1,
|
|
15
|
+
};
|
|
16
|
+
|
|
4
17
|
/**
|
|
5
18
|
* Structural fields the clean-code rules consume (mirror of the TypeScript
|
|
6
19
|
* adapter's function shape). Before #507 this adapter returned only
|
|
@@ -213,12 +226,18 @@ export const javaParse = {
|
|
|
213
226
|
countParams(paramList: string): number {
|
|
214
227
|
const trimmed = paramList.trim();
|
|
215
228
|
if (!trimmed) return 0;
|
|
229
|
+
// Generic and array brackets nest too: `Map<String, List<Integer>>` is ONE
|
|
230
|
+
// parameter, so the commas inside its arguments must not be counted.
|
|
231
|
+
// Which characters change depth and which separate is data, not control
|
|
232
|
+
// flow -- table lookups keep the scan to a single conditional.
|
|
216
233
|
let depth = 0;
|
|
217
234
|
let count = 1;
|
|
218
235
|
for (const ch of trimmed) {
|
|
219
|
-
if (ch === '
|
|
220
|
-
|
|
221
|
-
|
|
236
|
+
if (ch === ',') {
|
|
237
|
+
if (depth === 0) count += 1;
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
depth += PARAM_NESTING[ch] ?? 0;
|
|
222
241
|
}
|
|
223
242
|
return count;
|
|
224
243
|
},
|
|
@@ -195,6 +195,13 @@ function inQuotedState(state: QuoteState): boolean {
|
|
|
195
195
|
return state.inSingle || state.inDouble || state.inTemplate;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** The three quote kinds tracked by QuoteState, with the flag that marks each. */
|
|
199
|
+
const QUOTE_KINDS: ReadonlyArray<{ quote: string; flag: 'inSingle' | 'inDouble' | 'inTemplate' }> = [
|
|
200
|
+
{ quote: "'", flag: 'inSingle' },
|
|
201
|
+
{ quote: '"', flag: 'inDouble' },
|
|
202
|
+
{ quote: '`', flag: 'inTemplate' },
|
|
203
|
+
];
|
|
204
|
+
|
|
198
205
|
/**
|
|
199
206
|
* Advance one character of quote tracking.
|
|
200
207
|
*
|
|
@@ -202,28 +209,25 @@ function inQuotedState(state: QuoteState): boolean {
|
|
|
202
209
|
* six-branch block verbatim (#602): both must ignore delimiters that appear
|
|
203
210
|
* inside a string, and neither may treat an escape as a closing quote.
|
|
204
211
|
*
|
|
212
|
+
* Table-driven (#620): all three quote kinds behave identically -- an escape
|
|
213
|
+
* skips two characters, the matching quote closes the literal -- so they are one
|
|
214
|
+
* rule applied to a list rather than three copies of it.
|
|
215
|
+
*
|
|
205
216
|
* @returns the number of characters consumed (2 when the next char is escaped).
|
|
206
217
|
*/
|
|
207
218
|
function advanceQuoteState(src: string, i: number, state: QuoteState): number {
|
|
208
219
|
const c = src[i];
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
return 1;
|
|
213
|
-
}
|
|
214
|
-
if (state.inDouble) {
|
|
215
|
-
if (c === '\\') return 2;
|
|
216
|
-
if (c === '"') state.inDouble = false;
|
|
217
|
-
return 1;
|
|
218
|
-
}
|
|
219
|
-
if (state.inTemplate) {
|
|
220
|
+
|
|
221
|
+
for (const kind of QUOTE_KINDS) {
|
|
222
|
+
if (!state[kind.flag]) continue;
|
|
220
223
|
if (c === '\\') return 2;
|
|
221
|
-
if (c ===
|
|
224
|
+
if (c === kind.quote) state[kind.flag] = false;
|
|
222
225
|
return 1;
|
|
223
226
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
+
|
|
228
|
+
// Not inside any literal: open the one this character starts, if any.
|
|
229
|
+
const opener = QUOTE_KINDS.find(kind => kind.quote === c);
|
|
230
|
+
if (opener) state[opener.flag] = true;
|
|
227
231
|
return 1;
|
|
228
232
|
}
|
|
229
233
|
|
|
@@ -273,29 +277,66 @@ function countParams(rawList: string): number {
|
|
|
273
277
|
return count;
|
|
274
278
|
}
|
|
275
279
|
|
|
280
|
+
/** What one step of the paren-group scan did, so the caller can react. */
|
|
281
|
+
type ParenStep =
|
|
282
|
+
| { kind: 'consumed'; next: number }
|
|
283
|
+
| { kind: 'closed'; end: number };
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Advance one step through a paren group.
|
|
287
|
+
*
|
|
288
|
+
* Extracted from `readParenGroup` (#620) so the three-way decision -- inside a
|
|
289
|
+
* literal, opening a literal, or structural bracket -- reads as one rule instead
|
|
290
|
+
* of being folded into the loop that also owns depth and substring slicing.
|
|
291
|
+
*
|
|
292
|
+
* A literal is entered through `advanceQuoteState` at the SAME index rather than
|
|
293
|
+
* consumed: that call flips the state on the opening quote and reports one
|
|
294
|
+
* character consumed, so re-scans resume one past the quote with the state set.
|
|
295
|
+
*
|
|
296
|
+
* @param depth running paren depth, updated in place for structural brackets
|
|
297
|
+
* @returns the next index to read, or the offset just past the closing paren
|
|
298
|
+
*/
|
|
299
|
+
function stepParenGroup(
|
|
300
|
+
src: string,
|
|
301
|
+
i: number,
|
|
302
|
+
state: QuoteState,
|
|
303
|
+
depth: { value: number },
|
|
304
|
+
): ParenStep {
|
|
305
|
+
if (inQuotedState(state)) {
|
|
306
|
+
return { kind: 'consumed', next: i + advanceQuoteState(src, i, state) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const c = src[i];
|
|
310
|
+
if (isQuote(c)) {
|
|
311
|
+
// Entering a literal: flip state here, resume at the next character.
|
|
312
|
+
advanceQuoteState(src, i, state);
|
|
313
|
+
return { kind: 'consumed', next: i + 1 };
|
|
314
|
+
}
|
|
315
|
+
if (c === '(') {
|
|
316
|
+
depth.value++;
|
|
317
|
+
return { kind: 'consumed', next: i + 1 };
|
|
318
|
+
}
|
|
319
|
+
if (c === ')') {
|
|
320
|
+
depth.value--;
|
|
321
|
+
if (depth.value === 0) return { kind: 'closed', end: i + 1 };
|
|
322
|
+
}
|
|
323
|
+
return { kind: 'consumed', next: i + 1 };
|
|
324
|
+
}
|
|
325
|
+
|
|
276
326
|
/**
|
|
277
327
|
* Extract the balanced `( ... )` group that starts at `open`.
|
|
278
328
|
* Returns the inner text and the index just past the closing paren.
|
|
279
329
|
*/
|
|
280
330
|
function readParenGroup(src: string, open: number): { inner: string; end: number } {
|
|
281
|
-
let depth = 0;
|
|
282
331
|
const state = newQuoteState();
|
|
332
|
+
const depth = { value: 0 };
|
|
283
333
|
|
|
284
334
|
for (let i = open; i < src.length;) {
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
335
|
+
const step = stepParenGroup(src, i, state, depth);
|
|
336
|
+
if (step.kind === 'closed') {
|
|
337
|
+
return { inner: src.substring(open + 1, step.end - 1), end: step.end };
|
|
288
338
|
}
|
|
289
|
-
|
|
290
|
-
if (c === "'" || c === '"' || c === '`') {
|
|
291
|
-
advanceQuoteState(src, i, state);
|
|
292
|
-
} else if (c === '(') {
|
|
293
|
-
depth++;
|
|
294
|
-
} else if (c === ')') {
|
|
295
|
-
depth--;
|
|
296
|
-
if (depth === 0) return { inner: src.substring(open + 1, i), end: i + 1 };
|
|
297
|
-
}
|
|
298
|
-
i++;
|
|
339
|
+
i = step.next;
|
|
299
340
|
}
|
|
300
341
|
return { inner: src.substring(open + 1), end: src.length };
|
|
301
342
|
}
|
package/principles/analyzer.ts
CHANGED
|
@@ -131,7 +131,11 @@ function runRuleOnFile(ctx: RunRuleContext): void {
|
|
|
131
131
|
}
|
|
132
132
|
} catch (err) {
|
|
133
133
|
const msg = err instanceof Error ? err.message : String(err);
|
|
134
|
-
|
|
134
|
+
// A file that cannot be READ failed for every rule that touched it, so the
|
|
135
|
+
// same message would arrive once per rule. Dedupe to one entry per file: the
|
|
136
|
+
// caller acts on "this file was not examined", not on how many rules noticed.
|
|
137
|
+
const entry = `Rule ${rule.id} failed on ${file}: ${msg}`;
|
|
138
|
+
if (!errors.includes(entry)) errors.push(entry);
|
|
135
139
|
}
|
|
136
140
|
}
|
|
137
141
|
|
|
@@ -160,6 +164,35 @@ interface RunRuleContext {
|
|
|
160
164
|
errors: string[];
|
|
161
165
|
}
|
|
162
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Resolve the adapter for one path, reporting rather than throwing on failure.
|
|
169
|
+
*
|
|
170
|
+
* Resolving and probing an adapter can itself READ the file (adapters parse on
|
|
171
|
+
* construction), so an unreadable path throws here -- before any rule runs and
|
|
172
|
+
* outside runRuleOnFile's guard. Recorded as an error rather than allowed to
|
|
173
|
+
* abort the whole run: one bad path must not hide the findings for the good
|
|
174
|
+
* ones, but it must never be invisible either (#623).
|
|
175
|
+
*
|
|
176
|
+
* @returns the adapter, or null when the file cannot be examined at all
|
|
177
|
+
*/
|
|
178
|
+
function resolveAdapterReporting(
|
|
179
|
+
file: string,
|
|
180
|
+
adapterOrFactory: Adapter | AdapterFactory,
|
|
181
|
+
errors: string[],
|
|
182
|
+
): Adapter | null {
|
|
183
|
+
try {
|
|
184
|
+
const adapter = resolveAdapter(file, adapterOrFactory);
|
|
185
|
+
if (!adapter || adapter.detectLanguage() === 'unknown') return null;
|
|
186
|
+
return adapter;
|
|
187
|
+
} catch (err) {
|
|
188
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
189
|
+
// One entry per file: the failure belongs to the file, not to however many
|
|
190
|
+
// rules happened to touch it.
|
|
191
|
+
if (!errors.includes(msg)) errors.push(msg);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
163
196
|
export async function analyze(
|
|
164
197
|
files: string[],
|
|
165
198
|
rules: Rule[],
|
|
@@ -180,8 +213,8 @@ export async function analyze(
|
|
|
180
213
|
}
|
|
181
214
|
|
|
182
215
|
for (const file of files) {
|
|
183
|
-
const adapter =
|
|
184
|
-
if (!adapter
|
|
216
|
+
const adapter = resolveAdapterReporting(file, adapterOrFactory, errors);
|
|
217
|
+
if (!adapter) continue;
|
|
185
218
|
|
|
186
219
|
const fileResult: FileResult = { violations: [], ruleIds: [] };
|
|
187
220
|
fileResults[file] = fileResult;
|
|
@@ -107,6 +107,19 @@ export function pruneBaselineEntries(
|
|
|
107
107
|
return { kept, removed };
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* A non-null, non-array object — the only shape a baseline map or a nested
|
|
112
|
+
* `files` wrapper can legally take.
|
|
113
|
+
*
|
|
114
|
+
* The three checks in `parseBaselineFiles` were the same predicate written out
|
|
115
|
+
* three times (once positively for the wrapper, once negated for the root, once
|
|
116
|
+
* positively per entry), which is most of its CCN. Naming it once removes the
|
|
117
|
+
* repetition and makes the intent legible.
|
|
118
|
+
*/
|
|
119
|
+
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
|
120
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
121
|
+
}
|
|
122
|
+
|
|
110
123
|
/**
|
|
111
124
|
* Extract the path -> entry map, or a string describing why it is unusable.
|
|
112
125
|
*
|
|
@@ -125,22 +138,21 @@ function parseBaselineFiles(baselineJson: string): Record<string, unknown> | str
|
|
|
125
138
|
return `could not parse baseline: ${detail}`;
|
|
126
139
|
}
|
|
127
140
|
|
|
128
|
-
if (
|
|
141
|
+
if (!isJsonObject(parsed)) {
|
|
129
142
|
return 'baseline is not a JSON object';
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return nested as Record<string, unknown>;
|
|
145
|
+
// A `files` wrapper is unwrapped rather than merged, matching the old shape.
|
|
146
|
+
if (isJsonObject(parsed.files)) {
|
|
147
|
+
return parsed.files;
|
|
136
148
|
}
|
|
137
149
|
|
|
138
150
|
// Keep only object-valued keys: scalars at the top level are metadata, not
|
|
139
151
|
// path entries, and a path whose entry is a bare number is the malformed shape
|
|
140
152
|
// that assertValidEntry rejects elsewhere (#455).
|
|
141
153
|
const entries: Record<string, unknown> = {};
|
|
142
|
-
for (const [key, value] of Object.entries(
|
|
143
|
-
if (
|
|
154
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
155
|
+
if (isJsonObject(value)) {
|
|
144
156
|
entries[key] = value;
|
|
145
157
|
}
|
|
146
158
|
}
|
package/principles/config.ts
CHANGED
|
@@ -283,6 +283,43 @@ function mergeGroup<T extends Record<string, RuleConfig>>(
|
|
|
283
283
|
return result;
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
/**
|
|
287
|
+
* How each key category is type-checked, and what the warning says when it is not.
|
|
288
|
+
*
|
|
289
|
+
* Every category follows the same shape -- skip when undefined, assign when the
|
|
290
|
+
* value passes its guard, warn otherwise (#620). Stating the guard and the
|
|
291
|
+
* expectation once per category lets one loop enforce all of them, instead of
|
|
292
|
+
* four near-identical loops that each have to be kept in step by hand.
|
|
293
|
+
*/
|
|
294
|
+
type ValueGuard = {
|
|
295
|
+
accepts: (value: unknown) => boolean;
|
|
296
|
+
expectation: string;
|
|
297
|
+
/** Coerce the accepted value before assignment (arrays are copied to detach). */
|
|
298
|
+
coerce?: (value: unknown) => unknown;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const NUMERIC_GUARD: ValueGuard = {
|
|
302
|
+
accepts: value => typeof value === 'number' && Number.isFinite(value),
|
|
303
|
+
expectation: 'a finite number',
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const ARRAY_GUARD: ValueGuard = {
|
|
307
|
+
// Arrays REPLACE rather than concatenate, matching how .principlesrc is
|
|
308
|
+
// written today (an exclude list is the project's complete list).
|
|
309
|
+
accepts: value => Array.isArray(value),
|
|
310
|
+
expectation: 'an array',
|
|
311
|
+
coerce: value => [...(value as unknown[])],
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
function enumGuard(allowed: readonly string[]): ValueGuard {
|
|
315
|
+
return {
|
|
316
|
+
accepts: value => typeof value === 'string' && allowed.includes(value),
|
|
317
|
+
// Loud on purpose: silently keeping the default would let a project believe
|
|
318
|
+
// it raised a rule to blocking when it did not.
|
|
319
|
+
expectation: `one of ${allowed.join(', ')}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
286
323
|
/** Merge one rule, keeping the default for any key whose override is mistyped. */
|
|
287
324
|
function mergeRule(
|
|
288
325
|
base: RuleConfig,
|
|
@@ -290,48 +327,32 @@ function mergeRule(
|
|
|
290
327
|
ruleId: string
|
|
291
328
|
): RuleConfig {
|
|
292
329
|
const merged: RuleConfig = { ...base };
|
|
330
|
+
const guarded: Array<[string, ValueGuard]> = [
|
|
331
|
+
...NUMERIC_KEYS.map((key): [string, ValueGuard] => [key, NUMERIC_GUARD]),
|
|
332
|
+
...ARRAY_KEYS.map((key): [string, ValueGuard] => [key, ARRAY_GUARD]),
|
|
333
|
+
...Object.entries(ENUM_KEYS).map(([key, allowed]): [string, ValueGuard] => [
|
|
334
|
+
key,
|
|
335
|
+
enumGuard(allowed as readonly string[]),
|
|
336
|
+
]),
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
for (const [key, guard] of guarded) {
|
|
340
|
+
const value = override[key as keyof RuleConfig];
|
|
341
|
+
if (value === undefined) continue;
|
|
342
|
+
if (!guard.accepts(value)) {
|
|
343
|
+
warn(`rules.${ruleId}.${key} must be ${guard.expectation} — keeping default`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
merged[key as keyof RuleConfig] = (guard.coerce ? guard.coerce(value) : value) as never;
|
|
347
|
+
}
|
|
293
348
|
|
|
349
|
+
// `enabled` is a bare boolean, not one of the value categories above.
|
|
294
350
|
if (typeof override.enabled === 'boolean') {
|
|
295
351
|
merged.enabled = override.enabled;
|
|
296
352
|
} else if (override.enabled !== undefined) {
|
|
297
353
|
warn(`rules.${ruleId}.enabled must be a boolean — keeping default`);
|
|
298
354
|
}
|
|
299
355
|
|
|
300
|
-
for (const key of NUMERIC_KEYS) {
|
|
301
|
-
const value = override[key];
|
|
302
|
-
if (value === undefined) continue;
|
|
303
|
-
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
304
|
-
merged[key] = value;
|
|
305
|
-
} else {
|
|
306
|
-
warn(`rules.${ruleId}.${key} must be a finite number — keeping default`);
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
for (const key of ARRAY_KEYS) {
|
|
311
|
-
const value = override[key];
|
|
312
|
-
if (value === undefined) continue;
|
|
313
|
-
// Arrays REPLACE rather than concatenate, matching how .principlesrc is
|
|
314
|
-
// written today (an exclude list is the project's complete list).
|
|
315
|
-
if (Array.isArray(value)) {
|
|
316
|
-
merged[key] = [...value] as RuleConfig[typeof key];
|
|
317
|
-
} else {
|
|
318
|
-
warn(`rules.${ruleId}.${key} must be an array — keeping default`);
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
for (const key of Object.keys(ENUM_KEYS) as (keyof typeof ENUM_KEYS)[]) {
|
|
323
|
-
const allowed: readonly string[] = ENUM_KEYS[key];
|
|
324
|
-
const value = override[key];
|
|
325
|
-
if (value === undefined) continue;
|
|
326
|
-
if (typeof value === 'string' && allowed.includes(value)) {
|
|
327
|
-
merged[key] = value;
|
|
328
|
-
} else {
|
|
329
|
-
// Loud on purpose: silently keeping the default would let a project believe
|
|
330
|
-
// it raised a rule to blocking when it did not.
|
|
331
|
-
warn(`rules.${ruleId}.${key} must be one of ${allowed.join(', ')} — keeping default`);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
356
|
return merged;
|
|
336
357
|
}
|
|
337
358
|
|