opstruth 0.1.2 → 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.
@@ -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
- export const PROBE_CATALOGUE = [
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/redact.js CHANGED
@@ -16,6 +16,7 @@ export function redact(value = '') {
16
16
  let output = String(value);
17
17
  for (const pattern of VALUE_PATTERNS) output = output.replace(pattern, '$1[REDACTED]');
18
18
  output = output.replace(/([A-Za-z0-9_]{12,}\.[A-Za-z0-9_\-]{12,}\.[A-Za-z0-9_\-]{12,})/g, '[REDACTED_TOKEN]');
19
+ output = output.replace(/\b[A-Za-z0-9_-]{40,}\b/g, '[REDACTED_TOKEN]');
19
20
  return output;
20
21
  }
21
22
 
package/src/lib/scan.js CHANGED
@@ -1,53 +1,280 @@
1
1
  import path from 'node:path';
2
+ import { execFile } from 'node:child_process';
3
+ import { promisify } from 'node:util';
2
4
  import { walkFiles, readText, readJson, pathExists } from './fs.js';
3
5
  import { redact } from './redact.js';
4
6
  import { mergeIgnores } from './boundary.js';
5
7
 
8
+ const execFileAsync = promisify(execFile);
9
+
6
10
  export const DEFAULT_SKIP_DIRS = mergeIgnores();
7
- export const RISK_PATTERNS = [/OPENAI_API_KEY/i, /SUPABASE_SERVICE_ROLE_KEY/i, /service_role/i, /access_token/i, /refresh_token/i, /client_secret/i, /private_key/i, /webhook_secret/i, /api_key/i, /bearer/i, /authorization/i];
11
+ export const SECRET_CATEGORIES = [
12
+ 'actionable_source_finding',
13
+ 'documentation_reference',
14
+ 'placeholder_or_example',
15
+ 'local_only_file',
16
+ 'generated_artifact',
17
+ 'dependency_or_lockfile',
18
+ 'ignored_binary',
19
+ 'unknown_requires_review'
20
+ ];
21
+
22
+ export const RISK_PATTERNS = [
23
+ { label: 'OPENAI_API_KEY', regex: /OPENAI_API_KEY/i },
24
+ { label: 'SUPABASE_SERVICE_ROLE_KEY', regex: /SUPABASE_SERVICE_ROLE_KEY/i },
25
+ { label: 'service_role', regex: /service_role/i },
26
+ { label: 'access_token', regex: /access_token/i },
27
+ { label: 'refresh_token', regex: /refresh_token/i },
28
+ { label: 'client_secret', regex: /client_secret/i },
29
+ { label: 'private_key', regex: /private_key/i },
30
+ { label: 'webhook_secret', regex: /webhook_secret/i },
31
+ { label: 'api_key', regex: /api_key/i },
32
+ { label: 'bearer', regex: /bearer/i },
33
+ { label: 'authorization', regex: /authorization/i },
34
+ { label: 'GH_TOKEN', regex: /GH_TOKEN/i },
35
+ { label: 'GITHUB_TOKEN', regex: /GITHUB_TOKEN/i },
36
+ { label: 'SUPABASE_ACCESS_TOKEN', regex: /SUPABASE_ACCESS_TOKEN/i },
37
+ { label: 'IMPORT_REDDIT_TIPS_SECRET', regex: /IMPORT_REDDIT_TIPS_SECRET/i },
38
+ { label: 'NPM_TOKEN', regex: /NPM_TOKEN/i },
39
+ { label: 'jwt_like', regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/ },
40
+ { label: 'unknown_token_like', regex: /\b[A-Za-z0-9_-]{40,}\b/ }
41
+ ];
42
+
8
43
  const OPSTRUTH_SCANNER_FILES = new Set([
9
44
  'src/lib/redact.js',
10
45
  'src/lib/scan.js',
11
46
  'src/lib/probes.js',
12
47
  'test/typescript-compatibility.test.js',
48
+ 'cli/src/lib/redact.js',
49
+ 'cli/src/lib/scan.js',
50
+ 'cli/src/lib/probes.js',
51
+ 'cli/test/typescript-compatibility.test.js',
13
52
  'fixtures/risky-secret-app/src/config.js'
14
53
  ]);
15
54
 
16
- 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); }
55
+ const FIXTURE_PACKAGE_NAMES = new Set([
56
+ 'plain-node-app',
57
+ 'vite-react-app',
58
+ 'next-app',
59
+ 'tanstack-app',
60
+ 'cloudflare-worker-app',
61
+ 'supabase-app',
62
+ 'default-npm-placeholder-test',
63
+ 'failing-real-test-script',
64
+ 'risky-secret-app',
65
+ 'missing-build-script',
66
+ 'route-config-app'
67
+ ]);
68
+
69
+ const LOCKFILE_NAMES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'bun.lock', 'bun.lockb']);
70
+ const GENERATED_PREFIXES = ['dist/', 'dist-ssr/', 'build/', '.next/', '.cache/', 'coverage/'];
71
+ const DEPENDENCY_PREFIXES = ['node_modules/'];
72
+
73
+ export function isLikelyText(file) {
74
+ return /\.(js|mjs|cjs|ts|tsx|jsx|json|jsonc|toml|yml|yaml|md|txt|env|sql|html|css)$/i.test(file) || !path.extname(file);
75
+ }
76
+
77
+ function matchesAllowlist(file, line, { allowlistPaths = [], allowlistPatterns = [] } = {}) {
78
+ if (allowlistPaths.some((item) => file === item || file.startsWith(item.replace(/\/$/, '') + '/'))) return true;
79
+ return allowlistPatterns.some((pattern) => {
80
+ try {
81
+ return new RegExp(pattern).test(line) || new RegExp(pattern).test(file);
82
+ } catch {
83
+ return false;
84
+ }
85
+ });
86
+ }
87
+
88
+ function isDocumentationFile(file) {
89
+ return file === 'README.md' || file.endsWith('/README.md') || file.startsWith('docs/') || file.startsWith('cli/docs/') || /\.md$/i.test(file);
90
+ }
91
+
92
+ function isGeneratedPath(file) {
93
+ return GENERATED_PREFIXES.some((prefix) => file.startsWith(prefix));
94
+ }
95
+
96
+ function isDependencyPath(file) {
97
+ return DEPENDENCY_PREFIXES.some((prefix) => file.startsWith(prefix));
98
+ }
99
+
100
+ function isLockfile(file) {
101
+ return LOCKFILE_NAMES.has(path.basename(file));
102
+ }
103
+
104
+ function isEnvFile(file) {
105
+ return path.basename(file).startsWith('.env');
106
+ }
107
+
108
+ const SECRET_ASSIGNMENT_RE = /\b(?:const\s+|let\s+|var\s+|export\s+)?(?:OPENAI_API_KEY|SUPABASE_SERVICE_ROLE_KEY|GH_TOKEN|GITHUB_TOKEN|SUPABASE_ACCESS_TOKEN|IMPORT_REDDIT_TIPS_SECRET|NPM_TOKEN|service_role|access_token|refresh_token|client_secret|private_key|webhook_secret|api_key|authorization|bearer)\b\s*[:=]\s*["']?([^"'\s;]+)/i;
109
+
110
+ function hasAssignment(line) {
111
+ return SECRET_ASSIGNMENT_RE.test(line);
112
+ }
113
+
114
+ function assignedValue(line) {
115
+ const match = line.match(SECRET_ASSIGNMENT_RE);
116
+ return match?.[1] || '';
117
+ }
118
+
119
+ function isPlaceholderValue(line) {
120
+ const value = assignedValue(line).trim();
121
+ return /^(YOUR_[A-Z0-9_]+_HERE|__REDACTED_VALUE__|<[^>]*secret[^>]*>|example-only|\[REDACTED\]|REDACTED|\*{3,}|xxx+|placeholder)$/i.test(value);
122
+ }
123
+
124
+ function classifyKind(line) {
125
+ if (hasAssignment(line)) return 'secret-like value';
126
+ return 'secret reference';
127
+ }
128
+
129
+ export function classifySecretReference({ file, line = '', pattern = '', tracked = false, rootContext = 'source file' } = {}) {
130
+ if (!isLikelyText(file)) {
131
+ return { category: 'ignored_binary', severity: 'skipped', status: 'skipped', kind: 'ignored binary', context: 'binary file' };
132
+ }
133
+ if (isDependencyPath(file) || isLockfile(file)) {
134
+ return { category: 'dependency_or_lockfile', severity: 'skipped', status: 'skipped', kind: 'ignored dependency/lockfile', context: 'dependency or lockfile' };
135
+ }
136
+ if (isGeneratedPath(file)) {
137
+ return { category: 'generated_artifact', severity: 'skipped', status: 'skipped', kind: 'ignored generated artifact', context: 'generated artifact' };
138
+ }
139
+ if (isEnvFile(file) && !tracked) {
140
+ return { category: 'local_only_file', severity: 'skipped', status: 'skipped', kind: 'local-only env file', context: 'local-only file' };
141
+ }
142
+ if (isPlaceholderValue(line)) {
143
+ return { category: 'placeholder_or_example', severity: 'info', status: 'info', kind: 'placeholder/example', context: classifySourceContext(file, rootContext) };
144
+ }
145
+ if (pattern === 'unknown_token_like') {
146
+ return { category: 'unknown_requires_review', severity: 'review', status: 'warn', kind: 'unknown token-like content', context: classifySourceContext(file, rootContext) };
147
+ }
148
+ if (isDocumentationFile(file) && !hasAssignment(line)) {
149
+ return { category: 'documentation_reference', severity: 'info', status: 'info', kind: 'documentation reference', context: 'documentation reference' };
150
+ }
151
+ if (isEnvFile(file) && tracked) {
152
+ return { category: 'actionable_source_finding', severity: 'review', status: 'warn', kind: classifyKind(line), context: 'tracked env file' };
153
+ }
154
+ return { category: 'actionable_source_finding', severity: 'review', status: 'warn', kind: classifyKind(line), context: classifySourceContext(file, rootContext) };
155
+ }
156
+
157
+ function classifySourceContext(file, rootContext = 'source file') {
158
+ if (file.startsWith('fixtures/') || file.startsWith('cli/fixtures/')) return 'fixture/demo file';
159
+ if (isDocumentationFile(file)) return 'documentation reference';
160
+ return rootContext;
161
+ }
162
+
163
+ async function classifyRootContext(root) {
164
+ const packageFile = path.join(root, 'package.json');
165
+ if (!(await pathExists(packageFile))) return 'source file';
166
+ try {
167
+ const name = (await readJson(packageFile)).name;
168
+ return FIXTURE_PACKAGE_NAMES.has(name) ? 'fixture/demo file' : 'source file';
169
+ } catch {
170
+ return 'source file';
171
+ }
172
+ }
17
173
 
18
174
  async function isOpstruthRoot(root) {
19
175
  const packageFile = path.join(root, 'package.json');
20
- if (!(await pathExists(packageFile))) return false;
21
- try { return (await readJson(packageFile)).name === 'opstruth'; } catch { return false; }
176
+ const cliPackageFile = path.join(root, 'cli/package.json');
177
+ try {
178
+ if (await pathExists(packageFile)) {
179
+ const name = (await readJson(packageFile)).name;
180
+ if (name === 'opstruth' || name === 'opstruth-monorepo') return true;
181
+ }
182
+ if (await pathExists(cliPackageFile)) {
183
+ return (await readJson(cliPackageFile)).name === 'opstruth';
184
+ }
185
+ } catch {
186
+ return false;
187
+ }
188
+ return false;
22
189
  }
23
190
 
24
- export async function scanRiskyReferences(root, { skipDirs = DEFAULT_SKIP_DIRS } = {}) {
191
+ async function trackedFiles(root) {
192
+ try {
193
+ const { stdout } = await execFileAsync('git', ['ls-files'], { cwd: root });
194
+ return new Set(stdout.split(/\r?\n/).filter(Boolean).map((file) => file.replaceAll('\\', '/')));
195
+ } catch {
196
+ return new Set();
197
+ }
198
+ }
199
+
200
+ function emptySummary() {
201
+ return Object.fromEntries(SECRET_CATEGORIES.map((category) => [category, 0]));
202
+ }
203
+
204
+ function summaryText(summary) {
205
+ return [
206
+ `Actionable findings: ${summary.actionable_source_finding || 0}`,
207
+ `Documentation references: ${summary.documentation_reference || 0}`,
208
+ `Placeholders/examples: ${summary.placeholder_or_example || 0}`,
209
+ `Local-only files: ${summary.local_only_file || 0}`,
210
+ `Generated artifacts: ${summary.generated_artifact || 0}`,
211
+ `Dependency/lockfile paths: ${summary.dependency_or_lockfile || 0}`,
212
+ `Ignored binaries: ${summary.ignored_binary || 0}`,
213
+ `Unknown requiring review: ${summary.unknown_requires_review || 0}`
214
+ ].join('; ');
215
+ }
216
+
217
+ export function formatSecretSummary(summary = emptySummary()) {
218
+ return summaryText(summary);
219
+ }
220
+
221
+ export async function scanRiskyReferencesDetailed(root, { skipDirs = DEFAULT_SKIP_DIRS, allowlistPaths = [], allowlistPatterns = [] } = {}) {
25
222
  const files = await walkFiles(root, { skipDirs: mergeIgnores(skipDirs) });
223
+ const records = [];
26
224
  const findings = [];
225
+ const summary = emptySummary();
27
226
  const suppressInternalScannerDefinitions = await isOpstruthRoot(root);
227
+ const rootContext = await classifyRootContext(root);
228
+ const tracked = await trackedFiles(root);
229
+
230
+ function record(item) {
231
+ summary[item.category] = (summary[item.category] || 0) + 1;
232
+ records.push(item);
233
+ if (item.status === 'warn' || item.status === 'fail') findings.push(item);
234
+ }
235
+
28
236
  for (const file of files) {
29
237
  if (suppressInternalScannerDefinitions && OPSTRUTH_SCANNER_FILES.has(file.rel)) continue;
238
+ const isTracked = tracked.has(file.rel);
239
+ const pathOnly = classifySecretReference({ file: file.rel, line: '', tracked: isTracked, rootContext });
240
+ if (pathOnly.category === 'ignored_binary' || pathOnly.category === 'dependency_or_lockfile' || pathOnly.category === 'generated_artifact') {
241
+ record({ file: file.rel, line: null, pattern: 'path', match: 'path', preview: '', excerpt: '', ...pathOnly });
242
+ continue;
243
+ }
244
+ if (pathOnly.category === 'local_only_file') {
245
+ record({ file: file.rel, line: null, pattern: 'env_file', match: 'env_file', preview: '', excerpt: '', ...pathOnly });
246
+ continue;
247
+ }
30
248
  if (!isLikelyText(file.rel)) continue;
31
- if (path.basename(file.rel).startsWith('.env')) continue;
32
249
  let text = '';
33
- try { text = await readText(file.full); } catch { continue; }
250
+ try {
251
+ text = await readText(file.full);
252
+ } catch {
253
+ continue;
254
+ }
34
255
  const lines = text.split(/\r?\n/);
35
256
  lines.forEach((line, index) => {
36
- for (const pattern of RISK_PATTERNS) {
37
- if (pattern.test(line)) {
38
- findings.push({
257
+ if (matchesAllowlist(file.rel, line, { allowlistPaths, allowlistPatterns })) return;
258
+ for (const matcher of RISK_PATTERNS) {
259
+ if (matcher.regex.test(line)) {
260
+ const classification = classifySecretReference({ file: file.rel, line, pattern: matcher.label, tracked: isTracked, rootContext });
261
+ record({
39
262
  file: file.rel,
40
263
  line: index + 1,
41
- pattern: pattern.source.replaceAll('\\', ''),
42
- match: pattern.source.replaceAll('\\', ''),
264
+ pattern: matcher.label,
265
+ match: matcher.label,
43
266
  preview: redact(line.trim()).slice(0, 160),
44
267
  excerpt: redact(line.trim()).slice(0, 160),
45
- severity: 'review'
268
+ ...classification
46
269
  });
47
270
  break;
48
271
  }
49
272
  }
50
273
  });
51
274
  }
52
- return findings;
275
+ return { findings, records, summary, summaryText: summaryText(summary) };
276
+ }
277
+
278
+ export async function scanRiskyReferences(root, options = {}) {
279
+ return (await scanRiskyReferencesDetailed(root, options)).findings;
53
280
  }
@@ -1,4 +1,3 @@
1
- import path from 'node:path';
2
1
  import { runRepo } from './commands/repo.js';
3
2
  import { runSecrets } from './commands/secrets.js';
4
3
  import { runQuality } from './commands/quality.js';
@@ -6,11 +5,11 @@ import { runSupabase } from './commands/supabase.js';
6
5
  import { runCloudflare } from './commands/cloudflare.js';
7
6
  import { runRoutes } from './commands/routes.js';
8
7
  import { runLocal } from './commands/local.js';
8
+ import { runGitHubCi } from './commands/github-ci.js';
9
9
  import { runEvidence } from './commands/evidence.js';
10
10
  import { createResult, finalizeStatus, worstStatus } from './lib/result.js';
11
11
  import { detectStack, hasSupabase, hasCloudflare } from './lib/detect.js';
12
- import { findDefaultRoutesConfig } from './lib/config.js';
13
- import { pathExists } from './lib/fs.js';
12
+ import { findDefaultRoutesConfig, loadOpstruthConfig } from './lib/config.js';
14
13
  import { resolveProjectBoundary } from './lib/boundary.js';
15
14
  import { selectProbes } from './lib/probes.js';
16
15
 
@@ -25,12 +24,42 @@ function nextStepFor(aggregate) {
25
24
  return 'Attach the evidence pack to the change or handoff.';
26
25
  }
27
26
 
27
+ function probeJson(probe) {
28
+ return {
29
+ id: probe.id,
30
+ name: probe.name,
31
+ area: probe.area,
32
+ stack: probe.stack,
33
+ mode: probe.mode,
34
+ safetyLevel: probe.safetyLevel,
35
+ defaultMode: probe.defaultMode,
36
+ mutability: probe.mutability,
37
+ inputsRequired: probe.inputsRequired,
38
+ evidenceCollected: probe.evidenceCollected,
39
+ evidenceExpectation: probe.evidenceExpectation,
40
+ proves: probe.proves,
41
+ doesNotProve: probe.doesNotProve,
42
+ proofLimitation: probe.proofLimitation,
43
+ skipReason: probe.skipReason,
44
+ nextSafeStep: probe.nextSafeStep,
45
+ supportedStacks: probe.supportedStacks,
46
+ notVerified: probe.notVerified
47
+ };
48
+ }
49
+
50
+ function hasConfigLocalInputs(loadedConfig) {
51
+ if (loadedConfig.warning) return true;
52
+ const configLocal = loadedConfig.config?.local || {};
53
+ return Array.isArray(configLocal.ports) && configLocal.ports.length > 0;
54
+ }
55
+
28
56
  export async function runOrchestrator(options = {}) {
29
57
  const startCwd = options.cwd || process.cwd();
30
58
  const boundary = await resolveProjectBoundary(startCwd);
31
59
  const cwd = boundary.root;
32
60
  const stack = await detectStack(cwd);
33
61
  const probeSelection = await selectProbes({ root: cwd, stack, boundary, options });
62
+ const loadedConfig = await loadOpstruthConfig(cwd);
34
63
  options = { ...options, cwd };
35
64
  const skip = new Set(options.skip || []);
36
65
  const childResults = [];
@@ -45,11 +74,14 @@ export async function runOrchestrator(options = {}) {
45
74
  else childResults.push(skippedResult('supabase', 'Supabase checks skipped because no supabase directory was detected.', 'Supabase database exposure was not checked'));
46
75
  if (await hasCloudflare(cwd)) await maybe('cloudflare', () => runCloudflare(options));
47
76
  else childResults.push(skippedResult('cloudflare', 'Cloudflare checks skipped because no Wrangler config was detected.', 'Cloudflare deployment configuration was not checked'));
77
+ const githubCiConfig = loadedConfig.config?.github?.ci || {};
78
+ if (options.githubCi || githubCiConfig.enabled === true) {
79
+ await maybe('github-ci', () => runGitHubCi({ ...options, workflow: options.workflow || githubCiConfig.workflow }));
80
+ }
48
81
  const routeConfig = await findDefaultRoutesConfig(cwd);
49
82
  if (options.baseUrl || options.routesFile || routeConfig) await maybe('routes', () => runRoutes(options));
50
83
  else childResults.push(skippedResult('routes', 'Route checks skipped because no base URL or routes config was provided.', 'Production/public route availability was not checked'));
51
- const hasLocalConfig = await pathExists(path.join(cwd, 'opstruth.local.json'));
52
- if (options.port?.length || options.healthProvided || options.process || options.service || hasLocalConfig) await maybe('local', () => runLocal(options));
84
+ if (options.port?.length || options.healthProvided || options.process || options.service || hasConfigLocalInputs(loadedConfig)) await maybe('local', () => runLocal(options));
53
85
  else childResults.push(skippedResult('local', 'Local runtime checks skipped because no port, health path, process, or service was provided.', 'Local runtime liveness was not checked'));
54
86
  const aggregate = createResult('opstruth', worstStatus(childResults.map((item) => item.status)), {
55
87
  summary: 'One-command read-only proof run completed.',
@@ -75,18 +107,8 @@ export async function runOrchestrator(options = {}) {
75
107
  stack,
76
108
  probes: {
77
109
  catalogueSize: probeSelection.catalogueSize,
78
- selected: probeSelection.selected.map((probe) => ({
79
- id: probe.id,
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 }))
110
+ selected: probeSelection.selected.map(probeJson),
111
+ skipped: probeSelection.skipped.map((probe) => ({ ...probeJson(probe), reason: probe.reason }))
90
112
  },
91
113
  childResults
92
114
  },