@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/compare.ts ADDED
@@ -0,0 +1,131 @@
1
+ import { optimize } from './optimize.js';
2
+ import { computeSavings } from './savings.js';
3
+ import type { AdvisoryId, RuleId } from './types.js';
4
+ import type { OptimizeOptions, UsageProfile } from './types.js';
5
+
6
+ /**
7
+ * Comparing two versions of a prompt.
8
+ *
9
+ * `optimize()` answers "how much fat is in this prompt". This answers a
10
+ * different question, and the one a pull request actually raises: "somebody
11
+ * edited this — did it get worse?"
12
+ *
13
+ * Sign convention, stated once and held everywhere: **every number here is a
14
+ * delta, `after - before`, and positive means the change made things worse.**
15
+ * That is deliberately the opposite of the rest of the codebase, where every
16
+ * figure is a saving. Mixing the two conventions in one report is the single
17
+ * easiest way to make a cost tool lie, so nothing in this module is called a
18
+ * saving and nothing reuses that name.
19
+ */
20
+
21
+ export interface RuleDelta {
22
+ /** Rules that fire on the new version but did not on the old one. */
23
+ newlyFiring: RuleId[];
24
+ /** Rules that fired on the old version and no longer do. */
25
+ noLongerFiring: RuleId[];
26
+ }
27
+
28
+ export interface AdvisoryDelta {
29
+ /** Advisories the new version raises that the old one did not. */
30
+ appeared: AdvisoryId[];
31
+ /** Advisories the old version raised that the new one has resolved. */
32
+ resolved: AdvisoryId[];
33
+ }
34
+
35
+ export interface PromptComparison {
36
+ tokensBefore: number;
37
+ tokensAfter: number;
38
+ /** `after - before`. Positive means the prompt grew. */
39
+ tokenDelta: number;
40
+ /** The delta as a percentage of the old size. 0 when the old size was 0. */
41
+ deltaPct: number;
42
+ /** Monthly cost change in USD. Positive means it now costs more. */
43
+ monthlyDeltaUsd: number;
44
+ /** Per-call cost change in USD. Positive means it now costs more. */
45
+ perCallDeltaUsd: number;
46
+ rules: RuleDelta;
47
+ advisories: AdvisoryDelta;
48
+ usage: UsageProfile;
49
+ }
50
+
51
+ export interface CompareOptions extends OptimizeOptions {
52
+ /**
53
+ * Compare what the rules would leave, rather than what was written.
54
+ *
55
+ * Off by default, and that default matters: a pull request changed the file
56
+ * on disk, so the file on disk is what the reviewer is being asked about.
57
+ * Optimising both sides first would hide a prompt that doubled in length but
58
+ * happened to double in courtesy, which is exactly the change worth seeing.
59
+ */
60
+ optimizeBoth?: boolean;
61
+ }
62
+
63
+ const difference = <T>(a: readonly T[], b: readonly T[]): T[] => {
64
+ const inB = new Set(b);
65
+ return a.filter((item) => !inB.has(item));
66
+ };
67
+
68
+ /**
69
+ * Collapses negative zero.
70
+ *
71
+ * Negating a saving of zero produces `-0`, which is arithmetically fine and
72
+ * renders as `-$0` in a cost report — a change that did not happen, shown with
73
+ * a direction.
74
+ */
75
+ const noNegativeZero = (value: number): number => (value === 0 ? 0 : value);
76
+
77
+ /**
78
+ * Compares two prompt versions.
79
+ *
80
+ * Both sides go through `optimize()` regardless, because that is what produces
81
+ * the rule and advisory findings — but by default the *token and cost figures*
82
+ * come from the text as written. `optimizeBoth` switches the figures to the
83
+ * optimised text, for a team that runs Trazum in their pipeline and cares
84
+ * about what actually reaches the model.
85
+ */
86
+ export function comparePrompts(
87
+ before: string,
88
+ after: string,
89
+ options: CompareOptions = {},
90
+ ): PromptComparison {
91
+ const { optimizeBoth = false, ...optimizeOptions } = options;
92
+
93
+ const beforeResult = optimize(before, optimizeOptions);
94
+ const afterResult = optimize(after, optimizeOptions);
95
+
96
+ const tokensBefore = optimizeBoth ? beforeResult.tokensAfter : beforeResult.tokensBefore;
97
+ const tokensAfter = optimizeBoth ? afterResult.tokensAfter : afterResult.tokensBefore;
98
+
99
+ const tokenDelta = tokensAfter - tokensBefore;
100
+ const usage = afterResult.usage;
101
+
102
+ // computeSavings is before-minus-after, so its "saving" is our delta with
103
+ // the sign flipped. Negating once, here, is what keeps the convention above
104
+ // true everywhere else.
105
+ const savings = computeSavings(tokensBefore, tokensAfter, usage);
106
+
107
+ const beforeRules = beforeResult.rules.map((r) => r.id);
108
+ const afterRules = afterResult.rules.map((r) => r.id);
109
+ const beforeAdvisories = beforeResult.advisories.map((a) => a.id);
110
+ const afterAdvisories = afterResult.advisories.map((a) => a.id);
111
+
112
+ return {
113
+ tokensBefore,
114
+ tokensAfter,
115
+ tokenDelta,
116
+ deltaPct: tokensBefore > 0 ? (tokenDelta / tokensBefore) * 100 : 0,
117
+ monthlyDeltaUsd: noNegativeZero(-savings.monthlySavingsUsd),
118
+ perCallDeltaUsd: noNegativeZero(
119
+ savings.perCall.after.totalUsd - savings.perCall.before.totalUsd,
120
+ ),
121
+ rules: {
122
+ newlyFiring: difference(afterRules, beforeRules),
123
+ noLongerFiring: difference(beforeRules, afterRules),
124
+ },
125
+ advisories: {
126
+ appeared: difference(afterAdvisories, beforeAdvisories),
127
+ resolved: difference(beforeAdvisories, afterAdvisories),
128
+ },
129
+ usage,
130
+ };
131
+ }
@@ -0,0 +1,451 @@
1
+ import { BASELINE_FILENAME } from './baseline.js';
2
+ import { mostSpecificMatch } from './glob.js';
3
+ import type { PricingCatalogue } from './pricing.js';
4
+ import { isLocale } from './i18n/index.js';
5
+ import { nearestName } from './nearest.js';
6
+ import { RULES } from './rules.js';
7
+ import type { Locale } from './i18n/index.js';
8
+ import type { RuleId, RuleLevel, UsageProfile } from './types.js';
9
+
10
+ /**
11
+ * The shape and validation of `trazum.config.json`. No filesystem access — the
12
+ * loader that reads the file lives in `config.ts`, which is only reachable via
13
+ * `@trazum/core/node`. Keeping the schema pure is what lets the browser bundle
14
+ * import the types and key lists without dragging `node:fs` into its graph.
15
+ *
16
+ * The problem this solves is small and real: every command in a repository's CI
17
+ * repeats the same four flags, and the one place they get out of step is the
18
+ * place the numbers stop meaning anything.
19
+ *
20
+ * **Every validation failure here throws.** That is the whole design. A config
21
+ * file is trusted to carry a budget, and the failure mode of a lenient parser
22
+ * is a typo'd key silently ignored, defaults quietly restored, and a green
23
+ * build for a prompt nobody measured. An unreadable config is a loud error; a
24
+ * config that half-applies is a lie.
25
+ */
26
+
27
+ export const CONFIG_FILENAME = 'trazum.config.json';
28
+
29
+ /**
30
+ * Largest config file this will read.
31
+ *
32
+ * A config is a couple of dozen lines. This exists so a hostile or accidental
33
+ * multi-megabyte file is refused before `JSON.parse` is handed the whole thing.
34
+ */
35
+ export const MAX_CONFIG_BYTES = 64 * 1024;
36
+
37
+ /** How far up the tree the search for a config file will walk. */
38
+ export const MAX_CONFIG_SEARCH_DEPTH = 32;
39
+
40
+ export interface BaselineConfig {
41
+ /** Path to the baseline file, relative to the config. */
42
+ path: string;
43
+ /**
44
+ * Thresholds. At least one is required — see `parseBaselineConfig` for why a
45
+ * baseline with no threshold is a configuration error rather than a default.
46
+ */
47
+ maxGrowthTokens?: number;
48
+ maxGrowthPct?: number;
49
+ }
50
+
51
+ export interface TrazumConfig {
52
+ level?: RuleLevel;
53
+ locale?: Locale;
54
+ disable?: RuleId[];
55
+ usage?: Partial<UsageProfile>;
56
+ /**
57
+ * Token budgets by glob pattern. The most specific matching pattern wins —
58
+ * see `mostSpecificMatch`, which states what "specific" means rather than
59
+ * leaving it to be inferred.
60
+ */
61
+ budgets?: Record<string, number>;
62
+ /** Default for `trazum diff --max-growth`, in tokens. */
63
+ maxGrowth?: number;
64
+ /**
65
+ * The cost baseline, and how much drift from it is tolerated.
66
+ *
67
+ * This is the difference between a ceiling and a gate. `budgets` asks whether
68
+ * a file fits; this asks whether the repository got worse than it was at the
69
+ * commit somebody recorded. Present in the config means `trazum check` in
70
+ * directory mode reads the baseline and gates on it without a flag — a gate
71
+ * you have to remember to pass an argument to is a gate that runs in the
72
+ * author's terminal and not in CI.
73
+ */
74
+ baseline?: BaselineConfig;
75
+ /** File extensions directory mode treats as prompts. */
76
+ extensions?: string[];
77
+ /**
78
+ * Path to a pricing overlay, relative to the config file.
79
+ *
80
+ * Lets a project correct a published price without upgrading the library. The
81
+ * bundled catalogue stays the default; this only layers on top.
82
+ */
83
+ pricing?: string;
84
+ }
85
+
86
+ /**
87
+ * Every key the config accepts. Exported so the CLI's help can be tested
88
+ * against it rather than against a second hand-maintained list — a setting the
89
+ * help never mentions is one only the changelog knows about.
90
+ */
91
+ export const CONFIG_KEYS = [
92
+ 'level',
93
+ 'locale',
94
+ 'disable',
95
+ 'usage',
96
+ 'budgets',
97
+ 'maxGrowth',
98
+ 'baseline',
99
+ 'extensions',
100
+ 'pricing',
101
+ ] as const;
102
+
103
+ export const CONFIG_BASELINE_KEYS = ['path', 'maxGrowthTokens', 'maxGrowthPct'] as const;
104
+
105
+ export const CONFIG_USAGE_KEYS = [
106
+ 'model',
107
+ 'callsPerMonth',
108
+ 'avgOutputTokens',
109
+ 'cacheHitRate',
110
+ 'batchEligible',
111
+ ] as const;
112
+
113
+ const TOP_LEVEL_KEYS = CONFIG_KEYS;
114
+ const USAGE_KEYS = CONFIG_USAGE_KEYS;
115
+
116
+ /** Extensions directory mode reads when the config does not say otherwise. */
117
+ export const DEFAULT_EXTENSIONS = ['.txt', '.md', '.prompt', '.tmpl'];
118
+
119
+ export class ConfigError extends Error {
120
+ constructor(
121
+ message: string,
122
+ readonly source: string,
123
+ ) {
124
+ super(`${source}: ${message}`);
125
+ this.name = 'ConfigError';
126
+ }
127
+ }
128
+
129
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
130
+ typeof value === 'object' && value !== null && !Array.isArray(value);
131
+
132
+ /**
133
+ * Rejects a key the schema does not have, naming the nearest one that it does.
134
+ *
135
+ * This is the same failure as an unrecognised CLI flag, and it gets the same
136
+ * treatment for the same reason: `"maxtokens"` where the schema says `budgets`
137
+ * is a budget that is never read, and silence about it means CI stays green.
138
+ */
139
+ function rejectUnknownKeys(
140
+ object: Record<string, unknown>,
141
+ allowed: readonly string[],
142
+ source: string,
143
+ path: string,
144
+ ): void {
145
+ for (const key of Object.keys(object)) {
146
+ if (allowed.includes(key)) continue;
147
+ const nearest = nearestName(key, allowed);
148
+ throw new ConfigError(
149
+ nearest
150
+ ? `unknown key "${path}${key}" — did you mean "${nearest}"?`
151
+ : `unknown key "${path}${key}". Known keys: ${allowed.join(', ')}`,
152
+ source,
153
+ );
154
+ }
155
+ }
156
+
157
+ function requireNonNegativeNumber(value: unknown, label: string, source: string): number {
158
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
159
+ throw new ConfigError(`"${label}" must be a number of 0 or more`, source);
160
+ }
161
+ return value;
162
+ }
163
+
164
+ function parseUsage(raw: unknown, source: string): Partial<UsageProfile> {
165
+ if (!isPlainObject(raw)) throw new ConfigError('"usage" must be an object', source);
166
+ rejectUnknownKeys(raw, USAGE_KEYS, source, 'usage.');
167
+
168
+ const usage: Partial<UsageProfile> = {};
169
+
170
+ if (raw.model !== undefined) {
171
+ if (typeof raw.model !== 'string' || raw.model.trim() === '') {
172
+ throw new ConfigError('"usage.model" must be a non-empty string', source);
173
+ }
174
+ // Deliberately NOT checked against the catalogue here. A `pricing` overlay can
175
+ // introduce a model, and the path to that overlay is a key of this very
176
+ // document — so the parser cannot know the catalogue yet. The membership check
177
+ // happens in `loadConfig`, once the overlay has been resolved, which is the
178
+ // only place with enough information to be right about it.
179
+ usage.model = raw.model;
180
+ }
181
+ if (raw.callsPerMonth !== undefined) {
182
+ usage.callsPerMonth = requireNonNegativeNumber(raw.callsPerMonth, 'usage.callsPerMonth', source);
183
+ }
184
+ if (raw.avgOutputTokens !== undefined) {
185
+ usage.avgOutputTokens = requireNonNegativeNumber(
186
+ raw.avgOutputTokens,
187
+ 'usage.avgOutputTokens',
188
+ source,
189
+ );
190
+ }
191
+ if (raw.cacheHitRate !== undefined) {
192
+ const rate = requireNonNegativeNumber(raw.cacheHitRate, 'usage.cacheHitRate', source);
193
+ if (rate > 1) {
194
+ throw new ConfigError('"usage.cacheHitRate" is a fraction between 0 and 1', source);
195
+ }
196
+ usage.cacheHitRate = rate;
197
+ }
198
+ if (raw.batchEligible !== undefined) {
199
+ if (typeof raw.batchEligible !== 'boolean') {
200
+ throw new ConfigError('"usage.batchEligible" must be true or false', source);
201
+ }
202
+ usage.batchEligible = raw.batchEligible;
203
+ }
204
+
205
+ return usage;
206
+ }
207
+
208
+ /**
209
+ * An absolute path, in any of the shapes a config file might carry one.
210
+ *
211
+ * Written out rather than delegating to `path.isAbsolute` for two reasons. It
212
+ * keeps `node:path` out of this module — the browser bundle imports it, and a
213
+ * Node builtin anywhere in that graph fails the web build. And `isAbsolute` is
214
+ * platform-dependent: on Linux it reads `C:\prompts` as relative, so a pattern
215
+ * written on Windows would pass validation on a Linux CI runner and then match
216
+ * nothing. A config should be judged the same way everywhere it is checked.
217
+ */
218
+ const IS_ABSOLUTE = /^(?:[/\\]|[A-Za-z]:[/\\])/;
219
+
220
+ function parseBudgets(raw: unknown, source: string): Record<string, number> {
221
+ if (!isPlainObject(raw)) throw new ConfigError('"budgets" must be an object', source);
222
+
223
+ const budgets: Record<string, number> = {};
224
+ for (const [pattern, value] of Object.entries(raw)) {
225
+ if (pattern.length === 0) {
226
+ throw new ConfigError('"budgets" has an empty pattern', source);
227
+ }
228
+ if (IS_ABSOLUTE.test(pattern) || pattern.includes('..')) {
229
+ // Budgets key files inside the repository. An absolute pattern, or one
230
+ // that climbs out with "..", either matches nothing or matches something
231
+ // outside the project — both are mistakes worth naming.
232
+ throw new ConfigError(
233
+ `budget pattern "${pattern}" must be a relative path inside the project`,
234
+ source,
235
+ );
236
+ }
237
+ const tokens = requireNonNegativeNumber(value, `budgets["${pattern}"]`, source);
238
+ if (!Number.isInteger(tokens)) {
239
+ throw new ConfigError(`budgets["${pattern}"] must be a whole number of tokens`, source);
240
+ }
241
+ budgets[pattern] = tokens;
242
+ }
243
+ return budgets;
244
+ }
245
+
246
+ /**
247
+ * Validates the `baseline` block.
248
+ *
249
+ * **A baseline with no threshold is an error, not a default.** Either default is
250
+ * wrong in a way that is silent: zero tolerance turns every honest addition into
251
+ * a failed build and gets the whole block deleted within a week, and a generous
252
+ * default is a gate that passes things nobody agreed to let through. The number
253
+ * is a policy decision, so the policy has to be written down.
254
+ *
255
+ * `path` defaults, because there is one sensible answer and repeating it in every
256
+ * config is noise.
257
+ */
258
+ function parseBaselineConfig(raw: unknown, source: string): BaselineConfig {
259
+ if (!isPlainObject(raw)) throw new ConfigError('"baseline" must be an object', source);
260
+ rejectUnknownKeys(raw, CONFIG_BASELINE_KEYS, source, 'baseline.');
261
+
262
+ const baseline: BaselineConfig = { path: BASELINE_FILENAME };
263
+
264
+ if (raw.path !== undefined) {
265
+ if (typeof raw.path !== 'string' || raw.path.trim() === '') {
266
+ throw new ConfigError('"baseline.path" must be a path to a baseline file', source);
267
+ }
268
+ if (IS_ABSOLUTE.test(raw.path) || raw.path.includes('..')) {
269
+ throw new ConfigError(
270
+ `"baseline.path" must be a relative path inside the project (got "${raw.path}")`,
271
+ source,
272
+ );
273
+ }
274
+ baseline.path = raw.path;
275
+ }
276
+
277
+ if (raw.maxGrowthTokens !== undefined) {
278
+ const tokens = requireNonNegativeNumber(
279
+ raw.maxGrowthTokens,
280
+ 'baseline.maxGrowthTokens',
281
+ source,
282
+ );
283
+ if (!Number.isInteger(tokens)) {
284
+ throw new ConfigError('"baseline.maxGrowthTokens" must be a whole number of tokens', source);
285
+ }
286
+ baseline.maxGrowthTokens = tokens;
287
+ }
288
+
289
+ if (raw.maxGrowthPct !== undefined) {
290
+ baseline.maxGrowthPct = requireNonNegativeNumber(
291
+ raw.maxGrowthPct,
292
+ 'baseline.maxGrowthPct',
293
+ source,
294
+ );
295
+ }
296
+
297
+ if (baseline.maxGrowthTokens === undefined && baseline.maxGrowthPct === undefined) {
298
+ throw new ConfigError(
299
+ '"baseline" needs at least one of "maxGrowthTokens" or "maxGrowthPct" — ' +
300
+ 'a baseline with no threshold cannot fail, and a gate that cannot fail is not a gate',
301
+ source,
302
+ );
303
+ }
304
+
305
+ return baseline;
306
+ }
307
+
308
+ /**
309
+ * Checks a config's `usage.model` against a resolved catalogue.
310
+ *
311
+ * Separate from `parseConfig` because it needs the catalogue, and the catalogue
312
+ * may be defined by the very document being parsed — `pricing` is a config key.
313
+ * `loadConfig` calls this once the overlay is in hand, so a typo'd model is still
314
+ * a loud error, just one raised at the point where "unknown model" can be
315
+ * answered truthfully.
316
+ */
317
+ export function validateConfigModel(
318
+ config: TrazumConfig,
319
+ catalogue: PricingCatalogue,
320
+ source: string,
321
+ ): void {
322
+ const model = config.usage?.model;
323
+ if (model === undefined || catalogue.byId.has(model)) return;
324
+
325
+ const ids = catalogue.models.map((m) => m.id);
326
+ const nearest = nearestName(model, ids);
327
+ throw new ConfigError(
328
+ nearest
329
+ ? `"usage.model" names no such model: "${model}" — did you mean "${nearest}"?`
330
+ : `"usage.model" names no such model: "${model}". Available: ${ids.join(', ')}`,
331
+ source,
332
+ );
333
+ }
334
+
335
+ /**
336
+ * Validates a config document.
337
+ *
338
+ * `source` names the file in every error, because a config error found while
339
+ * running `trazum check` in a monorepo is useless without knowing which of
340
+ * several config files it came from.
341
+ */
342
+ export function parseConfig(raw: string, source = CONFIG_FILENAME): TrazumConfig {
343
+ let document: unknown;
344
+ try {
345
+ document = JSON.parse(raw);
346
+ } catch (error) {
347
+ const detail = error instanceof Error ? error.message : String(error);
348
+ throw new ConfigError(`not valid JSON — ${detail}`, source);
349
+ }
350
+
351
+ if (!isPlainObject(document)) {
352
+ throw new ConfigError('the top level must be an object', source);
353
+ }
354
+ rejectUnknownKeys(document, TOP_LEVEL_KEYS, source, '');
355
+
356
+ const config: TrazumConfig = {};
357
+
358
+ if (document.level !== undefined) {
359
+ if (document.level !== 'safe' && document.level !== 'aggressive') {
360
+ throw new ConfigError('"level" must be "safe" or "aggressive"', source);
361
+ }
362
+ config.level = document.level;
363
+ }
364
+
365
+ if (document.locale !== undefined) {
366
+ if (typeof document.locale !== 'string' || !isLocale(document.locale)) {
367
+ throw new ConfigError(`"locale" is not a locale Trazum ships: ${String(document.locale)}`, source);
368
+ }
369
+ config.locale = document.locale;
370
+ }
371
+
372
+ if (document.disable !== undefined) {
373
+ if (!Array.isArray(document.disable)) {
374
+ throw new ConfigError('"disable" must be an array of rule ids', source);
375
+ }
376
+ const ids = RULES.map((rule) => rule.id);
377
+ config.disable = document.disable.map((value) => {
378
+ if (typeof value !== 'string' || !ids.includes(value as RuleId)) {
379
+ const nearest = typeof value === 'string' ? nearestName(value, ids) : null;
380
+ throw new ConfigError(
381
+ nearest
382
+ ? `"disable" names no such rule: "${String(value)}" — did you mean "${nearest}"?`
383
+ : `"disable" names no such rule: "${String(value)}". Run "trazum rules" for the list.`,
384
+ source,
385
+ );
386
+ }
387
+ return value as RuleId;
388
+ });
389
+ }
390
+
391
+ if (document.usage !== undefined) config.usage = parseUsage(document.usage, source);
392
+ if (document.budgets !== undefined) config.budgets = parseBudgets(document.budgets, source);
393
+ if (document.baseline !== undefined) {
394
+ config.baseline = parseBaselineConfig(document.baseline, source);
395
+ }
396
+
397
+ if (document.maxGrowth !== undefined) {
398
+ config.maxGrowth = requireNonNegativeNumber(document.maxGrowth, 'maxGrowth', source);
399
+ }
400
+
401
+ if (document.pricing !== undefined) {
402
+ if (typeof document.pricing !== 'string' || document.pricing.trim() === '') {
403
+ throw new ConfigError('"pricing" must be a path to a pricing overlay file', source);
404
+ }
405
+ if (IS_ABSOLUTE.test(document.pricing) || document.pricing.includes('..')) {
406
+ throw new ConfigError(
407
+ `"pricing" must be a relative path inside the project (got "${document.pricing}")`,
408
+ source,
409
+ );
410
+ }
411
+ config.pricing = document.pricing;
412
+ }
413
+
414
+ if (document.extensions !== undefined) {
415
+ if (!Array.isArray(document.extensions) || document.extensions.length === 0) {
416
+ throw new ConfigError('"extensions" must be a non-empty array of strings', source);
417
+ }
418
+ config.extensions = document.extensions.map((value) => {
419
+ if (typeof value !== 'string' || value.length < 2 || !value.startsWith('.')) {
420
+ throw new ConfigError(
421
+ `"extensions" entries look like ".txt"; got ${JSON.stringify(value)}`,
422
+ source,
423
+ );
424
+ }
425
+ return value.toLowerCase();
426
+ });
427
+ }
428
+
429
+ return config;
430
+ }
431
+
432
+ export interface ResolvedBudget {
433
+ /** The pattern the budget came from, so a surprising number can be traced. */
434
+ pattern: string;
435
+ maxTokens: number;
436
+ }
437
+
438
+ /**
439
+ * The budget that applies to a path, or null when no pattern matches.
440
+ *
441
+ * Returning the pattern alongside the number is the point: a file failing
442
+ * against a budget the reader cannot locate in their config is a bug report.
443
+ */
444
+ export function budgetFor(
445
+ path: string,
446
+ budgets: Record<string, number> | undefined,
447
+ ): ResolvedBudget | null {
448
+ if (!budgets) return null;
449
+ const pattern = mostSpecificMatch(Object.keys(budgets), path);
450
+ return pattern === null ? null : { pattern, maxTokens: budgets[pattern]! };
451
+ }