@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.
- package/README.md +2 -1
- package/package.json +1 -1
- package/src/analyze/ci-extractors.js +317 -0
- package/src/analyze/doc-mining.js +142 -0
- package/src/analyze/gates.js +417 -0
- package/src/analyze/normalize.js +146 -0
- package/src/analyze/stacks.js +453 -0
- package/src/analyze/task-runners.js +146 -0
- package/src/commands/analyze.js +158 -205
- package/src/governance/yaml-run.js +58 -2
|
@@ -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 };
|