@trazum/core 1.8.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 (223) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/advisories.d.ts +23 -0
  4. package/dist/advisories.d.ts.map +1 -0
  5. package/dist/advisories.js +376 -0
  6. package/dist/advisories.js.map +1 -0
  7. package/dist/aws-sigv4.d.ts +88 -0
  8. package/dist/aws-sigv4.d.ts.map +1 -0
  9. package/dist/aws-sigv4.js +117 -0
  10. package/dist/aws-sigv4.js.map +1 -0
  11. package/dist/baseline.d.ts +171 -0
  12. package/dist/baseline.d.ts.map +1 -0
  13. package/dist/baseline.js +273 -0
  14. package/dist/baseline.js.map +1 -0
  15. package/dist/cache.d.ts +26 -0
  16. package/dist/cache.d.ts.map +1 -0
  17. package/dist/cache.js +28 -0
  18. package/dist/cache.js.map +1 -0
  19. package/dist/changes.d.ts +29 -0
  20. package/dist/changes.d.ts.map +1 -0
  21. package/dist/changes.js +142 -0
  22. package/dist/changes.js.map +1 -0
  23. package/dist/compare.d.ts +65 -0
  24. package/dist/compare.d.ts.map +1 -0
  25. package/dist/compare.js +58 -0
  26. package/dist/compare.js.map +1 -0
  27. package/dist/config-schema.d.ts +118 -0
  28. package/dist/config-schema.d.ts.map +1 -0
  29. package/dist/config-schema.js +315 -0
  30. package/dist/config-schema.js.map +1 -0
  31. package/dist/config.d.ts +47 -0
  32. package/dist/config.d.ts.map +1 -0
  33. package/dist/config.js +112 -0
  34. package/dist/config.js.map +1 -0
  35. package/dist/detect.d.ts +70 -0
  36. package/dist/detect.d.ts.map +1 -0
  37. package/dist/detect.js +228 -0
  38. package/dist/detect.js.map +1 -0
  39. package/dist/evaluate.d.ts +98 -0
  40. package/dist/evaluate.d.ts.map +1 -0
  41. package/dist/evaluate.js +110 -0
  42. package/dist/evaluate.js.map +1 -0
  43. package/dist/extract.d.ts +81 -0
  44. package/dist/extract.d.ts.map +1 -0
  45. package/dist/extract.js +280 -0
  46. package/dist/extract.js.map +1 -0
  47. package/dist/gcp-auth.d.ts +58 -0
  48. package/dist/gcp-auth.d.ts.map +1 -0
  49. package/dist/gcp-auth.js +113 -0
  50. package/dist/gcp-auth.js.map +1 -0
  51. package/dist/glob.d.ts +49 -0
  52. package/dist/glob.d.ts.map +1 -0
  53. package/dist/glob.js +154 -0
  54. package/dist/glob.js.map +1 -0
  55. package/dist/host.d.ts +30 -0
  56. package/dist/host.d.ts.map +1 -0
  57. package/dist/host.js +69 -0
  58. package/dist/host.js.map +1 -0
  59. package/dist/i18n/en.d.ts +4 -0
  60. package/dist/i18n/en.d.ts.map +1 -0
  61. package/dist/i18n/en.js +168 -0
  62. package/dist/i18n/en.js.map +1 -0
  63. package/dist/i18n/es.d.ts +4 -0
  64. package/dist/i18n/es.d.ts.map +1 -0
  65. package/dist/i18n/es.js +168 -0
  66. package/dist/i18n/es.js.map +1 -0
  67. package/dist/i18n/index.d.ts +36 -0
  68. package/dist/i18n/index.d.ts.map +1 -0
  69. package/dist/i18n/index.js +50 -0
  70. package/dist/i18n/index.js.map +1 -0
  71. package/dist/i18n/types.d.ts +180 -0
  72. package/dist/i18n/types.d.ts.map +1 -0
  73. package/dist/i18n/types.js +11 -0
  74. package/dist/i18n/types.js.map +1 -0
  75. package/dist/index.d.ts +66 -0
  76. package/dist/index.d.ts.map +1 -0
  77. package/dist/index.js +69 -0
  78. package/dist/index.js.map +1 -0
  79. package/dist/llm.d.ts +226 -0
  80. package/dist/llm.d.ts.map +1 -0
  81. package/dist/llm.js +485 -0
  82. package/dist/llm.js.map +1 -0
  83. package/dist/nearest.d.ts +20 -0
  84. package/dist/nearest.d.ts.map +1 -0
  85. package/dist/nearest.js +54 -0
  86. package/dist/nearest.js.map +1 -0
  87. package/dist/net.d.ts +90 -0
  88. package/dist/net.d.ts.map +1 -0
  89. package/dist/net.js +203 -0
  90. package/dist/net.js.map +1 -0
  91. package/dist/node.d.ts +32 -0
  92. package/dist/node.d.ts.map +1 -0
  93. package/dist/node.js +33 -0
  94. package/dist/node.js.map +1 -0
  95. package/dist/openrouter.d.ts +25 -0
  96. package/dist/openrouter.d.ts.map +1 -0
  97. package/dist/openrouter.js +72 -0
  98. package/dist/openrouter.js.map +1 -0
  99. package/dist/optimize.d.ts +38 -0
  100. package/dist/optimize.d.ts.map +1 -0
  101. package/dist/optimize.js +183 -0
  102. package/dist/optimize.js.map +1 -0
  103. package/dist/otlp.d.ts +91 -0
  104. package/dist/otlp.d.ts.map +1 -0
  105. package/dist/otlp.js +102 -0
  106. package/dist/otlp.js.map +1 -0
  107. package/dist/phrases.d.ts +169 -0
  108. package/dist/phrases.d.ts.map +1 -0
  109. package/dist/phrases.js +939 -0
  110. package/dist/phrases.js.map +1 -0
  111. package/dist/pricing-overlay.d.ts +55 -0
  112. package/dist/pricing-overlay.d.ts.map +1 -0
  113. package/dist/pricing-overlay.js +241 -0
  114. package/dist/pricing-overlay.js.map +1 -0
  115. package/dist/pricing.d.ts +115 -0
  116. package/dist/pricing.d.ts.map +1 -0
  117. package/dist/pricing.js +400 -0
  118. package/dist/pricing.js.map +1 -0
  119. package/dist/profile.d.ts +71 -0
  120. package/dist/profile.d.ts.map +1 -0
  121. package/dist/profile.js +55 -0
  122. package/dist/profile.js.map +1 -0
  123. package/dist/promptfoo.d.ts +58 -0
  124. package/dist/promptfoo.d.ts.map +1 -0
  125. package/dist/promptfoo.js +149 -0
  126. package/dist/promptfoo.js.map +1 -0
  127. package/dist/prune.d.ts +91 -0
  128. package/dist/prune.d.ts.map +1 -0
  129. package/dist/prune.js +110 -0
  130. package/dist/prune.js.map +1 -0
  131. package/dist/reorder.d.ts +82 -0
  132. package/dist/reorder.d.ts.map +1 -0
  133. package/dist/reorder.js +215 -0
  134. package/dist/reorder.js.map +1 -0
  135. package/dist/review.d.ts +54 -0
  136. package/dist/review.d.ts.map +1 -0
  137. package/dist/review.js +131 -0
  138. package/dist/review.js.map +1 -0
  139. package/dist/rules.d.ts +5 -0
  140. package/dist/rules.d.ts.map +1 -0
  141. package/dist/rules.js +279 -0
  142. package/dist/rules.js.map +1 -0
  143. package/dist/savings.d.ts +36 -0
  144. package/dist/savings.d.ts.map +1 -0
  145. package/dist/savings.js +83 -0
  146. package/dist/savings.js.map +1 -0
  147. package/dist/segment.d.ts +8 -0
  148. package/dist/segment.d.ts.map +1 -0
  149. package/dist/segment.js +74 -0
  150. package/dist/segment.js.map +1 -0
  151. package/dist/shared-prefix.d.ts +63 -0
  152. package/dist/shared-prefix.d.ts.map +1 -0
  153. package/dist/shared-prefix.js +151 -0
  154. package/dist/shared-prefix.js.map +1 -0
  155. package/dist/similarity.d.ts +13 -0
  156. package/dist/similarity.d.ts.map +1 -0
  157. package/dist/similarity.js +30 -0
  158. package/dist/similarity.js.map +1 -0
  159. package/dist/structure.d.ts +144 -0
  160. package/dist/structure.d.ts.map +1 -0
  161. package/dist/structure.js +455 -0
  162. package/dist/structure.js.map +1 -0
  163. package/dist/suggest.d.ts +100 -0
  164. package/dist/suggest.d.ts.map +1 -0
  165. package/dist/suggest.js +151 -0
  166. package/dist/suggest.js.map +1 -0
  167. package/dist/tokenizer.d.ts +57 -0
  168. package/dist/tokenizer.d.ts.map +1 -0
  169. package/dist/tokenizer.js +157 -0
  170. package/dist/tokenizer.js.map +1 -0
  171. package/dist/types.d.ts +296 -0
  172. package/dist/types.d.ts.map +1 -0
  173. package/dist/types.js +3 -0
  174. package/dist/types.js.map +1 -0
  175. package/dist/walk.d.ts +40 -0
  176. package/dist/walk.d.ts.map +1 -0
  177. package/dist/walk.js +95 -0
  178. package/dist/walk.js.map +1 -0
  179. package/package.json +56 -0
  180. package/src/advisories.ts +431 -0
  181. package/src/aws-sigv4.ts +174 -0
  182. package/src/baseline.ts +390 -0
  183. package/src/cache.ts +54 -0
  184. package/src/changes.ts +158 -0
  185. package/src/compare.ts +131 -0
  186. package/src/config-schema.ts +451 -0
  187. package/src/config.ts +161 -0
  188. package/src/detect.ts +312 -0
  189. package/src/evaluate.ts +188 -0
  190. package/src/extract.ts +336 -0
  191. package/src/gcp-auth.ts +166 -0
  192. package/src/glob.ts +160 -0
  193. package/src/host.ts +90 -0
  194. package/src/i18n/en.ts +236 -0
  195. package/src/i18n/es.ts +236 -0
  196. package/src/i18n/index.ts +68 -0
  197. package/src/i18n/types.ts +230 -0
  198. package/src/index.ts +228 -0
  199. package/src/llm.ts +708 -0
  200. package/src/nearest.ts +61 -0
  201. package/src/net.ts +233 -0
  202. package/src/node.ts +63 -0
  203. package/src/openrouter.ts +125 -0
  204. package/src/optimize.ts +228 -0
  205. package/src/otlp.ts +179 -0
  206. package/src/phrases.ts +1047 -0
  207. package/src/pricing-overlay.ts +319 -0
  208. package/src/pricing.ts +468 -0
  209. package/src/profile.ts +124 -0
  210. package/src/promptfoo.ts +213 -0
  211. package/src/prune.ts +211 -0
  212. package/src/reorder.ts +307 -0
  213. package/src/review.ts +180 -0
  214. package/src/rules.ts +324 -0
  215. package/src/savings.ts +121 -0
  216. package/src/segment.ts +106 -0
  217. package/src/shared-prefix.ts +198 -0
  218. package/src/similarity.ts +28 -0
  219. package/src/structure.ts +652 -0
  220. package/src/suggest.ts +254 -0
  221. package/src/tokenizer.ts +190 -0
  222. package/src/types.ts +323 -0
  223. package/src/walk.ts +117 -0
package/src/extract.ts ADDED
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Prompts embedded in source files.
3
+ *
4
+ * `check` and `diff` read `.txt`, `.md`, `.prompt` and `.tmpl`. Real prompts live
5
+ * in TypeScript template literals, Python triple-quoted strings and YAML blocks,
6
+ * so adopting Trazum has meant first refactoring them out into standalone files —
7
+ * a change to somebody's application as the price of admission.
8
+ *
9
+ * **It reads a marker, it does not guess.** `// trazum:prompt` on the line before
10
+ * the literal, and then the literal by delimiter matching. Guessing which string
11
+ * in a file is a prompt is a heuristic, and a heuristic inside a tool used as a
12
+ * CI gate produces failures on strings nobody meant to govern. A marker is one
13
+ * line of noise in exchange for never being wrong about what it picked up.
14
+ *
15
+ * **Interpolation already works and is not a special case.** `${x}` inside a
16
+ * template literal is exactly the placeholder shape `segment.ts` protects, so an
17
+ * embedded prompt gets the same cache-prefix analysis, the same protection from
18
+ * the rules, and the same `--reorder` treatment as a `{{x}}` template. Nothing
19
+ * here has to know about it.
20
+ *
21
+ * **The honest limit:** a prompt assembled from concatenated pieces cannot be
22
+ * read this way. `` `You are ${role}.` + rules.join('\n') `` is a prompt whose
23
+ * text does not exist until it runs. This module declines it and says so rather
24
+ * than governing the half it can see — a budget enforced against a fragment is a
25
+ * green build for a prompt nobody measured.
26
+ *
27
+ * Scanned character by character rather than with a regex. The module it most
28
+ * resembles shipped two quadratic patterns this week, and delimiter matching over
29
+ * untrusted source is exactly the shape that goes wrong.
30
+ */
31
+
32
+ export interface ExtractedPrompt {
33
+ /** Name from `trazum:prompt <name>`, or undefined when the marker was bare. */
34
+ name?: string;
35
+ /** 1-based line of the marker comment, which is what an error should cite. */
36
+ line: number;
37
+ /** The prompt text, delimiters removed and escapes resolved. */
38
+ text: string;
39
+ /** The delimiter it was written with, so a writer could put it back. */
40
+ quote: '`' | '"' | "'" | '"""' | "'''";
41
+ /** Offsets of the text within the source, exclusive of the delimiters. */
42
+ start: number;
43
+ end: number;
44
+ }
45
+
46
+ export interface DeclinedPrompt {
47
+ line: number;
48
+ /**
49
+ * Why this marker produced nothing.
50
+ *
51
+ * `concatenated` is the interesting one: the text exists only at runtime.
52
+ * `no-literal` means the marker was not followed by a string at all.
53
+ * `unterminated` means the file ends inside the literal, which is a syntax
54
+ * error in the source rather than a Trazum problem, but worth naming.
55
+ */
56
+ reason: 'concatenated' | 'no-literal' | 'unterminated';
57
+ detail: string;
58
+ }
59
+
60
+ export interface ExtractionResult {
61
+ prompts: ExtractedPrompt[];
62
+ declined: DeclinedPrompt[];
63
+ }
64
+
65
+ /** The marker, in the comment syntaxes that cover the languages prompts live in. */
66
+ const MARKERS = ['//', '#', '--', '<!--'];
67
+ const TAG = 'trazum:prompt';
68
+
69
+ /**
70
+ * Both ways an HTML comment can close.
71
+ *
72
+ * `--!>` is the "comment end bang" the HTML parser also accepts, and stripping
73
+ * only `-->` put it in the name: `<!-- trazum:prompt greeting--!>` produced the
74
+ * name `greeting--!>`. Found by CodeQL, which is the second time this week a
75
+ * pattern has been right about the case in front of it and wrong about the one
76
+ * beside it.
77
+ */
78
+ const COMMENT_CLOSE = /--!?>\s*$/;
79
+
80
+ /**
81
+ * What a name may be.
82
+ *
83
+ * An identifier charset rather than "whatever is left on the line". The name
84
+ * flows into `promptId`, which is printed in reports and matched against the
85
+ * budget patterns in `trazum.config.json` — a name is an identifier, and letting
86
+ * it be arbitrary text is how a stray terminator became part of one above. A
87
+ * candidate that does not fit falls back to the `file:line` form, which always
88
+ * works.
89
+ */
90
+ const NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
91
+
92
+ /**
93
+ * How far past the marker a literal may start.
94
+ *
95
+ * A prompt is expected on the next line or two — `const SYSTEM = \`` and the
96
+ * occasional type annotation. Scanning further would let a marker attach itself
97
+ * to an unrelated string much later in the file, which is the false positive this
98
+ * design exists to avoid.
99
+ */
100
+ const MAX_GAP_LINES = 3;
101
+
102
+ /**
103
+ * A forward-only line counter.
104
+ *
105
+ * Markers are found in increasing order, so each character is counted once
106
+ * across the whole scan. Recomputing the line by counting from zero at every
107
+ * marker is the obvious version and is quadratic in the number of markers —
108
+ * 15.5 seconds on a file of 20,000 of them, which the hostile-input tests
109
+ * caught before this shipped.
110
+ */
111
+ function lineCounter(source: string): (offset: number) => number {
112
+ let seen = 0;
113
+ let line = 1;
114
+ return (offset) => {
115
+ for (; seen < offset && seen < source.length; seen++) {
116
+ if (source[seen] === '\n') line++;
117
+ }
118
+ return line;
119
+ };
120
+ }
121
+
122
+ const ESCAPES: Record<string, string> = {
123
+ n: '\n',
124
+ t: '\t',
125
+ r: '\r',
126
+ '\\': '\\',
127
+ '"': '"',
128
+ "'": "'",
129
+ '`': '`',
130
+ '0': '\0',
131
+ };
132
+
133
+ /**
134
+ * Reads a string literal starting at `start`, which must be its opening quote.
135
+ *
136
+ * Returns the resolved text and the offset just past the closing delimiter, or
137
+ * null when the literal never closes. Escapes are resolved for single- and
138
+ * double-quoted strings, where `\n` is one newline written as two characters;
139
+ * backtick and triple-quoted literals carry their newlines literally and only
140
+ * need the delimiter itself unescaped.
141
+ */
142
+ function readLiteral(
143
+ source: string,
144
+ start: number,
145
+ ): { text: string; quote: ExtractedPrompt['quote']; end: number; textStart: number } | null {
146
+ const triple = source.slice(start, start + 3);
147
+ const quote: ExtractedPrompt['quote'] | null =
148
+ triple === '"""' || triple === "'''"
149
+ ? (triple as '"""' | "'''")
150
+ : source[start] === '`' || source[start] === '"' || source[start] === "'"
151
+ ? (source[start] as '`' | '"' | "'")
152
+ : null;
153
+ if (quote === null) return null;
154
+
155
+ const raw = quote === '`' || quote.length === 3;
156
+ const textStart = start + quote.length;
157
+ let out = '';
158
+ let i = textStart;
159
+
160
+ while (i < source.length) {
161
+ const ch = source[i]!;
162
+
163
+ if (ch === '\\') {
164
+ const next = source[i + 1];
165
+ if (next === undefined) break;
166
+ // In a raw literal only the delimiter and the backslash itself are
167
+ // escapes; `\n` there is a literal backslash followed by an n, and
168
+ // resolving it would invent a newline the prompt does not contain.
169
+ if (raw) {
170
+ out += next === quote[0] || next === '\\' ? next : `\\${next}`;
171
+ } else {
172
+ out += ESCAPES[next] ?? `\\${next}`;
173
+ }
174
+ i += 2;
175
+ continue;
176
+ }
177
+
178
+ if (source.startsWith(quote, i)) {
179
+ return { text: out, quote, end: i + quote.length, textStart };
180
+ }
181
+
182
+ // A single-quoted or double-quoted literal cannot span lines. Stopping here
183
+ // rather than running to the end of the file is what keeps a missing quote
184
+ // from swallowing the rest of the source.
185
+ if (quote.length === 1 && quote !== '`' && ch === '\n') return null;
186
+
187
+ out += ch;
188
+ i++;
189
+ }
190
+
191
+ return null;
192
+ }
193
+
194
+ /** Whether the expression continues past the literal with a concatenation. */
195
+ function isConcatenated(source: string, end: number): boolean {
196
+ let i = end;
197
+ while (i < source.length && (source[i] === ' ' || source[i] === '\t')) i++;
198
+ // `+` in JS/TS, `.` in PHP-ish sources, and a bare newline followed by `+` is
199
+ // the wrapped form of the same thing. A trailing comma or semicolon is not.
200
+ if (source[i] === '+') return true;
201
+ if (source[i] === '\n') {
202
+ let j = i + 1;
203
+ while (j < source.length && /[ \t\n]/.test(source[j]!)) j++;
204
+ return source[j] === '+';
205
+ }
206
+ return false;
207
+ }
208
+
209
+ /**
210
+ * Finds every marked prompt in a source file.
211
+ *
212
+ * Returns what it found and what it refused, because a marker that produced
213
+ * nothing is the case the author most needs to hear about: they asked for the
214
+ * prompt to be governed and it is not being governed.
215
+ */
216
+ export function extractPrompts(source: string): ExtractionResult {
217
+ const prompts: ExtractedPrompt[] = [];
218
+ const declined: DeclinedPrompt[] = [];
219
+
220
+ const lineAt = lineCounter(source);
221
+
222
+ let cursor = 0;
223
+ while (cursor < source.length) {
224
+ const at = source.indexOf(TAG, cursor);
225
+ if (at === -1) break;
226
+ cursor = at + TAG.length;
227
+
228
+ // The tag has to be inside a comment. Without this, the string 'trazum:prompt'
229
+ // appearing in a prompt's own text would mark the prompt after it.
230
+ const lineStart = source.lastIndexOf('\n', at) + 1;
231
+ const before = source.slice(lineStart, at).trimStart();
232
+ if (!MARKERS.some((m) => before.startsWith(m))) continue;
233
+
234
+ const markerLine = lineAt(at);
235
+
236
+ // The rest of the marker line is an optional name: `trazum:prompt greeting`.
237
+ const lineEnd = source.indexOf('\n', at);
238
+ const rest = source.slice(cursor, lineEnd === -1 ? source.length : lineEnd);
239
+ const candidate = rest.replace(COMMENT_CLOSE, '').trim().split(/\s+/)[0] ?? '';
240
+ const name = NAME.test(candidate) ? candidate : undefined;
241
+
242
+ // Scan forward for the opening delimiter, bounded so a marker cannot adopt
243
+ // a string much further down the file.
244
+ let i = lineEnd === -1 ? source.length : lineEnd + 1;
245
+ let lines = 0;
246
+ let opened: ReturnType<typeof readLiteral> = null;
247
+ let openAt = -1;
248
+
249
+ while (i < source.length && lines <= MAX_GAP_LINES) {
250
+ const ch = source[i]!;
251
+ if (ch === '\n') {
252
+ lines++;
253
+ i++;
254
+ continue;
255
+ }
256
+ if (ch === '`' || ch === '"' || ch === "'") {
257
+ openAt = i;
258
+ opened = readLiteral(source, i);
259
+ break;
260
+ }
261
+ i++;
262
+ }
263
+
264
+ if (openAt === -1) {
265
+ declined.push({
266
+ line: markerLine,
267
+ reason: 'no-literal',
268
+ detail: `no string literal within ${MAX_GAP_LINES} lines of the marker`,
269
+ });
270
+ continue;
271
+ }
272
+ if (opened === null) {
273
+ declined.push({
274
+ line: markerLine,
275
+ reason: 'unterminated',
276
+ detail: 'the literal is never closed',
277
+ });
278
+ continue;
279
+ }
280
+ if (isConcatenated(source, opened.end)) {
281
+ declined.push({
282
+ line: markerLine,
283
+ reason: 'concatenated',
284
+ detail:
285
+ 'the prompt is built by concatenation, so its text does not exist until it runs',
286
+ });
287
+ cursor = opened.end;
288
+ continue;
289
+ }
290
+
291
+ prompts.push({
292
+ name,
293
+ line: markerLine,
294
+ text: opened.text,
295
+ quote: opened.quote,
296
+ start: opened.textStart,
297
+ end: opened.end - opened.quote.length,
298
+ });
299
+ cursor = opened.end;
300
+ }
301
+
302
+ return { prompts, declined };
303
+ }
304
+
305
+ /**
306
+ * A stable identifier for an embedded prompt, for budgets and reports.
307
+ *
308
+ * `src/prompts.ts#support` when the marker was named, `src/prompts.ts:12` when it
309
+ * was not. Both are path-prefixed so the glob patterns in `trazum.config.json`
310
+ * cover embedded prompts without learning a new syntax — `src/**` matches either.
311
+ */
312
+ export function promptId(path: string, prompt: ExtractedPrompt): string {
313
+ return prompt.name ? `${path}#${prompt.name}` : `${path}:${prompt.line}`;
314
+ }
315
+
316
+ /** Whether a file is worth opening for extraction at all. */
317
+ export const SOURCE_EXTENSIONS = [
318
+ '.ts',
319
+ '.tsx',
320
+ '.js',
321
+ '.jsx',
322
+ '.mjs',
323
+ '.py',
324
+ '.rb',
325
+ '.go',
326
+ '.rs',
327
+ '.java',
328
+ '.kt',
329
+ '.cs',
330
+ '.php',
331
+ '.yaml',
332
+ '.yml',
333
+ ];
334
+
335
+ /** Cheap pre-filter: a source file with no marker has nothing to extract. */
336
+ export const hasMarker = (source: string): boolean => source.includes(TAG);
@@ -0,0 +1,166 @@
1
+ /**
2
+ * A Google service-account access token, without the SDK.
3
+ *
4
+ * Vertex will not take an API key. It wants an OAuth access token, and the only
5
+ * way to get one unattended is the two-legged flow: build a JWT, sign it with
6
+ * the service account's private key, and trade it at the token endpoint.
7
+ * `google-auth-library` is ninety-odd packages to do that; this library has zero
8
+ * runtime dependencies and a test that fails the build if one appears, because
9
+ * every dependency is somebody else's code reading your prompts.
10
+ *
11
+ * WebCrypto rather than `node:crypto`, so the browser-safe entry point stays
12
+ * browser-safe. `RSASSA-PKCS1-v1_5` with SHA-256 is what Google calls RS256.
13
+ */
14
+
15
+ /** Base64url, no padding — what a JWT uses and what `btoa` does not produce. */
16
+ function base64url(bytes: Uint8Array): string {
17
+ let binary = '';
18
+ for (const byte of bytes) binary += String.fromCharCode(byte);
19
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
20
+ }
21
+
22
+ const encoder = new TextEncoder();
23
+
24
+ const encodeJson = (value: unknown): string => base64url(encoder.encode(JSON.stringify(value)));
25
+
26
+ /**
27
+ * PEM to the DER bytes `importKey` wants.
28
+ *
29
+ * The header, the footer and every newline come out. A PEM that still has them
30
+ * fails inside WebCrypto with `DataError`, which says nothing about which of the
31
+ * fourteen things that can be wrong with a key is wrong with this one.
32
+ */
33
+ export function pkcs8FromPem(pem: string): ArrayBuffer {
34
+ const body = pem
35
+ .replace(/-----BEGIN [^-]+-----/, '')
36
+ .replace(/-----END [^-]+-----/, '')
37
+ .replace(/\s+/g, '');
38
+ if (!body) throw new Error('The service account private key is empty.');
39
+
40
+ let binary: string;
41
+ try {
42
+ binary = atob(body);
43
+ } catch {
44
+ throw new Error('The service account private key is not valid base64.');
45
+ }
46
+ // Returned as an ArrayBuffer rather than a view: `importKey` accepts a
47
+ // `BufferSource`, and a `Uint8Array` over a `SharedArrayBuffer` is not one
48
+ // as far as the type checker is concerned.
49
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0)).buffer as ArrayBuffer;
50
+ }
51
+
52
+ export interface ServiceAccount {
53
+ client_email: string;
54
+ private_key: string;
55
+ token_uri?: string;
56
+ }
57
+
58
+ /**
59
+ * The assertion Google trades for a token.
60
+ *
61
+ * Exported so a test can read it: this is the document where a wrong `aud`, a
62
+ * clock an hour out, or a scope nobody granted turns into `invalid_grant`, an
63
+ * error message that names none of the three.
64
+ */
65
+ export async function signedJwt(
66
+ account: ServiceAccount,
67
+ scope: string,
68
+ now: Date,
69
+ ): Promise<string> {
70
+ const tokenUri = account.token_uri ?? 'https://oauth2.googleapis.com/token';
71
+ const issued = Math.floor(now.getTime() / 1000);
72
+
73
+ const header = encodeJson({ alg: 'RS256', typ: 'JWT' });
74
+ const claims = encodeJson({
75
+ iss: account.client_email,
76
+ scope,
77
+ aud: tokenUri,
78
+ iat: issued,
79
+ // An hour is Google's maximum. Longer is rejected outright rather than
80
+ // clamped, which is a confusing way to learn about a limit.
81
+ exp: issued + 3600,
82
+ });
83
+
84
+ const key = await crypto.subtle.importKey(
85
+ 'pkcs8',
86
+ pkcs8FromPem(account.private_key),
87
+ { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
88
+ false,
89
+ ['sign'],
90
+ );
91
+
92
+ const signature = await crypto.subtle.sign(
93
+ 'RSASSA-PKCS1-v1_5',
94
+ key,
95
+ encoder.encode(`${header}.${claims}`),
96
+ );
97
+
98
+ return `${header}.${claims}.${base64url(new Uint8Array(signature))}`;
99
+ }
100
+
101
+ export interface CachedToken {
102
+ token: string;
103
+ /** Epoch seconds. */
104
+ expiresAt: number;
105
+ }
106
+
107
+ /**
108
+ * Trades the assertion for an access token, caching until shortly before expiry.
109
+ *
110
+ * The cache is the point. A token lasts an hour and `optimize --suggest` over a
111
+ * directory makes one call per prompt: fetching a token each time turns forty
112
+ * prompts into eighty requests, half of them to an endpoint that rate-limits.
113
+ *
114
+ * Sixty seconds of margin, because a token that expires in flight fails the
115
+ * request it was fetched for, and the clock here is not the clock there.
116
+ */
117
+ export async function accessToken(
118
+ account: ServiceAccount,
119
+ options: {
120
+ scope?: string;
121
+ fetchImpl?: typeof fetch;
122
+ now?: () => Date;
123
+ cache?: { current: CachedToken | null };
124
+ } = {},
125
+ ): Promise<string> {
126
+ const {
127
+ scope = 'https://www.googleapis.com/auth/cloud-platform',
128
+ fetchImpl = fetch,
129
+ now = () => new Date(),
130
+ cache,
131
+ } = options;
132
+
133
+ const at = now();
134
+ const seconds = Math.floor(at.getTime() / 1000);
135
+
136
+ if (cache?.current && cache.current.expiresAt - 60 > seconds) {
137
+ return cache.current.token;
138
+ }
139
+
140
+ const assertion = await signedJwt(account, scope, at);
141
+ const tokenUri = account.token_uri ?? 'https://oauth2.googleapis.com/token';
142
+
143
+ const res = await fetchImpl(tokenUri, {
144
+ method: 'POST',
145
+ redirect: 'error',
146
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
147
+ body: new URLSearchParams({
148
+ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
149
+ assertion,
150
+ }).toString(),
151
+ });
152
+
153
+ if (!res.ok) {
154
+ throw new Error(`Google refused the service account assertion (${res.status}): ${await res.text()}`);
155
+ }
156
+
157
+ const data = (await res.json()) as { access_token?: unknown; expires_in?: unknown };
158
+ if (typeof data.access_token !== 'string' || !data.access_token) {
159
+ throw new Error('Google returned no access_token for the service account.');
160
+ }
161
+
162
+ const lifetime = typeof data.expires_in === 'number' ? data.expires_in : 3600;
163
+ if (cache) cache.current = { token: data.access_token, expiresAt: seconds + lifetime };
164
+
165
+ return data.access_token;
166
+ }
package/src/glob.ts ADDED
@@ -0,0 +1,160 @@
1
+ /**
2
+ * A small, bounded glob matcher.
3
+ *
4
+ * Trazum has zero runtime dependencies and that is a security property, not a
5
+ * preference: a prompt optimiser reads your prompts, and every dependency is
6
+ * someone else's code doing that too. So the config file's budget patterns are
7
+ * matched here rather than by pulling in a glob library.
8
+ *
9
+ * **It is deliberately not a regex translation.** Turning `**` into
10
+ * `(?:[^/]*\/)*` produces exactly the nested-quantifier shape that makes a
11
+ * pattern take exponential time on the wrong input, and these patterns come
12
+ * from a file in a repository — which on a pull request means from whoever
13
+ * opened it. A segment-wise dynamic program has no such shape: it is O(pattern
14
+ * segments x path segments), and each segment match is O(pattern chars x path
15
+ * chars) with no backtracking beyond a single remembered star.
16
+ *
17
+ * Supported, and nothing else:
18
+ * `*` any run of characters within one path segment
19
+ * `**` any number of whole path segments, including none
20
+ * `?` exactly one character within one path segment
21
+ */
22
+
23
+ /**
24
+ * Longest pattern and path this will consider.
25
+ *
26
+ * The matcher is quadratic in both, so a pattern nobody would write by hand is
27
+ * declined rather than allowed to spend the CI runner's afternoon. Both limits
28
+ * are far above any real prompt path.
29
+ */
30
+ const MAX_PATTERN_LENGTH = 1024;
31
+ const MAX_PATH_LENGTH = 4096;
32
+
33
+ /** Normalises separators and strips the noise a hand-written path collects. */
34
+ function toSegments(value: string): string[] {
35
+ return value
36
+ .replace(/\\/g, '/')
37
+ .split('/')
38
+ .filter((segment) => segment.length > 0 && segment !== '.');
39
+ }
40
+
41
+ /**
42
+ * Matches one path segment against one pattern segment (`*` and `?` only).
43
+ *
44
+ * Linear-with-backtracking over a single remembered star, which is the classic
45
+ * bounded algorithm: on a mismatch it retreats to the last star and advances
46
+ * the input by one, so the work is O(pattern x input) and never exponential.
47
+ */
48
+ function matchSegment(pattern: string, value: string): boolean {
49
+ let p = 0;
50
+ let v = 0;
51
+ let star = -1;
52
+ let mark = 0;
53
+
54
+ while (v < value.length) {
55
+ if (p < pattern.length && (pattern[p] === '?' || pattern[p] === value[v])) {
56
+ p++;
57
+ v++;
58
+ } else if (p < pattern.length && pattern[p] === '*') {
59
+ star = p++;
60
+ mark = v;
61
+ } else if (star >= 0) {
62
+ p = star + 1;
63
+ v = ++mark;
64
+ } else {
65
+ return false;
66
+ }
67
+ }
68
+
69
+ while (p < pattern.length && pattern[p] === '*') p++;
70
+ return p === pattern.length;
71
+ }
72
+
73
+ /**
74
+ * True when `path` matches `pattern`.
75
+ *
76
+ * Both sides are treated as relative paths with `/` separators; a leading `./`
77
+ * and duplicate separators are ignored, and backslashes are read as separators
78
+ * so a Windows-shaped path still matches a pattern written with slashes.
79
+ */
80
+ export function matchGlob(pattern: string, path: string): boolean {
81
+ if (pattern.length > MAX_PATTERN_LENGTH || path.length > MAX_PATH_LENGTH) return false;
82
+
83
+ const p = toSegments(pattern);
84
+ const s = toSegments(path);
85
+
86
+ // reachable[j] === true means "the first j pattern segments can consume the
87
+ // path segments seen so far". Walking the path once and updating this row is
88
+ // the whole algorithm; `**` is the only segment that can stay put.
89
+ let reachable = new Array<boolean>(p.length + 1).fill(false);
90
+ reachable[0] = true;
91
+ // Leading `**` segments match nothing at all, so they are reachable up front.
92
+ for (let j = 0; j < p.length && p[j] === '**'; j++) reachable[j + 1] = true;
93
+
94
+ for (const segment of s) {
95
+ const next = new Array<boolean>(p.length + 1).fill(false);
96
+ for (let j = 0; j < p.length; j++) {
97
+ if (!reachable[j] && !(p[j] === '**' && next[j])) continue;
98
+ if (p[j] === '**') {
99
+ // Consume this segment and stay on the same `**`, or step past it.
100
+ next[j] = true;
101
+ next[j + 1] = true;
102
+ } else if (matchSegment(p[j]!, segment)) {
103
+ next[j + 1] = true;
104
+ }
105
+ }
106
+ // A `**` that has just been stepped past may be followed by another one,
107
+ // which can also match nothing. Propagate that before moving on.
108
+ for (let j = 0; j < p.length; j++) {
109
+ if (next[j] && p[j] === '**') next[j + 1] = true;
110
+ }
111
+ reachable = next;
112
+ }
113
+
114
+ return reachable[p.length] === true;
115
+ }
116
+
117
+ /**
118
+ * How specific a pattern is, for deciding which of two matching patterns wins.
119
+ *
120
+ * "Most specific" needs a definition that is stated rather than felt, because
121
+ * a budget silently resolved from the wrong pattern is a budget nobody can
122
+ * debug. The rule: **more literal characters wins**, and a longer pattern
123
+ * breaks a tie. So `prompts/system.txt` beats `prompts/*.txt`, which beats
124
+ * `prompts/**`, which beats `**`.
125
+ */
126
+ export function specificity(pattern: string): number {
127
+ const literals = pattern.replace(/[*?]/g, '').length;
128
+ // The multiplier has to exceed every possible tie-breaker value, or the
129
+ // primary rule stops holding: at a multiplier of 1,000 a pattern with one
130
+ // literal and 1,024 characters scores the same as one with two literals and
131
+ // 24, and the stated "more literals wins" quietly becomes a coin toss.
132
+ return literals * (MAX_PATTERN_LENGTH + 1) + pattern.length;
133
+ }
134
+
135
+ /**
136
+ * The most specific pattern in `patterns` that matches `path`, or null.
137
+ *
138
+ * Ties on specificity are broken by lexical order rather than by insertion
139
+ * order: object key order is easy to reorder by accident, and a budget that
140
+ * changes because two keys swapped places is the kind of bug that gets blamed
141
+ * on the tool.
142
+ */
143
+ export function mostSpecificMatch(
144
+ patterns: readonly string[],
145
+ path: string,
146
+ ): string | null {
147
+ let best: string | null = null;
148
+ let bestScore = -1;
149
+
150
+ for (const pattern of patterns) {
151
+ if (!matchGlob(pattern, path)) continue;
152
+ const score = specificity(pattern);
153
+ if (score > bestScore || (score === bestScore && best !== null && pattern < best)) {
154
+ best = pattern;
155
+ bestScore = score;
156
+ }
157
+ }
158
+
159
+ return best;
160
+ }