Skip to content

feat: JSON template engine with shell-style variable substitution - #1

Merged
amery merged 3 commits into
mainfrom
feat/template-engine
Mar 23, 2026
Merged

feat: JSON template engine with shell-style variable substitution#1
amery merged 3 commits into
mainfrom
feat/template-engine

Conversation

@amery

@amery amery commented Mar 22, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Compile-once, render-many JSON template engine with shell-style
    ${var:-default} variable substitution
  • Bare variables preserve native types; embedded variables concatenate
    as strings — position determined at compile time
  • Brace-depth-aware scanner handles nested JSON in defaults
  • Dotted key paths with per-segment validation, pre-tokenized
    paths (split once at compile time), and own-property-only
    resolution (no prototype chain leakage)
  • Strict mode, listVariables() static analysis
  • Bare defaults JSON-parsed at compile time; object/array
    defaults deep-copied per render via structuredClone
  • Sentinel collision guard rejects literal U+E000 and
    JSON-escaped \uE000 sequences; double-escaped
    \\uE000 (literal text) is correctly allowed
  • Object.defineProperty for safe __proto__ key handling (no prototype pollution)
  • TemplateVariable fields readonly + elements frozen
    at construction; listVariables() returns frozen results
  • 84 tests (23 scanner, 60 template, 1 VERSION)

Structure

src/
├── types.ts             — TemplateVariable, CompileOptions
├── errors.ts            — TemplateParseError, UnresolvedVariableError
├── json.ts              — jsonNull, isNull, isNonStringPrimitive, isObject
├── scanner.ts           — scan(), ScannedExpr
├── tree.ts              — buildTree(), TNode, IPart, SENTINEL
├── template.ts          — Template class, compile(), listVariables()
├── index.ts             — VERSION + barrel re-exports
└── __tests__/
    ├── index.test.ts    — VERSION test
    ├── scanner.test.ts  — scanner and parse error tests
    └── template.test.ts — Template rendering tests

Test plan

  • pnpm precommit passes (build, lint, typecheck, test)
  • Review API surface in README.md
  • Verify AGENTS.md captures architecture and invariants

Summary by CodeRabbit

  • New Features

    • JSON-template API: compile/render, Template class, listVariables, dual output, shell-style defaults, dotted-path resolution, strict-mode behavior, exported jsonNull/isNull and new template error types.
  • Documentation

    • Expanded README with semantics, API, defaults, strictness, and npm provenance; added repository-level contributor/assistant workflow and style guide.
  • Tests

    • Comprehensive test suites covering scanning, parsing, defaults, rendering, serialization, and error cases.
  • Style

    • ESLint updates including additional ignores and arrow-parens rule.
  • Chores

    • Repository URL format adjusted.

@coderabbitai

coderabbitai Bot commented Mar 22, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds a TypeScript JSON-template engine: scanner, sentinel-based compile (scan → sentinel-insert → single JSON.parse) and AST builder, Template runtime with bare vs embedded semantics, shell-style defaults, dotted-path own-property resolution, strict-mode errors, JSON-null helpers, tests, and documentation.

Changes

Cohort / File(s) Summary
Documentation
AGENTS.md, README.md
New agent guidelines and expanded README describing engine design, API (compile, listVariables, Template), semantics (bare vs embedded), shell-style :- defaults, dotted-path resolution, strict-mode behavior, jsonNull/isNull, contributor/workflow/style rules, and provenance.
Public surface & types
src/index.ts, src/types.ts
Added re-exports and type definitions: compile, listVariables, Template, TemplateVariable, CompileOptions, TemplateParseError, UnresolvedVariableError, jsonNull, isNull.
JSON utilities
src/json.ts
Introduced jsonNull sentinel and helpers: isNull, isNonStringPrimitive, isObject.
Errors
src/errors.ts
Added TemplateParseError(message, offset) and UnresolvedVariableError(variableName) classes with readonly properties and explicit name fields.
Scanner
src/scanner.ts
New single-pass scanner scan(template: string): ScannedExpr[] tracking string context, escapes, brace depth, extracting name and optional defaultValue, recording offsets/lengths, and throwing TemplateParseError for malformed expressions.
Tree & AST
src/tree.ts
Added SENTINEL (reserved marker), TNode/IPart AST types and buildTree(value) converting post-JSON.parse values into AST nodes; rejects variable expressions in object keys.
Compiler & Runtime
src/template.ts
Implements compile(template, options?) (sentinel injection, single JSON.parse, AST build) and Template class with render/toJSON, pre-parsed fallbacks (JSON-parse when possible), dotted-path own-property resolution, bare vs embedded coercion/fallback rules, strict-mode unresolved handling, and frozen metadata.
Tests
src/__tests__/scanner.test.ts, src/__tests__/template.test.ts
New Vitest suites covering scanning, listVariables, default parsing (including nested JSON defaults), bare vs embedded semantics, strict-mode errors, parse-offset checks, prototype-key safety, sentinel collision cases, and extensive render behaviors.
Config
eslint.config.mjs, package.json
ESLint config extended (ignore patterns, scoped rules including arrow-parens) and repository.url scheme updated in package.json.

Sequence Diagram

sequenceDiagram
    participant User
    participant Compiler as Compiler\n(src/template.ts)
    participant Scanner as Scanner\n(src/scanner.ts)
    participant Parser as JSON_Parser
    participant TreeBuilder as TreeBuilder\n(src/tree.ts)
    participant Template as Template\n(instance)
    participant Renderer as Renderer\n(render)
    participant Context as Context

    User->>Compiler: compile(templateString, options)
    Compiler->>Scanner: scan(templateString)
    Scanner-->>Compiler: ScannedExpr[]
    Compiler->>Compiler: inject SENTINEL markers into text
    Compiler->>Parser: JSON.parse(withSentinels)
    Parser-->>Compiler: parsedValue
    Compiler->>TreeBuilder: buildTree(parsedValue)
    TreeBuilder-->>Compiler: AST (TNode)
    Compiler->>Template: new Template(AST, variables, options.strict)
    User->>Template: render(context)
    Template->>Renderer: traverse AST nodes
    Renderer->>Context: resolve dotted-paths (own-property lookup)
    Context-->>Renderer: value or undefined
    Renderer->>Renderer: apply fallbacks / coerce (bare vs embedded)
    Renderer-->>Template: rendered JS value
    Template-->>User: final value / toJSON string
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes

Poem

🐰 I hopped through braces, quotes, and sand,
Sentinels tucked gently in my hand,
Trees grew nodes where placeholders hide,
Dotted paths led values inside,
Render hummed — JSON ready, build and bind.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly and accurately describes the primary feature implemented in the changeset: a JSON template engine supporting shell-style variable substitution with defaults.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/template-engine

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/scanner.test.ts`:
- Around line 83-85: The test "unterminated expression" is currently closing the
`${...}` and thus tests invalid JSON rather than an unterminated expression;
update the test input in the it block that calls compile to leave the expression
unclosed (e.g. change compile('{"v": ${oops}') to compile('{"v": ${oops') or
another string where the `${` has no matching `}`) so the compile function's
expression parser (compile) triggers TemplateParseError as intended.

In `@src/__tests__/template.test.ts`:
- Around line 163-179: Add two Vitest regression tests: (1) under the
strict-mode suite, add expectations that rendering with strict:true throws
UnresolvedVariableError for unresolved variables named "toString" and
"__proto__" when given a plain {} context by invoking compile(...).render({});
(2) add a test that a template string containing the engine's internal sentinel
character (the parsing placeholder used by the template engine) does not break
parsing and still respects defaults — compile a template that includes the
sentinel character plus a defaulted variable (use compile(..., { strict: true
}).render({})) and assert it returns the expected default value; reference
compile and UnresolvedVariableError to locate where to add these cases.

In `@src/template.ts`:
- Around line 47-53: The issue is repeated JSON parses at render time via
tryParseJSON when handling bare defaults; move parsing to compile-time and store
the parsed fallback so render() can use it directly. Modify the compile() logic
that processes expr.defaultValue (and the other occurrences noted around lines
113-115) to call tryParseJSON once and attach the resulting parsed value (or a
marker for invalid JSON) to the compiled template node, then update render() to
read that cached parsed fallback instead of calling
tryParseJSON(expr.defaultValue) repeatedly; keep the tryParseJSON helper for
compile-time use and ensure compiled nodes expose a field like parsedDefault or
defaultFallback used by the render path.
- Around line 25-31: The resolve function is traversing object properties via
bracket access which follows the prototype chain and allows inherited properties
like toString or __proto__ to resolve; update resolve to only access own
properties by checking Object.prototype.hasOwnProperty.call(current, part) (and
return undefined if it’s not an own property) before assigning current =
(current as Record<string, unknown>)[part]; keep the existing typeof/current
null checks and use the same function name resolve to locate the change.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 40de9431-d627-46c5-8e62-13c1406d7292

📥 Commits

Reviewing files that changed from the base of the PR and between db9cdf0 and bc4ecf8.

📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread src/__tests__/scanner.test.ts
Comment thread src/__tests__/template.test.ts
Comment thread src/template.ts Outdated
Comment thread src/template.ts Outdated
@amery
amery force-pushed the feat/template-engine branch from bc4ecf8 to c0b948e Compare March 22, 2026 18:52
@pkg-pr-new

pkg-pr-new Bot commented Mar 22, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/kagal-dev/json-template/@kagal/json-template@1

commit: b5c8ba0

@amery

amery commented Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressed all five review comments in a single amended commit:

Fixes:

  1. Prototype chain leak in resolve() (template.ts) — added hasOwn() guard so inherited properties like toString, __proto__, and constructor are treated as missing, not resolved from the prototype chain.

  2. Sentinel collision guard (template.ts) — compile() now rejects templates containing the PUA sentinel character (U+E000) with a TemplateParseError before scanning.

  3. Compile-time fallback parsing (template.ts) — bare defaults are now JSON-parsed once during compile() via parseFallback() and stored in a Fallback[] array. render() reads the pre-parsed value instead of calling JSON.parse on every invocation.

  4. Unterminated expression test (scanner.test.ts) — changed test input from ${oops} to ${oops so it exercises the scanner's depth !== 0 path instead of a post-extraction JSON parse error.

  5. Regression tests (template.test.ts) — 6 new tests: prototype key safety (3 strict + 1 non-strict) and sentinel collision (2 cases).

Documentation updates:

  • AGENTS.md: added index.test.ts to structure tree, updated invariant v0.1.1 #3 to reflect compile-time default parsing, added invariant chore: Configure Renovate #6 for sentinel guard, expanded resolve() "By design" entry to document own-property constraint, removed sentinel collision from "Low priority" (now a hard error)
  • README.md: added "reserved sentinel character" to TemplateParseError causes

67 tests, pnpm precommit clean.

@coderabbitai full review

@amery amery self-assigned this Mar 22, 2026
@coderabbitai

coderabbitai Bot commented Mar 22, 2026

Copy link
Copy Markdown

@amery Thanks for the detailed summary — all five fixes look well-targeted:

  • The hasOwn() guard closes the prototype-chain leak cleanly.
  • The sentinel collision guard making it a hard TemplateParseError (rather than a silent corruption) is the right call.
  • Moving parseFallback() to compile-time eliminates the per-render JSON.parse overhead.
  • The corrected unterminated-expression test now actually exercises the depth !== 0 scanner path as intended.
  • The 6 new regression tests give good coverage of the security-sensitive and edge-case scenarios.

Triggering a full review of the updated PR now.

✅ Actions performed

Full review triggered.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/__tests__/scanner.test.ts`:
- Around line 82-98: Current tests in src/__tests__/scanner.test.ts only assert
error class for compile(...) calls; update each spec in the 'parse errors' suite
to also assert TemplateParseError.message contains an expected fragment and that
the error.offset equals the expected numeric position. For each case
(unterminated expression, empty expression, invalid variable name, invalid JSON
structure) capture the thrown error from compile(...) (e.g., using try/catch or
expect().toThrowError and returning the error), then assert on
error.message.includes('<expected fragment>') and error.offset === <expected
offset> to lock API behavior; reference the compile function and
TemplateParseError type when locating the tests to change.

In `@src/__tests__/template.test.ts`:
- Around line 157-162: Add a unit test to exercise hyphenated variable names
because VAR_NAME_RE permits hyphens but no test covers it: in the tests around
compile/render (see compile and render usage and the 'names set' suite), add a
new it block that compiles a template with a hyphenated variable (for example
compile('{"v": ${my-var}}')), calls .render({ 'my-var': 42 }), and asserts the
result equals { v: 42 } to ensure hyphenated keys are handled end-to-end.

In `@src/json.ts`:
- Around line 15-20: Rename the function isLiteral to a clearer name
(suggestion: isNonStringPrimitive) and update its JSDoc to state it returns true
for null, boolean, and number (explicitly excluding strings); change the
exported function name and all references/usages (calls, imports, tests, and
type annotations) from isLiteral to isNonStringPrimitive to avoid JSON
terminology ambiguity while preserving the same implementation (keep isNull,
typeof checks and boolean return logic).

In `@src/template.ts`:
- Around line 234-238: In the catch block that currently throws new
TemplateParseError(`Template is not valid JSON...`, 0) replace the hardcoded 0
with an extracted offset: parse the thrown error's message (cast to
SyntaxError/Error) with a small regex to find a numeric column/position (e.g.
"column X" or "position X") and pass that number into the TemplateParseError
constructor; if no numeric position can be extracted, fall back to 0. Update the
catch around JSON.parse in template parsing (the catch handling
TemplateParseError creation) to perform this extraction before throwing.

In `@src/tree.ts`:
- Around line 86-97: Replace unary plus numeric coercion with explicit integer
parsing for sentinel indices: in the branch returning { kind: 'bare', idx:
+bareMatch[1] } and in the loop pushing { idx: +m[1] } use Number.parseInt(...,
10) instead (e.g., parse the captured group with Number.parseInt(bareMatch[1],
10) and Number.parseInt(m[1], 10)) to make intent and radix explicit in the
parsing logic around SENTINEL_RE and the parts assembly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: d87afd98-1a6e-4580-8a90-61c132a2f12c

📥 Commits

Reviewing files that changed from the base of the PR and between bc4ecf8 and c0b948e.

📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread src/__tests__/scanner.test.ts
Comment thread src/__tests__/template.test.ts
Comment thread src/json.ts Outdated
Comment thread src/template.ts
Comment on lines +234 to +238
} catch (error) {
throw new TemplateParseError(
`Template is not valid JSON (after extracting variables): ${(error as Error).message}`,
0,
);

@coderabbitai coderabbitai Bot Mar 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

JSON parse error offset is always 0, losing position information.

When JSON.parse fails, the error offset is hardcoded to 0. The actual error position within the modified JSON string could be extracted from the native SyntaxError.message (some engines include column info), but mapping it back to the original template is non-trivial due to sentinel substitution. The current approach is pragmatic given the complexity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/template.ts` around lines 234 - 238, In the catch block that currently
throws new TemplateParseError(`Template is not valid JSON...`, 0) replace the
hardcoded 0 with an extracted offset: parse the thrown error's message (cast to
SyntaxError/Error) with a small regex to find a numeric column/position (e.g.
"column X" or "position X") and pass that number into the TemplateParseError
constructor; if no numeric position can be extracted, fall back to 0. Update the
catch around JSON.parse in template parsing (the catch handling
TemplateParseError creation) to perform this extraction before throwing.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai could you create a ticket for proper line/column tracking for better error reporting?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh issue create \
  --repo kagal-dev/json-template \
  --title "feat: proper line/column tracking in TemplateParseError for better error reporting" \
  --body "## Summary

When \`JSON.parse\` fails during template compilation in \`src/template.ts\`, the thrown \`TemplateParseError\` currently hardcodes the offset to \`0\`, losing actual position information.

## Motivation

Accurate line/column offsets in \`TemplateParseError\` would significantly improve the developer experience by pointing directly to the problematic location in the original template string, rather than always reporting offset \`0\`.

## Suggested Approach

- Parse the native \`SyntaxError\` message from \`JSON.parse\` using a regex to extract numeric column/position info (e.g. \`\"column X\"\` or \`\"position X\"\` patterns present in V8/SpiderMonkey messages).
- Map that extracted position back to the original template string, accounting for the sentinel substitutions made during compilation.
- Fall back to \`0\` if no numeric position can be extracted from the error message.
- Consider also tracking line numbers (not just character offsets) in \`TemplateParseError\` for multi-line templates.

## Context

- Identified in PR `#1` (review comment): https://github.com/kagal-dev/json-template/pull/1#discussion_r2971923053
- Affected code: \`src/template.ts\`, catch block around \`JSON.parse\` in the \`compile()\` function.

## Requested by

`@amery`" \
  --assignee amery

Length of output: 51


@amery, I've created the follow-up issue: #2

It covers:

  • Extracting position info from the native SyntaxError message via regex (e.g. "column X" / "position X" patterns)
  • Mapping that position back to the original template string (accounting for sentinel substitutions)
  • Falling back to 0 when no numeric position can be extracted
  • Optionally tracking line numbers as well for multi-line templates

Comment thread src/tree.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 101-110: Add a short note under the "Dotted key paths" section
clarifying that dotted resolution only checks own properties (not inherited ones
like toString, constructor, __proto__), reference the example using compile(...)
and .render(...), and mention that this behavior is the intentional
security/prototype-chain fix (linking or pointing readers to AGENTS.md for more
details).

In `@src/__tests__/template.test.ts`:
- Around line 212-214: The test currently uses a malformed placeholder so the
scanner's unterminated-expression path is exercised instead of the
sentinel-default guard; update the fixture in the it('sentinel in default value
throws') test to use a well-formed template placeholder (e.g. replace '{"v":
"${x:-\uE000"' with '{"v":"${x:-\uE000}"}') so compile(...) hits the
sentinel-default branch and still throws TemplateParseError, ensuring the
sentinel guard is exercised by the test.

In `@src/index.ts`:
- Around line 11-14: Decide whether isNull and jsonNull should be part of the
public root API: if they are intended public entrypoints, add documentation for
isNull and jsonNull to the README alongside Template, exported errors, and
VERSION and keep the export in src/index.ts; otherwise remove their re-export
from src/index.ts (so only Template, errors, VERSION, etc. remain public) and
instead export them from an internal module (e.g., ./json) or an internal barrel
to keep the root API deliberate. Ensure references to the symbols isNull and
jsonNull in docs/tests are updated to the chosen public or internal path.

In `@src/template.ts`:
- Around line 156-160: The object materialization in the 'object' branch uses
direct assignment (object[k] = ...) which allows "__proto__" prototype
poisoning; in the case handling inside renderNode where you iterate
node.entries, replace direct property assignment with creating the property via
Object.defineProperty on the created object (use enumerable: true, configurable:
true, writable: true and set value to this.renderNode(child, context)) so keys
like "__proto__" become real own properties and cannot mutate the prototype
chain.

In `@src/tree.ts`:
- Around line 113-118: The interpolated-key errors always use offset 0; fix by
threading real expression offsets into buildTree (compile should call
buildTree(..., exprs.map(expr => expr.offset))) and change buildTree's signature
to accept that offsets array; in the SENTINEL_RE check use SENTINEL_RE.exec(k)
(not test) to get the match index and then pass offsets[match.index] (or the
corresponding mapped offset) into TemplateParseError instead of 0, ensuring
SENTINEL_RE.lastIndex is handled/reset as before.

In `@src/types.ts`:
- Around line 2-13: Make the exported TemplateVariable interface immutable by
marking all properties readonly (e.g., readonly raw, name, defaultValue, bare,
offset) so consumers cannot mutate metadata, and when exposing
Template.variables ensure the runtime freezes each element (Object.freeze) and
the array itself before returning to match the readonly type; update any
factory/constructor or getter that produces Template.variables to return a
frozen array of frozen TemplateVariable objects.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 83e013bc-4f11-4334-9afa-cc1f923fae69

📥 Commits

Reviewing files that changed from the base of the PR and between db9cdf0 and c0b948e.

📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread README.md
Comment thread src/__tests__/template.test.ts
Comment thread src/index.ts
Comment on lines +11 to +14
export {
isNull,
jsonNull,
} from './json';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Decide whether jsonNull / isNull belong in the root API.

Re-exporting these here makes them part of the package’s semver contract alongside Template, the errors, and VERSION. If they are intended entrypoint APIs, document them in the README with the rest of the surface; otherwise keep them internal so the root barrel stays deliberate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` around lines 11 - 14, Decide whether isNull and jsonNull should
be part of the public root API: if they are intended public entrypoints, add
documentation for isNull and jsonNull to the README alongside Template, exported
errors, and VERSION and keep the export in src/index.ts; otherwise remove their
re-export from src/index.ts (so only Template, errors, VERSION, etc. remain
public) and instead export them from an internal module (e.g., ./json) or an
internal barrel to keep the root API deliberate. Ensure references to the
symbols isNull and jsonNull in docs/tests are updated to the chosen public or
internal path.

Comment thread src/template.ts
Comment thread src/tree.ts
Comment on lines +113 to +118
SENTINEL_RE.lastIndex = 0;
if (SENTINEL_RE.test(k)) {
throw new TemplateParseError(
'Variable expressions in object keys are not supported',
0,
);

@coderabbitai coderabbitai Bot Mar 22, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Use the real source offset for interpolated-key parse errors.

This currently throws TemplateParseError(..., 0) for every key-position variable, so all such failures point at the start of the document even though the sentinel already identifies the originating expression. Please thread the scanned offsets into buildTree() and use the matched index here.

🩹 Suggested direction
-export function buildTree(value: unknown): TNode {
+export function buildTree(
+  value: unknown,
+  exprOffsets: readonly number[],
+): TNode {
   if (isLiteral(value)) {
     return { kind: 'literal', value };
   }
 
   if (typeof value === 'string') {
@@
   }
 
   if (Array.isArray(value)) {
-    return { kind: 'array', items: value.map(v => buildTree(v)) };
+    return { kind: 'array', items: value.map(v => buildTree(v, exprOffsets)) };
   }
 
   if (typeof value === 'object') {
     const entries = Object.entries(value as Record<string, unknown>)
       .map(([k, v]): [string, TNode] => {
         SENTINEL_RE.lastIndex = 0;
-        if (SENTINEL_RE.test(k)) {
+        const match = SENTINEL_RE.exec(k);
+        if (match) {
           throw new TemplateParseError(
             'Variable expressions in object keys are not supported',
-            0,
+            exprOffsets[Number(match[1])] ?? 0,
           );
         }
-        return [k, buildTree(v)];
+        return [k, buildTree(v, exprOffsets)];
       });

compile() would then pass exprs.map(expr => expr.offset) into the root buildTree() call.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tree.ts` around lines 113 - 118, The interpolated-key errors always use
offset 0; fix by threading real expression offsets into buildTree (compile
should call buildTree(..., exprs.map(expr => expr.offset))) and change
buildTree's signature to accept that offsets array; in the SENTINEL_RE check use
SENTINEL_RE.exec(k) (not test) to get the match index and then pass
offsets[match.index] (or the corresponding mapped offset) into
TemplateParseError instead of 0, ensuring SENTINEL_RE.lastIndex is handled/reset
as before.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#2

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh issue view 2 --repo kagal-dev/json-template

Length of output: 1455


@amery, understood — deferring to #2.

Worth noting that #2 currently describes only the JSON.parse catch-block offset in compile(). This case (object-key sentinel errors in buildTree() always reporting offset: 0) is a related but distinct occurrence — would you like me to update #2's body to also call it out explicitly so it doesn't get overlooked?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, please

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
gh issue view 2 --repo kagal-dev/json-template --json title,body,url

Length of output: 1431


🏁 Script executed:

#!/bin/bash
gh issue edit 2 --repo kagal-dev/json-template --body "## Summary

When \`JSON.parse\` fails during template compilation in \`src/template.ts\`, the thrown \`TemplateParseError\` currently hardcodes the offset to \`0\`, losing actual position information.

Additionally, \`buildTree()\` in \`src/tree.ts\` always throws \`TemplateParseError(..., 0)\` when it detects a variable expression in an object key, even though the sentinel encodes the originating expression index — which maps directly to a real source offset.

## Motivation

Accurate offsets in \`TemplateParseError\` would significantly improve the developer experience by pointing directly to the problematic location in the original template string, rather than always reporting offset \`0\`.

## Affected locations

### 1. \`compile()\` — \`JSON.parse\` catch block (\`src/template.ts\`)

- Parse the native \`SyntaxError\` message from \`JSON.parse\` using a regex to extract numeric column/position info (e.g. \`\"column X\"\` or \`\"position X\"\` patterns present in V8/SpiderMonkey messages).
- Map that extracted position back to the original template string, accounting for the sentinel substitutions made during compilation.
- Fall back to \`0\` if no numeric position can be extracted from the error message.
- Consider also tracking line numbers (not just character offsets) in \`TemplateParseError\` for multi-line templates.

### 2. \`buildTree()\` — object-key sentinel check (\`src/tree.ts\`)

\`buildTree()\` currently uses \`SENTINEL_RE.test(k)\` and hard-codes \`0\` as the offset:

\`\`\`ts
if (SENTINEL_RE.test(k)) {
  throw new TemplateParseError(
    'Variable expressions in object keys are not supported',
    0,  // always 0 — real offset is knowable
  );
}
\`\`\`

The sentinel encodes the expression index (e.g. \`\uE000E2\uE000\` → index \`2\`), which can be looked up in the \`exprs\` array already available at compile time. The fix is to:

- Thread a \`readonly number[]\` of expression offsets into \`buildTree()\` (e.g. \`exprs.map(e => e.offset)\` passed from \`compile()\`).
- Switch from \`SENTINEL_RE.test(k)\` to \`SENTINEL_RE.exec(k)\` to capture the match group.
- Use \`exprOffsets[Number(match[1])] ?? 0\` as the \`TemplateParseError\` offset.
- Propagate \`exprOffsets\` into recursive \`buildTree()\` calls for arrays and object values.

## Context

- Identified in PR `#1` (review comment — \`JSON.parse\` offset): https://github.com/kagal-dev/json-template/pull/1#discussion_r2971923053
- Identified in PR `#1` (review comment — \`buildTree()\` object-key offset): https://github.com/kagal-dev/json-template/pull/1#discussion_r2971939323
- Affected code: \`src/template.ts\` (catch block), \`src/tree.ts\` (\`buildTree()\` object-key branch).

## Requested by

`@amery`"

Length of output: 51


@amery, done! #2 has been updated to cover both cases:

  1. compile()JSON.parse catch block (src/template.ts): the original item, mapping SyntaxError position back through sentinel substitutions.
  2. buildTree() — object-key sentinel check (src/tree.ts): the new item, switching from SENTINEL_RE.test(k) to SENTINEL_RE.exec(k), threading exprOffsets through the recursive calls, and using exprOffsets[Number(match[1])] ?? 0 instead of the hard-coded 0.

Comment thread src/types.ts
@amery

amery commented Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressed round 2 review (4 of 5 comments, 1 deferred):

Implemented:

  1. Parse-error offset assertions (scanner.test.ts) — all 4 parse error tests now assert both error class and .offset property via expect.assertions(2) + try/catch pattern. Offsets verified by tracing scanner character-by-character.

  2. Hyphenated variable name test (template.test.ts) — added my-var test in dotted keys suite, confirming VAR_NAME_RE hyphen support works end-to-end.

  3. Rename isLiteralisNonStringPrimitive (json.ts, tree.ts, AGENTS.md) — avoids JSON terminology ambiguity. Expanded JSDoc explains why strings are excluded.

  4. Number.parseInt over unary + (tree.ts) — both sentinel index coercions now use explicit Number.parseInt(..., 10).

Also fixed:

  • buildTree() JSDoc claimed default parsing belongs in renderNode() — corrected to: "Default parsing belongs in compile() (see parseFallback)."

Deferred:

  1. Extract JSON parse error offset (template.ts:238) — the JSON string passed to JSON.parse is the sentinel-replaced string, not the original template. Any extracted offset would be meaningless without proper position mapping. Tracked in feat: proper line/column tracking in TemplateParseError for better error reporting #2.

68 tests, pnpm precommit clean.

@coderabbitai review please

@coderabbitai

coderabbitai Bot commented Mar 22, 2026

Copy link
Copy Markdown

@amery Thanks for the thorough round 2 update — all four implemented changes look solid:

  • Offset assertions in scanner tests improve error contract coverage significantly.
  • isNonStringPrimitive is a much clearer name given JSON's own definition of "literal" includes strings.
  • Number.parseInt(..., 10) over the unary + coercion is a good explicit-over-implicit call.
  • The hyphenated variable name end-to-end test confirms VAR_NAME_RE coverage.

The deferral of item 5 is well-reasoned — without mapping sentinel-string offsets back to the original template positions, any reported offset would be misleading. The issue #2 backlink keeps it tracked appropriately.

Triggering a review now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (5)
README.md (1)

101-110: ⚠️ Potential issue | 🟡 Minor

Document the own-property lookup rule next to dotted paths.

The section doesn't mention that resolution only follows own properties, treating inherited keys like toString, constructor, and __proto__ as missing. This is a security-relevant behavior that users should be aware of.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 101 - 110, The README's "Dotted key paths" section
omits that resolution only checks own properties (inherited keys like toString,
constructor, __proto__ are treated as missing); update that section to state the
own-property lookup rule explicitly and give a short note or example using the
compile/render API (e.g., the compile(...) and .render(...) example) clarifying
that only Object.prototype.hasOwnProperty-style own keys are followed and
inherited properties are not resolved.
src/types.ts (1)

2-13: 🛠️ Refactor suggestion | 🟠 Major

TemplateVariable properties should be readonly.

The previous review comment about making these fields readonly remains unaddressed. Since TemplateVariable is part of the public API and represents compile-time metadata that shouldn't be mutated, marking all properties as readonly would prevent accidental corruption and align with the documented known limitation in AGENTS.md.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types.ts` around lines 2 - 13, Update the TemplateVariable interface so
all its properties are readonly: mark raw, name, defaultValue (optional), bare,
and offset as readonly in the TemplateVariable declaration; then fix any call
sites that attempt to mutate a TemplateVariable (e.g., assignments to
raw/name/defaultValue/bare/offset) by creating a new object or copying with
spread before changing values to preserve immutability.
src/tree.ts (1)

113-119: ⚠️ Potential issue | 🟡 Minor

Use the real source offset for interpolated-key parse errors.

The hardcoded offset: 0 means all key-position variable errors point to the document start, which is unhelpful for debugging. The sentinel match already identifies the expression index, which can be used to look up the real offset.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tree.ts` around lines 113 - 119, The error uses a hardcoded offset 0 when
rejecting variable expressions in object keys; instead capture the actual match
position from SENTINEL_RE and pass that into TemplateParseError. Replace the
SENTINEL_RE.test(k) check with an exec (or use the RegExp match result) to
obtain match.index (resetting SENTINEL_RE.lastIndex as needed), compute the real
offset from that index, and construct the TemplateParseError with that computed
offset rather than 0 (referencing SENTINEL_RE, the key variable k, and
TemplateParseError).
src/__tests__/template.test.ts (1)

217-219: ⚠️ Potential issue | 🟡 Minor

Sentinel-default test still exercises the wrong code path.

The test input '{"v": "${x:-\uE000"}' is missing the closing } of the placeholder expression. This causes the scanner to throw for an unterminated expression rather than the sentinel collision guard in compile(). The test passes but for the wrong reason.

🔧 Proposed fix
   it('sentinel in default value throws', () => {
-    expect(() => compile('{"v": "${x:-\uE000"}')).toThrow(TemplateParseError);
+    expect(() => compile('{"v": "${x:-\uE000}"}')).toThrow(
+      /reserved sentinel character/i,
+    );
   });

Note the added } before the final " to properly close the ${...} expression, and the regex assertion to verify the correct error path is exercised.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/__tests__/template.test.ts` around lines 217 - 219, The test currently
uses a malformed placeholder '{"v": "${x:-\uE000"}' (missing the closing '}') so
the scanner throws for an unterminated expression instead of exercising the
sentinel collision guard in compile(); update the test input to properly close
the placeholder (e.g., '{"v": "${x:-\uE000}"}') and strengthen the assertion to
verify compile() throws TemplateParseError for the sentinel-default collision by
matching the error message with a regex that targets the sentinel-related text.
src/template.ts (1)

156-162: ⚠️ Potential issue | 🔴 Critical

Add protection against __proto__ poisoning in object materialization.

Direct assignment object[k] = ... when k === "__proto__" invokes JavaScript's prototype setter instead of creating an own property. This affects templates with literal "__proto__" keys—the returned object's prototype chain is mutated, and serialization via toJSON() may omit the key.

The existing prototype key safety tests (lines 194-196 in src/tests/template.test.ts) cover variable references to __proto__, but not literal "__proto__" keys in the template structure itself.

🔒 Proposed fix using Object.defineProperty
      case 'object': {
        const object: Record<string, unknown> = {};
        for (const [k, child] of node.entries) {
-          object[k] = this.renderNode(child, context);
+          Object.defineProperty(object, k, {
+            value: this.renderNode(child, context),
+            enumerable: true,
+            configurable: true,
+            writable: true,
+          });
        }
        return object;
      }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/template.ts` around lines 156 - 162, The object materialization in
renderNode (case 'object') directly assigns object[k] = ..., which triggers
prototype mutation when k === "__proto__"; change the loop so that for the
"__proto__" key you create an own enumerable property instead of using the
assignment—e.g., compute the value with this.renderNode(child, context) and then
call Object.defineProperty(object, "__proto__", { value, enumerable: true,
writable: true, configurable: true }); for that key, while keeping the direct
assignment for all other keys; this ensures literal "__proto__" entries become
normal own properties and do not mutate the returned object's prototype.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@README.md`:
- Around line 101-110: The README's "Dotted key paths" section omits that
resolution only checks own properties (inherited keys like toString,
constructor, __proto__ are treated as missing); update that section to state the
own-property lookup rule explicitly and give a short note or example using the
compile/render API (e.g., the compile(...) and .render(...) example) clarifying
that only Object.prototype.hasOwnProperty-style own keys are followed and
inherited properties are not resolved.

In `@src/__tests__/template.test.ts`:
- Around line 217-219: The test currently uses a malformed placeholder '{"v":
"${x:-\uE000"}' (missing the closing '}') so the scanner throws for an
unterminated expression instead of exercising the sentinel collision guard in
compile(); update the test input to properly close the placeholder (e.g., '{"v":
"${x:-\uE000}"}') and strengthen the assertion to verify compile() throws
TemplateParseError for the sentinel-default collision by matching the error
message with a regex that targets the sentinel-related text.

In `@src/template.ts`:
- Around line 156-162: The object materialization in renderNode (case 'object')
directly assigns object[k] = ..., which triggers prototype mutation when k ===
"__proto__"; change the loop so that for the "__proto__" key you create an own
enumerable property instead of using the assignment—e.g., compute the value with
this.renderNode(child, context) and then call Object.defineProperty(object,
"__proto__", { value, enumerable: true, writable: true, configurable: true });
for that key, while keeping the direct assignment for all other keys; this
ensures literal "__proto__" entries become normal own properties and do not
mutate the returned object's prototype.

In `@src/tree.ts`:
- Around line 113-119: The error uses a hardcoded offset 0 when rejecting
variable expressions in object keys; instead capture the actual match position
from SENTINEL_RE and pass that into TemplateParseError. Replace the
SENTINEL_RE.test(k) check with an exec (or use the RegExp match result) to
obtain match.index (resetting SENTINEL_RE.lastIndex as needed), compute the real
offset from that index, and construct the TemplateParseError with that computed
offset rather than 0 (referencing SENTINEL_RE, the key variable k, and
TemplateParseError).

In `@src/types.ts`:
- Around line 2-13: Update the TemplateVariable interface so all its properties
are readonly: mark raw, name, defaultValue (optional), bare, and offset as
readonly in the TemplateVariable declaration; then fix any call sites that
attempt to mutate a TemplateVariable (e.g., assignments to
raw/name/defaultValue/bare/offset) by creating a new object or copying with
spread before changing values to preserve immutability.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 03807efe-2912-41aa-967b-d33fe548bb1f

📥 Commits

Reviewing files that changed from the base of the PR and between c0b948e and eabd255.

📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

@amery
amery force-pushed the feat/template-engine branch from eabd255 to 8a5e994 Compare March 22, 2026 20:13
@amery

amery commented Mar 22, 2026

Copy link
Copy Markdown
Contributor Author

Addressed round 3 review (5 of 6 comments, 1 deferred):

Implemented:

  1. __proto__ poisoning fix (template.ts:159) — object materialization now uses Object.defineProperty instead of direct assignment, so literal "__proto__" keys become own properties without mutating the prototype chain. Test added.

  2. TemplateVariable immutable (types.ts, template.ts:87) — all interface fields marked readonly, individual elements frozen via Object.freeze at construction time. Test added.

  3. jsonNull/isNull documented in README — both are intentional public exports; added API sections in README rather than removing them.

  4. Sentinel-default test fixed (template.test.ts:232) — template is now well-formed (${x:-\uE000} properly closed) and asserts specific message /reserved sentinel character/i.

  5. Own-property lookup documented in README — Dotted key paths section now notes that resolution only follows own properties, inherited keys treated as missing.

Also fixed:

  • Removed stale "Known Limitations" entry in AGENTS.md that said TemplateVariable objects are not frozen.

Deferred:

  1. Interpolated-key parse error offset (tree.ts:118) — tracked in feat: proper line/column tracking in TemplateParseError for better error reporting #2.

70 tests, pnpm precommit clean.

@coderabbitai review please

@coderabbitai

coderabbitai Bot commented Mar 22, 2026

Copy link
Copy Markdown

@amery Thanks for the comprehensive round 3 update — all five implemented items look well-targeted:

  • Object.defineProperty for __proto__ keys is exactly the right fix; direct assignment silently mutates the prototype, while Object.defineProperty creates a genuine own property regardless of the key name.
  • readonly fields + Object.freeze on TemplateVariable gives both compile-time safety (TypeScript) and runtime immutability — a complete solution.
  • README documentation for jsonNull/isNull and the own-property-only dotted-path resolution keeps the public API contract explicit and auditable.
  • Corrected sentinel-default test with a well-formed template and specific message assertion is much stronger than the prior version.
  • Removing the stale AGENTS.md "Known Limitations" entry keeps the invariants accurate now that TemplateVariable objects are frozen.

The deferral of item 6 (interpolated-key parse error offset) is well-reasoned — any offset derived from the sentinel-replaced string would need non-trivial mapping back to the original template to be meaningful, and #2 is the right place to track it.

Triggering a review now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@AGENTS.md`:
- Around line 186-191: Replace the wordy phrase "at the moment `${` is
encountered" with the more concise "when `${` is encountered" in the sentence
discussing inString and sentinel prefixes; update the sentence that references
the inString flag, `${`, and the scanner's backslash handling (`pos += 2`) to
use "when `${` is encountered" so the wording is shorter and consistent.

In `@src/template.ts`:
- Around line 32-38: resolve currently calls key.split('.') on every lookup
which allocates each time; change the implementation and call sites to
pre-tokenize dotted paths at compile time and use the array of path segments
during render. Introduce a cached representation (e.g., a Map<string, string[]>
or change node shape to store segments) so resolve(context, segments: string[])
or resolveSegments(context, segments) walks the precomputed segments with hasOwn
checks (use the existing hasOwn and current variable) instead of splitting on
each call; update any callers that pass a string key (e.g., code that uses
resolve(key)) to pass the cached segments produced during compilation.
- Around line 53-60: parseFallback currently only checks the raw defaultValue
for SENTINEL but JSON.parse can decode "\uE000" into the sentinel char; update
parseFallback and any JSON.parse usage in compile to validate the parsed
string/structure for presence of SENTINEL and reject (or treat as invalid) if
found—specifically, after JSON.parse in parseFallback and in compile,
recursively scan string values in the parsed result for the SENTINEL constant
and return has: false or throw as appropriate; add a regression test fixture
that supplies a JSON-escaped "\uE000" in a template/default and assert the
runtime rejects it (also apply the same fix to the code paths noted around lines
212-243).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 0bb0f43c-5c09-4a4f-a058-e14abbdd1256

📥 Commits

Reviewing files that changed from the base of the PR and between eabd255 and 8a5e994.

📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread AGENTS.md
Comment thread src/template.ts Outdated
Comment thread src/template.ts
@amery
amery force-pushed the feat/template-engine branch from 8a5e994 to 40753b0 Compare March 23, 2026 18:33
@amery

amery commented Mar 23, 2026

Copy link
Copy Markdown
Contributor Author

Addressed round 4 review (3 comments):

  1. AGENTS.md style — "at the moment ${ is encountered" → "when ${ is encountered".

  2. Pre-tokenize dotted paths (scanner.ts, template.ts) — VAR_NAME_RE replaced with per-segment SEGMENT_RE (/^[a-zA-Z_][a-zA-Z0-9_-]*$/). Paths are split once at compile time into a segments[] parallel array; resolve() walks the pre-split array instead of calling key.split('.') per render. This also validates path structure at compile time — consecutive dots (a..b), leading/trailing dots (.a, a.), and numeric segments (arr.0) are now rejected. README variable name rules updated. 4 tests added.

  3. JSON-escaped sentinel hole (template.ts) — compile() now rejects \uE000/\ue000 escape sequences in the raw template before JSON.parse can decode them into actual U+E000 sentinel characters. Without this guard, a template like {"v": "\uE000B0\uE000"} would pass the raw sentinel check, then JSON.parse would decode the escapes, and buildTree would match fake sentinel markers — causing index aliasing or crashes. AGENTS.md invariant chore: Configure Renovate #6 updated. 6 tests added.

80 tests (23 scanner, 56 template, 1 VERSION), pnpm precommit clean.

@coderabbitai review please

@coderabbitai

coderabbitai Bot commented Mar 23, 2026

Copy link
Copy Markdown
🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cat src/scanner.ts
echo "---"
cat src/template.ts
echo "---"
cat src/tree.ts
echo "---"
# Also check test counts
grep -c "it(" src/__tests__/scanner.test.ts src/__tests__/template.test.ts || true
grep -c "test(" src/__tests__/scanner.test.ts src/__tests__/template.test.ts || true

Length of output: 16464


@amery Thanks for the round 4 update — all three changes look correct. A couple of minor observations worth noting:

segments.length === 0 is unreachable (scanner.ts line ~92)

String.prototype.split() never returns an empty array — even "".split(".") yields [""]. So the segments.length === 0 guard is dead code. The empty-name case is correctly caught by segments.some(s => !SEGMENT_RE.test(s)) since SEGMENT_RE.test("") is false. The check can be removed, though it's harmless.

Name is split twice

scanner.ts splits name.split('.') for validation but discards the result (it's not stored in ScannedExpr). Then template.ts repeats expr.name.split('.') to build segments. Storing the pre-split segments in ScannedExpr would eliminate the second split — though again, this is a minor efficiency point rather than a correctness issue.

SENTINEL_ESC_RE check looks correct — /\\u[eE]000/ matches the 6-character ASCII escape sequence in the raw template string before JSON.parse decodes it. ✓

Triggering a review now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/scanner.ts`:
- Around line 100-103: The explicit check segments.length === 0 is unreachable
because name.split('.') always returns at least one element; simplify the
validation in the scanner by removing that clause and only test each segment
against SEGMENT_RE (i.e., replace the condition in the block using segments and
SEGMENT_RE), leaving the throw of TemplateParseError(`Invalid variable name
"${name}"`, start + 2) intact so invalid or empty segments still trigger the
same error.

In `@src/template.ts`:
- Around line 10-11: The regex SENTINEL_ESC_RE currently matches any "\uE000"
sequence even when the backslash is itself escaped (e.g. in
String.raw`{"v":"\\uE000"}`); change the check to only match escapes that will
actually decode to U+E000 by requiring the backslash before "u" to be unescaped
(i.e. ensure an odd number of backslashes immediately preceding the "u" so the
escape is active). Replace the /\\u[eE]000/ pattern with a regex that enforces
an unescaped backslash (for example: use a lookbehind or an explicit
odd-backslash pattern) and apply the same fix to the other occurrence mentioned
(lines ~225-231), and add a regression test that includes
String.raw`{"v":"\\uE000"}` to ensure double-escaped literals are not rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 530220ec-6e4c-4af3-9ea6-01a06f45af6e

📥 Commits

Reviewing files that changed from the base of the PR and between 8a5e994 and 40753b0.

📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread src/scanner.ts
Comment thread src/template.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/template.ts (1)

10-11: ⚠️ Potential issue | 🟠 Major

Only reject active \uE000 escapes, not double-escaped literals.

/\\u[eE]000/ also matches the second slash in \\uE000, so valid templates containing the literal six-character text \uE000 are rejected before JSON.parse() even though they would not decode to the sentinel. The guard should only match odd backslash runs, and the reported offset should point at the slash that actually starts the active unicode escape.

Suggested fix
-/** Matches a JSON-encoded `\uE000` or `\ue000` escape sequence (6 ASCII chars). */
-const SENTINEL_ESC_RE = /\\u[eE]000/;
+/**
+ * Matches a JSON unicode escape that will decode to U+E000.
+ * Even-length backslash runs like `\\uE000` stay literal and must remain allowed.
+ */
+const SENTINEL_ESC_RE = /(^|[^\\])((?:\\\\)*)\\u[eE]000/;
@@
   const escMatch = template.match(SENTINEL_ESC_RE);
   if (escMatch) {
+    const offset = escMatch.index! + escMatch[1].length + escMatch[2].length;
     throw new TemplateParseError(
       String.raw`Template contains JSON-escaped sentinel sequence (\uE000)`,
-      escMatch.index!,
+      offset,
     );
   }

Please also add a regression for String.raw\{"v":"\uE000"}`` in the sentinel suite.

Also applies to: 225-230

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/template.ts` around lines 10 - 11, The current SENTINEL_ESC_RE
(/\\u[eE]000/) incorrectly matches double-escaped literals; change the sentinel
guard to only detect active unicode escapes by matching an odd run of
backslashes before the u (e.g. a pattern that asserts an odd number of
backslashes like /(^|[^\\])(?:\\\\)*\\u[eE]000/ or equivalent) and update any
offset calculation that reports the match to point at the actual backslash that
starts the active escape (use the capture/group index for the slash). Also add a
regression test in the sentinel test suite for String.raw`{"v":"\\uE000"}` to
ensure literal six-character sequences are not rejected.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/template.ts`:
- Around line 53-63: parseFallback currently returns a single parsed
object/array value that is reused across renders (via renderNode), which allows
mutations in one render to affect others; fix by ensuring mutable fallbacks are
not shared: in parseFallback (or in renderNode where the fallback is used)
detect when the parsed value is an object or array and return a deep copy (e.g.,
structured clone / JSON parse/stringify or a custom deepClone) instead of the
original cached reference so each render receives its own instance; update
references to parseFallback and renderNode to clone the fallback before
returning to callers.
- Around line 93-94: The listVariables() API currently returns mutable metadata
while Template.variables and each TemplateVariable are frozen; update
listVariables() to return an immutable structure by either returning the
already-frozen this.variables array or by returning a new frozen array with each
element Object.freeze(...) (ensuring each TemplateVariable is frozen too).
Locate the listVariables() method and replace its mutable return with a
reference to the frozen this.variables or construct/return
Object.freeze(variables.map(v => Object.freeze(v))). Apply the same change to
the other similar accessor method mentioned so both public APIs consistently
expose immutable TemplateVariable instances.

---

Duplicate comments:
In `@src/template.ts`:
- Around line 10-11: The current SENTINEL_ESC_RE (/\\u[eE]000/) incorrectly
matches double-escaped literals; change the sentinel guard to only detect active
unicode escapes by matching an odd run of backslashes before the u (e.g. a
pattern that asserts an odd number of backslashes like
/(^|[^\\])(?:\\\\)*\\u[eE]000/ or equivalent) and update any offset calculation
that reports the match to point at the actual backslash that starts the active
escape (use the capture/group index for the slash). Also add a regression test
in the sentinel test suite for String.raw`{"v":"\\uE000"}` to ensure literal
six-character sequences are not rejected.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 90a18e16-2c89-464f-b130-224887be1653

📥 Commits

Reviewing files that changed from the base of the PR and between 8a5e994 and 40753b0.

📒 Files selected for processing (11)
  • AGENTS.md
  • README.md
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread src/template.ts
Comment thread src/template.ts Outdated
Add @stylistic/arrow-parens 'always' rule scoped to source
files, and add .tmp to the ignores list.

Signed-off-by: Alejandro Mery <amery@apptly.co>
@amery
amery force-pushed the feat/template-engine branch from 40753b0 to d6e0e71 Compare March 23, 2026 21:27
@amery

amery commented Mar 23, 2026

Copy link
Copy Markdown
Contributor Author

Addressed round 5 review (4 comments):

  1. Dead segments.length === 0 check (scanner.ts) — removed; name.split('.') always returns ≥1 element, and SEGMENT_RE.test('') already catches the empty case.

  2. SENTINEL_ESC_RE false positive on \\uE000 (template.ts) — regex now uses a lookbehind ((?<=(?:^|[^\\])(?:\\\\)*)) to only match active JSON unicode escapes. Double-escaped \\uE000 (literal text after JSON.parse) is correctly allowed. Regression test added.

  3. Shared mutable object/array fallbacks (template.ts) — cloneFallback() deep-copies via structuredClone when the cached value is an object or array, so mutations in one render cannot contaminate later renders. 2 tests added (object and array mutation isolation).

  4. listVariables() returns mutable metadata (template.ts) — extracted freezeVariables() helper shared by both Template constructor and listVariables(); return type narrowed to readonly TemplateVariable[]. Test added.

Also: eslint.config.mjs committed in a separate chore(eslint) commit (arrow-parens rule + .tmp ignore).

84 tests (23 scanner, 60 template, 1 VERSION), pnpm precommit clean.

@coderabbitai full review

@amery amery added the enhancement New feature or request label Mar 23, 2026
@coderabbitai

coderabbitai Bot commented Mar 23, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
cat src/scanner.ts
echo "==="
cat src/template.ts
echo "==="
cat src/tree.ts
echo "==="
grep -n "cloneFallback\|freezeVariables\|SENTINEL_ESC_RE\|structuredClone\|listVariables\|segments" src/template.ts | head -60

Length of output: 18021


🏁 Script executed:

#!/bin/bash
cat package.json
echo "==="
cat tsconfig.json 2>/dev/null || echo "not found"
echo "==="
# Check test files for the new tests mentioned
grep -n "structuredClone\|cloneFallback\|mutation\|isolat\|freezeVariables\|listVariables.*readonly\|lookbehind\|\\\\\\\\uE000" src/__tests__/template.test.ts | head -30
grep -n "structuredClone\|cloneFallback\|mutation\|isolat" src/__tests__/scanner.test.ts | head -10

Length of output: 2429


@amery All four round 5 changes look correct after reading the current code:

1. Dead segments.length === 0 removed (scanner.ts)
Confirmed gone. The empty-segment case is fully handled by SEGMENT_RE.test("") returning false inside the some() call. ✓

2. SENTINEL_ESC_RE lookbehind (template.ts:14)

/(?<=(?:^|[^\\])(?:\\\\)*)\\u[eE]000/

The variable-length lookbehind correctly tracks even-depth backslash runs:

  • \uE000 (real escape) → matched ✓
  • \\uE000 (literal backslash + text) → [^\\] fails, ^ not applicable → not matched ✓
  • \\\uE000 (odd-depth → real escape) → [^\\] + one \\ pair → matched ✓

The approach is sound. The engines.node >= 18 requirement in package.json means both structuredClone and ES2018 variable-length lookbehinds are guaranteed to be available. One minor note: .match() (no g flag) reports only the first sentinel escape's offset, but since the template is rejected on any match, this is correct.

3. cloneFallback() with structuredClone (template.ts:64-65)
Deep-copying object/array fallbacks at render time is the right call — primitives are immutable and pass through as-is, and the fallback isolation test suite (line 293) confirms the mutation guard works. ✓

4. freezeVariables() shared helper (template.ts:28-30)
Clean extraction — both the Template constructor (line 106) and listVariables() (line 283) now share the same freeze logic, and the return type of listVariables is correctly narrowed to readonly TemplateVariable[]. ✓

Triggering the full review now.

[full review]

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/tree.ts (1)

113-118: ⚠️ Potential issue | 🟡 Minor

Use real source offsets for key-position variable parse errors.

Line 117 always reports offset 0, so {"${key}": ...} failures point to the wrong location. Thread expression offsets into buildTree() and map the sentinel index to its original ${...} offset.

💡 Proposed fix
-export function buildTree(value: unknown): TNode {
+export function buildTree(
+  value: unknown,
+  exprOffsets: readonly number[],
+): TNode {
@@
-  if (Array.isArray(value)) {
-    return { kind: 'array', items: value.map((v) => buildTree(v)) };
+  if (Array.isArray(value)) {
+    return { kind: 'array', items: value.map((v) => buildTree(v, exprOffsets)) };
   }
@@
-        SENTINEL_RE.lastIndex = 0;
-        if (SENTINEL_RE.test(k)) {
+        SENTINEL_RE.lastIndex = 0;
+        const keyMatch = SENTINEL_RE.exec(k);
+        if (keyMatch) {
           throw new TemplateParseError(
             'Variable expressions in object keys are not supported',
-            0,
+            exprOffsets[Number.parseInt(keyMatch[1], 10)] ?? 0,
           );
         }
-        return [k, buildTree(v)];
+        return [k, buildTree(v, exprOffsets)];
       });

Also update the compile call site to pass offsets, e.g. buildTree(parsed, exprs.map(e => e.offset)).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tree.ts` around lines 113 - 118, The TemplateParseError thrown when
object keys contain variable expressions uses a hardcoded offset (0); update
buildTree() to accept an array of expression offsets (e.g., offsets from
compile’s parse step) and when you detect a sentinel via SENTINEL_RE map the
sentinel match index back to the corresponding expression offset and pass that
into new TemplateParseError instead of 0. Thread the offsets through the compile
call site (call buildTree(parsed, exprOffsets) or similar) so SENTINEL_RE test
can lookup the original ${...} offset by sentinel position and surface the
correct source location.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@eslint.config.mjs`:
- Line 4: Replace the generic TODO comment "// TODO: upstream arrow-parens to
`@poupe/eslint-config`" with a tracked reference: create an upstream issue or PR
and update the comment to include the issue/PR number or URL (for example "TODO:
upstream arrow-parens to `@poupe/eslint-config` — tracking: `#123`" or full PR link)
so the intent is actionable and won't drift; ensure the updated comment includes
the maintainer or repo name and the tracking ID so reviewers can follow up
(refer to the existing TODO comment text to locate where to edit).

In `@README.md`:
- Around line 206-209: Update the README to stop referencing a non-existent
JsonNull alias: either document the actual exported symbols (jsonNull and
isNull) instead of "JsonNull" or add and export a JsonNull type alias from the
library; specifically edit the paragraph that mentions JsonNull (around the
explanation of bare unresolved variables) to reference jsonNull: null and
isNull(...) or describe the newly exported JsonNull alias so consumers see
correct available API symbols (jsonNull, isNull, or JsonNull) and aren’t misled.

---

Duplicate comments:
In `@src/tree.ts`:
- Around line 113-118: The TemplateParseError thrown when object keys contain
variable expressions uses a hardcoded offset (0); update buildTree() to accept
an array of expression offsets (e.g., offsets from compile’s parse step) and
when you detect a sentinel via SENTINEL_RE map the sentinel match index back to
the corresponding expression offset and pass that into new TemplateParseError
instead of 0. Thread the offsets through the compile call site (call
buildTree(parsed, exprOffsets) or similar) so SENTINEL_RE test can lookup the
original ${...} offset by sentinel position and surface the correct source
location.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 574c6fa0-b404-4b66-a5d6-3d99a9c092be

📥 Commits

Reviewing files that changed from the base of the PR and between 40753b0 and d6e0e71.

📒 Files selected for processing (12)
  • AGENTS.md
  • README.md
  • eslint.config.mjs
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread eslint.config.mjs
import { defineConfig } from '@poupe/eslint-config';
import { defineConfig, GLOB_SRC } from '@poupe/eslint-config';

// TODO: upstream arrow-parens to @poupe/eslint-config

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

TODO should be tracked to avoid drift.

Line 4 has a TODO for upstreaming; consider linking an issue/PR ID so it stays actionable.

If you want, I can draft a short issue body for upstream tracking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@eslint.config.mjs` at line 4, Replace the generic TODO comment "// TODO:
upstream arrow-parens to `@poupe/eslint-config`" with a tracked reference: create
an upstream issue or PR and update the comment to include the issue/PR number or
URL (for example "TODO: upstream arrow-parens to `@poupe/eslint-config` —
tracking: `#123`" or full PR link) so the intent is actionable and won't drift;
ensure the updated comment includes the maintainer or repo name and the tracking
ID so reviewers can follow up (refer to the existing TODO comment text to locate
where to edit).

Comment thread README.md
@amery
amery force-pushed the feat/template-engine branch from d6e0e71 to 35826fe Compare March 23, 2026 21:41

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
src/tree.ts (1)

79-120: ⚠️ Potential issue | 🟡 Minor

Use the originating placeholder offset for key-position parse errors.

Line 117 still hard-codes 0, so ${...} inside an object key always points users at the start of the template instead of the offending placeholder. The sentinel index already identifies the right scanned expression, so this error can report a real source offset.

🩹 Suggested direction
-export function buildTree(value: unknown): TNode {
+export function buildTree(
+  value: unknown,
+  exprOffsets: readonly number[],
+): TNode {
   if (isNonStringPrimitive(value)) {
     return { kind: 'literal', value };
   }
@@
   if (Array.isArray(value)) {
-    return { kind: 'array', items: value.map((v) => buildTree(v)) };
+    return { kind: 'array', items: value.map((v) => buildTree(v, exprOffsets)) };
   }
@@
   if (typeof value === 'object') {
     const entries = Object.entries(value as Record<string, unknown>)
       .map(([k, v]): [string, TNode] => {
         SENTINEL_RE.lastIndex = 0;
-        if (SENTINEL_RE.test(k)) {
+        const match = SENTINEL_RE.exec(k);
+        if (match) {
           throw new TemplateParseError(
             'Variable expressions in object keys are not supported',
-            0,
+            exprOffsets[Number.parseInt(match[1], 10)] ?? 0,
           );
         }
-        return [k, buildTree(v)];
+        return [k, buildTree(v, exprOffsets)];
       });

The root call in src/template.ts can then pass exprs.map(expr => expr.offset).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/tree.ts` around lines 79 - 120, The object-key parse error currently
always reports offset 0; update the throw in buildTree (the TemplateParseError
raised when SENTINEL_RE.test(k) is true inside the Object.entries mapping) to
accept and propagate the actual placeholder offset instead of 0: change the call
site so buildTree can receive a placeholder-offset parameter (e.g., pass in the
matching sentinel's offset when scanning keys using SENTINEL_RE) and rethrow
TemplateParseError with that offset; ensure the root caller (in src/template.ts)
passes exprs.map(expr => expr.offset) or otherwise supplies the originating expr
offset into buildTree so the error reports the real source position.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/template.ts`:
- Line 107: The runtime mutability of the Set stored in this.names (created from
variables) should be prevented: after creating the Set via new
Set(variables.map(v => v.name)) call Object.freeze on it and keep the property
typed as ReadonlySet<string> so callers still see immutability at compile-time
and the runtime object is frozen; update the constructor/assignment that sets
this.names to freeze the Set instance (reference the this.names and variables
symbols in template.ts) so external code cannot mutate it at runtime.

---

Duplicate comments:
In `@src/tree.ts`:
- Around line 79-120: The object-key parse error currently always reports offset
0; update the throw in buildTree (the TemplateParseError raised when
SENTINEL_RE.test(k) is true inside the Object.entries mapping) to accept and
propagate the actual placeholder offset instead of 0: change the call site so
buildTree can receive a placeholder-offset parameter (e.g., pass in the matching
sentinel's offset when scanning keys using SENTINEL_RE) and rethrow
TemplateParseError with that offset; ensure the root caller (in src/template.ts)
passes exprs.map(expr => expr.offset) or otherwise supplies the originating expr
offset into buildTree so the error reports the real source position.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 685f7327-6121-4d30-a312-bda6729bac66

📥 Commits

Reviewing files that changed from the base of the PR and between 40753b0 and 35826fe.

📒 Files selected for processing (12)
  • AGENTS.md
  • README.md
  • eslint.config.mjs
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread src/template.ts Outdated
@amery
amery force-pushed the feat/template-engine branch from 35826fe to 63b1ff3 Compare March 23, 2026 22:12

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/tree.ts`:
- Line 94: The while condition uses a redundant null-coalescing on RegExp.exec —
change the loop to directly assign m from SENTINEL_RE.exec(value) (i.e., while
((m = SENTINEL_RE.exec(value)))) and remove the "?? undefined" so the null
return correctly breaks the loop; alternatively, if type checking complains,
widen the type of m to allow null instead of coalescing.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4fb01f8b-b077-4be4-aed8-d86a6df575eb

📥 Commits

Reviewing files that changed from the base of the PR and between 40753b0 and 63b1ff3.

📒 Files selected for processing (13)
  • AGENTS.md
  • README.md
  • eslint.config.mjs
  • package.json
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread src/tree.ts Outdated
amery added 2 commits March 23, 2026 22:43
Compile-once, render-many template engine that treats
JSON structure as a first-class concern. Variables in
bare positions preserve their native type; variables
inside strings concatenate as text.

- scan() with brace-depth-aware parsing and string
  context tracking for bare vs embedded classification
- buildTree() converts post-JSON.parse values into a
  template AST using sentinel-indexed markers
- Template class with render() and toJSON() methods
- Shell-style ${var:-default} with typed bare defaults
  (JSON-parsed at compile time) and string embedded
  defaults
- Bare object/array defaults deep-copied per render
  via structuredClone (no cross-render mutation)
- Dotted key paths with per-segment validation and
  pre-tokenized paths (split once at compile time);
  own-property-only resolution (no prototype chain)
- listVariables() for static analysis without valid
  JSON; returns frozen results matching Template API
- Strict mode (UnresolvedVariableError) and lenient
  mode (null for bare, "" for embedded)
- Embedded non-primitive coercion via JSON.stringify
- Sentinel collision guard rejects literal U+E000 and
  JSON-escaped \uE000 in input; double-escaped
  \\uE000 (literal text) is allowed
- Object.defineProperty for safe __proto__ key handling
  in rendered objects (no prototype pollution)
- TemplateVariable fields readonly + elements frozen
  at construction time
- AGENTS.md with pipeline overview, key invariants,
  and known limitations
- README.md with full API reference, own-property
  rule, jsonNull/isNull documentation
- 84 tests across scanner, template, and index suites

Signed-off-by: Alejandro Mery <amery@apptly.co>
publint expects the git+ protocol prefix.

Signed-off-by: Alejandro Mery <amery@apptly.co>
@amery
amery force-pushed the feat/template-engine branch from 63b1ff3 to b5c8ba0 Compare March 23, 2026 22:44

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/index.ts (1)

1-1: ⚠️ Potential issue | 🟠 Major

Potential Node 18 compatibility gap with JSON import attributes.

Line 1 uses with { type: 'json' }, which is not supported in Node 18.0–18.19.x despite the package declaring "engines": { "node": ">=18.0.0" }. This syntax is only supported from Node 18.20.0 onwards. Since the build output preserves ESNext module syntax without transpilation, consumers on Node 18.0–18.19.x will fail at module load time.

♻️ Safer Node-18-compatible alternative
-import pkg from '../package.json' with { type: 'json' };
+import { createRequire } from 'node:module';
+
+const require = createRequire(import.meta.url);
+const pkg = require('../package.json') as { version: string };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/index.ts` at line 1, The import statement "import pkg from
'../package.json' with { type: 'json' };" uses unsupported "with" JSON-import
syntax; replace it with a Node-18-compatible approach such as reading and
parsing the file via the fs module (e.g., use fs.readFileSync(new
URL('../package.json', import.meta.url), 'utf8') and JSON.parse(...)) or, if you
prefer import assertions, change to the valid ESM syntax "import pkg from
'../package.json' assert { type: 'json' };" so consumers on Node 18.0–18.19.x
won't fail at module load time—update the code where the original import line
appears.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/template.ts`:
- Line 254: The template currently recomputes path segments with
expr.name.split('.') in compile; instead, add a segments: string[] field to the
ScannedExpr type in scanner.ts and populate it when each ScannedExpr is created
(e.g., split the name once during scanning), then update template.ts to use
expr.segments (replace expr.name.split('.')) wherever segments are needed so the
split work isn’t duplicated.

---

Outside diff comments:
In `@src/index.ts`:
- Line 1: The import statement "import pkg from '../package.json' with { type:
'json' };" uses unsupported "with" JSON-import syntax; replace it with a
Node-18-compatible approach such as reading and parsing the file via the fs
module (e.g., use fs.readFileSync(new URL('../package.json', import.meta.url),
'utf8') and JSON.parse(...)) or, if you prefer import assertions, change to the
valid ESM syntax "import pkg from '../package.json' assert { type: 'json' };" so
consumers on Node 18.0–18.19.x won't fail at module load time—update the code
where the original import line appears.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 5604a6f3-29e1-482f-bf30-77232aadafbb

📥 Commits

Reviewing files that changed from the base of the PR and between 63b1ff3 and b5c8ba0.

📒 Files selected for processing (12)
  • AGENTS.md
  • README.md
  • package.json
  • src/__tests__/scanner.test.ts
  • src/__tests__/template.test.ts
  • src/errors.ts
  • src/index.ts
  • src/json.ts
  • src/scanner.ts
  • src/template.ts
  • src/tree.ts
  • src/types.ts

Comment thread src/template.ts
const exprs = scan(template);

const variables = exprs.map((expr) => toVariable(template, expr));
const segments = exprs.map((expr) => expr.name.split('.'));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

Minor: Duplicate path splitting between scanner and template.

The scanner already validates segments via name.split('.') (scanner.ts line 100), but compile splits again here. Consider storing pre-split segments in ScannedExpr to avoid redundant work.

♻️ Optional: Store segments in ScannedExpr

In src/scanner.ts, add segments: string[] to ScannedExpr and populate it during scanning:

 export interface ScannedExpr {
   offset: number
   length: number
   name: string
+  segments: string[]
   defaultValue?: string
   inString: boolean
 }

Then in src/template.ts:

-  const segments = exprs.map((expr) => expr.name.split('.'));
+  const segments = exprs.map((expr) => expr.segments);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/template.ts` at line 254, The template currently recomputes path segments
with expr.name.split('.') in compile; instead, add a segments: string[] field to
the ScannedExpr type in scanner.ts and populate it when each ScannedExpr is
created (e.g., split the name once during scanning), then update template.ts to
use expr.segments (replace expr.name.split('.')) wherever segments are needed so
the split work isn’t duplicated.

@amery
amery merged commit 3e69506 into main Mar 23, 2026
6 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Mar 24, 2026
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant