@massu/core 1.10.8 → 1.12.1

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 (178) hide show
  1. package/CHANGELOG.md +1334 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +581 -448
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +67 -19
  16. package/dist/hooks/pre-compact.js +28 -3
  17. package/dist/hooks/pre-delete-check.js +53 -27
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +31 -6
  23. package/dist/hooks/session-start.js +8709 -8459
  24. package/dist/hooks/user-prompt.js +25 -1
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,27 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * `massu install-hooks` — Standalone hook installation.
6
- *
7
- * Installs or updates the canonical Massu hook set in .claude/settings.local.json.
8
- * Count is sourced from lib/hook-registry.ts SoT; see REGISTERED_HOOKS there.
9
- * Uses the same logic as `massu init` but only handles hooks.
10
- */
11
-
12
- import { installHooks } from './init.ts';
13
-
14
- export async function runInstallHooks(): Promise<void> {
15
- const projectRoot = process.cwd();
16
-
17
- console.log('');
18
- console.log('Massu AI - Hook Installation');
19
- console.log('============================');
20
- console.log('');
21
-
22
- const { count } = installHooks(projectRoot);
23
- console.log(` Installed ${count} hooks in .claude/settings.local.json`);
24
- console.log('');
25
- console.log('Hooks will activate on your next Claude Code session.');
26
- console.log('');
27
- }
@@ -1,150 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * `massu permissions <subcommand>` — MCP permission lifecycle CLI.
6
- *
7
- * Subcommands (each is documented at https://massu.ai/docs/reference/cli-reference):
8
- * install Seed `mcp__massu__*` into permissions.allow + propagate global
9
- * defaultMode (idempotent, kept-because-edited preservation).
10
- * verify Read-only check; exit 0 if all canonical entries present, else 1.
11
- * check-drift Extended diagnostic (4 drift kinds, severity-mapped exit codes).
12
- *
13
- * Exit code matrix for `check-drift` (highest severity wins when multiple kinds present):
14
- * 0 = clean
15
- * 1 = missing-allow
16
- * 2 = invalid-default-mode
17
- * 3 = unknown-key
18
- * 4 = strips-global-defaultmode
19
- *
20
- * Mirrors the existing `handleConfigSubcommand` dispatch pattern at cli.ts.
21
- */
22
-
23
- import { resolve } from 'path';
24
- import { getConfig } from '../config.ts';
25
- import {
26
- installPermissions,
27
- verifyPermissions,
28
- checkPermissionsDrift,
29
- type DriftKind,
30
- } from '../permissions.ts';
31
- import { runWithManifest } from './install-commands.ts';
32
-
33
- function resolveClaudeDir(): string {
34
- let claudeDirName = '.claude';
35
- try {
36
- claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
37
- } catch {
38
- claudeDirName = '.claude';
39
- }
40
- return resolve(process.cwd(), claudeDirName);
41
- }
42
-
43
- const DRIFT_KIND_EXIT_CODE: Record<DriftKind, number> = {
44
- 'missing-allow': 1,
45
- 'invalid-default-mode': 2,
46
- 'unknown-key': 3,
47
- 'strips-global-defaultmode': 4,
48
- };
49
-
50
- export async function handlePermissionsSubcommand(
51
- args: string[],
52
- ): Promise<{ exitCode: number }> {
53
- const sub = args[0];
54
-
55
- switch (sub) {
56
- case 'install': {
57
- const claudeDir = resolveClaudeDir();
58
- const result = runWithManifest(claudeDir, (manifest) =>
59
- installPermissions(claudeDir, manifest, { silent: false }),
60
- );
61
- // Final user-facing summary line on stdout
62
- if (result.installed > 0) {
63
- process.stdout.write(
64
- 'Wrote merged permissions block to .claude/settings.local.json.\n',
65
- );
66
- } else if (result.skipped > 0) {
67
- process.stdout.write('Permissions already in sync — no changes.\n');
68
- } else if (result.kept > 0) {
69
- process.stdout.write(
70
- 'Operator-edited permissions block preserved. Run `npx massu permissions check-drift` to inspect.\n',
71
- );
72
- }
73
- return { exitCode: 0 };
74
- }
75
-
76
- case 'verify': {
77
- const claudeDir = resolveClaudeDir();
78
- const { missing } = verifyPermissions(claudeDir);
79
- if (missing.length === 0) {
80
- process.stdout.write('All MCP allowlist entries present.\n');
81
- return { exitCode: 0 };
82
- }
83
- for (const entry of missing) {
84
- process.stderr.write(`missing: ${entry}\n`);
85
- }
86
- return { exitCode: 1 };
87
- }
88
-
89
- case 'check-drift': {
90
- const claudeDir = resolveClaudeDir();
91
- const { driftItems } = checkPermissionsDrift(claudeDir);
92
- if (driftItems.length === 0) {
93
- process.stdout.write('No permission drift detected.\n');
94
- return { exitCode: 0 };
95
- }
96
- // Highest-severity kind wins for exit code
97
- let highest = 0;
98
- for (const item of driftItems) {
99
- const code = DRIFT_KIND_EXIT_CODE[item.kind];
100
- if (code > highest) highest = code;
101
- process.stderr.write(
102
- `drift[${item.kind}]: ${item.detail} — remediation: ${item.remediation}\n`,
103
- );
104
- }
105
- return { exitCode: highest };
106
- }
107
-
108
- case '--help':
109
- case '-h':
110
- case undefined: {
111
- printPermissionsHelp();
112
- return { exitCode: 0 };
113
- }
114
-
115
- default: {
116
- process.stderr.write(`massu: unknown permissions subcommand: ${sub}\n`);
117
- printPermissionsHelp();
118
- return { exitCode: 1 };
119
- }
120
- }
121
- }
122
-
123
- export function printPermissionsHelp(): void {
124
- process.stdout.write(`
125
- massu permissions <subcommand>
126
-
127
- Subcommands:
128
- install Seed mcp__massu__* into .claude/settings.local.json's permissions.allow.
129
- Also propagates global defaultMode (from ~/.claude/settings.json) into
130
- the project-local file to prevent the merge-replacement trap (see
131
- https://massu.ai/docs/reference/cli-reference#permissions-trap).
132
- Idempotent. Preserves operator-edited values.
133
-
134
- verify Read-only check that all canonical MCP allowlist entries are present.
135
- Exit 0 if clean, exit 1 with one diagnostic line per missing entry.
136
-
137
- check-drift Extended diagnostic surfacing 4 drift kinds:
138
- - missing-allow (exit 1) — canonical entries missing
139
- - invalid-default-mode (exit 2) — defaultMode requires launch flag
140
- - unknown-key (exit 3) — undocumented top-level setting
141
- - strips-global-defaultmode (exit 4) — project-local would strip global value
142
-
143
- Examples:
144
- npx massu permissions install
145
- npx massu permissions verify
146
- npx massu permissions check-drift
147
-
148
- Documentation: https://massu.ai/docs/reference/cli-reference#massu-permissions
149
- `);
150
- }
@@ -1,37 +0,0 @@
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
- }
@@ -1,65 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * `massu show-template <name>` — print the resolved variant of a template.
6
- *
7
- * Used by the local-edit-protection messaging to support a workflow like:
8
- *
9
- * diff .claude/commands/massu-scaffold-router.md \
10
- * <(npx massu show-template massu-scaffold-router)
11
- *
12
- * The resolved variant honors `pickVariant` against the consumer's current
13
- * `massu.config.yaml`. Exits 0 on success, 1 on unknown template.
14
- */
15
-
16
- import { existsSync, readFileSync } from 'fs';
17
- import { resolve } from 'path';
18
- import { getConfig } from '../config.ts';
19
- import { pickVariant, resolveAssetDir } from './install-commands.ts';
20
-
21
- /** Strip an optional trailing `.md` extension. */
22
- function normalizeBaseName(input: string): string {
23
- return input.endsWith('.md') ? input.slice(0, -'.md'.length) : input;
24
- }
25
-
26
- export async function runShowTemplate(args: string[]): Promise<void> {
27
- const rawName = args[0];
28
- if (!rawName) {
29
- process.stderr.write('massu: show-template requires a template name\n');
30
- process.stderr.write(' usage: massu show-template <name>\n');
31
- process.exit(1);
32
- return;
33
- }
34
-
35
- const baseName = normalizeBaseName(rawName);
36
- const sourceDir = resolveAssetDir('commands');
37
- if (!sourceDir) {
38
- process.stderr.write('massu: could not locate the bundled commands directory\n');
39
- process.exit(1);
40
- return;
41
- }
42
-
43
- const framework = getConfig().framework;
44
- const choice = pickVariant(baseName, sourceDir, framework);
45
-
46
- if (choice.kind === 'miss') {
47
- process.stderr.write(`massu: no template named "${baseName}" found\n`);
48
- process.exit(1);
49
- return;
50
- }
51
-
52
- const suffix = choice.kind === 'hit' ? choice.suffix : '';
53
- const file = suffix === ''
54
- ? resolve(sourceDir, `${baseName}.md`)
55
- : resolve(sourceDir, `${baseName}${suffix}.md`);
56
-
57
- if (!existsSync(file)) {
58
- // Defensive: pickVariant said hit but the file isn't there.
59
- process.stderr.write(`massu: resolved template "${file}" no longer exists\n`);
60
- process.exit(1);
61
- return;
62
- }
63
-
64
- process.stdout.write(readFileSync(file, 'utf-8'));
65
- }
@@ -1,301 +0,0 @@
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
-
73
- // P-H007 (plan-stage-c-high-batch): JSX pass-through. Pattern docs
74
- // contain content like `action={{ label: "X" }}` (JSX object literal)
75
- // that LOOKS like a template token but isn't. Pre-fix the engine
76
- // threw TemplateParseError and the WHOLE file silently failed to
77
- // install. Now: detect ONLY clearly-JSX patterns (multi-line OR
78
- // leading whitespace inside the braces) and emit verbatim. Everything
79
- // else (including security probes, empty `{{}}`, malformed filters,
80
- // invalid path characters) still goes through strict renderToken and
81
- // throws — security tests still pass.
82
- if (isJsxPassThrough(inner)) {
83
- out.push('{{');
84
- out.push(inner);
85
- out.push('}}');
86
- i = closeIdx + 2;
87
- continue;
88
- }
89
-
90
- const rendered = renderToken(inner, vars, tokenStart);
91
- out.push(rendered);
92
- i = closeIdx + 2;
93
- continue;
94
- }
95
-
96
- out.push(ch);
97
- i++;
98
- }
99
-
100
- return out.join('');
101
- }
102
-
103
- /**
104
- * Find the closing `}}` for a token that starts at index `start` (which points
105
- * to the first character INSIDE the `{{`). Skips `}}` that occur inside a
106
- * double-quoted string literal (so `default("a }} b")` works).
107
- *
108
- * Returns the index of the first `}` of the closing `}}`, or -1 if not found.
109
- */
110
- function findTokenClose(template: string, start: number): number {
111
- const len = template.length;
112
- let i = start;
113
- let inString = false;
114
-
115
- while (i < len) {
116
- const ch = template[i];
117
-
118
- if (inString) {
119
- if (ch === '\\' && i + 1 < len) {
120
- // Skip the next char (escape sequence inside default string literal).
121
- i += 2;
122
- continue;
123
- }
124
- if (ch === '"') {
125
- inString = false;
126
- i++;
127
- continue;
128
- }
129
- i++;
130
- continue;
131
- }
132
-
133
- if (ch === '"') {
134
- inString = true;
135
- i++;
136
- continue;
137
- }
138
-
139
- if (ch === '}' && i + 1 < len && template[i + 1] === '}') {
140
- return i;
141
- }
142
-
143
- i++;
144
- }
145
-
146
- return -1;
147
- }
148
-
149
- /**
150
- * P-H007: Decide whether the inner text of a `{{...}}` block is a multi-line
151
- * JSX expression that should be emitted verbatim (NOT parsed as a Massu
152
- * template token).
153
- *
154
- * Detection is intentionally MINIMAL: only multi-line content passes through.
155
- * The original P-H007 evidence (`patterns/component-patterns.md` JSX
156
- * `action={{ label: "X", onClick: () => ... }}` formatted across lines)
157
- * IS multi-line; that is the bug class to close.
158
- *
159
- * Single-line content of EVERY shape (valid Massu vars, malformed paths,
160
- * empty `{{}}`, security probes like `constructor.constructor("...")()`,
161
- * default filters with escaped quotes) goes through the strict renderToken
162
- * path so all pre-existing template-engine.test.ts behavior is preserved.
163
- *
164
- * Tradeoff: single-line JSX `action={{ x: 1 }}` (rare in practice) would
165
- * still throw under this rule. The vast majority of JSX in shipped pattern
166
- * docs is multi-line, so this is the correct conservative fix.
167
- */
168
- function isJsxPassThrough(inner: string): boolean {
169
- return inner.includes('\n');
170
- }
171
-
172
- /**
173
- * Render a single token (text BETWEEN `{{` and `}}`).
174
- * Format: `path.to.var` OR `path.to.var | default("fallback")`.
175
- */
176
- function renderToken(
177
- inner: string,
178
- vars: Record<string, unknown>,
179
- tokenStart: number,
180
- ): string {
181
- const trimmed = inner.trim();
182
- if (trimmed === '') {
183
- throw new TemplateParseError('empty token `{{}}`', tokenStart);
184
- }
185
-
186
- // Split on the first unquoted `|`. Anything inside a string literal is preserved.
187
- const pipeIdx = findUnquotedPipe(trimmed);
188
-
189
- let path: string;
190
- let defaultValue: string | null = null;
191
-
192
- if (pipeIdx === -1) {
193
- path = trimmed;
194
- } else {
195
- path = trimmed.slice(0, pipeIdx).trim();
196
- const filterPart = trimmed.slice(pipeIdx + 1).trim();
197
- defaultValue = parseDefaultFilter(filterPart, tokenStart);
198
- }
199
-
200
- if (!isValidPath(path)) {
201
- throw new TemplateParseError(
202
- `invalid variable path: "${path}" (allowed: dot-separated identifiers)`,
203
- tokenStart,
204
- );
205
- }
206
-
207
- const looked = lookup(vars, path);
208
- if (looked === undefined) {
209
- if (defaultValue !== null) return defaultValue;
210
- throw new MissingVariableError(path);
211
- }
212
-
213
- return stringify(looked);
214
- }
215
-
216
- /**
217
- * Find the first `|` outside a double-quoted string literal. Returns -1 if none.
218
- * Backslash escapes are honored inside the string.
219
- */
220
- function findUnquotedPipe(s: string): number {
221
- let inString = false;
222
- for (let i = 0; i < s.length; i++) {
223
- const c = s[i];
224
- if (inString) {
225
- if (c === '\\' && i + 1 < s.length) {
226
- i++;
227
- continue;
228
- }
229
- if (c === '"') inString = false;
230
- continue;
231
- }
232
- if (c === '"') {
233
- inString = true;
234
- continue;
235
- }
236
- if (c === '|') return i;
237
- }
238
- return -1;
239
- }
240
-
241
- /**
242
- * Parse a `default("...")` filter, returning the literal string.
243
- * Throws `TemplateParseError` on any deviation from the grammar.
244
- */
245
- function parseDefaultFilter(filter: string, tokenStart: number): string {
246
- // Must be exactly: `default(<string-literal>)`
247
- const m = /^default\s*\(\s*"((?:\\.|[^"\\])*)"\s*\)\s*$/.exec(filter);
248
- if (!m) {
249
- throw new TemplateParseError(
250
- `malformed filter: expected default("...")`,
251
- tokenStart,
252
- );
253
- }
254
- // Decode \" → ", \\ → \. Other backslashes are preserved verbatim per spec.
255
- const raw = m[1];
256
- return raw.replace(/\\(["\\])/g, '$1');
257
- }
258
-
259
- /**
260
- * Validate a dot-walk path: must be one or more segments, each a valid identifier.
261
- * Identifiers: ASCII letters, digits, underscore, hyphen; must not start with a digit.
262
- * Hyphens are allowed because some YAML keys (e.g., `web-source`) use them.
263
- */
264
- function isValidPath(path: string): boolean {
265
- if (path.length === 0) return false;
266
- const segments = path.split('.');
267
- for (const seg of segments) {
268
- if (seg.length === 0) return false;
269
- if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(seg)) return false;
270
- }
271
- return true;
272
- }
273
-
274
- /**
275
- * Walk a dot-path through `obj` using own-property lookups only. NEVER traverses
276
- * the prototype chain. Returns `undefined` if any segment is missing or if the
277
- * value at any non-leaf segment is not an object.
278
- */
279
- function lookup(obj: Record<string, unknown>, path: string): unknown {
280
- const segments = path.split('.');
281
- let current: unknown = obj;
282
- for (const seg of segments) {
283
- if (current === null || current === undefined) return undefined;
284
- if (typeof current !== 'object') return undefined;
285
- if (Array.isArray(current)) return undefined;
286
- if (!Object.hasOwn(current as object, seg)) return undefined;
287
- current = (current as Record<string, unknown>)[seg];
288
- }
289
- return current;
290
- }
291
-
292
- /**
293
- * Stringify a looked-up value. `undefined` is impossible at this point (caller
294
- * already checked). `null` becomes the literal `"null"`. Everything else uses
295
- * `String(value)`.
296
- */
297
- function stringify(value: unknown): string {
298
- if (value === null) return 'null';
299
- if (typeof value === 'string') return value;
300
- return String(value);
301
- }