Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,15 @@ jobs:
# A prerelease is a GitHub prerelease and never "Latest".
channel=()
if [ "$PRERELEASE" = true ]; then channel=(--prerelease --latest=false); fi
# The diagnostics catalog (docs/PUBLIC_CONTRACT.md, "Diagnostic codes").
catalog="diagnostics_catalog-${GIT_TAG}.json"
cp pineforge_codegen/diagnostics_catalog.json "${catalog}"
gh release create "${GIT_TAG}" \
--title "${GIT_TAG}" \
--generate-notes \
--notes "Install: \`pip install pineforge-codegen==${PYPI_VERSION}\`" \
${channel[@]+"${channel[@]}"} \
dist/*
dist/* "${catalog}"

# Notify the pineforge-release hub: it bumps its CODEGEN_VERSION pin,
# rebuilds the combined image, and fans out to the MCPs. FAIL LOUD (no
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ pineforge_codegen/
├── tv_input_choices.py input.string options metadata
├── errors.py CompileError + SourceLocation +
│ Diagnostic + Level / Phase
├── diagnostic_codes.py Stable diagnostic codes: a Diagnostic's
│ code/args are read off its text by the
│ ICU templates of diagnostics_catalog.json
│ (scripts/gen_diagnostics_catalog.py adds a
│ code per new template; the pin in
│ tests/fixtures/diagnostic_codes_pin.json
│ never changes a code's meaning).
├── pine_spelling.py String-literal-safe helpers for the Pine
│ spellings of TA ctor args (inline
│ input calls kept whole; see quirk 10)
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,26 @@ supported as exact pairs; on the 0.x line they are independent. See the
without the extension warn and run, including their requests, without proving
eligibility. Default batch computation is unchanged.

### Diagnostic codes

An additive change to the public API; it leaves the emitted C++ unchanged.

- **Diagnostic codes.** Every transpile diagnostic carries a stable `code`
(`PF-E1203` / `PF-W0412`) and named, raw `args`, in
`transpile_full(...)["diagnostics"]`, in `CompileError.diagnostics` and in
the `transpile_json` envelopes. `diagnostics_catalog()` and
`pineforge_codegen/diagnostics_catalog.json` (attached to each GitHub
release) give each code its severity, English ICU MessageFormat templates,
argument kinds and a one-line explanation; the templates render the
`message` and `hint` byte for byte, which keep their text. Codes are never
reused (`tests/fixtures/diagnostic_codes_pin.json`). See
[Diagnostic codes](docs/PUBLIC_CONTRACT.md#diagnostic-codes).
- **First error in source order.** Since 1.1.0 the settings metadata visited
every input's `defval`, `options`, `minval`, `maxval` and `step` ahead of
the script body, so an error there (an unknown name in `minval`) was raised
before an error on an earlier line. The script's first error in source
order is raised again; no emitted C++ changes.

## Release note policy

- Keep a section for each released version, including prereleases. Use the exact
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,13 @@ pineforge_codegen/
├── tv_input_choices.py input.string options metadata
├── errors.py CompileError + SourceLocation +
│ Diagnostic + Level / Phase
├── diagnostic_codes.py Stable diagnostic codes: a Diagnostic's
│ code/args are read off its text by the
│ ICU templates of diagnostics_catalog.json
│ (scripts/gen_diagnostics_catalog.py adds a
│ code per new template; the pin in
│ tests/fixtures/diagnostic_codes_pin.json
│ never changes a code's meaning).
├── pine_spelling.py String-literal-safe helpers for the Pine
│ spellings of TA ctor args (inline
│ input calls kept whole; see quirk 10)
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,33 @@ warnings; since 1.1.0 the success envelope also carries `requests`. See the
for the exact fields, severity values, and input key rules. There is no
installed CLI or exit-code contract.

### Diagnostic codes

Every diagnostic carries a stable `code` and named `args` (since 1.2.0):
`PF-E1203` for an error, `PF-W0412` for a warning, and the data its English
`message` and `hint` were built from — identifiers, types, keywords and
numbers, raw.

```python
from pineforge_codegen import diagnostics_catalog, transpile_full

for d in transpile_full(source)["diagnostics"]:
print(d.code, d.args, d.message)
# PF-W1067 {'name': 'bar_index'} bar_index diverges from TradingView semantics in PineForge.

entry = diagnostics_catalog()["codes"]["PF-W1067"]
# {'severity': 'warning', 'area': 'support',
# 'message': '{name} diverges from TradingView semantics in PineForge.',
# 'hint': '...', 'explanation': '...', 'args': {'name': {'kind': 'identifier'}}}
```

The catalog (`pineforge_codegen/diagnostics_catalog.json`, also attached to
each GitHub release) gives each code its severity, English ICU MessageFormat
templates and a one-line explanation; rendering the templates with `args`
gives the `message` and `hint` byte for byte, so an application can translate
a diagnostic by its code. Codes are never reused. See
[Diagnostic codes](docs/PUBLIC_CONTRACT.md#diagnostic-codes).

### List the other symbols a script requests

A run reads another symbol's bars only from the feed it is given for that
Expand Down
47 changes: 44 additions & 3 deletions docs/PUBLIC_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,50 @@ and `diagnostics` contains the error's diagnostics; `cpp`, `inputs`,
integer `line` and `col`, a `message` string, and `severity` equal to
`"warning"` or `"error"`. `endCol` is included when the
source location provides it. A diagnostic hint, when present, is appended to
`message` after ` — `. The glue catches `CompileError`; an unexpected Python
exception may propagate instead of producing an envelope. The JSON entry
point does not accept a `filename` argument.
`message` after ` — `. Since 1.2.0 every JSON diagnostic also has `code` and
`args` ([Diagnostic codes](#diagnostic-codes)). The glue catches
`CompileError`; an unexpected Python exception may propagate instead of
producing an envelope. The JSON entry point does not accept a `filename`
argument.

## Diagnostic codes

Since 1.2.0 every `Diagnostic` (in `transpile_full(...)["diagnostics"]`, in a
`CompileError`'s `diagnostics`, and in the glue envelopes) carries:

- `code`: a stable string `PF-<S><NNNN>`, `S` being `E` for an error and `W`
for a warning. Its first digit is the area that spells the text: `0`
source (lexer, parser, limits), `1` support checker and requests, `2`
analysis, `3` `request.security`, `4` libraries and imports, `5` code
generation, `6` array and matrix history, `7` `ta.*`. The support checker
reports what it refuses inside a `switch` arm as a warning: `PF-W1nnn` with
`nnn` below 500 is `PF-E1nnn` there.
- `args`: an object of named values, raw: identifiers, types, keywords and
Pine spellings as strings, counts as JSON numbers; never quoting, backticks
or a formatted number.

`diagnostics_catalog()` returns the catalog, which ships as
`pineforge_codegen/diagnostics_catalog.json` (schema
`pineforge-diagnostics-catalog/v1`) and is attached to each GitHub release.
Per code it gives `severity`, `area`, the English ICU MessageFormat `message`
template, the `hint` template or `null`, a one-line `explanation`, and `args`:
per argument its `kind` — `identifier`, `type`, `keyword`, `number`, `vocab`
(an English word or phrase the transpiler picks from the closed set listed in
`values`, which an application may translate) or `text` (open English text the
transpiler builds, such as a nested reason; shown as is). The templates use
simple `{name}` arguments and ICU apostrophe quoting (`''` is one apostrophe,
`'{'` a literal brace); a string argument renders as is and a number in plain
decimal digits. `render_diagnostic(code, args)` returns the English
`(message, hint)`, which equal the diagnostic's `message` and `hint` byte for
byte; in the glue envelope `message` is the message, plus ` — ` and the hint
when there is one.

A code is never removed or reused, and a changed meaning gets a new code
(`tests/fixtures/diagnostic_codes_pin.json` pins each code's templates). A
text no template renders carries `PF-E0000` / `PF-W0000` with the text in
`args.message` (and `args.hint`); the test suite refuses it. The `message`
text itself is unchanged and stays the English rendering; `runtime.error`
text a strategy authors is not a transpile diagnostic.

## Compatibility boundary

Expand Down
4 changes: 4 additions & 0 deletions gate/glue.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ def _diagnostic_entries(diagnostics) -> list:
"col": loc.col if loc else 1,
"message": message,
"severity": getattr(d.level, "value", "error"),
# The stable code and named arguments of the catalog templates the
# message (and the hint after " — ") render from.
"code": d.code,
"args": d.args,
}
end_col = getattr(loc, "end_col", None) if loc else None
if end_col is not None:
Expand Down
3 changes: 2 additions & 1 deletion npm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
"exports": {
".": "./index.mjs",
"./tables.json": "./tables.json",
"./release.json": "./release.json"
"./release.json": "./release.json",
"./diagnostics_catalog.json": "./pineforge_codegen/diagnostics_catalog.json"
},
"files": [
"index.mjs",
Expand Down
1 change: 1 addition & 0 deletions pineforge_codegen/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from .parser import Parser
from .analyzer import Analyzer
from .codegen import CodeGen
from .diagnostic_codes import diagnostics_catalog, render_diagnostic
from .errors import CompileError, Level, Phase
from .external_requests import lower_no_data_requests
from .builtin_keywords import bind_builtin_keywords
Expand Down
39 changes: 38 additions & 1 deletion pineforge_codegen/codegen/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4414,7 +4414,44 @@ def _declared_cpp_type(self, decl: VarDecl) -> str | None:
return None

def generate(self) -> str:
"""Generate C++ source from the AnalyzerContext."""
"""Generate C++ source from the AnalyzerContext.

An error the settings metadata finds in an input's arguments (emitted
in the constructor, ahead of the body: ``checked_settings``) is held
until the rest is generated; the error raised is the first in source
order, as before that metadata visited the arguments."""
self._deferred_settings_errors: list[CompileError] | None = []
try:
try:
cpp = self._generate_cpp()
except CompileError as error:
first = self._first_settings_error_before(error)
if first is None:
raise
raise first from None
if self._deferred_settings_errors:
raise self._deferred_settings_errors[0]
return cpp
finally:
self._deferred_settings_errors = None

def _defer_settings_error(self, error: CompileError) -> None:
held = getattr(self, "_deferred_settings_errors", None)
if held is None: # outside generate(): nothing holds it
raise error
held.append(error)

def _first_settings_error_before(self, error: CompileError) -> CompileError | None:
"""A held settings error located before ``error``, else None."""
def where(err: CompileError):
loc = err.diagnostics[0].location if err.diagnostics else None
return (loc.line, loc.col) if loc is not None else (float("inf"), 0)
held = sorted(self._deferred_settings_errors, key=where)
if held and where(held[0]) < where(error):
return held[0]
return None

def _generate_cpp(self) -> str:
# Every input is keyed by its title: refuse a non-constant one first,
# then flag inputs one override key would reach together.
self._check_input_titles()
Expand Down
19 changes: 16 additions & 3 deletions pineforge_codegen/codegen/checked_settings.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
"""Opt-in settings metadata, validation and exception-contained C exports."""

from ..ast_nodes import Identifier, MemberAccess
from ..errors import CompileError


def _visit_setting_arg(emitter, expr) -> str:
"""An input argument's C++ for the settings metadata. The metadata is
emitted in the constructor, ahead of the script body: an error here is
held until the body is generated (``CodeGen.generate``), so the script's
first error in source order is the one raised."""
try:
return emitter._visit_expr(expr)
except CompileError as error:
emitter._defer_settings_error(error)
return "0"


def emit_settings_members(emitter, lines: list[str], constructor: list[str]) -> None:
Expand Down Expand Up @@ -38,7 +51,7 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
key = emitter._input_key_literal(name)
default = emitter._get_input_default(node)
getter = emitter._input_getter_for_call(node, func_name, namespace)
default_cpp = emitter._visit_expr(default) if default is not None else "0"
default_cpp = _visit_setting_arg(emitter, default) if default is not None else "0"
default_cpp = emitter._coerce_string_input_default(getter, default_cpp)
value_type = {
"get_input_int": "int", "get_input_int64": "int",
Expand Down Expand Up @@ -89,7 +102,7 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
declared = getattr(arguments.get("options"), "elements", None)
if declared:
for option in declared:
option_cpp = emitter._visit_expr(option)
option_cpp = _visit_setting_arg(emitter, option)
options.append(option_cpp if getter == "get_input_string" else
f'::pineforge::checked_settings::number({option_cpp})')
expression = f'{getter}({key}, {default_cpp})'
Expand All @@ -98,7 +111,7 @@ def emit_settings_members(emitter, lines: list[str], constructor: list[str]) ->
if default_serialized is None:
default_serialized = (default_cpp if value_type in ("string", "source") else
f'::pineforge::checked_settings::number({default_cpp})')
constraints = [emitter._visit_expr(arguments[name]) if arguments.get(name) is not None
constraints = [_visit_setting_arg(emitter, arguments[name]) if arguments.get(name) is not None
else "std::numeric_limits<double>::quiet_NaN()"
for name in ("minval", "maxval", "step")]
metadata = (f'{{{key}, "{value_type}", {default_serialized}, '
Expand Down
Loading
Loading