opstruth 0.1.1 → 0.1.3
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/README.md +22 -1
- package/package.json +1 -1
- package/src/cli.js +72 -38
- package/src/commands/local.js +7 -1
- package/src/commands/probes.js +33 -15
- package/src/commands/routes.js +4 -4
- package/src/commands/secrets.js +12 -4
- package/src/lib/config.js +41 -3
- package/src/lib/markdown.js +22 -1
- package/src/lib/probes.js +44 -2
- package/src/lib/scan.js +64 -3
- package/src/lib/terminal.js +119 -0
- package/src/orchestrator.js +25 -12
package/README.md
CHANGED
|
@@ -6,13 +6,15 @@ Read-only operational truth checks for AI-assisted engineering workflows.
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Current published package:
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
npm install -g opstruth
|
|
13
13
|
opstruth
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
The public npm package is `opstruth@0.1.2`.
|
|
17
|
+
|
|
16
18
|
One-off usage:
|
|
17
19
|
|
|
18
20
|
```bash
|
|
@@ -24,18 +26,37 @@ npx opstruth
|
|
|
24
26
|
```bash
|
|
25
27
|
opstruth
|
|
26
28
|
opstruth welcome
|
|
29
|
+
opstruth init --yes
|
|
27
30
|
opstruth probes
|
|
28
31
|
opstruth secrets
|
|
29
32
|
opstruth routes --base-url https://example.com
|
|
30
33
|
opstruth local --port 3000 --health /health
|
|
34
|
+
opstruth --json
|
|
35
|
+
opstruth --no-color
|
|
31
36
|
```
|
|
32
37
|
|
|
38
|
+
## Terminal Output
|
|
39
|
+
|
|
40
|
+
Human output uses a restrained colour theme for status, warnings, proof gaps, evidence, and next safe steps when the terminal supports ANSI colour.
|
|
41
|
+
|
|
42
|
+
Use `--no-color` or `NO_COLOR=1` to disable colour. Use `--color` to force colour for demos. JSON output remains machine-readable and does not include ANSI codes. Evidence markdown output remains ANSI-free.
|
|
43
|
+
|
|
33
44
|
## Safety Model
|
|
34
45
|
|
|
35
46
|
opstruth is read-only by default. CLI checks do not deploy, mutate databases, trigger queues or jobs, call OpenAI, restart services, publish content, or print raw secrets.
|
|
36
47
|
|
|
37
48
|
Skipped checks and not-verified areas are reported as proof gaps instead of being treated as safe.
|
|
38
49
|
|
|
50
|
+
## Configuration
|
|
51
|
+
|
|
52
|
+
`opstruth.config.json` can provide route paths, local ports/health paths, and secret allowlists. Generate a starter config:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
opstruth init --yes
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
CLI flags remain the clearest way to provide runtime inputs.
|
|
59
|
+
|
|
39
60
|
## Repository
|
|
40
61
|
|
|
41
62
|
Source, docs, and release evidence live at:
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -12,6 +12,7 @@ import { runLocal } from './commands/local.js';
|
|
|
12
12
|
import { runEvidence } from './commands/evidence.js';
|
|
13
13
|
import { runProbes } from './commands/probes.js';
|
|
14
14
|
import { resultToMarkdown } from './lib/markdown.js';
|
|
15
|
+
import { formatTerminalOutput } from './lib/terminal.js';
|
|
15
16
|
import { writeFileSafe } from './lib/fs.js';
|
|
16
17
|
import { redactObject } from './lib/redact.js';
|
|
17
18
|
import { exitCodeFor } from './lib/result.js';
|
|
@@ -26,6 +27,8 @@ export function parseArgs(argv) {
|
|
|
26
27
|
if (!arg.startsWith('-') && !command && COMMANDS.has(arg)) { command = arg; continue; }
|
|
27
28
|
if (arg === '--help' || arg === '-h') options.help = true;
|
|
28
29
|
if (arg === '--json') options.json = true;
|
|
30
|
+
else if (arg === '--color') options.color = true;
|
|
31
|
+
else if (arg === '--no-color') options.noColor = true;
|
|
29
32
|
else if (arg === '--out') options.out = take(argv, i++);
|
|
30
33
|
else if (arg === '--base-url') options.baseUrl = take(argv, i++);
|
|
31
34
|
else if (arg === '--routes') options.routesFile = take(argv, i++);
|
|
@@ -108,32 +111,47 @@ function helpText(command) {
|
|
|
108
111
|
' --out <file> Write command output/evidence',
|
|
109
112
|
' --skip <area|id> Skip a command or probe area',
|
|
110
113
|
' --only <area|id> Select a probe area or id',
|
|
114
|
+
' --color Force colour for human terminal output',
|
|
115
|
+
' --no-color Disable colour for human terminal output',
|
|
111
116
|
' -h, --help Print help and exit 0'
|
|
112
117
|
];
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
'Usage: opstruth
|
|
116
|
-
'',
|
|
117
|
-
'
|
|
118
|
-
'',
|
|
119
|
-
'
|
|
120
|
-
'
|
|
121
|
-
'
|
|
122
|
-
'
|
|
123
|
-
'
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
118
|
+
const commandHelp = {
|
|
119
|
+
repo: ['Usage: opstruth repo [--json] [--strict]', 'Inspect cwd, git root, branch, latest commit, dirty files, and detected stack.'],
|
|
120
|
+
quality: ['Usage: opstruth quality [--script name] [--continue] [--json]', 'Run only existing safe package scripts; missing scripts and npm placeholder tests are skipped.'],
|
|
121
|
+
routes: ['Usage: opstruth routes --base-url <url> [--routes file] [--json]', 'Collect read-only URL, method, status, latency, redirect, and header evidence.'],
|
|
122
|
+
secrets: ['Usage: opstruth secrets [--json]', 'Scan source text for risky secret/auth references with redacted previews; .env contents are skipped.'],
|
|
123
|
+
supabase: ['Usage: opstruth supabase [--protected-table name] [--frontend-dir dir] [--migrations-dir dir]', 'Run a static Supabase migration/frontend exposure audit without credentials or database calls.'],
|
|
124
|
+
cloudflare: ['Usage: opstruth cloudflare [--url https://example.com] [--json]', 'Inspect Wrangler config, deploy scripts, and optional read-only route status; no deploy is run.'],
|
|
125
|
+
local: ['Usage: opstruth local --port 3000 [--health /health] [--process name] [--service name]', 'Check explicit local runtime inputs without starting, stopping, or killing services.'],
|
|
126
|
+
probes: ['Usage: opstruth probes [--json] [--only area|id] [--skip area|id]', 'Inspect probe catalogue metadata, eligible probes, skipped probes, required inputs, and proof gaps.'],
|
|
127
|
+
evidence: ['Usage: opstruth evidence [--title text] [--out evidence/opstruth.md] [--include file]', 'Write a markdown evidence pack with verified facts, proof gaps, boundaries, and next safe step.'],
|
|
128
|
+
init: ['Usage: opstruth init [--yes]', 'Create a safe starter opstruth.config.json after confirmation.']
|
|
129
|
+
};
|
|
130
|
+
if (commandHelp[command]) {
|
|
131
|
+
const [usage, description] = commandHelp[command];
|
|
132
|
+
return [
|
|
133
|
+
ASCII_HEADER,
|
|
134
|
+
usage,
|
|
135
|
+
'',
|
|
136
|
+
description,
|
|
137
|
+
'',
|
|
138
|
+
'Examples:',
|
|
139
|
+
' opstruth',
|
|
140
|
+
' opstruth --base-url https://example.com',
|
|
141
|
+
' opstruth routes --base-url https://example.com',
|
|
142
|
+
' opstruth local --port 3000 --health /health',
|
|
143
|
+
' opstruth --json',
|
|
144
|
+
' opstruth --no-color',
|
|
145
|
+
' opstruth --color',
|
|
146
|
+
'',
|
|
147
|
+
'Options:',
|
|
148
|
+
' --json Print JSON output',
|
|
149
|
+
' --strict Treat warnings/skips as failing confidence',
|
|
150
|
+
' --color Force colour for human terminal output',
|
|
151
|
+
' --no-color Disable colour for human terminal output',
|
|
152
|
+
' -h, --help Print help and exit 0'
|
|
153
|
+
].join('\n') + '\n';
|
|
154
|
+
}
|
|
137
155
|
return common.join('\n') + '\n';
|
|
138
156
|
}
|
|
139
157
|
|
|
@@ -156,8 +174,17 @@ function welcomeText() {
|
|
|
156
174
|
'- opstruth --strict',
|
|
157
175
|
'- opstruth routes --base-url https://example.com',
|
|
158
176
|
'- opstruth local --port 3000 --health /health',
|
|
177
|
+
'- opstruth --json',
|
|
178
|
+
'- opstruth --no-color',
|
|
179
|
+
'- opstruth --color',
|
|
159
180
|
'- opstruth evidence --title "Release proof"',
|
|
160
181
|
'',
|
|
182
|
+
'Improving confidence:',
|
|
183
|
+
'- provide --base-url or route config when route proof matters',
|
|
184
|
+
'- provide --port and --health when local runtime proof matters',
|
|
185
|
+
'- run inside a git repo for stronger change evidence',
|
|
186
|
+
'- attach evidence/opstruth-report.md to reviews or CI artifacts',
|
|
187
|
+
'',
|
|
161
188
|
'Safety philosophy:',
|
|
162
189
|
'opstruth prefers skipped or not verified over pretending something is safe. Dangerous actions require explicit approval and are not part of the default run.',
|
|
163
190
|
''
|
|
@@ -165,27 +192,34 @@ function welcomeText() {
|
|
|
165
192
|
}
|
|
166
193
|
|
|
167
194
|
const INIT_CONFIG = {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
'strict-transport-security',
|
|
174
|
-
'x-frame-options',
|
|
175
|
-
'referrer-policy'
|
|
176
|
-
]
|
|
177
|
-
},
|
|
195
|
+
projectName: 'example',
|
|
196
|
+
routes: [
|
|
197
|
+
{ path: '/', expectedStatus: 200 },
|
|
198
|
+
{ path: '/health', expectedStatus: 200 }
|
|
199
|
+
],
|
|
178
200
|
local: {
|
|
179
201
|
ports: [],
|
|
180
|
-
|
|
202
|
+
healthPaths: ['/health']
|
|
203
|
+
},
|
|
204
|
+
quality: {
|
|
205
|
+
skipScripts: [],
|
|
206
|
+
requiredScripts: []
|
|
207
|
+
},
|
|
208
|
+
secrets: {
|
|
209
|
+
allowlistPaths: [],
|
|
210
|
+
allowlistPatterns: []
|
|
181
211
|
},
|
|
182
212
|
supabase: {
|
|
213
|
+
enabled: true,
|
|
183
214
|
protectedTables: [
|
|
184
215
|
'agent_jobs',
|
|
185
216
|
'platform_credentials',
|
|
186
217
|
'worker_logs'
|
|
187
218
|
]
|
|
188
219
|
},
|
|
220
|
+
cloudflare: {
|
|
221
|
+
enabled: true
|
|
222
|
+
},
|
|
189
223
|
ignore: [
|
|
190
224
|
'.cache',
|
|
191
225
|
'.agents',
|
|
@@ -234,13 +268,13 @@ export async function runCli(argv, cwd = process.cwd()) {
|
|
|
234
268
|
const { command, options } = parseArgs(argv);
|
|
235
269
|
options.cwd = cwd;
|
|
236
270
|
if (options.help) {
|
|
237
|
-
await writeStdout(helpText(command));
|
|
271
|
+
await writeStdout(formatTerminalOutput(helpText(command), options));
|
|
238
272
|
process.exitCode = 0;
|
|
239
273
|
return;
|
|
240
274
|
}
|
|
241
275
|
const result = await dispatch(command, options);
|
|
242
276
|
if (typeof result === 'string') {
|
|
243
|
-
await writeStdout(result);
|
|
277
|
+
await writeStdout(formatTerminalOutput(result, options));
|
|
244
278
|
process.exitCode = 0;
|
|
245
279
|
return;
|
|
246
280
|
}
|
|
@@ -249,6 +283,6 @@ export async function runCli(argv, cwd = process.cwd()) {
|
|
|
249
283
|
const outPath = path.isAbsolute(options.out) ? options.out : path.join(cwd, options.out);
|
|
250
284
|
await writeFileSafe(outPath, output);
|
|
251
285
|
}
|
|
252
|
-
await writeStdout(output);
|
|
286
|
+
await writeStdout(options.json ? output : formatTerminalOutput(output, options));
|
|
253
287
|
process.exitCode = exitCodeFor(result, { strict: options.strict });
|
|
254
288
|
}
|
package/src/commands/local.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
2
|
import { runCommand } from '../lib/exec.js';
|
|
3
3
|
import { probeUrl } from '../lib/http.js';
|
|
4
|
+
import { loadOpstruthConfig } from '../lib/config.js';
|
|
4
5
|
|
|
5
6
|
export async function runLocal({ cwd = process.cwd(), port = [], health = '/', process: processName, service, strict = false } = {}) {
|
|
6
|
-
const
|
|
7
|
+
const loaded = await loadOpstruthConfig(cwd);
|
|
8
|
+
const configLocal = loaded.config?.local || {};
|
|
9
|
+
const ports = (Array.isArray(port) && port.length ? port : Array.isArray(configLocal.ports) ? configLocal.ports : [port].filter(Boolean));
|
|
10
|
+
const healthPaths = Array.isArray(configLocal.healthPaths) ? configLocal.healthPaths : [configLocal.healthPath].filter(Boolean);
|
|
11
|
+
if ((!health || health === '/') && healthPaths.length) health = healthPaths[0];
|
|
12
|
+
if (loaded.warning && !ports.length && !processName && !service) return createResult('local', 'warn', { warnings: [loaded.warning], notVerified: ['Local runtime liveness'], nextSafeStep: 'Fix opstruth.config.json or pass --port and --health explicitly.' });
|
|
7
13
|
if (!ports.length && !processName && !service) return createResult('local', 'skipped', { skipped: ['Local runtime checks skipped because no --port, --process, or --service was provided'], notVerified: ['Local runtime liveness'], nextSafeStep: 'Run opstruth local --port 3000 --health /health when the app is running.' });
|
|
8
14
|
const checks = [];
|
|
9
15
|
const warnings = [];
|
package/src/commands/probes.js
CHANGED
|
@@ -13,6 +13,29 @@ function summarizeCounts(label, counts) {
|
|
|
13
13
|
return `${label}: ${Object.entries(counts).map(([name, count]) => `${name}=${count}`).join(', ')}`;
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
function probeJson(probe) {
|
|
17
|
+
return {
|
|
18
|
+
id: probe.id,
|
|
19
|
+
name: probe.name,
|
|
20
|
+
area: probe.area,
|
|
21
|
+
stack: probe.stack,
|
|
22
|
+
mode: probe.mode,
|
|
23
|
+
safetyLevel: probe.safetyLevel,
|
|
24
|
+
defaultMode: probe.defaultMode,
|
|
25
|
+
mutability: probe.mutability,
|
|
26
|
+
inputsRequired: probe.inputsRequired,
|
|
27
|
+
evidenceCollected: probe.evidenceCollected,
|
|
28
|
+
evidenceExpectation: probe.evidenceExpectation,
|
|
29
|
+
proves: probe.proves,
|
|
30
|
+
doesNotProve: probe.doesNotProve,
|
|
31
|
+
proofLimitation: probe.proofLimitation,
|
|
32
|
+
skipReason: probe.skipReason,
|
|
33
|
+
nextSafeStep: probe.nextSafeStep,
|
|
34
|
+
supportedStacks: probe.supportedStacks,
|
|
35
|
+
notVerified: probe.notVerified
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
16
39
|
export async function runProbes({ cwd = process.cwd(), strict = false, skip = [], only = [] } = {}) {
|
|
17
40
|
const boundary = await resolveProjectBoundary(cwd);
|
|
18
41
|
const stack = await detectStack(boundary.root);
|
|
@@ -20,6 +43,7 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
|
|
|
20
43
|
const byArea = countBy(PROBE_CATALOGUE, 'area');
|
|
21
44
|
const byMode = countBy(PROBE_CATALOGUE, 'defaultMode');
|
|
22
45
|
const bySafety = countBy(PROBE_CATALOGUE, 'safetyLevel');
|
|
46
|
+
const explicitInputProbes = PROBE_CATALOGUE.filter((probe) => probe.inputsRequired?.length);
|
|
23
47
|
const result = createResult('probes', 'pass', {
|
|
24
48
|
summary: 'Probe catalogue inspected for the current project without running mutating actions.',
|
|
25
49
|
verified: [
|
|
@@ -28,19 +52,20 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
|
|
|
28
52
|
summarizeCounts('Probes by default mode', byMode),
|
|
29
53
|
summarizeCounts('Probes by safety level', bySafety),
|
|
30
54
|
'Detected safe automatic probes for this project: ' + selection.selected.length,
|
|
55
|
+
'Explicit input probes: ' + explicitInputProbes.map((probe) => `${probe.id} (${probe.inputsRequired.join(' + ')})`).join('; '),
|
|
31
56
|
'Project boundary: ' + boundary.root,
|
|
32
57
|
'Detected platforms: ' + (stack.platforms.length ? stack.platforms.join(', ') : 'none')
|
|
33
58
|
],
|
|
34
59
|
skipped: [
|
|
35
60
|
...(boundary.message ? [boundary.message] : []),
|
|
36
|
-
...selection.skipped.slice(0, 20).map((probe) => `${probe.id}: ${probe.reason}`)
|
|
61
|
+
...selection.skipped.slice(0, 20).map((probe) => `${probe.id}: ${probe.reason}; next: ${probe.nextSafeStep}`)
|
|
37
62
|
],
|
|
38
63
|
checks: selection.selected.map((probe) => ({
|
|
39
64
|
name: probe.id,
|
|
40
65
|
status: 'pass',
|
|
41
66
|
message: `${probe.area}/${probe.stack}: ${probe.name}`
|
|
42
67
|
})),
|
|
43
|
-
notVerified: ['Probe catalogue inspection does not run route, local runtime, deploy, or external service checks by itself.'],
|
|
68
|
+
notVerified: ['Probe catalogue inspection does not run route, local runtime, deploy, or external service checks by itself.', 'Skipped probes are proof gaps, not failures.'],
|
|
44
69
|
data: {
|
|
45
70
|
boundary,
|
|
46
71
|
stack,
|
|
@@ -48,19 +73,12 @@ export async function runProbes({ cwd = process.cwd(), strict = false, skip = []
|
|
|
48
73
|
byArea,
|
|
49
74
|
byMode,
|
|
50
75
|
bySafety,
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
defaultMode: probe.defaultMode,
|
|
58
|
-
evidenceCollected: probe.evidenceCollected,
|
|
59
|
-
proves: probe.proves,
|
|
60
|
-
doesNotProve: probe.doesNotProve,
|
|
61
|
-
nextSafeStep: probe.nextSafeStep
|
|
62
|
-
})),
|
|
63
|
-
skipped: selection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
|
|
76
|
+
catalogue: PROBE_CATALOGUE.map(probeJson),
|
|
77
|
+
detected: selection.selected.map(probeJson),
|
|
78
|
+
skipped: selection.skipped.map((probe) => ({
|
|
79
|
+
...probeJson(probe),
|
|
80
|
+
reason: probe.reason
|
|
81
|
+
}))
|
|
64
82
|
},
|
|
65
83
|
nextSafeStep: 'Run opstruth for selected safe probes, or add explicit route/local inputs for stronger runtime evidence.'
|
|
66
84
|
});
|
package/src/commands/routes.js
CHANGED
|
@@ -8,12 +8,12 @@ export async function runRoutes({ cwd = process.cwd(), baseUrl, routesFile, stri
|
|
|
8
8
|
const boundary = await resolveProjectBoundary(cwd);
|
|
9
9
|
cwd = boundary.root;
|
|
10
10
|
let config = routesFile ? await loadRoutesConfig(cwd, routesFile) : null;
|
|
11
|
-
|
|
12
|
-
if (!config) config =
|
|
11
|
+
const defaultConfig = config ? null : await findDefaultRoutesConfig(cwd);
|
|
12
|
+
if (!config) config = defaultConfig?.config;
|
|
13
|
+
if (defaultConfig?.warning) return createResult('routes', 'warn', { warnings: [defaultConfig.warning], notVerified: ['Public route availability'], nextSafeStep: 'Fix opstruth.config.json or pass --routes with valid JSON.' });
|
|
13
14
|
const finalBase = baseUrl || config?.baseUrl;
|
|
14
15
|
if (!finalBase) return createResult('routes', 'skipped', { skipped: ['Route checks skipped because no --base-url or route config was provided'], notVerified: ['Public route availability'], nextSafeStep: 'Run opstruth routes --base-url https://example.com or provide --routes.' });
|
|
15
|
-
const
|
|
16
|
-
const routes = config?.routes?.length ? config.routes : paths.length ? paths : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
|
|
16
|
+
const routes = config?.routes?.length ? config.routes : [{ path: '/', method: 'HEAD', expectStatus: [200, 301, 302] }];
|
|
17
17
|
const requiredHeaders = config?.requiredHeaders || DEFAULT_HEADERS;
|
|
18
18
|
const checks = [];
|
|
19
19
|
const warnings = [];
|
package/src/commands/secrets.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { createFinding, createResult, finalizeStatus } from '../lib/result.js';
|
|
2
2
|
import { scanRiskyReferences } from '../lib/scan.js';
|
|
3
3
|
import { resolveProjectBoundary } from '../lib/boundary.js';
|
|
4
|
+
import { loadOpstruthConfig } from '../lib/config.js';
|
|
4
5
|
|
|
5
6
|
export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
|
|
6
7
|
const boundary = await resolveProjectBoundary(cwd);
|
|
7
|
-
const
|
|
8
|
+
const loaded = await loadOpstruthConfig(boundary.root);
|
|
9
|
+
const secretConfig = loaded.config?.secrets || {};
|
|
10
|
+
const findings = await scanRiskyReferences(boundary.root, {
|
|
11
|
+
allowlistPaths: secretConfig.allowlistPaths || [],
|
|
12
|
+
allowlistPatterns: secretConfig.allowlistPatterns || []
|
|
13
|
+
});
|
|
8
14
|
const findingObjects = findings.map((item) => createFinding({
|
|
9
15
|
status: 'warn',
|
|
10
16
|
area: 'secrets',
|
|
@@ -14,19 +20,21 @@ export async function runSecrets({ cwd = process.cwd(), strict = false } = {}) {
|
|
|
14
20
|
'file: ' + item.file,
|
|
15
21
|
'line: ' + item.line,
|
|
16
22
|
'pattern: ' + item.pattern,
|
|
23
|
+
'kind: ' + item.kind,
|
|
24
|
+
'context: ' + item.context,
|
|
17
25
|
'redacted preview: ' + item.preview
|
|
18
26
|
],
|
|
19
27
|
whyItMatters: 'Secret-like values and service-role references can create account, data, or infrastructure exposure if committed or exposed to browsers.',
|
|
20
28
|
nextSafeStep: 'Confirm whether this is a harmless reference. Move real secrets to secret storage and keep only names/placeholders in source.'
|
|
21
29
|
}));
|
|
22
|
-
const result = createResult('secrets', findings.length ? 'warn' : 'pass', {
|
|
30
|
+
const result = createResult('secrets', loaded.warning || findings.length ? 'warn' : 'pass', {
|
|
23
31
|
summary: 'Redacted risky secret/reference scan completed. .env file contents are skipped.',
|
|
24
32
|
verified: ['Project boundary scanned: ' + boundary.root, 'Source files scanned with redaction', '.env contents were not printed'],
|
|
25
|
-
warnings: findingObjects.map((finding) => finding.finding),
|
|
33
|
+
warnings: [...(loaded.warning ? [loaded.warning] : []), ...findingObjects.map((finding) => finding.finding)],
|
|
26
34
|
findings: findingObjects,
|
|
27
35
|
skipped: boundary.message ? [boundary.message] : [],
|
|
28
36
|
checks: [{ name: 'secret reference scan', status: findings.length ? 'warn' : 'pass', message: findings.length + ' finding(s)' }],
|
|
29
|
-
data: { boundary, findings },
|
|
37
|
+
data: { boundary, configFile: loaded.file, allowlistPaths: secretConfig.allowlistPaths || [], allowlistPatterns: secretConfig.allowlistPatterns || [], findings },
|
|
30
38
|
nextSafeStep: findings.length ? 'Review whether each reference is expected and move real secrets to safe storage.' : 'Keep secrets out of source and rerun before publishing.'
|
|
31
39
|
});
|
|
32
40
|
return finalizeStatus(result, { strict });
|
package/src/lib/config.js
CHANGED
|
@@ -1,18 +1,56 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { pathExists, readJson, readText } from './fs.js';
|
|
3
3
|
|
|
4
|
+
export async function loadOpstruthConfig(root) {
|
|
5
|
+
const full = path.join(root, 'opstruth.config.json');
|
|
6
|
+
if (!(await pathExists(full))) return { config: null, warning: null, file: null };
|
|
7
|
+
try {
|
|
8
|
+
return { config: await readJson(full), warning: null, file: 'opstruth.config.json' };
|
|
9
|
+
} catch (error) {
|
|
10
|
+
return { config: null, warning: 'Invalid opstruth.config.json: ' + error.message, file: 'opstruth.config.json' };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function normalizeRoutesConfig(config) {
|
|
15
|
+
if (!config) return null;
|
|
16
|
+
const routesConfig = config.routes?.baseUrl !== undefined || config.routes?.paths || Array.isArray(config.routes)
|
|
17
|
+
? config.routes
|
|
18
|
+
: config;
|
|
19
|
+
const normalizeRoute = (route) => ({
|
|
20
|
+
...route,
|
|
21
|
+
expectStatus: route.expectStatus || (Number.isFinite(route.expectedStatus) ? [route.expectedStatus] : route.expectedStatus)
|
|
22
|
+
});
|
|
23
|
+
if (Array.isArray(routesConfig)) return { routes: routesConfig.map(normalizeRoute) };
|
|
24
|
+
if (Array.isArray(routesConfig?.routes)) return { ...routesConfig, routes: routesConfig.routes.map(normalizeRoute) };
|
|
25
|
+
if (Array.isArray(routesConfig?.paths)) {
|
|
26
|
+
return {
|
|
27
|
+
...routesConfig,
|
|
28
|
+
routes: routesConfig.paths.map((routePath) => ({
|
|
29
|
+
path: routePath,
|
|
30
|
+
method: String(routePath).includes('health') ? 'GET' : 'HEAD',
|
|
31
|
+
expectStatus: [200, 301, 302]
|
|
32
|
+
}))
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
return routesConfig;
|
|
36
|
+
}
|
|
37
|
+
|
|
4
38
|
export async function loadRoutesConfig(root, file) {
|
|
5
39
|
if (!file) return null;
|
|
6
40
|
const full = path.isAbsolute(file) ? file : path.join(root, file);
|
|
7
41
|
if (!(await pathExists(full))) return null;
|
|
8
|
-
return readJson(full);
|
|
42
|
+
return normalizeRoutesConfig(await readJson(full));
|
|
9
43
|
}
|
|
10
44
|
export async function findDefaultRoutesConfig(root) {
|
|
11
45
|
for (const file of ['opstruth.config.json', 'opstruth.routes.json', 'routes.json']) {
|
|
12
46
|
const full = path.join(root, file);
|
|
13
47
|
if (await pathExists(full)) {
|
|
14
|
-
|
|
15
|
-
|
|
48
|
+
try {
|
|
49
|
+
const config = await readJson(full);
|
|
50
|
+
return { file, config: normalizeRoutesConfig(config) };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
return { file, config: null, warning: 'Invalid route config ' + file + ': ' + error.message };
|
|
53
|
+
}
|
|
16
54
|
}
|
|
17
55
|
}
|
|
18
56
|
return null;
|
package/src/lib/markdown.js
CHANGED
|
@@ -9,7 +9,7 @@ const ASCII_HEADER = ` ____ _______ __ __
|
|
|
9
9
|
|
|
10
10
|
Operational truth checks for AI-assisted engineering.`;
|
|
11
11
|
|
|
12
|
-
function statusLabel(status) {
|
|
12
|
+
export function statusLabel(status) {
|
|
13
13
|
if (status === 'pass') return 'Pass';
|
|
14
14
|
if (status === 'warn') return 'Partial pass';
|
|
15
15
|
if (status === 'fail') return 'Fail';
|
|
@@ -178,9 +178,24 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
|
|
|
178
178
|
'## Check Results',
|
|
179
179
|
list(checks, '- No checks attached', 25),
|
|
180
180
|
'',
|
|
181
|
+
'## Probe Results',
|
|
182
|
+
list(checks, '- No probe results attached', 25),
|
|
183
|
+
'',
|
|
181
184
|
'## Verified Facts',
|
|
182
185
|
list(liveVerification, '- No live verification evidence attached', 20),
|
|
183
186
|
'',
|
|
187
|
+
'## Warnings',
|
|
188
|
+
list(risks.filter((risk) => /warn|warning|review/i.test(risk)), '- None recorded', 20),
|
|
189
|
+
'',
|
|
190
|
+
'## Failures',
|
|
191
|
+
list(risks.filter((risk) => /fail|failure|blocked/i.test(risk)), '- None recorded', 20),
|
|
192
|
+
'',
|
|
193
|
+
'## Skipped / Not Configured',
|
|
194
|
+
'Skipped checks are proof gaps, not failures. See command output for skipped probe IDs and reasons.',
|
|
195
|
+
'',
|
|
196
|
+
'## Not Verified',
|
|
197
|
+
'Production, local runtime, database state, queues, publishing, and external AI usage are not verified unless explicit read-only inputs or external evidence are attached.',
|
|
198
|
+
'',
|
|
184
199
|
'## Risks And Gaps',
|
|
185
200
|
table(['Severity', 'Finding'], riskRows.slice(0, 25)),
|
|
186
201
|
'',
|
|
@@ -196,6 +211,12 @@ export function evidenceMarkdown({ title = 'opstruth Evidence Pack', status = 'n
|
|
|
196
211
|
'## Safety Boundaries',
|
|
197
212
|
list(safetyBoundaries, '- Read-only checks only'),
|
|
198
213
|
'',
|
|
214
|
+
'## Evidence Files / Paths',
|
|
215
|
+
list(scope.concat(filesChanged).filter(Boolean), '- No evidence paths attached', 30),
|
|
216
|
+
'',
|
|
217
|
+
'## Confidence',
|
|
218
|
+
confidenceFor({ status, failures: risks.filter((risk) => /fail|failure|blocked/i.test(risk)), warnings: risks, skipped: [], notVerified: [] }),
|
|
219
|
+
'',
|
|
199
220
|
'## Next Safe Step',
|
|
200
221
|
nextSafeStep || 'Run the narrowest missing read-only verification and attach the result.'
|
|
201
222
|
].join('\n') + '\n';
|
package/src/lib/probes.js
CHANGED
|
@@ -10,7 +10,7 @@ function nodeDependencyDetector(name) {
|
|
|
10
10
|
return async (_root, stack) => stack.dependencies?.includes(name);
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
const RAW_PROBE_CATALOGUE = [
|
|
14
14
|
{
|
|
15
15
|
id: 'git.status',
|
|
16
16
|
name: 'Git status',
|
|
@@ -463,6 +463,48 @@ export const PROBE_CATALOGUE = [
|
|
|
463
463
|
}
|
|
464
464
|
];
|
|
465
465
|
|
|
466
|
+
function inputsRequiredFor(probe) {
|
|
467
|
+
if (probe.id.startsWith('routes.')) return ['--base-url or route config'];
|
|
468
|
+
if (probe.id === 'local.ports') return ['--port or local config'];
|
|
469
|
+
if (probe.id === 'local.health') return ['--port and --health or local config'];
|
|
470
|
+
if (probe.id === 'supabase.migrations') return ['supabase/migrations directory'];
|
|
471
|
+
if (probe.id === 'cloudflare.wrangler') return ['wrangler.toml, wrangler.json, or wrangler.jsonc'];
|
|
472
|
+
if (probe.id.startsWith('quality.')) return ['matching package.json script'];
|
|
473
|
+
if (probe.id.startsWith('node.')) return ['matching package metadata/config/source'];
|
|
474
|
+
if (probe.id.startsWith('git.')) return ['git repository'];
|
|
475
|
+
return [];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function skipReasonFor(probe) {
|
|
479
|
+
if (probe.id.startsWith('routes.')) return 'Requires --base-url, --routes, or opstruth.config.json route entries';
|
|
480
|
+
if (probe.id === 'local.ports') return 'Requires --port or opstruth.config.json local ports';
|
|
481
|
+
if (probe.id === 'local.health') return 'Requires --port with --health or opstruth.config.json local health paths';
|
|
482
|
+
if (probe.id === 'supabase.migrations') return 'Requires a Supabase migrations directory';
|
|
483
|
+
if (probe.id === 'cloudflare.wrangler') return 'Requires Wrangler configuration';
|
|
484
|
+
if (probe.id.startsWith('quality.')) return 'Requires a matching non-placeholder package.json script';
|
|
485
|
+
if (probe.id.startsWith('git.')) return 'Requires a git repository';
|
|
486
|
+
return 'Not relevant to detected stack or missing configuration';
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function normalizeProbe(probe) {
|
|
490
|
+
const inputsRequired = probe.inputsRequired || inputsRequiredFor(probe);
|
|
491
|
+
return {
|
|
492
|
+
...probe,
|
|
493
|
+
mode: probe.mode || probe.defaultMode,
|
|
494
|
+
mutability: probe.mutability || 'none',
|
|
495
|
+
inputsRequired,
|
|
496
|
+
evidenceExpectation: probe.evidenceExpectation || probe.evidenceCollected || [],
|
|
497
|
+
skipReason: probe.skipReason || skipReasonFor(probe),
|
|
498
|
+
proofLimitation: probe.proofLimitation || probe.doesNotProve,
|
|
499
|
+
supportedStacks: probe.supportedStacks || [probe.stack],
|
|
500
|
+
notVerified: probe.notVerified || [probe.doesNotProve],
|
|
501
|
+
falsePositiveRisk: probe.falsePositiveRisk || 'Low to medium; depends on project conventions and fixture/demo content.',
|
|
502
|
+
falseNegativeRisk: probe.falseNegativeRisk || 'Does not prove absence outside scanned files, configured inputs, or supported stack heuristics.'
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export const PROBE_CATALOGUE = RAW_PROBE_CATALOGUE.map(normalizeProbe);
|
|
507
|
+
|
|
466
508
|
export async function selectProbes({ root, stack, boundary, options = {} }) {
|
|
467
509
|
const only = new Set(options.only || []);
|
|
468
510
|
const skip = new Set(options.skip || []);
|
|
@@ -483,7 +525,7 @@ export async function selectProbes({ root, stack, boundary, options = {} }) {
|
|
|
483
525
|
}
|
|
484
526
|
const relevant = await probe.detector(root, stack, boundary, options);
|
|
485
527
|
if (relevant) selected.push(probe);
|
|
486
|
-
else skipped.push({ ...probe, reason: 'Not relevant to detected stack or missing configuration' });
|
|
528
|
+
else skipped.push({ ...probe, reason: probe.skipReason || 'Not relevant to detected stack or missing configuration' });
|
|
487
529
|
}
|
|
488
530
|
return { selected, skipped, catalogueSize: PROBE_CATALOGUE.length };
|
|
489
531
|
}
|
package/src/lib/scan.js
CHANGED
|
@@ -10,21 +10,79 @@ const OPSTRUTH_SCANNER_FILES = new Set([
|
|
|
10
10
|
'src/lib/scan.js',
|
|
11
11
|
'src/lib/probes.js',
|
|
12
12
|
'test/typescript-compatibility.test.js',
|
|
13
|
+
'cli/src/lib/redact.js',
|
|
14
|
+
'cli/src/lib/scan.js',
|
|
15
|
+
'cli/src/lib/probes.js',
|
|
16
|
+
'cli/test/typescript-compatibility.test.js',
|
|
13
17
|
'fixtures/risky-secret-app/src/config.js'
|
|
14
18
|
]);
|
|
19
|
+
const FIXTURE_PACKAGE_NAMES = new Set([
|
|
20
|
+
'plain-node-app',
|
|
21
|
+
'vite-react-app',
|
|
22
|
+
'next-app',
|
|
23
|
+
'tanstack-app',
|
|
24
|
+
'cloudflare-worker-app',
|
|
25
|
+
'supabase-app',
|
|
26
|
+
'default-npm-placeholder-test',
|
|
27
|
+
'failing-real-test-script',
|
|
28
|
+
'risky-secret-app',
|
|
29
|
+
'missing-build-script',
|
|
30
|
+
'route-config-app'
|
|
31
|
+
]);
|
|
15
32
|
|
|
16
33
|
export function isLikelyText(file) { return /\.(js|mjs|cjs|ts|tsx|jsx|json|jsonc|toml|yml|yaml|md|txt|env|sql|html|css)$/i.test(file) || !path.extname(file); }
|
|
17
34
|
|
|
35
|
+
function matchesAllowlist(file, line, { allowlistPaths = [], allowlistPatterns = [] } = {}) {
|
|
36
|
+
if (allowlistPaths.some((item) => file === item || file.startsWith(item.replace(/\/$/, '') + '/'))) return true;
|
|
37
|
+
return allowlistPatterns.some((pattern) => {
|
|
38
|
+
try { return new RegExp(pattern).test(line) || new RegExp(pattern).test(file); } catch { return false; }
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function classifySecretLine(line) {
|
|
43
|
+
if (/[=:]\s*["']?[^"'\s;]+/.test(line)) return 'secret-like value';
|
|
44
|
+
return 'secret reference';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function classifySourceContext(file, rootContext = 'source file') {
|
|
48
|
+
if (file.startsWith('fixtures/') || file.startsWith('cli/fixtures/')) return 'fixture/demo file';
|
|
49
|
+
if (file.startsWith('docs/') || file.startsWith('cli/docs/')) return 'documentation reference';
|
|
50
|
+
return rootContext;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function classifyRootContext(root) {
|
|
54
|
+
const packageFile = path.join(root, 'package.json');
|
|
55
|
+
if (!(await pathExists(packageFile))) return 'source file';
|
|
56
|
+
try {
|
|
57
|
+
const name = (await readJson(packageFile)).name;
|
|
58
|
+
return FIXTURE_PACKAGE_NAMES.has(name) ? 'fixture/demo file' : 'source file';
|
|
59
|
+
} catch {
|
|
60
|
+
return 'source file';
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
18
64
|
async function isOpstruthRoot(root) {
|
|
19
65
|
const packageFile = path.join(root, 'package.json');
|
|
20
|
-
|
|
21
|
-
try {
|
|
66
|
+
const cliPackageFile = path.join(root, 'cli/package.json');
|
|
67
|
+
try {
|
|
68
|
+
if (await pathExists(packageFile)) {
|
|
69
|
+
const name = (await readJson(packageFile)).name;
|
|
70
|
+
if (name === 'opstruth' || name === 'opstruth-monorepo') return true;
|
|
71
|
+
}
|
|
72
|
+
if (await pathExists(cliPackageFile)) {
|
|
73
|
+
return (await readJson(cliPackageFile)).name === 'opstruth';
|
|
74
|
+
}
|
|
75
|
+
} catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
return false;
|
|
22
79
|
}
|
|
23
80
|
|
|
24
|
-
export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS } = {}) {
|
|
81
|
+
export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS, allowlistPaths = [], allowlistPatterns = [] } = {}) {
|
|
25
82
|
const files = await walkFiles(root, { skipDirs: mergeIgnores(skipDirs) });
|
|
26
83
|
const findings = [];
|
|
27
84
|
const suppressInternalScannerDefinitions = await isOpstruthRoot(root);
|
|
85
|
+
const rootContext = await classifyRootContext(root);
|
|
28
86
|
for (const file of files) {
|
|
29
87
|
if (suppressInternalScannerDefinitions && OPSTRUTH_SCANNER_FILES.has(file.rel)) continue;
|
|
30
88
|
if (!isLikelyText(file.rel)) continue;
|
|
@@ -33,6 +91,7 @@ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS }
|
|
|
33
91
|
try { text = await readText(file.full); } catch { continue; }
|
|
34
92
|
const lines = text.split(/\r?\n/);
|
|
35
93
|
lines.forEach((line, index) => {
|
|
94
|
+
if (matchesAllowlist(file.rel, line, { allowlistPaths, allowlistPatterns })) return;
|
|
36
95
|
for (const pattern of RISK_PATTERNS) {
|
|
37
96
|
if (pattern.test(line)) {
|
|
38
97
|
findings.push({
|
|
@@ -40,6 +99,8 @@ export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS }
|
|
|
40
99
|
line: index + 1,
|
|
41
100
|
pattern: pattern.source.replaceAll('\\', ''),
|
|
42
101
|
match: pattern.source.replaceAll('\\', ''),
|
|
102
|
+
kind: classifySecretLine(line),
|
|
103
|
+
context: classifySourceContext(file.rel, rootContext),
|
|
43
104
|
preview: redact(line.trim()).slice(0, 160),
|
|
44
105
|
excerpt: redact(line.trim()).slice(0, 160),
|
|
45
106
|
severity: 'review'
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
|
|
2
|
+
|
|
3
|
+
const RESET = '\x1b[0m';
|
|
4
|
+
|
|
5
|
+
// Website-derived terminal approximations from website/src/styles.css.
|
|
6
|
+
// The website tokens are OKLCH; these RGB values keep the same calm dark,
|
|
7
|
+
// muted, evidence-first feel in common ANSI terminals.
|
|
8
|
+
export const THEME = {
|
|
9
|
+
heading: ['\x1b[1m', '\x1b[38;2;235;237;240m'],
|
|
10
|
+
muted: ['\x1b[38;2;150;154;163m'],
|
|
11
|
+
accent: ['\x1b[38;2;126;190;164m'],
|
|
12
|
+
success: ['\x1b[38;2;104;194;142m'],
|
|
13
|
+
warning: ['\x1b[38;2;219;178;80m'],
|
|
14
|
+
failure: ['\x1b[38;2;221;105;92m'],
|
|
15
|
+
code: ['\x1b[38;2;226;229;232m'],
|
|
16
|
+
border: ['\x1b[38;2;82;87;96m']
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function hasAnsi(text) {
|
|
20
|
+
return ANSI_PATTERN.test(text);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function stripAnsi(text) {
|
|
24
|
+
return String(text).replace(ANSI_PATTERN, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function supportsColor(options = {}, stream = process.stdout, env = process.env) {
|
|
28
|
+
if (options.json || options.noColor) return false;
|
|
29
|
+
if (options.color) return true;
|
|
30
|
+
if ('NO_COLOR' in env) return false;
|
|
31
|
+
if (env.TERM === 'dumb') return false;
|
|
32
|
+
return Boolean(stream?.isTTY);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function style(text, token, options = {}) {
|
|
36
|
+
if (!options.colorEnabled) return String(text);
|
|
37
|
+
const open = THEME[token] || THEME.heading;
|
|
38
|
+
return open.join('') + text + RESET;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function statusToken(label = '') {
|
|
42
|
+
const normalized = label.toLowerCase();
|
|
43
|
+
if (normalized === 'pass' || normalized === 'verified') return 'success';
|
|
44
|
+
if (normalized === 'partial pass' || normalized === 'warn' || normalized === 'warning') return 'warning';
|
|
45
|
+
if (normalized === 'fail' || normalized === 'failure') return 'failure';
|
|
46
|
+
if (normalized === 'skipped' || normalized === 'not verified' || normalized === 'not_verified') return 'muted';
|
|
47
|
+
return 'heading';
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sectionToken(line) {
|
|
51
|
+
if (/^## Verified\b/.test(line)) return 'success';
|
|
52
|
+
if (/^## Warnings\b/.test(line)) return 'warning';
|
|
53
|
+
if (/^## Failures\b/.test(line)) return 'failure';
|
|
54
|
+
if (/^## Skipped\b/.test(line)) return 'muted';
|
|
55
|
+
if (/^## Not Verified\b/.test(line)) return 'warning';
|
|
56
|
+
if (/^## Evidence\b/.test(line)) return 'accent';
|
|
57
|
+
if (/^## Next Safe Step\b/.test(line)) return 'accent';
|
|
58
|
+
if (/^## Overall Confidence\b/.test(line)) return 'accent';
|
|
59
|
+
if (/^## Checks\b|^## Check Summary\b|^## What Matters Most\b/.test(line)) return 'heading';
|
|
60
|
+
return 'heading';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function colorStatusWords(line, options) {
|
|
64
|
+
return line.replace(/\b(Partial pass|Not verified|Pass|Fail|Skipped|pass|warn|fail|skipped|not_verified)\b/g, (label) => {
|
|
65
|
+
return style(label, statusToken(label), options);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function colorTableLine(line, options) {
|
|
70
|
+
if (/^\| [-| ]+\|$/.test(line) || /^\| Status \|/.test(line) || /^\| Area \|/.test(line)) {
|
|
71
|
+
return style(line, 'border', options);
|
|
72
|
+
}
|
|
73
|
+
return line.replace(/\| (Partial pass|Not verified|Pass|Fail|Skipped|pass|warn|fail|skipped|not_verified) \|/g, (_, label) => {
|
|
74
|
+
return '| ' + style(label, statusToken(label), options) + ' |';
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function colorEvidenceLine(line, options) {
|
|
79
|
+
const fileMatch = line.match(/^(\s*evidence: )(file:)(\s+)(.+)$/);
|
|
80
|
+
if (fileMatch) {
|
|
81
|
+
return style(fileMatch[1], 'muted', options) + style(fileMatch[2], 'accent', options) + fileMatch[3] + style(fileMatch[4], 'code', options);
|
|
82
|
+
}
|
|
83
|
+
const writtenMatch = line.match(/^(.*Evidence written to:\s+)(.+)$/);
|
|
84
|
+
if (writtenMatch) {
|
|
85
|
+
return style(writtenMatch[1], 'accent', options) + style(writtenMatch[2], 'code', options);
|
|
86
|
+
}
|
|
87
|
+
let formatted = line.replace(/^(\s*evidence: )([^:]+:)/, (_, prefix, key) => {
|
|
88
|
+
return style(prefix, 'muted', options) + style(key, 'accent', options);
|
|
89
|
+
});
|
|
90
|
+
formatted = formatted.replace(/^(\s*(why it matters|next safe step):)/, (match) => style(match, 'accent', options));
|
|
91
|
+
return colorStatusWords(formatted, options);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function colorLine(line, options) {
|
|
95
|
+
if (!line || hasAnsi(line)) return line;
|
|
96
|
+
if (/^ ____|^ \/|^ \/ |^\/ \/_|^\\____|^ \/_/.test(line)) return style(line, 'accent', options);
|
|
97
|
+
if (/^Operational truth checks/.test(line)) return style(line, 'muted', options);
|
|
98
|
+
if (/^# /.test(line)) return style(line.replace(/^# /, ''), 'heading', options);
|
|
99
|
+
if (/^## /.test(line)) return style(line.replace(/^## /, ''), sectionToken(line), options);
|
|
100
|
+
if (/^STATUS: /.test(line)) {
|
|
101
|
+
const label = line.slice('STATUS: '.length);
|
|
102
|
+
return style('STATUS:', 'muted', options) + ' ' + style(label, statusToken(label), options);
|
|
103
|
+
}
|
|
104
|
+
if (/^\|/.test(line)) return colorTableLine(line, options);
|
|
105
|
+
if (/^- \[(pass|warn|fail|skipped|not_verified)\]/.test(line)) return colorStatusWords(line, options);
|
|
106
|
+
if (/^\s*(evidence|why it matters|next safe step):/.test(line)) return colorEvidenceLine(line, options);
|
|
107
|
+
if (/Evidence written to:|Evidence file was not written/.test(line)) return colorEvidenceLine(line, options);
|
|
108
|
+
if (/^Usage:|^Commands:|^Global options:|^Options:|^Common workflows:|^Safety philosophy:/.test(line)) return style(line, 'heading', options);
|
|
109
|
+
if (/^\s{2}(opstruth|--|-h|npm|npx)\b/.test(line) || /^- opstruth\b/.test(line)) return style(line, 'code', options);
|
|
110
|
+
if (/^- None$|^- No failures$/.test(line)) return style(line, 'muted', options);
|
|
111
|
+
return line;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function formatTerminalOutput(text, options = {}) {
|
|
115
|
+
const colorEnabled = supportsColor(options);
|
|
116
|
+
if (!colorEnabled) return String(text);
|
|
117
|
+
const styleOptions = { ...options, colorEnabled };
|
|
118
|
+
return String(text).split('\n').map((line) => colorLine(line, styleOptions)).join('\n');
|
|
119
|
+
}
|
package/src/orchestrator.js
CHANGED
|
@@ -25,6 +25,29 @@ function nextStepFor(aggregate) {
|
|
|
25
25
|
return 'Attach the evidence pack to the change or handoff.';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
function probeJson(probe) {
|
|
29
|
+
return {
|
|
30
|
+
id: probe.id,
|
|
31
|
+
name: probe.name,
|
|
32
|
+
area: probe.area,
|
|
33
|
+
stack: probe.stack,
|
|
34
|
+
mode: probe.mode,
|
|
35
|
+
safetyLevel: probe.safetyLevel,
|
|
36
|
+
defaultMode: probe.defaultMode,
|
|
37
|
+
mutability: probe.mutability,
|
|
38
|
+
inputsRequired: probe.inputsRequired,
|
|
39
|
+
evidenceCollected: probe.evidenceCollected,
|
|
40
|
+
evidenceExpectation: probe.evidenceExpectation,
|
|
41
|
+
proves: probe.proves,
|
|
42
|
+
doesNotProve: probe.doesNotProve,
|
|
43
|
+
proofLimitation: probe.proofLimitation,
|
|
44
|
+
skipReason: probe.skipReason,
|
|
45
|
+
nextSafeStep: probe.nextSafeStep,
|
|
46
|
+
supportedStacks: probe.supportedStacks,
|
|
47
|
+
notVerified: probe.notVerified
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
28
51
|
export async function runOrchestrator(options = {}) {
|
|
29
52
|
const startCwd = options.cwd || process.cwd();
|
|
30
53
|
const boundary = await resolveProjectBoundary(startCwd);
|
|
@@ -75,18 +98,8 @@ export async function runOrchestrator(options = {}) {
|
|
|
75
98
|
stack,
|
|
76
99
|
probes: {
|
|
77
100
|
catalogueSize: probeSelection.catalogueSize,
|
|
78
|
-
selected: probeSelection.selected.map(
|
|
79
|
-
|
|
80
|
-
name: probe.name,
|
|
81
|
-
area: probe.area,
|
|
82
|
-
stack: probe.stack,
|
|
83
|
-
safetyLevel: probe.safetyLevel,
|
|
84
|
-
defaultMode: probe.defaultMode,
|
|
85
|
-
proves: probe.proves,
|
|
86
|
-
doesNotProve: probe.doesNotProve,
|
|
87
|
-
evidenceCollected: probe.evidenceCollected
|
|
88
|
-
})),
|
|
89
|
-
skipped: probeSelection.skipped.map((probe) => ({ id: probe.id, area: probe.area, stack: probe.stack, reason: probe.reason }))
|
|
101
|
+
selected: probeSelection.selected.map(probeJson),
|
|
102
|
+
skipped: probeSelection.skipped.map((probe) => ({ ...probeJson(probe), reason: probe.reason }))
|
|
90
103
|
},
|
|
91
104
|
childResults
|
|
92
105
|
},
|