|
| 1 | +# AGENTS.md |
| 2 | + |
| 3 | +This file provides guidance to AI coding assistants |
| 4 | +(Claude Code, GitHub Copilot, Cody, etc.) when working |
| 5 | +with the json-template repository. |
| 6 | + |
| 7 | +## Project Overview |
| 8 | + |
| 9 | +`@kagal/json-template` is a TypeScript template engine |
| 10 | +for JSON documents with shell-style `${var:-default}` |
| 11 | +variable substitution. It compiles a JSON template |
| 12 | +string once, then renders it to native JavaScript |
| 13 | +objects by resolving variables against a context. |
| 14 | + |
| 15 | +## Structure |
| 16 | + |
| 17 | +```text |
| 18 | +src/ |
| 19 | +├── types.ts — TemplateVariable, CompileOptions |
| 20 | +├── errors.ts — TemplateParseError, UnresolvedVariableError |
| 21 | +├── json.ts — jsonNull, isNull, isNonStringPrimitive, isObject |
| 22 | +├── scanner.ts — scan(), ScannedExpr |
| 23 | +├── tree.ts — buildTree(), TNode, IPart, SENTINEL |
| 24 | +├── template.ts — Template class, compile(), listVariables() |
| 25 | +├── index.ts — barrel re-exports |
| 26 | +└── __tests__/ |
| 27 | + ├── index.test.ts — VERSION test |
| 28 | + ├── scanner.test.ts — scanner and parse error tests |
| 29 | + └── template.test.ts — Template rendering tests |
| 30 | +``` |
| 31 | + |
| 32 | +## Common Commands |
| 33 | + |
| 34 | +```bash |
| 35 | +pnpm build # Build with unbuild |
| 36 | +pnpm test # Run vitest |
| 37 | +pnpm lint # ESLint with auto-fix |
| 38 | +pnpm typecheck # tsc --noEmit |
| 39 | +pnpm precommit # build, lint, typecheck, test |
| 40 | +``` |
| 41 | + |
| 42 | +## Code Style Guidelines |
| 43 | + |
| 44 | +Enforced by .editorconfig and @poupe/eslint-config: |
| 45 | + |
| 46 | +- **Indentation**: 2 spaces |
| 47 | +- **Line Endings**: Unix (LF) |
| 48 | +- **Charset**: UTF-8 |
| 49 | +- **Quotes**: Single quotes |
| 50 | +- **Semicolons**: Always |
| 51 | +- **Module System**: ES modules (`type: "module"`) |
| 52 | +- **Line Length**: Max 78 characters preferred |
| 53 | +- **Comments**: Use TSDoc format for documentation |
| 54 | +- **Naming**: camelCase for variables/functions, |
| 55 | + PascalCase for types/interfaces/classes |
| 56 | +- **Final Newline**: Always insert |
| 57 | +- **Trailing Whitespace**: Always trim |
| 58 | + |
| 59 | +### JSON null handling |
| 60 | + |
| 61 | +The `unicorn/no-null` rule is enforced project-wide. |
| 62 | +Use `jsonNull` and `isNull()` from `json.ts` where |
| 63 | +JSON null semantics are needed. A single |
| 64 | +`eslint-disable` exists at the `jsonNull` declaration. |
| 65 | +Use `undefined` where semantically viable. |
| 66 | + |
| 67 | +## Development Practices |
| 68 | + |
| 69 | +### Pre-commit (MANDATORY) |
| 70 | + |
| 71 | +Before committing any changes, ALWAYS run: |
| 72 | + |
| 73 | +1. `pnpm precommit` (if any source changed) |
| 74 | +2. Fix any issues found |
| 75 | + |
| 76 | +### DO |
| 77 | + |
| 78 | +- Write tests for all new functionality |
| 79 | +- Check existing code patterns before creating new ones |
| 80 | +- Follow strict TypeScript practices |
| 81 | +- Use `git -C <subpath>` instead of `cd` for git on |
| 82 | + subpaths, but not `-C .` at repo root |
| 83 | + |
| 84 | +### DON'T |
| 85 | + |
| 86 | +- Create files unless necessary — prefer editing |
| 87 | + existing ones |
| 88 | +- Add external dependencies without careful |
| 89 | + consideration |
| 90 | +- Ignore TypeScript errors or ESLint warnings |
| 91 | +- **NEVER use `git add .` or `git add -A`** |
| 92 | +- **NEVER commit without explicitly listing files** |
| 93 | +- **NEVER rely on the staging area — always list files |
| 94 | + explicitly** |
| 95 | +- **NEVER use `cd`** — it loses working directory |
| 96 | + context for all subsequent tool calls |
| 97 | + |
| 98 | +## Git Workflow |
| 99 | + |
| 100 | +### Commits |
| 101 | + |
| 102 | +- Always use `-s` flag for sign-off |
| 103 | +- Write clear messages describing actual changes |
| 104 | +- No AI advertising in commit messages |
| 105 | +- Focus commit messages on the final result, not the |
| 106 | + iterations |
| 107 | + |
| 108 | +### Direct Commits (MANDATORY) |
| 109 | + |
| 110 | +ALWAYS list files explicitly in the commit command. |
| 111 | +Use `git add` only for new/untracked files, then pass |
| 112 | +all files (new and modified) to `git commit`. |
| 113 | + |
| 114 | +```bash |
| 115 | +# Stage new files, then commit with explicit file list |
| 116 | +git add src/new-file.ts |
| 117 | +git commit -sF .tmp/commit-<slug>.txt -- src/new-file.ts src/changed.ts |
| 118 | +``` |
| 119 | + |
| 120 | +Temporary message files use a shared prefix with a |
| 121 | +meaningful slug: |
| 122 | + |
| 123 | +- Commit messages: `.tmp/commit-<slug>.txt` |
| 124 | +- PR descriptions: `.tmp/pr-<slug>.md` |
| 125 | + |
| 126 | +### Commit Message Guidelines |
| 127 | + |
| 128 | +- First line: type(scope): brief description (50 chars) |
| 129 | +- Blank line |
| 130 | +- Body: what and why, not how (wrap at 72 chars) |
| 131 | +- Use bullet points for multiple changes |
| 132 | +- Reference issues/PRs when relevant |
| 133 | + |
| 134 | +## Build and Test |
| 135 | + |
| 136 | +- **Build**: unbuild (ESM + DTS, sourcemaps) |
| 137 | +- **Test**: Vitest with v8 coverage (90/90/85 |
| 138 | + thresholds) |
| 139 | +- **Lint**: @poupe/eslint-config via `defineConfig()` |
| 140 | +- **Prepare**: `cross-test -s dist/index.mjs || unbuild --stub` |
| 141 | + |
| 142 | +## Publishing |
| 143 | + |
| 144 | +Published via GitHub Actions using npm's OIDC trusted |
| 145 | +publishing with `--provenance`. No tokens stored as |
| 146 | +secrets. |
| 147 | + |
| 148 | +## Architecture |
| 149 | + |
| 150 | +### Pipeline overview |
| 151 | + |
| 152 | +Compilation is three phases. Rendering is a single |
| 153 | +tree walk. |
| 154 | + |
| 155 | +```text |
| 156 | +template string |
| 157 | + │ |
| 158 | + ▼ |
| 159 | + scan() → ScannedExpr[] |
| 160 | + (phase 1: find expressions, |
| 161 | + track string context) |
| 162 | + │ |
| 163 | + ▼ |
| 164 | + sentinel replace → modified JSON str |
| 165 | + + JSON.parse() → unknown |
| 166 | + (phase 2: substitute expressions |
| 167 | + with markers, parse once) |
| 168 | + │ |
| 169 | + ▼ |
| 170 | + buildTree() → TNode |
| 171 | + (phase 3: convert parsed JSON |
| 172 | + into template AST) |
| 173 | +
|
| 174 | + ─── compile time ends here ─── |
| 175 | +
|
| 176 | + Template.render() → unknown |
| 177 | + (per-call: walk tree, resolve |
| 178 | + vars, assemble object) |
| 179 | +``` |
| 180 | + |
| 181 | +### Key invariants |
| 182 | + |
| 183 | +These are the things most likely to break during |
| 184 | +modification: |
| 185 | + |
| 186 | +1. **Scanner string tracking determines everything |
| 187 | + downstream.** The `inString` flag when `${` is |
| 188 | + encountered determines whether the sentinel |
| 189 | + gets `B` (bare) or `E` (embedded) prefix. The |
| 190 | + scanner's backslash handling (`pos += 2` to skip |
| 191 | + `\"`) must exactly mirror JSON's escape rules. |
| 192 | + |
| 193 | +2. **Expression index = sentinel index.** The |
| 194 | + `for (const [i, expr] of exprs.entries())` loop in |
| 195 | + `compile()` writes sentinels using `i` as the |
| 196 | + index. If expressions were ever reordered or |
| 197 | + filtered between `scan()` and sentinel replacement, |
| 198 | + every `TNode.idx` in the tree would point to the |
| 199 | + wrong `ScannedExpr`. |
| 200 | + |
| 201 | +3. **`buildTree()` runs once at compile time; |
| 202 | + `Template.renderNode()` runs per-call.** Structural |
| 203 | + classification and default parsing belong at compile |
| 204 | + time. Variable resolution belongs in |
| 205 | + `Template.renderNode`. Moving compile-time work |
| 206 | + into render or vice versa is a correctness risk. |
| 207 | + |
| 208 | +4. **Object keys are checked for sentinels in |
| 209 | + `buildTree`, not the scanner.** The scanner doesn't |
| 210 | + know about JSON structure. The key-rejection check |
| 211 | + happens after `JSON.parse`. If you add key support, |
| 212 | + you'd need a new `TNode` kind for interpolated keys |
| 213 | + and corresponding `Template.renderNode` logic. |
| 214 | + |
| 215 | +5. **`SENTINEL_RE` is a module-level regex in |
| 216 | + `tree.ts` with the `g` flag.** Its `lastIndex` is |
| 217 | + reset before each use in `buildTree`. If you add |
| 218 | + another call site, you must also reset `lastIndex`. |
| 219 | + |
| 220 | +6. **`compile()` rejects the sentinel character |
| 221 | + (U+E000) in input.** The PUA character is used as |
| 222 | + an in-band marker between `scan()` and |
| 223 | + `JSON.parse`. If it appeared in user input — |
| 224 | + either as a literal character or as a JSON-encoded |
| 225 | + `\uE000` escape — it would collide with the |
| 226 | + markers and cause miscompilation. Both forms are |
| 227 | + checked before `scan()`. |
| 228 | + |
| 229 | +7. **Embedded non-primitives use `JSON.stringify`, not |
| 230 | + `String()`.** `String({})` produces |
| 231 | + `[object Object]`. The current code checks |
| 232 | + `isObject(value)` before choosing the serialisation |
| 233 | + path. If you change the coercion logic, test with |
| 234 | + objects and arrays in embedded positions. |
| 235 | + |
| 236 | +## Known Limitations |
| 237 | + |
| 238 | +### Worth fixing if the use case arises |
| 239 | + |
| 240 | +**No variable interpolation in object keys.** |
| 241 | +Expressions in JSON keys are detected and rejected at |
| 242 | +compile time. Supporting this would require a new |
| 243 | +`TNode` variant for interpolated keys and changes to |
| 244 | +`Template.renderNode`'s object branch. |
| 245 | + |
| 246 | +**No escape mechanism for literal `${`.** There's no |
| 247 | +way to include `${` verbatim. Since `${` has no meaning |
| 248 | +in standard JSON, this rarely matters. Workaround: |
| 249 | +use a variable with a default, e.g. |
| 250 | +`"${dollar:-$}{rest"`. |
| 251 | + |
| 252 | +### By design |
| 253 | + |
| 254 | +**Bare defaults that fail `JSON.parse` silently become |
| 255 | +strings.** `${name:-hello}` defaults to `"hello"` |
| 256 | +because `JSON.parse("hello")` throws and the engine |
| 257 | +falls back to the raw text. This allows simple unquoted |
| 258 | +string defaults without forcing `${name:-"hello"}`. |
| 259 | +The trade-off: a typo like `${cfg:-{broken}` produces |
| 260 | +a string instead of an error. |
| 261 | + |
| 262 | +**`resolve()` only follows own properties.** Inherited |
| 263 | +keys like `toString` or `__proto__` are treated as |
| 264 | +missing, not resolved from the prototype chain. This |
| 265 | +prevents leaking prototype methods/objects through |
| 266 | +templates. It also means `resolve()` cannot distinguish |
| 267 | +a missing key from explicit `undefined` — `{ v: |
| 268 | +undefined }` is treated the same as `{}`. Both choices |
| 269 | +match POSIX shell `:-` semantics and are consistent |
| 270 | +with JSON (where `undefined` is not a valid value). |
| 271 | + |
| 272 | +### Low priority |
| 273 | + |
| 274 | +**No streaming or partial rendering.** The engine |
| 275 | +builds the complete object tree synchronously. Not a |
| 276 | +problem for config-sized templates. |
| 277 | + |
| 278 | +## Claude Code Specific Instructions |
| 279 | + |
| 280 | +- **CRITICAL: Always enumerate files explicitly in git |
| 281 | + commit commands** |
| 282 | +- **NEVER use bare `git commit` without file |
| 283 | + arguments** |
| 284 | +- **Check `git status --porcelain` before every |
| 285 | + commit** |
| 286 | +- NEVER apologise or explain why you did something |
| 287 | + wrong |
| 288 | +- Fix issues immediately without commentary |
| 289 | +- Stay focused on the task at hand |
0 commit comments