Skip to content

Commit df06687

Browse files
fix: added unit tests for structural and semantic parsing
Signed-off-by: Fredrik Nordlander <fredrik.nordlander@digg.se>
1 parent 6010114 commit df06687

File tree

7 files changed

+342
-12
lines changed

7 files changed

+342
-12
lines changed

src/app.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,11 @@ import { DiagnosticReport, RapLPDiagnostic } from './util/RapLPDiagnostic.js';
2323
import { AggregateError } from './util/RapLPCustomErrorInfo.js';
2424
import chalk from 'chalk';
2525
import { ExcelReportProcessor } from './util/excelReportProcessor.js';
26-
import { parseApiSpecInput,detectSpecFormatPreference,semanticValidate, ParseResult} from './util/validateUtil.js';
26+
import { parseApiSpecInput,detectSpecFormatPreference, ParseResult} from './util/validateUtil.js';
2727
import { SpecParseError } from './util/RapLPSpecParseError.js';
2828
import type { IParser } from '@stoplight/spectral-parsers';
2929
import { Document as SpectralDocument } from '@stoplight/spectral-core';
3030
import { Issue } from './util/RapLPIssueHelpers.js';
31-
import * as IssueHelper from './util/RapLPIssueHelpers.js';
3231

3332
declare var AggregateError: {
3433
prototype: AggregateError;
@@ -91,12 +90,11 @@ async function main(): Promise<void> {
9190
const ruleCategories = argv.categories ? (argv.categories as string).split(',') : undefined;
9291
const logErrorFilePath = argv.logError as string | undefined;
9392
const logDiagnosticFilePath = argv.logDiagnostic as string | undefined;
94-
const disableSanity = argv.disableSanity as boolean ?? false;
9593
const strict = argv.strict as boolean ?? false;
9694

9795
// Schemevalidation and Spectral Document creation ----------
9896
let apiSpecDocument: SpectralDocument;
99-
let parseResult: any;
97+
let parseResult: ParseResult;
10098
try {
10199
const prefer = detectSpecFormatPreference(apiSpecFileName,undefined,'auto');
102100
parseResult = await parseApiSpecInput(
@@ -109,7 +107,7 @@ async function main(): Promise<void> {
109107
if (parseResult.strictIssues && parseResult.strictIssues.length > 0) {
110108
console.error('Strict validation reported issues:');
111109
parseResult.strictIssues.forEach((iss: Issue) =>
112-
console.error(chalk.yellow(`- ${iss.type} at ${iss.path} : ${iss.message} ${iss.line ? `(line ${iss.line})` : ''}`)),
110+
console.error(chalk.yellow(`- ${iss.type} at ${iss.path} : ${iss.message} ${iss.line ? `(line ${iss.line})` : ''}`)),
113111
);
114112
process.exitCode = 2;
115113
return;

src/util/RapLPCustomSpectral.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,6 @@ class RapLPCustomSpectral {
6767
// Map properties from result ISpectralDiagnostic to CustomSpectralDiagnostic
6868
const { message, code, severity, path, source, range, ...rest } = result;
6969
// Map severity to corresponding string value for allvarlighetsgrad
70-
console.log("Code is:" + code);
7170
let allvarlighetsgrad: string;
7271
switch (severity) {
7372
case 0:

src/util/RapLPIssueHelpers.ts

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ function extractJumpLinesFromPretty(prettyLines?: string[]): Set<number> {
4343
export function spectralDiagnosticsToIssuesSimple(
4444
diagnostics: SpectralCore.ISpectralDiagnostic[] | readonly SpectralCore.ISpectralDiagnostic[] | undefined,
4545
prettyLines?: string[],
46-
fallbackAddOne = false // om ingen referens finns, välj detta
46+
fallbackAddOne = false // If no ref, choose thisone
4747
): Issue[] {
4848
const issues: Issue[] = [];
4949
if (!diagnostics || diagnostics.length === 0) return issues;
@@ -59,18 +59,18 @@ export function spectralDiagnosticsToIssuesSimple(
5959
let line: number | null = null;
6060
if (rawLine !== null) {
6161
if (useJumpRef) {
62-
// Om prettifier har rawLine+1 så använd +1, annars använd rawLine (ingen annan gissning)
62+
// Om prettifier har rawLine+1 så använd +1, annars använd rawLine
6363
if (jumpSet.has(rawLine + 1)) {
6464
line = rawLine + 1;
6565
} else {
6666
line = rawLine;
6767
}
6868
} else {
69-
// ingen referens — fallback baserat på param
69+
// No ref — fallback
7070
line = fallbackAddOne ? rawLine + 1 : rawLine;
7171
}
7272
}
73-
73+
//Create Issue
7474
issues.push({
7575
type: 'Semantic',
7676
code: d.code ?? undefined,
@@ -207,12 +207,113 @@ export function mergeAndDedupeIssues(prettyIssues: Issue[], spectralIssues: Issu
207207

208208
return result;
209209
}
210+
export function consolidateIssues(issues: Issue[]): Issue[] {
211+
if (!issues || issues.length === 0) return [];
212+
213+
const normalizeMsg = (s?: string) => (s ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
214+
const normalizePath = (p?: string) => (p ?? '').toString().trim();
215+
216+
// Key now: path + line (group by location), not path+message
217+
const map = new Map<string, Issue>();
218+
219+
const priorityScore = (type?: string) => {
220+
if (!type) return 0;
221+
const t = type.toLowerCase();
222+
if (t === 'semantic') return 3;
223+
if (t === 'structural') return 2;
224+
if (t === 'info') return 1;
225+
return 0;
226+
};
227+
228+
for (const rawIss of issues) {
229+
const iss: Issue = { ...rawIss }; // shallow copy
230+
iss.path = normalizePath(iss.path);
231+
iss.message = (iss.message ?? '').trim();
232+
233+
const linePart = iss.line != null ? String(iss.line) : '_noline_';
234+
const key = `${iss.path}|${linePart}`;
235+
236+
const existing = map.get(key);
237+
if (!existing) {
238+
// ensure details is array
239+
if (!Array.isArray(iss.details)) iss.details = [];
240+
if (iss.raw && !Array.isArray(iss.raw)) iss.raw = [String(iss.raw)];
241+
map.set(key, iss);
242+
continue;
243+
}
244+
245+
// If incoming has higher priority (e.g. Semantic > Structural) - replace as primary
246+
if (priorityScore(iss.type) > priorityScore(existing.type)) {
247+
// preserve existing as detail(s)
248+
const details = new Set<string>(existing.details ?? []);
249+
if (existing.message && existing.message !== iss.message) details.add(existing.message);
250+
if (existing.raw && Array.isArray(existing.raw)) {
251+
for (const r of existing.raw) details.add(String(r).trim());
252+
}
253+
// merge existing.details too
254+
if (Array.isArray(existing.details)) {
255+
for (const d of existing.details) details.add((d ?? '').toString().trim());
256+
}
257+
258+
iss.details = Array.from(new Set([...(iss.details ?? []), ...Array.from(details)])).filter(Boolean);
259+
// copy metadata if missing on new
260+
if (!iss.documentationUrl) iss.documentationUrl = existing.documentationUrl;
261+
if (!iss.source) iss.source = existing.source;
262+
// keep smallest non-null line if new misses it
263+
if ((iss.line == null || iss.line === 0) && (existing.line != null)) iss.line = existing.line;
264+
map.set(key, iss);
265+
} else {
266+
// Keep existing primary; merge incoming into details
267+
const details = new Set<string>(existing.details ?? []);
268+
if (iss.message && iss.message !== existing.message) details.add(iss.message);
269+
if (Array.isArray(iss.details)) for (const d of iss.details) details.add(d);
270+
if (Array.isArray(iss.raw)) for (const r of iss.raw) details.add(String(r).trim());
271+
existing.details = Array.from(details).filter(Boolean);
272+
// keep smallest non-null line if existing missing
273+
if ((existing.line == null || existing.line === 0) && (iss.line != null)) existing.line = iss.line;
274+
existing.documentationUrl = existing.documentationUrl ?? iss.documentationUrl;
275+
existing.source = existing.source ?? iss.source;
276+
map.set(key, existing);
277+
}
278+
}
279+
280+
// Convert to array
281+
let merged = Array.from(map.values());
282+
283+
// Filter: ta bort generiska oneOf-meddelanden om specifika finns i samma parent path
284+
merged = merged.filter(i => {
285+
if (i.message && /oneOf schema/i.test(i.message)) {
286+
const parent = i.path.split('.').slice(0, -1).join('.');
287+
const hasSpecific = merged.some(o => o.path.startsWith(parent) && !/oneOf schema/i.test(o.message));
288+
if (hasSpecific) return false;
289+
}
290+
return true;
291+
});
292+
293+
// Sort determenistic: source, line (nummer), path
294+
merged.sort((a, b) => {
295+
const sa = `${a.source ?? ''}|${String(a.line ?? 0).padStart(6, '0')}|${a.path ?? ''}`;
296+
const sb = `${b.source ?? ''}|${String(b.line ?? 0).padStart(6, '0')}|${b.path ?? ''}`;
297+
return sa.localeCompare(sb);
298+
});
299+
300+
// Trim messages & details, dedupe details
301+
for (const i of merged) {
302+
i.message = (i.message ?? '').trim();
303+
if (i.details && Array.isArray(i.details)) {
304+
i.details = Array.from(new Set(i.details.map(d => (d ?? '').toString().trim()))).filter(Boolean);
305+
}
306+
}
307+
308+
return merged;
309+
}
310+
210311
/**
211312
* ConsolidateIssues
212313
* @param issues
213314
* @returns
214315
*/
215-
export function consolidateIssues(issues: Issue[]): Issue[] {
316+
export function consolidateIssues2(issues: Issue[]): Issue[] {
216317
if (!issues || issues.length === 0) return [];
217318

218319
// Normaliseringshjälpare

src/util/validateUtil.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ import type { IParser } from '@stoplight/spectral-parsers';
2020
import * as IssueHelper from './RapLPIssueHelpers.js';
2121
import { SpecParseError} from './RapLPSpecParseError.js';
2222

23-
2423
/**
2524
* Possible input types for specifications
2625
* - filePath: Read from filesystem
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// tests/unit/integration/validateRunner.mjs
2+
import path from 'path';
3+
4+
async function main() {
5+
const arg = process.argv[2] || '{}';
6+
const input = JSON.parse(arg);
7+
8+
try {
9+
10+
const { parseApiSpecInput } = await import('../../../dist/util/validateUtil.js');
11+
12+
const res = await parseApiSpecInput(input, { strict: !!input.strict });
13+
console.log(JSON.stringify({ ok: true, result: res }));
14+
process.exit(0);
15+
} catch (err) {
16+
console.error(JSON.stringify({ ok: false, message: err?.message, stack: err?.stack }));
17+
process.exit(2);
18+
}
19+
}
20+
21+
main();

tests/unit/issueHelper.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { Issue, sortIssues,consolidateIssues } from '../../src/util/RapLPIssueHelpers.js';
2+
import { spectralDiagnosticsToIssuesSimple } from '../../src/util/RapLPIssueHelpers.js';
3+
import type * as SpectralCore from '@stoplight/spectral-core';
4+
describe('IssueHelper.sortIssues', ()=> {
5+
it('Sorterar issues efter line, path och typ', () => {
6+
const issues: Issue[] = [
7+
{
8+
type: 'Structural',
9+
path: 'components.schemas.B',
10+
message: 'Missing $ref',
11+
line: 50,
12+
raw: [],
13+
},
14+
{
15+
type: 'Semantic',
16+
path: 'components.schemas.A',
17+
message: 'Required property must not be empty',
18+
line: 20,
19+
code: 'oas3-schema',
20+
raw: [],
21+
},
22+
{
23+
type: 'Structural',
24+
path: 'components.schemas.A',
25+
message: 'Should have required property',
26+
line: 20,
27+
raw: [],
28+
},
29+
{
30+
type: 'Semantic',
31+
path: 'components.schemas.C',
32+
message: 'Extra property not allowed',
33+
line: 50,
34+
code: 'oas3-schema',
35+
raw: [],
36+
},
37+
{
38+
type: 'Info',
39+
path: 'components.schemas.D',
40+
message: 'Some info message',
41+
line: undefined,
42+
raw: [],
43+
},
44+
];
45+
expect (issues.map(i =>i.path)).toEqual ([
46+
'components.schemas.B',
47+
'components.schemas.A',
48+
'components.schemas.A',
49+
'components.schemas.C',
50+
'components.schemas.D',
51+
]);
52+
const sorted = sortIssues(issues);
53+
54+
const expectedOrder = [
55+
'components.schemas.A', // Semantic
56+
'components.schemas.A', // Structural
57+
'components.schemas.B', // Structural
58+
'components.schemas.C', // Semantic
59+
'components.schemas.D', // Info, line undefined
60+
];
61+
expect(sorted.map(i => i.path)).toEqual(expectedOrder);
62+
expect(sorted[0].type).toBe('Semantic');
63+
expect(sorted[1].type).toBe('Structural');
64+
expect(sorted[4].line).toBeUndefined();
65+
});
66+
67+
});
68+
describe('spectralDiagnosticsToIssuesSimple', () => {
69+
it('konverterar ISpectralDiagnostic till Issue med fallbackAddOne=false', () => {
70+
const diag: Partial<SpectralCore.ISpectralDiagnostic> = {
71+
code: 'path-params',
72+
message: 'Operation must define parameter "{id}"',
73+
path: ['paths', '/petshamta/{id}', 'get'],
74+
range: { start: { line: 196, character: 0 }, end: { line: 200, character: 0 } },
75+
source: '/tmp/api.yaml',
76+
} as any;
77+
78+
// no prettyLines -> fallback false -> line should equal rawLine (196)
79+
const issues = spectralDiagnosticsToIssuesSimple([diag as any], undefined, false);
80+
expect(issues.length).toBe(1);
81+
expect(issues[0].line).toBe(196);
82+
expect(issues[0].path).toBe('paths./petshamta/{id}.get');
83+
});
84+
85+
it('använder prettyLines för att bestämma +1 (om jump innehåller rawLine+1)', () => {
86+
const diag: Partial<SpectralCore.ISpectralDiagnostic> = {
87+
code: 'X',
88+
message: 'm',
89+
path: ['paths', '/x', 'get'],
90+
range: { start: { line: 5, character: 0 }, end: { line: 5, character: 1 } },
91+
} as any;
92+
93+
const pretty = ['Structural error at paths./x', 'should something', 'Jump to line 6'];
94+
const issues = spectralDiagnosticsToIssuesSimple([diag as any], pretty, false);
95+
expect(issues[0].line).toBe(6);
96+
});
97+
});
98+
describe('consolidateIssues', () => {
99+
it('prioriterar semantic över structural och samlar details', () => {
100+
const items: Issue[] = [
101+
{ type: 'Structural', path: 'c.s', message: 'x', line: 10 },
102+
{ type: 'Semantic', path: 'c.s', message: 'y', line: 10 }
103+
];
104+
const out = consolidateIssues(items);
105+
expect(out.length).toBe(1);
106+
expect(out[0].type).toBe('Semantic');
107+
// details should contain structural message
108+
expect(out[0].details && out[0].details.some(d => d === 'x')).toBe(true);
109+
});
110+
111+
it('tar bort oneOf när mer specifika finns', () => {
112+
const items: Issue[] = [
113+
{ type: 'Structural', path: 'parent.child', message: 'oneOf schema', line: 1 },
114+
{ type: 'Structural', path: 'parent.child.specific', message: 'specific error', line: 1 }
115+
];
116+
const out = consolidateIssues(items);
117+
expect(out.some(i => /oneOf/i.test(i.message))).toBe(false);
118+
expect(out.some(i => /specific error/i.test(i.message))).toBe(true);
119+
});
120+
});

0 commit comments

Comments
 (0)