@trazum/cli 1.47.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -35,9 +35,12 @@ import {
35
35
  computeSavings,
36
36
  countTokensAnthropic,
37
37
  DEFAULT_USAGE,
38
+ budgetPositions,
38
39
  detectFromSource,
39
40
  matchLocale,
40
41
  parsePlanDocument,
42
+ waiverDay,
43
+ waiverHistory,
41
44
  proposeInit,
42
45
  MIN_RATE_DAYS,
43
46
  parseConfig,
@@ -125,7 +128,9 @@ import type {
125
128
  UsageProfile,
126
129
  } from '@trazum/core';
127
130
  import type {
131
+ BudgetReport,
128
132
  UsageProfileReport,
133
+ WaiverUse,
129
134
  InitDecline,
130
135
  InitJustification,
131
136
  InitObservations,
@@ -168,6 +173,7 @@ import {
168
173
  import type { Revision } from './git.js';
169
174
  import { fetchProviderUsage, findCredential } from './connect.js';
170
175
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
176
+ import { WAIVER_LOG, appendWaiverUse, readWaiverLog } from './waiver-log.js';
171
177
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
172
178
  import {
173
179
  WATCH_STATE_VERSION,
@@ -2743,41 +2749,39 @@ async function commandServe(
2743
2749
  t: CliMessages,
2744
2750
  ): Promise<void> {
2745
2751
  const root = process.cwd();
2746
- const limitUsd = config.spend?.maxUsd;
2747
2752
 
2748
2753
  const { resolved } = await readStore(root);
2749
- const measured = resolved.records.length > 0;
2754
+
2750
2755
  /**
2751
- * The window the measurement covers, carried into every answer.
2756
+ * The live budget, from `budgetPositions` the same number `store` prints
2757
+ * and the same one the MCP guard consults.
2752
2758
  *
2753
- * The position is read once at start, so a caller has to be able to see how
2754
- * old it is. A null window here would let a figure from last month read as
2755
- * current, which is the staleness this endpoint is otherwise honest about.
2759
+ * **This used to read `spend.maxUsd` against the whole store**, which is a
2760
+ * per-log gate compared against however much history the store happened to
2761
+ * hold. A year of records against a monthly limit reported as a budget
2762
+ * position, with a straight face and no way for a caller to tell. Same
2763
+ * units, different denominators, and the two surfaces disagreed by exactly
2764
+ * as much history as the machine had. `spend.monthlyUsd` is the key for a
2765
+ * calendar month and nothing infers one key from the other: a repository
2766
+ * with a per-log gate and no monthly budget has no monthly position, and
2767
+ * this says so rather than picking a number that is the right shape.
2756
2768
  */
2757
- const window = measured
2758
- ? {
2759
- fromMs: Math.min(...resolved.records.map((record) => record.fromMs)),
2760
- toMs: Math.max(...resolved.records.map((record) => record.toMs)),
2761
- }
2762
- : null;
2763
- const report = bucketedProfile(
2764
- {
2765
- provider: 'store',
2766
- granularity: 'bucketed',
2767
- buckets: bucketsFromRecords(resolved.records),
2768
- window,
2769
- gaps: [],
2770
- unavailable: [],
2771
- },
2772
- { catalogue: pricing },
2773
- );
2769
+ const budget = budgetPositions(resolved.records, config.spend, { catalogue: pricing });
2770
+ const standing = budget.positions[0] ?? null;
2771
+ const limitUsd = config.spend?.monthlyUsd;
2772
+ const measured = standing !== null && standing.coverage !== 'none';
2774
2773
 
2775
2774
  const server = buildServer({
2776
2775
  catalogue: pricing,
2777
2776
  position: () => ({
2778
- consumedUsd: measured ? report.total.totalUsd : undefined,
2777
+ // Nothing measured inside the period is `undefined`, never zero: the
2778
+ // endpoint's `cannot-tell` exists for exactly this, and a $0 consumed
2779
+ // would be the healthiest-looking budget a dead store can produce.
2780
+ consumedUsd: measured ? standing.consumedUsd : undefined,
2779
2781
  limitUsd,
2780
- window: report.span,
2782
+ // The period, not the store's span. A caller judging staleness needs to
2783
+ // know which month the figure is about.
2784
+ window: standing === null ? null : { fromMs: standing.period.fromMs, toMs: standing.period.toMs },
2781
2785
  }),
2782
2786
  });
2783
2787
 
@@ -2792,8 +2796,13 @@ async function commandServe(
2792
2796
  console.log(c.bold(t.serve.listening(where)));
2793
2797
  console.log(` ${c.dim(wrap(t.serve.loopbackOnly(), 74, ' '))}`);
2794
2798
  console.log(
2795
- ` ${c.dim(wrap(measured ? t.serve.measuredFrom(formatUsd(report.total.totalUsd)) : t.serve.nothingMeasured(STORE_DIR), 74, ' '))}`,
2799
+ ` ${c.dim(wrap(measured ? t.serve.measuredFrom(formatUsd(standing.consumedUsd)) : t.serve.nothingMeasured(STORE_DIR), 74, ' '))}`,
2796
2800
  );
2801
+ if (standing !== null && standing.coverage === 'partial') {
2802
+ console.log(
2803
+ ` ${c.yellow(wrap(t.serve.partialCoverage(standing.measuredDays, standing.elapsedDays, standing.period.id), 74, ' '))}`,
2804
+ );
2805
+ }
2797
2806
  if (limitUsd === undefined) {
2798
2807
  console.log(` ${c.dim(wrap(t.serve.noBudget(), 74, ' '))}`);
2799
2808
  }
@@ -3160,6 +3169,72 @@ async function commandStore(
3160
3169
  console.log(
3161
3170
  ` ${c.dim(wrap(keepDays === undefined ? t.store.noRetention() : t.store.retention(String(keepDays)), 74, ' '))}`,
3162
3171
  );
3172
+
3173
+ /**
3174
+ * The live budget, printed here because this is where the measurement lives.
3175
+ *
3176
+ * The same call `serve` makes and the same call the MCP guard makes, so the
3177
+ * three cannot disagree about how much of the month is gone — which is the
3178
+ * whole point of the number existing in one place.
3179
+ */
3180
+ renderBudget(budgetPositions(resolved.records, config.spend, { catalogue: pricing }), t, n);
3181
+ }
3182
+
3183
+ /**
3184
+ * One budget standing, rendered.
3185
+ *
3186
+ * Coverage before the money, deliberately. A reader who sees "$61 of $100"
3187
+ * first has already formed a view by the time they reach "over three of
3188
+ * nineteen elapsed days", and the second sentence has to undo the first.
3189
+ */
3190
+ function renderBudget(report: BudgetReport, t: CliMessages, n: (value: number) => string): void {
3191
+ const standing = report.positions[0];
3192
+ if (standing === undefined) {
3193
+ if (report.unmeasuredScopes.length > 0) {
3194
+ console.log();
3195
+ console.log(
3196
+ ` ${c.dim(wrap(t.store.budgetScopesUnmeasured(report.unmeasuredScopes.length), 74, ' '))}`,
3197
+ );
3198
+ }
3199
+ return;
3200
+ }
3201
+
3202
+ console.log();
3203
+ console.log(c.bold(t.store.budgetHeading(standing.period.id)));
3204
+
3205
+ if (standing.coverage === 'none') {
3206
+ // Nothing measured is never rendered as nothing spent. A dead store and a
3207
+ // quiet month produce the same zero, and only one of them is good news.
3208
+ console.log(` ${c.red(wrap(t.store.budgetNothingMeasured(standing.elapsedDays), 74, ' '))}`);
3209
+ return;
3210
+ }
3211
+ if (standing.coverage === 'partial') {
3212
+ console.log(
3213
+ ` ${c.yellow(wrap(t.store.budgetPartial(standing.measuredDays, standing.elapsedDays, standing.unmeasuredDays.join(', ')), 74, ' '))}`,
3214
+ );
3215
+ }
3216
+
3217
+ const share = standing.burn.consumedShare;
3218
+ console.log(
3219
+ ` ${t.store.budgetStanding(
3220
+ formatUsd(standing.consumedUsd),
3221
+ formatUsd(standing.limitUsd),
3222
+ share === null ? '—' : `${Math.round(share * 100)}%`,
3223
+ n(standing.measuredDays),
3224
+ n(standing.period.days),
3225
+ )}`,
3226
+ );
3227
+ const line = t.store.budgetShape(
3228
+ standing.burn.shape,
3229
+ Math.round(standing.burn.elapsedShare * 100),
3230
+ standing.coverage,
3231
+ );
3232
+ console.log(` ${standing.verdict === 'over' ? c.red(line) : c.dim(wrap(line, 74, ' '))}`);
3233
+ // Only where there is a shape to disclaim. "That is a shape, not a forecast"
3234
+ // under "nothing to compare against" is a disclaimer about nothing.
3235
+ if (standing.burn.shape !== 'cannot-tell') {
3236
+ console.log(` ${c.dim(wrap(t.store.budgetNeverForecast(), 74, ' '))}`);
3237
+ }
3163
3238
  }
3164
3239
 
3165
3240
  /**
@@ -3348,7 +3423,12 @@ async function commandConnect(
3348
3423
  * same action planned twice — and no series, however long, becomes a
3349
3424
  * forecast.
3350
3425
  */
3351
- async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
3426
+ async function commandHistory(
3427
+ args: Args,
3428
+ config: TrazumConfig,
3429
+ pricing: PricingCatalogue,
3430
+ t: CliMessages,
3431
+ ): Promise<void> {
3352
3432
  /**
3353
3433
  * `--store` builds the series from measured spend already on disk.
3354
3434
  *
@@ -3452,7 +3532,23 @@ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessa
3452
3532
  throw new Error(t.history.needsThree(String(history.periods.length)));
3453
3533
  }
3454
3534
 
3455
- const stamped = { ...history, unrecognizedFiles: unrecognized };
3535
+ /**
3536
+ * The waiver record — closing the gap 1.40 named and could not fill.
3537
+ *
3538
+ * 1.40 wanted to say "this finding has been waived three times in a row" and
3539
+ * refused to, because the only material available was the config as it
3540
+ * stands, and a past reconstructed from a present is a guess wearing a
3541
+ * record's clothes. The material exists now: since 1.48 a waiver that
3542
+ * silences a gate writes down that it did, and this reads those lines back.
3543
+ *
3544
+ * Read from the working directory rather than from the reports directory:
3545
+ * the waiver record belongs to the repository whose gates fired, and the
3546
+ * stored reports may have come from anywhere.
3547
+ */
3548
+ const waivers = await readWaiverLog('.');
3549
+ const waiverReport = waiverHistory(waivers.uses, config.waive ?? []);
3550
+
3551
+ const stamped = { ...history, unrecognizedFiles: unrecognized, waivers: waiverReport };
3456
3552
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
3457
3553
  const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
3458
3554
  const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
@@ -3506,6 +3602,58 @@ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessa
3506
3602
  for (const name of unrecognized) {
3507
3603
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
3508
3604
  }
3605
+ /**
3606
+ * The waiver record, printed only once something has been recorded.
3607
+ *
3608
+ * Silent on a repository that has never waived anything, rather than a
3609
+ * heading over "0 uses" — an empty section teaches a reader to skip the
3610
+ * section, and this is the one they should not learn to skip.
3611
+ */
3612
+ if (waivers.present) {
3613
+ out.push('');
3614
+ out.push(md ? `### ${t.history.waiverHeading()}` : t.history.waiverHeading());
3615
+ if (waiverReport.totalUses === 0) {
3616
+ out.push(md ? `- ${t.history.waiverNoneRecorded()}` : ` ${t.history.waiverNoneRecorded()}`);
3617
+ } else {
3618
+ const since = t.history.waiverSince(waiverReport.since ?? '', waiverReport.totalUses);
3619
+ out.push(md ? `- ${since}` : ` ${since}`);
3620
+ out.push(md ? `- _${t.history.waiverStartsHere()}_` : ` ${t.history.waiverStartsHere()}`);
3621
+ for (const habit of waiverReport.habits) {
3622
+ out.push('');
3623
+ const head = t.history.waiverHabit(
3624
+ habit.gate,
3625
+ habit.uses,
3626
+ habit.days,
3627
+ habit.firstDay,
3628
+ habit.lastDay,
3629
+ );
3630
+ out.push(md ? `- **${head}**` : ` ${head}`);
3631
+ const rows = [t.history.waiverVerdict(habit.verdict)];
3632
+ // The reason as it stands *now* — never read backwards onto an
3633
+ // older use, which is the same mistake the record exists to avoid.
3634
+ const latest = habit.reasons[habit.reasons.length - 1];
3635
+ if (latest !== undefined) rows.push(t.history.waiverReasonNow(latest));
3636
+ if (habit.reasons.length > 1) rows.push(t.history.waiverReasonsChanged(habit.reasons.length));
3637
+ const firstExpiry = habit.expiries[0];
3638
+ const lastExpiry = habit.expiries[habit.expiries.length - 1];
3639
+ if (habit.expiries.length > 1 && firstExpiry !== undefined && lastExpiry !== undefined) {
3640
+ rows.push(t.history.waiverExpiriesMoved(firstExpiry, lastExpiry, habit.expiries.length - 1));
3641
+ }
3642
+ if (!habit.stillConfigured) rows.push(t.history.waiverNoLongerConfigured());
3643
+ for (const row of rows) out.push(md ? ` - ${row}` : ` ${row}`);
3644
+ }
3645
+ }
3646
+ if (waiverReport.neverUsed.length > 0) {
3647
+ out.push('');
3648
+ const dead = t.history.waiverNeverUsed(waiverReport.neverUsed.join(', '));
3649
+ out.push(md ? `- ${dead}` : ` ${dead}`);
3650
+ }
3651
+ if (waivers.unreadable.length > 0) {
3652
+ const bad = t.history.waiverUnreadable(waivers.unreadable.length, WAIVER_LOG);
3653
+ out.push(md ? `- ${bad}` : ` ${bad}`);
3654
+ }
3655
+ }
3656
+
3509
3657
  if (fromStore) {
3510
3658
  out.push('');
3511
3659
  const note = t.history.storeNoLabels();
@@ -4248,7 +4396,16 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4248
4396
  * way, because a waived failure that vanished from the output would be a
4249
4397
  * finding deleted with extra steps.
4250
4398
  */
4251
- const waived = (gate: string): boolean => {
4399
+ /**
4400
+ * Uses recorded this run, flushed after the gates have finished.
4401
+ *
4402
+ * Collected rather than written inline because `waived` is synchronous and
4403
+ * called from seven places inside the gate pass. Writing from each of them
4404
+ * would mean seven awaits threaded through the exit-code logic — the one
4405
+ * part of this command where a mistake turns a red build green.
4406
+ */
4407
+ const waiverUses: WaiverUse[] = [];
4408
+ const waived = (gate: string, measuredUsd: number | null = null, limitUsd: number | null = null): boolean => {
4252
4409
  const found = waiverFor(gate);
4253
4410
  if (found === null) return false;
4254
4411
  if (found.expired) {
@@ -4264,6 +4421,29 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4264
4421
  console.error(
4265
4422
  c.yellow(t.profile.waiveActive(gate, found.entry.reason, found.entry.until, String(daysLeft))),
4266
4423
  );
4424
+ /**
4425
+ * Recorded **when it silences something**, never when it is configured.
4426
+ *
4427
+ * A waiver nobody's build has ever hit is not a habit — it is dead config
4428
+ * — and the history reports the two apart. This is also the only honest
4429
+ * way to build the record 1.40 refused to invent: it starts today and
4430
+ * says so, rather than reconstructing a past from the present.
4431
+ *
4432
+ * The reason and the expiry are taken from the config **as it stands at
4433
+ * this moment**, because that is the decision that was actually in force.
4434
+ * Reading today's reason back onto last quarter's use is the same mistake
4435
+ * one layer down.
4436
+ */
4437
+ waiverUses.push({
4438
+ schemaVersion: 1,
4439
+ day: waiverDay(new Date()),
4440
+ gate,
4441
+ reason: found.entry.reason,
4442
+ until: found.entry.until,
4443
+ commit: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? null,
4444
+ measuredUsd,
4445
+ limitUsd,
4446
+ });
4267
4447
  return true;
4268
4448
  };
4269
4449
  const applyGates = (): void => {
@@ -4312,7 +4492,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4312
4492
  }
4313
4493
  if (usd > limit) {
4314
4494
  console.error(c.red(t.profile.labelBudgetFailed(label, formatUsd(usd), formatUsd(limit))));
4315
- if (!waived(`byLabel:${label}`)) process.exitCode = 1;
4495
+ if (!waived(`byLabel:${label}`, usd, limit)) process.exitCode = 1;
4316
4496
  } else {
4317
4497
  console.error(c.dim(t.profile.labelBudgetOk(label, formatUsd(usd), formatUsd(limit))));
4318
4498
  }
@@ -4385,7 +4565,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4385
4565
  * copy says so.
4386
4566
  */
4387
4567
  explainFailure(report.total.totalUsd - maxUsd);
4388
- if (!waived('maxUsd')) process.exitCode = 1;
4568
+ if (!waived('maxUsd', report.total.totalUsd, maxUsd)) process.exitCode = 1;
4389
4569
  } else {
4390
4570
  console.error(c.dim(t.profile.maxUsdOk(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
4391
4571
  explainMargin(report.total.totalUsd, maxUsd);
@@ -4425,7 +4605,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4425
4605
  process.exitCode = 1;
4426
4606
  } else if (againstDelta > maxGrowth) {
4427
4607
  console.error(c.red(t.profile.maxGrowthUsdFailed(formatSignedUsd(againstDelta), formatUsd(maxGrowth))));
4428
- if (!waived('maxGrowthUsd')) process.exitCode = 1;
4608
+ if (!waived('maxGrowthUsd', againstDelta, maxGrowth)) process.exitCode = 1;
4429
4609
  }
4430
4610
  }
4431
4611
  /**
@@ -4443,7 +4623,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4443
4623
  console.error(
4444
4624
  c.red(t.profile.maxCacheLossFailed(formatUsd(gateCache.deltaUsd), formatUsd(maxLoss))),
4445
4625
  );
4446
- if (!waived('maxCacheLossUsd')) process.exitCode = 1;
4626
+ if (!waived('maxCacheLossUsd', gateCache.deltaUsd, maxLoss)) process.exitCode = 1;
4447
4627
  } else if (gateCache.worstCaseDeltaUsd > maxLoss) {
4448
4628
  console.error(
4449
4629
  c.red(
@@ -4454,7 +4634,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4454
4634
  ),
4455
4635
  ),
4456
4636
  );
4457
- if (!waived('maxCacheLossUsd')) process.exitCode = 1;
4637
+ if (!waived('maxCacheLossUsd', gateCache.worstCaseDeltaUsd, maxLoss)) process.exitCode = 1;
4458
4638
  } else {
4459
4639
  console.error(
4460
4640
  c.dim(t.profile.maxCacheLossOk(formatUsd(Math.max(0, gateCache.worstCaseDeltaUsd)), formatUsd(maxLoss))),
@@ -4503,7 +4683,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4503
4683
  c.red(`${t.profile.maxDayFailed(worst.day, formatUsd(worst.usd), formatUsd(maxDay))}${suspect}`),
4504
4684
  );
4505
4685
  explainFailure(worst.usd - maxDay, { namesLargest: true });
4506
- if (!waived('maxDayUsd')) process.exitCode = 1;
4686
+ if (!waived('maxDayUsd', worst.usd, maxDay)) process.exitCode = 1;
4507
4687
  } else {
4508
4688
  console.error(c.dim(t.profile.maxDayOk(worst.day, formatUsd(worst.usd), formatUsd(maxDay))));
4509
4689
  explainMargin(worst.usd, maxDay);
@@ -4545,7 +4725,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4545
4725
  c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
4546
4726
  );
4547
4727
  explainFailure(report.sessionSpend.maxUsd - maxSession);
4548
- if (!waived('maxSessionUsd')) process.exitCode = 1;
4728
+ if (!waived('maxSessionUsd', report.sessionSpend.maxUsd, maxSession)) process.exitCode = 1;
4549
4729
  } else {
4550
4730
  console.error(
4551
4731
  c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
@@ -4579,6 +4759,26 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4579
4759
  }
4580
4760
  };
4581
4761
 
4762
+ /**
4763
+ * Writes down every waiver that silenced something this run.
4764
+ *
4765
+ * **A failure here never fails the build.** The gate's job is the exit code;
4766
+ * a read-only checkout or a full disk must not turn a passing build red on
4767
+ * account of bookkeeping. The problem is reported and the gate's own verdict
4768
+ * stands — which is also why this runs after `recordGates` rather than
4769
+ * inside it: nothing about the exit code depends on the write.
4770
+ */
4771
+ const recordWaiverUses = async (): Promise<void> => {
4772
+ if (waiverUses.length === 0) return;
4773
+ for (const use of waiverUses) {
4774
+ const failed = await appendWaiverUse('.', use);
4775
+ if (failed !== null) {
4776
+ console.error(c.dim(t.profile.waiveNotRecorded(WAIVER_LOG, failed)));
4777
+ return;
4778
+ }
4779
+ }
4780
+ };
4781
+
4582
4782
  /**
4583
4783
  * The side files the caller asked for. Written on **both** output paths:
4584
4784
  * under --json the human rendering returns early, and the first version of
@@ -4745,6 +4945,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4745
4945
  ),
4746
4946
  );
4747
4947
  recordGates();
4948
+ await recordWaiverUses();
4748
4949
  await writeSideFiles();
4749
4950
  return;
4750
4951
  }
@@ -5899,6 +6100,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
5899
6100
  reportProfileGaps(report, t, n, pricingStale);
5900
6101
 
5901
6102
  recordGates();
6103
+ await recordWaiverUses();
5902
6104
 
5903
6105
  await writeSideFiles();
5904
6106
  }
@@ -7612,7 +7814,7 @@ async function main(): Promise<void> {
7612
7814
  await commandVerify(args, pricing, t);
7613
7815
  break;
7614
7816
  case 'history':
7615
- await commandHistory(args, pricing, t);
7817
+ await commandHistory(args, config, pricing, t);
7616
7818
  break;
7617
7819
  case 'connect':
7618
7820
  await commandConnect(args, pricing, t);
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Where a waiver's uses are written down.
3
+ *
4
+ * The core decides what a use means and what a run of them adds up to; this
5
+ * decides where the bytes go — the same split every module here follows, so
6
+ * `@trazum/core` stays browser-safe and the CLI keeps its monopoly on I/O.
7
+ *
8
+ * **Append-only, and never rewritten.** There is deliberately no prune, no
9
+ * compaction and no `--clear`: a record of decisions that the tool can erase
10
+ * is a record nobody can rely on, and the one thing a waiver history is for is
11
+ * being awkward six months later. Deleting the file is a thing a person does
12
+ * with `rm`, on purpose, having seen it.
13
+ *
14
+ * **A write that fails never fails the run.** The gate's job is the exit code.
15
+ * A read-only checkout, a full disk or a directory somebody's CI cannot create
16
+ * must not turn a passing build red on account of bookkeeping — the failure is
17
+ * reported and the gate's own verdict stands.
18
+ *
19
+ * **A line that will not parse is counted and skipped**, exactly as in the
20
+ * store. Losing the whole history because one line is broken would be the
21
+ * worst possible response; pretending the history is complete would be the
22
+ * second worst.
23
+ */
24
+
25
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
26
+ import { join } from 'node:path';
27
+ import { isWaiverUse } from '@trazum/core';
28
+ import type { WaiverUse } from '@trazum/core';
29
+
30
+ /** One file, not one per month: a waiver history is small and read whole. */
31
+ export const WAIVER_LOG = '.trazum/waivers.jsonl';
32
+
33
+ export interface WaiverReadResult {
34
+ uses: WaiverUse[];
35
+ /** 1-based positions of lines that would not parse. Named, never dropped quietly. */
36
+ unreadable: number[];
37
+ /** False when the file does not exist — "nothing recorded" is not "no file". */
38
+ present: boolean;
39
+ }
40
+
41
+ export async function readWaiverLog(root: string): Promise<WaiverReadResult> {
42
+ let raw: string;
43
+ try {
44
+ raw = await readFile(join(root, WAIVER_LOG), 'utf8');
45
+ } catch {
46
+ // Absent is the normal state of a repository that has never waived
47
+ // anything, and it is not an error.
48
+ return { uses: [], unreadable: [], present: false };
49
+ }
50
+
51
+ const uses: WaiverUse[] = [];
52
+ const unreadable: number[] = [];
53
+ raw.split('\n').forEach((line, index) => {
54
+ if (line.trim() === '') return;
55
+ try {
56
+ const parsed: unknown = JSON.parse(line);
57
+ if (isWaiverUse(parsed)) uses.push(parsed);
58
+ else unreadable.push(index + 1);
59
+ } catch {
60
+ unreadable.push(index + 1);
61
+ }
62
+ });
63
+ return { uses, unreadable, present: true };
64
+ }
65
+
66
+ /**
67
+ * Appends one use, and swallows any failure after reporting it.
68
+ *
69
+ * Returns the error message rather than throwing, so the caller can print it
70
+ * beside the gate's own output without the gate ever depending on the write.
71
+ */
72
+ export async function appendWaiverUse(root: string, use: WaiverUse): Promise<string | null> {
73
+ const path = join(root, WAIVER_LOG);
74
+ try {
75
+ await mkdir(join(path, '..'), { recursive: true });
76
+ await writeFile(path, `${JSON.stringify(use)}\n`, { flag: 'a', mode: 0o600 });
77
+ return null;
78
+ } catch (error) {
79
+ return error instanceof Error ? error.message : String(error);
80
+ }
81
+ }