stilltrue 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +36 -3
- package/dist/history.d.ts +11 -0
- package/dist/history.js +37 -0
- package/dist/report.d.ts +2 -0
- package/dist/report.js +120 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -6,9 +6,12 @@
|
|
|
6
6
|
* outage must not block CI), 1 = rot detected or usage error.
|
|
7
7
|
*/
|
|
8
8
|
import { writeFile } from 'node:fs/promises';
|
|
9
|
+
import { resolve } from 'node:path';
|
|
9
10
|
import { parseArgs } from 'node:util';
|
|
10
11
|
import { findConfigPath, loadConfig } from "./loadConfig.js";
|
|
11
12
|
import { runDrift } from "./runner.js";
|
|
13
|
+
import { DEFAULT_HISTORY_PATH, loadHistory, recordRun } from "./history.js";
|
|
14
|
+
import { renderReport } from "./report.js";
|
|
12
15
|
const GH = !!process.env.GITHUB_ACTIONS;
|
|
13
16
|
function printResult(r) {
|
|
14
17
|
const icon = { pass: '✓', rot: '✗', error: '⚠' }[r.outcome];
|
|
@@ -31,6 +34,8 @@ async function drift(argv) {
|
|
|
31
34
|
config: { type: 'string' },
|
|
32
35
|
only: { type: 'string' },
|
|
33
36
|
json: { type: 'string' },
|
|
37
|
+
record: { type: 'boolean' },
|
|
38
|
+
history: { type: 'string' },
|
|
34
39
|
},
|
|
35
40
|
});
|
|
36
41
|
const configPath = findConfigPath(process.cwd(), values.config);
|
|
@@ -53,21 +58,49 @@ async function drift(argv) {
|
|
|
53
58
|
await writeFile(values.json, JSON.stringify(results, null, 2));
|
|
54
59
|
console.log(`results written to ${values.json}`);
|
|
55
60
|
}
|
|
61
|
+
if (values.record) {
|
|
62
|
+
const historyPath = resolve(values.history ?? DEFAULT_HISTORY_PATH);
|
|
63
|
+
await recordRun(results, historyPath);
|
|
64
|
+
console.log(`run recorded to ${historyPath}`);
|
|
65
|
+
}
|
|
56
66
|
return rotted > 0 ? 1 : 0;
|
|
57
67
|
}
|
|
68
|
+
async function report(argv) {
|
|
69
|
+
const { values } = parseArgs({
|
|
70
|
+
args: argv,
|
|
71
|
+
options: {
|
|
72
|
+
history: { type: 'string' },
|
|
73
|
+
out: { type: 'string' },
|
|
74
|
+
title: { type: 'string' },
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
const historyPath = resolve(values.history ?? DEFAULT_HISTORY_PATH);
|
|
78
|
+
const history = await loadHistory(historyPath);
|
|
79
|
+
if (!history.length) {
|
|
80
|
+
console.error(`no recorded runs at ${historyPath} — run \`stilltrue drift --record\` first (use --history to point elsewhere)`);
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
const out = resolve(values.out ?? 'stilltrue-report.html');
|
|
84
|
+
await writeFile(out, renderReport(history, values.title));
|
|
85
|
+
console.log(`report written to ${out} (${history.length} runs)`);
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
58
88
|
const [command, ...rest] = process.argv.slice(2);
|
|
59
89
|
try {
|
|
60
90
|
switch (command) {
|
|
61
91
|
case 'drift':
|
|
62
92
|
process.exit(await drift(rest));
|
|
63
93
|
break;
|
|
64
|
-
case 'golden':
|
|
65
94
|
case 'report':
|
|
66
|
-
|
|
95
|
+
process.exit(await report(rest));
|
|
96
|
+
break;
|
|
97
|
+
case 'golden':
|
|
98
|
+
console.error('stilltrue golden: not implemented yet — drift shipped first (docs/BRIEF.md)');
|
|
67
99
|
process.exit(1);
|
|
68
100
|
break;
|
|
69
101
|
default:
|
|
70
|
-
console.log('usage: stilltrue drift [--config <path>] [--only <names>] [--json <path>]'
|
|
102
|
+
console.log('usage: stilltrue drift [--config <path>] [--only <names>] [--json <path>] [--record] [--history <path>]\n' +
|
|
103
|
+
' stilltrue report [--history <path>] [--out <path>] [--title <text>]');
|
|
71
104
|
process.exit(command ? 1 : 0);
|
|
72
105
|
}
|
|
73
106
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DriftResult } from './config.ts';
|
|
2
|
+
/** One recorded `stilltrue drift` run. */
|
|
3
|
+
export interface RunRecord {
|
|
4
|
+
at: string;
|
|
5
|
+
results: DriftResult[];
|
|
6
|
+
}
|
|
7
|
+
export declare const DEFAULT_HISTORY_PATH = ".stilltrue/history.jsonl";
|
|
8
|
+
/** Append a run to the JSONL history file (created on first record). */
|
|
9
|
+
export declare function recordRun(results: DriftResult[], historyPath: string): Promise<RunRecord>;
|
|
10
|
+
/** Load history oldest-first. Missing file → []. Corrupt lines are skipped. */
|
|
11
|
+
export declare function loadHistory(historyPath: string): Promise<RunRecord[]>;
|
package/dist/history.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, readFile, appendFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
export const DEFAULT_HISTORY_PATH = '.stilltrue/history.jsonl';
|
|
4
|
+
/** Append a run to the JSONL history file (created on first record). */
|
|
5
|
+
export async function recordRun(results, historyPath) {
|
|
6
|
+
const record = { at: new Date().toISOString(), results };
|
|
7
|
+
await mkdir(dirname(historyPath), { recursive: true });
|
|
8
|
+
await appendFile(historyPath, JSON.stringify(record) + '\n');
|
|
9
|
+
return record;
|
|
10
|
+
}
|
|
11
|
+
/** Load history oldest-first. Missing file → []. Corrupt lines are skipped. */
|
|
12
|
+
export async function loadHistory(historyPath) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = await readFile(historyPath, 'utf8');
|
|
16
|
+
}
|
|
17
|
+
catch (err) {
|
|
18
|
+
if (err.code === 'ENOENT')
|
|
19
|
+
return [];
|
|
20
|
+
throw err;
|
|
21
|
+
}
|
|
22
|
+
const records = [];
|
|
23
|
+
for (const line of raw.split('\n')) {
|
|
24
|
+
if (!line.trim())
|
|
25
|
+
continue;
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(line);
|
|
28
|
+
if (parsed && typeof parsed.at === 'string' && Array.isArray(parsed.results)) {
|
|
29
|
+
records.push(parsed);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// A truncated line (interrupted CI write) shouldn't sink the report.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return records;
|
|
37
|
+
}
|
package/dist/report.d.ts
ADDED
package/dist/report.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
const STATUS = {
|
|
2
|
+
pass: { color: '#0ca30c', glyph: '✓', ink: '#ffffff', label: 'pass' },
|
|
3
|
+
rot: { color: '#d03b3b', glyph: '✗', ink: '#ffffff', label: 'rot' },
|
|
4
|
+
error: { color: '#fab219', glyph: '⚠', ink: '#1a1a19', label: 'error' },
|
|
5
|
+
};
|
|
6
|
+
const MAX_COLUMNS = 60;
|
|
7
|
+
const MAX_LOG = 50;
|
|
8
|
+
function esc(s) {
|
|
9
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
10
|
+
}
|
|
11
|
+
function day(iso) {
|
|
12
|
+
return iso.slice(5, 10); // MM-DD
|
|
13
|
+
}
|
|
14
|
+
export function renderReport(history, title = 'stilltrue drift report') {
|
|
15
|
+
const runs = history.slice(-MAX_COLUMNS);
|
|
16
|
+
const latest = runs[runs.length - 1];
|
|
17
|
+
// Stable row order: latest run's order first, then any check seen earlier.
|
|
18
|
+
const names = [];
|
|
19
|
+
for (const record of [...runs].reverse()) {
|
|
20
|
+
for (const r of record.results)
|
|
21
|
+
if (!names.includes(r.name))
|
|
22
|
+
names.push(r.name);
|
|
23
|
+
}
|
|
24
|
+
const counts = { pass: 0, rot: 0, error: 0 };
|
|
25
|
+
for (const r of latest?.results ?? [])
|
|
26
|
+
counts[r.outcome]++;
|
|
27
|
+
const tiles = ['pass', 'rot', 'error']
|
|
28
|
+
.map((o) => `<div class="tile">
|
|
29
|
+
<div class="tile-num" style="color:${counts[o] && o !== 'pass' ? STATUS[o].color : 'var(--ink)'}">${counts[o]}</div>
|
|
30
|
+
<div class="tile-label">${STATUS[o].glyph} ${STATUS[o].label}</div>
|
|
31
|
+
</div>`)
|
|
32
|
+
.join('');
|
|
33
|
+
const headerCells = runs
|
|
34
|
+
.map((rec, i) => {
|
|
35
|
+
const sparse = runs.length <= 10 || i % Math.ceil(runs.length / 10) === 0 || i === runs.length - 1;
|
|
36
|
+
return `<th title="${esc(rec.at)}">${sparse ? day(rec.at) : ''}</th>`;
|
|
37
|
+
})
|
|
38
|
+
.join('');
|
|
39
|
+
const rows = names
|
|
40
|
+
.map((name) => {
|
|
41
|
+
const cells = runs
|
|
42
|
+
.map((rec) => {
|
|
43
|
+
const r = rec.results.find((x) => x.name === name);
|
|
44
|
+
if (!r)
|
|
45
|
+
return '<td><span class="cell none" title="not run"></span></td>';
|
|
46
|
+
const s = STATUS[r.outcome];
|
|
47
|
+
const detail = [r.outcome, ...r.messages, ...r.warnings.map((w) => `warning: ${w}`)].join('\n');
|
|
48
|
+
return `<td><span class="cell" style="background:${s.color};color:${s.ink}" title="${esc(rec.at)}\n${esc(detail)}">${s.glyph}</span></td>`;
|
|
49
|
+
})
|
|
50
|
+
.join('');
|
|
51
|
+
return `<tr><th class="check-name">${esc(name)}</th>${cells}</tr>`;
|
|
52
|
+
})
|
|
53
|
+
.join('');
|
|
54
|
+
const events = [...runs]
|
|
55
|
+
.reverse()
|
|
56
|
+
.flatMap((rec) => rec.results
|
|
57
|
+
.filter((r) => r.outcome !== 'pass' || r.warnings.length)
|
|
58
|
+
.map((r) => ({ at: rec.at, ...r })))
|
|
59
|
+
.slice(0, MAX_LOG);
|
|
60
|
+
const log = events.length
|
|
61
|
+
? events
|
|
62
|
+
.map((e) => {
|
|
63
|
+
const s = STATUS[e.outcome];
|
|
64
|
+
const items = [...e.messages, ...e.warnings.map((w) => `warning: ${w}`)]
|
|
65
|
+
.map((m) => `<li>${esc(m)}</li>`)
|
|
66
|
+
.join('');
|
|
67
|
+
return `<details ${e.outcome === 'rot' ? 'open' : ''}>
|
|
68
|
+
<summary><span class="badge" style="background:${s.color};color:${s.ink}">${s.glyph} ${s.label}</span>
|
|
69
|
+
<strong>${esc(e.name)}</strong> <span class="muted">${esc(e.at)}</span></summary>
|
|
70
|
+
<ul>${items}</ul>
|
|
71
|
+
</details>`;
|
|
72
|
+
})
|
|
73
|
+
.join('')
|
|
74
|
+
: '<p class="muted">Nothing has rotted, errored, or warned in recorded history.</p>';
|
|
75
|
+
return `<!doctype html>
|
|
76
|
+
<meta charset="utf-8">
|
|
77
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
78
|
+
<title>${esc(title)}</title>
|
|
79
|
+
<style>
|
|
80
|
+
:root { --bg:#ffffff; --ink:#1a1a19; --ink-2:#5f5e5a; --line:#e4e2dd; --surface:#f6f5f2; }
|
|
81
|
+
@media (prefers-color-scheme: dark) {
|
|
82
|
+
:root { --bg:#1a1a19; --ink:#f2f1ee; --ink-2:#a5a39d; --line:#3a3936; --surface:#242422; }
|
|
83
|
+
}
|
|
84
|
+
* { box-sizing: border-box; }
|
|
85
|
+
body { margin:0; padding:2rem 1.5rem; background:var(--bg); color:var(--ink);
|
|
86
|
+
font:15px/1.5 ui-sans-serif, system-ui, sans-serif; max-width:64rem; margin-inline:auto; }
|
|
87
|
+
h1 { font-size:1.3rem; margin:0 0 .25rem; }
|
|
88
|
+
h2 { font-size:1rem; margin:2rem 0 .75rem; }
|
|
89
|
+
.muted { color:var(--ink-2); font-size:.85rem; }
|
|
90
|
+
.tiles { display:flex; gap:.75rem; margin:1.25rem 0; flex-wrap:wrap; }
|
|
91
|
+
.tile { background:var(--surface); border:1px solid var(--line); border-radius:8px;
|
|
92
|
+
padding:.75rem 1.25rem; min-width:6.5rem; }
|
|
93
|
+
.tile-num { font-size:1.6rem; font-weight:600; font-variant-numeric:tabular-nums; }
|
|
94
|
+
.tile-label { color:var(--ink-2); font-size:.85rem; }
|
|
95
|
+
.grid-wrap { overflow-x:auto; border:1px solid var(--line); border-radius:8px; padding:.75rem; }
|
|
96
|
+
table { border-collapse:separate; border-spacing:2px; }
|
|
97
|
+
th { font-weight:500; color:var(--ink-2); font-size:.7rem; text-align:left; white-space:nowrap; }
|
|
98
|
+
thead th { vertical-align:bottom; padding-bottom:.25rem; }
|
|
99
|
+
.check-name { font-size:.85rem; color:var(--ink); padding-right:.75rem; position:sticky; left:0; background:var(--bg); }
|
|
100
|
+
.cell { display:inline-flex; width:20px; height:20px; border-radius:4px;
|
|
101
|
+
align-items:center; justify-content:center; font-size:12px; }
|
|
102
|
+
.cell.none { background:var(--surface); border:1px dashed var(--line); }
|
|
103
|
+
.badge { display:inline-block; border-radius:4px; padding:0 .45rem; font-size:.8rem; margin-right:.5rem; }
|
|
104
|
+
details { border-left:2px solid var(--line); padding:.35rem .75rem; margin:.4rem 0; }
|
|
105
|
+
summary { cursor:pointer; }
|
|
106
|
+
details ul { margin:.4rem 0 .2rem; padding-left:1.2rem; }
|
|
107
|
+
details li { font-size:.9rem; }
|
|
108
|
+
</style>
|
|
109
|
+
<h1>${esc(title)}</h1>
|
|
110
|
+
<p class="muted">${runs.length} recorded run${runs.length === 1 ? '' : 's'}${latest ? `, latest ${esc(latest.at)}` : ''} — pass / rot (facts changed) / error (source unreachable)</p>
|
|
111
|
+
<div class="tiles">${tiles}</div>
|
|
112
|
+
<h2>Timeline</h2>
|
|
113
|
+
<div class="grid-wrap"><table>
|
|
114
|
+
<thead><tr><th></th>${headerCells}</tr></thead>
|
|
115
|
+
<tbody>${rows}</tbody>
|
|
116
|
+
</table></div>
|
|
117
|
+
<h2>Rot, errors & warnings</h2>
|
|
118
|
+
${log}
|
|
119
|
+
`;
|
|
120
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stilltrue",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Your tests check your code. stilltrue checks your facts. CI checks for AI-in-the-loop apps: drift (curated facts vs live sources), golden (regression evals), verify (judge answers against sources).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"drift",
|