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 +19 -0
- package/node/cli.js +119 -25
- package/node/color.js +78 -0
- package/node/test/cli_color.test.js +87 -0
- package/node/test/color.test.js +90 -0
- package/node/test/json_surface.test.js +87 -0
- package/node/test/log.test.js +12 -0
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
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, ...
|
|
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
|
+
});
|
package/node/test/log.test.js
CHANGED
|
@@ -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.
|
|
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"
|