@veraxhq/verax 0.1.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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +237 -0
  3. package/bin/verax.js +452 -0
  4. package/package.json +57 -0
  5. package/src/verax/detect/comparison.js +69 -0
  6. package/src/verax/detect/confidence-engine.js +498 -0
  7. package/src/verax/detect/evidence-validator.js +33 -0
  8. package/src/verax/detect/expectation-model.js +204 -0
  9. package/src/verax/detect/findings-writer.js +31 -0
  10. package/src/verax/detect/index.js +397 -0
  11. package/src/verax/detect/skip-classifier.js +202 -0
  12. package/src/verax/flow/flow-engine.js +265 -0
  13. package/src/verax/flow/flow-spec.js +145 -0
  14. package/src/verax/flow/redaction.js +74 -0
  15. package/src/verax/index.js +97 -0
  16. package/src/verax/learn/action-contract-extractor.js +281 -0
  17. package/src/verax/learn/ast-contract-extractor.js +255 -0
  18. package/src/verax/learn/index.js +18 -0
  19. package/src/verax/learn/manifest-writer.js +97 -0
  20. package/src/verax/learn/project-detector.js +87 -0
  21. package/src/verax/learn/react-router-extractor.js +73 -0
  22. package/src/verax/learn/route-extractor.js +122 -0
  23. package/src/verax/learn/route-validator.js +215 -0
  24. package/src/verax/learn/source-instrumenter.js +214 -0
  25. package/src/verax/learn/static-extractor.js +222 -0
  26. package/src/verax/learn/truth-assessor.js +96 -0
  27. package/src/verax/learn/ts-contract-resolver.js +395 -0
  28. package/src/verax/observe/browser.js +22 -0
  29. package/src/verax/observe/console-sensor.js +166 -0
  30. package/src/verax/observe/dom-signature.js +23 -0
  31. package/src/verax/observe/domain-boundary.js +38 -0
  32. package/src/verax/observe/evidence-capture.js +5 -0
  33. package/src/verax/observe/human-driver.js +376 -0
  34. package/src/verax/observe/index.js +67 -0
  35. package/src/verax/observe/interaction-discovery.js +269 -0
  36. package/src/verax/observe/interaction-runner.js +410 -0
  37. package/src/verax/observe/network-sensor.js +173 -0
  38. package/src/verax/observe/selector-generator.js +74 -0
  39. package/src/verax/observe/settle.js +155 -0
  40. package/src/verax/observe/state-ui-sensor.js +200 -0
  41. package/src/verax/observe/traces-writer.js +82 -0
  42. package/src/verax/observe/ui-signal-sensor.js +197 -0
  43. package/src/verax/resolve-workspace-root.js +173 -0
  44. package/src/verax/scan-summary-writer.js +41 -0
  45. package/src/verax/shared/artifact-manager.js +139 -0
  46. package/src/verax/shared/caching.js +104 -0
  47. package/src/verax/shared/expectation-proof.js +4 -0
  48. package/src/verax/shared/redaction.js +227 -0
  49. package/src/verax/shared/retry-policy.js +89 -0
  50. package/src/verax/shared/timing-metrics.js +44 -0
@@ -0,0 +1,97 @@
1
+ import { learn } from './learn/index.js';
2
+ import { observe } from './observe/index.js';
3
+ import { detect } from './detect/index.js';
4
+ import { writeScanSummary } from './scan-summary-writer.js';
5
+ import { validateRoutes } from './learn/route-validator.js';
6
+ import { initArtifactPaths, generateRunId } from './shared/artifact-manager.js';
7
+
8
+ export async function scan(projectDir, url, manifestPath = null, runId = null) {
9
+ // Generate runId for this scan if not provided
10
+ const scanRunId = runId || generateRunId();
11
+ const artifactPaths = initArtifactPaths(projectDir, scanRunId);
12
+ // If manifestPath is provided, read it first before learn() overwrites it
13
+ let loadedManifest = null;
14
+ if (manifestPath) {
15
+ const { readFileSync } = await import('fs');
16
+ try {
17
+ const manifestContent = JSON.parse(readFileSync(manifestPath, 'utf-8'));
18
+ loadedManifest = manifestContent;
19
+ } catch (e) {
20
+ // Fall through to learn if we can't read the manifest
21
+ }
22
+ }
23
+
24
+ const learnedManifest = await learn(projectDir);
25
+
26
+ // Merge: prefer loaded manifest for routes, but use learned for project type and truth
27
+ let manifest;
28
+ if (loadedManifest) {
29
+ manifest = {
30
+ ...learnedManifest,
31
+ projectType: loadedManifest.projectType || learnedManifest.projectType,
32
+ publicRoutes: loadedManifest.publicRoutes || learnedManifest.publicRoutes,
33
+ routes: loadedManifest.routes || learnedManifest.routes,
34
+ internalRoutes: loadedManifest.internalRoutes || learnedManifest.internalRoutes,
35
+ manifestPath: manifestPath
36
+ };
37
+ } else {
38
+ manifest = learnedManifest;
39
+ }
40
+
41
+ if (!url) {
42
+ return { manifest, observation: null, findings: null, scanSummary: null, validation: null };
43
+ }
44
+
45
+ // manifestForValidation uses the provided/merged manifest (which may have been modified)
46
+ const manifestForValidation = {
47
+ projectType: manifest.projectType,
48
+ publicRoutes: manifest.publicRoutes,
49
+ routes: manifest.routes,
50
+ manifestPath: manifest.manifestPath
51
+ };
52
+
53
+ const validation = await validateRoutes(manifestForValidation, url);
54
+
55
+ if (validation.warnings && validation.warnings.length > 0) {
56
+ if (!manifest.learnTruth.warnings) {
57
+ manifest.learnTruth.warnings = [];
58
+ }
59
+ manifest.learnTruth.warnings.push(...validation.warnings);
60
+ }
61
+
62
+ const usedManifestPath = manifestPath || manifest.manifestPath;
63
+ const observation = await observe(url, usedManifestPath, artifactPaths);
64
+
65
+ // For detect, we need tracesPath - use observation.tracesPath if available, otherwise use artifact paths
66
+ let tracesPathForDetect = observation.tracesPath;
67
+ if (artifactPaths && !tracesPathForDetect) {
68
+ tracesPathForDetect = artifactPaths.traces;
69
+ }
70
+
71
+ const findings = await detect(usedManifestPath, tracesPathForDetect, validation, artifactPaths);
72
+
73
+ const learnTruthWithValidation = {
74
+ ...manifest.learnTruth,
75
+ validation: validation
76
+ };
77
+
78
+ const scanSummary = writeScanSummary(
79
+ projectDir,
80
+ url,
81
+ manifest.projectType,
82
+ learnTruthWithValidation,
83
+ observation.observeTruth,
84
+ findings.detectTruth,
85
+ manifest.manifestPath,
86
+ observation.tracesPath,
87
+ findings.findingsPath,
88
+ artifactPaths
89
+ );
90
+
91
+ return { manifest, observation, findings, scanSummary, validation };
92
+ }
93
+
94
+ export { learn } from './learn/index.js';
95
+ export { observe } from './observe/index.js';
96
+ export { detect } from './detect/index.js';
97
+
@@ -0,0 +1,281 @@
1
+ /**
2
+ * Wave 5 — Action Contract Extractor
3
+ *
4
+ * Extracts PROVEN network action contracts from JSX/React source files.
5
+ * Uses AST analysis to find onClick/onSubmit handlers that call fetch/axios
6
+ * with static URL literals.
7
+ *
8
+ * NO HEURISTICS. Only static, deterministic analysis.
9
+ */
10
+
11
+ import { parse } from '@babel/parser';
12
+ import traverse from '@babel/traverse';
13
+ import { readFileSync } from 'fs';
14
+ import { resolve, relative, sep } from 'path';
15
+
16
+ /**
17
+ * Extract action contracts from a source file.
18
+ *
19
+ * @param {string} filePath - Absolute path to source file
20
+ * @param {string} workspaceRoot - Workspace root for relative paths
21
+ * @returns {Array<Object>} - Array of contract objects
22
+ */
23
+ export function extractActionContracts(filePath, workspaceRoot) {
24
+ const contracts = [];
25
+
26
+ try {
27
+ const code = readFileSync(filePath, 'utf-8');
28
+ const ast = parse(code, {
29
+ sourceType: 'module',
30
+ plugins: ['jsx', 'typescript'],
31
+ });
32
+
33
+ // Track function declarations and arrow function assignments
34
+ const functionBodies = new Map(); // name -> AST node
35
+
36
+ traverse.default(ast, {
37
+ // Track function declarations
38
+ FunctionDeclaration(path) {
39
+ if (path.node.id && path.node.id.name) {
40
+ functionBodies.set(path.node.id.name, path.node.body);
41
+ }
42
+ },
43
+
44
+ // Track arrow function variable assignments
45
+ VariableDeclarator(path) {
46
+ if (
47
+ path.node.id.type === 'Identifier' &&
48
+ path.node.init &&
49
+ path.node.init.type === 'ArrowFunctionExpression'
50
+ ) {
51
+ functionBodies.set(path.node.id.name, path.node.init.body);
52
+ }
53
+ },
54
+
55
+ // Find JSX elements with onClick or onSubmit
56
+ JSXAttribute(path) {
57
+ const attrName = path.node.name.name;
58
+ if (attrName !== 'onClick' && attrName !== 'onSubmit') {
59
+ return;
60
+ }
61
+
62
+ const value = path.node.value;
63
+ if (!value) return;
64
+
65
+ // Only analyze inline arrow functions for now
66
+ // Case 1: Inline arrow function: onClick={() => fetch(...)}
67
+ if (
68
+ value.type === 'JSXExpressionContainer' &&
69
+ value.expression.type === 'ArrowFunctionExpression'
70
+ ) {
71
+ const handlerBody = value.expression.body;
72
+ const networkCalls = findNetworkCallsInNode(handlerBody);
73
+
74
+ for (const call of networkCalls) {
75
+ const loc = path.node.loc;
76
+ const sourceRef = formatSourceRef(filePath, workspaceRoot, loc);
77
+
78
+ contracts.push({
79
+ kind: 'NETWORK_ACTION',
80
+ method: call.method,
81
+ urlPath: call.url,
82
+ source: sourceRef,
83
+ elementType: path.parent.name.name, // button, form, etc.
84
+ });
85
+ }
86
+ }
87
+ // Case 2: Function reference - analyze the function declaration
88
+ else if (
89
+ value.type === 'JSXExpressionContainer' &&
90
+ value.expression.type === 'Identifier'
91
+ ) {
92
+ const refName = value.expression.name;
93
+ const handlerBody = functionBodies.get(refName);
94
+
95
+ if (handlerBody) {
96
+ const networkCalls = findNetworkCallsInNode(handlerBody);
97
+
98
+ for (const call of networkCalls) {
99
+ const loc = path.node.loc;
100
+ const sourceRef = formatSourceRef(filePath, workspaceRoot, loc);
101
+
102
+ contracts.push({
103
+ kind: 'NETWORK_ACTION',
104
+ method: call.method,
105
+ urlPath: call.url,
106
+ source: sourceRef,
107
+ elementType: path.parent.name.name,
108
+ });
109
+ }
110
+ }
111
+ }
112
+ },
113
+ });
114
+ } catch (err) {
115
+ // Parse errors are not fatal; just return empty contracts
116
+ console.warn(`Failed to parse ${filePath}: ${err.message}`);
117
+ }
118
+
119
+ return contracts;
120
+ }
121
+
122
+ /**
123
+ * Find network calls (fetch, axios) in an AST node by recursively scanning.
124
+ * Only returns calls with static URL literals.
125
+ *
126
+ * @param {Object} node - AST node to scan
127
+ * @returns {Array<Object>} - Array of {method, url}
128
+ */
129
+ function findNetworkCallsInNode(node) {
130
+ const calls = [];
131
+
132
+ // Recursive function to scan all nodes
133
+ function scan(n) {
134
+ if (!n || typeof n !== 'object') return;
135
+
136
+ // Check if this is a CallExpression
137
+ if (n.type === 'CallExpression') {
138
+ const callee = n.callee;
139
+
140
+ // Case 1: fetch(url, options)
141
+ if (callee.type === 'Identifier' && callee.name === 'fetch') {
142
+ const urlArg = n.arguments[0];
143
+ const optionsArg = n.arguments[1];
144
+
145
+ // Only accept static string literals
146
+ if (urlArg && urlArg.type === 'StringLiteral') {
147
+ let method = 'GET'; // default
148
+
149
+ // Try to extract method from options
150
+ if (optionsArg && optionsArg.type === 'ObjectExpression') {
151
+ const methodProp = optionsArg.properties.find(
152
+ (p) => p.key && p.key.name === 'method'
153
+ );
154
+ if (methodProp && methodProp.value.type === 'StringLiteral') {
155
+ method = methodProp.value.value.toUpperCase();
156
+ }
157
+ }
158
+
159
+ calls.push({ method, url: urlArg.value });
160
+ }
161
+ }
162
+
163
+ // Case 2: axios.get(url), axios.post(url, data), etc.
164
+ if (
165
+ callee.type === 'MemberExpression' &&
166
+ callee.object.type === 'Identifier' &&
167
+ callee.object.name === 'axios' &&
168
+ callee.property.type === 'Identifier'
169
+ ) {
170
+ const methodName = callee.property.name.toUpperCase();
171
+ const urlArg = n.arguments[0];
172
+
173
+ if (urlArg && urlArg.type === 'StringLiteral') {
174
+ calls.push({ method: methodName, url: urlArg.value });
175
+ }
176
+ }
177
+
178
+ // Case 3: XMLHttpRequest (optional, basic support)
179
+ if (
180
+ callee.type === 'MemberExpression' &&
181
+ callee.property.type === 'Identifier' &&
182
+ callee.property.name === 'open'
183
+ ) {
184
+ // xhr.open(method, url)
185
+ const methodArg = n.arguments[0];
186
+ const urlArg = n.arguments[1];
187
+
188
+ if (
189
+ methodArg && methodArg.type === 'StringLiteral' &&
190
+ urlArg && urlArg.type === 'StringLiteral'
191
+ ) {
192
+ calls.push({
193
+ method: methodArg.value.toUpperCase(),
194
+ url: urlArg.value,
195
+ });
196
+ }
197
+ }
198
+ }
199
+
200
+ // Recursively scan all properties
201
+ for (const key in n) {
202
+ if (n.hasOwnProperty(key)) {
203
+ const value = n[key];
204
+ if (Array.isArray(value)) {
205
+ value.forEach(item => scan(item));
206
+ } else if (value && typeof value === 'object') {
207
+ scan(value);
208
+ }
209
+ }
210
+ }
211
+ }
212
+
213
+ scan(node);
214
+ return calls;
215
+ }
216
+
217
+ /**
218
+ * Format source reference as "file:line:col"
219
+ * Normalizes Windows paths to use forward slashes.
220
+ *
221
+ * @param {string} filePath - Absolute file path
222
+ * @param {string} workspaceRoot - Workspace root
223
+ * @param {Object} loc - Location object from AST
224
+ * @returns {string} - Formatted source reference
225
+ */
226
+ function formatSourceRef(filePath, workspaceRoot, loc) {
227
+ let relPath = relative(workspaceRoot, filePath);
228
+
229
+ // Normalize to forward slashes
230
+ relPath = relPath.split(sep).join('/');
231
+
232
+ const line = loc.start.line;
233
+ const col = loc.start.column;
234
+
235
+ return `${relPath}:${line}:${col}`;
236
+ }
237
+
238
+ /**
239
+ * Scan a directory tree for source files and extract all contracts.
240
+ *
241
+ * @param {string} rootPath - Root directory to scan
242
+ * @param {string} workspaceRoot - Workspace root
243
+ * @returns {Array<Object>} - All contracts found
244
+ */
245
+ export async function scanForContracts(rootPath, workspaceRoot) {
246
+ const contracts = [];
247
+
248
+ // For now, just scan known patterns
249
+ // In real implementation, would recurse through directories
250
+ const { readdirSync, statSync } = await import('fs');
251
+ const { join } = await import('path');
252
+
253
+ function walk(dir) {
254
+ try {
255
+ const entries = readdirSync(dir);
256
+
257
+ for (const entry of entries) {
258
+ const fullPath = join(dir, entry);
259
+ const stat = statSync(fullPath);
260
+
261
+ if (stat.isDirectory()) {
262
+ // Skip node_modules, .git, etc.
263
+ if (!entry.startsWith('.') && entry !== 'node_modules') {
264
+ walk(fullPath);
265
+ }
266
+ } else if (stat.isFile()) {
267
+ // Only process .js, .jsx, .ts, .tsx files
268
+ if (/\.(jsx?|tsx?)$/.test(entry)) {
269
+ const fileContracts = extractActionContracts(fullPath, workspaceRoot);
270
+ contracts.push(...fileContracts);
271
+ }
272
+ }
273
+ }
274
+ } catch (err) {
275
+ // Ignore errors (permission issues, etc.)
276
+ }
277
+ }
278
+
279
+ walk(rootPath);
280
+ return contracts;
281
+ }
@@ -0,0 +1,255 @@
1
+ import { parse } from '@babel/parser';
2
+ import traverse from '@babel/traverse';
3
+ import { readFileSync } from 'fs';
4
+ import { glob } from 'glob';
5
+ import { resolve } from 'path';
6
+ import { ExpectationProof } from '../shared/expectation-proof.js';
7
+
8
+ const MAX_FILES_TO_SCAN = 200;
9
+
10
+ /**
11
+ * Extracts static string value from JSX attribute or expression.
12
+ * Returns null if value is dynamic (variable, template with interpolation, etc.)
13
+ *
14
+ * Wave 1 - CODE TRUTH ENGINE: Only static literals are PROVEN.
15
+ */
16
+ function extractStaticStringValue(node) {
17
+ if (!node) return null;
18
+
19
+ // Direct string literal: href="/about"
20
+ if (node.type === 'StringLiteral') {
21
+ return node.value;
22
+ }
23
+
24
+ // JSX expression: href={'/about'} or href={`/about`}
25
+ if (node.type === 'JSXExpressionContainer') {
26
+ const expr = node.expression;
27
+
28
+ // String literal in expression: {'/about'}
29
+ if (expr.type === 'StringLiteral') {
30
+ return expr.value;
31
+ }
32
+
33
+ // Template literal WITHOUT interpolation: {`/about`}
34
+ if (expr.type === 'TemplateLiteral' && expr.expressions.length === 0) {
35
+ // Only if there are no ${...} expressions
36
+ if (expr.quasis.length === 1) {
37
+ return expr.quasis[0].value.cooked;
38
+ }
39
+ }
40
+
41
+ // Anything else (variables, interpolated templates, etc.) is UNKNOWN
42
+ return null;
43
+ }
44
+
45
+ return null;
46
+ }
47
+
48
+ /**
49
+ * Extracts PROVEN navigation contracts from JSX elements and imperative calls.
50
+ *
51
+ * Supported patterns (all require static string literals):
52
+ * - Next.js: <Link href="/about">
53
+ * - React Router: <Link to="/about"> or <NavLink to="/about">
54
+ * - Plain JSX: <a href="/about">
55
+ * - Imperative: navigate("/about"), router.push("/about")
56
+ *
57
+ * Returns array of contracts with:
58
+ * - kind: 'NAVIGATION'
59
+ * - targetPath: string
60
+ * - sourceFile: string
61
+ * - element: 'Link' | 'NavLink' | 'a' | 'navigate' | 'router'
62
+ * - attribute: 'href' | 'to' (for runtime matching)
63
+ * - proof: PROVEN_EXPECTATION
64
+ * - line: number (optional)
65
+ */
66
+ function extractContractsFromFile(filePath, fileContent) {
67
+ const contracts = [];
68
+
69
+ try {
70
+ // Parse with Babel - support JSX and TypeScript
71
+ const ast = parse(fileContent, {
72
+ sourceType: 'module',
73
+ plugins: ['jsx', 'typescript']
74
+ });
75
+
76
+ traverse.default(ast, {
77
+ // JSX elements: <Link href>, <a href>, <NavLink to>
78
+ JSXElement(path) {
79
+ const openingElement = path.node.openingElement;
80
+ const elementName = openingElement.name.name;
81
+
82
+ // Check for Link, NavLink, or a elements
83
+ if (elementName === 'Link' || elementName === 'NavLink' || elementName === 'a') {
84
+ let targetPath = null;
85
+ let attributeName = null;
86
+
87
+ // Find href or to attribute
88
+ for (const attr of openingElement.attributes) {
89
+ if (attr.type === 'JSXAttribute') {
90
+ const attrName = attr.name.name;
91
+
92
+ if (attrName === 'href' || attrName === 'to') {
93
+ targetPath = extractStaticStringValue(attr.value);
94
+ attributeName = attrName;
95
+ break;
96
+ }
97
+ }
98
+ }
99
+
100
+ // Only create contract if we have a static target path
101
+ if (targetPath && !targetPath.startsWith('http://') && !targetPath.startsWith('https://') && !targetPath.startsWith('mailto:') && !targetPath.startsWith('tel:')) {
102
+ // Normalize path
103
+ const normalized = targetPath.startsWith('/') ? targetPath : '/' + targetPath;
104
+
105
+ contracts.push({
106
+ kind: 'NAVIGATION',
107
+ targetPath: normalized,
108
+ sourceFile: filePath,
109
+ element: elementName,
110
+ attribute: attributeName,
111
+ proof: ExpectationProof.PROVEN_EXPECTATION,
112
+ line: openingElement.loc?.start.line || null
113
+ });
114
+ }
115
+ }
116
+ },
117
+
118
+ // Imperative navigation calls: navigate("/about"), router.push("/about")
119
+ // Note: These are recorded but not used for element-based expectations
120
+ // since we cannot reliably tie them to clicked elements
121
+ CallExpression(path) {
122
+ const callee = path.node.callee;
123
+
124
+ // navigate("/about") - useNavigate hook
125
+ if (callee.type === 'Identifier' && callee.name === 'navigate') {
126
+ const firstArg = path.node.arguments[0];
127
+ if (firstArg && firstArg.type === 'StringLiteral') {
128
+ const targetPath = firstArg.value;
129
+ if (targetPath.startsWith('/')) {
130
+ contracts.push({
131
+ kind: 'NAVIGATION',
132
+ targetPath: targetPath,
133
+ sourceFile: filePath,
134
+ element: 'navigate',
135
+ attribute: null,
136
+ proof: ExpectationProof.PROVEN_EXPECTATION,
137
+ line: path.node.loc?.start.line || null,
138
+ imperativeOnly: true // Cannot match to DOM element
139
+ });
140
+ }
141
+ }
142
+ }
143
+
144
+ // router.push("/about") or router.replace("/about")
145
+ if (callee.type === 'MemberExpression' &&
146
+ callee.object.type === 'Identifier' &&
147
+ callee.object.name === 'router' &&
148
+ callee.property.type === 'Identifier' &&
149
+ (callee.property.name === 'push' || callee.property.name === 'replace')) {
150
+ const firstArg = path.node.arguments[0];
151
+ if (firstArg && firstArg.type === 'StringLiteral') {
152
+ const targetPath = firstArg.value;
153
+ if (targetPath.startsWith('/')) {
154
+ contracts.push({
155
+ kind: 'NAVIGATION',
156
+ targetPath: targetPath,
157
+ sourceFile: filePath,
158
+ element: 'router',
159
+ attribute: null,
160
+ proof: ExpectationProof.PROVEN_EXPECTATION,
161
+ line: path.node.loc?.start.line || null,
162
+ imperativeOnly: true // Cannot match to DOM element
163
+ });
164
+ }
165
+ }
166
+ }
167
+ }
168
+ });
169
+ } catch (error) {
170
+ // Parse error - skip this file
171
+ // This is expected for files with syntax errors or unsupported syntax
172
+ return [];
173
+ }
174
+
175
+ return contracts;
176
+ }
177
+
178
+ /**
179
+ * Scans a project directory for navigation contracts using AST analysis.
180
+ * Returns array of PROVEN navigation contracts.
181
+ *
182
+ * Wave 1 - CODE TRUTH ENGINE: AST-derived PROVEN expectations only.
183
+ */
184
+ export async function extractASTContracts(projectDir) {
185
+ const contracts = [];
186
+
187
+ try {
188
+ // Find all JS/JSX/TS/TSX files (excluding node_modules, dist, build)
189
+ const files = await glob('**/*.{js,jsx,ts,tsx}', {
190
+ cwd: projectDir,
191
+ absolute: false,
192
+ ignore: ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'out/**']
193
+ });
194
+
195
+ // Limit files to scan
196
+ const filesToScan = files.slice(0, MAX_FILES_TO_SCAN);
197
+
198
+ for (const file of filesToScan) {
199
+ try {
200
+ const filePath = resolve(projectDir, file);
201
+ const content = readFileSync(filePath, 'utf-8');
202
+ const fileContracts = extractContractsFromFile(file, content);
203
+ contracts.push(...fileContracts);
204
+ } catch (error) {
205
+ // Skip files that can't be read
206
+ continue;
207
+ }
208
+ }
209
+ } catch (error) {
210
+ // If glob fails, return empty
211
+ return [];
212
+ }
213
+
214
+ return contracts;
215
+ }
216
+
217
+ /**
218
+ * Converts AST contracts to manifest expectations format.
219
+ * Only includes contracts that can be matched at runtime (excludes imperativeOnly).
220
+ */
221
+ export function contractsToExpectations(contracts, projectType) {
222
+ const expectations = [];
223
+ const seenPaths = new Set();
224
+
225
+ for (const contract of contracts) {
226
+ // Skip imperative-only contracts (navigate, router.push)
227
+ // These cannot be reliably matched to DOM elements
228
+ if (contract.imperativeOnly) {
229
+ continue;
230
+ }
231
+
232
+ // Skip duplicates
233
+ const key = `${contract.targetPath}:${contract.attribute}`;
234
+ if (seenPaths.has(key)) {
235
+ continue;
236
+ }
237
+ seenPaths.add(key);
238
+
239
+ // Create expectation for runtime matching
240
+ // We don't specify fromPath since we'll match by href/to attribute value
241
+ expectations.push({
242
+ type: 'spa_navigation',
243
+ targetPath: contract.targetPath,
244
+ proof: ExpectationProof.PROVEN_EXPECTATION,
245
+ matchAttribute: contract.attribute, // 'href' or 'to'
246
+ evidence: {
247
+ source: contract.sourceFile,
248
+ element: contract.element,
249
+ line: contract.line
250
+ }
251
+ });
252
+ }
253
+
254
+ return expectations;
255
+ }
@@ -0,0 +1,18 @@
1
+ import { resolve } from 'path';
2
+ import { existsSync } from 'fs';
3
+ import { detectProjectType } from './project-detector.js';
4
+ import { extractRoutes } from './route-extractor.js';
5
+ import { writeManifest } from './manifest-writer.js';
6
+
7
+ export async function learn(projectDir) {
8
+ const absoluteProjectDir = resolve(projectDir);
9
+
10
+ if (!existsSync(absoluteProjectDir)) {
11
+ throw new Error(`Project directory does not exist: ${absoluteProjectDir}`);
12
+ }
13
+
14
+ const projectType = await detectProjectType(absoluteProjectDir);
15
+ const routes = await extractRoutes(absoluteProjectDir, projectType);
16
+
17
+ return await writeManifest(absoluteProjectDir, projectType, routes);
18
+ }