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.
Files changed (78) hide show
  1. package/.agents/skills/clckernel_cli/SKILL.md +23 -6
  2. package/.agents/skills/clckernel_cli/reference/audit.md +13 -3
  3. package/.agents/skills/clckernel_cli/reference/commit.md +1 -1
  4. package/.agents/skills/clckernel_cli/reference/doctor.md +5 -3
  5. package/.agents/skills/clckernel_cli/reference/feature.md +3 -3
  6. package/.agents/skills/clckernel_cli/reference/guard.md +48 -12
  7. package/.agents/skills/clckernel_cli/reference/phase.md +6 -3
  8. package/.agents/skills/clckernel_cli/reference/start.md +14 -4
  9. package/.agents/skills/clckernel_cli/reference/test.md +1 -1
  10. package/.agents/skills/clckernel_cli/reference/update.md +19 -19
  11. package/CHANGELOG.md +244 -0
  12. package/LICENSE +21 -0
  13. package/README.es.md +280 -51
  14. package/README.md +274 -50
  15. package/bin/cli.js +8 -0
  16. package/package.json +12 -2
  17. package/src/adapters/agent-os.js +161 -84
  18. package/src/adapters/astro.js +8 -6
  19. package/src/adapters/base.js +27 -46
  20. package/src/adapters/django.js +14 -13
  21. package/src/adapters/fastapi.js +16 -15
  22. package/src/adapters/go.js +11 -9
  23. package/src/adapters/laravel.js +12 -10
  24. package/src/adapters/nextjs.js +7 -5
  25. package/src/adapters/rails.js +13 -11
  26. package/src/adapters/rust.js +11 -9
  27. package/src/catalog.js +58 -49
  28. package/src/config.js +38 -240
  29. package/src/detector.js +243 -44
  30. package/src/doctor.js +18 -9
  31. package/src/generator.js +737 -309
  32. package/src/gitignore.js +144 -0
  33. package/src/index.js +257 -18
  34. package/src/io.js +409 -0
  35. package/src/known-hashes.json +309 -0
  36. package/src/onboarding.js +15 -0
  37. package/src/technologies/detector.js +1 -1
  38. package/src/technologies/guards/_shared.js +1 -1
  39. package/src/ui.js +233 -40
  40. package/src/updater.js +271 -36
  41. package/tools/_changes.js +279 -0
  42. package/tools/_changes.py +233 -0
  43. package/tools/_config.js +294 -0
  44. package/tools/_config.py +271 -0
  45. package/tools/_yaml.py +489 -0
  46. package/tools/audit.js +511 -111
  47. package/tools/audit.py +506 -128
  48. package/tools/celery_guard.py +13 -35
  49. package/tools/check_a11y.js +8 -28
  50. package/tools/check_api_contracts.js +13 -39
  51. package/tools/check_architecture.js +35 -44
  52. package/tools/check_architecture.py +172 -105
  53. package/tools/check_custom.js +108 -61
  54. package/tools/check_custom.py +108 -271
  55. package/tools/check_data_files.js +88 -0
  56. package/tools/check_db_efficiency.py +80 -65
  57. package/tools/check_hitl.js +112 -69
  58. package/tools/check_links.js +138 -0
  59. package/tools/check_migrations.py +143 -94
  60. package/tools/check_performance.js +35 -44
  61. package/tools/check_placeholders.js +104 -0
  62. package/tools/check_responsive.js +8 -28
  63. package/tools/check_scope.py +40 -31
  64. package/tools/check_seo.js +8 -28
  65. package/tools/check_skills.js +158 -0
  66. package/tools/check_storybook.js +8 -21
  67. package/tools/check_symlinks.js +40 -22
  68. package/tools/check_ui_reuse.js +17 -29
  69. package/tools/docker_guard.py +19 -41
  70. package/tools/postgres_guard.py +15 -36
  71. package/tools/redis_guard.py +13 -35
  72. package/tools/scan_secrets.js +134 -67
  73. package/tools/scan_secrets.py +163 -72
  74. package/tools/verify_tdd.js +867 -0
  75. package/tools/verify_tdd.py +407 -165
  76. package/tools/yaml.js +404 -132
  77. package/tools/check_domain_data.js +0 -100
  78. 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) + '$')