@massu/core 1.10.8 → 1.12.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.
- package/CHANGELOG.md +1303 -0
- package/dist/adapter.js +6 -4
- package/dist/cli.js +517 -397
- package/dist/detect/adapters/.bundle-shasums.json +4 -7
- package/dist/detect/adapters/rails.js +6 -4
- package/dist/detect/adapters/spring.js +6 -4
- package/dist/detect/adapters/tree-sitter-loader.js +6 -4
- package/dist/hooks/auto-learning-pipeline.js +24 -1
- package/dist/hooks/classify-failure.js +32 -3
- package/dist/hooks/cost-tracker.js +24 -0
- package/dist/hooks/fix-detector.js +97 -1
- package/dist/hooks/incident-pipeline.js +30 -1
- package/dist/hooks/intent-suggester.js +6 -3
- package/dist/hooks/post-edit-context.js +24 -1
- package/dist/hooks/post-tool-use.js +66 -18
- package/dist/hooks/pre-compact.js +27 -2
- package/dist/hooks/pre-delete-check.js +48 -22
- package/dist/hooks/pre-tool-use-gate.js +962 -0
- package/dist/hooks/quality-event.js +24 -0
- package/dist/hooks/rule-enforcement-pipeline.js +24 -1
- package/dist/hooks/security-gate.js +29 -16
- package/dist/hooks/session-end.js +24 -0
- package/dist/hooks/session-start.js +8710 -8460
- package/dist/hooks/user-prompt.js +24 -0
- package/package.json +15 -18
- package/commands/README.md +0 -134
- package/src/adapter.ts +0 -121
- package/src/adr-generator.ts +0 -292
- package/src/analytics.ts +0 -373
- package/src/audit-trail.ts +0 -450
- package/src/backfill-sessions.ts +0 -182
- package/src/changelog-generator.ts +0 -178
- package/src/cli.ts +0 -246
- package/src/cloud-sync.ts +0 -234
- package/src/commands/adapters.ts +0 -824
- package/src/commands/changelog.ts +0 -165
- package/src/commands/config-check-drift.ts +0 -133
- package/src/commands/config-refresh.ts +0 -396
- package/src/commands/config-upgrade.ts +0 -127
- package/src/commands/doctor.ts +0 -485
- package/src/commands/hook-runner.ts +0 -145
- package/src/commands/init.ts +0 -1823
- package/src/commands/install-commands.ts +0 -692
- package/src/commands/install-hooks.ts +0 -27
- package/src/commands/permissions.ts +0 -150
- package/src/commands/refresh-log.ts +0 -37
- package/src/commands/show-template.ts +0 -65
- package/src/commands/template-engine.ts +0 -301
- package/src/commands/watch.ts +0 -430
- package/src/config.ts +0 -765
- package/src/cost-tracker.ts +0 -355
- package/src/db.ts +0 -370
- package/src/dependency-scorer.ts +0 -337
- package/src/detect/adapters/aspnet.ts +0 -4
- package/src/detect/adapters/discover.ts +0 -469
- package/src/detect/adapters/file-sampler.ts +0 -225
- package/src/detect/adapters/go-chi.ts +0 -4
- package/src/detect/adapters/index.ts +0 -51
- package/src/detect/adapters/nextjs-trpc.ts +0 -166
- package/src/detect/adapters/parse-guard.ts +0 -133
- package/src/detect/adapters/phoenix.ts +0 -4
- package/src/detect/adapters/python-django.ts +0 -208
- package/src/detect/adapters/python-fastapi.ts +0 -223
- package/src/detect/adapters/python-flask.ts +0 -235
- package/src/detect/adapters/query-helpers.ts +0 -170
- package/src/detect/adapters/rails.ts +0 -4
- package/src/detect/adapters/runner.ts +0 -284
- package/src/detect/adapters/spring.ts +0 -4
- package/src/detect/adapters/swift-swiftui.ts +0 -171
- package/src/detect/adapters/tree-sitter-loader.ts +0 -517
- package/src/detect/adapters/types.ts +0 -191
- package/src/detect/codebase-introspector.ts +0 -210
- package/src/detect/domain-inferrer.ts +0 -197
- package/src/detect/drift.ts +0 -199
- package/src/detect/framework-detector.ts +0 -307
- package/src/detect/index.ts +0 -209
- package/src/detect/manifest-registry.ts +0 -261
- package/src/detect/migrate.ts +0 -362
- package/src/detect/monorepo-detector.ts +0 -348
- package/src/detect/package-detector.ts +0 -828
- package/src/detect/passthrough.ts +0 -108
- package/src/detect/regex-fallback.ts +0 -449
- package/src/detect/source-dir-detector.ts +0 -297
- package/src/detect/vr-command-map.ts +0 -167
- package/src/docs-map.json +0 -100
- package/src/docs-tools.ts +0 -519
- package/src/domains.ts +0 -182
- package/src/hooks/auto-learning-pipeline.ts +0 -228
- package/src/hooks/classify-failure.ts +0 -259
- package/src/hooks/cost-tracker.ts +0 -66
- package/src/hooks/fix-detector.ts +0 -186
- package/src/hooks/incident-pipeline.ts +0 -190
- package/src/hooks/intent-suggester.ts +0 -131
- package/src/hooks/post-edit-context.ts +0 -91
- package/src/hooks/post-tool-use.ts +0 -413
- package/src/hooks/pre-compact.ts +0 -168
- package/src/hooks/pre-delete-check.ts +0 -243
- package/src/hooks/quality-event.ts +0 -127
- package/src/hooks/rule-enforcement-pipeline.ts +0 -159
- package/src/hooks/security-gate.ts +0 -153
- package/src/hooks/session-end.ts +0 -467
- package/src/hooks/session-start.ts +0 -436
- package/src/hooks/user-prompt.ts +0 -157
- package/src/import-resolver.ts +0 -226
- package/src/knowledge-db.ts +0 -176
- package/src/knowledge-indexer.ts +0 -708
- package/src/knowledge-tools.ts +0 -1414
- package/src/lib/fileLock.ts +0 -203
- package/src/lib/framework-supports.ts +0 -85
- package/src/lib/gitToplevel.ts +0 -22
- package/src/lib/hook-registry.ts +0 -43
- package/src/lib/installLock.ts +0 -66
- package/src/lib/memory-path.ts +0 -49
- package/src/lib/pidLiveness.ts +0 -67
- package/src/lib/settings-local.ts +0 -110
- package/src/lib/sql-table-names.ts +0 -71
- package/src/license.ts +0 -547
- package/src/lsp/auto-detect.ts +0 -98
- package/src/lsp/client.ts +0 -776
- package/src/lsp/enrich.ts +0 -127
- package/src/lsp/types.ts +0 -221
- package/src/memory-db.ts +0 -1614
- package/src/memory-file-ingest.ts +0 -128
- package/src/memory-tools.ts +0 -424
- package/src/middleware-tree.ts +0 -71
- package/src/observability-tools.ts +0 -343
- package/src/observation-extractor.ts +0 -418
- package/src/page-deps.ts +0 -285
- package/src/permissions.ts +0 -422
- package/src/prompt-analyzer.ts +0 -332
- package/src/python/coupling-detector.ts +0 -125
- package/src/python/domain-enforcer.ts +0 -84
- package/src/python/impact-analyzer.ts +0 -96
- package/src/python/import-parser.ts +0 -244
- package/src/python/import-resolver.ts +0 -136
- package/src/python/migration-indexer.ts +0 -116
- package/src/python/migration-parser.ts +0 -332
- package/src/python/model-indexer.ts +0 -71
- package/src/python/model-parser.ts +0 -279
- package/src/python/route-indexer.ts +0 -59
- package/src/python/route-parser.ts +0 -317
- package/src/python-tools.ts +0 -630
- package/src/regression-detector.ts +0 -319
- package/src/rules.ts +0 -57
- package/src/schema-mapper.ts +0 -232
- package/src/security/adapter-origin.ts +0 -130
- package/src/security/adapter-verifier.ts +0 -319
- package/src/security/atomic-write.ts +0 -164
- package/src/security/fetcher.ts +0 -200
- package/src/security/install-tracking.ts +0 -319
- package/src/security/license-pubkey.generated.ts +0 -30
- package/src/security/license-response-verifier.ts +0 -149
- package/src/security/local-fingerprint.ts +0 -225
- package/src/security/manifest-cache.ts +0 -333
- package/src/security/manifest-schema.ts +0 -129
- package/src/security/registry-pubkey.generated.ts +0 -35
- package/src/security/telemetry.ts +0 -320
- package/src/security-scorer.ts +0 -405
- package/src/security-utils.ts +0 -133
- package/src/sentinel-db.ts +0 -580
- package/src/sentinel-scanner.ts +0 -406
- package/src/sentinel-tools.ts +0 -513
- package/src/sentinel-types.ts +0 -140
- package/src/server-dispatch.ts +0 -225
- package/src/server.ts +0 -107
- package/src/session-archiver.ts +0 -111
- package/src/session-state-generator.ts +0 -174
- package/src/team-knowledge.ts +0 -407
- package/src/tool-db-needs.ts +0 -232
- package/src/tools.ts +0 -1206
- package/src/transcript-parser.ts +0 -458
- package/src/trpc-index.ts +0 -215
- package/src/validate-features-runner.ts +0 -107
- package/src/validation-engine.ts +0 -358
- package/src/watch/daemon.ts +0 -385
- package/src/watch/lockfile-detector.ts +0 -65
- package/src/watch/paths.ts +0 -279
- 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
|
-
}
|