@whitehatd/crag 0.2.3 → 0.2.4

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,453 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stack detection for crag analyze.
5
+ *
6
+ * Each language/runtime gets its own detector that runs against a project
7
+ * directory and contributes to the `result.stack` list. Detectors are
8
+ * intentionally independent — a project can be polyglot and every detector
9
+ * that matches fires.
10
+ *
11
+ * The golden rule: a detector only fires when a *primary manifest* exists.
12
+ * Substring matches in test fixtures, node_modules, or dependencies do NOT
13
+ * count. If axios's tests import express, that does not make axios an
14
+ * Express app.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const exists = (dir, f) => fs.existsSync(path.join(dir, f));
21
+ const existsAny = (dir, files) => files.some(f => exists(dir, f));
22
+
23
+ /**
24
+ * Read a file with a conservative size cap so a 100MB lockfile can't exhaust
25
+ * memory on a pathological repo. Returns '' on any failure.
26
+ */
27
+ function safeRead(filePath, maxBytes = 2 * 1024 * 1024) {
28
+ try {
29
+ const stat = fs.statSync(filePath);
30
+ if (stat.size > maxBytes) return '';
31
+ return fs.readFileSync(filePath, 'utf-8');
32
+ } catch {
33
+ return '';
34
+ }
35
+ }
36
+
37
+ function safeJson(filePath) {
38
+ const content = safeRead(filePath);
39
+ if (!content) return null;
40
+ try { return JSON.parse(content); } catch { return null; }
41
+ }
42
+
43
+ /**
44
+ * Minimal TOML reader — extracts top-level section tables and key=value pairs.
45
+ * We do not need a full TOML parser; we just need to see whether specific
46
+ * tables exist (e.g. [tool.pytest.ini_options]) and read string values from
47
+ * well-known keys. This is vastly simpler than pulling in a dependency.
48
+ *
49
+ * Returns: { sections: Set<string>, values: Map<string, string> } where
50
+ * values keys are flattened (e.g. "project.name", "tool.poetry.dependencies").
51
+ */
52
+ function parseSimpleToml(content) {
53
+ const sections = new Set();
54
+ const values = new Map();
55
+ let currentSection = '';
56
+
57
+ for (const rawLine of content.split(/\r?\n/)) {
58
+ const line = rawLine.trim();
59
+ if (!line || line.startsWith('#')) continue;
60
+
61
+ const sectionMatch = line.match(/^\[([^\]]+)\]$/);
62
+ if (sectionMatch) {
63
+ currentSection = sectionMatch[1].trim();
64
+ sections.add(currentSection);
65
+ continue;
66
+ }
67
+
68
+ const kvMatch = line.match(/^([A-Za-z0-9_-]+)\s*=\s*(.+?)\s*(?:#.*)?$/);
69
+ if (kvMatch) {
70
+ const key = (currentSection ? currentSection + '.' : '') + kvMatch[1];
71
+ let value = kvMatch[2].trim();
72
+ // Strip surrounding quotes
73
+ if ((value.startsWith('"') && value.endsWith('"')) ||
74
+ (value.startsWith("'") && value.endsWith("'"))) {
75
+ value = value.slice(1, -1);
76
+ }
77
+ values.set(key, value);
78
+ }
79
+ }
80
+
81
+ return { sections, values };
82
+ }
83
+
84
+ /**
85
+ * Detect languages, frameworks, and package managers in `dir`.
86
+ * Mutates `result.stack`, `result.name`, `result.description`, and attaches
87
+ * `result._manifests` for downstream gate detection.
88
+ */
89
+ function detectStack(dir, result) {
90
+ result._manifests = result._manifests || {};
91
+
92
+ detectNode(dir, result);
93
+ detectDeno(dir, result);
94
+ detectBun(dir, result);
95
+ detectRust(dir, result);
96
+ detectGo(dir, result);
97
+ detectPython(dir, result);
98
+ detectJava(dir, result);
99
+ detectKotlin(dir, result);
100
+ detectDotNet(dir, result);
101
+ detectSwift(dir, result);
102
+ detectElixir(dir, result);
103
+ detectRuby(dir, result);
104
+ detectPhp(dir, result);
105
+ detectDocker(dir, result);
106
+ detectInfrastructure(dir, result);
107
+ }
108
+
109
+ // --- Node ------------------------------------------------------------------
110
+
111
+ function detectNode(dir, result) {
112
+ if (!exists(dir, 'package.json')) return;
113
+ result.stack.push('node');
114
+
115
+ const pkg = safeJson(path.join(dir, 'package.json'));
116
+ if (!pkg) return;
117
+
118
+ result._manifests.packageJson = pkg;
119
+ if (pkg.name) result.name = pkg.name;
120
+ if (pkg.description) result.description = pkg.description;
121
+
122
+ // FRAMEWORK DETECTION — only flag if in runtime dependencies (not devDeps).
123
+ // devDeps often contains test/build frameworks that the project USES to test
124
+ // itself without BEING that framework. axios pulls express into devDeps for
125
+ // its test server; that does not make axios an Express application.
126
+ const runtimeDeps = pkg.dependencies || {};
127
+ const devDeps = pkg.devDependencies || {};
128
+ const allDeps = { ...runtimeDeps, ...devDeps };
129
+
130
+ const pushFramework = (name, label) => {
131
+ if (runtimeDeps[name]) result.stack.push(label);
132
+ };
133
+
134
+ pushFramework('next', 'next.js');
135
+ if (runtimeDeps.react && !runtimeDeps.next) result.stack.push('react');
136
+ pushFramework('vue', 'vue');
137
+ pushFramework('svelte', 'svelte');
138
+ pushFramework('@sveltejs/kit', 'sveltekit');
139
+ pushFramework('nuxt', 'nuxt');
140
+ pushFramework('astro', 'astro');
141
+ pushFramework('solid-js', 'solid');
142
+ pushFramework('qwik', 'qwik');
143
+ pushFramework('remix', 'remix');
144
+ pushFramework('express', 'express');
145
+ pushFramework('fastify', 'fastify');
146
+ pushFramework('koa', 'koa');
147
+ pushFramework('hono', 'hono');
148
+ pushFramework('nestjs', 'nestjs');
149
+ pushFramework('@nestjs/core', 'nestjs');
150
+
151
+ // TypeScript can legitimately be in devDeps — it's a language, not a framework.
152
+ if (allDeps.typescript || exists(dir, 'tsconfig.json')) {
153
+ if (!result.stack.includes('typescript')) result.stack.push('typescript');
154
+ }
155
+ }
156
+
157
+ // --- Deno ------------------------------------------------------------------
158
+
159
+ function detectDeno(dir, result) {
160
+ if (existsAny(dir, ['deno.json', 'deno.jsonc'])) {
161
+ result.stack.push('deno');
162
+ const cfg = safeJson(path.join(dir, 'deno.json')) || safeJson(path.join(dir, 'deno.jsonc'));
163
+ if (cfg) result._manifests.denoJson = cfg;
164
+ }
165
+ }
166
+
167
+ // --- Bun -------------------------------------------------------------------
168
+
169
+ function detectBun(dir, result) {
170
+ if (exists(dir, 'bun.lockb') || exists(dir, 'bunfig.toml')) {
171
+ if (!result.stack.includes('bun')) result.stack.push('bun');
172
+ }
173
+ }
174
+
175
+ // --- Rust ------------------------------------------------------------------
176
+
177
+ function detectRust(dir, result) {
178
+ if (!exists(dir, 'Cargo.toml')) return;
179
+ result.stack.push('rust');
180
+ const content = safeRead(path.join(dir, 'Cargo.toml'));
181
+ if (content.includes('[workspace]')) {
182
+ result._manifests.cargoWorkspace = true;
183
+ }
184
+ }
185
+
186
+ // --- Go --------------------------------------------------------------------
187
+
188
+ function detectGo(dir, result) {
189
+ if (exists(dir, 'go.mod')) result.stack.push('go');
190
+ if (exists(dir, 'go.work')) result._manifests.goWorkspace = true;
191
+ }
192
+
193
+ // --- Python ----------------------------------------------------------------
194
+
195
+ function detectPython(dir, result) {
196
+ const hasPyproject = exists(dir, 'pyproject.toml');
197
+ const hasSetup = exists(dir, 'setup.py') || exists(dir, 'setup.cfg');
198
+ const hasRequirements = exists(dir, 'requirements.txt');
199
+
200
+ if (!hasPyproject && !hasSetup && !hasRequirements) return;
201
+ result.stack.push('python');
202
+
203
+ // Detect package manager / runner
204
+ if (exists(dir, 'uv.lock')) result._manifests.pythonRunner = 'uv';
205
+ else if (exists(dir, 'poetry.lock')) result._manifests.pythonRunner = 'poetry';
206
+ else if (exists(dir, 'pdm.lock')) result._manifests.pythonRunner = 'pdm';
207
+ else if (exists(dir, 'Pipfile.lock')) result._manifests.pythonRunner = 'pipenv';
208
+
209
+ if (hasPyproject) {
210
+ const content = safeRead(path.join(dir, 'pyproject.toml'));
211
+ const toml = parseSimpleToml(content);
212
+ result._manifests.pyproject = toml;
213
+
214
+ // Poetry / PDM / Hatch signals from build-system or tool section
215
+ if (toml.sections.has('tool.poetry')) {
216
+ result._manifests.pythonRunner = result._manifests.pythonRunner || 'poetry';
217
+ }
218
+ if (toml.sections.has('tool.pdm')) {
219
+ result._manifests.pythonRunner = result._manifests.pythonRunner || 'pdm';
220
+ }
221
+ if (toml.sections.has('tool.hatch.envs.default') ||
222
+ toml.sections.has('tool.hatch.envs.test') ||
223
+ toml.sections.has('tool.hatch')) {
224
+ result._manifests.pythonRunner = result._manifests.pythonRunner || 'hatch';
225
+ }
226
+ if (toml.sections.has('tool.rye')) {
227
+ result._manifests.pythonRunner = result._manifests.pythonRunner || 'rye';
228
+ }
229
+ }
230
+
231
+ // tox is a classic runner and may coexist with any package manager
232
+ if (exists(dir, 'tox.ini') || (result._manifests.pyproject &&
233
+ result._manifests.pyproject.sections.has('tool.tox'))) {
234
+ result._manifests.hasTox = true;
235
+ }
236
+
237
+ // nox
238
+ if (exists(dir, 'noxfile.py')) result._manifests.hasNox = true;
239
+ }
240
+
241
+ // --- Java ------------------------------------------------------------------
242
+
243
+ function detectJava(dir, result) {
244
+ const hasMaven = exists(dir, 'pom.xml');
245
+ const hasGradle = existsAny(dir, ['build.gradle.kts', 'build.gradle']);
246
+
247
+ if (hasMaven) {
248
+ result.stack.push('java/maven');
249
+ result._manifests.javaBuildSystem = 'maven';
250
+ result._manifests.javaWrapper = exists(dir, 'mvnw') || exists(dir, 'mvnw.cmd');
251
+ }
252
+ if (hasGradle) {
253
+ // We push 'java/gradle' tentatively; detectKotlin may replace with kotlin
254
+ result.stack.push('java/gradle');
255
+ result._manifests.javaBuildSystem = result._manifests.javaBuildSystem || 'gradle';
256
+ result._manifests.gradleWrapper = exists(dir, 'gradlew') || exists(dir, 'gradlew.bat');
257
+ }
258
+ }
259
+
260
+ // --- Kotlin ----------------------------------------------------------------
261
+
262
+ function detectKotlin(dir, result) {
263
+ // Kotlin is almost always also "java/gradle" via Gradle. We look for
264
+ // the kotlin plugin in build.gradle.kts or .kt source files.
265
+ const gradleKts = path.join(dir, 'build.gradle.kts');
266
+ if (fs.existsSync(gradleKts)) {
267
+ const content = safeRead(gradleKts);
268
+ if (/kotlin\(["']jvm["']\)|kotlin\(["']android["']\)|org\.jetbrains\.kotlin/.test(content)) {
269
+ if (!result.stack.includes('kotlin')) result.stack.push('kotlin');
270
+ }
271
+ }
272
+ // Also detect via .kt files in src/
273
+ try {
274
+ const srcMain = path.join(dir, 'src', 'main', 'kotlin');
275
+ if (fs.existsSync(srcMain)) {
276
+ if (!result.stack.includes('kotlin')) result.stack.push('kotlin');
277
+ }
278
+ } catch { /* skip */ }
279
+ }
280
+
281
+ // --- C# / .NET -------------------------------------------------------------
282
+
283
+ function detectDotNet(dir, result) {
284
+ // Look for *.csproj, *.fsproj, *.vbproj, *.sln at top level
285
+ try {
286
+ const entries = fs.readdirSync(dir);
287
+ const hasDotNet = entries.some(f =>
288
+ f.endsWith('.csproj') || f.endsWith('.fsproj') ||
289
+ f.endsWith('.vbproj') || f.endsWith('.sln')
290
+ );
291
+ if (hasDotNet) {
292
+ result.stack.push('dotnet');
293
+ result._manifests.dotnet = true;
294
+ if (entries.some(f => f.endsWith('.fsproj'))) result.stack.push('fsharp');
295
+ }
296
+ } catch { /* skip */ }
297
+ }
298
+
299
+ // --- Swift -----------------------------------------------------------------
300
+
301
+ function detectSwift(dir, result) {
302
+ if (exists(dir, 'Package.swift')) {
303
+ result.stack.push('swift');
304
+ result._manifests.swiftPackage = true;
305
+ }
306
+ }
307
+
308
+ // --- Elixir ----------------------------------------------------------------
309
+
310
+ function detectElixir(dir, result) {
311
+ if (exists(dir, 'mix.exs')) {
312
+ result.stack.push('elixir');
313
+ const content = safeRead(path.join(dir, 'mix.exs'));
314
+ if (content.includes('phoenix') || content.includes(':phoenix')) {
315
+ result.stack.push('phoenix');
316
+ }
317
+ }
318
+ }
319
+
320
+ // --- Ruby ------------------------------------------------------------------
321
+
322
+ function detectRuby(dir, result) {
323
+ const hasGemfile = exists(dir, 'Gemfile');
324
+ const hasGemspec = safeListContains(dir, /\.gemspec$/);
325
+ const hasRakefile = exists(dir, 'Rakefile');
326
+
327
+ if (!hasGemfile && !hasGemspec && !hasRakefile) return;
328
+ result.stack.push('ruby');
329
+
330
+ // Framework detection via Gemfile / gemspec content
331
+ const gemfileContent = safeRead(path.join(dir, 'Gemfile'));
332
+ const gemspecFile = listFiles(dir).find(f => f.endsWith('.gemspec'));
333
+ const gemspecContent = gemspecFile ? safeRead(path.join(dir, gemspecFile)) : '';
334
+ const allGemContent = gemfileContent + '\n' + gemspecContent;
335
+
336
+ if (/['"]rails['"]/.test(allGemContent)) result.stack.push('rails');
337
+ else if (/['"]sinatra['"]/.test(allGemContent)) result.stack.push('sinatra');
338
+ else if (/['"]hanami['"]/.test(allGemContent)) result.stack.push('hanami');
339
+
340
+ result._manifests.ruby = {
341
+ gemfile: hasGemfile,
342
+ gemspec: gemspecFile || null,
343
+ rakefile: hasRakefile,
344
+ hasRspec: /['"]rspec['"]/.test(allGemContent) || exists(dir, '.rspec'),
345
+ hasMinitest: /['"]minitest['"]/.test(allGemContent),
346
+ hasRubocop: /['"]rubocop['"]/.test(allGemContent) || exists(dir, '.rubocop.yml'),
347
+ hasStandardRb: /['"]standard['"]/.test(allGemContent),
348
+ hasReek: /['"]reek['"]/.test(allGemContent),
349
+ hasBrakeman: /['"]brakeman['"]/.test(allGemContent),
350
+ };
351
+ }
352
+
353
+ // --- PHP -------------------------------------------------------------------
354
+
355
+ function detectPhp(dir, result) {
356
+ if (!exists(dir, 'composer.json')) return;
357
+ result.stack.push('php');
358
+
359
+ const composer = safeJson(path.join(dir, 'composer.json'));
360
+ if (!composer) return;
361
+
362
+ const requireAll = { ...composer.require, ...composer['require-dev'] };
363
+
364
+ // Framework detection
365
+ if (requireAll['laravel/framework']) result.stack.push('laravel');
366
+ else if (requireAll['symfony/framework-bundle'] || requireAll['symfony/symfony']) result.stack.push('symfony');
367
+ else if (requireAll['slim/slim']) result.stack.push('slim');
368
+ else if (requireAll['yiisoft/yii2']) result.stack.push('yii');
369
+ else if (requireAll['cakephp/cakephp']) result.stack.push('cakephp');
370
+
371
+ result._manifests.composer = composer;
372
+ result._manifests.php = {
373
+ hasPhpunit: !!requireAll['phpunit/phpunit'] ||
374
+ exists(dir, 'phpunit.xml') || exists(dir, 'phpunit.xml.dist'),
375
+ hasPest: !!requireAll['pestphp/pest'],
376
+ hasPhpcs: !!requireAll['squizlabs/php_codesniffer'] || exists(dir, 'phpcs.xml') || exists(dir, 'phpcs.xml.dist'),
377
+ hasPhpStan: !!requireAll['phpstan/phpstan'] || exists(dir, 'phpstan.neon') || exists(dir, 'phpstan.neon.dist'),
378
+ hasPsalm: !!requireAll['vimeo/psalm'] || exists(dir, 'psalm.xml'),
379
+ hasPhpCsFixer: !!requireAll['friendsofphp/php-cs-fixer'] || exists(dir, '.php-cs-fixer.php') || exists(dir, '.php-cs-fixer.dist.php'),
380
+ hasRector: !!requireAll['rector/rector'] || exists(dir, 'rector.php'),
381
+ scripts: composer.scripts || {},
382
+ };
383
+ }
384
+
385
+ // --- Docker ----------------------------------------------------------------
386
+
387
+ function detectDocker(dir, result) {
388
+ if (exists(dir, 'Dockerfile') || exists(dir, 'Containerfile') ||
389
+ exists(dir, 'Dockerfile.dev') || exists(dir, 'Dockerfile.prod')) {
390
+ if (!result.stack.includes('docker')) result.stack.push('docker');
391
+ }
392
+ }
393
+
394
+ // --- Infrastructure (Terraform, Helm, K8s) ---------------------------------
395
+
396
+ function detectInfrastructure(dir, result) {
397
+ result._manifests.infra = {};
398
+
399
+ try {
400
+ const entries = fs.readdirSync(dir);
401
+ if (entries.some(f => f.endsWith('.tf'))) {
402
+ result._manifests.infra.terraform = true;
403
+ if (!result.stack.includes('terraform')) result.stack.push('terraform');
404
+ }
405
+ } catch { /* skip */ }
406
+
407
+ if (exists(dir, 'Chart.yaml')) {
408
+ result._manifests.infra.helm = true;
409
+ }
410
+
411
+ // Kubernetes manifests: look for common patterns
412
+ for (const k8sDir of ['k8s', 'kubernetes', 'deploy', 'manifests']) {
413
+ if (exists(dir, k8sDir)) {
414
+ result._manifests.infra.kubernetes = k8sDir;
415
+ break;
416
+ }
417
+ }
418
+
419
+ // OpenAPI / Swagger specs
420
+ for (const spec of ['openapi.yaml', 'openapi.yml', 'openapi.json',
421
+ 'swagger.yaml', 'swagger.yml', 'swagger.json']) {
422
+ if (exists(dir, spec)) {
423
+ result._manifests.infra.openapi = spec;
424
+ break;
425
+ }
426
+ }
427
+
428
+ // Protocol buffers
429
+ try {
430
+ const entries = fs.readdirSync(dir);
431
+ if (entries.some(f => f.endsWith('.proto'))) {
432
+ result._manifests.infra.proto = true;
433
+ }
434
+ } catch { /* skip */ }
435
+ }
436
+
437
+ // --- helpers ---------------------------------------------------------------
438
+
439
+ function listFiles(dir) {
440
+ try { return fs.readdirSync(dir); } catch { return []; }
441
+ }
442
+
443
+ function safeListContains(dir, regex) {
444
+ return listFiles(dir).some(f => regex.test(f));
445
+ }
446
+
447
+ module.exports = {
448
+ detectStack,
449
+ safeRead,
450
+ safeJson,
451
+ parseSimpleToml,
452
+ listFiles,
453
+ };
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Task-runner target mining.
5
+ *
6
+ * Many projects use Make/Taskfile/just as the canonical entry points to
7
+ * their test, lint, and build commands. The existing analyze emitted
8
+ * "Makefile detected" which is useless — it doesn't tell the governance
9
+ * compiler what to actually run.
10
+ *
11
+ * This module reads the task file and extracts target names that match
12
+ * well-known patterns (test, lint, build, check, ci, fmt, format, etc.)
13
+ * so those can be emitted as real gates.
14
+ */
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { safeRead } = require('./stacks');
19
+
20
+ const GATE_TARGET_NAMES = new Set([
21
+ 'test', 'tests', 'spec', 'check', 'ci',
22
+ 'lint', 'format', 'fmt', 'style',
23
+ 'build', 'compile',
24
+ 'typecheck', 'type-check', 'types',
25
+ 'verify', 'validate',
26
+ ]);
27
+
28
+ function mineTaskTargets(dir) {
29
+ const result = { make: [], task: [], just: [] };
30
+
31
+ const makefile = path.join(dir, 'Makefile');
32
+ if (fs.existsSync(makefile)) {
33
+ result.make = extractMakeTargets(safeRead(makefile));
34
+ }
35
+
36
+ for (const taskFile of ['Taskfile.yml', 'Taskfile.yaml']) {
37
+ const full = path.join(dir, taskFile);
38
+ if (fs.existsSync(full)) {
39
+ result.task = extractTaskfileTargets(safeRead(full));
40
+ break;
41
+ }
42
+ }
43
+
44
+ const justfile = path.join(dir, 'justfile');
45
+ if (fs.existsSync(justfile)) {
46
+ result.just = extractJustfileTargets(safeRead(justfile));
47
+ }
48
+
49
+ return result;
50
+ }
51
+
52
+ /**
53
+ * Extract target names from a Makefile. Looks for:
54
+ * - .PHONY lines listing targets
55
+ * - Lines matching `name: deps` at column 0 (not prefixed by tabs)
56
+ */
57
+ function extractMakeTargets(content) {
58
+ const targets = new Set();
59
+
60
+ // .PHONY lines
61
+ for (const match of content.matchAll(/^\.PHONY\s*:\s*(.+)$/gm)) {
62
+ for (const name of match[1].split(/\s+/)) {
63
+ const clean = name.trim();
64
+ if (clean && !clean.includes('=') && GATE_TARGET_NAMES.has(clean)) {
65
+ targets.add(clean);
66
+ }
67
+ }
68
+ }
69
+
70
+ // Target definitions at column 0
71
+ for (const line of content.split(/\r?\n/)) {
72
+ // Skip comments and lines with leading whitespace (recipe bodies)
73
+ if (line.startsWith('#') || line.startsWith('\t') || line.startsWith(' ')) continue;
74
+ const match = line.match(/^([A-Za-z0-9_.-]+)\s*:(?!\s*=)/);
75
+ if (match && GATE_TARGET_NAMES.has(match[1])) {
76
+ targets.add(match[1]);
77
+ }
78
+ }
79
+
80
+ return [...targets];
81
+ }
82
+
83
+ /**
84
+ * Extract task names from Taskfile.yml. Looks for:
85
+ * tasks:
86
+ * test:
87
+ * cmds: [...]
88
+ * lint:
89
+ * ...
90
+ */
91
+ function extractTaskfileTargets(content) {
92
+ const targets = new Set();
93
+ const lines = content.split(/\r?\n/);
94
+ let inTasks = false;
95
+ let tasksIndent = -1;
96
+
97
+ for (const line of lines) {
98
+ if (/^tasks\s*:/.test(line)) {
99
+ inTasks = true;
100
+ tasksIndent = (line.match(/^(\s*)/) || ['', ''])[1].length;
101
+ continue;
102
+ }
103
+ if (!inTasks) continue;
104
+
105
+ const indentMatch = line.match(/^(\s*)/);
106
+ const indent = indentMatch[1].length;
107
+
108
+ // Leaving the tasks block (back to root-level key)
109
+ if (line.trim() !== '' && indent <= tasksIndent) {
110
+ inTasks = false;
111
+ continue;
112
+ }
113
+
114
+ // Task name: direct children of tasks
115
+ const taskMatch = line.match(/^\s+([A-Za-z0-9_:-]+)\s*:\s*(?:#.*)?$/);
116
+ if (taskMatch && indent === tasksIndent + 2) {
117
+ const name = taskMatch[1];
118
+ if (GATE_TARGET_NAMES.has(name)) targets.add(name);
119
+ }
120
+ }
121
+
122
+ return [...targets];
123
+ }
124
+
125
+ /**
126
+ * Extract recipe names from a justfile. Recipes look like:
127
+ * test:
128
+ * cargo test
129
+ * lint:
130
+ * cargo clippy
131
+ */
132
+ function extractJustfileTargets(content) {
133
+ const targets = new Set();
134
+
135
+ for (const line of content.split(/\r?\n/)) {
136
+ if (line.startsWith('#') || line.startsWith(' ') || line.startsWith('\t')) continue;
137
+ const match = line.match(/^([A-Za-z0-9_-]+)(?:\s+[A-Za-z0-9_-]+)*\s*:(?!\s*=)/);
138
+ if (match && GATE_TARGET_NAMES.has(match[1])) {
139
+ targets.add(match[1]);
140
+ }
141
+ }
142
+
143
+ return [...targets];
144
+ }
145
+
146
+ module.exports = { mineTaskTargets, extractMakeTargets, extractTaskfileTargets, extractJustfileTargets };