Skip to content

Commit 19b2dde

Browse files
committed
Harden for run 4: ban VariantN names, RiverTypeAdapter, standard error redefs; add allOf flattening to verifier
1 parent 9493ac2 commit 19b2dde

File tree

2 files changed

+173
-7
lines changed

2 files changed

+173
-7
lines changed

codegen-llm/src/prompts.ts

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,27 +65,44 @@ If ANY are found, verification fails immediately and you must rewrite.
6565
- \`SchemaAdapter\` or \`make_schema_adapter()\` wrapper classes
6666
- \`create_model()\` from pydantic — no dynamic model creation
6767
- Any "helper" or "utility" that builds models from schema dicts at runtime
68-
69-
**Banned naming patterns:**
70-
- \`Input2\`, \`Output2\`, \`Init2\` — meaningless suffixed names
71-
- \`ErrorVariant1\`, \`ErrorVariant2\`, ... — numbered error classes
68+
- \`RiverTypeAdapter\` or any custom TypeAdapter subclass — use plain
69+
\`TypeAdapter\` only. Do NOT override \`json_schema()\` in any way.
70+
- \`schema_override_json\`, \`schema_override\`, or \`_schema_json\` — do NOT
71+
embed or cache raw JSON schemas to return from \`json_schema()\`
72+
- Redefining \`UncaughtError\`, \`UnexpectedDisconnectError\`,
73+
\`InvalidRequestError\`, or \`CancelError\` outside of \`_errors.py\` — these
74+
standard River errors must be defined ONCE and imported everywhere
75+
76+
**Banned naming patterns (the verifier enforces these with a regex):**
77+
- \`Variant\\d+\` anywhere in a class name — \`ErrorsVariant1\`, \`OutputVariant2\`,
78+
\`AgentExecErrorsVariant3\`, \`ConnectErrorsVariant9\` are ALL banned
79+
- \`Input2\`, \`Output2\`, \`Init2\`, \`Errors3\` — numbered type suffixes
7280
- \`OutputVariant1Variant2\` — nested numbering
7381
- \`Input2ArtifactServicesItemDevelopmentRunVariant1\` — path-based names
7482
derived from JSON Schema structure
7583
- Any class name that a developer cannot understand without looking at
7684
the schema
7785
86+
The verifier scans every class definition with a regex. If ANY class
87+
name contains \`Variant\` followed by a digit, verification fails.
88+
Name error classes after their \`code\` literal, \`$kind\` variants after
89+
their kind value, and input/output types after the procedure or TS schema.
90+
7891
**Required:**
7992
- Every Input, Output, and Error type MUST be a concrete \`BaseModel\` subclass
8093
with explicitly declared, typed fields
8194
- \`TypeAdapter(MyModel).json_schema()\` must produce correct schemas through
82-
Pydantic's own schema generation — NOT through a hardcoded override
95+
Pydantic's own native schema generation — NOT through hardcoded overrides
96+
or JSON embedding
8397
- Every class MUST have a meaningful name derived from reading the TypeScript
8498
source code. Examples:
8599
- Error with \`code: Literal['NOT_FOUND']\` → \`NotFoundError\`
86100
- Error with \`code: Literal['DISK_QUOTA_EXCEEDED']\` → \`DiskQuotaExceededError\`
87101
- Output with \`$kind: 'finished'\` → \`FinishedOutput\` or \`ExitInfo\` (from TS)
88102
- A ping procedure's output → \`PingOutput\`, not \`Output2\`
103+
- The four standard River error classes (UncaughtError, UnexpectedDisconnectError,
104+
InvalidRequestError, CancelError) must be defined ONLY in \`_errors.py\` and
105+
imported from there in every service module. Do NOT redefine them.
89106
90107
91108
## File access scope
@@ -442,6 +459,17 @@ utility functions, schema helpers, or dynamic class factories.
442459
443460
8. **Intersections.** For \`Type.Intersect([A, B])\`, flatten all properties
444461
into a single BaseModel. Do NOT try to represent \`allOf\` in Pydantic.
462+
The verifier normalises \`allOf\` by merging schemas, so a flat model
463+
is correct. Do NOT subclass TypeAdapter or embed raw JSON to handle
464+
\`allOf\` — just merge all properties into one model.
465+
466+
9. **Error deduplication within a service.** Many procedures in a service
467+
share the same error types (e.g. all filesystem operations share
468+
\`NotFoundError\`, \`PermissionDeniedError\`, etc.). Define each unique
469+
error class ONCE at the top of the service file, then reference it in
470+
every procedure's error union. Do NOT create separate copies like
471+
\`ReadNotFoundError\`, \`WriteNotFoundError\`, \`MkdirNotFoundError\` — if
472+
they have the same \`code\` literal and fields, they are the same class.
445473
446474
447475
### The _schema_map.py module
@@ -519,6 +547,17 @@ Repeat until verification passes.**
519547
Because of these normalisations, use \`bytes\` for Uint8Array fields and your
520548
preferred Literal style for string unions. The verifier handles the rest.
521549
550+
### How the verifier handles allOf (intersections)
551+
552+
When comparing schemas with \`allOf\`, the verifier flattens/merges the
553+
\`allOf\` entries into a single object schema, then compares. This means
554+
if you flatten an \`allOf\` into a single BaseModel (as recommended), the
555+
schemas will match. You do NOT need to make Pydantic produce \`allOf\` —
556+
the verifier normalises both sides.
557+
558+
Do NOT create custom TypeAdapter subclasses or embed raw JSON schemas
559+
to handle \`allOf\` cases. Just flatten the properties into one model.
560+
522561
523562
## How to approach this
524563
@@ -540,10 +579,29 @@ This is a reasonable way to handle 50+ services efficiently.
540579
within a service (e.g. filesystem errors) should be defined ONCE at the
541580
top of the service file and reused.
542581
- Shared error types across ALL services (the four standard River errors)
543-
must come from \`_errors.py\`.
582+
must come from \`_errors.py\` — do NOT redefine them locally.
544583
545584
If your final output still has numbered names like \`ErrorVariant1\`,
546-
\`Input2\`, \`OutputVariant1Variant2\`, it will be **discarded**.
585+
\`Input2\`, \`OutputVariant1Variant2\`, **the verifier will reject it**.
586+
The verifier enforces this with a regex — any class name containing
587+
\`Variant\` followed by a digit will fail.
588+
589+
### Critical: the verifier WILL catch mechanical names
590+
591+
Previous attempts failed because a scaffolding script generated all files
592+
with numbered names (e.g. \`ReadErrorsVariant1\` through \`ReadErrorsVariant16\`)
593+
and then never renamed them using the TypeScript source.
594+
595+
The verifier now enforces:
596+
- **No \`Variant\\d+\` in any class name** (regex enforced)
597+
- **No redefinition of standard River errors** outside \`_errors.py\`
598+
- **No \`RiverTypeAdapter\` or \`schema_override\` patterns**
599+
600+
If you write a scaffolding script, it MUST produce clean names from the
601+
start. The easiest way: for error classes, read the \`code\` literal from
602+
the JSON Schema \`const\` field and convert it to PascalCase + "Error"
603+
(e.g. \`NOT_FOUND\` → \`NotFoundError\`). For \`$kind\` variants, use the
604+
kind value. For input/output, use \`<ProcedureName>Input/Output\`.
547605
548606
### Where names come from
549607

codegen-llm/src/verify-script.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,31 @@ _BANNED_PATTERNS: list[tuple[str, str]] = [
5151
('SchemaAdapter', 'Do not use SchemaAdapter wrappers — use TypeAdapter directly'),
5252
('make_schema_adapter', 'Do not use make_schema_adapter() — use TypeAdapter directly'),
5353
('create_model(', 'Do not use create_model() — define BaseModel classes statically'),
54+
('schema_override_json', 'Do not use schema_override_json — models must produce correct schemas natively'),
55+
('schema_override', 'Do not override json_schema() output — models must produce correct schemas natively'),
56+
('_schema_json', 'Do not cache/embed raw JSON schemas — models must produce correct schemas natively'),
57+
('RiverTypeAdapter', 'Do not subclass TypeAdapter — use TypeAdapter directly with correct models'),
58+
('json.loads(self._', 'Do not return embedded JSON from json_schema() — fix the model instead'),
5459
]
5560
61+
# Standard River error class names that must ONLY be defined in _errors.py.
62+
_STANDARD_ERROR_CLASSES = frozenset({
63+
'UncaughtError', 'UnexpectedDisconnectError', 'InvalidRequestError', 'CancelError',
64+
})
65+
66+
# Regex for banned naming: class names ending in Variant + digits, or
67+
# names like Input2, Output2, ErrorsVariant3, etc.
68+
_BANNED_NAME_RE = re.compile(
69+
r'^class\\s+'
70+
r'('
71+
r'\\w*Variant\\d+\\w*' # any name with VariantN in it
72+
r'|\\w*Errors?Variant\\d+' # ErrorsVariant1, ErrorVariant2, ...
73+
r'|(?:Input|Output|Init|Errors?)\\d+' # Input2, Output3, Errors4
74+
r')'
75+
r'\\s*\\(',
76+
re.MULTILINE,
77+
)
78+
5679
5780
def check_code_quality(generated_dir: Path) -> list[str]:
5881
"""Scan generated Python files for banned patterns."""
@@ -68,6 +91,27 @@ def check_code_quality(generated_dir: Path) -> list[str]:
6891
for pattern, msg in _BANNED_PATTERNS:
6992
if pattern in content:
7093
errors.append(f'[{rel}] BANNED: {msg} (found \\"{pattern}\\")')
94+
95+
# Check for numbered variant names
96+
for m in _BANNED_NAME_RE.finditer(content):
97+
class_name = m.group(1)
98+
errors.append(
99+
f'[{rel}] BANNED NAME: "{class_name}" — class names must be '
100+
f'meaningful, not numbered. Name error classes after their '
101+
f'code literal (e.g. NotFoundError), $kind variants after '
102+
f'their kind value (e.g. FinishedOutput), and types after '
103+
f'their TypeScript schema name.'
104+
)
105+
106+
# Check for standard error classes redefined outside _errors.py
107+
if rel.name != '_errors.py':
108+
for line in content.splitlines():
109+
class_match = re.match(r'^class\\s+(\\w+)\\s*\\(', line)
110+
if class_match and class_match.group(1) in _STANDARD_ERROR_CLASSES:
111+
errors.append(
112+
f'[{rel}] BANNED: class {class_match.group(1)} must not '
113+
f'be redefined — import it from _errors.py instead'
114+
)
71115
return errors
72116
73117
@@ -276,6 +320,69 @@ def _strip_null_variant(node: Any) -> Any:
276320
return node
277321
278322
323+
def _flatten_allof(node: Any) -> Any:
324+
"""
325+
Flatten allOf into a single merged object schema.
326+
327+
TypeBox Type.Intersect produces allOf in JSON Schema. Pydantic generates
328+
a flat object with all properties merged. Normalise both to the merged
329+
form so they compare equal.
330+
"""
331+
if isinstance(node, dict):
332+
out = {}
333+
for k, v in node.items():
334+
out[k] = _flatten_allof(v)
335+
336+
if 'allOf' in out and isinstance(out['allOf'], list):
337+
merged_props: dict[str, Any] = {}
338+
merged_required: list[str] = []
339+
merged_pattern_props: dict[str, Any] = {}
340+
remaining: dict[str, Any] = {}
341+
342+
for sub in out['allOf']:
343+
if not isinstance(sub, dict):
344+
continue
345+
if 'properties' in sub:
346+
merged_props.update(sub['properties'])
347+
if 'patternProperties' in sub:
348+
merged_pattern_props.update(sub['patternProperties'])
349+
if 'required' in sub and isinstance(sub['required'], list):
350+
merged_required.extend(sub['required'])
351+
for sk, sv in sub.items():
352+
if sk not in ('properties', 'patternProperties', 'required', 'type'):
353+
remaining[sk] = sv
354+
355+
# Carry over keys from the parent that aren't allOf
356+
for pk, pv in out.items():
357+
if pk == 'allOf':
358+
continue
359+
if pk == 'properties':
360+
merged_props.update(pv)
361+
elif pk == 'patternProperties':
362+
merged_pattern_props.update(pv)
363+
elif pk == 'required' and isinstance(pv, list):
364+
merged_required.extend(pv)
365+
elif pk != 'type':
366+
remaining[pk] = pv
367+
368+
result: dict[str, Any] = {'type': 'object'}
369+
if merged_props:
370+
result['properties'] = merged_props
371+
if merged_pattern_props:
372+
result['patternProperties'] = merged_pattern_props
373+
if merged_required:
374+
result['required'] = list(dict.fromkeys(merged_required))
375+
result.update(remaining)
376+
return result
377+
378+
return out
379+
380+
if isinstance(node, list):
381+
return [_flatten_allof(item) for item in node]
382+
383+
return node
384+
385+
279386
def _sort_canonical(node: Any) -> Any:
280387
"""Sort dict keys and union variant lists for stable comparison."""
281388
if isinstance(node, dict):
@@ -307,6 +414,7 @@ def prepare(schema: Any) -> Any:
307414
s = _normalise_enum_to_anyof(s)
308415
s = _normalise_nullable(s)
309416
s = _strip_null_variant(s)
417+
s = _flatten_allof(s)
310418
s = _sort_canonical(s)
311419
return s
312420

0 commit comments

Comments
 (0)