clckernel 1.2.18 → 2.0.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/.agents/skills/clckernel_cli/SKILL.md +23 -6
- package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
- package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
- package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
- package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
- package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
- package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
- package/.agents/skills/clckernel_cli/reference/start.md +14 -4
- package/.agents/skills/clckernel_cli/reference/test.md +1 -1
- package/.agents/skills/clckernel_cli/reference/update.md +19 -19
- package/CHANGELOG.md +244 -0
- package/LICENSE +21 -0
- package/README.es.md +280 -51
- package/README.md +274 -50
- package/bin/cli.js +8 -0
- package/package.json +12 -2
- package/src/adapters/agent-os.js +161 -84
- package/src/adapters/astro.js +8 -6
- package/src/adapters/base.js +27 -46
- package/src/adapters/django.js +14 -13
- package/src/adapters/fastapi.js +16 -15
- package/src/adapters/go.js +11 -9
- package/src/adapters/laravel.js +12 -10
- package/src/adapters/nextjs.js +7 -5
- package/src/adapters/rails.js +13 -11
- package/src/adapters/rust.js +11 -9
- package/src/catalog.js +58 -49
- package/src/config.js +38 -240
- package/src/detector.js +243 -44
- package/src/doctor.js +18 -9
- package/src/generator.js +737 -309
- package/src/gitignore.js +144 -0
- package/src/index.js +257 -18
- package/src/io.js +409 -0
- package/src/known-hashes.json +309 -0
- package/src/onboarding.js +15 -0
- package/src/technologies/detector.js +1 -1
- package/src/technologies/guards/_shared.js +1 -1
- package/src/ui.js +233 -40
- package/src/updater.js +271 -36
- package/tools/_changes.js +279 -0
- package/tools/_changes.py +233 -0
- package/tools/_config.js +294 -0
- package/tools/_config.py +271 -0
- package/tools/_yaml.py +489 -0
- package/tools/audit.js +511 -111
- package/tools/audit.py +506 -128
- package/tools/celery_guard.py +13 -35
- package/tools/check_a11y.js +8 -28
- package/tools/check_api_contracts.js +13 -39
- package/tools/check_architecture.js +35 -44
- package/tools/check_architecture.py +172 -105
- package/tools/check_custom.js +108 -61
- package/tools/check_custom.py +108 -271
- package/tools/check_data_files.js +88 -0
- package/tools/check_db_efficiency.py +80 -65
- package/tools/check_hitl.js +112 -69
- package/tools/check_links.js +138 -0
- package/tools/check_migrations.py +143 -94
- package/tools/check_performance.js +35 -44
- package/tools/check_placeholders.js +104 -0
- package/tools/check_responsive.js +8 -28
- package/tools/check_scope.py +40 -31
- package/tools/check_seo.js +8 -28
- package/tools/check_skills.js +158 -0
- package/tools/check_storybook.js +8 -21
- package/tools/check_symlinks.js +40 -22
- package/tools/check_ui_reuse.js +17 -29
- package/tools/docker_guard.py +19 -41
- package/tools/postgres_guard.py +15 -36
- package/tools/redis_guard.py +13 -35
- package/tools/scan_secrets.js +134 -67
- package/tools/scan_secrets.py +163 -72
- package/tools/verify_tdd.js +867 -0
- package/tools/verify_tdd.py +407 -165
- package/tools/yaml.js +404 -132
- package/tools/check_domain_data.js +0 -100
- package/tools/check_test_calendar_sync.js +0 -111
package/tools/_yaml.py
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Zero-dependency YAML subset parser and glob matcher shared by the Python
|
|
3
|
+
guards (twin of tools/yaml.js): same functions, same order, same error
|
|
4
|
+
messages. parse_yaml uses PyYAML when it is installed, otherwise the strict
|
|
5
|
+
subset parser below.
|
|
6
|
+
|
|
7
|
+
Self-contained: Python 3.9+ standard library only (PyYAML optional).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
15
|
+
# YAML Parser — PyYAML when available, otherwise the strict subset parser.
|
|
16
|
+
# minimal_yaml_parse is the twin of parseYaml in tools/yaml.js: same
|
|
17
|
+
# functions, same order, same error messages.
|
|
18
|
+
#
|
|
19
|
+
# Supported subset (anything else raises ValueError
|
|
20
|
+
# "Unsupported YAML syntax at line N: ..."):
|
|
21
|
+
# - block mappings and block sequences, including a sequence at the SAME
|
|
22
|
+
# indent as its key (`rules:` / `- name:`) and multi-key mapping items
|
|
23
|
+
# - scalars: plain, 'single' (only the '' escape), "double" (escapes
|
|
24
|
+
# \\ \" \/ \n \t \r \0)
|
|
25
|
+
# - types: int, float, true/false (+ True/TRUE/False/FALSE),
|
|
26
|
+
# null/Null/NULL/~/empty; ambiguous YAML 1.1 forms (yes/no/on/off, octal,
|
|
27
|
+
# hex, 1_000, 1:30, dates, .5, .inf, ...) are errors — quote them
|
|
28
|
+
# - flow `[...]` / `{...}` of scalars on one line, quote-aware
|
|
29
|
+
# - comments: `#` at line start or preceded by a space, outside quotes
|
|
30
|
+
#
|
|
31
|
+
# The top level must be a mapping (an empty document parses to {}).
|
|
32
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
33
|
+
|
|
34
|
+
def parse_yaml(text: str) -> dict:
|
|
35
|
+
"""Parse YAML text. Uses PyYAML if available, otherwise the subset parser.
|
|
36
|
+
|
|
37
|
+
Parse errors propagate (PyYAML's YAMLError or ValueError) so the caller
|
|
38
|
+
can fail closed.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
import yaml
|
|
42
|
+
except ImportError:
|
|
43
|
+
return minimal_yaml_parse(text)
|
|
44
|
+
result = yaml.safe_load(text)
|
|
45
|
+
if result is None:
|
|
46
|
+
return {}
|
|
47
|
+
if not isinstance(result, dict):
|
|
48
|
+
raise ValueError('Unsupported YAML syntax at line 1: the top level must be a mapping')
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
NULL_WORDS = ('', '~', 'null', 'Null', 'NULL')
|
|
53
|
+
TRUE_WORDS = ('true', 'True', 'TRUE')
|
|
54
|
+
FALSE_WORDS = ('false', 'False', 'FALSE')
|
|
55
|
+
YAML11_BOOL_RE = re.compile(r'^(?:yes|Yes|YES|no|No|NO|on|On|ON|off|Off|OFF)$')
|
|
56
|
+
INT_RE = re.compile(r'^[-+]?(?:0|[1-9][0-9]*)$')
|
|
57
|
+
FLOAT_RE = re.compile(r'^[-+]?[0-9]+\.[0-9]+$')
|
|
58
|
+
# Everything PyYAML would resolve to an int/float/timestamp; used to reject
|
|
59
|
+
# forms outside the subset instead of silently reading them as strings.
|
|
60
|
+
YAML11_INT_RE = re.compile(
|
|
61
|
+
r'^(?:[-+]?0b[0-1_]+|[-+]?0[0-7_]+|[-+]?(?:0|[1-9][0-9_]*)|[-+]?0x[0-9a-fA-F_]+'
|
|
62
|
+
r'|[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$')
|
|
63
|
+
YAML11_FLOAT_RE = re.compile(
|
|
64
|
+
r'^(?:[-+]?[0-9][0-9_]*\.[0-9_]*(?:[eE][-+][0-9]+)?|\.[0-9_]+(?:[eE][-+][0-9]+)?'
|
|
65
|
+
r'|[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]*|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$')
|
|
66
|
+
YAML11_TIMESTAMP_RE = re.compile(r'^[0-9]{4}-[0-9]{1,2}-[0-9]{1,2}(?:$|[Tt \t])')
|
|
67
|
+
DOUBLE_ESCAPES = {'\\': '\\', '"': '"', '/': '/', 'n': '\n', 't': '\t', 'r': '\r', '0': '\0'}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _yaml_error(line_no: int, reason: str) -> ValueError:
|
|
71
|
+
"""Build the error raised for any input outside the subset."""
|
|
72
|
+
return ValueError(f'Unsupported YAML syntax at line {line_no}: {reason}')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class _Cursor:
|
|
76
|
+
"""Cursor over the significant lines of the document."""
|
|
77
|
+
|
|
78
|
+
def __init__(self, lines: list) -> None:
|
|
79
|
+
self.lines = lines
|
|
80
|
+
self.index = 0
|
|
81
|
+
|
|
82
|
+
def peek(self) -> Optional[dict]:
|
|
83
|
+
return self.lines[self.index] if self.index < len(self.lines) else None
|
|
84
|
+
|
|
85
|
+
def advance(self) -> None:
|
|
86
|
+
self.index += 1
|
|
87
|
+
|
|
88
|
+
def replace(self, line: dict) -> None:
|
|
89
|
+
"""Replace the current line (used to re-read `- key: v` as a mapping)."""
|
|
90
|
+
self.lines[self.index] = line
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def minimal_yaml_parse(text: str) -> dict:
|
|
94
|
+
"""Parse a YAML string (strict subset) into a dict ({} for an empty document)."""
|
|
95
|
+
cursor = _Cursor(_split_lines(text))
|
|
96
|
+
first = cursor.peek()
|
|
97
|
+
if first is None:
|
|
98
|
+
return {}
|
|
99
|
+
if _is_seq_item(first['text']):
|
|
100
|
+
raise _yaml_error(first['line_no'], 'the top level must be a mapping')
|
|
101
|
+
root = _parse_mapping(cursor, first['indent'])
|
|
102
|
+
rest = cursor.peek()
|
|
103
|
+
if rest is not None:
|
|
104
|
+
raise _yaml_error(rest['line_no'], 'inconsistent indentation')
|
|
105
|
+
return root
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _split_lines(text: str) -> list:
|
|
109
|
+
"""
|
|
110
|
+
Split the source into significant lines: {indent, text, line_no}.
|
|
111
|
+
Drops blank and comment-only lines; rejects tabs in indentation and
|
|
112
|
+
document markers.
|
|
113
|
+
"""
|
|
114
|
+
lines = []
|
|
115
|
+
for i, raw in enumerate(text.split('\n')):
|
|
116
|
+
line_no = i + 1
|
|
117
|
+
line = raw[:-1] if raw.endswith('\r') else raw
|
|
118
|
+
content = line.lstrip(' \t')
|
|
119
|
+
if content == '' or content.startswith('#'):
|
|
120
|
+
continue
|
|
121
|
+
leading = line[:len(line) - len(content)]
|
|
122
|
+
if '\t' in leading:
|
|
123
|
+
raise _yaml_error(line_no, 'tabs are not allowed in indentation')
|
|
124
|
+
body = content.rstrip(' \t')
|
|
125
|
+
if leading == '' and re.match(r'^(?:---|\.\.\.)(?:[ \t]|$)', body):
|
|
126
|
+
raise _yaml_error(line_no, 'multiple documents (---/...) are not supported')
|
|
127
|
+
lines.append({'indent': len(leading), 'text': body, 'line_no': line_no})
|
|
128
|
+
return lines
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _is_seq_item(text: str) -> bool:
|
|
132
|
+
"""`- item` or a bare `-`."""
|
|
133
|
+
return text == '-' or text.startswith('- ')
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _parse_block(cursor: _Cursor, indent: int) -> Any:
|
|
137
|
+
"""Parse the block that starts at the current line, whose indent is `indent`."""
|
|
138
|
+
if _is_seq_item(cursor.peek()['text']):
|
|
139
|
+
return _parse_sequence(cursor, indent)
|
|
140
|
+
return _parse_mapping(cursor, indent)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _parse_mapping(cursor: _Cursor, indent: int) -> dict:
|
|
144
|
+
"""Parse a block mapping whose keys sit at exactly `indent`."""
|
|
145
|
+
obj: dict = {}
|
|
146
|
+
line = cursor.peek()
|
|
147
|
+
while line is not None:
|
|
148
|
+
if line['indent'] < indent:
|
|
149
|
+
break
|
|
150
|
+
if line['indent'] > indent:
|
|
151
|
+
raise _yaml_error(line['line_no'], 'inconsistent indentation')
|
|
152
|
+
if _is_seq_item(line['text']):
|
|
153
|
+
raise _yaml_error(line['line_no'], 'sequence item where a mapping key was expected')
|
|
154
|
+
entry = _split_mapping_entry(line['text'], line['line_no'])
|
|
155
|
+
if entry is None:
|
|
156
|
+
_assert_node_start(line['text'], 0, line['line_no'], False)
|
|
157
|
+
raise _yaml_error(line['line_no'], 'expected "key: value"')
|
|
158
|
+
key, rest = entry
|
|
159
|
+
if key in obj:
|
|
160
|
+
raise _yaml_error(line['line_no'], f'duplicate key "{key}"')
|
|
161
|
+
cursor.advance()
|
|
162
|
+
empty, value = _parse_inline_value(rest, line['line_no'])
|
|
163
|
+
obj[key] = _parse_child(cursor, indent, True) if empty else value
|
|
164
|
+
line = cursor.peek()
|
|
165
|
+
return obj
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _parse_sequence(cursor: _Cursor, indent: int) -> list:
|
|
169
|
+
"""Parse a block sequence whose `-` markers sit at exactly `indent`."""
|
|
170
|
+
arr: list = []
|
|
171
|
+
line = cursor.peek()
|
|
172
|
+
while line is not None:
|
|
173
|
+
if line['indent'] < indent:
|
|
174
|
+
break
|
|
175
|
+
if line['indent'] > indent:
|
|
176
|
+
raise _yaml_error(line['line_no'], 'inconsistent indentation')
|
|
177
|
+
if not _is_seq_item(line['text']):
|
|
178
|
+
break
|
|
179
|
+
rest = line['text'][1:].lstrip(' ')
|
|
180
|
+
column = line['indent'] + (len(line['text']) - len(rest))
|
|
181
|
+
if rest == '' or rest.startswith('#'):
|
|
182
|
+
cursor.advance()
|
|
183
|
+
arr.append(_parse_child(cursor, indent, False))
|
|
184
|
+
elif _is_seq_item(rest):
|
|
185
|
+
raise _yaml_error(line['line_no'], 'nested sequences on one line are not supported')
|
|
186
|
+
elif _split_mapping_entry(rest, line['line_no']) is not None:
|
|
187
|
+
cursor.replace({'indent': column, 'text': rest, 'line_no': line['line_no']})
|
|
188
|
+
arr.append(_parse_mapping(cursor, column))
|
|
189
|
+
else:
|
|
190
|
+
cursor.advance()
|
|
191
|
+
arr.append(_parse_inline_value(rest, line['line_no'])[1])
|
|
192
|
+
line = cursor.peek()
|
|
193
|
+
return arr
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _parse_child(cursor: _Cursor, parent_indent: int, allow_same_indent_seq: bool) -> Any:
|
|
197
|
+
"""
|
|
198
|
+
Value of a `key:` or `-` with nothing after it: a nested block, a sequence
|
|
199
|
+
at the same indent (only after a key), or None.
|
|
200
|
+
"""
|
|
201
|
+
nxt = cursor.peek()
|
|
202
|
+
if nxt is None:
|
|
203
|
+
return None
|
|
204
|
+
if nxt['indent'] > parent_indent:
|
|
205
|
+
return _parse_block(cursor, nxt['indent'])
|
|
206
|
+
if allow_same_indent_seq and nxt['indent'] == parent_indent and _is_seq_item(nxt['text']):
|
|
207
|
+
return _parse_sequence(cursor, parent_indent)
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _split_mapping_entry(text: str, line_no: int) -> Optional[tuple]:
|
|
212
|
+
"""
|
|
213
|
+
Split `key: rest` on the first mapping indicator outside quotes.
|
|
214
|
+
Returns (key, rest), or None when the text is not a mapping entry.
|
|
215
|
+
"""
|
|
216
|
+
first = text[0]
|
|
217
|
+
if first in '[{':
|
|
218
|
+
return None
|
|
219
|
+
if first in '\'"':
|
|
220
|
+
key, end = _parse_quoted(text, 0, line_no)
|
|
221
|
+
colon = _skip_spaces(text, end)
|
|
222
|
+
if colon < len(text) and text[colon] == ':' and _is_blank_or_end(text, colon + 1):
|
|
223
|
+
return key, text[colon + 1:]
|
|
224
|
+
return None
|
|
225
|
+
for i, ch in enumerate(text):
|
|
226
|
+
if ch == '#' and i > 0 and text[i - 1] == ' ':
|
|
227
|
+
return None
|
|
228
|
+
if ch == ':' and _is_blank_or_end(text, i + 1):
|
|
229
|
+
return _parse_plain_key(text[:i].rstrip(' \t'), line_no), text[i + 1:]
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _parse_plain_key(raw: str, line_no: int) -> str:
|
|
234
|
+
"""Validate a plain (unquoted) mapping key: it must resolve to a string."""
|
|
235
|
+
if raw == '':
|
|
236
|
+
raise _yaml_error(line_no, 'empty mapping key')
|
|
237
|
+
_assert_node_start(raw, 0, line_no, False)
|
|
238
|
+
if raw == '<<':
|
|
239
|
+
raise _yaml_error(line_no, 'merge keys (<<) are not supported')
|
|
240
|
+
if not isinstance(_resolve_plain(raw, line_no), str):
|
|
241
|
+
raise _yaml_error(line_no, f'key "{raw}" is not a string; quote it')
|
|
242
|
+
return raw
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _parse_inline_value(text: str, line_no: int) -> tuple:
|
|
246
|
+
"""
|
|
247
|
+
Parse what follows `key:` or `- ` on the same line.
|
|
248
|
+
Returns (empty, value); empty is True when only spaces/comment remain.
|
|
249
|
+
"""
|
|
250
|
+
start = _skip_spaces(text, 0)
|
|
251
|
+
if start >= len(text) or text[start] == '#':
|
|
252
|
+
return True, None
|
|
253
|
+
first = text[start]
|
|
254
|
+
if first in '[{':
|
|
255
|
+
value, end = _parse_flow(text, start, line_no)
|
|
256
|
+
elif first in '\'"':
|
|
257
|
+
value, end = _parse_quoted(text, start, line_no)
|
|
258
|
+
else:
|
|
259
|
+
_assert_node_start(text, start, line_no, False)
|
|
260
|
+
raw, end = _read_plain(text, start, False, line_no)
|
|
261
|
+
if ': ' in raw or raw.endswith(':'):
|
|
262
|
+
raise _yaml_error(line_no, 'mapping values are not allowed inside a plain scalar; quote it')
|
|
263
|
+
value = _resolve_plain(raw, line_no)
|
|
264
|
+
_assert_end_of_line(text, end, line_no)
|
|
265
|
+
return False, value
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _parse_flow(text: str, pos: int, line_no: int) -> tuple:
|
|
269
|
+
"""
|
|
270
|
+
Parse a one-line flow collection of scalars starting at `pos` (`[` or `{`).
|
|
271
|
+
Returns (value, index after the closer).
|
|
272
|
+
"""
|
|
273
|
+
is_seq = text[pos] == '['
|
|
274
|
+
close = ']' if is_seq else '}'
|
|
275
|
+
result: Any = [] if is_seq else {}
|
|
276
|
+
i = _skip_spaces(text, pos + 1)
|
|
277
|
+
if _char_at(text, i) == close:
|
|
278
|
+
return result, i + 1
|
|
279
|
+
while True:
|
|
280
|
+
i = _skip_spaces(text, i)
|
|
281
|
+
if is_seq:
|
|
282
|
+
item, i = _parse_flow_scalar(text, i, line_no)
|
|
283
|
+
result.append(item)
|
|
284
|
+
else:
|
|
285
|
+
key, i = _parse_flow_scalar(text, i, line_no, True)
|
|
286
|
+
i = _skip_spaces(text, i)
|
|
287
|
+
if _char_at(text, i) != ':':
|
|
288
|
+
raise _yaml_error(line_no, 'expected ":" in flow mapping')
|
|
289
|
+
i = _skip_spaces(text, i + 1)
|
|
290
|
+
value = None
|
|
291
|
+
if _char_at(text, i) not in (',', close):
|
|
292
|
+
value, i = _parse_flow_scalar(text, i, line_no)
|
|
293
|
+
if key in result:
|
|
294
|
+
raise _yaml_error(line_no, f'duplicate key "{key}"')
|
|
295
|
+
result[key] = value
|
|
296
|
+
i = _skip_spaces(text, i)
|
|
297
|
+
ch = _char_at(text, i)
|
|
298
|
+
if ch == ',':
|
|
299
|
+
i = _skip_spaces(text, i + 1)
|
|
300
|
+
if _char_at(text, i) == close:
|
|
301
|
+
return result, i + 1
|
|
302
|
+
elif ch == close:
|
|
303
|
+
return result, i + 1
|
|
304
|
+
elif ch in ('', '#'):
|
|
305
|
+
raise _yaml_error(line_no, 'unclosed flow collection (flow must fit on one line)')
|
|
306
|
+
else:
|
|
307
|
+
raise _yaml_error(line_no, f'expected "," or "{close}" in flow collection')
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _parse_flow_scalar(text: str, pos: int, line_no: int, is_key: bool = False) -> tuple:
|
|
311
|
+
"""Parse one scalar inside a flow collection. Returns (value, index after it)."""
|
|
312
|
+
if pos >= len(text):
|
|
313
|
+
raise _yaml_error(line_no, 'unclosed flow collection (flow must fit on one line)')
|
|
314
|
+
first = text[pos]
|
|
315
|
+
if first in '[{':
|
|
316
|
+
raise _yaml_error(line_no, 'nested flow collections are not supported')
|
|
317
|
+
if first in '\'"':
|
|
318
|
+
return _parse_quoted(text, pos, line_no)
|
|
319
|
+
_assert_node_start(text, pos, line_no, True)
|
|
320
|
+
raw, end = _read_plain(text, pos, True, line_no)
|
|
321
|
+
if raw == '':
|
|
322
|
+
raise _yaml_error(line_no, 'empty entry in flow collection')
|
|
323
|
+
return (_parse_plain_key(raw, line_no) if is_key else _resolve_plain(raw, line_no)), end
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _parse_quoted(text: str, pos: int, line_no: int) -> tuple:
|
|
327
|
+
"""
|
|
328
|
+
Parse a single- or double-quoted scalar starting at `pos`.
|
|
329
|
+
Returns (value, index after the closing quote).
|
|
330
|
+
"""
|
|
331
|
+
quote = text[pos]
|
|
332
|
+
out = []
|
|
333
|
+
i = pos + 1
|
|
334
|
+
while i < len(text):
|
|
335
|
+
ch = text[i]
|
|
336
|
+
if quote == "'" and ch == "'":
|
|
337
|
+
if _char_at(text, i + 1) != "'":
|
|
338
|
+
return ''.join(out), i + 1
|
|
339
|
+
out.append("'")
|
|
340
|
+
i += 2
|
|
341
|
+
elif quote == '"' and ch == '"':
|
|
342
|
+
return ''.join(out), i + 1
|
|
343
|
+
elif quote == '"' and ch == '\\':
|
|
344
|
+
esc = _char_at(text, i + 1)
|
|
345
|
+
if esc == '':
|
|
346
|
+
raise _yaml_error(line_no, 'unclosed double-quoted scalar')
|
|
347
|
+
if esc not in DOUBLE_ESCAPES:
|
|
348
|
+
raise _yaml_error(line_no, f'unsupported escape "\\{esc}" in double-quoted scalar')
|
|
349
|
+
out.append(DOUBLE_ESCAPES[esc])
|
|
350
|
+
i += 2
|
|
351
|
+
else:
|
|
352
|
+
out.append(ch)
|
|
353
|
+
i += 1
|
|
354
|
+
kind = 'single' if quote == "'" else 'double'
|
|
355
|
+
raise _yaml_error(line_no, f'unclosed {kind}-quoted scalar')
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _read_plain(text: str, pos: int, in_flow: bool, line_no: int) -> tuple:
|
|
359
|
+
"""
|
|
360
|
+
Read a plain scalar starting at `pos`; stops at a comment and, inside flow
|
|
361
|
+
collections, at flow indicators and `: `.
|
|
362
|
+
Returns (trimmed raw text, index where it stopped).
|
|
363
|
+
"""
|
|
364
|
+
i = pos
|
|
365
|
+
while i < len(text):
|
|
366
|
+
ch = text[i]
|
|
367
|
+
prev = text[i - 1] if i > 0 else ''
|
|
368
|
+
if ch == '#' and prev == ' ':
|
|
369
|
+
break
|
|
370
|
+
if ch == '#' and prev == '\t':
|
|
371
|
+
raise _yaml_error(line_no, 'tabs are not allowed before comments')
|
|
372
|
+
if in_flow and ch in ',[]{}?':
|
|
373
|
+
break
|
|
374
|
+
if in_flow and ch == ':' and (i + 1 >= len(text) or text[i + 1] in ' \t,[]{}'):
|
|
375
|
+
break
|
|
376
|
+
i += 1
|
|
377
|
+
return text[pos:i].rstrip(' \t'), i
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _resolve_plain(raw: str, line_no: int) -> Any:
|
|
381
|
+
"""Resolve a plain scalar to None / bool / int / float / str."""
|
|
382
|
+
if raw in NULL_WORDS:
|
|
383
|
+
return None
|
|
384
|
+
if raw in TRUE_WORDS:
|
|
385
|
+
return True
|
|
386
|
+
if raw in FALSE_WORDS:
|
|
387
|
+
return False
|
|
388
|
+
if YAML11_BOOL_RE.match(raw):
|
|
389
|
+
raise _yaml_error(line_no, f'ambiguous boolean "{raw}"; use true/false or quote it')
|
|
390
|
+
if INT_RE.match(raw):
|
|
391
|
+
return int(raw)
|
|
392
|
+
if FLOAT_RE.match(raw):
|
|
393
|
+
return float(raw)
|
|
394
|
+
if (YAML11_INT_RE.match(raw) or YAML11_FLOAT_RE.match(raw) or YAML11_TIMESTAMP_RE.match(raw)
|
|
395
|
+
or raw in ('=', '<<')):
|
|
396
|
+
raise _yaml_error(line_no, f'ambiguous scalar "{raw}"; quote it')
|
|
397
|
+
return raw
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _assert_node_start(text: str, pos: int, line_no: int, in_flow: bool) -> None:
|
|
401
|
+
"""Reject indicators that cannot start a node in the subset."""
|
|
402
|
+
ch = text[pos]
|
|
403
|
+
spaced = _is_blank_or_end(text, pos + 1)
|
|
404
|
+
if ch in '&*':
|
|
405
|
+
raise _yaml_error(line_no, 'anchors/aliases are not supported')
|
|
406
|
+
if ch == '!':
|
|
407
|
+
raise _yaml_error(line_no, 'tags (!) are not supported')
|
|
408
|
+
if ch in '|>':
|
|
409
|
+
raise _yaml_error(line_no, 'block scalars (| and >) are not supported')
|
|
410
|
+
if ch == '?' and spaced:
|
|
411
|
+
raise _yaml_error(line_no, 'complex keys (? ) are not supported')
|
|
412
|
+
if ch == '-' and spaced:
|
|
413
|
+
raise _yaml_error(line_no, 'a sequence is not allowed here')
|
|
414
|
+
if ch == ':' and spaced:
|
|
415
|
+
raise _yaml_error(line_no, 'unexpected ":"')
|
|
416
|
+
if ch in '%@`,]}#':
|
|
417
|
+
raise _yaml_error(line_no, f'a plain scalar cannot start with "{ch}"; quote it')
|
|
418
|
+
if in_flow and ch in '[{':
|
|
419
|
+
raise _yaml_error(line_no, 'nested flow collections are not supported')
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _assert_end_of_line(text: str, pos: int, line_no: int) -> None:
|
|
423
|
+
"""After a value only spaces and a ` #` comment may follow."""
|
|
424
|
+
i = _skip_spaces(text, pos)
|
|
425
|
+
if i >= len(text):
|
|
426
|
+
return
|
|
427
|
+
if text[i] == '#' and text[i - 1] == ' ':
|
|
428
|
+
return
|
|
429
|
+
raise _yaml_error(line_no, f'unexpected content after value: "{text[i:]}"')
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _skip_spaces(text: str, pos: int) -> int:
|
|
433
|
+
i = pos
|
|
434
|
+
while i < len(text) and text[i] in ' \t':
|
|
435
|
+
i += 1
|
|
436
|
+
return i
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _is_blank_or_end(text: str, pos: int) -> bool:
|
|
440
|
+
return pos >= len(text) or text[pos] in ' \t'
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _char_at(text: str, pos: int) -> str:
|
|
444
|
+
"""Character at `pos`, or '' past the end (JS `text[pos]` semantics)."""
|
|
445
|
+
return text[pos] if pos < len(text) else ''
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
449
|
+
# Glob-to-Regex Converter
|
|
450
|
+
# Handles: **/, trailing **, *, ?, {a,b} alternation; everything else literal
|
|
451
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
452
|
+
|
|
453
|
+
def glob_to_regex(glob_pattern: str) -> re.Pattern:
|
|
454
|
+
"""
|
|
455
|
+
Convert a glob pattern to a compiled, fully anchored regex.
|
|
456
|
+
|
|
457
|
+
- ``**/`` matches zero or more leading directories
|
|
458
|
+
- ``**`` (trailing or standalone) matches anything, separators included
|
|
459
|
+
- ``*`` matches any run of characters except ``/``
|
|
460
|
+
- ``?`` matches exactly one character except ``/``
|
|
461
|
+
- ``{a,b}`` matches either alternative (alternatives are literal)
|
|
462
|
+
- every other character is matched literally
|
|
463
|
+
"""
|
|
464
|
+
out = []
|
|
465
|
+
i = 0
|
|
466
|
+
n = len(glob_pattern)
|
|
467
|
+
while i < n:
|
|
468
|
+
ch = glob_pattern[i]
|
|
469
|
+
if glob_pattern.startswith('**/', i):
|
|
470
|
+
out.append('(?:.+/)?')
|
|
471
|
+
i += 3
|
|
472
|
+
elif glob_pattern.startswith('**', i):
|
|
473
|
+
out.append('.*')
|
|
474
|
+
i += 2
|
|
475
|
+
elif ch == '*':
|
|
476
|
+
out.append('[^/]*')
|
|
477
|
+
i += 1
|
|
478
|
+
elif ch == '?':
|
|
479
|
+
out.append('[^/]')
|
|
480
|
+
i += 1
|
|
481
|
+
elif ch == '{' and '}' in glob_pattern[i + 1:]:
|
|
482
|
+
close = glob_pattern.index('}', i + 1)
|
|
483
|
+
alternatives = glob_pattern[i + 1:close].split(',')
|
|
484
|
+
out.append('(?:' + '|'.join(re.escape(a) for a in alternatives) + ')')
|
|
485
|
+
i = close + 1
|
|
486
|
+
else:
|
|
487
|
+
out.append(re.escape(ch))
|
|
488
|
+
i += 1
|
|
489
|
+
return re.compile('^' + ''.join(out) + '$')
|