clckernel 1.2.6

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 (71) hide show
  1. package/AGENTS.md +124 -0
  2. package/README.es.md +166 -0
  3. package/README.md +167 -0
  4. package/bin/cli.js +12 -0
  5. package/docs/Journal/001-adr-clckernel-governance.md +18 -0
  6. package/package.json +44 -0
  7. package/src/adapters/astro.js +67 -0
  8. package/src/adapters/base.js +109 -0
  9. package/src/adapters/django.js +70 -0
  10. package/src/adapters/fastapi.js +69 -0
  11. package/src/adapters/go.js +50 -0
  12. package/src/adapters/laravel.js +52 -0
  13. package/src/adapters/nextjs.js +72 -0
  14. package/src/adapters/rails.js +54 -0
  15. package/src/adapters/rust.js +51 -0
  16. package/src/catalog.js +217 -0
  17. package/src/config.js +250 -0
  18. package/src/detector.js +85 -0
  19. package/src/doctor.js +125 -0
  20. package/src/generator.js +473 -0
  21. package/src/index.js +52 -0
  22. package/src/technologies/detector.js +243 -0
  23. package/src/technologies/guards/_shared.js +66 -0
  24. package/src/ui.js +101 -0
  25. package/src/yaml.js +173 -0
  26. package/test/adapters/astro.test.js +102 -0
  27. package/test/adapters/base.test.js +86 -0
  28. package/test/adapters/django.test.js +95 -0
  29. package/test/adapters/fastapi.test.js +88 -0
  30. package/test/adapters/go.test.js +81 -0
  31. package/test/adapters/laravel.test.js +106 -0
  32. package/test/adapters/nextjs.test.js +113 -0
  33. package/test/adapters/rails.test.js +108 -0
  34. package/test/adapters/rust.test.js +83 -0
  35. package/test/catalog.test.js +170 -0
  36. package/test/config.test.js +512 -0
  37. package/test/detector.test.js +345 -0
  38. package/test/doctor.test.js +302 -0
  39. package/test/generator.test.js +419 -0
  40. package/test/guards_new.test.js +95 -0
  41. package/test/helpers.js +36 -0
  42. package/test/integration/cli-flow.test.js +270 -0
  43. package/test/technologies/celery_guard.test.js +185 -0
  44. package/test/technologies/detector.test.js +342 -0
  45. package/test/technologies/docker_guard.test.js +187 -0
  46. package/test/technologies/integration.test.js +123 -0
  47. package/test/technologies/postgres_guard.test.js +87 -0
  48. package/test/technologies/redis_guard.test.js +128 -0
  49. package/tools/audit.js +219 -0
  50. package/tools/audit.py +273 -0
  51. package/tools/celery_guard.py +209 -0
  52. package/tools/check_a11y.js +109 -0
  53. package/tools/check_api_contracts.js +139 -0
  54. package/tools/check_architecture.js +139 -0
  55. package/tools/check_architecture.py +264 -0
  56. package/tools/check_custom.js +163 -0
  57. package/tools/check_custom.py +395 -0
  58. package/tools/check_db_efficiency.py +190 -0
  59. package/tools/check_migrations.py +223 -0
  60. package/tools/check_performance.js +142 -0
  61. package/tools/check_responsive.js +131 -0
  62. package/tools/check_scope.py +180 -0
  63. package/tools/check_seo.js +139 -0
  64. package/tools/check_storybook.js +108 -0
  65. package/tools/check_ui_reuse.js +135 -0
  66. package/tools/docker_guard.py +210 -0
  67. package/tools/postgres_guard.py +192 -0
  68. package/tools/redis_guard.py +197 -0
  69. package/tools/scan_secrets.js +109 -0
  70. package/tools/scan_secrets.py +153 -0
  71. package/tools/verify_tdd.py +212 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * CLC Forge — TechnologyDetector
3
+ * Detects Phase-1 technologies (Celery, Redis, PostgreSQL, Docker) via file signatures.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * @typedef {'celery' | 'redis' | 'postgresql' | 'docker'} Technology
11
+ */
12
+
13
+ /**
14
+ * TechnologyDetector scans a project root for Phase-1 technology signatures.
15
+ */
16
+ class TechnologyDetector {
17
+ /**
18
+ * Detect which Phase-1 technologies are present in targetDir.
19
+ * @param {string} targetDir - Project root path
20
+ * @returns {Technology[]} Array of detected technology identifiers
21
+ */
22
+ detect(targetDir) {
23
+ /** @type {Technology[]} */
24
+ const detected = [];
25
+
26
+ try {
27
+ if (!fs.existsSync(targetDir)) {
28
+ return [];
29
+ }
30
+
31
+ // Celery detection
32
+ if (this._detectCelery(targetDir)) {
33
+ detected.push('celery');
34
+ }
35
+
36
+ // Redis detection
37
+ if (this._detectRedis(targetDir)) {
38
+ detected.push('redis');
39
+ }
40
+
41
+ // PostgreSQL detection
42
+ if (this._detectPostgresql(targetDir)) {
43
+ detected.push('postgresql');
44
+ }
45
+
46
+ // Docker detection
47
+ if (this._detectDocker(targetDir)) {
48
+ detected.push('docker');
49
+ }
50
+ } catch (err) {
51
+ console.warn(`[TechnologyDetector] Error during detection: ${err.message}`);
52
+ }
53
+
54
+ return detected;
55
+ }
56
+
57
+ /**
58
+ * @param {string} targetDir
59
+ * @returns {boolean}
60
+ */
61
+ _detectCelery(targetDir) {
62
+ // Check requirements.txt
63
+ if (this._matchInFile(targetDir, 'requirements.txt', 'celery')) {
64
+ return true;
65
+ }
66
+
67
+ // Check pyproject.toml
68
+ if (this._matchInFile(targetDir, 'pyproject.toml', 'celery')) {
69
+ return true;
70
+ }
71
+
72
+ // Check celery.py or celeryconfig.py
73
+ const celeryFiles = ['celery.py', 'celeryconfig.py', 'celery_app.py'];
74
+ for (const file of celeryFiles) {
75
+ if (fs.existsSync(path.join(targetDir, file))) {
76
+ return true;
77
+ }
78
+ }
79
+
80
+ return false;
81
+ }
82
+
83
+ /**
84
+ * @param {string} targetDir
85
+ * @returns {boolean}
86
+ */
87
+ _detectRedis(targetDir) {
88
+ // Check requirements.txt
89
+ if (this._matchInFile(targetDir, 'requirements.txt', 'redis')) {
90
+ return true;
91
+ }
92
+
93
+ // Check pyproject.toml
94
+ if (this._matchInFile(targetDir, 'pyproject.toml', 'redis')) {
95
+ return true;
96
+ }
97
+
98
+ // Check redis.conf
99
+ if (fs.existsSync(path.join(targetDir, 'redis.conf'))) {
100
+ return true;
101
+ }
102
+
103
+ // Check for redis:// URL in .env files
104
+ if (this._containsRedisUrl(targetDir)) {
105
+ return true;
106
+ }
107
+
108
+ return false;
109
+ }
110
+
111
+ /**
112
+ * @param {string} targetDir
113
+ * @returns {boolean}
114
+ */
115
+ _detectPostgresql(targetDir) {
116
+ // Check for psycopg2, asyncpg, or psycopg2-binary
117
+ const pgPackages = ['psycopg2', 'asyncpg', 'psycopg2-binary'];
118
+
119
+ for (const pkg of pgPackages) {
120
+ if (this._matchInFile(targetDir, 'requirements.txt', pkg)) {
121
+ return true;
122
+ }
123
+ if (this._matchInFile(targetDir, 'pyproject.toml', pkg)) {
124
+ return true;
125
+ }
126
+ }
127
+
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * @param {string} targetDir
133
+ * @returns {boolean}
134
+ */
135
+ _detectDocker(targetDir) {
136
+ if (fs.existsSync(path.join(targetDir, 'Dockerfile'))) {
137
+ return true;
138
+ }
139
+ if (fs.existsSync(path.join(targetDir, 'docker-compose.yml'))) {
140
+ return true;
141
+ }
142
+ return false;
143
+ }
144
+
145
+ /**
146
+ * Check if a file exists and contains a case-insensitive substring.
147
+ * @param {string} targetDir
148
+ * @param {string} filename
149
+ * @param {string} pattern
150
+ * @returns {boolean}
151
+ */
152
+ _matchInFile(targetDir, filename, pattern) {
153
+ const filePath = path.join(targetDir, filename);
154
+ if (!fs.existsSync(filePath)) {
155
+ return false;
156
+ }
157
+ try {
158
+ const content = fs.readFileSync(filePath, 'utf-8');
159
+ return content.toLowerCase().includes(pattern.toLowerCase());
160
+ } catch (err) {
161
+ console.warn(`[TechnologyDetector] Could not read ${filePath}: ${err.message}`);
162
+ return false;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Check for redis:// URL in .env files.
168
+ * @param {string} targetDir
169
+ * @returns {boolean}
170
+ */
171
+ _containsRedisUrl(targetDir) {
172
+ try {
173
+ const entries = fs.readdirSync(targetDir);
174
+ for (const entry of entries) {
175
+ if (entry === '.env' || entry.startsWith('.env.')) {
176
+ const filePath = path.join(targetDir, entry);
177
+ const content = fs.readFileSync(filePath, 'utf-8');
178
+ if (content.toLowerCase().includes('redis://')) {
179
+ return true;
180
+ }
181
+ }
182
+ }
183
+ } catch (err) {
184
+ // Ignore read errors
185
+ }
186
+ return false;
187
+ }
188
+
189
+ /**
190
+ * Returns tool manifest entries for detected technologies.
191
+ * @param {Technology[]} technologies
192
+ * @returns {Array<{name: string, language: string, path: string, command: string, description: string}>}
193
+ */
194
+ getToolsForTech(technologies) {
195
+ /** @type {Array<{name: string, language: string, path: string, command: string, description: string}>} */
196
+ const tools = [];
197
+
198
+ for (const tech of technologies) {
199
+ switch (tech) {
200
+ case 'celery':
201
+ tools.push({
202
+ name: 'celery_guard',
203
+ language: 'py',
204
+ path: 'celery_guard.py',
205
+ command: 'python3 tools/celery_guard.py',
206
+ description: 'Celery guard: broker URL exposure, missing ignore_result on @shared_task, hardcoded result backend',
207
+ });
208
+ break;
209
+ case 'redis':
210
+ tools.push({
211
+ name: 'redis_guard',
212
+ language: 'py',
213
+ path: 'redis_guard.py',
214
+ command: 'python3 tools/redis_guard.py',
215
+ description: 'Redis guard: decode_responses without password check, missing socket_connect_timeout, hardcoded Redis URLs',
216
+ });
217
+ break;
218
+ case 'postgresql':
219
+ tools.push({
220
+ name: 'postgres_guard',
221
+ language: 'py',
222
+ path: 'postgres_guard.py',
223
+ command: 'python3 tools/postgres_guard.py',
224
+ description: 'PostgreSQL guard: raw SQL with string formatting, hstore raw usage',
225
+ });
226
+ break;
227
+ case 'docker':
228
+ tools.push({
229
+ name: 'docker_guard',
230
+ language: 'py',
231
+ path: 'docker_guard.py',
232
+ command: 'python3 tools/docker_guard.py',
233
+ description: 'Docker guard: non-root USER, ENV secrets exposure, missing HEALTHCHECK',
234
+ });
235
+ break;
236
+ }
237
+ }
238
+
239
+ return tools;
240
+ }
241
+ }
242
+
243
+ module.exports = { TechnologyDetector };
@@ -0,0 +1,66 @@
1
+ /**
2
+ * CLC Forge — Shared Guard Utilities
3
+ * Common file-reading and result-formatting helpers for technology guards.
4
+ */
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+
9
+ /**
10
+ * Read file content if it exists, otherwise return null.
11
+ * @param {string} filePath
12
+ * @returns {string | null}
13
+ */
14
+ function readFileIfExists(filePath) {
15
+ if (!fs.existsSync(filePath)) {
16
+ return null;
17
+ }
18
+ try {
19
+ return fs.readFileSync(filePath, 'utf-8');
20
+ } catch (err) {
21
+ console.warn(`[_shared] Could not read ${filePath}: ${err.message}`);
22
+ return null;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Check if a pattern matches in file content (case-insensitive).
28
+ * @param {string} pattern - Substring to search for
29
+ * @param {string | null} content - File content or null
30
+ * @returns {boolean}
31
+ */
32
+ function matchInFile(pattern, content) {
33
+ if (content === null) {
34
+ return false;
35
+ }
36
+ return content.toLowerCase().includes(pattern.toLowerCase());
37
+ }
38
+
39
+ /**
40
+ * Format a standard guard result.
41
+ * @param {string} name - Guard name
42
+ * @param {Array<object>} violations - List of violation findings
43
+ * @param {boolean} skipped - Whether guard was skipped
44
+ * @param {string} [message] - Optional human-readable summary
45
+ * @returns {object} Standard guard result
46
+ */
47
+ function formatResult(name, violations, skipped, message) {
48
+ const hasViolations = violations.length > 0;
49
+ const exitCode = hasViolations ? 1 : 0;
50
+
51
+ return {
52
+ exit_code: exitCode,
53
+ skipped: skipped,
54
+ name: name,
55
+ data: {
56
+ findings: violations,
57
+ message: message || (skipped
58
+ ? 'Guard skipped'
59
+ : hasViolations
60
+ ? `${violations.length} violation(s) found`
61
+ : 'No violations found'),
62
+ },
63
+ };
64
+ }
65
+
66
+ module.exports = { readFileIfExists, matchInFile, formatResult };
package/src/ui.js ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * CLC Kernel — Pro Terminal UI & ANSI Renderer
3
+ * Brand Identity: The AI Agent Governance & Quality Engineering Engine
4
+ */
5
+
6
+ const readline = require('readline');
7
+
8
+ // ANSI Color Palette
9
+ const colors = {
10
+ reset: '\x1b[0m',
11
+ bold: '\x1b[1m',
12
+ dim: '\x1b[2m',
13
+ italic: '\x1b[3m',
14
+ cyan: '\x1b[36m',
15
+ emerald: '\x1b[32m',
16
+ violet: '\x1b[35m',
17
+ amber: '\x1b[33m',
18
+ red: '\x1b[31m',
19
+ blue: '\x1b[34m',
20
+ gray: '\x1b[90m',
21
+ bgCyan: '\x1b[46m\x1b[30m',
22
+ bgViolet: '\x1b[45m\x1b[30m',
23
+ bgEmerald: '\x1b[42m\x1b[30m'
24
+ };
25
+
26
+ function printBanner() {
27
+ console.clear();
28
+ console.log(`${colors.cyan}
29
+ ██████╗██╗ ██████╗ ██╗ ██╗███████╗██████╗ ███╗ ██╗███████╗██╗
30
+ ██╔════╝██║ ██╔════╝ ██║ ██╔╝██╔════╝██╔══██╗████╗ ██║██╔════╝██║
31
+ ██║ ██║ ██║ █████╗█████═╝ █████╗ ██████╔╝██╔██╗ ██║█████╗ ██║
32
+ ██║ ██║ ██║ ╚════╝██╔═██╗ ██╔══╝ ██╔══██╗██║╚██╗██║██╔══╝ ██║
33
+ ╚██████╗███████╗╚██████╗ ██║ ╚██╗███████╗██║ ██║██║ ╚████║███████╗███████╗
34
+ ╚═════╝╚══════╝ ╚═════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚══════╝╚══════╝
35
+ ${colors.reset}`);
36
+ console.log(` ${colors.bgCyan} CLC KERNEL ${colors.reset} ${colors.bold}The AI Agent Governance & Quality Engineering Engine${colors.reset} ${colors.gray}v1.2.4${colors.reset}\n`);
37
+ }
38
+
39
+ function printBox(title, items) {
40
+ const width = 68;
41
+ console.log(`${colors.dim}┌${'─'.repeat(width)}┐${colors.reset}`);
42
+ console.log(`${colors.dim}│${colors.reset} ${colors.bold}${colors.cyan}${title.padEnd(width - 4)}${colors.reset} ${colors.dim}│${colors.reset}`);
43
+ console.log(`${colors.dim}├${'─'.repeat(width)}┤${colors.reset}`);
44
+
45
+ items.forEach(({ label, value, badgeColor = colors.emerald }) => {
46
+ const line = ` ${colors.gray}${label.padEnd(22)}:${colors.reset} ${badgeColor}${colors.bold}${value}${colors.reset}`;
47
+ const plainLength = `${label} : ${value}`.length;
48
+ const padding = Math.max(0, width - plainLength - 3);
49
+ console.log(`${colors.dim}│${colors.reset} ${line}${' '.repeat(padding)}${colors.dim}│${colors.reset}`);
50
+ });
51
+
52
+ console.log(`${colors.dim}└${'─'.repeat(width)}┘${colors.reset}\n`);
53
+ }
54
+
55
+ function promptOptions(detected) {
56
+ return new Promise((resolve) => {
57
+ const rl = readline.createInterface({
58
+ input: process.stdin,
59
+ output: process.stdout
60
+ });
61
+
62
+ console.log(`${colors.bold} \x1b[32m[1]\x1b[0m Confirmar e Instalar CLC Kernel ${colors.dim}(Recomendado)${colors.reset}`);
63
+ console.log(` \x1b[33m[2]\x1b[0m Forzar modo FRONTEND`);
64
+ console.log(` \x1b[33m[3]\x1b[0m Forzar modo BACKEND`);
65
+ console.log(` \x1b[90m[4] Cancelar${colors.reset}\n`);
66
+
67
+ rl.question(`${colors.cyan}${colors.bold}➔ Seleccioná una opción [1]: ${colors.reset}`, (answer) => {
68
+ const choice = answer.trim() || '1';
69
+ rl.close();
70
+
71
+ if (choice === '1') {
72
+ resolve(detected);
73
+ } else if (choice === '2') {
74
+ detected.projectType = 'frontend';
75
+ resolve(detected);
76
+ } else if (choice === '3') {
77
+ detected.projectType = 'backend';
78
+ resolve(detected);
79
+ } else {
80
+ console.log(`\n${colors.red}❌ Instalación de CLC Kernel cancelada.${colors.reset}`);
81
+ process.exit(0);
82
+ }
83
+ });
84
+ });
85
+ }
86
+
87
+ function printSuccess(targetDir, isFront, config) {
88
+ console.log(`\n${colors.bgEmerald} SUCCESS ${colors.reset} ${colors.bold}${colors.emerald}¡CLC Kernel instalado exitosamente!${colors.reset}\n`);
89
+ console.log(` 🔨 ${colors.bold}Proyecto Inicializado:${colors.reset} ${colors.cyan}${targetDir}${colors.reset}`);
90
+ console.log(` 🛡️ ${colors.bold}Salvaguardas:${colors.reset} ${isFront ? `${colors.emerald}10 Guardias Frontend (A11y, Zod, Next/Image, UI Reuse, Storybook)` : `${colors.violet}7 Guardias Backend (TDD, Architecture, Scope, Secret Scan)`}${colors.reset}`);
91
+ console.log(` 📝 ${colors.bold}Leyes & SDD:${colors.reset} AGENTS.md, sdds/, docs/Journal/ precargados`);
92
+ console.log(` 🔒 ${colors.bold}Git Hooks:${colors.reset} Configurados en ${colors.amber}${config.gitHooks === 'husky' ? '.husky/pre-commit' : '.githooks/pre-commit'}${colors.reset}\n`);
93
+ }
94
+
95
+ module.exports = {
96
+ colors,
97
+ printBanner,
98
+ printBox,
99
+ promptOptions,
100
+ printSuccess
101
+ };
package/src/yaml.js ADDED
@@ -0,0 +1,173 @@
1
+ /**
2
+ * CLC Forge — Zero-Dependency YAML Parser
3
+ * Self-contained inline YAML parser extracted from tools/check_custom.js.
4
+ * Uses only Node.js built-ins (no npm deps).
5
+ *
6
+ * Supports: scalars, block sequences, block mappings, inline flow
7
+ * sequences/mappings, comments. No anchors/aliases, no multiple documents.
8
+ *
9
+ * Stack entries: { obj, indent, _parentObj, _parentKey }
10
+ * obj: the object we are building key-value pairs into
11
+ * indent: indent level (children at same indent stay)
12
+ * _parentObj/_parentKey: where to find/create the array for sequences
13
+ *
14
+ * @module yaml
15
+ */
16
+
17
+ /**
18
+ * Detect YAML anchors (`&name`) or aliases (`*name`) on a line.
19
+ * @param {string} line - Trimmed content of a YAML line.
20
+ * @param {number} lineNo - 1-based line number for error reporting.
21
+ * @throws {Error} If anchor or alias syntax is found (unsupported).
22
+ */
23
+ function assertNoAnchorAlias(line, lineNo) {
24
+ // YAML anchors/aliases are token-boundary markers: `&name` / `*name`
25
+ // preceded by whitespace or start of line. Requiring the boundary avoids
26
+ // false positives on regex/glob content such as `pattern: 'a*b'`.
27
+ if (/(?:^|\s)[&*][A-Za-z0-9_-]/.test(line)) {
28
+ throw new Error(
29
+ `Unsupported YAML syntax at line ${lineNo}: anchors/aliases are not supported`
30
+ );
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Parse a YAML string into a JavaScript object.
36
+ * Throws on anchors/aliases (unsupported).
37
+ * @param {string} text - Raw YAML source.
38
+ * @returns {object} Parsed object tree.
39
+ */
40
+ function parseYaml(text) {
41
+ const lines = text.split('\n');
42
+ const root = {};
43
+ const stack = [{ obj: root, indent: -1, _parentObj: null, _parentKey: null }];
44
+
45
+ for (let i = 0; i < lines.length; i++) {
46
+ let line = lines[i].replace(/\s*#.*$/, '').trimEnd();
47
+ if (line.trim() === '' || line.trim().startsWith('#')) continue;
48
+
49
+ const indent = line.search(/\S/);
50
+ const trimmed = line.trim();
51
+
52
+ // New behavior: anchors/aliases are unsupported
53
+ assertNoAnchorAlias(trimmed, i + 1);
54
+
55
+ while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
56
+ stack.pop();
57
+ }
58
+ const entry = stack[stack.length - 1];
59
+
60
+ // ── Sequence item: "- rest" ──
61
+ if (trimmed.startsWith('- ')) {
62
+ const rest = trimmed.slice(2).trim();
63
+
64
+ if (rest.includes(': ')) {
65
+ // "- key: value" — mapping inside a sequence
66
+ let arr = null;
67
+ // Check if parent key already points to an array
68
+ if (entry._parentObj && entry._parentKey) {
69
+ const pk = entry._parentObj[entry._parentKey];
70
+ if (Array.isArray(pk)) {
71
+ arr = pk;
72
+ } else if (pk && typeof pk === 'object' && Object.keys(pk).length === 0) {
73
+ // Convert empty object to array
74
+ entry._parentObj[entry._parentKey] = [];
75
+ arr = entry._parentObj[entry._parentKey];
76
+ }
77
+ }
78
+ if (arr) {
79
+ const obj = {};
80
+ const colonIdx = rest.indexOf(': ');
81
+ obj[rest.slice(0, colonIdx).trim()] = parseScalar(rest.slice(colonIdx + 2).trim());
82
+ arr.push(obj);
83
+ stack.push({ obj, indent, _parentObj: arr, _parentKey: null });
84
+ }
85
+ } else if (entry._parentObj && entry._parentKey) {
86
+ // "- scalar" inside a sequence
87
+ const pk = entry._parentObj[entry._parentKey];
88
+ if (Array.isArray(pk)) {
89
+ pk.push(parseScalar(rest));
90
+ } else if (pk && typeof pk === 'object' && Object.keys(pk).length === 0) {
91
+ // Convert empty object to array (same rule as the mapping-in-sequence
92
+ // branch above) so plain block sequences like
93
+ // active_guards:
94
+ // - check_custom
95
+ // parse into arrays, not empty objects. Additive: previously valid
96
+ // YAML parses identically.
97
+ entry._parentObj[entry._parentKey] = [parseScalar(rest)];
98
+ }
99
+ }
100
+ continue;
101
+ }
102
+
103
+ // ── key: value pair ──
104
+ const kvMatch = trimmed.match(/^([^:]+):\s*(.*)$/);
105
+ if (kvMatch) {
106
+ const key = kvMatch[1].trim();
107
+ const val = kvMatch[2].trim();
108
+
109
+ if (val === '' || val === '|' || val === '>') {
110
+ // Empty value → children follow
111
+ if (!entry.obj[key]) entry.obj[key] = {};
112
+ stack.push({ obj: entry.obj[key], indent, _parentObj: entry.obj, _parentKey: key });
113
+ } else if (val.startsWith('[') && val.endsWith(']')) {
114
+ entry.obj[key] = val.slice(1, -1).split(',').map(s => parseScalar(s.trim()));
115
+ } else if (val.startsWith('{') && val.endsWith('}')) {
116
+ entry.obj[key] = parseFlowMapping(val);
117
+ } else {
118
+ entry.obj[key] = parseScalar(val);
119
+ }
120
+ continue;
121
+ }
122
+ }
123
+
124
+ return root;
125
+ }
126
+
127
+ function parseScalar(val) {
128
+ if (val === '' || val === '~' || val === 'null') return null;
129
+ if (val === 'true') return true;
130
+ if (val === 'false') return false;
131
+ if (/^-?\d+$/.test(val)) return parseInt(val, 10);
132
+ if (/^-?\d+\.\d+$/.test(val)) return parseFloat(val);
133
+ // Strip quotes and unescape common escape sequences
134
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
135
+ return val.slice(1, -1).replace(/\\(.)/g, '$1');
136
+ }
137
+ return val;
138
+ }
139
+
140
+ function parseFlowMapping(str) {
141
+ const inner = str.slice(1, -1).trim();
142
+ if (!inner) return {};
143
+ const obj = {};
144
+ const pairs = inner.split(',');
145
+ for (const pair of pairs) {
146
+ const colonIdx = pair.indexOf(':');
147
+ if (colonIdx === -1) continue;
148
+ const k = pair.slice(0, colonIdx).trim();
149
+ const v = pair.slice(colonIdx + 1).trim();
150
+ obj[k] = parseScalar(v);
151
+ }
152
+ return obj;
153
+ }
154
+
155
+ // ═══════════════════════════════════════════════════════════════════════
156
+ // Glob-to-Regex Converter (~15 lines)
157
+ // Handles: **, *, {a,b} alternation
158
+ // ═══════════════════════════════════════════════════════════════════════
159
+
160
+ function globToRegex(glob) {
161
+ let re = glob
162
+ .replace(/\./g, '\\.') // escape dots
163
+ .replace(/\*\*\//g, '{{GLOBSTAR_SLASH}}') // protect **/ (zero or more segments + separator)
164
+ .replace(/\*\*/g, '{{GLOBSTAR}}') // protect trailing **
165
+ .replace(/\*/g, '[^/]*') // single * = no slashes
166
+ .replace(/\{\{GLOBSTAR_SLASH\}\}/g, '(?:.+/)?') // **/ = optional path segments
167
+ .replace(/\{\{GLOBSTAR\}\}/g, '.*') // ** = anything
168
+ .replace(/\{([^}]+)\}/g, (_, alt) =>
169
+ `(${alt.split(',').join('|')})`); // {a,b} alternation
170
+ return new RegExp('^' + re + '$');
171
+ }
172
+
173
+ module.exports = { parseYaml, globToRegex };
@@ -0,0 +1,102 @@
1
+ const { describe, it } = require('node:test');
2
+ const assert = require('node:assert');
3
+ const { createTmpProject, teardown } = require('../helpers');
4
+
5
+ describe('AstroAdapter', () => {
6
+ const AstroAdapter = require('../../src/adapters/astro');
7
+
8
+ describe('constructor', () => {
9
+ it('creates adapter with correct name and projectType', () => {
10
+ const adapter = new AstroAdapter();
11
+ assert.strictEqual(adapter.name, 'Astro Framework');
12
+ assert.strictEqual(adapter.projectType, 'frontend');
13
+ assert.strictEqual(adapter.testRunner, 'Vitest / Playwright');
14
+ });
15
+ });
16
+
17
+ describe('detect()', () => {
18
+ it('returns true when package.json has astro dependency', () => {
19
+ const dir = createTmpProject({
20
+ 'package.json': JSON.stringify({ dependencies: { astro: '4.0.0' } }),
21
+ });
22
+ try {
23
+ const adapter = new AstroAdapter();
24
+ assert.strictEqual(adapter.detect(dir), true);
25
+ } finally {
26
+ teardown(dir);
27
+ }
28
+ });
29
+
30
+ it('returns true when astro.config.mjs exists', () => {
31
+ const dir = createTmpProject({
32
+ 'astro.config.mjs': 'export default {}',
33
+ 'package.json': JSON.stringify({ dependencies: {} }),
34
+ });
35
+ try {
36
+ const adapter = new AstroAdapter();
37
+ assert.strictEqual(adapter.detect(dir), true);
38
+ } finally {
39
+ teardown(dir);
40
+ }
41
+ });
42
+
43
+ it('returns true when astro.config.js exists', () => {
44
+ const dir = createTmpProject({
45
+ 'astro.config.js': 'module.exports = {}',
46
+ 'package.json': JSON.stringify({ dependencies: {} }),
47
+ });
48
+ try {
49
+ const adapter = new AstroAdapter();
50
+ assert.strictEqual(adapter.detect(dir), true);
51
+ } finally {
52
+ teardown(dir);
53
+ }
54
+ });
55
+
56
+ it('returns false when no package.json exists', () => {
57
+ const dir = createTmpProject({});
58
+ try {
59
+ const adapter = new AstroAdapter();
60
+ assert.strictEqual(adapter.detect(dir), false);
61
+ } finally {
62
+ teardown(dir);
63
+ }
64
+ });
65
+
66
+ it('returns false when package.json has no astro and no config file', () => {
67
+ const dir = createTmpProject({
68
+ 'package.json': JSON.stringify({ dependencies: { react: '18.0.0' } }),
69
+ });
70
+ try {
71
+ const adapter = new AstroAdapter();
72
+ assert.strictEqual(adapter.detect(dir), false);
73
+ } finally {
74
+ teardown(dir);
75
+ }
76
+ });
77
+ });
78
+
79
+ describe('getTools()', () => {
80
+ it('returns array of tool objects with required fields', () => {
81
+ const adapter = new AstroAdapter();
82
+ const tools = adapter.getTools();
83
+ assert.ok(Array.isArray(tools));
84
+ assert.ok(tools.length > 0);
85
+ for (const tool of tools) {
86
+ assert.ok(tool.name);
87
+ assert.ok(tool.language);
88
+ assert.ok(typeof tool.command === 'string');
89
+ assert.ok(tool.description);
90
+ }
91
+ });
92
+ });
93
+
94
+ describe('getSafeguards()', () => {
95
+ it('returns non-empty array of strings', () => {
96
+ const adapter = new AstroAdapter();
97
+ const guards = adapter.getSafeguards();
98
+ assert.ok(Array.isArray(guards));
99
+ assert.ok(guards.length >= 5);
100
+ });
101
+ });
102
+ });