mustard-claude 3.1.4 → 3.1.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mustard-claude",
3
- "version": "3.1.4",
3
+ "version": "3.1.6",
4
4
  "description": "Framework-agnostic CLI for Claude Code project setup",
5
5
  "type": "module",
6
6
  "bin": {
@@ -140,9 +140,13 @@ Never search in:
140
140
  - `node_modules/`, `.next/`, `bin/`, `obj/`, `dist/`, `migrations/`
141
141
  ```
142
142
 
143
- **`.claude/entity-registry.json`** — empty skeleton:
143
+ **`.claude/entity-registry.json`** — generate via registry scanner:
144
+ ```bash
145
+ node .claude/scripts/sync-registry.js --force
146
+ ```
147
+ If `sync-registry.js` fails or is not available, create empty skeleton:
144
148
  ```json
145
- { "_patterns": {}, "_enums": {}, "e": {} }
149
+ { "_meta": { "version": "4.0" }, "_patterns": {}, "_enums": {}, "e": {} }
146
150
  ```
147
151
 
148
152
  **`{subproject}/CLAUDE.md`** — per subproject (skip if exists):
@@ -310,6 +314,31 @@ See `scan-format.md` §10 for decomposition rules, SKILL.md format, and descript
310
314
  Skills are generated ONLY in `{subproject}/.claude/skills/{skill-name}/` (NOT in root `.claude/skills/`).
311
315
  Mark all with `<!-- mustard:generated -->`. Overwrite on next scan.
312
316
 
317
+ ### 4.7. Generate Pattern Skills from Registry (OODA: Observe → Act)
318
+
319
+ After agent-generated skills (4.6), run the registry-based skill generator to create structural pattern skills from `_patterns`:
320
+
321
+ ```bash
322
+ node .claude/scripts/sync-registry.js --force
323
+ node .claude/scripts/skill-generator.js --force
324
+ ```
325
+
326
+ This generates skills that the agents in Step 3 may have missed — particularly:
327
+ - `{role}-entity-creation` — entity folder, base class, interfaces, namespace
328
+ - `{role}-enum-placement` — enum folder, decorators, NEVER inline in entities
329
+ - `{role}-route-conventions` — route naming, auth pattern, CRUD standard
330
+ - `{role}-service-pattern` — interface-first, base interface, DI
331
+ - `{role}-repository-pattern` — base class, interface, DI
332
+ - `{role}-dto-conventions` — folder, naming, validation pattern
333
+ - `{role}-module-registration` — DI registration, route wiring
334
+
335
+ These skills are derived from **detected patterns** (not hardcoded). They complement agent-generated skills by covering structural conventions that agents may not explicitly document.
336
+
337
+ **Skip conditions:**
338
+ - `entity-registry.json` version < 4.0 → skip (registry not populated)
339
+ - `skill-generator.js` not present → skip
340
+ - Pattern skill already exists and was NOT generated by mustard → skip (user-edited)
341
+
313
342
  ### 4. Update CLAUDE.md files
314
343
 
315
344
  After agents complete:
@@ -358,8 +387,9 @@ Include findings in scan output under a `## Security` section:
358
387
  5. Old files backed up in `_backup/`
359
388
  6. Each subproject's CLAUDE.md has `## Scan References`
360
389
  7. Root CLAUDE.md has `## Project Structure` with all subprojects
361
- 8. `.claude/entity-registry.json` exists
362
- 9. Each generated skill has valid YAML frontmatter (name + description)
390
+ 8. `.claude/entity-registry.json` exists and is v4.0
391
+ 9. Pattern skills generated from registry (entity-creation, enum-placement, route-conventions, etc.)
392
+ 10. Each generated skill has valid YAML frontmatter (name + description)
363
393
  10. Each skill's description is "pushy" — includes casual trigger phrases
364
394
  11. If security scan ran: findings summarized in `## Security` section of output
365
395
 
@@ -0,0 +1,126 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * file-utils.js
5
+ *
6
+ * Single Responsibility: file collection and path helpers shared across scanners.
7
+ * No scanning logic, no schema building — only filesystem utilities.
8
+ *
9
+ * Usage:
10
+ * const { collectFiles, relativePath, readFileSafe } = require('./registry/file-utils');
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Constants
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const DEFAULT_IGNORE = [
21
+ 'node_modules', 'bin', 'obj', 'dist', '.next',
22
+ '__pycache__', '.venv', 'venv', 'target', 'build',
23
+ '.git', 'migrations', 'Migrations',
24
+ ];
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // collectFiles
28
+ // ---------------------------------------------------------------------------
29
+
30
+ /**
31
+ * Recursively collect all files with the given extension under a directory.
32
+ * Skips DEFAULT_IGNORE directories, dot-directories, and any extra ignore dirs.
33
+ *
34
+ * @param {string} dir - root directory to walk
35
+ * @param {string} extension - file extension including dot, e.g. '.cs', '.ts'
36
+ * @param {string[]} [ignore] - additional directory names to skip
37
+ * @returns {string[]} - absolute file paths
38
+ */
39
+ function collectFiles(dir, extension, ignore = []) {
40
+ const allIgnore = new Set([...DEFAULT_IGNORE, ...ignore]);
41
+ const results = [];
42
+
43
+ function walk(currentDir) {
44
+ try {
45
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
46
+ for (const entry of entries) {
47
+ if (entry.isDirectory()) {
48
+ if (allIgnore.has(entry.name) || entry.name.startsWith('.')) continue;
49
+ walk(path.join(currentDir, entry.name));
50
+ } else if (entry.name.endsWith(extension)) {
51
+ results.push(path.join(currentDir, entry.name));
52
+ }
53
+ }
54
+ } catch { /* ignore permission errors */ }
55
+ }
56
+
57
+ walk(dir);
58
+ return results;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // relativePath
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Get a relative path from a base directory, normalised with forward slashes.
67
+ *
68
+ * @param {string} base - absolute base directory
69
+ * @param {string} filePath - absolute file path
70
+ * @returns {string} - relative path with forward slashes
71
+ */
72
+ function relativePath(base, filePath) {
73
+ return path.relative(base, filePath).replace(/\\/g, '/');
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // readFileSafe
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Read a file as UTF-8 string. Returns null on any error.
82
+ *
83
+ * @param {string} filePath - absolute path to file
84
+ * @returns {string|null}
85
+ */
86
+ function readFileSafe(filePath) {
87
+ try {
88
+ return fs.readFileSync(filePath, 'utf-8');
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // inferCommonFolder
96
+ // ---------------------------------------------------------------------------
97
+
98
+ /**
99
+ * Detect the most common parent folder from a list of relative file paths.
100
+ * Useful for pattern inference (e.g., "most entities live in Domain/Entities/").
101
+ *
102
+ * @param {string[]} filePaths - relative paths (forward slashes)
103
+ * @returns {string|null} - most common parent folder with trailing slash, or null
104
+ */
105
+ function inferCommonFolder(filePaths) {
106
+ if (!filePaths.length) return null;
107
+
108
+ const counts = new Map();
109
+ for (const fp of filePaths) {
110
+ const dir = path.dirname(fp).replace(/\\/g, '/');
111
+ counts.set(dir, (counts.get(dir) || 0) + 1);
112
+ }
113
+
114
+ let maxDir = null;
115
+ let maxCount = 0;
116
+ for (const [dir, count] of counts) {
117
+ if (count > maxCount) {
118
+ maxDir = dir;
119
+ maxCount = count;
120
+ }
121
+ }
122
+
123
+ return maxDir ? maxDir + '/' : null;
124
+ }
125
+
126
+ module.exports = { collectFiles, relativePath, readFileSafe, inferCommonFolder, DEFAULT_IGNORE };
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * pluralize.js
5
+ *
6
+ * Single Responsibility: English pluralization helpers for converting
7
+ * snake_case plural database table names to PascalCase singular entity names.
8
+ *
9
+ * Previously inlined in sync-registry.js. Extracted here so scanners can
10
+ * reuse without depending on the top-level CLI script.
11
+ *
12
+ * Usage:
13
+ * const { snakeToPascalSingular, singularize, snakeToPascal } = require('./registry/pluralize');
14
+ */
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // IRREGULAR_PLURALS
18
+ // ---------------------------------------------------------------------------
19
+
20
+ /**
21
+ * Lookup for common irregular plurals.
22
+ * Key: lowercase plural form (snake_case table name or single word)
23
+ * Value: PascalCase singular entity name
24
+ */
25
+ const IRREGULAR_PLURALS = {
26
+ people: 'Person',
27
+ children: 'Child',
28
+ men: 'Man',
29
+ women: 'Woman',
30
+ mice: 'Mouse',
31
+ geese: 'Goose',
32
+ teeth: 'Tooth',
33
+ feet: 'Foot',
34
+ data: 'Datum',
35
+ indices: 'Index',
36
+ matrices: 'Matrix',
37
+ vertices: 'Vertex',
38
+ analyses: 'Analysis',
39
+ bases: 'Base',
40
+ crises: 'Crisis',
41
+ diagnoses: 'Diagnosis',
42
+ hypotheses: 'Hypothesis',
43
+ parentheses: 'Parenthesis',
44
+ theses: 'Thesis',
45
+ criteria: 'Criterion',
46
+ phenomena: 'Phenomenon',
47
+ media: 'Medium',
48
+ statuses: 'Status',
49
+ addresses: 'Address',
50
+ };
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // singularize
54
+ // ---------------------------------------------------------------------------
55
+
56
+ /**
57
+ * Singularize a single lowercase English word using simple heuristics.
58
+ *
59
+ * Examples:
60
+ * companies -> company
61
+ * addresses -> address
62
+ * boxes -> box
63
+ * contracts -> contract
64
+ * queue -> queue (already singular)
65
+ *
66
+ * @param {string} word - lowercase word
67
+ * @returns {string} - singular form (lowercase)
68
+ */
69
+ function singularize(word) {
70
+ // Check irregular
71
+ if (IRREGULAR_PLURALS[word]) {
72
+ return IRREGULAR_PLURALS[word].toLowerCase();
73
+ }
74
+
75
+ // Already-singular indicators
76
+ if (
77
+ word.endsWith('ss') ||
78
+ word.endsWith('us') ||
79
+ word.endsWith('is') ||
80
+ word === 'queue'
81
+ ) {
82
+ return word;
83
+ }
84
+
85
+ // -ies -> -y (companies -> company, categories -> category)
86
+ if (word.endsWith('ies')) {
87
+ return word.slice(0, -3) + 'y';
88
+ }
89
+
90
+ // -sses -> -ss (addresses -> address)
91
+ if (word.endsWith('sses')) {
92
+ return word.slice(0, -2);
93
+ }
94
+
95
+ // -es after sh, ch, x, z -> remove -es (boxes -> box, churches -> church)
96
+ if (
97
+ word.endsWith('shes') ||
98
+ word.endsWith('ches') ||
99
+ word.endsWith('xes') ||
100
+ word.endsWith('zes')
101
+ ) {
102
+ return word.slice(0, -2);
103
+ }
104
+
105
+ // Generic -s removal (contracts -> contract)
106
+ if (word.endsWith('s') && !word.endsWith('ss')) {
107
+ return word.slice(0, -1);
108
+ }
109
+
110
+ return word;
111
+ }
112
+
113
+ // ---------------------------------------------------------------------------
114
+ // snakeToPascalSingular
115
+ // ---------------------------------------------------------------------------
116
+
117
+ /**
118
+ * Convert a snake_case plural table name to PascalCase singular entity name.
119
+ *
120
+ * Examples:
121
+ * contracts -> Contract
122
+ * partner_types -> PartnerType
123
+ * people -> Person
124
+ * companies -> Company
125
+ * product_categories -> ProductCategory
126
+ * email_queue -> EmailQueue (already singular)
127
+ *
128
+ * @param {string} snakePlural - snake_case plural name (e.g., 'partner_types')
129
+ * @returns {string} - PascalCase singular entity name
130
+ */
131
+ function snakeToPascalSingular(snakePlural) {
132
+ // Check irregular lookup for the full compound name
133
+ if (IRREGULAR_PLURALS[snakePlural]) {
134
+ return IRREGULAR_PLURALS[snakePlural];
135
+ }
136
+
137
+ // Split by underscore; singularize only the LAST part (the noun)
138
+ const parts = snakePlural.split('_');
139
+ const result = parts.map((part, idx) => {
140
+ const word = idx === parts.length - 1 ? singularize(part) : part;
141
+ return word.charAt(0).toUpperCase() + word.slice(1);
142
+ });
143
+
144
+ return result.join('');
145
+ }
146
+
147
+ // ---------------------------------------------------------------------------
148
+ // snakeToPascal
149
+ // ---------------------------------------------------------------------------
150
+
151
+ /**
152
+ * Convert a snake_case name to PascalCase (no singularization).
153
+ *
154
+ * Example:
155
+ * contract_status -> ContractStatus
156
+ *
157
+ * @param {string} snakeName
158
+ * @returns {string}
159
+ */
160
+ function snakeToPascal(snakeName) {
161
+ return snakeName
162
+ .split('_')
163
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
164
+ .join('');
165
+ }
166
+
167
+ module.exports = { snakeToPascalSingular, singularize, snakeToPascal, IRREGULAR_PLURALS };
@@ -0,0 +1,188 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scanner-contract.js
5
+ *
6
+ * Base contract for all stack scanners (Interface Segregation + Liskov Substitution).
7
+ * Every scanner extends ScannerContract and implements detect() and optionally
8
+ * the scan* methods. Each method has a single responsibility.
9
+ *
10
+ * Usage:
11
+ * const { ScannerContract } = require('./scanner-contract');
12
+ * class MyScanner extends ScannerContract { ... }
13
+ */
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // JSDoc typedefs
17
+ // ---------------------------------------------------------------------------
18
+
19
+ /**
20
+ * @typedef {Object} EntityInfo
21
+ * @property {string} file - relative path from subproject root
22
+ * @property {string} [namespace]
23
+ * @property {string} [baseClass]
24
+ * @property {string[]} [interfaces]
25
+ * @property {string[]} [decorators] - class-level decorators/attributes
26
+ * @property {string[]} [refs] - referenced entities (FK/navigation)
27
+ * @property {string[]} [sub] - child/collection entities
28
+ * @property {string[]} [enums] - enum types used
29
+ * @property {string[]} [properties] - key property names with types
30
+ */
31
+
32
+ /**
33
+ * @typedef {Object} EnumInfo
34
+ * @property {string[]} values
35
+ * @property {string} file - relative path
36
+ * @property {string} [namespace]
37
+ * @property {string[]} [decorators] - enum-level decorators
38
+ * @property {string[]} [valueDecorators] - decorators found on values (e.g., Description, Display)
39
+ * @property {string} [valueConvention] - UPPER_CASE | PascalCase | camelCase
40
+ */
41
+
42
+ /**
43
+ * @typedef {Object} InterfaceInfo
44
+ * @property {string} file
45
+ * @property {string} [namespace]
46
+ * @property {string[]} [methods]
47
+ * @property {string[]} [extends] - parent interfaces
48
+ * @property {string[]} [implementedBy] - known implementing classes
49
+ */
50
+
51
+ /**
52
+ * @typedef {Object} RouteInfo
53
+ * @property {string} file
54
+ * @property {string} prefix - route group prefix (e.g., /contracts)
55
+ * @property {Object[]} endpoints - { method, path, name, auth }
56
+ */
57
+
58
+ /**
59
+ * @typedef {Object} DtoInfo
60
+ * @property {string} file
61
+ * @property {string} [namespace]
62
+ * @property {string} [entity] - linked entity name
63
+ * @property {string} [validationPattern] - FluentValidation, Zod, class-validator, etc.
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} ServiceInfo
68
+ * @property {string} file
69
+ * @property {string} [interface] - interface it implements
70
+ * @property {string} [entity] - linked entity name
71
+ * @property {string[]} [dependencies] - injected interfaces
72
+ */
73
+
74
+ /**
75
+ * @typedef {Object} RepoInfo
76
+ * @property {string} file
77
+ * @property {string} [interface]
78
+ * @property {string} [entity]
79
+ * @property {string} [baseClass]
80
+ */
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // ScannerContract
84
+ // ---------------------------------------------------------------------------
85
+
86
+ /**
87
+ * Base contract for stack scanners (Interface Segregation + Liskov Substitution).
88
+ * Every scanner implements detect() and scan(). Each method has a single responsibility.
89
+ */
90
+ class ScannerContract {
91
+ /**
92
+ * @param {string} subprojectPath - absolute path to subproject root
93
+ * @param {Object} subprojectMeta - metadata from sync-detect.js output
94
+ */
95
+ constructor(subprojectPath, subprojectMeta) {
96
+ if (new.target === ScannerContract) {
97
+ throw new Error('ScannerContract is abstract — extend it');
98
+ }
99
+ this.subprojectPath = subprojectPath;
100
+ this.meta = subprojectMeta;
101
+ }
102
+
103
+ /**
104
+ * Detect if this scanner applies to the subproject.
105
+ * @returns {boolean}
106
+ */
107
+ detect() { throw new Error('detect() not implemented'); }
108
+
109
+ /**
110
+ * Detect the architecture pattern used in the project.
111
+ * @returns {string} - e.g., 'solid', 'layered', 'minimal', 'mvvm', 'mvc'
112
+ */
113
+ detectArchitecture() { return 'unknown'; }
114
+
115
+ /**
116
+ * Scan entities (models, domain objects).
117
+ * @returns {Map<string, EntityInfo>}
118
+ */
119
+ scanEntities() { return new Map(); }
120
+
121
+ /**
122
+ * Scan enums/value types.
123
+ * @returns {Map<string, EnumInfo>}
124
+ */
125
+ scanEnums() { return new Map(); }
126
+
127
+ /**
128
+ * Scan interfaces/contracts.
129
+ * @returns {Map<string, InterfaceInfo>}
130
+ */
131
+ scanInterfaces() { return new Map(); }
132
+
133
+ /**
134
+ * Scan routes/endpoints.
135
+ * @returns {Map<string, RouteInfo>}
136
+ */
137
+ scanRoutes() { return new Map(); }
138
+
139
+ /**
140
+ * Scan DTOs/schemas/view models.
141
+ * @returns {Map<string, DtoInfo>}
142
+ */
143
+ scanDtos() { return new Map(); }
144
+
145
+ /**
146
+ * Scan services.
147
+ * @returns {Map<string, ServiceInfo>}
148
+ */
149
+ scanServices() { return new Map(); }
150
+
151
+ /**
152
+ * Scan repositories.
153
+ * @returns {Map<string, RepoInfo>}
154
+ */
155
+ scanRepositories() { return new Map(); }
156
+
157
+ /**
158
+ * Infer structural patterns from all scanned data.
159
+ * Called AFTER all scan methods. Receives the results.
160
+ * @param {{ entities: Map, enums: Map, interfaces: Map, routes: Map, dtos: Map, services: Map, repositories: Map }} scanResults
161
+ * @returns {Object} - patterns object for _patterns.{stack}
162
+ */
163
+ inferPatterns(scanResults) { return {}; } // eslint-disable-line no-unused-vars
164
+
165
+ /**
166
+ * Run the full scan pipeline.
167
+ * Calls all scan* methods in order, then inferPatterns, then returns the combined result.
168
+ * @returns {{ entities: Map, enums: Map, interfaces: Map, routes: Map, dtos: Map, services: Map, repositories: Map, patterns: Object }}
169
+ */
170
+ scan() {
171
+ const entities = this.scanEntities();
172
+ const enums = this.scanEnums();
173
+ const interfaces = this.scanInterfaces();
174
+ const routes = this.scanRoutes();
175
+ const dtos = this.scanDtos();
176
+ const services = this.scanServices();
177
+ const repositories = this.scanRepositories();
178
+ const architecture = this.detectArchitecture();
179
+
180
+ const scanResults = { entities, enums, interfaces, routes, dtos, services, repositories };
181
+ const patterns = this.inferPatterns(scanResults);
182
+ patterns.architecture = architecture;
183
+
184
+ return { ...scanResults, patterns };
185
+ }
186
+ }
187
+
188
+ module.exports = { ScannerContract };
@@ -0,0 +1,120 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scanner-loader.js
5
+ *
6
+ * Dependency Inversion: sync-registry.js depends on this loader, not on concrete scanners.
7
+ * Open/Closed: adding a new stack scanner = dropping a new file in scanners/, zero other changes.
8
+ *
9
+ * Usage:
10
+ * const { loadScanner, detectStack } = require('./registry/scanner-loader');
11
+ * const scanner = loadScanner(subprojectPath, subprojectMeta);
12
+ * if (scanner) { const result = scanner.scan(); }
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+
18
+ const SCANNERS_DIR = path.join(__dirname, 'scanners');
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Stack detection signals
22
+ // Each key is the stack ID that maps to a scanner file: {stackId}-scanner.js
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const STACK_SIGNALS = {
26
+ dotnet: { files: ['*.csproj', '*.sln'], dirs: [] },
27
+ typescript: { files: ['package.json', 'tsconfig.json'], dirs: [] },
28
+ dart: { files: ['pubspec.yaml'], dirs: ['lib'] },
29
+ php: { files: ['composer.json', 'artisan'], dirs: [] },
30
+ python: { files: ['pyproject.toml', 'setup.py', 'requirements.txt', 'manage.py'], dirs: [] },
31
+ java: { files: ['pom.xml', 'build.gradle', 'build.gradle.kts'], dirs: [] },
32
+ go: { files: ['go.mod'], dirs: [] },
33
+ rust: { files: ['Cargo.toml'], dirs: [] },
34
+ };
35
+
36
+ // ---------------------------------------------------------------------------
37
+ // detectStack
38
+ // ---------------------------------------------------------------------------
39
+
40
+ /**
41
+ * Detect which stack a subproject uses via file-presence heuristics.
42
+ * Iterates STACK_SIGNALS in definition order (most specific first).
43
+ *
44
+ * @param {string} subprojectPath - absolute path to subproject root
45
+ * @returns {string|null} - stack ID (e.g., 'dotnet') or null if unrecognised
46
+ */
47
+ function detectStack(subprojectPath) {
48
+ for (const [stackId, signals] of Object.entries(STACK_SIGNALS)) {
49
+ for (const pattern of signals.files) {
50
+ // Handle glob-like patterns (*.ext) — match any file with that extension
51
+ if (pattern.startsWith('*')) {
52
+ const ext = pattern.slice(1); // e.g., '.csproj'
53
+ try {
54
+ const entries = fs.readdirSync(subprojectPath);
55
+ if (entries.some(e => e.endsWith(ext))) return stackId;
56
+ } catch { /* ignore unreadable dirs */ }
57
+ } else {
58
+ if (fs.existsSync(path.join(subprojectPath, pattern))) return stackId;
59
+ }
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+
65
+ // ---------------------------------------------------------------------------
66
+ // loadScanner
67
+ // ---------------------------------------------------------------------------
68
+
69
+ /**
70
+ * Load the appropriate scanner class for a subproject and return an instance,
71
+ * or null if no scanner is available or detect() returns false.
72
+ *
73
+ * Resolution order:
74
+ * 1. Use subprojectMeta.stack if provided
75
+ * 2. Fall back to detectStack()
76
+ * 3. Look for scanners/{stackId}-scanner.js
77
+ * 4. Instantiate and call detect() — return null if detect() is false
78
+ *
79
+ * @param {string} subprojectPath - absolute path to subproject root
80
+ * @param {Object} subprojectMeta - metadata from sync-detect.js output
81
+ * @returns {import('./scanner-contract').ScannerContract|null}
82
+ */
83
+ function loadScanner(subprojectPath, subprojectMeta) {
84
+ const stackId = subprojectMeta.stack || detectStack(subprojectPath);
85
+ if (!stackId) return null;
86
+
87
+ const scannerFile = path.join(SCANNERS_DIR, `${stackId}-scanner.js`);
88
+ if (!fs.existsSync(scannerFile)) return null;
89
+
90
+ try {
91
+ const ScannerClass = require(scannerFile);
92
+ const scanner = new ScannerClass(subprojectPath, subprojectMeta);
93
+ if (scanner.detect()) return scanner;
94
+ } catch (err) {
95
+ process.stderr.write(`[scanner-loader] Failed to load ${stackId} scanner: ${err.message}\n`);
96
+ }
97
+
98
+ return null;
99
+ }
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // listAvailableScanners
103
+ // ---------------------------------------------------------------------------
104
+
105
+ /**
106
+ * List all scanner files currently present in the scanners/ directory.
107
+ * Useful for diagnostics and --list-scanners flag.
108
+ * @returns {string[]} - array of stack IDs with scanners installed
109
+ */
110
+ function listAvailableScanners() {
111
+ try {
112
+ return fs.readdirSync(SCANNERS_DIR)
113
+ .filter(f => f.endsWith('-scanner.js'))
114
+ .map(f => f.replace('-scanner.js', ''));
115
+ } catch {
116
+ return [];
117
+ }
118
+ }
119
+
120
+ module.exports = { detectStack, loadScanner, listAvailableScanners, STACK_SIGNALS };
File without changes