@massu/core 1.10.7 → 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.
Files changed (177) hide show
  1. package/CHANGELOG.md +1303 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +559 -400
  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 +66 -18
  16. package/dist/hooks/pre-compact.js +27 -2
  17. package/dist/hooks/pre-delete-check.js +48 -22
  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 +24 -0
  23. package/dist/hooks/session-start.js +8710 -8460
  24. package/dist/hooks/user-prompt.js +24 -0
  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/gitToplevel.ts +0 -22
  110. package/src/lib/hook-registry.ts +0 -43
  111. package/src/lib/installLock.ts +0 -66
  112. package/src/lib/memory-path.ts +0 -49
  113. package/src/lib/pidLiveness.ts +0 -67
  114. package/src/lib/settings-local.ts +0 -110
  115. package/src/lib/sql-table-names.ts +0 -71
  116. package/src/license.ts +0 -547
  117. package/src/lsp/auto-detect.ts +0 -98
  118. package/src/lsp/client.ts +0 -776
  119. package/src/lsp/enrich.ts +0 -127
  120. package/src/lsp/types.ts +0 -221
  121. package/src/memory-db.ts +0 -1614
  122. package/src/memory-file-ingest.ts +0 -128
  123. package/src/memory-tools.ts +0 -424
  124. package/src/middleware-tree.ts +0 -71
  125. package/src/observability-tools.ts +0 -343
  126. package/src/observation-extractor.ts +0 -418
  127. package/src/page-deps.ts +0 -285
  128. package/src/permissions.ts +0 -422
  129. package/src/prompt-analyzer.ts +0 -332
  130. package/src/python/coupling-detector.ts +0 -125
  131. package/src/python/domain-enforcer.ts +0 -84
  132. package/src/python/impact-analyzer.ts +0 -96
  133. package/src/python/import-parser.ts +0 -244
  134. package/src/python/import-resolver.ts +0 -136
  135. package/src/python/migration-indexer.ts +0 -116
  136. package/src/python/migration-parser.ts +0 -332
  137. package/src/python/model-indexer.ts +0 -71
  138. package/src/python/model-parser.ts +0 -279
  139. package/src/python/route-indexer.ts +0 -59
  140. package/src/python/route-parser.ts +0 -317
  141. package/src/python-tools.ts +0 -630
  142. package/src/regression-detector.ts +0 -319
  143. package/src/rules.ts +0 -57
  144. package/src/schema-mapper.ts +0 -232
  145. package/src/security/adapter-origin.ts +0 -130
  146. package/src/security/adapter-verifier.ts +0 -319
  147. package/src/security/atomic-write.ts +0 -164
  148. package/src/security/fetcher.ts +0 -200
  149. package/src/security/install-tracking.ts +0 -319
  150. package/src/security/license-pubkey.generated.ts +0 -30
  151. package/src/security/license-response-verifier.ts +0 -149
  152. package/src/security/local-fingerprint.ts +0 -225
  153. package/src/security/manifest-cache.ts +0 -333
  154. package/src/security/manifest-schema.ts +0 -129
  155. package/src/security/registry-pubkey.generated.ts +0 -35
  156. package/src/security/telemetry.ts +0 -320
  157. package/src/security-scorer.ts +0 -405
  158. package/src/security-utils.ts +0 -133
  159. package/src/sentinel-db.ts +0 -580
  160. package/src/sentinel-scanner.ts +0 -406
  161. package/src/sentinel-tools.ts +0 -513
  162. package/src/sentinel-types.ts +0 -140
  163. package/src/server-dispatch.ts +0 -225
  164. package/src/server.ts +0 -107
  165. package/src/session-archiver.ts +0 -111
  166. package/src/session-state-generator.ts +0 -174
  167. package/src/team-knowledge.ts +0 -407
  168. package/src/tool-db-needs.ts +0 -232
  169. package/src/tools.ts +0 -1205
  170. package/src/transcript-parser.ts +0 -458
  171. package/src/trpc-index.ts +0 -215
  172. package/src/validate-features-runner.ts +0 -107
  173. package/src/validation-engine.ts +0 -358
  174. package/src/watch/daemon.ts +0 -385
  175. package/src/watch/lockfile-detector.ts +0 -65
  176. package/src/watch/paths.ts +0 -279
  177. package/src/watch/state.ts +0 -178
@@ -1,517 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * Plan 3b — Phase 1: Tree-sitter WASM grammar loader (Strategy A).
6
- *
7
- * Strategy A: grammars are NOT bundled in the npm tarball. The loader downloads
8
- * each requested grammar at first use from a pinned URL, verifies SHA-256
9
- * against a hardcoded manifest, caches under `~/.massu/wasm-cache/`.
10
- *
11
- * Security model (Phase 3.5 #3):
12
- * - SHA-256 manifest hardcoded HERE — never network-fetched.
13
- * - Mismatch → throw `GrammarSHAMismatchError`. NO silent fallback.
14
- * - Atomic cache write: `<lang>-<sha>.wasm.tmp.<pid>` → rename → final.
15
- * - Offline + no-cache → throw `GrammarUnavailableError` so the runner can
16
- * translate to a regex-fallback path with a stderr note.
17
- *
18
- * Phase 1 ships the CODE PATH; the actual SHA-256 values for each grammar
19
- * URL are placeholders pending Phase 9 release-prep (`curl <url> | shasum
20
- * -a 256`). The placeholder string is intentionally non-empty so the
21
- * verification logic exercises the comparison branch in tests.
22
- */
23
-
24
- import { createHash } from 'crypto';
25
- import {
26
- mkdirSync,
27
- readdirSync,
28
- readFileSync,
29
- writeFileSync,
30
- renameSync,
31
- unlinkSync,
32
- lstatSync,
33
- chmodSync,
34
- utimesSync,
35
- } from 'fs';
36
- import { homedir } from 'os';
37
- import { dirname, join } from 'path';
38
- import { Language, Parser } from 'web-tree-sitter';
39
- import type { TreeSitterLanguage } from './types.ts';
40
-
41
- // ============================================================
42
- // Typed errors
43
- // ============================================================
44
-
45
- /** Thrown when downloaded WASM SHA-256 doesn't match the hardcoded manifest. */
46
- export class GrammarSHAMismatchError extends Error {
47
- public readonly language: TreeSitterLanguage;
48
- public readonly expected: string;
49
- public readonly actual: string;
50
- constructor(language: TreeSitterLanguage, expected: string, actual: string) {
51
- super(
52
- `[tree-sitter-loader] SHA-256 mismatch for grammar "${language}". ` +
53
- `Expected ${expected}, got ${actual}. ` +
54
- `REFUSING to load — see Phase 3.5 audit attack vector #3.`,
55
- );
56
- this.name = 'GrammarSHAMismatchError';
57
- this.language = language;
58
- this.expected = expected;
59
- this.actual = actual;
60
- }
61
- }
62
-
63
- /** Thrown when a grammar can't be obtained: download failed AND cache empty. */
64
- export class GrammarUnavailableError extends Error {
65
- public readonly language: TreeSitterLanguage;
66
- public readonly cause?: unknown;
67
- constructor(language: TreeSitterLanguage, cause?: unknown) {
68
- const causeMsg =
69
- cause instanceof Error ? cause.message : cause ? String(cause) : 'no cached grammar and download failed';
70
- super(
71
- `[tree-sitter-loader] Grammar for "${language}" is unavailable: ${causeMsg}. ` +
72
- `Falling back to regex introspection for files in ${language}.`,
73
- );
74
- this.name = 'GrammarUnavailableError';
75
- this.language = language;
76
- this.cause = cause;
77
- }
78
- }
79
-
80
- /**
81
- * Thrown when the cache path resolves to a symlink (or any non-regular
82
- * file). Pre-creating a symlink at the expected cache path is a known
83
- * vector for redirecting reads/writes elsewhere on the filesystem.
84
- * (Phase 3.5 finding #3 — symlink attack on cache dir.)
85
- */
86
- export class GrammarCacheSymlinkError extends Error {
87
- public readonly cachePath: string;
88
- constructor(cachePath: string) {
89
- super(
90
- `[tree-sitter-loader] Refusing to load grammar — cache path "${cachePath}" is a symlink ` +
91
- `or non-regular file. (Phase 3.5 finding #3 — symlink attack vector.)`,
92
- );
93
- this.name = 'GrammarCacheSymlinkError';
94
- this.cachePath = cachePath;
95
- }
96
- }
97
-
98
- /**
99
- * Thrown when a manifest URL is not HTTPS. The manifest is hardcoded in
100
- * source, but defense in depth: any future edit that introduces an http://
101
- * URL is rejected at load time, not at code review.
102
- * (Phase 3.5 finding #3 — MITM on download.)
103
- */
104
- export class GrammarUrlNotHttpsError extends Error {
105
- public readonly url: string;
106
- constructor(url: string) {
107
- super(
108
- `[tree-sitter-loader] Refusing to download grammar from non-HTTPS URL: ${url}. ` +
109
- `Only https:// URLs are accepted. (Phase 3.5 finding #3.)`,
110
- );
111
- this.name = 'GrammarUrlNotHttpsError';
112
- this.url = url;
113
- }
114
- }
115
-
116
- // ============================================================
117
- // Pinned manifest
118
- // ============================================================
119
-
120
- interface ManifestEntry {
121
- url: string;
122
- sha256: string;
123
- version: string;
124
- }
125
-
126
- /**
127
- * Hardcoded grammar manifest. Source-code-resident; tampering requires a
128
- * release.
129
- *
130
- * Source: `tree-sitter-wasms` npm package (https://npm.im/tree-sitter-wasms)
131
- * — pre-built WASM binaries for Tree-sitter language parsers. NOT added as
132
- * a dependency (per plan §Phase 0 ban on bundling); fetched from unpkg at
133
- * first use. The individual `tree-sitter-<lang>` packages on npm do NOT
134
- * ship `.wasm` files, only C source + native .node prebuilds — confirmed
135
- * by inspecting unpkg `?meta` listings during Phase 9 release-prep.
136
- *
137
- * SHA-256 hashes computed 2026-04-28 via:
138
- * curl -fsSL <url> | shasum -a 256
139
- *
140
- * The verification code path is exercised in `tree-sitter-loader.test.ts`
141
- * by injecting test manifest entries that intentionally mismatch.
142
- */
143
- export const GRAMMAR_MANIFEST: Partial<Record<TreeSitterLanguage, ManifestEntry>> = {
144
- python: {
145
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-python.wasm',
146
- sha256: '9056d0fb0c337810d019fae350e8167786119da98f0f282aceae7ab89ee8253b',
147
- version: '0.1.13',
148
- },
149
- typescript: {
150
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-typescript.wasm',
151
- sha256: '8515404dceed38e1ed86aa34b09fcf3379fff1b4ff9dd3967bcd6d1eb5ac3d8f',
152
- version: '0.1.13',
153
- },
154
- javascript: {
155
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-javascript.wasm',
156
- sha256: '63812b9e275d26851264734868d27a1656bd44a2ef6eb3e85e6b03728c595ab5',
157
- version: '0.1.13',
158
- },
159
- swift: {
160
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-swift.wasm',
161
- sha256: '41c4fdb2249a3aa6d87eed0d383081ff09725c2248b4977043a43825980ffcc7',
162
- version: '0.1.13',
163
- },
164
- // ----------------------------------------------------------------
165
- // Plan 3c Phase 7 expansion (2026-05-07):
166
- //
167
- // Six additional grammars to support the registry-verified framework
168
- // adapters (go-chi, rails, aspnet, spring, ktor, phoenix) plus the
169
- // bundled adapters in the same language families (gin/echo/fiber,
170
- // sinatra, etc.). All entries use the SAME pinned tree-sitter-wasms
171
- // version (0.1.13) as the v1 four to keep the dependency surface
172
- // single-source.
173
- //
174
- // SHA-256s computed 2026-05-07 via:
175
- // curl -fsSL <url> | shasum -a 256
176
- //
177
- // The unpkg filename for C# uses an underscore (`c_sharp`) while the
178
- // TreeSitterLanguage identifier uses no separator (`csharp`); the map
179
- // key is the type identifier, the URL is the storage path — they do
180
- // NOT need to match, the same as how `python` maps to `tree-sitter-
181
- // python.wasm`. This is intentional and validated by the manifest
182
- // shape test in tree-sitter-loader-manifest.test.ts.
183
- // ----------------------------------------------------------------
184
- go: {
185
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-go.wasm',
186
- sha256: '9963ca89b616eaf04b08a43bc1fb0f07b85395bec313330851f1f1ead2f755b6',
187
- version: '0.1.13',
188
- },
189
- ruby: {
190
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-ruby.wasm',
191
- sha256: '93a5022855314cdb45458c7bb026a24a0ebc3a5ff6439e542e881f14dfa13a39',
192
- version: '0.1.13',
193
- },
194
- csharp: {
195
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-c_sharp.wasm',
196
- sha256: '6266a7e32d68a3459104d994dc848df15d5672b0ea8e86d327274b694f8e6991',
197
- version: '0.1.13',
198
- },
199
- java: {
200
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-java.wasm',
201
- sha256: '637aac4415fb39a211a4f4292d63c66b5ce9c32fa2cd35464af4f681d91b9a1f',
202
- version: '0.1.13',
203
- },
204
- kotlin: {
205
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-kotlin.wasm',
206
- sha256: 'b5cb00c8d06ed0f10f1dbe497205b437809d7e87db1f638721a8cfb30e044449',
207
- version: '0.1.13',
208
- },
209
- elixir: {
210
- url: 'https://unpkg.com/tree-sitter-wasms@0.1.13/out/tree-sitter-elixir.wasm',
211
- sha256: '82e91b9759ddca30d8978ebbfa8e347b4451b64c931f9ae62112e6db9b8fac20',
212
- version: '0.1.13',
213
- },
214
- };
215
-
216
- // ============================================================
217
- // Cache + Parser init
218
- // ============================================================
219
-
220
- function getCacheDir(): string {
221
- return process.env.MASSU_WASM_CACHE_DIR ?? join(homedir(), '.massu', 'wasm-cache');
222
- }
223
-
224
- function getCachedPath(language: TreeSitterLanguage, sha: string): string {
225
- return join(getCacheDir(), `${language}-${sha}.wasm`);
226
- }
227
-
228
- // ============================================================
229
- // LRU cache eviction (Phase 3.5 audit F-011 — closed 2026-05-06)
230
- // ============================================================
231
- //
232
- // F-011 was deferred at v1 ("at ~3MB per grammar, full cache footprint is
233
- // <100MB — not an attack vector"). The 2026-05-06 audit-leak retrospective
234
- // elevated it: now that the cache path + naming convention are publicly
235
- // known (the security audit doc was visible for 9 days), opportunistic
236
- // disk-fill attacks become slightly less hypothetical, AND the cost of
237
- // retrofitting LRU once Plan 3c expands the supported grammar set is
238
- // strictly higher than doing it now while only 4 grammars exist.
239
- //
240
- // Eviction rule: keep the N most-recently-USED entries (mtime, updated by
241
- // the cache-hit path on every read). Default cap = 16 — leaves headroom
242
- // for Plan 3c's 31-grammar expansion plus dev-time version churn, while
243
- // bounding total cache to ~50MB at 3MB/grammar.
244
-
245
- const DEFAULT_CACHE_RETAIN_COUNT = 16;
246
-
247
- function getCacheRetainCount(): number {
248
- const env = process.env.MASSU_WASM_CACHE_RETAIN;
249
- if (env) {
250
- const n = Number(env);
251
- if (Number.isFinite(n) && n >= 1 && n <= 1024) return Math.floor(n);
252
- }
253
- return DEFAULT_CACHE_RETAIN_COUNT;
254
- }
255
-
256
- /**
257
- * Touch a cache file's mtime to mark "most recently used." Called on every
258
- * cache-hit. Best-effort: any failure is silently swallowed — touching is
259
- * an optimization signal for eviction, not load-bearing.
260
- *
261
- * Uses utimes via writeFileSync round-trip would be expensive; instead we
262
- * use the same filesystem touch trick as `touch -a`: open + close. On
263
- * macOS/Linux Node, `chmodSync` to the same mode does NOT update mtime,
264
- * so we do a no-op write of empty content via a tmp marker file. Cheaper
265
- * approach: just rely on atime if filesystem records it. Most modern
266
- * filesystems are mounted with `relatime` so atime updates only when
267
- * older than mtime — which means after our first eviction-relevant
268
- * read, atime IS the right signal.
269
- *
270
- * Decision: use mtime via `utimesSync` — explicit and portable.
271
- */
272
- function touchCacheFile(path: string): void {
273
- try {
274
- const now = new Date();
275
- utimesSync(path, now, now);
276
- } catch {
277
- // best-effort
278
- }
279
- }
280
-
281
- /**
282
- * Evict cache entries beyond the retain count, keeping the N most recently
283
- * used (by mtime). Called after every successful cache write. Best-effort:
284
- * eviction failure never blocks a load.
285
- *
286
- * Rejects symlinks and non-regular files via lstat — the same defense as
287
- * the cache-hit path (F-008 fix). A symlink in the cache dir is logged
288
- * as a security warning but not deleted (don't act on attacker-controlled
289
- * paths automatically).
290
- */
291
- function evictBeyondRetainCount(retain: number = getCacheRetainCount()): void {
292
- const dir = getCacheDir();
293
- let entries: string[];
294
- try {
295
- entries = readdirSync(dir);
296
- } catch {
297
- return; // Dir doesn't exist yet; nothing to evict.
298
- }
299
-
300
- const candidates: { path: string; mtimeMs: number }[] = [];
301
- for (const name of entries) {
302
- if (!name.endsWith('.wasm')) continue; // Don't touch non-grammar files.
303
- const path = join(dir, name);
304
- let stat;
305
- try {
306
- stat = lstatSync(path);
307
- } catch {
308
- continue;
309
- }
310
- if (stat.isSymbolicLink() || !stat.isFile()) {
311
- // Skip — never automatically delete what could be an attacker-placed
312
- // symlink. Surface via stderr; user's cache dir is suspect.
313
- console.error(
314
- `[tree-sitter-loader] cache eviction skipped non-regular file: ${path} ` +
315
- `(possible symlink attack — see Phase 3.5 finding F-008).`,
316
- );
317
- continue;
318
- }
319
- candidates.push({ path, mtimeMs: stat.mtimeMs });
320
- }
321
-
322
- if (candidates.length <= retain) return;
323
-
324
- // Sort newest-first; everything beyond `retain` is evictable.
325
- candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
326
- for (const victim of candidates.slice(retain)) {
327
- try {
328
- unlinkSync(victim.path);
329
- } catch {
330
- // best-effort
331
- }
332
- }
333
- }
334
-
335
- /** Test-injection hook: lets tests force eviction without writing a new grammar. */
336
- export function _evictCacheForTest(retain?: number): void {
337
- evictBeyondRetainCount(retain);
338
- }
339
-
340
- function sha256(bytes: Uint8Array): string {
341
- return createHash('sha256').update(bytes).digest('hex');
342
- }
343
-
344
- let parserInitPromise: Promise<void> | null = null;
345
-
346
- /**
347
- * `Parser.init()` is async and must be called once before any `new Parser()`.
348
- * This function is idempotent — repeated calls return the same promise.
349
- *
350
- * Test harnesses can mock this by stubbing `Parser.init`.
351
- */
352
- export async function ensureParserInitialized(): Promise<void> {
353
- if (parserInitPromise) return parserInitPromise;
354
- parserInitPromise = Parser.init();
355
- return parserInitPromise;
356
- }
357
-
358
- // ============================================================
359
- // Loader (the main entry point)
360
- // ============================================================
361
-
362
- interface LoaderOptions {
363
- /**
364
- * Test-injection: override the manifest entry for a language. Production
365
- * callers leave this undefined; tests use it to exercise SHA-mismatch and
366
- * download-failure paths.
367
- */
368
- manifestOverride?: Partial<Record<TreeSitterLanguage, ManifestEntry>>;
369
- /**
370
- * Test-injection: override the fetch implementation. Defaults to global
371
- * `fetch`. Tests pass a mock that returns a fixed body or throws.
372
- */
373
- fetchImpl?: (url: string) => Promise<{ ok: boolean; arrayBuffer: () => Promise<ArrayBuffer>; status?: number }>;
374
- }
375
-
376
- const loadedGrammars = new Map<TreeSitterLanguage, Language>();
377
-
378
- /**
379
- * Lazy-load a Tree-sitter grammar. Only fetches/caches the grammar for
380
- * `language`; other languages are unaffected.
381
- *
382
- * Order:
383
- * 1. In-memory cache hit → return.
384
- * 2. Disk cache hit + SHA verify pass → load from disk.
385
- * 3. Disk cache hit + SHA mismatch → throw GrammarSHAMismatchError.
386
- * 4. Cache miss → fetch from pinned URL → SHA verify → atomic write → load.
387
- * 5. Fetch fails AND no cache → throw GrammarUnavailableError.
388
- */
389
- export async function loadGrammar(
390
- language: TreeSitterLanguage,
391
- options: LoaderOptions = {},
392
- ): Promise<Language> {
393
- await ensureParserInitialized();
394
-
395
- const cached = loadedGrammars.get(language);
396
- if (cached) return cached;
397
-
398
- const manifest = options.manifestOverride?.[language] ?? GRAMMAR_MANIFEST[language];
399
- if (!manifest) {
400
- throw new GrammarUnavailableError(
401
- language,
402
- new Error(`No manifest entry for language "${language}". v1 supports: ${Object.keys(GRAMMAR_MANIFEST).join(', ')}.`),
403
- );
404
- }
405
-
406
- const cachePath = getCachedPath(language, manifest.sha256);
407
-
408
- // 2/3: disk cache check. Use lstatSync (NOT statSync) so a symlink at
409
- // the cache path is detected and rejected — never followed.
410
- // (Phase 3.5 finding #3 — symlink attack on cache dir.)
411
- let cacheLstat;
412
- try {
413
- cacheLstat = lstatSync(cachePath);
414
- } catch {
415
- cacheLstat = null;
416
- }
417
- if (cacheLstat) {
418
- if (cacheLstat.isSymbolicLink() || !cacheLstat.isFile()) {
419
- throw new GrammarCacheSymlinkError(cachePath);
420
- }
421
- let bytes: Uint8Array;
422
- try {
423
- bytes = readFileSync(cachePath);
424
- } catch (e) {
425
- // Treat read failure as cache miss; fall through to download.
426
- bytes = new Uint8Array(0);
427
- }
428
- if (bytes.byteLength > 0) {
429
- const actualSha = sha256(bytes);
430
- if (actualSha !== manifest.sha256) {
431
- // Refuse to load. Don't silently re-download — that would mask
432
- // tampering of the on-disk cache.
433
- throw new GrammarSHAMismatchError(language, manifest.sha256, actualSha);
434
- }
435
- const lang = await Language.load(bytes);
436
- loadedGrammars.set(language, lang);
437
- // F-011 LRU: mark this entry as most-recently-used so it survives
438
- // future evictions.
439
- touchCacheFile(cachePath);
440
- return lang;
441
- }
442
- }
443
-
444
- // 4/5: download. Defense in depth: refuse non-HTTPS URLs.
445
- if (!/^https:\/\//i.test(manifest.url)) {
446
- throw new GrammarUrlNotHttpsError(manifest.url);
447
- }
448
-
449
- const fetchImpl = options.fetchImpl ?? (globalThis.fetch as LoaderOptions['fetchImpl']);
450
- if (!fetchImpl) {
451
- throw new GrammarUnavailableError(
452
- language,
453
- new Error('No fetch implementation available (Node < 18?)'),
454
- );
455
- }
456
-
457
- let body: Uint8Array;
458
- try {
459
- const res = await fetchImpl(manifest.url);
460
- if (!res.ok) {
461
- throw new Error(`HTTP ${res.status ?? 'unknown'} from ${manifest.url}`);
462
- }
463
- body = new Uint8Array(await res.arrayBuffer());
464
- } catch (e) {
465
- throw new GrammarUnavailableError(language, e);
466
- }
467
-
468
- const downloadedSha = sha256(body);
469
- if (downloadedSha !== manifest.sha256) {
470
- throw new GrammarSHAMismatchError(language, manifest.sha256, downloadedSha);
471
- }
472
-
473
- // Atomic cache write. Always create the dir first.
474
- // Mode 0o700 on the dir + 0o600 on files — owner-only access prevents
475
- // local information disclosure of cached grammars.
476
- // (Phase 3.5 finding #3 — file-mode hardening.)
477
- try {
478
- mkdirSync(dirname(cachePath), { recursive: true, mode: 0o700 });
479
- try { chmodSync(dirname(cachePath), 0o700); } catch { /* best effort */ }
480
- const tmpPath = `${cachePath}.tmp.${process.pid}`;
481
- writeFileSync(tmpPath, body, { mode: 0o600 });
482
- try { chmodSync(tmpPath, 0o600); } catch { /* best effort */ }
483
- try {
484
- renameSync(tmpPath, cachePath);
485
- try { chmodSync(cachePath, 0o600); } catch { /* best effort */ }
486
- } catch (e) {
487
- // Try to clean up the tmp file on rename failure
488
- try {
489
- unlinkSync(tmpPath);
490
- } catch {
491
- /* ignore */
492
- }
493
- throw e;
494
- }
495
- // F-011 LRU: prune cache to retain count after every successful write.
496
- // Best-effort — eviction failure never blocks a load.
497
- evictBeyondRetainCount();
498
- } catch (e) {
499
- // Cache write failure is non-fatal — we still have `body` in memory and
500
- // can load directly. Log to stderr per VR-USER-ERROR-MESSAGES style.
501
- console.error(
502
- `[tree-sitter-loader] cache write failed for ${language}: ${e instanceof Error ? e.message : String(e)} — loading directly from memory.`,
503
- );
504
- }
505
-
506
- const lang = await Language.load(body);
507
- loadedGrammars.set(language, lang);
508
- return lang;
509
- }
510
-
511
- /**
512
- * Test-only: clear in-memory loaded grammar cache. Disk cache persists.
513
- * Production code never needs this; the in-memory map lives for the process.
514
- */
515
- export function __resetLoadedGrammars(): void {
516
- loadedGrammars.clear();
517
- }