@0xcraft/powershot 1.0.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 (87) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +306 -0
  3. package/dist/agents.js +82 -0
  4. package/dist/bench.js +179 -0
  5. package/dist/budget.js +59 -0
  6. package/dist/bundle.js +173 -0
  7. package/dist/cache.js +155 -0
  8. package/dist/cli/agent-command.js +27 -0
  9. package/dist/cli/app.js +31 -0
  10. package/dist/cli/args.js +76 -0
  11. package/dist/cli/bench-command.js +89 -0
  12. package/dist/cli/dismiss-command.js +42 -0
  13. package/dist/cli/environment.js +32 -0
  14. package/dist/cli/reports.js +64 -0
  15. package/dist/cli/review-command.js +268 -0
  16. package/dist/cli/session-command.js +62 -0
  17. package/dist/cli.js +7 -0
  18. package/dist/config.js +130 -0
  19. package/dist/delegate.js +84 -0
  20. package/dist/dismissed.js +130 -0
  21. package/dist/fspolicy.js +62 -0
  22. package/dist/git.js +238 -0
  23. package/dist/ground.js +286 -0
  24. package/dist/judges/judge.js +85 -0
  25. package/dist/judges/llm.js +234 -0
  26. package/dist/judges/prompts.js +86 -0
  27. package/dist/judges/tools.js +125 -0
  28. package/dist/lang/packs.js +557 -0
  29. package/dist/lang/pyright.js +108 -0
  30. package/dist/lang/python-deps.js +174 -0
  31. package/dist/lang/ruby-deps.js +77 -0
  32. package/dist/langtest.js +248 -0
  33. package/dist/manifest.js +209 -0
  34. package/dist/otel.js +75 -0
  35. package/dist/package-meta.js +13 -0
  36. package/dist/package-smoke.js +110 -0
  37. package/dist/plan.js +134 -0
  38. package/dist/position.js +94 -0
  39. package/dist/report/ansi.js +18 -0
  40. package/dist/report/codequality.js +19 -0
  41. package/dist/report/compact.js +15 -0
  42. package/dist/report/highlight.js +54 -0
  43. package/dist/report/markdown.js +113 -0
  44. package/dist/report/sarif.js +66 -0
  45. package/dist/report/terminal.js +170 -0
  46. package/dist/report/viewer.js +148 -0
  47. package/dist/review.js +355 -0
  48. package/dist/scan.js +67 -0
  49. package/dist/selftest.js +1928 -0
  50. package/dist/session.js +140 -0
  51. package/dist/snapshot.js +101 -0
  52. package/dist/text.js +50 -0
  53. package/dist/types.js +2 -0
  54. package/dist/verifiers/assertion-drift.js +137 -0
  55. package/dist/verifiers/contract-drift.js +140 -0
  56. package/dist/verifiers/copy-paste-drift.js +106 -0
  57. package/dist/verifiers/dead-on-arrival.js +92 -0
  58. package/dist/verifiers/dropped-guard.js +144 -0
  59. package/dist/verifiers/foreign-contract-drift.js +114 -0
  60. package/dist/verifiers/foreign-copy-paste-drift.js +83 -0
  61. package/dist/verifiers/foreign-dropped-guard.js +78 -0
  62. package/dist/verifiers/foreign-phantom-api.js +36 -0
  63. package/dist/verifiers/foreign-phantom-config.js +40 -0
  64. package/dist/verifiers/foreign-phantom-dep.js +82 -0
  65. package/dist/verifiers/foreign-reinvented.js +65 -0
  66. package/dist/verifiers/foreign-scope-creep.js +42 -0
  67. package/dist/verifiers/foreign-swallowed-error.js +36 -0
  68. package/dist/verifiers/foreign-tests.js +143 -0
  69. package/dist/verifiers/foreign-tokens.js +94 -0
  70. package/dist/verifiers/foreign.js +16 -0
  71. package/dist/verifiers/index.js +38 -0
  72. package/dist/verifiers/lying-comment.js +90 -0
  73. package/dist/verifiers/phantom-api.js +88 -0
  74. package/dist/verifiers/phantom-config.js +93 -0
  75. package/dist/verifiers/phantom-dep.js +110 -0
  76. package/dist/verifiers/reinvented.js +74 -0
  77. package/dist/verifiers/scope-creep.js +77 -0
  78. package/dist/verifiers/swallowed-error.js +110 -0
  79. package/dist/verifiers/vacuous-test.js +138 -0
  80. package/docs/architecture.md +191 -0
  81. package/docs/assets/cli-preview.svg +68 -0
  82. package/docs/assets/powershot-logo.png +0 -0
  83. package/docs/ci.md +151 -0
  84. package/examples/github-actions/action.yml +23 -0
  85. package/examples/github-actions/cli.yml +43 -0
  86. package/examples/gitlab/.gitlab-ci.yml +21 -0
  87. package/package.json +65 -0
package/dist/review.js ADDED
@@ -0,0 +1,355 @@
1
+ import { buildGround } from './ground.js';
2
+ import { baseRefOf, collectChanges, statedIntent } from './git.js';
3
+ import { bundle, bundleName, reviewables, uncovered } from './bundle.js';
4
+ import { attachFrames, positionable } from './position.js';
5
+ import { skippedLanguages } from './lang/packs.js';
6
+ import { JudgeCache } from './cache.js';
7
+ import { Dismissals, rememberReport } from './dismissed.js';
8
+ import { renderChanges } from './judges/judge.js';
9
+ import { VERIFIERS } from './verifiers/index.js';
10
+ import { runJudge } from './judges/judge.js';
11
+ import { COMMON, JUDGES } from './judges/prompts.js';
12
+ import { apiKey } from './judges/llm.js';
13
+ import { enabled } from './config.js';
14
+ import { SelectionPlan, capabilitiesOf } from './plan.js';
15
+ import { Budget } from './budget.js';
16
+ import { packFor } from './lang/packs.js';
17
+ import { SEVERITIES } from './types.js';
18
+ import { stripControl, stripPath } from './text.js';
19
+ const packOf = (path) => packFor(path)?.name ?? 'other';
20
+ export function atLeast(severity, min) {
21
+ return SEVERITIES.indexOf(severity) >= SEVERITIES.indexOf(min);
22
+ }
23
+ /** How much two titles say the same thing, as a share of the words they use. */
24
+ export function titleOverlap(a, b) {
25
+ const words = (t) => new Set(t.toLowerCase().match(/[a-z0-9_]{3,}/g) ?? []);
26
+ const left = words(a);
27
+ const right = words(b);
28
+ if (left.size === 0 || right.size === 0)
29
+ return 0;
30
+ let shared = 0;
31
+ for (const w of left)
32
+ if (right.has(w))
33
+ shared++;
34
+ return shared / Math.min(left.size, right.size);
35
+ }
36
+ /**
37
+ * Files this verifier can actually answer for, with unavailable oracles kept per
38
+ * file. `base` is applicability rather than a missing capability: a before/after
39
+ * check has no question to ask about a newly created file.
40
+ */
41
+ function verifierTargets(v, g, have) {
42
+ if (v.domain === 'typescript') {
43
+ return g.files
44
+ .filter((file) => !v.needs.includes('base') || file.before !== undefined)
45
+ .map((file) => ({
46
+ kind: 'typescript',
47
+ path: file.changed.path,
48
+ file,
49
+ missing: v.needs.filter((need) => {
50
+ if (need === 'types' || need === 'references')
51
+ return !file.typed;
52
+ if (need === 'python-types')
53
+ return true;
54
+ return false;
55
+ }),
56
+ }));
57
+ }
58
+ if (v.domain === 'foreign' || v.domain === 'python') {
59
+ return g.foreign
60
+ .filter((file) => v.domain !== 'python' || file.pack.name === 'python')
61
+ .filter((file) => !v.supports || v.supports(file))
62
+ .filter((file) => !v.needs.includes('base') || file.beforeTree !== undefined)
63
+ .map((file) => ({
64
+ kind: 'foreign',
65
+ path: file.path,
66
+ file,
67
+ missing: v.needs.filter((need) => {
68
+ if (need === 'python-types')
69
+ return file.pack.name !== 'python' || !have.has('python-types');
70
+ if (need === 'types' || need === 'references')
71
+ return true;
72
+ return false;
73
+ }),
74
+ }));
75
+ }
76
+ return [];
77
+ }
78
+ function groundFor(g, targets) {
79
+ const files = targets
80
+ .filter((target) => target.kind === 'typescript' && target.missing.length === 0)
81
+ .map((target) => target.file);
82
+ const foreign = targets
83
+ .filter((target) => target.kind === 'foreign' && target.missing.length === 0)
84
+ .map((target) => target.file);
85
+ return { ...g, files, foreign, typed: files.some((file) => file.typed) };
86
+ }
87
+ function dropNearDuplicates(findings) {
88
+ const kept = [];
89
+ for (const f of findings) {
90
+ const duplicate = kept.some((k) => k.file === f.file && Math.abs(k.line - f.line) <= 2 && titleOverlap(k.title, f.title) >= 0.7);
91
+ if (!duplicate)
92
+ kept.push(f);
93
+ }
94
+ return kept;
95
+ }
96
+ function finalize(findings) {
97
+ // the compiler reports one mistake through several diagnostics
98
+ const seen = new Set();
99
+ findings = findings.filter((f) => {
100
+ const k = f.check + '|' + f.file + '|' + f.line + '|' + f.title;
101
+ if (seen.has(k))
102
+ return false;
103
+ seen.add(k);
104
+ return true;
105
+ });
106
+ // Sorted before folding: folding keeps whichever copy comes first, so the other
107
+ // way round a `low` duplicate would evict the `critical` saying the same thing.
108
+ const sorted = findings.slice().sort((a, b) => {
109
+ if (a.class !== b.class)
110
+ return a.class === 'verified' ? -1 : 1;
111
+ const sev = SEVERITIES.indexOf(b.severity) - SEVERITIES.indexOf(a.severity);
112
+ if (sev !== 0)
113
+ return sev;
114
+ return a.file.localeCompare(b.file) || a.line - b.line;
115
+ });
116
+ // Sanitized once here rather than per renderer: a title or a frame line is
117
+ // attacker-controlled text, and every output path prints it somewhere
118
+ return dropNearDuplicates(sorted).map((f, i) => ({
119
+ ...f,
120
+ id: 'F' + (i + 1),
121
+ file: stripPath(f.file),
122
+ title: stripControl(f.title),
123
+ fix: f.fix === undefined ? undefined : stripControl(f.fix),
124
+ suggestion: f.suggestion === undefined ? undefined : stripControl(f.suggestion),
125
+ evidence: f.evidence && { oracle: stripControl(f.evidence.oracle), detail: stripControl(f.evidence.detail) },
126
+ frame: f.frame && { ...f.frame, lines: f.frame.lines.map(stripControl) },
127
+ }));
128
+ }
129
+ export async function review(opts) {
130
+ const { root, range, config, verifyOnly, onProgress } = opts;
131
+ const repo = opts.stateRoot ?? root;
132
+ const say = onProgress ?? (() => { });
133
+ const stage = opts.onStage ?? (() => () => { });
134
+ const failures = [];
135
+ let budgetStop;
136
+ const all = opts.changes ?? collectChanges(repo, range);
137
+ const plan = SelectionPlan.build(root, all, config);
138
+ const changed = plan.keep(all);
139
+ if (changed.length === 0) {
140
+ for (const line of plan.summary())
141
+ say('selection ' + line);
142
+ return { findings: [], stats: { files: 0, verified: 0, judged: 0, dismissed: 0 }, failures, plan };
143
+ }
144
+ const skipped = new Map();
145
+ const budget = opts.budget ?? new Budget();
146
+ const manifest = opts.manifest;
147
+ const groundDone = stage('ground');
148
+ const g = await buildGround(root, changed, opts.signal);
149
+ groundDone(g.project.getSourceFiles().length + ' files · ' + g.symbolIndex.size + ' symbols' +
150
+ (g.typed ? '' : ' · no tsconfig, phantom-api disabled'));
151
+ const wanted = (v) => {
152
+ const id = v.id ?? v.name;
153
+ return opts.checks
154
+ ? opts.checks.includes(id) || opts.checks.includes(v.name)
155
+ : enabled(config.verifiers, id) || enabled(config.verifiers, v.name);
156
+ };
157
+ const have = capabilitiesOf(g);
158
+ const targets = new Map();
159
+ for (const verifier of VERIFIERS) {
160
+ if (!wanted(verifier))
161
+ continue;
162
+ const files = verifierTargets(verifier, g, have);
163
+ if (files.length > 0)
164
+ targets.set(verifier, files);
165
+ }
166
+ const selectedVerifiers = [...targets.keys()];
167
+ // a file the change touched that no parser produced a tree for was not reviewed,
168
+ // whatever the summary says about the ones that were
169
+ const grounded = new Set([...g.files.map((f) => f.changed.path), ...g.foreign.map((f) => f.path)]);
170
+ for (const c of changed) {
171
+ if (!grounded.has(c.path))
172
+ plan.waive(c.path, 'no parser for this language');
173
+ }
174
+ // Capabilities belong to files, not runs. A typed file beside one excluded from
175
+ // tsconfig must not make the latter look checked, and an old Ruby file must not
176
+ // make a new Python file eligible for a before/after oracle.
177
+ for (const files of targets.values()) {
178
+ for (const file of files)
179
+ plan.limit(file.path, file.missing);
180
+ }
181
+ if (skippedLanguages.length > 0) {
182
+ for (const c of changed) {
183
+ if (!grounded.has(c.path) && skippedLanguages.includes(packOf(c.path))) {
184
+ plan.fail(c.path, 'grammar budget reached');
185
+ }
186
+ }
187
+ failures.push('not reviewed, grammar budget reached: ' + skippedLanguages.join(', '));
188
+ }
189
+ for (const line of plan.summary())
190
+ say('selection ' + line);
191
+ for (const f of plan.of('failed'))
192
+ failures.push('not reviewed: ' + f.path + ' — ' + f.reason);
193
+ const verifyDone = stage('verify');
194
+ const findings = [];
195
+ let ran = 0;
196
+ for (const v of selectedVerifiers) {
197
+ // a check that cannot run must say so rather than return nothing, which reads
198
+ // exactly like a check that ran and was satisfied
199
+ const files = targets.get(v);
200
+ const eligible = files.filter((file) => file.missing.length === 0);
201
+ const missing = [...new Set(files.flatMap((file) => file.missing))];
202
+ if (missing.length > 0 && eligible.length === 0) {
203
+ skipped.set(v.id ?? v.name, missing.join(', '));
204
+ continue;
205
+ }
206
+ // a scan spends most of its time here, so Ctrl-C has to reach this half
207
+ if (opts.signal?.aborted) {
208
+ failures.push('cancelled during verification');
209
+ break;
210
+ }
211
+ ran++;
212
+ const check = v.id ?? v.name;
213
+ manifest?.ran(check);
214
+ for (const file of eligible)
215
+ plan.checked(file.path, check);
216
+ try {
217
+ findings.push(...v.run(groundFor(g, files)));
218
+ }
219
+ catch (e) {
220
+ failures.push(v.name + ': ' + e.message);
221
+ }
222
+ }
223
+ verifyDone(ran + ' checks × ' + reviewables(g).length + ' files · 0 tokens');
224
+ if (skipped.size > 0) {
225
+ const names = [...skipped].map(([n, why]) => n + ' (no ' + why + ')');
226
+ say('skipped ' + names.join(', '));
227
+ }
228
+ // --checks overrides the config rather than filtering it
229
+ const isGated = range.from !== undefined || range.commit !== undefined;
230
+ const judgeCache = opts.cache === false || verifyOnly ? undefined : JudgeCache.open(repo, isGated);
231
+ const wantedJudges = JUDGES.filter((j) => opts.checks ? opts.checks.includes(j.name) : enabled(config.judges, j.name));
232
+ if (!verifyOnly && wantedJudges.length > 0) {
233
+ if (!apiKey(config)) {
234
+ // requested and not run is not the same as not requested: a run that was asked
235
+ // for judges and never reached one must not report the deterministic half as
236
+ // the whole answer
237
+ failures.push('judges requested but no API key set: ' + wantedJudges.map((j) => j.name).join(', '));
238
+ say('judge skipped — no API key set (use --verify-only to silence this)');
239
+ }
240
+ else {
241
+ opts.onCancelable?.(true);
242
+ const intent = statedIntent(repo, range);
243
+ const units = bundle(g, opts.maxBundleLines ?? 1200);
244
+ let cancelled = false;
245
+ if (units.length > 1) {
246
+ say('bundle ' + reviewables(g).length + ' files → ' + units.length + ' review units');
247
+ }
248
+ const missed = uncovered(g, units);
249
+ if (missed.length > 0)
250
+ failures.push('not sent to any judge: ' + missed.slice(0, 5).join(', '));
251
+ for (const spec of wantedJudges) {
252
+ if (cancelled)
253
+ break;
254
+ for (const unit of units) {
255
+ // between units, not mid-flight: `--resume` picks up exactly here
256
+ if (opts.signal?.aborted) {
257
+ cancelled = true;
258
+ failures.push('cancelled before ' + spec.name + ' finished');
259
+ manifest?.unit({ judge: spec.name, unit: bundleName(unit, root), outcome: 'waived', reason: 'cancelled', findings: 0 });
260
+ break;
261
+ }
262
+ const stop = budget.exhausted();
263
+ if (stop) {
264
+ budgetStop = stop;
265
+ manifest?.unit({ judge: spec.name, unit: bundleName(unit, root), outcome: 'waived', reason: stop, findings: 0 });
266
+ continue;
267
+ }
268
+ const label = units.length > 1 ? spec.name + ' · ' + bundleName(unit, root) : spec.name;
269
+ const rendered = renderChanges(unit.files);
270
+ const cached = opts.session?.get(spec.name, bundleName(unit, root), rendered);
271
+ if (cached) {
272
+ findings.push(...cached);
273
+ manifest?.unit({ judge: spec.name, unit: bundleName(unit, root), outcome: 'reused', reason: 'resumed from session', findings: cached.length });
274
+ say('judge ' + label + ' → ' + cached.length + ' findings (resumed)');
275
+ continue;
276
+ }
277
+ const key = JudgeCache.key({
278
+ judge: spec.name,
279
+ provider: config.provider,
280
+ model: config.model,
281
+ prompt: COMMON + '\n' + spec.brief,
282
+ tools: opts.tools ?? false,
283
+ content: rendered,
284
+ intent: spec.needsIntent ? intent : undefined,
285
+ });
286
+ const remembered = judgeCache?.get(key);
287
+ if (remembered) {
288
+ findings.push(...remembered);
289
+ opts.session?.record(spec.name, bundleName(unit, root), rendered, remembered);
290
+ manifest?.unit({ judge: spec.name, unit: bundleName(unit, root), outcome: 'reused', reason: 'answered before, same question', findings: remembered.length });
291
+ say('judge ' + label + ' → ' + remembered.length + ' findings (cached)');
292
+ continue;
293
+ }
294
+ const judgeDone = stage('judge');
295
+ try {
296
+ const judged = await runJudge(spec, g, config, { intent, bundle: unit, useTools: opts.tools, budget });
297
+ findings.push(...judged);
298
+ opts.session?.record(spec.name, bundleName(unit, root), rendered, judged);
299
+ judgeCache?.put(key, judged, new Date().toISOString());
300
+ manifest?.unit({ judge: spec.name, unit: bundleName(unit, root), outcome: 'completed', findings: judged.length });
301
+ judgeDone(label + ' → ' + judged.length + ' findings');
302
+ }
303
+ catch (e) {
304
+ // keep what the others found, but the run is incomplete from here
305
+ const detail = label + ': ' + e.message;
306
+ failures.push(detail);
307
+ manifest?.unit({ judge: spec.name, unit: bundleName(unit, root), outcome: 'failed', reason: e.message, findings: 0 });
308
+ judgeDone(label + ' failed: ' + e.message);
309
+ }
310
+ }
311
+ }
312
+ }
313
+ }
314
+ judgeCache?.save();
315
+ opts.onCancelable?.(false);
316
+ if (opts.absorbed?.length)
317
+ findings.push(...opts.absorbed);
318
+ const positioned = positionable(findings, g);
319
+ if (positioned.dropped > 0) {
320
+ say('position dropped ' + positioned.dropped + ' judged finding(s) pointing outside the change');
321
+ }
322
+ const framed = attachFrames(positioned.kept, g).filter((f) => atLeast(f.severity, config.minSeverity));
323
+ // the count is still reported: a filter nobody can see is one nobody can trust
324
+ const gated = range.from !== undefined || range.commit !== undefined;
325
+ const base = gated ? baseRefOf(repo, range) : undefined;
326
+ const dismissals = Dismissals.open(repo, base);
327
+ const surviving = opts.showDismissed ? framed : framed.filter((f) => !dismissals.has(f));
328
+ const dismissed = framed.length - surviving.length;
329
+ if (dismissed > 0 && !opts.showDismissed) {
330
+ say('dismissed ' + dismissed + ' finding(s) previously called correct — psh dismiss list');
331
+ }
332
+ const pending = base ? Dismissals.pendingIn(repo, base) : 0;
333
+ if (pending > 0)
334
+ say('dismissed ' + pending + ' new dismissal(s) in this change — not applied to it');
335
+ const kept = finalize(surviving);
336
+ rememberReport(repo, kept); // so `psh dismiss F2` knows which finding F2 was
337
+ // stats describe what is reported, not what was found before filtering
338
+ return {
339
+ findings: kept,
340
+ stats: {
341
+ files: reviewables(g).length,
342
+ verified: kept.filter((f) => f.class === 'verified').length,
343
+ judged: kept.filter((f) => f.class === 'judged').length,
344
+ dismissed,
345
+ },
346
+ failures,
347
+ plan,
348
+ skippedChecks: [...skipped].map(([check, missing]) => ({ check, missing })),
349
+ usage: budget.finish(),
350
+ budgetStop,
351
+ cancelled: opts.signal?.aborted ?? false,
352
+ droppedPosition: positioned.dropped,
353
+ };
354
+ }
355
+ //# sourceMappingURL=review.js.map
package/dist/scan.js ADDED
@@ -0,0 +1,67 @@
1
+ import { readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { decode, lines as splitLines } from './text.js';
3
+ import { join } from 'node:path';
4
+ import { insideRepo, isSymlink, repoPath } from './fspolicy.js';
5
+ import { packFor } from './lang/packs.js';
6
+ const TS_CODE = /\.(ts|tsx|mts|cts|js|jsx|mjs|cjs)$/;
7
+ /**
8
+ * A scan covers every language the tool claims to review, not only the two the
9
+ * TypeScript compiler reads. Walking only JS/TS meant `psh scan` on a Go or Python
10
+ * repository reported a clean tree it had never opened.
11
+ */
12
+ function isCode(name) {
13
+ return TS_CODE.test(name) || packFor(name) !== undefined;
14
+ }
15
+ const SKIP = new Set(['node_modules', 'dist', 'build', 'coverage', '.git', '.next', 'out']);
16
+ /** No base revision, so the before/after verifiers stay silent by construction. */
17
+ export function scanPaths(root, target) {
18
+ const out = [];
19
+ const walk = (dir) => {
20
+ let entries;
21
+ try {
22
+ entries = readdirSync(dir);
23
+ }
24
+ catch {
25
+ return;
26
+ }
27
+ for (const name of entries) {
28
+ if (SKIP.has(name) || name.startsWith('.'))
29
+ continue;
30
+ const full = join(dir, name);
31
+ // a link inside the repository can point anywhere, and a scan feeds what it
32
+ // reads to the judges — the boundary has to hold here, not only in their tools
33
+ if (isSymlink(full) || !insideRepo(root, full))
34
+ continue;
35
+ const stat = statSync(full, { throwIfNoEntry: false });
36
+ if (!stat)
37
+ continue;
38
+ if (stat.isDirectory()) {
39
+ walk(full);
40
+ continue;
41
+ }
42
+ if (!isCode(name))
43
+ continue;
44
+ const lines = splitLines(decode(readFileSync(full))).length;
45
+ out.push({
46
+ path: repoPath(root, full),
47
+ added: new Set(Array.from({ length: lines }, (_, i) => i + 1)),
48
+ before: undefined,
49
+ });
50
+ }
51
+ };
52
+ const start = insideRepo(root, target);
53
+ if (!start)
54
+ return out;
55
+ const stat = statSync(start, { throwIfNoEntry: false });
56
+ if (stat?.isFile()) {
57
+ if (isSymlink(start))
58
+ return out;
59
+ const lines = splitLines(decode(readFileSync(start))).length;
60
+ out.push({ path: repoPath(root, start), added: new Set(Array.from({ length: lines }, (_, i) => i + 1)) });
61
+ }
62
+ else {
63
+ walk(start);
64
+ }
65
+ return out;
66
+ }
67
+ //# sourceMappingURL=scan.js.map