@massu/core 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/commands/README.md +23 -11
  2. package/commands/massu-deploy.python-docker.md +170 -0
  3. package/commands/massu-deploy.python-fly.md +189 -0
  4. package/commands/massu-deploy.python-launchd.md +144 -0
  5. package/commands/massu-deploy.python-systemd.md +163 -0
  6. package/commands/massu-scaffold-page.swift.md +10 -10
  7. package/commands/massu-scaffold-router.python-django.md +153 -0
  8. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  9. package/dist/cli.js +9914 -4133
  10. package/dist/hooks/auto-learning-pipeline.js +45 -2
  11. package/dist/hooks/classify-failure.js +45 -2
  12. package/dist/hooks/cost-tracker.js +45 -2
  13. package/dist/hooks/fix-detector.js +45 -2
  14. package/dist/hooks/incident-pipeline.js +45 -2
  15. package/dist/hooks/post-edit-context.js +45 -2
  16. package/dist/hooks/post-tool-use.js +45 -2
  17. package/dist/hooks/pre-compact.js +45 -2
  18. package/dist/hooks/pre-delete-check.js +45 -2
  19. package/dist/hooks/quality-event.js +45 -2
  20. package/dist/hooks/rule-enforcement-pipeline.js +45 -2
  21. package/dist/hooks/session-end.js +45 -2
  22. package/dist/hooks/session-start.js +4790 -406
  23. package/dist/hooks/user-prompt.js +45 -2
  24. package/package.json +13 -4
  25. package/src/cli.ts +22 -2
  26. package/src/commands/config-refresh.ts +91 -23
  27. package/src/commands/init.ts +131 -24
  28. package/src/commands/install-commands.ts +142 -26
  29. package/src/commands/refresh-log.ts +37 -0
  30. package/src/commands/template-engine.ts +260 -0
  31. package/src/commands/watch.ts +430 -0
  32. package/src/config.ts +71 -0
  33. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  34. package/src/detect/adapters/parse-guard.ts +133 -0
  35. package/src/detect/adapters/python-django.ts +208 -0
  36. package/src/detect/adapters/python-fastapi.ts +223 -0
  37. package/src/detect/adapters/query-helpers.ts +170 -0
  38. package/src/detect/adapters/runner.ts +252 -0
  39. package/src/detect/adapters/swift-swiftui.ts +171 -0
  40. package/src/detect/adapters/tree-sitter-loader.ts +467 -0
  41. package/src/detect/adapters/types.ts +173 -0
  42. package/src/detect/codebase-introspector.ts +190 -0
  43. package/src/detect/index.ts +28 -2
  44. package/src/detect/migrate.ts +4 -4
  45. package/src/detect/regex-fallback.ts +449 -0
  46. package/src/hooks/session-start.ts +94 -3
  47. package/src/lib/gitToplevel.ts +22 -0
  48. package/src/lib/installLock.ts +179 -0
  49. package/src/lib/pidLiveness.ts +67 -0
  50. package/src/lsp/auto-detect.ts +98 -0
  51. package/src/lsp/client.ts +776 -0
  52. package/src/lsp/enrich.ts +127 -0
  53. package/src/lsp/types.ts +221 -0
  54. package/src/watch/daemon.ts +385 -0
  55. package/src/watch/lockfile-detector.ts +65 -0
  56. package/src/watch/paths.ts +279 -0
  57. package/src/watch/state.ts +178 -0
@@ -20,9 +20,13 @@
20
20
  */
21
21
 
22
22
  import {
23
+ closeSync,
23
24
  existsSync,
25
+ fsyncSync,
26
+ openSync,
24
27
  readFileSync,
25
- writeFileSync,
28
+ rmSync,
29
+ writeSync,
26
30
  mkdirSync,
27
31
  readdirSync,
28
32
  statSync,
@@ -33,6 +37,7 @@ import { fileURLToPath } from 'url';
33
37
  import { createHash } from 'crypto';
34
38
  import { getConfig } from '../config.ts';
35
39
  import type { Config } from '../config.ts';
40
+ import { renderTemplate, MissingVariableError, TemplateParseError } from './template-engine.ts';
36
41
 
37
42
  const __filename = fileURLToPath(import.meta.url);
38
43
  const __dirname = dirname(__filename);
@@ -126,17 +131,55 @@ export function loadManifest(claudeDir: string): Manifest {
126
131
  }
127
132
  }
128
133
 
129
- /** Write the manifest atomically: tempfile + renameSync. */
134
+ /**
135
+ * Iter-7 fix: atomic file write — tmp + fsync + rename.
136
+ *
137
+ * Plan 3a §3 Risk #4 ("Watcher writes to .claude/ while editor has it open:
138
+ * editors can lose changes. Mitigation: write to .massu-tmp then atomic rename")
139
+ * AND the watcher spec doc §3 Shutdown Semantics claim ("every file op the
140
+ * refresh issues is already atomic-rename-safe... installAll writes <path>.tmp
141
+ * then renameSync") both demand this. Previously installAll's per-file writes
142
+ * (lines 463/467) and saveManifest were direct `writeFileSync` calls — a
143
+ * SIGINT/power-loss between truncate and complete-write left a partial file.
144
+ * Now both go through this helper so the watcher's iter-6 "we don't await
145
+ * fireRefresh because atomic-rename covers everything" decision is sound.
146
+ *
147
+ * Writes via openSync + writeSync + fsyncSync + closeSync + renameSync so the
148
+ * data hits the platter before the rename. On any error, removes the tmp file.
149
+ * Tmp filename includes process.pid to avoid clashes with concurrent installs
150
+ * from sibling processes (e.g. a manual `npx massu config refresh` racing the
151
+ * watcher daemon — the install-lock should prevent this, but the file-level
152
+ * tmp name disambiguates if it ever happens).
153
+ */
154
+ function atomicWriteFile(targetPath: string, content: string, mode = 0o644): void {
155
+ const tmpPath = `${targetPath}.${process.pid}.tmp`;
156
+ try {
157
+ const fd = openSync(tmpPath, 'w', mode);
158
+ try {
159
+ const buf = Buffer.from(content, 'utf-8');
160
+ writeSync(fd, buf, 0, buf.length, 0);
161
+ fsyncSync(fd);
162
+ } finally {
163
+ closeSync(fd);
164
+ }
165
+ renameSync(tmpPath, targetPath);
166
+ } catch (err) {
167
+ if (existsSync(tmpPath)) {
168
+ try { rmSync(tmpPath, { force: true }); } catch { /* ignore */ }
169
+ }
170
+ throw err;
171
+ }
172
+ }
173
+
174
+ /** Write the manifest atomically: tempfile + fsync + renameSync. */
130
175
  export function saveManifest(claudeDir: string, manifest: Manifest): void {
131
176
  const dir = resolve(claudeDir, '.massu');
132
177
  if (!existsSync(dir)) {
133
178
  mkdirSync(dir, { recursive: true });
134
179
  }
135
180
  const finalPath = resolve(dir, 'install-manifest.json');
136
- const tempPath = finalPath + '.tmp';
137
181
  manifest.generatedAt = new Date().toISOString();
138
- writeFileSync(tempPath, JSON.stringify(manifest, null, 2), 'utf-8');
139
- renameSync(tempPath, finalPath);
182
+ atomicWriteFile(finalPath, JSON.stringify(manifest, null, 2));
140
183
  }
141
184
 
142
185
  function emptyManifest(): Manifest {
@@ -183,12 +226,22 @@ const PASSTHROUGH_LANG_KEYS = [
183
226
  /**
184
227
  * Choose the variant suffix for a base template name.
185
228
  *
186
- * Priority order (per plan §"Variant resolution algorithm"):
187
- * 1. `framework.primary` (or `framework.type` if primary undefined)
229
+ * Two-axis priority (Plan #2 P2-001 extends Plan #1's lang-only axis):
230
+ * For each language `L` in priority order (primary, languages.*, passthrough.*):
231
+ * a. If a sub-framework `F` is declared for L, probe `<base>.<L>-<F>.md`.
232
+ * b. Probe `<base>.<L>.md` (lang-only fallback).
233
+ * Then probe the unsuffixed `<base>.md`.
234
+ *
235
+ * Priority order for the language list (Plan #1):
236
+ * 1. `framework.primary` (or `framework.type` if primary undefined). With the
237
+ * sub-framework axis, the candidate framework is the matching
238
+ * `framework.languages[primary].framework` if present, else `framework.router`
239
+ * / `framework.orm` / `framework.ui` heuristics, else just lang-only.
188
240
  * 2. Each declared `framework.languages.<lang>` entry with a non-empty `framework`,
189
- * in YAML declaration order.
241
+ * in YAML declaration order. Sub-framework = `entry.framework`.
190
242
  * 3. Passthrough fallback: well-known top-level `framework.<lang>` blocks with a
191
243
  * non-empty `framework` field, in fixed order, excluding entries already covered.
244
+ * Sub-framework = top-level block's `framework` field.
192
245
  * 4. The unsuffixed default ("").
193
246
  *
194
247
  * The function NEVER throws. It returns a discriminated union so the caller can
@@ -199,12 +252,37 @@ export function pickVariant(
199
252
  sourceDir: string,
200
253
  framework: Config['framework'],
201
254
  ): PickVariantResult {
202
- const candidates: string[] = [];
255
+ // Build (lang, subFramework) candidate pairs in priority order. Sub-framework
256
+ // can be undefined — in that case only the lang-only axis is probed for that
257
+ // language.
258
+ type Candidate = { lang: string; subFramework?: string };
259
+ const candidates: Candidate[] = [];
260
+ const seenLangs = new Set<string>();
261
+
262
+ function pushCandidate(lang: string, sub: string | undefined): void {
263
+ if (seenLangs.has(lang)) return;
264
+ seenLangs.add(lang);
265
+ candidates.push({ lang, subFramework: sub && sub.length > 0 ? sub : undefined });
266
+ }
203
267
 
204
268
  // 1. framework.primary (or fall back to framework.type)
205
269
  const primary = framework.primary ?? framework.type;
206
270
  if (primary && primary !== 'multi') {
207
- candidates.push(primary);
271
+ // Best-effort sub-framework detection for the primary lang:
272
+ // - If `framework.languages[primary]` has a `framework`, use that.
273
+ // - Else, fall back to top-level passthrough `framework[primary].framework`.
274
+ let primarySub: string | undefined;
275
+ if (framework.languages && framework.languages[primary]?.framework) {
276
+ primarySub = framework.languages[primary].framework;
277
+ } else {
278
+ const passthrough = framework as unknown as Record<string, unknown>;
279
+ const block = passthrough[primary];
280
+ if (block && typeof block === 'object') {
281
+ const fw = (block as { framework?: unknown }).framework;
282
+ if (typeof fw === 'string' && fw.length > 0) primarySub = fw;
283
+ }
284
+ }
285
+ pushCandidate(primary, primarySub);
208
286
  }
209
287
 
210
288
  // 2. framework.languages declaration order
@@ -212,34 +290,35 @@ export function pickVariant(
212
290
  for (const lang of Object.keys(framework.languages)) {
213
291
  const entry = framework.languages[lang];
214
292
  if (entry && typeof entry.framework === 'string' && entry.framework.length > 0) {
215
- if (!candidates.includes(lang)) {
216
- candidates.push(lang);
217
- }
293
+ pushCandidate(lang, entry.framework);
218
294
  }
219
295
  }
220
296
  }
221
297
 
222
298
  // 3. Passthrough fallback — `framework.<lang>` (top-level passthrough block).
223
- // Zod's `.passthrough()` preserves these at runtime even though they are not
224
- // typed members of `Config['framework']`. Reading via a Record-shaped widening
225
- // avoids `any`/`@ts-ignore`.
226
299
  const passthrough = framework as unknown as Record<string, unknown>;
227
300
  for (const lang of PASSTHROUGH_LANG_KEYS) {
228
- if (candidates.includes(lang)) continue;
301
+ if (seenLangs.has(lang)) continue;
229
302
  const block = passthrough[lang];
230
303
  if (block && typeof block === 'object') {
231
304
  const fw = (block as { framework?: unknown }).framework;
232
305
  if (typeof fw === 'string' && fw.length > 0) {
233
- candidates.push(lang);
306
+ pushCandidate(lang, fw);
234
307
  }
235
308
  }
236
309
  }
237
310
 
238
- // 4. Probe disk
311
+ // 4. Probe disk — for each (lang, sub) pair, try lang-sub first, then lang-only.
239
312
  for (const cand of candidates) {
240
- const path = resolve(sourceDir, `${baseName}.${cand}.md`);
241
- if (existsSync(path)) {
242
- return { kind: 'hit', suffix: `.${cand}` };
313
+ if (cand.subFramework) {
314
+ const subPath = resolve(sourceDir, `${baseName}.${cand.lang}-${cand.subFramework}.md`);
315
+ if (existsSync(subPath)) {
316
+ return { kind: 'hit', suffix: `.${cand.lang}-${cand.subFramework}` };
317
+ }
318
+ }
319
+ const langPath = resolve(sourceDir, `${baseName}.${cand.lang}.md`);
320
+ if (existsSync(langPath)) {
321
+ return { kind: 'hit', suffix: `.${cand.lang}` };
243
322
  }
244
323
  }
245
324
  // Unsuffixed default
@@ -296,6 +375,7 @@ export function syncDirectory(
296
375
  manifest: Manifest,
297
376
  manifestKeyPrefix: string,
298
377
  topLevel: boolean = true,
378
+ templateVars: Record<string, unknown> = {},
299
379
  ): SyncStats {
300
380
  const stats: SyncStats = { installed: 0, updated: 0, skipped: 0, kept: 0 };
301
381
 
@@ -322,6 +402,7 @@ export function syncDirectory(
322
402
  manifest,
323
403
  subPrefix,
324
404
  false,
405
+ templateVars,
325
406
  );
326
407
  stats.installed += subStats.installed;
327
408
  stats.updated += subStats.updated;
@@ -360,7 +441,25 @@ export function syncDirectory(
360
441
  // Target filename is always the BASE name (variant suffix is internal to the package).
361
442
  const targetFilename = topLevel ? `${baseName}.md` : entry;
362
443
  const targetPath = resolve(targetDir, targetFilename);
363
- const sourceContent = readFileSync(resolvedSourcePath, 'utf-8');
444
+ const rawContent = readFileSync(resolvedSourcePath, 'utf-8');
445
+
446
+ // Plan #2 P1-003: render any `{{var}}` substitutions BEFORE hashing so
447
+ // the manifest entry hash matches the byte-stream that lands on disk.
448
+ // Engine errors (missing var, malformed token) fail this single file but
449
+ // never abort the whole install — see spec §"Error semantics".
450
+ let sourceContent: string;
451
+ try {
452
+ sourceContent = renderTemplate(rawContent, templateVars);
453
+ } catch (err) {
454
+ if (err instanceof MissingVariableError || err instanceof TemplateParseError) {
455
+ process.stderr.write(
456
+ `massu: skipping ${resolvedSourcePath}: ${err.message}\n`,
457
+ );
458
+ stats.skipped++;
459
+ continue;
460
+ }
461
+ throw err;
462
+ }
364
463
  const sourceHash = hashContent(sourceContent);
365
464
 
366
465
  const manifestKey = manifestKeyPrefix === ''
@@ -403,11 +502,11 @@ export function syncDirectory(
403
502
  }
404
503
 
405
504
  // existingHash === lastInstalledHash and sourceHash differs → safe upgrade.
406
- writeFileSync(targetPath, sourceContent, 'utf-8');
505
+ atomicWriteFile(targetPath, sourceContent);
407
506
  manifest.entries[manifestKey] = sourceHash;
408
507
  stats.updated++;
409
508
  } else {
410
- writeFileSync(targetPath, sourceContent, 'utf-8');
509
+ atomicWriteFile(targetPath, sourceContent);
411
510
  manifest.entries[manifestKey] = sourceHash;
412
511
  stats.installed++;
413
512
  }
@@ -428,6 +527,20 @@ export interface InstallCommandsResult {
428
527
  commandsDir: string;
429
528
  }
430
529
 
530
+ /**
531
+ * Build the variable scope passed to the templating engine.
532
+ * See spec §"Variable scope passed to the engine" for the contract.
533
+ */
534
+ export function buildTemplateVars(): Record<string, unknown> {
535
+ const config = getConfig();
536
+ return {
537
+ framework: config.framework,
538
+ paths: config.paths,
539
+ detected: config.detected ?? {},
540
+ config,
541
+ };
542
+ }
543
+
431
544
  export function installCommands(projectRoot: string): InstallCommandsResult {
432
545
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
433
546
  const claudeDir = resolve(projectRoot, claudeDirName);
@@ -445,8 +558,9 @@ export function installCommands(projectRoot: string): InstallCommandsResult {
445
558
  }
446
559
 
447
560
  const framework = getConfig().framework;
561
+ const templateVars = buildTemplateVars();
448
562
  const stats = runWithManifest(claudeDir, (manifest) =>
449
- syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true),
563
+ syncDirectory(sourceDir, targetDir, framework, manifest, 'commands', true, templateVars),
450
564
  );
451
565
  return { ...stats, commandsDir: targetDir };
452
566
  }
@@ -475,6 +589,7 @@ export function installAll(projectRoot: string): InstallAllResult {
475
589
  let totalKept = 0;
476
590
 
477
591
  const framework = getConfig().framework;
592
+ const templateVars = buildTemplateVars();
478
593
 
479
594
  runWithManifest(claudeDir, (manifest) => {
480
595
  for (const assetType of ASSET_TYPES) {
@@ -489,6 +604,7 @@ export function installAll(projectRoot: string): InstallAllResult {
489
604
  manifest,
490
605
  assetType.targetSubdir,
491
606
  true,
607
+ templateVars,
492
608
  );
493
609
 
494
610
  assets[assetType.name] = stats;
@@ -0,0 +1,37 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu refresh-log [N]` — show the last N watcher auto-refresh events.
6
+ *
7
+ * Library discipline: returns a result object; cli.ts owns process.exit.
8
+ */
9
+
10
+ import { gitToplevel } from '../lib/gitToplevel.ts';
11
+ import { readRefreshLog } from './watch.ts';
12
+
13
+ export interface RefreshLogResult {
14
+ exitCode: 0 | 1;
15
+ message?: string;
16
+ }
17
+
18
+ export async function runRefreshLog(args: string[]): Promise<RefreshLogResult> {
19
+ const limitArg = args.find((a) => /^\d+$/.test(a));
20
+ const limit = limitArg ? Math.max(1, Math.min(1000, Number(limitArg))) : 10;
21
+ const root = gitToplevel(process.cwd());
22
+
23
+ const events = readRefreshLog(root, limit);
24
+ if (events.length === 0) {
25
+ process.stdout.write('massu refresh-log: no auto-refresh events recorded yet.\n');
26
+ return { exitCode: 0 };
27
+ }
28
+
29
+ for (const e of events) {
30
+ const from = e.fromFingerprint ? e.fromFingerprint.slice(0, 12) : 'init';
31
+ const to = e.toFingerprint.slice(0, 12);
32
+ process.stdout.write(
33
+ `${e.at} ${from} -> ${to} installed=${e.filesInstalled} updated=${e.filesUpdated} kept=${e.filesKept}\n`,
34
+ );
35
+ }
36
+ return { exitCode: 0 };
37
+ }
@@ -0,0 +1,260 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Massu codebase-aware templating engine — string substitution only.
6
+ *
7
+ * Grammar (the entire surface):
8
+ * {{path.to.var}} Look up + render
9
+ * {{path.to.var | default("fallback")}} Look up; use literal on miss
10
+ * \{{ Literal `{{` (escape)
11
+ *
12
+ * Hard rules (do not weaken in maintenance):
13
+ * - NO `eval`, `Function`, `new Function`, `vm`, `child_process`, `exec`, `spawn`.
14
+ * - Variable lookup uses `Object.hasOwn` ONLY — no prototype walk.
15
+ * - Output is NEVER re-rendered (a value containing `{{x}}` stays literal).
16
+ * - No HTML escaping; output is markdown.
17
+ * - Single linear pass; no recursion, no fixed-point loop.
18
+ */
19
+
20
+ /** Thrown when a variable is missing and no `| default("...")` was given. */
21
+ export class MissingVariableError extends Error {
22
+ readonly path: string;
23
+ constructor(path: string) {
24
+ super(`Template variable not found: "${path}"`);
25
+ this.name = 'MissingVariableError';
26
+ this.path = path;
27
+ }
28
+ }
29
+
30
+ /** Thrown on an unbalanced or malformed template token. */
31
+ export class TemplateParseError extends Error {
32
+ readonly position: number;
33
+ constructor(message: string, position: number) {
34
+ super(`Template parse error at position ${position}: ${message}`);
35
+ this.name = 'TemplateParseError';
36
+ this.position = position;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Render `template` against the given variables object.
42
+ *
43
+ * Throws:
44
+ * - `MissingVariableError` if a token references a path that doesn't exist
45
+ * and no `default("...")` is provided.
46
+ * - `TemplateParseError` on unbalanced `{{` (no closing `}}`) or malformed
47
+ * `default(...)` syntax.
48
+ */
49
+ export function renderTemplate(template: string, vars: Record<string, unknown>): string {
50
+ const out: string[] = [];
51
+ const len = template.length;
52
+ let i = 0;
53
+
54
+ while (i < len) {
55
+ const ch = template[i];
56
+
57
+ // Escape sequence: \{{ → literal {{
58
+ if (ch === '\\' && i + 2 < len && template[i + 1] === '{' && template[i + 2] === '{') {
59
+ out.push('{{');
60
+ i += 3;
61
+ continue;
62
+ }
63
+
64
+ // Token open: {{...}}
65
+ if (ch === '{' && i + 1 < len && template[i + 1] === '{') {
66
+ const tokenStart = i;
67
+ const closeIdx = findTokenClose(template, i + 2);
68
+ if (closeIdx === -1) {
69
+ throw new TemplateParseError('unclosed `{{` (no matching `}}`)', tokenStart);
70
+ }
71
+ const inner = template.slice(i + 2, closeIdx);
72
+ const rendered = renderToken(inner, vars, tokenStart);
73
+ out.push(rendered);
74
+ i = closeIdx + 2;
75
+ continue;
76
+ }
77
+
78
+ out.push(ch);
79
+ i++;
80
+ }
81
+
82
+ return out.join('');
83
+ }
84
+
85
+ /**
86
+ * Find the closing `}}` for a token that starts at index `start` (which points
87
+ * to the first character INSIDE the `{{`). Skips `}}` that occur inside a
88
+ * double-quoted string literal (so `default("a }} b")` works).
89
+ *
90
+ * Returns the index of the first `}` of the closing `}}`, or -1 if not found.
91
+ */
92
+ function findTokenClose(template: string, start: number): number {
93
+ const len = template.length;
94
+ let i = start;
95
+ let inString = false;
96
+
97
+ while (i < len) {
98
+ const ch = template[i];
99
+
100
+ if (inString) {
101
+ if (ch === '\\' && i + 1 < len) {
102
+ // Skip the next char (escape sequence inside default string literal).
103
+ i += 2;
104
+ continue;
105
+ }
106
+ if (ch === '"') {
107
+ inString = false;
108
+ i++;
109
+ continue;
110
+ }
111
+ i++;
112
+ continue;
113
+ }
114
+
115
+ if (ch === '"') {
116
+ inString = true;
117
+ i++;
118
+ continue;
119
+ }
120
+
121
+ if (ch === '}' && i + 1 < len && template[i + 1] === '}') {
122
+ return i;
123
+ }
124
+
125
+ i++;
126
+ }
127
+
128
+ return -1;
129
+ }
130
+
131
+ /**
132
+ * Render a single token (text BETWEEN `{{` and `}}`).
133
+ * Format: `path.to.var` OR `path.to.var | default("fallback")`.
134
+ */
135
+ function renderToken(
136
+ inner: string,
137
+ vars: Record<string, unknown>,
138
+ tokenStart: number,
139
+ ): string {
140
+ const trimmed = inner.trim();
141
+ if (trimmed === '') {
142
+ throw new TemplateParseError('empty token `{{}}`', tokenStart);
143
+ }
144
+
145
+ // Split on the first unquoted `|`. Anything inside a string literal is preserved.
146
+ const pipeIdx = findUnquotedPipe(trimmed);
147
+
148
+ let path: string;
149
+ let defaultValue: string | null = null;
150
+
151
+ if (pipeIdx === -1) {
152
+ path = trimmed;
153
+ } else {
154
+ path = trimmed.slice(0, pipeIdx).trim();
155
+ const filterPart = trimmed.slice(pipeIdx + 1).trim();
156
+ defaultValue = parseDefaultFilter(filterPart, tokenStart);
157
+ }
158
+
159
+ if (!isValidPath(path)) {
160
+ throw new TemplateParseError(
161
+ `invalid variable path: "${path}" (allowed: dot-separated identifiers)`,
162
+ tokenStart,
163
+ );
164
+ }
165
+
166
+ const looked = lookup(vars, path);
167
+ if (looked === undefined) {
168
+ if (defaultValue !== null) return defaultValue;
169
+ throw new MissingVariableError(path);
170
+ }
171
+
172
+ return stringify(looked);
173
+ }
174
+
175
+ /**
176
+ * Find the first `|` outside a double-quoted string literal. Returns -1 if none.
177
+ * Backslash escapes are honored inside the string.
178
+ */
179
+ function findUnquotedPipe(s: string): number {
180
+ let inString = false;
181
+ for (let i = 0; i < s.length; i++) {
182
+ const c = s[i];
183
+ if (inString) {
184
+ if (c === '\\' && i + 1 < s.length) {
185
+ i++;
186
+ continue;
187
+ }
188
+ if (c === '"') inString = false;
189
+ continue;
190
+ }
191
+ if (c === '"') {
192
+ inString = true;
193
+ continue;
194
+ }
195
+ if (c === '|') return i;
196
+ }
197
+ return -1;
198
+ }
199
+
200
+ /**
201
+ * Parse a `default("...")` filter, returning the literal string.
202
+ * Throws `TemplateParseError` on any deviation from the grammar.
203
+ */
204
+ function parseDefaultFilter(filter: string, tokenStart: number): string {
205
+ // Must be exactly: `default(<string-literal>)`
206
+ const m = /^default\s*\(\s*"((?:\\.|[^"\\])*)"\s*\)\s*$/.exec(filter);
207
+ if (!m) {
208
+ throw new TemplateParseError(
209
+ `malformed filter: expected default("...")`,
210
+ tokenStart,
211
+ );
212
+ }
213
+ // Decode \" → ", \\ → \. Other backslashes are preserved verbatim per spec.
214
+ const raw = m[1];
215
+ return raw.replace(/\\(["\\])/g, '$1');
216
+ }
217
+
218
+ /**
219
+ * Validate a dot-walk path: must be one or more segments, each a valid identifier.
220
+ * Identifiers: ASCII letters, digits, underscore, hyphen; must not start with a digit.
221
+ * Hyphens are allowed because some YAML keys (e.g., `web-source`) use them.
222
+ */
223
+ function isValidPath(path: string): boolean {
224
+ if (path.length === 0) return false;
225
+ const segments = path.split('.');
226
+ for (const seg of segments) {
227
+ if (seg.length === 0) return false;
228
+ if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(seg)) return false;
229
+ }
230
+ return true;
231
+ }
232
+
233
+ /**
234
+ * Walk a dot-path through `obj` using own-property lookups only. NEVER traverses
235
+ * the prototype chain. Returns `undefined` if any segment is missing or if the
236
+ * value at any non-leaf segment is not an object.
237
+ */
238
+ function lookup(obj: Record<string, unknown>, path: string): unknown {
239
+ const segments = path.split('.');
240
+ let current: unknown = obj;
241
+ for (const seg of segments) {
242
+ if (current === null || current === undefined) return undefined;
243
+ if (typeof current !== 'object') return undefined;
244
+ if (Array.isArray(current)) return undefined;
245
+ if (!Object.hasOwn(current as object, seg)) return undefined;
246
+ current = (current as Record<string, unknown>)[seg];
247
+ }
248
+ return current;
249
+ }
250
+
251
+ /**
252
+ * Stringify a looked-up value. `undefined` is impossible at this point (caller
253
+ * already checked). `null` becomes the literal `"null"`. Everything else uses
254
+ * `String(value)`.
255
+ */
256
+ function stringify(value: unknown): string {
257
+ if (value === null) return 'null';
258
+ if (typeof value === 'string') return value;
259
+ return String(value);
260
+ }