@trazum/cli 1.46.0 → 1.48.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.
@@ -0,0 +1,42 @@
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
+ import type { WaiverUse } from '@trazum/core';
25
+ /** One file, not one per month: a waiver history is small and read whole. */
26
+ export declare const WAIVER_LOG = ".trazum/waivers.jsonl";
27
+ export interface WaiverReadResult {
28
+ uses: WaiverUse[];
29
+ /** 1-based positions of lines that would not parse. Named, never dropped quietly. */
30
+ unreadable: number[];
31
+ /** False when the file does not exist — "nothing recorded" is not "no file". */
32
+ present: boolean;
33
+ }
34
+ export declare function readWaiverLog(root: string): Promise<WaiverReadResult>;
35
+ /**
36
+ * Appends one use, and swallows any failure after reporting it.
37
+ *
38
+ * Returns the error message rather than throwing, so the caller can print it
39
+ * beside the gate's own output without the gate ever depending on the write.
40
+ */
41
+ export declare function appendWaiverUse(root: string, use: WaiverUse): Promise<string | null>;
42
+ //# sourceMappingURL=waiver-log.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waiver-log.d.ts","sourceRoot":"","sources":["../src/waiver-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAKH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AAE9C,6EAA6E;AAC7E,eAAO,MAAM,UAAU,0BAA0B,CAAC;AAElD,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,SAAS,EAAE,CAAC;IAClB,qFAAqF;IACrF,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,gFAAgF;IAChF,OAAO,EAAE,OAAO,CAAC;CAClB;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAuB3E;AAED;;;;;GAKG;AACH,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAS1F"}
@@ -0,0 +1,74 @@
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
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
25
+ import { join } from 'node:path';
26
+ import { isWaiverUse } from '@trazum/core';
27
+ /** One file, not one per month: a waiver history is small and read whole. */
28
+ export const WAIVER_LOG = '.trazum/waivers.jsonl';
29
+ export async function readWaiverLog(root) {
30
+ let raw;
31
+ try {
32
+ raw = await readFile(join(root, WAIVER_LOG), 'utf8');
33
+ }
34
+ catch {
35
+ // Absent is the normal state of a repository that has never waived
36
+ // anything, and it is not an error.
37
+ return { uses: [], unreadable: [], present: false };
38
+ }
39
+ const uses = [];
40
+ const unreadable = [];
41
+ raw.split('\n').forEach((line, index) => {
42
+ if (line.trim() === '')
43
+ return;
44
+ try {
45
+ const parsed = JSON.parse(line);
46
+ if (isWaiverUse(parsed))
47
+ uses.push(parsed);
48
+ else
49
+ unreadable.push(index + 1);
50
+ }
51
+ catch {
52
+ unreadable.push(index + 1);
53
+ }
54
+ });
55
+ return { uses, unreadable, present: true };
56
+ }
57
+ /**
58
+ * Appends one use, and swallows any failure after reporting it.
59
+ *
60
+ * Returns the error message rather than throwing, so the caller can print it
61
+ * beside the gate's own output without the gate ever depending on the write.
62
+ */
63
+ export async function appendWaiverUse(root, use) {
64
+ const path = join(root, WAIVER_LOG);
65
+ try {
66
+ await mkdir(join(path, '..'), { recursive: true });
67
+ await writeFile(path, `${JSON.stringify(use)}\n`, { flag: 'a', mode: 0o600 });
68
+ return null;
69
+ }
70
+ catch (error) {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+ }
74
+ //# sourceMappingURL=waiver-log.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"waiver-log.js","sourceRoot":"","sources":["../src/waiver-log.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AACjC,OAAO,EAAE,WAAW,EAAE,MAAM,cAAc,CAAC;AAG3C,6EAA6E;AAC7E,MAAM,CAAC,MAAM,UAAU,GAAG,uBAAuB,CAAC;AAUlD,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAY;IAC9C,IAAI,GAAW,CAAC;IAChB,IAAI,CAAC;QACH,GAAG,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,EAAE,MAAM,CAAC,CAAC;IACvD,CAAC;IAAC,MAAM,CAAC;QACP,mEAAmE;QACnE,oCAAoC;QACpC,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,UAAU,EAAE,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC;IACtD,CAAC;IAED,MAAM,IAAI,GAAgB,EAAE,CAAC;IAC7B,MAAM,UAAU,GAAa,EAAE,CAAC;IAChC,GAAG,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE;QACtC,IAAI,IAAI,CAAC,IAAI,EAAE,KAAK,EAAE;YAAE,OAAO;QAC/B,IAAI,CAAC;YACH,MAAM,MAAM,GAAY,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;YACzC,IAAI,WAAW,CAAC,MAAM,CAAC;gBAAE,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;;gBACtC,UAAU,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QAClC,CAAC;QAAC,MAAM,CAAC;YACP,UAAU,CAAC,IAAI,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC;QAC7B,CAAC;IACH,CAAC,CAAC,CAAC;IACH,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,OAAO,EAAE,IAAI,EAAE,CAAC;AAC7C,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CAAC,IAAY,EAAE,GAAc;IAChE,MAAM,IAAI,GAAG,IAAI,CAAC,IAAI,EAAE,UAAU,CAAC,CAAC;IACpC,IAAI,CAAC;QACH,MAAM,KAAK,CAAC,IAAI,CAAC,IAAI,EAAE,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACnD,MAAM,SAAS,CAAC,IAAI,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,IAAI,EAAE,EAAE,IAAI,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QAC9E,OAAO,IAAI,CAAC;IACd,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IAChE,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trazum/cli",
3
- "version": "1.46.0",
3
+ "version": "1.48.0",
4
4
  "description": "Trazum CLI: find where your LLM bill goes, price every finding per month, and enforce token budgets in CI.",
5
5
  "license": "MIT",
6
6
  "author": "David Mu\u00f1oz Rey",
@@ -37,7 +37,7 @@
37
37
  "prepublishOnly": "npm run build && npm test"
38
38
  },
39
39
  "dependencies": {
40
- "@trazum/core": "1.46.0"
40
+ "@trazum/core": "1.48.0"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "^26.2.0",
package/src/i18n/en.ts CHANGED
@@ -1554,6 +1554,8 @@ ${bold('EXAMPLES')}
1554
1554
  `WAIVED — the ${gate} failure above is on the record and silenced until ${until} (${daysLeft} days left): "${reason}". The bill still counts it; only the exit code is quiet, and the day the waiver expires this gate fails again.`,
1555
1555
  waiveExpired: (gate, until, reason) =>
1556
1556
  `The waiver on ${gate} expired on ${until} and no longer silences anything. It was written for: "${reason}". Renew it with a new date and a current reason, or fix what it was covering — an expired waiver left in place is a finding deleted with extra steps.`,
1557
+ waiveNotRecorded: (path, because) =>
1558
+ ` (This waiver was not written to ${path}: ${because}. The gate's verdict above is unaffected.)`,
1557
1559
  summaryNoComparison: () =>
1558
1560
  'No previous log was given, so nothing here says whether the bill moved — a summary without a comparison states the bill, not its stability.',
1559
1561
  summaryFooter: () =>
@@ -1807,6 +1809,33 @@ ${bold('EXAMPLES')}
1807
1809
  unrecognized: (name) => `${name} is neither a stored report nor a saved plan, so it is in no series above.`,
1808
1810
  footer: () =>
1809
1811
  'A series names shapes, not futures. Twenty points make a trend visible; they do not make next month knowable — where these lines go next is yours to judge.',
1812
+ waiverHeading: () => 'What this repository has been living with',
1813
+ waiverSince: (day, uses) =>
1814
+ `${uses} recorded ${uses === 1 ? 'use' : 'uses'} since ${day}, when recording started.`,
1815
+ waiverNoneRecorded: () =>
1816
+ 'No waiver has silenced a gate since recording started. Nothing here is inferred from the config — a waiver written down and never hit is not a decision anyone is living with.',
1817
+ waiverHabit: (gate, uses, days, firstDay, lastDay) =>
1818
+ `${gate}: ${uses} ${uses === 1 ? 'use' : 'uses'} across ${days} ${days === 1 ? 'day' : 'days'}, ${firstDay} to ${lastDay}`,
1819
+ waiverVerdict: (verdict) =>
1820
+ verdict === 'used-once'
1821
+ ? 'Used once. Nothing to read into it yet.'
1822
+ : verdict === 'recurring'
1823
+ ? 'The same decision, holding. The gate keeps firing and the reason has not moved.'
1824
+ : verdict === 'renewed-without-revisiting'
1825
+ ? 'The expiry has moved and the reason has not. That is the shape a decision takes when nobody is revisiting it — which is sometimes exactly right, and worth saying out loud either way.'
1826
+ : 'The reason changed between uses. Somebody looked again.',
1827
+ waiverReasonNow: (reason) => `Reason: ${reason}`,
1828
+ waiverReasonsChanged: (count) => `${count} different reasons given over that time.`,
1829
+ waiverExpiriesMoved: (from, to, count) =>
1830
+ `Expiry moved ${count} ${count === 1 ? 'time' : 'times'}: ${from} → ${to}.`,
1831
+ waiverNoLongerConfigured: () =>
1832
+ 'Not in the config any more. The decision was reversed; the record keeps it.',
1833
+ waiverNeverUsed: (gates) =>
1834
+ `Waived in the config and never hit by a recorded run: ${gates}. Either the gate stopped failing — good news nobody wrote down — or the waiver names a situation that does not arise. Both are worth deleting.`,
1835
+ waiverUnreadable: (count, path) =>
1836
+ `${count} ${count === 1 ? 'line' : 'lines'} in ${path} could not be read and are not counted above.`,
1837
+ waiverStartsHere: () =>
1838
+ 'Nothing before that day exists. This record began when recording did, and no past was reconstructed from the config as it stands.',
1810
1839
  },
1811
1840
 
1812
1841
  verify: {
@@ -1814,8 +1843,18 @@ ${bold('EXAMPLES')}
1814
1843
  'Point this at a saved plan and a newer log: trazum verify plan.json --against usage.jsonl. It says, per action, whether the change arrived, did not arrive, or cannot be told — and never fewer than those three.',
1815
1844
  needsAgainst: () =>
1816
1845
  '--against <newer.jsonl|dir> is required. A plan can only be verified against a log that came after it; without one there is nothing to hold the prediction to.',
1817
- badPlan: (path) =>
1818
- `${path} is not a plan document this tool can verify expected the JSON that "trazum plan -o" writes (schemaVersion 1, with an actions array).`,
1846
+ badPlan: (path, why) =>
1847
+ `${path} is not a plan document this tool can verify: ${why}. Expected the JSON that "trazum plan -o" writes.`,
1848
+ planRefusal: (why) =>
1849
+ why.kind === 'not-json'
1850
+ ? 'it is not valid JSON'
1851
+ : why.kind === 'not-an-object'
1852
+ ? 'the top level is not a JSON object'
1853
+ : why.kind === 'wrong-schema-version'
1854
+ ? `schemaVersion is ${JSON.stringify(why.found)} rather than 1`
1855
+ : why.kind === 'actions-not-a-list'
1856
+ ? 'there is no actions array'
1857
+ : `action ${why.index + 1} is malformed (${why.because})`,
1819
1858
  heading: (actions, planDate) =>
1820
1859
  planDate === null
1821
1860
  ? `Did it work? ${actions} actions from an undated plan, against this log`
package/src/i18n/es.ts CHANGED
@@ -1584,6 +1584,8 @@ ${bold('EJEMPLOS')}
1584
1584
  `WAIVED — el fallo de ${gate} de arriba queda registrado y silenciado hasta ${until} (${daysLeft} días restantes): "${reason}". La factura lo sigue contando; solo el código de salida calla, y el día que caduque el waiver este gate vuelve a fallar.`,
1585
1585
  waiveExpired: (gate, until, reason) =>
1586
1586
  `El waiver de ${gate} caducó el ${until} y ya no silencia nada. Se escribió por: "${reason}". Renuévalo con fecha nueva y razón vigente, o arregla lo que cubría — un waiver caducado dejado ahí es un hallazgo borrado con pasos extra.`,
1587
+ waiveNotRecorded: (path, because) =>
1588
+ ` (Este waiver no se pudo escribir en ${path}: ${because}. El veredicto de la puerta de arriba no cambia.)`,
1587
1589
  summaryNoComparison: () =>
1588
1590
  'No se dio un registro anterior, así que nada de aquí dice si la factura se movió — un resumen sin comparación indica la factura, no su estabilidad.',
1589
1591
  summaryFooter: () =>
@@ -1837,6 +1839,33 @@ ${bold('EJEMPLOS')}
1837
1839
  unrecognized: (name) => `${name} no es ni un informe guardado ni un plan guardado, así que no está en ninguna serie de arriba.`,
1838
1840
  footer: () =>
1839
1841
  'Una serie nombra formas, no futuros. Veinte puntos hacen visible una tendencia; no hacen conocible el mes que viene — adónde van estas líneas después lo juzgas tú.',
1842
+ waiverHeading: () => 'Con lo que este repositorio ha estado conviviendo',
1843
+ waiverSince: (day, uses) =>
1844
+ `${uses} ${uses === 1 ? 'uso registrado' : 'usos registrados'} desde el ${day}, cuando empezó el registro.`,
1845
+ waiverNoneRecorded: () =>
1846
+ 'Ningún waiver ha silenciado una puerta desde que empezó el registro. Nada de aquí se deduce de la configuración — un waiver escrito y nunca usado no es una decisión con la que nadie conviva.',
1847
+ waiverHabit: (gate, uses, days, firstDay, lastDay) =>
1848
+ `${gate}: ${uses} ${uses === 1 ? 'uso' : 'usos'} en ${days} ${days === 1 ? 'día' : 'días'}, del ${firstDay} al ${lastDay}`,
1849
+ waiverVerdict: (verdict) =>
1850
+ verdict === 'used-once'
1851
+ ? 'Usado una vez. Todavía no hay nada que leer en ello.'
1852
+ : verdict === 'recurring'
1853
+ ? 'La misma decisión, sosteniéndose. La puerta sigue saltando y el motivo no ha cambiado.'
1854
+ : verdict === 'renewed-without-revisiting'
1855
+ ? 'La fecha de caducidad se ha movido y el motivo no. Esa es la forma que toma una decisión que nadie está revisando — a veces es exactamente lo correcto, y merece decirse en voz alta igualmente.'
1856
+ : 'El motivo cambió entre usos. Alguien volvió a mirarlo.',
1857
+ waiverReasonNow: (reason) => `Motivo: ${reason}`,
1858
+ waiverReasonsChanged: (count) => `${count} motivos distintos dados en ese tiempo.`,
1859
+ waiverExpiriesMoved: (from, to, count) =>
1860
+ `La caducidad se movió ${count} ${count === 1 ? 'vez' : 'veces'}: ${from} → ${to}.`,
1861
+ waiverNoLongerConfigured: () =>
1862
+ 'Ya no está en la configuración. La decisión se revirtió; el registro la conserva.',
1863
+ waiverNeverUsed: (gates) =>
1864
+ `Con waiver en la configuración y nunca usados por una ejecución registrada: ${gates}. O la puerta dejó de fallar — buenas noticias que nadie anotó — o el waiver nombra una situación que no ocurre. Ambos merecen borrarse.`,
1865
+ waiverUnreadable: (count, path) =>
1866
+ `${count} ${count === 1 ? 'línea' : 'líneas'} de ${path} no se pudieron leer y no cuentan arriba.`,
1867
+ waiverStartsHere: () =>
1868
+ 'Nada anterior a ese día existe. Este registro empezó cuando empezó a registrarse, y no se reconstruyó ningún pasado a partir de la configuración actual.',
1840
1869
  },
1841
1870
 
1842
1871
  verify: {
@@ -1844,8 +1873,18 @@ ${bold('EJEMPLOS')}
1844
1873
  'Apunta esto a un plan guardado y a un registro posterior: trazum verify plan.json --against usage.jsonl. Dice, por acción, si el cambio llegó, no llegó o no se puede saber — y nunca menos de esos tres.',
1845
1874
  needsAgainst: () =>
1846
1875
  '--against <nuevo.jsonl|dir> es obligatorio. Un plan solo puede verificarse contra un registro posterior; sin uno no hay nada a lo que someter la predicción.',
1847
- badPlan: (path) =>
1848
- `${path} no es un documento de plan que esta herramienta pueda verificar se esperaba el JSON que escribe "trazum plan -o" (schemaVersion 1, con un array actions).`,
1876
+ badPlan: (path, why) =>
1877
+ `${path} no es un documento de plan que esta herramienta pueda verificar: ${why}. Se esperaba el JSON que escribe "trazum plan -o".`,
1878
+ planRefusal: (why) =>
1879
+ why.kind === 'not-json'
1880
+ ? 'no es JSON válido'
1881
+ : why.kind === 'not-an-object'
1882
+ ? 'el nivel superior no es un objeto JSON'
1883
+ : why.kind === 'wrong-schema-version'
1884
+ ? `schemaVersion es ${JSON.stringify(why.found)} en vez de 1`
1885
+ : why.kind === 'actions-not-a-list'
1886
+ ? 'no hay un array actions'
1887
+ : `la acción ${why.index + 1} está mal formada (${why.because})`,
1849
1888
  heading: (actions, planDate) =>
1850
1889
  planDate === null
1851
1890
  ? `¿Funcionó? ${actions} acciones de un plan sin fecha, contra este registro`
package/src/i18n/types.ts CHANGED
@@ -877,6 +877,14 @@ export interface CliMessages {
877
877
  */
878
878
  waiveActive(gate: string, reason: string, until: string, daysLeft: string): string;
879
879
  waiveExpired(gate: string, until: string, reason: string): string;
880
+ /**
881
+ * A use that could not be written down.
882
+ *
883
+ * Printed dim and never as an error: the gate's verdict is unaffected, and
884
+ * a read-only checkout must not turn a passing build red on account of
885
+ * bookkeeping.
886
+ */
887
+ waiveNotRecorded(path: string, because: string): string;
880
888
  /**
881
889
  * `--markdown-summary`: the short form for a pull-request body or a weekly
882
890
  * note. A view over the same report, never a different set of figures.
@@ -1254,6 +1262,19 @@ export interface CliMessages {
1254
1262
  undated(name: string): string;
1255
1263
  unrecognized(name: string): string;
1256
1264
  footer(): string;
1265
+ /** The waiver record — what this team has been living with, and for how long. */
1266
+ waiverHeading(): string;
1267
+ waiverSince(day: string, uses: number): string;
1268
+ waiverNoneRecorded(): string;
1269
+ waiverHabit(gate: string, uses: number, days: number, firstDay: string, lastDay: string): string;
1270
+ waiverVerdict(verdict: string): string;
1271
+ waiverReasonNow(reason: string): string;
1272
+ waiverReasonsChanged(count: number): string;
1273
+ waiverExpiriesMoved(from: string, to: string, count: number): string;
1274
+ waiverNoLongerConfigured(): string;
1275
+ waiverNeverUsed(gates: string): string;
1276
+ waiverUnreadable(count: number, path: string): string;
1277
+ waiverStartsHere(): string;
1257
1278
  };
1258
1279
 
1259
1280
  /**
@@ -1269,7 +1290,22 @@ export interface CliMessages {
1269
1290
  noTarget(): string;
1270
1291
  needsAgainst(): string;
1271
1292
  /** Not a plan document: wrong shape, wrong version, or not JSON at all. */
1272
- badPlan(path: string): string;
1293
+ /**
1294
+ * `why` is the typed reason from `parsePlanDocument`, rendered so the
1295
+ * refusal names what is wrong rather than only that something is. A file
1296
+ * that is valid JSON and not a plan and a plan with one bad action are
1297
+ * different problems with different fixes.
1298
+ */
1299
+ badPlan(path: string, why: string): string;
1300
+ /** The typed refusal from `parsePlanDocument`, in one clause. */
1301
+ planRefusal(
1302
+ why:
1303
+ | { kind: 'not-json' }
1304
+ | { kind: 'not-an-object' }
1305
+ | { kind: 'wrong-schema-version'; found: unknown }
1306
+ | { kind: 'actions-not-a-list' }
1307
+ | { kind: 'action-malformed'; index: number; because: string },
1308
+ ): string;
1273
1309
  heading(actions: string, planDate: string | null): string;
1274
1310
  counts(arrived: string, notArrived: string, cannotTell: string): string;
1275
1311
  /** Two price lists are two measurements; every dollar line inherits this. */
package/src/index.ts CHANGED
@@ -37,6 +37,9 @@ import {
37
37
  DEFAULT_USAGE,
38
38
  detectFromSource,
39
39
  matchLocale,
40
+ parsePlanDocument,
41
+ waiverDay,
42
+ waiverHistory,
40
43
  proposeInit,
41
44
  MIN_RATE_DAYS,
42
45
  parseConfig,
@@ -125,6 +128,7 @@ import type {
125
128
  } from '@trazum/core';
126
129
  import type {
127
130
  UsageProfileReport,
131
+ WaiverUse,
128
132
  InitDecline,
129
133
  InitJustification,
130
134
  InitObservations,
@@ -167,6 +171,7 @@ import {
167
171
  import type { Revision } from './git.js';
168
172
  import { fetchProviderUsage, findCredential } from './connect.js';
169
173
  import { STORE_DIR, appendRecords, readStore, rewriteStore } from './store-fs.js';
174
+ import { WAIVER_LOG, appendWaiverUse, readWaiverLog } from './waiver-log.js';
170
175
  import { DEFAULT_PORT, buildServer, listen } from './serve.js';
171
176
  import {
172
177
  WATCH_STATE_VERSION,
@@ -3347,7 +3352,12 @@ async function commandConnect(
3347
3352
  * same action planned twice — and no series, however long, becomes a
3348
3353
  * forecast.
3349
3354
  */
3350
- async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessages): Promise<void> {
3355
+ async function commandHistory(
3356
+ args: Args,
3357
+ config: TrazumConfig,
3358
+ pricing: PricingCatalogue,
3359
+ t: CliMessages,
3360
+ ): Promise<void> {
3351
3361
  /**
3352
3362
  * `--store` builds the series from measured spend already on disk.
3353
3363
  *
@@ -3451,7 +3461,23 @@ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessa
3451
3461
  throw new Error(t.history.needsThree(String(history.periods.length)));
3452
3462
  }
3453
3463
 
3454
- const stamped = { ...history, unrecognizedFiles: unrecognized };
3464
+ /**
3465
+ * The waiver record — closing the gap 1.40 named and could not fill.
3466
+ *
3467
+ * 1.40 wanted to say "this finding has been waived three times in a row" and
3468
+ * refused to, because the only material available was the config as it
3469
+ * stands, and a past reconstructed from a present is a guess wearing a
3470
+ * record's clothes. The material exists now: since 1.48 a waiver that
3471
+ * silences a gate writes down that it did, and this reads those lines back.
3472
+ *
3473
+ * Read from the working directory rather than from the reports directory:
3474
+ * the waiver record belongs to the repository whose gates fired, and the
3475
+ * stored reports may have come from anywhere.
3476
+ */
3477
+ const waivers = await readWaiverLog('.');
3478
+ const waiverReport = waiverHistory(waivers.uses, config.waive ?? []);
3479
+
3480
+ const stamped = { ...history, unrecognizedFiles: unrecognized, waivers: waiverReport };
3455
3481
  const n = (value: number): string => value.toLocaleString(t.numberLocale);
3456
3482
  const day = (ms: number): string => new Date(ms).toISOString().slice(0, 10);
3457
3483
  const pct = (value: number): string => `${(value * 100).toFixed(1)}%`;
@@ -3505,6 +3531,58 @@ async function commandHistory(args: Args, pricing: PricingCatalogue, t: CliMessa
3505
3531
  for (const name of unrecognized) {
3506
3532
  out.push(md ? `- ${t.history.unrecognized(name)}` : ` ${t.history.unrecognized(name)}`);
3507
3533
  }
3534
+ /**
3535
+ * The waiver record, printed only once something has been recorded.
3536
+ *
3537
+ * Silent on a repository that has never waived anything, rather than a
3538
+ * heading over "0 uses" — an empty section teaches a reader to skip the
3539
+ * section, and this is the one they should not learn to skip.
3540
+ */
3541
+ if (waivers.present) {
3542
+ out.push('');
3543
+ out.push(md ? `### ${t.history.waiverHeading()}` : t.history.waiverHeading());
3544
+ if (waiverReport.totalUses === 0) {
3545
+ out.push(md ? `- ${t.history.waiverNoneRecorded()}` : ` ${t.history.waiverNoneRecorded()}`);
3546
+ } else {
3547
+ const since = t.history.waiverSince(waiverReport.since ?? '', waiverReport.totalUses);
3548
+ out.push(md ? `- ${since}` : ` ${since}`);
3549
+ out.push(md ? `- _${t.history.waiverStartsHere()}_` : ` ${t.history.waiverStartsHere()}`);
3550
+ for (const habit of waiverReport.habits) {
3551
+ out.push('');
3552
+ const head = t.history.waiverHabit(
3553
+ habit.gate,
3554
+ habit.uses,
3555
+ habit.days,
3556
+ habit.firstDay,
3557
+ habit.lastDay,
3558
+ );
3559
+ out.push(md ? `- **${head}**` : ` ${head}`);
3560
+ const rows = [t.history.waiverVerdict(habit.verdict)];
3561
+ // The reason as it stands *now* — never read backwards onto an
3562
+ // older use, which is the same mistake the record exists to avoid.
3563
+ const latest = habit.reasons[habit.reasons.length - 1];
3564
+ if (latest !== undefined) rows.push(t.history.waiverReasonNow(latest));
3565
+ if (habit.reasons.length > 1) rows.push(t.history.waiverReasonsChanged(habit.reasons.length));
3566
+ const firstExpiry = habit.expiries[0];
3567
+ const lastExpiry = habit.expiries[habit.expiries.length - 1];
3568
+ if (habit.expiries.length > 1 && firstExpiry !== undefined && lastExpiry !== undefined) {
3569
+ rows.push(t.history.waiverExpiriesMoved(firstExpiry, lastExpiry, habit.expiries.length - 1));
3570
+ }
3571
+ if (!habit.stillConfigured) rows.push(t.history.waiverNoLongerConfigured());
3572
+ for (const row of rows) out.push(md ? ` - ${row}` : ` ${row}`);
3573
+ }
3574
+ }
3575
+ if (waiverReport.neverUsed.length > 0) {
3576
+ out.push('');
3577
+ const dead = t.history.waiverNeverUsed(waiverReport.neverUsed.join(', '));
3578
+ out.push(md ? `- ${dead}` : ` ${dead}`);
3579
+ }
3580
+ if (waivers.unreadable.length > 0) {
3581
+ const bad = t.history.waiverUnreadable(waivers.unreadable.length, WAIVER_LOG);
3582
+ out.push(md ? `- ${bad}` : ` ${bad}`);
3583
+ }
3584
+ }
3585
+
3508
3586
  if (fromStore) {
3509
3587
  out.push('');
3510
3588
  const note = t.history.storeNoLabels();
@@ -3546,17 +3624,21 @@ async function commandVerify(
3546
3624
  const againstPath = stringFlag(args, 'against');
3547
3625
  if (againstPath === undefined) throw new Error(t.verify.needsAgainst());
3548
3626
 
3549
- let plan: PlanDocument & { createdAt?: string };
3550
- try {
3551
- const parsed = JSON.parse(await readFile(planPath, 'utf8'));
3552
- if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.actions)) {
3553
- throw new Error(t.verify.badPlan(planPath));
3554
- }
3555
- plan = parsed;
3556
- } catch (error) {
3557
- if (error instanceof SyntaxError) throw new Error(t.verify.badPlan(planPath));
3558
- throw error;
3627
+ /**
3628
+ * One validator, shared with the browser since 1.47.
3629
+ *
3630
+ * The check here used to be `schemaVersion === 1 && Array.isArray(actions)`
3631
+ * and nothing more, which accepts a file whose actions are arbitrary
3632
+ * objects — `verifyPlan` would then read `label` off `undefined`, match it
3633
+ * against no slice, and report `cannot-tell: workload-vanished` for every
3634
+ * one. A verification of a document that was never a plan, rendered exactly
3635
+ * like a real one.
3636
+ */
3637
+ const parsed = parsePlanDocument(await readFile(planPath, 'utf8'));
3638
+ if (!parsed.ok) {
3639
+ throw new Error(t.verify.badPlan(planPath, t.verify.planRefusal(parsed.why)));
3559
3640
  }
3641
+ const plan = parsed.plan;
3560
3642
 
3561
3643
  const GZ = LOG_EXTENSIONS.map((ext) => `${ext}.gz`);
3562
3644
  const READABLE = [...LOG_EXTENSIONS, ...GZ];
@@ -4243,7 +4325,16 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4243
4325
  * way, because a waived failure that vanished from the output would be a
4244
4326
  * finding deleted with extra steps.
4245
4327
  */
4246
- const waived = (gate: string): boolean => {
4328
+ /**
4329
+ * Uses recorded this run, flushed after the gates have finished.
4330
+ *
4331
+ * Collected rather than written inline because `waived` is synchronous and
4332
+ * called from seven places inside the gate pass. Writing from each of them
4333
+ * would mean seven awaits threaded through the exit-code logic — the one
4334
+ * part of this command where a mistake turns a red build green.
4335
+ */
4336
+ const waiverUses: WaiverUse[] = [];
4337
+ const waived = (gate: string, measuredUsd: number | null = null, limitUsd: number | null = null): boolean => {
4247
4338
  const found = waiverFor(gate);
4248
4339
  if (found === null) return false;
4249
4340
  if (found.expired) {
@@ -4259,6 +4350,29 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4259
4350
  console.error(
4260
4351
  c.yellow(t.profile.waiveActive(gate, found.entry.reason, found.entry.until, String(daysLeft))),
4261
4352
  );
4353
+ /**
4354
+ * Recorded **when it silences something**, never when it is configured.
4355
+ *
4356
+ * A waiver nobody's build has ever hit is not a habit — it is dead config
4357
+ * — and the history reports the two apart. This is also the only honest
4358
+ * way to build the record 1.40 refused to invent: it starts today and
4359
+ * says so, rather than reconstructing a past from the present.
4360
+ *
4361
+ * The reason and the expiry are taken from the config **as it stands at
4362
+ * this moment**, because that is the decision that was actually in force.
4363
+ * Reading today's reason back onto last quarter's use is the same mistake
4364
+ * one layer down.
4365
+ */
4366
+ waiverUses.push({
4367
+ schemaVersion: 1,
4368
+ day: waiverDay(new Date()),
4369
+ gate,
4370
+ reason: found.entry.reason,
4371
+ until: found.entry.until,
4372
+ commit: process.env.GITHUB_SHA ?? process.env.CI_COMMIT_SHA ?? null,
4373
+ measuredUsd,
4374
+ limitUsd,
4375
+ });
4262
4376
  return true;
4263
4377
  };
4264
4378
  const applyGates = (): void => {
@@ -4307,7 +4421,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4307
4421
  }
4308
4422
  if (usd > limit) {
4309
4423
  console.error(c.red(t.profile.labelBudgetFailed(label, formatUsd(usd), formatUsd(limit))));
4310
- if (!waived(`byLabel:${label}`)) process.exitCode = 1;
4424
+ if (!waived(`byLabel:${label}`, usd, limit)) process.exitCode = 1;
4311
4425
  } else {
4312
4426
  console.error(c.dim(t.profile.labelBudgetOk(label, formatUsd(usd), formatUsd(limit))));
4313
4427
  }
@@ -4380,7 +4494,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4380
4494
  * copy says so.
4381
4495
  */
4382
4496
  explainFailure(report.total.totalUsd - maxUsd);
4383
- if (!waived('maxUsd')) process.exitCode = 1;
4497
+ if (!waived('maxUsd', report.total.totalUsd, maxUsd)) process.exitCode = 1;
4384
4498
  } else {
4385
4499
  console.error(c.dim(t.profile.maxUsdOk(formatUsd(report.total.totalUsd), formatUsd(maxUsd))));
4386
4500
  explainMargin(report.total.totalUsd, maxUsd);
@@ -4420,7 +4534,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4420
4534
  process.exitCode = 1;
4421
4535
  } else if (againstDelta > maxGrowth) {
4422
4536
  console.error(c.red(t.profile.maxGrowthUsdFailed(formatSignedUsd(againstDelta), formatUsd(maxGrowth))));
4423
- if (!waived('maxGrowthUsd')) process.exitCode = 1;
4537
+ if (!waived('maxGrowthUsd', againstDelta, maxGrowth)) process.exitCode = 1;
4424
4538
  }
4425
4539
  }
4426
4540
  /**
@@ -4438,7 +4552,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4438
4552
  console.error(
4439
4553
  c.red(t.profile.maxCacheLossFailed(formatUsd(gateCache.deltaUsd), formatUsd(maxLoss))),
4440
4554
  );
4441
- if (!waived('maxCacheLossUsd')) process.exitCode = 1;
4555
+ if (!waived('maxCacheLossUsd', gateCache.deltaUsd, maxLoss)) process.exitCode = 1;
4442
4556
  } else if (gateCache.worstCaseDeltaUsd > maxLoss) {
4443
4557
  console.error(
4444
4558
  c.red(
@@ -4449,7 +4563,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4449
4563
  ),
4450
4564
  ),
4451
4565
  );
4452
- if (!waived('maxCacheLossUsd')) process.exitCode = 1;
4566
+ if (!waived('maxCacheLossUsd', gateCache.worstCaseDeltaUsd, maxLoss)) process.exitCode = 1;
4453
4567
  } else {
4454
4568
  console.error(
4455
4569
  c.dim(t.profile.maxCacheLossOk(formatUsd(Math.max(0, gateCache.worstCaseDeltaUsd)), formatUsd(maxLoss))),
@@ -4498,7 +4612,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4498
4612
  c.red(`${t.profile.maxDayFailed(worst.day, formatUsd(worst.usd), formatUsd(maxDay))}${suspect}`),
4499
4613
  );
4500
4614
  explainFailure(worst.usd - maxDay, { namesLargest: true });
4501
- if (!waived('maxDayUsd')) process.exitCode = 1;
4615
+ if (!waived('maxDayUsd', worst.usd, maxDay)) process.exitCode = 1;
4502
4616
  } else {
4503
4617
  console.error(c.dim(t.profile.maxDayOk(worst.day, formatUsd(worst.usd), formatUsd(maxDay))));
4504
4618
  explainMargin(worst.usd, maxDay);
@@ -4540,7 +4654,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4540
4654
  c.red(t.profile.maxSessionFailed(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
4541
4655
  );
4542
4656
  explainFailure(report.sessionSpend.maxUsd - maxSession);
4543
- if (!waived('maxSessionUsd')) process.exitCode = 1;
4657
+ if (!waived('maxSessionUsd', report.sessionSpend.maxUsd, maxSession)) process.exitCode = 1;
4544
4658
  } else {
4545
4659
  console.error(
4546
4660
  c.dim(t.profile.maxSessionOk(formatUsd(report.sessionSpend.maxUsd), formatUsd(maxSession), n(report.sessionSpend.sessions))),
@@ -4574,6 +4688,26 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4574
4688
  }
4575
4689
  };
4576
4690
 
4691
+ /**
4692
+ * Writes down every waiver that silenced something this run.
4693
+ *
4694
+ * **A failure here never fails the build.** The gate's job is the exit code;
4695
+ * a read-only checkout or a full disk must not turn a passing build red on
4696
+ * account of bookkeeping. The problem is reported and the gate's own verdict
4697
+ * stands — which is also why this runs after `recordGates` rather than
4698
+ * inside it: nothing about the exit code depends on the write.
4699
+ */
4700
+ const recordWaiverUses = async (): Promise<void> => {
4701
+ if (waiverUses.length === 0) return;
4702
+ for (const use of waiverUses) {
4703
+ const failed = await appendWaiverUse('.', use);
4704
+ if (failed !== null) {
4705
+ console.error(c.dim(t.profile.waiveNotRecorded(WAIVER_LOG, failed)));
4706
+ return;
4707
+ }
4708
+ }
4709
+ };
4710
+
4577
4711
  /**
4578
4712
  * The side files the caller asked for. Written on **both** output paths:
4579
4713
  * under --json the human rendering returns early, and the first version of
@@ -4740,6 +4874,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
4740
4874
  ),
4741
4875
  );
4742
4876
  recordGates();
4877
+ await recordWaiverUses();
4743
4878
  await writeSideFiles();
4744
4879
  return;
4745
4880
  }
@@ -5894,6 +6029,7 @@ async function commandProfile(args: Args, config: TrazumConfig, pricing: Pricing
5894
6029
  reportProfileGaps(report, t, n, pricingStale);
5895
6030
 
5896
6031
  recordGates();
6032
+ await recordWaiverUses();
5897
6033
 
5898
6034
  await writeSideFiles();
5899
6035
  }
@@ -7607,7 +7743,7 @@ async function main(): Promise<void> {
7607
7743
  await commandVerify(args, pricing, t);
7608
7744
  break;
7609
7745
  case 'history':
7610
- await commandHistory(args, pricing, t);
7746
+ await commandHistory(args, config, pricing, t);
7611
7747
  break;
7612
7748
  case 'connect':
7613
7749
  await commandConnect(args, pricing, t);