mustflow 2.74.7 → 2.75.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,705 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, lstatSync, readdirSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { ensureInside, ensureInsideWithoutSymlinks, readFileInsideWithoutSymlinks } from './safe-filesystem.js';
5
+ export const CODE_PACK_ID = 'code';
6
+ export const CODE_OUTLINE_SCRIPT_ID = 'outline';
7
+ export const CODE_OUTLINE_SCRIPT_REF = `${CODE_PACK_ID}/${CODE_OUTLINE_SCRIPT_ID}`;
8
+ export const CODE_SYMBOL_READ_SCRIPT_ID = 'symbol-read';
9
+ export const CODE_SYMBOL_READ_SCRIPT_REF = `${CODE_PACK_ID}/${CODE_SYMBOL_READ_SCRIPT_ID}`;
10
+ const DEFAULT_MAX_FILE_BYTES = 1024 * 1024;
11
+ const DEFAULT_MAX_FILES = 200;
12
+ const DEFAULT_CONTEXT_LINES = 0;
13
+ const DEFAULT_MAX_SNIPPET_LINES = 250;
14
+ const RETURN_PREVIEW_MAX_CHARS = 120;
15
+ const CODE_FILE_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'];
16
+ const IGNORED_DIRECTORIES = [
17
+ '.git',
18
+ '.mustflow/cache',
19
+ '.mustflow/state',
20
+ 'node_modules',
21
+ 'dist',
22
+ 'build',
23
+ 'coverage',
24
+ '.next',
25
+ '.turbo',
26
+ ];
27
+ const ERROR_OUTLINE_CODES = new Set([
28
+ 'code_outline_path_outside_root',
29
+ 'code_outline_unreadable_path',
30
+ 'code_outline_file_too_large',
31
+ 'code_outline_max_files_exceeded',
32
+ ]);
33
+ const ERROR_SYMBOL_READ_CODES = new Set([
34
+ 'code_symbol_read_path_outside_root',
35
+ 'code_symbol_read_unreadable_path',
36
+ 'code_symbol_read_invalid_range',
37
+ 'code_symbol_read_no_symbol_at_line',
38
+ 'code_symbol_read_snippet_too_large',
39
+ ]);
40
+ function toPosixPath(value) {
41
+ return value.replace(/\\/gu, '/');
42
+ }
43
+ function normalizeRelativePath(value) {
44
+ return toPosixPath(value).replace(/^\.\/+/u, '') || '.';
45
+ }
46
+ function sha256Tagged(buffer) {
47
+ return `sha256:${createHash('sha256').update(buffer).digest('hex')}`;
48
+ }
49
+ function languageForPath(filePath) {
50
+ switch (path.extname(filePath).toLowerCase()) {
51
+ case '.ts':
52
+ case '.mts':
53
+ case '.cts':
54
+ return 'typescript';
55
+ case '.tsx':
56
+ return 'tsx';
57
+ case '.js':
58
+ return 'javascript';
59
+ case '.jsx':
60
+ return 'jsx';
61
+ case '.mjs':
62
+ return 'javascript-module';
63
+ case '.cjs':
64
+ return 'javascript-commonjs';
65
+ default:
66
+ return null;
67
+ }
68
+ }
69
+ function isIgnoredDirectory(relativePath) {
70
+ const normalized = normalizeRelativePath(relativePath);
71
+ return IGNORED_DIRECTORIES.some((directory) => normalized === directory || normalized.startsWith(`${directory}/`));
72
+ }
73
+ function makeOutlineFinding(code, severity, pathValue, message) {
74
+ return { code, severity, path: pathValue, message };
75
+ }
76
+ function makeSymbolReadFinding(code, severity, pathValue, startLine, endLine, message) {
77
+ return { code, severity, path: pathValue, start_line: startLine, end_line: endLine, message };
78
+ }
79
+ function addMaxFilesFinding(findings, issues, policy, pathValue) {
80
+ if (findings.some((finding) => finding.code === 'code_outline_max_files_exceeded')) {
81
+ return;
82
+ }
83
+ const message = `Code outline matched more than ${policy.max_files} files; remaining files were skipped.`;
84
+ issues.push(`${pathValue}: ${message}`);
85
+ findings.push(makeOutlineFinding('code_outline_max_files_exceeded', 'high', pathValue, message));
86
+ }
87
+ function normalizeTargetPath(projectRoot, targetPath) {
88
+ const absolutePath = path.resolve(process.cwd(), targetPath);
89
+ ensureInside(projectRoot, absolutePath);
90
+ return {
91
+ absolutePath,
92
+ relativePath: normalizeRelativePath(path.relative(projectRoot, absolutePath)),
93
+ };
94
+ }
95
+ function collectCandidatesFromDirectory(projectRoot, absoluteDirectory, candidates, findings, issues, policy) {
96
+ const relativeDirectory = normalizeRelativePath(path.relative(projectRoot, absoluteDirectory));
97
+ if (isIgnoredDirectory(relativeDirectory)) {
98
+ return;
99
+ }
100
+ let entries;
101
+ try {
102
+ ensureInsideWithoutSymlinks(projectRoot, absoluteDirectory);
103
+ entries = readdirSync(absoluteDirectory, { withFileTypes: true });
104
+ }
105
+ catch (error) {
106
+ const message = error instanceof Error ? error.message : String(error);
107
+ issues.push(`${relativeDirectory}: ${message}`);
108
+ findings.push(makeOutlineFinding('code_outline_unreadable_path', 'high', relativeDirectory, message));
109
+ return;
110
+ }
111
+ for (const entry of entries) {
112
+ if (candidates.length >= policy.max_files) {
113
+ addMaxFilesFinding(findings, issues, policy, relativeDirectory);
114
+ return;
115
+ }
116
+ const absoluteEntry = path.join(absoluteDirectory, entry.name);
117
+ const relativeEntry = normalizeRelativePath(path.relative(projectRoot, absoluteEntry));
118
+ if (entry.isDirectory()) {
119
+ collectCandidatesFromDirectory(projectRoot, absoluteEntry, candidates, findings, issues, policy);
120
+ continue;
121
+ }
122
+ if (!entry.isFile()) {
123
+ continue;
124
+ }
125
+ const language = languageForPath(absoluteEntry);
126
+ if (language) {
127
+ candidates.push({ absolutePath: absoluteEntry, relativePath: relativeEntry, language });
128
+ }
129
+ }
130
+ }
131
+ function collectSourceCandidates(projectRoot, options, policy, findings, issues) {
132
+ const candidates = [];
133
+ for (const targetPath of options.paths) {
134
+ let absolutePath;
135
+ let relativePath;
136
+ try {
137
+ const normalized = normalizeTargetPath(projectRoot, targetPath);
138
+ absolutePath = normalized.absolutePath;
139
+ relativePath = normalized.relativePath;
140
+ ensureInsideWithoutSymlinks(projectRoot, absolutePath);
141
+ }
142
+ catch (error) {
143
+ const message = error instanceof Error ? error.message : String(error);
144
+ issues.push(message);
145
+ findings.push(makeOutlineFinding('code_outline_path_outside_root', 'high', targetPath, message));
146
+ continue;
147
+ }
148
+ let stats;
149
+ try {
150
+ stats = lstatSync(absolutePath);
151
+ }
152
+ catch (error) {
153
+ const message = error instanceof Error ? error.message : String(error);
154
+ issues.push(`${relativePath}: ${message}`);
155
+ findings.push(makeOutlineFinding('code_outline_unreadable_path', 'high', relativePath, message));
156
+ continue;
157
+ }
158
+ if (stats.isDirectory()) {
159
+ collectCandidatesFromDirectory(projectRoot, absolutePath, candidates, findings, issues, policy);
160
+ continue;
161
+ }
162
+ if (!stats.isFile()) {
163
+ findings.push(makeOutlineFinding('code_outline_unsupported_file', 'low', relativePath, `${relativePath} is not a regular source file.`));
164
+ continue;
165
+ }
166
+ const language = languageForPath(absolutePath);
167
+ if (!language) {
168
+ findings.push(makeOutlineFinding('code_outline_unsupported_file', 'low', relativePath, `${relativePath} is not a supported code file.`));
169
+ continue;
170
+ }
171
+ candidates.push({ absolutePath, relativePath, language });
172
+ }
173
+ if (candidates.length > policy.max_files) {
174
+ const overflow = candidates.length - policy.max_files;
175
+ findings.push(makeOutlineFinding('code_outline_max_files_exceeded', 'high', '.', `Code outline matched ${candidates.length} files; max_files is ${policy.max_files}. ${overflow} files were skipped.`));
176
+ }
177
+ return candidates.slice(0, policy.max_files);
178
+ }
179
+ function stripLineForBraceScan(line) {
180
+ return line
181
+ .replace(/\/\/.*$/u, '')
182
+ .replace(/"(?:\\.|[^"\\])*"/gu, '""')
183
+ .replace(/'(?:\\.|[^'\\])*'/gu, "''")
184
+ .replace(/`(?:\\.|[^`\\])*`/gu, '``');
185
+ }
186
+ function braceDelta(line) {
187
+ const stripped = stripLineForBraceScan(line);
188
+ let delta = 0;
189
+ for (const character of stripped) {
190
+ if (character === '{') {
191
+ delta += 1;
192
+ }
193
+ else if (character === '}') {
194
+ delta -= 1;
195
+ }
196
+ }
197
+ return delta;
198
+ }
199
+ function declarationFromLine(line) {
200
+ const trimmed = line.trim();
201
+ const functionMatch = /^(?<exported>export\s+(?:default\s+)?)?(?<async>async\s+)?function\s+(?<name>[$A-Z_a-z][$\w]*)\s*\(/u.exec(trimmed);
202
+ if (functionMatch?.groups) {
203
+ return {
204
+ kind: 'function',
205
+ name: functionMatch.groups.name ?? '<anonymous>',
206
+ exported: Boolean(functionMatch.groups.exported),
207
+ async: Boolean(functionMatch.groups.async),
208
+ };
209
+ }
210
+ const shapeMatch = /^(?<exported>export\s+)?(?<kind>class|interface|type|enum)\s+(?<name>[$A-Z_a-z][$\w]*)/u.exec(trimmed);
211
+ if (shapeMatch?.groups) {
212
+ return {
213
+ kind: shapeMatch.groups.kind,
214
+ name: shapeMatch.groups.name ?? '<anonymous>',
215
+ exported: Boolean(shapeMatch.groups.exported),
216
+ async: false,
217
+ };
218
+ }
219
+ const variableMatch = /^(?<exported>export\s+)?(?:const|let|var)\s+(?<name>[$A-Z_a-z][$\w]*)\s*[:=]/u.exec(trimmed);
220
+ if (variableMatch?.groups && (Boolean(variableMatch.groups.exported) || /=>|function\s*\(/u.test(trimmed))) {
221
+ return {
222
+ kind: /=>|function\s*\(/u.test(trimmed) ? 'function' : 'variable',
223
+ name: variableMatch.groups.name ?? '<anonymous>',
224
+ exported: Boolean(variableMatch.groups.exported),
225
+ async: /async\s*(?:\(|[A-Z_a-z_$])/u.test(trimmed),
226
+ };
227
+ }
228
+ return null;
229
+ }
230
+ function findDeclarationEndLine(lines, startIndex) {
231
+ let balance = 0;
232
+ let sawBrace = false;
233
+ for (let index = startIndex; index < lines.length; index += 1) {
234
+ const line = lines[index] ?? '';
235
+ const delta = braceDelta(line);
236
+ if (line.includes('{')) {
237
+ sawBrace = true;
238
+ }
239
+ balance += delta;
240
+ if (sawBrace && balance <= 0) {
241
+ return index + 1;
242
+ }
243
+ if (!sawBrace && /;\s*$/u.test(line.trim())) {
244
+ return index + 1;
245
+ }
246
+ }
247
+ return lines.length;
248
+ }
249
+ function buildSignature(lines, startLine, endLine) {
250
+ const startIndex = startLine - 1;
251
+ const endIndex = Math.min(endLine, startLine + 4);
252
+ const parts = [];
253
+ for (let index = startIndex; index < endIndex; index += 1) {
254
+ const trimmed = (lines[index] ?? '').trim();
255
+ if (trimmed.length > 0) {
256
+ parts.push(trimmed);
257
+ }
258
+ if (/[{;]\s*$/u.test(trimmed) || /=>/u.test(trimmed)) {
259
+ break;
260
+ }
261
+ }
262
+ const signature = parts.join(' ').replace(/\s+/gu, ' ');
263
+ return signature.length > 240 ? `${signature.slice(0, 237)}...` : signature;
264
+ }
265
+ function normalizeReturnType(value) {
266
+ return value.replace(/\s+/gu, ' ').replace(/[;{]\s*$/u, '').trim();
267
+ }
268
+ function extractExplicitReturnType(signature, match) {
269
+ if (match.kind !== 'function') {
270
+ return null;
271
+ }
272
+ const functionReturnMatch = /\bfunction(?:\s+[$A-Z_a-z][$\w]*)?\s*\([^)]*\)\s*:\s*(?<returnType>[^={;]+?)(?=\s*(?:\{|$))/u.exec(signature);
273
+ if (functionReturnMatch?.groups?.returnType) {
274
+ return normalizeReturnType(functionReturnMatch.groups.returnType);
275
+ }
276
+ const arrowReturnMatch = /\)\s*:\s*(?<returnType>[^=]+?)\s*=>/u.exec(signature);
277
+ if (arrowReturnMatch?.groups?.returnType) {
278
+ return normalizeReturnType(arrowReturnMatch.groups.returnType);
279
+ }
280
+ const arrowTypeAnnotationMatch = /:\s*(?:async\s*)?\([^)]*\)\s*=>\s*(?<returnType>[^=;]+?)\s*=/u.exec(signature);
281
+ if (arrowTypeAnnotationMatch?.groups?.returnType) {
282
+ return normalizeReturnType(arrowTypeAnnotationMatch.groups.returnType);
283
+ }
284
+ return null;
285
+ }
286
+ function truncateReturnPreview(value) {
287
+ const preview = value.replace(/\s+/gu, ' ').trim();
288
+ return preview.length > RETURN_PREVIEW_MAX_CHARS
289
+ ? `${preview.slice(0, RETURN_PREVIEW_MAX_CHARS - 3)}...`
290
+ : preview;
291
+ }
292
+ function extractArrowExpressionReturnPreview(signature) {
293
+ const arrowIndex = signature.lastIndexOf('=>');
294
+ if (arrowIndex < 0) {
295
+ return null;
296
+ }
297
+ const expression = signature.slice(arrowIndex + 2).trim().replace(/;\s*$/u, '');
298
+ if (expression.length === 0 || expression.startsWith('{')) {
299
+ return null;
300
+ }
301
+ return truncateReturnPreview(expression);
302
+ }
303
+ function isIdentifierCharacter(value) {
304
+ return typeof value === 'string' && /[$\w]/u.test(value);
305
+ }
306
+ function findReturnKeywordIndex(line) {
307
+ let quote = null;
308
+ let escaped = false;
309
+ for (let index = 0; index < line.length; index += 1) {
310
+ const character = line[index];
311
+ const nextCharacter = line[index + 1];
312
+ if (quote !== null) {
313
+ if (escaped) {
314
+ escaped = false;
315
+ continue;
316
+ }
317
+ if (character === '\\') {
318
+ escaped = true;
319
+ continue;
320
+ }
321
+ if (character === quote) {
322
+ quote = null;
323
+ }
324
+ continue;
325
+ }
326
+ if (character === '/' && nextCharacter === '/') {
327
+ return -1;
328
+ }
329
+ if (character === '"' || character === "'" || character === '`') {
330
+ quote = character;
331
+ continue;
332
+ }
333
+ if (line.startsWith('return', index) &&
334
+ !isIdentifierCharacter(line[index - 1]) &&
335
+ !isIdentifierCharacter(line[index + 'return'.length])) {
336
+ return index;
337
+ }
338
+ }
339
+ return -1;
340
+ }
341
+ function extractReturnExpressionPreview(line) {
342
+ const returnIndex = findReturnKeywordIndex(line);
343
+ if (returnIndex < 0) {
344
+ return null;
345
+ }
346
+ const expression = line
347
+ .slice(returnIndex + 'return'.length)
348
+ .replace(/\/\/.*$/u, '')
349
+ .trim()
350
+ .replace(/;\s*$/u, '')
351
+ .trim();
352
+ return expression.length === 0 ? null : truncateReturnPreview(expression);
353
+ }
354
+ function hasReturnStatement(line) {
355
+ return findReturnKeywordIndex(line) >= 0;
356
+ }
357
+ function simpleBodyThrowsOnly(lines, startLine, endLine, returnType) {
358
+ let sawThrow = false;
359
+ let sawOtherExecutableLine = false;
360
+ for (let index = startLine; index < endLine - 1; index += 1) {
361
+ const bodyLine = stripLineForBraceScan(lines[index] ?? '')
362
+ .replace(/[{};]/gu, '')
363
+ .trim();
364
+ if (bodyLine.length === 0) {
365
+ continue;
366
+ }
367
+ if (/^throw\b/u.test(bodyLine)) {
368
+ sawThrow = true;
369
+ continue;
370
+ }
371
+ sawOtherExecutableLine = true;
372
+ }
373
+ return sawThrow && (!sawOtherExecutableLine || returnType === 'never');
374
+ }
375
+ function extractReturnMetadata(lines, startLine, endLine, signature, match) {
376
+ const returnType = extractExplicitReturnType(signature, match);
377
+ if (match.kind !== 'function') {
378
+ return {
379
+ return_type: null,
380
+ return_behavior: 'unknown',
381
+ return_count: 0,
382
+ return_lines: [],
383
+ return_preview: null,
384
+ };
385
+ }
386
+ const returnLines = [];
387
+ const valueReturnPreviews = [];
388
+ let voidReturnCount = 0;
389
+ for (let index = startLine - 1; index < endLine; index += 1) {
390
+ const line = lines[index] ?? '';
391
+ if (!hasReturnStatement(line)) {
392
+ continue;
393
+ }
394
+ returnLines.push(index + 1);
395
+ const preview = extractReturnExpressionPreview(line);
396
+ if (preview === null) {
397
+ voidReturnCount += 1;
398
+ }
399
+ else {
400
+ valueReturnPreviews.push(preview);
401
+ }
402
+ }
403
+ const arrowExpressionPreview = returnLines.length === 0 ? extractArrowExpressionReturnPreview(signature) : null;
404
+ let returnBehavior;
405
+ if (valueReturnPreviews.length > 0 && voidReturnCount > 0) {
406
+ returnBehavior = 'mixed';
407
+ }
408
+ else if (valueReturnPreviews.length > 0 || arrowExpressionPreview !== null) {
409
+ returnBehavior = 'value';
410
+ }
411
+ else if (voidReturnCount > 0) {
412
+ returnBehavior = 'void';
413
+ }
414
+ else if (simpleBodyThrowsOnly(lines, startLine, endLine, returnType)) {
415
+ returnBehavior = 'throws_only';
416
+ }
417
+ else {
418
+ returnBehavior = 'implicit_undefined';
419
+ }
420
+ return {
421
+ return_type: returnType,
422
+ return_behavior: returnBehavior,
423
+ return_count: returnLines.length,
424
+ return_lines: returnLines,
425
+ return_preview: valueReturnPreviews[0] ?? arrowExpressionPreview,
426
+ };
427
+ }
428
+ function extractSymbols(relativePath, language, contentSha256, text) {
429
+ const lines = text.split(/\r\n|\r|\n/u);
430
+ const symbols = [];
431
+ for (const [index, line] of lines.entries()) {
432
+ const match = declarationFromLine(line);
433
+ if (!match) {
434
+ continue;
435
+ }
436
+ const startLine = index + 1;
437
+ const endLine = findDeclarationEndLine(lines, index);
438
+ const signature = buildSignature(lines, startLine, endLine);
439
+ const returnMetadata = extractReturnMetadata(lines, startLine, endLine, signature, match);
440
+ symbols.push({
441
+ id: `${relativePath}:${startLine}:${match.kind}:${match.name}`,
442
+ path: relativePath,
443
+ language,
444
+ kind: match.kind,
445
+ name: match.name,
446
+ start_line: startLine,
447
+ end_line: endLine,
448
+ signature,
449
+ exported: match.exported,
450
+ async: match.async,
451
+ ...returnMetadata,
452
+ parent: null,
453
+ content_sha256: contentSha256,
454
+ });
455
+ }
456
+ return symbols;
457
+ }
458
+ function createOutlineInputHash(policy, files, findings) {
459
+ const inputState = {
460
+ policy,
461
+ files: files.map((file) => ({
462
+ path: file.path,
463
+ sha256: file.sha256,
464
+ size_bytes: file.size_bytes,
465
+ symbol_count: file.symbol_count,
466
+ })),
467
+ input_errors: findings
468
+ .filter((finding) => ERROR_OUTLINE_CODES.has(finding.code))
469
+ .map((finding) => ({ code: finding.code, path: finding.path })),
470
+ };
471
+ return sha256Tagged(JSON.stringify(inputState));
472
+ }
473
+ function outlineStatus(findings) {
474
+ return findings.some((finding) => ERROR_OUTLINE_CODES.has(finding.code))
475
+ ? 'error'
476
+ : findings.length > 0
477
+ ? 'failed'
478
+ : 'passed';
479
+ }
480
+ export function inspectCodeOutline(projectRoot, options) {
481
+ const root = path.resolve(projectRoot);
482
+ const policy = {
483
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
484
+ max_files: options.maxFiles ?? DEFAULT_MAX_FILES,
485
+ extensions: [...CODE_FILE_EXTENSIONS],
486
+ ignored_directories: [...IGNORED_DIRECTORIES],
487
+ };
488
+ const files = [];
489
+ const symbols = [];
490
+ const findings = [];
491
+ const issues = [];
492
+ const candidates = collectSourceCandidates(root, options, policy, findings, issues);
493
+ for (const candidate of candidates) {
494
+ let buffer;
495
+ try {
496
+ buffer = readFileInsideWithoutSymlinks(root, candidate.absolutePath, { maxBytes: policy.max_file_bytes });
497
+ }
498
+ catch (error) {
499
+ const message = error instanceof Error ? error.message : String(error);
500
+ const code = message.includes('exceeds maximum size')
501
+ ? 'code_outline_file_too_large'
502
+ : 'code_outline_unreadable_path';
503
+ issues.push(`${candidate.relativePath}: ${message}`);
504
+ findings.push(makeOutlineFinding(code, 'high', candidate.relativePath, message));
505
+ continue;
506
+ }
507
+ const contentSha256 = sha256Tagged(buffer);
508
+ const text = buffer.toString('utf8');
509
+ const fileSymbols = extractSymbols(candidate.relativePath, candidate.language, contentSha256, text);
510
+ symbols.push(...fileSymbols);
511
+ files.push({
512
+ kind: 'source_file',
513
+ path: candidate.relativePath,
514
+ language: candidate.language,
515
+ sha256: contentSha256,
516
+ size_bytes: buffer.byteLength,
517
+ line_count: text.split(/\r\n|\r|\n/u).length,
518
+ symbol_count: fileSymbols.length,
519
+ });
520
+ }
521
+ const status = outlineStatus(findings);
522
+ return {
523
+ schema_version: '1',
524
+ command: 'script-pack',
525
+ pack_id: CODE_PACK_ID,
526
+ script_id: CODE_OUTLINE_SCRIPT_ID,
527
+ script_ref: CODE_OUTLINE_SCRIPT_REF,
528
+ action: 'scan',
529
+ status,
530
+ ok: status === 'passed',
531
+ mustflow_root: root,
532
+ policy,
533
+ input_hash: createOutlineInputHash(policy, files, findings),
534
+ files,
535
+ symbols: symbols.sort((left, right) => left.path.localeCompare(right.path) || left.start_line - right.start_line),
536
+ findings,
537
+ issues,
538
+ };
539
+ }
540
+ function createEmptySymbolReadReport(root, policy, findings, issues) {
541
+ const status = findings.some((finding) => ERROR_SYMBOL_READ_CODES.has(finding.code)) ? 'error' : 'passed';
542
+ return {
543
+ schema_version: '1',
544
+ command: 'script-pack',
545
+ pack_id: CODE_PACK_ID,
546
+ script_id: CODE_SYMBOL_READ_SCRIPT_ID,
547
+ script_ref: CODE_SYMBOL_READ_SCRIPT_REF,
548
+ action: 'read',
549
+ status,
550
+ ok: false,
551
+ mustflow_root: root,
552
+ policy,
553
+ input_hash: sha256Tagged(JSON.stringify({ policy, findings, issues })),
554
+ target: null,
555
+ snippet: null,
556
+ findings,
557
+ issues,
558
+ };
559
+ }
560
+ function createSymbolReadInputHash(policy, target, findings) {
561
+ const inputState = {
562
+ policy,
563
+ target: target === null
564
+ ? null
565
+ : {
566
+ path: target.path,
567
+ sha256: target.sha256,
568
+ requested_start_line: target.requested_start_line,
569
+ requested_end_line: target.requested_end_line,
570
+ context_start_line: target.context_start_line,
571
+ context_end_line: target.context_end_line,
572
+ },
573
+ input_errors: findings
574
+ .filter((finding) => ERROR_SYMBOL_READ_CODES.has(finding.code))
575
+ .map((finding) => ({
576
+ code: finding.code,
577
+ path: finding.path,
578
+ start_line: finding.start_line,
579
+ end_line: finding.end_line,
580
+ })),
581
+ };
582
+ return sha256Tagged(JSON.stringify(inputState));
583
+ }
584
+ export function readCodeSymbol(projectRoot, options) {
585
+ const root = path.resolve(projectRoot);
586
+ const policy = {
587
+ start_line: options.startLine,
588
+ end_line: options.endLine ?? null,
589
+ context_lines: options.contextLines ?? DEFAULT_CONTEXT_LINES,
590
+ max_file_bytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
591
+ max_snippet_lines: options.maxSnippetLines ?? DEFAULT_MAX_SNIPPET_LINES,
592
+ };
593
+ const findings = [];
594
+ const issues = [];
595
+ if (policy.start_line < 1 || (policy.end_line !== null && policy.end_line < policy.start_line) || policy.context_lines < 0) {
596
+ findings.push(makeSymbolReadFinding('code_symbol_read_invalid_range', 'high', options.path, policy.start_line, policy.end_line, 'Line range must use 1-based positive lines, and end_line must be greater than or equal to start_line.'));
597
+ return createEmptySymbolReadReport(root, policy, findings, issues);
598
+ }
599
+ let absolutePath;
600
+ let relativePath;
601
+ try {
602
+ const normalized = normalizeTargetPath(root, options.path);
603
+ absolutePath = normalized.absolutePath;
604
+ relativePath = normalized.relativePath;
605
+ ensureInsideWithoutSymlinks(root, absolutePath);
606
+ }
607
+ catch (error) {
608
+ const message = error instanceof Error ? error.message : String(error);
609
+ issues.push(message);
610
+ findings.push(makeSymbolReadFinding('code_symbol_read_path_outside_root', 'high', options.path, policy.start_line, policy.end_line, message));
611
+ return createEmptySymbolReadReport(root, policy, findings, issues);
612
+ }
613
+ const language = languageForPath(absolutePath);
614
+ if (!language || !existsSync(absolutePath)) {
615
+ const message = `${relativePath} is not a supported existing code file.`;
616
+ issues.push(message);
617
+ findings.push(makeSymbolReadFinding('code_symbol_read_unreadable_path', 'high', relativePath, policy.start_line, policy.end_line, message));
618
+ return createEmptySymbolReadReport(root, policy, findings, issues);
619
+ }
620
+ let buffer;
621
+ try {
622
+ buffer = readFileInsideWithoutSymlinks(root, absolutePath, { maxBytes: policy.max_file_bytes });
623
+ }
624
+ catch (error) {
625
+ const message = error instanceof Error ? error.message : String(error);
626
+ issues.push(`${relativePath}: ${message}`);
627
+ findings.push(makeSymbolReadFinding('code_symbol_read_unreadable_path', 'high', relativePath, policy.start_line, policy.end_line, message));
628
+ return createEmptySymbolReadReport(root, policy, findings, issues);
629
+ }
630
+ const contentSha256 = sha256Tagged(buffer);
631
+ const text = buffer.toString('utf8');
632
+ const lines = text.split(/\r\n|\r|\n/u);
633
+ if (policy.start_line > lines.length || (policy.end_line !== null && policy.end_line > lines.length)) {
634
+ const message = `Line range ${policy.start_line}${policy.end_line === null ? '' : `-${policy.end_line}`} is outside ${relativePath}, which has ${lines.length} lines.`;
635
+ issues.push(message);
636
+ findings.push(makeSymbolReadFinding('code_symbol_read_invalid_range', 'high', relativePath, policy.start_line, policy.end_line, message));
637
+ return createEmptySymbolReadReport(root, policy, findings, issues);
638
+ }
639
+ const symbols = extractSymbols(relativePath, language, contentSha256, text);
640
+ const matchedSymbol = policy.end_line === null
641
+ ? (symbols.find((symbol) => symbol.start_line === policy.start_line) ??
642
+ symbols.find((symbol) => symbol.start_line <= policy.start_line && symbol.end_line >= policy.start_line) ??
643
+ null)
644
+ : null;
645
+ const resolvedStartLine = matchedSymbol?.start_line ?? policy.start_line;
646
+ const resolvedEndLine = matchedSymbol?.end_line ?? policy.end_line;
647
+ if (resolvedEndLine === null) {
648
+ const message = `No outline symbol starts at or contains line ${policy.start_line} in ${relativePath}. Pass --end-line to read an explicit range.`;
649
+ issues.push(message);
650
+ findings.push(makeSymbolReadFinding('code_symbol_read_no_symbol_at_line', 'high', relativePath, policy.start_line, null, message));
651
+ }
652
+ const contextStartLine = resolvedEndLine === null ? null : Math.max(1, resolvedStartLine - policy.context_lines);
653
+ const contextEndLine = resolvedEndLine === null ? null : Math.min(lines.length, resolvedEndLine + policy.context_lines);
654
+ const target = {
655
+ path: relativePath,
656
+ language,
657
+ sha256: contentSha256,
658
+ size_bytes: buffer.byteLength,
659
+ line_count: lines.length,
660
+ requested_start_line: policy.start_line,
661
+ requested_end_line: policy.end_line,
662
+ resolved_start_line: resolvedEndLine === null ? null : resolvedStartLine,
663
+ resolved_end_line: resolvedEndLine,
664
+ context_start_line: contextStartLine,
665
+ context_end_line: contextEndLine,
666
+ symbol: matchedSymbol,
667
+ };
668
+ let snippet = null;
669
+ if (contextStartLine !== null && contextEndLine !== null) {
670
+ const snippetLineCount = contextEndLine - contextStartLine + 1;
671
+ if (snippetLineCount > policy.max_snippet_lines) {
672
+ const message = `Snippet has ${snippetLineCount} lines; max_snippet_lines is ${policy.max_snippet_lines}.`;
673
+ issues.push(message);
674
+ findings.push(makeSymbolReadFinding('code_symbol_read_snippet_too_large', 'high', relativePath, contextStartLine, contextEndLine, message));
675
+ }
676
+ else {
677
+ snippet = {
678
+ path: relativePath,
679
+ start_line: contextStartLine,
680
+ end_line: contextEndLine,
681
+ text: lines.slice(contextStartLine - 1, contextEndLine).join('\n'),
682
+ };
683
+ }
684
+ }
685
+ const status = findings.some((finding) => ERROR_SYMBOL_READ_CODES.has(finding.code))
686
+ ? 'error'
687
+ : 'passed';
688
+ return {
689
+ schema_version: '1',
690
+ command: 'script-pack',
691
+ pack_id: CODE_PACK_ID,
692
+ script_id: CODE_SYMBOL_READ_SCRIPT_ID,
693
+ script_ref: CODE_SYMBOL_READ_SCRIPT_REF,
694
+ action: 'read',
695
+ status,
696
+ ok: status === 'passed',
697
+ mustflow_root: root,
698
+ policy,
699
+ input_hash: createSymbolReadInputHash(policy, target, findings),
700
+ target,
701
+ snippet,
702
+ findings,
703
+ issues,
704
+ };
705
+ }