tamper-signal 1.7.0 → 1.7.1

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/AGENTS.md CHANGED
@@ -299,6 +299,25 @@ folded into `verdict`, `exit_code`, `caveats`, and `report` (a missing
299
299
  anchor turns a green run yellow; a mismatch turns it red), so the payload
300
300
  never contradicts itself.
301
301
 
302
+ `--json` is also available on `ingest`, `export`, and `doctor` (and `diff`,
303
+ `log`, `anchor`), so an agent can drive the whole flow without scraping text.
304
+ `ingest --json` returns `source`, `evidence_hash`, `semantic_hash`,
305
+ `row_count`, `column_count`, `tolerance`, and `source_manifest`;
306
+ `export --json` returns `output`, `row_count`, `column_count`, `data_hash`,
307
+ and `bundle`; `doctor --json` returns `checks` (each with `name`, `ok`, `fix`),
308
+ `warnings`, and `all_passed`. `log --json` carries the declared `band` and
309
+ `settle_hours` per run entry when that run signed a tolerance. Failures under
310
+ `--json` print a structured `{"ok": false, "error": ...}` object on stdout.
311
+ The Python and Node payloads are key-identical for every shared command; Node
312
+ has no `doctor` command, so `doctor --json` is Python-only.
313
+
314
+ The human CLI is colored on an interactive terminal: the verdict shows as a
315
+ green/amber/red `●` light that agrees with the exit code, and `diff` deltas are
316
+ colored by direction. Color never appears in `--json` output or when stdout is
317
+ piped or redirected. It honors `NO_COLOR` (force off, wins over everything),
318
+ `FORCE_COLOR` (force on past the TTY check), and a `--no-color` flag. Agents
319
+ parsing stdout get clean, ANSI-free output by default (a pipe is not a TTY).
320
+
302
321
  ### CI: verify the chain on every push
303
322
 
304
323
  ```yaml
package/node/cli.js CHANGED
@@ -15,6 +15,7 @@ import { basename, dirname, join } from "node:path";
15
15
  import { parseArgs } from "node:util";
16
16
  import process from "node:process";
17
17
 
18
+ import * as color from "./color.js";
18
19
  import { canonicalDocument, canonicalJsonBytes, semanticHash } from "./canonical.js";
19
20
  import {
20
21
  LOG_GRANULARITIES,
@@ -181,6 +182,12 @@ function archivePriorChain(chainDir) {
181
182
  }
182
183
  }
183
184
 
185
+ // Emit a structured payload on stdout (the established --json convention,
186
+ // shared byte-for-byte with the Python CLI).
187
+ function printJson(payload) {
188
+ console.log(JSON.stringify(payload, null, 2));
189
+ }
190
+
184
191
  // `ingest --as period`: continue the chain's run history under a trusted signer.
185
192
  function cmdIngestPeriod(values, file) {
186
193
  // Like replace, compute the unsnapshotted-reset condition and preserve the
@@ -205,19 +212,36 @@ function cmdIngestPeriod(values, file) {
205
212
  });
206
213
  } catch (err) {
207
214
  if (err instanceof UntrustedSignerError) {
208
- console.error(`✗ Refusing to append a period: ${err.message}`);
215
+ if (values.json) printJson({ ok: false, error: `Refusing to append a period: ${err.message}` });
216
+ else console.error(`✗ Refusing to append a period: ${err.message}`);
209
217
  return 1;
210
218
  }
211
- console.error(err.message);
219
+ if (values.json) printJson({ ok: false, error: err.message });
220
+ else console.error(err.message);
212
221
  return 1;
213
222
  }
214
223
  if (unsnapshottedReset) {
215
224
  console.error("warning: previous run was never verified; its totals will not enter history");
216
225
  }
217
226
  const totals = result.manifest.control_totals;
227
+ if (values.json) {
228
+ const caveats = result.caveats ?? [];
229
+ printJson({
230
+ source: result.manifest.source.filename,
231
+ evidence_hash: result.manifest.source.evidence_hash,
232
+ semantic_hash: result.manifest.semantic_hash,
233
+ row_count: totals.row_count,
234
+ column_count: totals.column_count,
235
+ mode: "period",
236
+ verdict: caveats.length ? "yellow" : "green",
237
+ caveats,
238
+ compared: Boolean(result.compared),
239
+ });
240
+ return caveats.length ? 2 : 0;
241
+ }
218
242
  console.log(`Imported next period: ${result.manifest.source.filename}`);
219
- console.log(` evidence_hash ${result.manifest.source.evidence_hash}`);
220
- console.log(` semantic_hash ${result.manifest.semantic_hash}`);
243
+ console.log(` evidence_hash ${color.dim(result.manifest.source.evidence_hash)}`);
244
+ console.log(` semantic_hash ${color.dim(result.manifest.semantic_hash)}`);
221
245
  console.log(` rows ${totals.row_count}, columns ${totals.column_count}`);
222
246
  const grouped = groupedNumericColumns(result.records);
223
247
  if (grouped.length) {
@@ -255,6 +279,7 @@ function cmdIngest(args) {
255
279
  "bucket-column": { type: "string" },
256
280
  as: { type: "string", default: "replace" },
257
281
  pub: { type: "string", multiple: true, default: [] },
282
+ json: { type: "boolean", default: false },
258
283
  },
259
284
  });
260
285
  const file = positionals[0];
@@ -290,16 +315,29 @@ function cmdIngest(args) {
290
315
  bucketColumn: values["bucket-column"] ?? null,
291
316
  }));
292
317
  } catch (err) {
293
- console.error(err.message);
318
+ if (values.json) printJson({ ok: false, error: err.message });
319
+ else console.error(err.message);
294
320
  return 1;
295
321
  }
296
322
  if (unsnapshottedReset) {
297
323
  console.error("warning: previous run was never verified; its totals will not enter history");
298
324
  }
299
325
  const totals = manifest.control_totals;
326
+ if (values.json) {
327
+ printJson({
328
+ source: manifest.source.filename,
329
+ evidence_hash: manifest.source.evidence_hash,
330
+ semantic_hash: manifest.semantic_hash,
331
+ row_count: totals.row_count,
332
+ column_count: totals.column_count,
333
+ tolerance: manifest.tolerance ?? null,
334
+ source_manifest: `${values.out.replace(/\/?$/, "/")}${SOURCE_RECEIPT_NAME}`,
335
+ });
336
+ return 0;
337
+ }
300
338
  console.log(`Ingested ${basename(file)}`);
301
- console.log(` evidence_hash ${manifest.source.evidence_hash}`);
302
- console.log(` semantic_hash ${manifest.semantic_hash}`);
339
+ console.log(` evidence_hash ${color.dim(manifest.source.evidence_hash)}`);
340
+ console.log(` semantic_hash ${color.dim(manifest.semantic_hash)}`);
303
341
  console.log(` rows ${totals.row_count}, columns ${totals.column_count}`);
304
342
  if (manifest.tolerance) {
305
343
  const t = manifest.tolerance;
@@ -422,6 +460,9 @@ function cmdVerify(args) {
422
460
  };
423
461
  console.log(JSON.stringify(payload, null, 2));
424
462
  } else {
463
+ if (color.shouldColor()) {
464
+ console.log(`${color.light(result.verdict)} ${color.colorize(result.verdict.toUpperCase(), result.verdict)}`);
465
+ }
425
466
  for (const line of result.lines) console.log(line);
426
467
  }
427
468
  // Archive the run snapshot AFTER the final exit code settled: a red run
@@ -602,14 +643,14 @@ function renderStageDelta(delta) {
602
643
  for (const key of ["row_count", "column_count"]) {
603
644
  const entry = delta[key];
604
645
  if (entry) {
605
- const suffix = "delta" in entry ? ` (${signedInt(entry.delta)})` : "";
646
+ const suffix = "delta" in entry ? ` (${color.signed(signedInt(entry.delta))})` : "";
606
647
  lines.push(`${key} ${entry.before} -> ${entry.after}${suffix}`);
607
648
  }
608
649
  }
609
650
  for (const [column, entry] of Object.entries(delta.numeric_sums ?? {})) {
610
651
  const before = entry.before !== null ? entry.before : "(added)";
611
652
  const after = entry.after !== null ? entry.after : "(removed)";
612
- const suffix = "delta" in entry ? ` (${entry.delta})` : "";
653
+ const suffix = "delta" in entry ? ` (${color.signed(entry.delta)})` : "";
613
654
  lines.push(`${column} ${before} -> ${after}${suffix}`);
614
655
  }
615
656
  for (const [column, entry] of Object.entries(delta.null_counts ?? {})) {
@@ -1014,19 +1055,36 @@ function cmdLog(args) {
1014
1055
  const { rows, collapsed } = buildLogPeriods(items, granularity, metrics);
1015
1056
 
1016
1057
  if (values.json) {
1017
- const payload = {
1018
- granularity,
1019
- // Total runs collapsed away by the granularity. Ordered chronological
1020
- // (oldest first), matching the Python CLI byte-for-byte.
1021
- collapsed,
1022
- runs: rows.map((row, index) => ({
1058
+ // The signed tolerance declaration is per-snapshot, display-only; surface
1059
+ // it per run entry (omitted when that run declared none), matching Python.
1060
+ const tolByTail = new Map();
1061
+ for (const snapshot of snapshots) {
1062
+ if (snapshot.tolerance && typeof snapshot.tolerance === "object") {
1063
+ tolByTail.set(snapshot.chain_tail_hash, snapshot.tolerance);
1064
+ }
1065
+ }
1066
+ const runEntry = (row, index) => {
1067
+ const entry = {
1023
1068
  period: row.period,
1024
1069
  created_at: row.created_at,
1025
1070
  tail: row.tail ? row.tail.slice(0, 8) : null,
1026
1071
  unsigned: row.unsigned,
1027
1072
  metrics: logJsonMetrics(rows, index, metrics),
1028
1073
  breached: metrics.filter((m) => row.breached.has(m)).sort(),
1029
- })),
1074
+ };
1075
+ const tolerance = tolByTail.get(row.tail);
1076
+ if (tolerance && typeof tolerance === "object") {
1077
+ if (tolerance.band != null) entry.band = tolerance.band;
1078
+ if (tolerance.settle_hours != null) entry.settle_hours = tolerance.settle_hours;
1079
+ }
1080
+ return entry;
1081
+ };
1082
+ const payload = {
1083
+ granularity,
1084
+ // Total runs collapsed away by the granularity. Ordered chronological
1085
+ // (oldest first), matching the Python CLI byte-for-byte.
1086
+ collapsed,
1087
+ runs: rows.map(runEntry),
1030
1088
  };
1031
1089
  console.log(JSON.stringify(payload, null, 2));
1032
1090
  return 0;
@@ -1045,6 +1103,7 @@ function cmdExport(args) {
1045
1103
  data: { type: "string" },
1046
1104
  out: { type: "string" },
1047
1105
  bundle: { type: "boolean" },
1106
+ json: { type: "boolean", default: false },
1048
1107
  },
1049
1108
  });
1050
1109
  const chainPath = positionals[0];
@@ -1062,11 +1121,13 @@ function cmdExport(args) {
1062
1121
  try {
1063
1122
  receipts = (chain.receipts ?? []).map((name) => readReceipt(chainDir, name));
1064
1123
  } catch (err) {
1065
- console.error(`Cannot load chain: ${err.message}`);
1124
+ if (values.json) printJson({ ok: false, error: `Cannot load chain: ${err.message}` });
1125
+ else console.error(`Cannot load chain: ${err.message}`);
1066
1126
  return 1;
1067
1127
  }
1068
1128
  if (!receipts.length) {
1069
- console.error("Chain is empty; nothing to export against.");
1129
+ if (values.json) printJson({ ok: false, error: "Chain is empty; nothing to export against." });
1130
+ else console.error("Chain is empty; nothing to export against.");
1070
1131
  return 1;
1071
1132
  }
1072
1133
 
@@ -1080,10 +1141,19 @@ function cmdExport(args) {
1080
1141
  const dataHash = createHash("sha256").update(canonicalJsonBytes(document)).digest("hex");
1081
1142
  const expected = outputHashOf(receipts[receipts.length - 1]);
1082
1143
  if (dataHash !== expected) {
1083
- console.error("✗ Refusing to export: the data does not match the final receipt.");
1084
- console.error(` expected output hash ${expected}`);
1085
- console.error(` found data hash ${dataHash}`);
1086
- console.error(" The Data tab only shows attested data. Re-run the pipeline or fix --data.");
1144
+ if (values.json) {
1145
+ printJson({
1146
+ ok: false,
1147
+ error: "the data does not match the final receipt",
1148
+ expected_output_hash: expected,
1149
+ data_hash: dataHash,
1150
+ });
1151
+ } else {
1152
+ console.error("✗ Refusing to export: the data does not match the final receipt.");
1153
+ console.error(` expected output hash ${expected}`);
1154
+ console.error(` found data hash ${dataHash}`);
1155
+ console.error(" The Data tab only shows attested data. Re-run the pipeline or fix --data.");
1156
+ }
1087
1157
  return 1;
1088
1158
  }
1089
1159
 
@@ -1103,22 +1173,46 @@ function cmdExport(args) {
1103
1173
  const stem = dataName.replace(/\.[^.]+$/, "");
1104
1174
  const bundlePath = values.out || join(chainDir, `${stem}-verified.zip`);
1105
1175
  writeFileSync(bundlePath, makeStoredZip(entries));
1176
+ if (values.json) {
1177
+ printJson({
1178
+ output: bundlePath,
1179
+ data: dataName,
1180
+ receipts: receiptNames.length,
1181
+ data_hash: dataHash,
1182
+ bundle: true,
1183
+ });
1184
+ return 0;
1185
+ }
1106
1186
  console.log(`Exported verified bundle: ${bundlePath}`);
1107
1187
  console.log(` data ${dataName}, ${receiptNames.length} receipts + ${CHAIN_FILENAME}`);
1108
- console.log(` semantic_hash ${dataHash} (matches final receipt)`);
1188
+ console.log(` semantic_hash ${color.dim(dataHash)} (matches final receipt)`);
1109
1189
  console.log(` recipient: unzip, then \`tamper-signal verify ${CHAIN_FILENAME}\``);
1110
1190
  return 0;
1111
1191
  }
1112
1192
 
1113
1193
  const outPath = values.out || join(chainDir, "table.json");
1114
1194
  writeFileSync(outPath, JSON.stringify(document, null, 2) + "\n");
1195
+ if (values.json) {
1196
+ printJson({
1197
+ output: outPath,
1198
+ row_count: document.rows.length,
1199
+ column_count: document.headers.length,
1200
+ data_hash: dataHash,
1201
+ bundle: false,
1202
+ });
1203
+ return 0;
1204
+ }
1115
1205
  console.log(`Exported verified table: ${outPath}`);
1116
1206
  console.log(` rows ${document.rows.length}, columns ${document.headers.length}`);
1117
- console.log(` semantic_hash ${dataHash} (matches final receipt)`);
1207
+ console.log(` semantic_hash ${color.dim(dataHash)} (matches final receipt)`);
1118
1208
  return 0;
1119
1209
  }
1120
1210
 
1121
- const [, , command, ...rest] = process.argv;
1211
+ const [, , command, ...rawRest] = process.argv;
1212
+ // --no-color is global: honor it at any position and strip it so each command's
1213
+ // strict parser does not reject it. NO_COLOR / FORCE_COLOR env are honored too.
1214
+ if (rawRest.includes("--no-color")) color.setNoColor(true);
1215
+ const rest = rawRest.filter((arg) => arg !== "--no-color");
1122
1216
  const commands = { keygen: cmdKeygen, ingest: cmdIngest, verify: cmdVerify, diff: cmdDiff, log: cmdLog, export: cmdExport };
1123
1217
  if (!command || !(command in commands)) {
1124
1218
  console.error(USAGE);
package/node/color.js ADDED
@@ -0,0 +1,78 @@
1
+ // Terminal color for the human-facing CLI: the Node port of tamper_signal/color.py.
2
+ // Presentation layer only. Color is emitted to stdout only when it is an
3
+ // interactive terminal and no override turns it off; every primitive returns
4
+ // plain text when color is off, so the word and glyph survive without ANSI.
5
+ //
6
+ // Gating precedence (highest first):
7
+ // 1. --no-color flag (setNoColor) -> off
8
+ // 2. NO_COLOR env present (any value) -> off
9
+ // 3. FORCE_COLOR env present -> on
10
+ // 4. otherwise -> stream.isTTY
11
+ // NO_COLOR wins over FORCE_COLOR. shouldColor is only evaluated against stdout;
12
+ // stderr (notices) and --json output are always plain.
13
+
14
+ // SGR codes. Kept identical to tamper_signal/color.py so the two CLIs match.
15
+ export const RESET = "\x1b[0m";
16
+ export const DIM = "\x1b[2m";
17
+ export const BOLD = "\x1b[1m";
18
+ export const GREEN = "\x1b[32m";
19
+ export const YELLOW = "\x1b[33m";
20
+ export const RED = "\x1b[31m";
21
+
22
+ // "amber" names the color; the verdict value stays "yellow".
23
+ const VERDICT_COLOR = { green: GREEN, yellow: YELLOW, red: RED };
24
+
25
+ // Set by the CLI when --no-color is passed. Always wins over the environment.
26
+ let noColor = false;
27
+
28
+ export function setNoColor(value) {
29
+ noColor = Boolean(value);
30
+ }
31
+
32
+ export function shouldColor(stream = process.stdout) {
33
+ if (noColor) return false;
34
+ if (process.env.NO_COLOR !== undefined) return false;
35
+ if (process.env.FORCE_COLOR !== undefined) return true;
36
+ return Boolean(stream && stream.isTTY);
37
+ }
38
+
39
+ function paint(text, code, stream) {
40
+ return shouldColor(stream) ? `${code}${text}${RESET}` : text;
41
+ }
42
+
43
+ // Dim secondary detail (hashes, counts) when color is on.
44
+ export function dim(text, stream = process.stdout) {
45
+ return paint(text, DIM, stream);
46
+ }
47
+
48
+ export function bold(text, stream = process.stdout) {
49
+ return paint(text, BOLD, stream);
50
+ }
51
+
52
+ // The colored traffic-light glyph for a verdict ("green"/"yellow"/"red").
53
+ // The caller prints the verdict word alongside it, so meaning survives color-off.
54
+ export function light(verdict, stream = process.stdout) {
55
+ return paint("●", VERDICT_COLOR[verdict] ?? "", stream);
56
+ }
57
+
58
+ // Paint text in a verdict's color ("green"/"yellow"/"red").
59
+ export function colorize(text, verdict, stream = process.stdout) {
60
+ return paint(text, VERDICT_COLOR[verdict] ?? "", stream);
61
+ }
62
+
63
+ // Color a pre-formatted signed token by its sign (+green, -red).
64
+ export function signed(token, stream = process.stdout) {
65
+ const text = String(token);
66
+ if (text.startsWith("+")) return paint(text, GREEN, stream);
67
+ if (text.startsWith("-")) return paint(text, RED, stream);
68
+ return text;
69
+ }
70
+
71
+ // A signed movement value, colored by direction (increase green, decrease red).
72
+ // The sign is always printed, so direction reads without color and for
73
+ // colorblind users. Direction is a neutral cue, not a verdict.
74
+ export function delta(value, stream = process.stdout) {
75
+ if (value > 0) return paint(`+${value}`, GREEN, stream);
76
+ if (value < 0) return paint(`${value}`, RED, stream);
77
+ return "0";
78
+ }
@@ -0,0 +1,87 @@
1
+ // Node CLI color application and gating (U6): the colored verdict headline and
2
+ // the NO_COLOR / FORCE_COLOR / --no-color gate, mirroring tests/test_cli_color.py.
3
+ // Color is gated to a TTY, so these force it on with FORCE_COLOR (the pipe from
4
+ // execFileSync is not a TTY) and assert plain output when it is off.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { test } from "node:test";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ import { GREEN, RESET } from "../color.js";
15
+
16
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
17
+ const cli = join(repoRoot, "node", "cli.js");
18
+
19
+ function run(cwd, args, env = {}) {
20
+ // Start from a clean slate: NO_COLOR / FORCE_COLOR only present when a test
21
+ // sets them (a key set to undefined would stringify to "undefined").
22
+ const base = { ...process.env, TAMPER_SIGNAL_KEY: "" };
23
+ delete base.NO_COLOR;
24
+ delete base.FORCE_COLOR;
25
+ return execFileSync(process.execPath, [cli, ...args], { cwd, env: { ...base, ...env }, encoding: "utf-8" });
26
+ }
27
+
28
+ function seedChain() {
29
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-color-"));
30
+ run(dir, ["keygen", "--out", "keys"]);
31
+ writeFileSync(join(dir, "d.csv"), "day,amount\n2026-05-01,10\n");
32
+ run(dir, ["ingest", "d.csv"]);
33
+ return dir;
34
+ }
35
+
36
+ test("verify headline is a colored light under FORCE_COLOR", () => {
37
+ const dir = seedChain();
38
+ const out = run(dir, ["verify", "receipts/chain.json"], { FORCE_COLOR: "1" });
39
+ assert.ok(out.includes(`${GREEN}●${RESET}`));
40
+ assert.ok(out.includes(`${GREEN}GREEN${RESET}`));
41
+ assert.ok(out.includes("CHAIN INTACT"));
42
+ });
43
+
44
+ test("verify piped is plain text with no ANSI", () => {
45
+ const dir = seedChain();
46
+ const out = run(dir, ["verify", "receipts/chain.json"]);
47
+ assert.ok(!out.includes("\x1b"));
48
+ assert.ok(out.includes("CHAIN INTACT"));
49
+ });
50
+
51
+ test("--no-color beats FORCE_COLOR", () => {
52
+ const dir = seedChain();
53
+ const out = run(dir, ["verify", "receipts/chain.json", "--no-color"], { FORCE_COLOR: "1" });
54
+ assert.ok(!out.includes("\x1b"));
55
+ });
56
+
57
+ test("NO_COLOR beats FORCE_COLOR", () => {
58
+ const dir = seedChain();
59
+ const out = run(dir, ["verify", "receipts/chain.json"], { FORCE_COLOR: "1", NO_COLOR: "1" });
60
+ assert.ok(!out.includes("\x1b"));
61
+ });
62
+
63
+ // The acceptance gate (R16): ANSI must never leak into --json, even with color
64
+ // forced on. Mirrors tests/test_cli_json_gate.py (node has no doctor command).
65
+ test("no ANSI leaks into --json even under FORCE_COLOR", () => {
66
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-gate-"));
67
+ run(dir, ["keygen", "--out", "keys"]);
68
+ writeFileSync(join(dir, "d.csv"), "day,amount\n2026-05-01,10\n");
69
+ run(dir, ["ingest", "d.csv"]);
70
+ run(dir, ["verify", "receipts/chain.json"]);
71
+ writeFileSync(join(dir, "d.csv"), "day,amount\n2026-05-01,10\n2026-05-02,20\n");
72
+ run(dir, ["ingest", "d.csv"]);
73
+ run(dir, ["verify", "receipts/chain.json"]);
74
+
75
+ const commands = [
76
+ ["verify", "receipts/chain.json", "--json"],
77
+ ["log", "--chain", "receipts/", "--json"],
78
+ ["diff", "--json"],
79
+ ["export", "receipts/chain.json", "--data", "d.csv", "--json"],
80
+ ["ingest", "d.csv", "--json"],
81
+ ];
82
+ for (const args of commands) {
83
+ const out = run(dir, args, { FORCE_COLOR: "1" });
84
+ assert.ok(!out.includes("\x1b"), `ANSI leaked into --json of ${args[0]}`);
85
+ JSON.parse(out);
86
+ }
87
+ });
@@ -0,0 +1,90 @@
1
+ // Mirrors tests/test_color.py: the gating matrix plus verdict/delta conventions.
2
+ // Also asserts the SGR palette matches the Python helper byte for byte (pins R15).
3
+
4
+ import { test, beforeEach } from "node:test";
5
+ import assert from "node:assert/strict";
6
+
7
+ import { setNoColor, shouldColor, light, delta, dim, bold, GREEN, YELLOW, RED, DIM, BOLD, RESET } from "../color.js";
8
+
9
+ const TTY = { isTTY: true };
10
+ const PIPE = { isTTY: false };
11
+
12
+ beforeEach(() => {
13
+ delete process.env.NO_COLOR;
14
+ delete process.env.FORCE_COLOR;
15
+ setNoColor(false);
16
+ });
17
+
18
+ test("shouldColor: true on a TTY with no overrides", () => {
19
+ assert.equal(shouldColor(TTY), true);
20
+ });
21
+
22
+ test("shouldColor: false when not a TTY", () => {
23
+ assert.equal(shouldColor(PIPE), false);
24
+ });
25
+
26
+ test("shouldColor: NO_COLOR wins even on a TTY (any value)", () => {
27
+ process.env.NO_COLOR = "1";
28
+ assert.equal(shouldColor(TTY), false);
29
+ process.env.NO_COLOR = "";
30
+ assert.equal(shouldColor(TTY), false);
31
+ });
32
+
33
+ test("shouldColor: --no-color flag wins even on a TTY", () => {
34
+ setNoColor(true);
35
+ assert.equal(shouldColor(TTY), false);
36
+ });
37
+
38
+ test("shouldColor: FORCE_COLOR turns on even when piped", () => {
39
+ process.env.FORCE_COLOR = "1";
40
+ assert.equal(shouldColor(PIPE), true);
41
+ });
42
+
43
+ test("shouldColor: NO_COLOR beats FORCE_COLOR", () => {
44
+ process.env.NO_COLOR = "1";
45
+ process.env.FORCE_COLOR = "1";
46
+ assert.equal(shouldColor(TTY), false);
47
+ });
48
+
49
+ test("light: colors each verdict when on", () => {
50
+ process.env.FORCE_COLOR = "1";
51
+ assert.equal(light("green", PIPE), `${GREEN}●${RESET}`);
52
+ assert.equal(light("yellow", PIPE), `${YELLOW}●${RESET}`);
53
+ assert.equal(light("red", PIPE), `${RED}●${RESET}`);
54
+ });
55
+
56
+ test("light: plain glyph when off", () => {
57
+ assert.equal(light("green", PIPE), "●");
58
+ assert.ok(!light("red", PIPE).includes("\x1b"));
59
+ });
60
+
61
+ test("delta: signs and colors direction when on", () => {
62
+ process.env.FORCE_COLOR = "1";
63
+ assert.equal(delta(12, PIPE), `${GREEN}+12${RESET}`);
64
+ assert.equal(delta(-5, PIPE), `${RED}-5${RESET}`);
65
+ assert.equal(delta(0, PIPE), "0");
66
+ });
67
+
68
+ test("delta: keeps sign when color off", () => {
69
+ assert.equal(delta(12, PIPE), "+12");
70
+ assert.equal(delta(-5, PIPE), "-5");
71
+ assert.ok(!delta(12, PIPE).includes("\x1b"));
72
+ });
73
+
74
+ test("dim/bold wrap only when on", () => {
75
+ assert.equal(dim("abc", PIPE), "abc");
76
+ assert.equal(bold("abc", PIPE), "abc");
77
+ process.env.FORCE_COLOR = "1";
78
+ assert.equal(dim("abc", PIPE), `${DIM}abc${RESET}`);
79
+ assert.equal(bold("abc", PIPE), `${BOLD}abc${RESET}`);
80
+ });
81
+
82
+ // Cross-stack palette parity: these MUST equal tamper_signal/color.py's codes.
83
+ test("palette parity: SGR codes match the Python helper", () => {
84
+ assert.equal(GREEN, "\x1b[32m");
85
+ assert.equal(YELLOW, "\x1b[33m");
86
+ assert.equal(RED, "\x1b[31m");
87
+ assert.equal(DIM, "\x1b[2m");
88
+ assert.equal(BOLD, "\x1b[1m");
89
+ assert.equal(RESET, "\x1b[0m");
90
+ });
@@ -0,0 +1,87 @@
1
+ // `tamper-signal ingest --json` and `export --json` (U4): the machine surface
2
+ // added in 1.7.1. Mirrors the Python tests in tests/test_cli_agent_ergonomics.py;
3
+ // payload keys must match the Python CLI byte for byte (R5). Driven through the
4
+ // real CLI via execFileSync so the captured stdout is exactly what ships.
5
+
6
+ import assert from "node:assert/strict";
7
+ import { execFileSync } from "node:child_process";
8
+ import { mkdtempSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { test } from "node:test";
12
+ import { fileURLToPath } from "node:url";
13
+
14
+ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
15
+ const cli = join(repoRoot, "node", "cli.js");
16
+
17
+ function runCli(cwd, args, { expectFail = false } = {}) {
18
+ const env = { ...process.env, TAMPER_SIGNAL_KEY: "" };
19
+ try {
20
+ return { stdout: execFileSync(process.execPath, [cli, ...args], { cwd, env, encoding: "utf-8" }), stderr: "", status: 0 };
21
+ } catch (err) {
22
+ if (!expectFail) throw err;
23
+ return { stdout: err.stdout ?? "", stderr: err.stderr ?? "", status: err.status ?? 1 };
24
+ }
25
+ }
26
+
27
+ function seedChain() {
28
+ const dir = mkdtempSync(join(tmpdir(), "tamper-signal-json-"));
29
+ runCli(dir, ["keygen", "--out", "keys"]);
30
+ writeFileSync(join(dir, "data.csv"), "day,amount\n2026-05-01,10\n2026-05-02,20\n");
31
+ return dir;
32
+ }
33
+
34
+ test("ingest --json emits a structured result", () => {
35
+ const dir = seedChain();
36
+ const payload = JSON.parse(runCli(dir, ["ingest", "data.csv", "--json"]).stdout);
37
+ assert.equal(payload.source, "data.csv");
38
+ assert.equal(payload.row_count, 2);
39
+ assert.equal(payload.column_count, 2);
40
+ assert.equal(payload.tolerance, null);
41
+ assert.ok(payload.semantic_hash);
42
+ assert.ok(payload.source_manifest.endsWith("000_source.json"));
43
+ // Pinned key set: must equal the Python ingest --json payload (R5).
44
+ assert.deepEqual(
45
+ Object.keys(payload).sort(),
46
+ ["column_count", "evidence_hash", "row_count", "semantic_hash", "source", "source_manifest", "tolerance"],
47
+ );
48
+ });
49
+
50
+ test("ingest --json includes a declared tolerance", () => {
51
+ const dir = seedChain();
52
+ const payload = JSON.parse(runCli(dir, ["ingest", "data.csv", "--band", "5%", "--settle", "72h", "--json"]).stdout);
53
+ assert.equal(payload.tolerance.band, "0.05");
54
+ assert.equal(payload.tolerance.settle_hours, 72);
55
+ });
56
+
57
+ test("export --json writes a table result", () => {
58
+ const dir = seedChain();
59
+ runCli(dir, ["ingest", "data.csv"]);
60
+ const payload = JSON.parse(runCli(dir, ["export", "receipts/chain.json", "--data", "data.csv", "--json"]).stdout);
61
+ assert.equal(payload.bundle, false);
62
+ assert.ok(payload.output.endsWith("table.json"));
63
+ assert.ok(payload.data_hash);
64
+ assert.equal(payload.row_count, 2);
65
+ // Pinned key set: must equal the Python export --json payload (R5).
66
+ assert.deepEqual(Object.keys(payload).sort(), ["bundle", "column_count", "data_hash", "output", "row_count"]);
67
+ });
68
+
69
+ test("export --bundle --json reports the bundle", () => {
70
+ const dir = seedChain();
71
+ runCli(dir, ["ingest", "data.csv"]);
72
+ const payload = JSON.parse(runCli(dir, ["export", "receipts/chain.json", "--data", "data.csv", "--bundle", "--json"]).stdout);
73
+ assert.equal(payload.bundle, true);
74
+ assert.ok(payload.receipts >= 1);
75
+ assert.ok(payload.output.endsWith("-verified.zip"));
76
+ });
77
+
78
+ test("export --json gives a structured error on mismatch (clean stdout)", () => {
79
+ const dir = seedChain();
80
+ runCli(dir, ["ingest", "data.csv"]);
81
+ writeFileSync(join(dir, "wrong.csv"), "x\n1\n");
82
+ const res = runCli(dir, ["export", "receipts/chain.json", "--data", "wrong.csv", "--json"], { expectFail: true });
83
+ assert.equal(res.status, 1);
84
+ const payload = JSON.parse(res.stdout);
85
+ assert.equal(payload.ok, false);
86
+ assert.match(payload.error, /match/);
87
+ });
@@ -233,3 +233,15 @@ test("log renders a real built history end to end", () => {
233
233
  assert.ok(payload.runs.length >= 1);
234
234
  assert.ok(payload.runs.every((r) => r.unsigned === false));
235
235
  });
236
+
237
+ test("log --json surfaces band/settle per run, only where declared", () => {
238
+ const s0 = snapshot({ createdAt: "2026-05-01T00:00:00Z", rowCount: 100, tail: "aa".repeat(32) });
239
+ const s1 = snapshot({ createdAt: "2026-05-02T00:00:00Z", rowCount: 110, tail: "bb".repeat(32) });
240
+ s1.tolerance = { band: "0.05", settle_hours: 72, bucket_column: "day" };
241
+ const receipts = writeHistory([s0, s1]);
242
+
243
+ const runs = JSON.parse(runLog(receipts, ["--json"]).stdout).runs; // oldest first
244
+ assert.ok(!("band" in runs[0]) && !("settle_hours" in runs[0]));
245
+ assert.equal(runs[1].band, "0.05");
246
+ assert.equal(runs[1].settle_hours, 72);
247
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tamper-signal",
3
- "version": "1.7.0",
3
+ "version": "1.7.1",
4
4
  "description": "Signed receipts for vibe-coded data pipelines. Proves nobody changed your data, and shows the exact link if they did.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -64,7 +64,7 @@
64
64
  "AGENTS.md"
65
65
  ],
66
66
  "scripts": {
67
- "test": "node --test node/test/badge.test.js node/test/canonical.test.js node/test/chain.test.js node/test/interop.test.js node/test/express.test.js node/test/totals.test.js node/test/pipeline.test.js node/test/history.test.js node/test/diff.test.js node/test/log.test.js node/test/judgment.test.js node/test/theme.test.js node/test/zip.test.js node/test/append_period.test.js"
67
+ "test": "node --test node/test/badge.test.js node/test/canonical.test.js node/test/chain.test.js node/test/interop.test.js node/test/express.test.js node/test/totals.test.js node/test/pipeline.test.js node/test/history.test.js node/test/diff.test.js node/test/log.test.js node/test/judgment.test.js node/test/theme.test.js node/test/zip.test.js node/test/append_period.test.js node/test/color.test.js node/test/json_surface.test.js node/test/cli_color.test.js"
68
68
  },
69
69
  "engines": {
70
70
  "node": ">=18.17"