-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathparser.ts
More file actions
171 lines (142 loc) · 5.2 KB
/
parser.ts
File metadata and controls
171 lines (142 loc) · 5.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
// v1 Query Parser — equity= URL parameter
// Contract defined in SCENARIOS.md sections 1–13
// See LIL-INTDEV-AGENTS.md §3 for architecture context
export const MAX_PORTFOLIOS = 5;
export const MAX_TICKERS_PER_PORTFOLIO = 20;
export const MAX_TICKER_LENGTH = 10;
/**
* Pinned v1 error string for colon rejection.
* This is the single source of truth for the ':' rejection message.
* Referenced by SCENARIOS.md §7.7 / #117 / #132.
*/
export const COLON_REJECTION_ERROR =
'Weights (:) are not supported in v1. Use a comma-separated list of tickers like "AAPL,MSFT".';
const VALID_TICKER_CHARS = /^[A-Za-z0-9.\-]+$/;
const STARTS_WITH_LETTER = /^[A-Za-z]/;
const RESERVED_COLON = /:/;
const RESERVED_EQUALS = /=/;
export interface ParseSuccess {
ok: true;
portfolios: string[][];
}
export interface ParseError {
ok: false;
error: string;
}
export type ParseResult = ParseSuccess | ParseError;
/**
* Parse the `equity` query parameter(s) from a URL search string.
*
* Follows the validation pipeline order defined in SCENARIOS.md:
* 1. Portfolio count check
* 2. Empty value check
* 3. Split on comma
* 4. Per-token validation (trim, empty, reserved chars, illegal chars, starts-with-letter, max-length, uppercase, dedup)
* 5. Ticker count check
*/
export function parsePortfolios(searchParams: URLSearchParams): ParseResult {
const equityValues = searchParams.getAll('equity');
// No equity param at all → empty portfolios, no error (scenario 6.1)
if (equityValues.length === 0) {
return { ok: true, portfolios: [] };
}
// Step 1: Portfolio count check (scenario 10.3)
if (equityValues.length > MAX_PORTFOLIOS) {
return {
ok: false,
error: `Too many portfolios: ${equityValues.length} exceeds maximum of ${MAX_PORTFOLIOS}`,
};
}
const portfolios: string[][] = [];
const isMulti = equityValues.length > 1;
for (let pIdx = 0; pIdx < equityValues.length; pIdx++) {
const raw = equityValues[pIdx];
const portfolioNum = pIdx + 1;
const suffix = isMulti ? ` in portfolio ${portfolioNum}` : '';
// Step 2: Empty value check (scenario 6.2, 10.5)
if (raw === '') {
return {
ok: false,
error: `Empty equity parameter${suffix}`,
};
}
// Step 3: Split on comma
const tokens = raw.split(',');
// Step 5 (checked early before processing tokens): Ticker count (scenario 5.2)
// Note: we check after per-token validation per the pipeline order,
// but empty tokens would fail first anyway. We do the actual count check after token processing.
const seen = new Set<string>();
const tickers: string[] = [];
for (let tIdx = 0; tIdx < tokens.length; tIdx++) {
const position = tIdx + 1;
const posSuffix = isMulti ? ` in portfolio ${portfolioNum}` : '';
// Step 4a: Trim whitespace
const trimmed = tokens[tIdx].trim();
// Step 4b: Empty token check (scenarios 3.2, 6.3, 6.4, 6.5, 6.6)
if (trimmed === '') {
return {
ok: false,
error: `Empty ticker at position ${position}${posSuffix}`,
};
}
// Step 4c: Reserved characters — colon (scenarios 7.1, 7.2, 7.4, 7.7, 7.8)
// Pinned v1 error string from SCENARIOS.md §7.7 / #117
if (RESERVED_COLON.test(trimmed)) {
return {
ok: false,
error: COLON_REJECTION_ERROR,
};
}
// Step 4c: Reserved characters — equals (scenario 7.3)
if (RESERVED_EQUALS.test(trimmed)) {
return {
ok: false,
error: `Invalid character '=' in ticker '${trimmed}' — equals signs are reserved`,
};
}
// Step 4d: Illegal characters (scenarios 7.5, 7.6, 8.2, 8.3, 8.4, 8.5)
if (!VALID_TICKER_CHARS.test(trimmed)) {
// Find the first illegal character for the error message
const illegalChar = trimmed.split('').find((ch) => !ch.match(/[A-Za-z0-9.\-]/));
return {
ok: false,
error: `Invalid character '${illegalChar}' in ticker '${trimmed}'`,
};
}
// Step 4e: Must start with letter (scenario 8.1)
if (!STARTS_WITH_LETTER.test(trimmed)) {
return {
ok: false,
error: `Invalid ticker format: '${trimmed}' — must start with a letter`,
};
}
// Step 4f: Max length (scenario 8.6)
if (trimmed.length > MAX_TICKER_LENGTH) {
return {
ok: false,
error: `Ticker too long: '${trimmed}' exceeds ${MAX_TICKER_LENGTH} character limit`,
};
}
// Step 4g: Uppercase normalization (scenarios 2.1, 2.2)
const upper = trimmed.toUpperCase();
// Step 4h: Duplicate check within this portfolio (scenarios 4.1, 4.2, 4.3)
if (seen.has(upper)) {
return {
ok: false,
error: `Duplicate ticker: ${upper}${posSuffix}`,
};
}
seen.add(upper);
tickers.push(upper);
}
// Step 5: Ticker count check (scenario 5.2)
if (tickers.length > MAX_TICKERS_PER_PORTFOLIO) {
return {
ok: false,
error: `Too many tickers in portfolio ${portfolioNum}: ${tickers.length} exceeds maximum of ${MAX_TICKERS_PER_PORTFOLIO}`,
};
}
portfolios.push(tickers);
}
return { ok: true, portfolios };
}