tlc-claude-code 2.4.10 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.claude/commands/tlc/autofix.md +34 -1
  2. package/.claude/commands/tlc/build.md +203 -27
  3. package/.claude/commands/tlc/ci.md +178 -414
  4. package/.claude/commands/tlc/coverage.md +34 -0
  5. package/.claude/commands/tlc/deploy.md +19 -6
  6. package/.claude/commands/tlc/discuss.md +34 -0
  7. package/.claude/commands/tlc/docs.md +35 -1
  8. package/.claude/commands/tlc/e2e.md +300 -0
  9. package/.claude/commands/tlc/edge-cases.md +35 -1
  10. package/.claude/commands/tlc/init.md +38 -8
  11. package/.claude/commands/tlc/issues.md +46 -0
  12. package/.claude/commands/tlc/new-project.md +46 -4
  13. package/.claude/commands/tlc/plan.md +76 -0
  14. package/.claude/commands/tlc/quick.md +33 -0
  15. package/.claude/commands/tlc/release.md +85 -135
  16. package/.claude/commands/tlc/restore.md +14 -0
  17. package/.claude/commands/tlc/review.md +80 -1
  18. package/.claude/commands/tlc/tlc.md +134 -0
  19. package/.claude/commands/tlc/verify.md +64 -65
  20. package/.claude/commands/tlc/watchci.md +10 -0
  21. package/.claude/hooks/tlc-block-tools.sh +13 -0
  22. package/.claude/hooks/tlc-session-init.sh +9 -0
  23. package/CODING-STANDARDS.md +35 -10
  24. package/package.json +1 -1
  25. package/server/lib/block-tools-hook.js +23 -0
  26. package/server/lib/e2e/acceptance-parser.js +132 -0
  27. package/server/lib/e2e/acceptance-parser.test.js +110 -0
  28. package/server/lib/e2e/framework-detector.js +47 -0
  29. package/server/lib/e2e/framework-detector.test.js +94 -0
  30. package/server/lib/e2e/log-assertions.js +107 -0
  31. package/server/lib/e2e/log-assertions.test.js +68 -0
  32. package/server/lib/e2e/test-generator.js +159 -0
  33. package/server/lib/e2e/test-generator.test.js +121 -0
  34. package/server/lib/e2e/verify-runner.js +191 -0
  35. package/server/lib/e2e/verify-runner.test.js +167 -0
  36. package/server/lib/github/config.js +458 -0
  37. package/server/lib/github/config.test.js +385 -0
  38. package/server/lib/github/gh-client.js +303 -0
  39. package/server/lib/github/gh-client.test.js +499 -0
  40. package/server/lib/github/gh-projects.js +594 -0
  41. package/server/lib/github/gh-projects.test.js +583 -0
  42. package/server/lib/github/index.js +19 -0
  43. package/server/lib/github/plan-sync.js +456 -0
  44. package/server/lib/github/plan-sync.test.js +805 -0
  45. package/server/lib/hooks/block-tools-hook.test.js +54 -0
  46. package/server/lib/orchestration/cli-dispatch.js +16 -1
  47. package/server/lib/orchestration/cli-dispatch.test.js +94 -8
  48. package/server/lib/orchestration/completion-checker.js +101 -0
  49. package/server/lib/orchestration/completion-checker.test.js +177 -0
  50. package/server/lib/orchestration/result-verifier.js +143 -0
  51. package/server/lib/orchestration/result-verifier.test.js +291 -0
  52. package/server/lib/orchestration/session-dispatcher.js +99 -0
  53. package/server/lib/orchestration/session-dispatcher.test.js +215 -0
  54. package/server/lib/orchestration/session-status.js +147 -0
  55. package/server/lib/orchestration/session-status.test.js +130 -0
  56. package/server/lib/release/agent-runner-updates.js +24 -0
  57. package/server/lib/release/agent-runner-updates.test.js +22 -0
  58. package/server/lib/release/changelog-generator.js +142 -0
  59. package/server/lib/release/changelog-generator.test.js +113 -0
  60. package/server/lib/release/ci-watcher.js +83 -0
  61. package/server/lib/release/ci-watcher.test.js +81 -0
  62. package/server/lib/release/health-checker.js +111 -0
  63. package/server/lib/release/health-checker.test.js +121 -0
  64. package/server/lib/release/release-pipeline.js +187 -0
  65. package/server/lib/release/release-pipeline.test.js +262 -0
  66. package/server/lib/release/version-bumper.js +183 -0
  67. package/server/lib/release/version-bumper.test.js +142 -0
  68. package/server/lib/routing-preamble.integration.test.js +12 -0
  69. package/server/lib/routing-preamble.js +13 -2
  70. package/server/lib/routing-preamble.test.js +49 -0
  71. package/server/lib/scaffolding/ci-detector.js +139 -0
  72. package/server/lib/scaffolding/ci-detector.test.js +198 -0
  73. package/server/lib/scaffolding/ci-scaffolder.js +347 -0
  74. package/server/lib/scaffolding/ci-scaffolder.test.js +157 -0
  75. package/server/lib/scaffolding/deploy-detector.js +135 -0
  76. package/server/lib/scaffolding/deploy-detector.test.js +106 -0
  77. package/server/lib/scaffolding/health-scaffold.js +374 -0
  78. package/server/lib/scaffolding/health-scaffold.test.js +99 -0
  79. package/server/lib/scaffolding/logger-scaffold.js +196 -0
  80. package/server/lib/scaffolding/logger-scaffold.test.js +146 -0
  81. package/server/lib/scaffolding/migration-detector.js +78 -0
  82. package/server/lib/scaffolding/migration-detector.test.js +127 -0
  83. package/server/lib/scaffolding/snapshot-manager.js +142 -0
  84. package/server/lib/scaffolding/snapshot-manager.test.js +225 -0
  85. package/server/lib/task-router-config.js +50 -20
  86. package/server/lib/task-router-config.test.js +29 -15
@@ -0,0 +1,347 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const yaml = require('js-yaml');
5
+
6
+ function scaffoldCI({ projectDir, platform, gaps, testCommand, deployTarget, fs }) {
7
+ const warnings = [];
8
+ const resolvedFs = fs || require('fs');
9
+ const resolvedPlatform = platform || 'github-actions';
10
+ const resolvedGaps = Array.isArray(gaps) ? gaps : ['no-ci'];
11
+
12
+ if (resolvedPlatform !== 'github-actions') {
13
+ return {
14
+ files: [],
15
+ warnings: [`CI scaffolding for platform "${resolvedPlatform}" is not supported.`],
16
+ };
17
+ }
18
+
19
+ const resolvedTestCommand = resolveTestCommand({
20
+ projectDir,
21
+ testCommand,
22
+ fs: resolvedFs,
23
+ warnings,
24
+ });
25
+
26
+ const workflowFiles = listGitHubWorkflowFiles(projectDir, resolvedFs);
27
+
28
+ if (resolvedGaps.includes('no-ci') || workflowFiles.length === 0) {
29
+ return {
30
+ files: [
31
+ {
32
+ path: '.github/workflows/ci.yml',
33
+ content: dumpWorkflow(
34
+ createWorkflow({
35
+ testCommand: resolvedTestCommand,
36
+ deployTarget,
37
+ })
38
+ ),
39
+ },
40
+ ],
41
+ warnings,
42
+ };
43
+ }
44
+
45
+ const targetWorkflow = pickWorkflowFile(workflowFiles);
46
+ const workflowPath = path.join(projectDir, targetWorkflow);
47
+ const workflow = loadWorkflow(workflowPath, resolvedFs, warnings);
48
+
49
+ applyMissingSteps({
50
+ workflow,
51
+ gaps: resolvedGaps,
52
+ testCommand: resolvedTestCommand,
53
+ deployTarget,
54
+ });
55
+
56
+ return {
57
+ files: [
58
+ {
59
+ path: targetWorkflow,
60
+ content: dumpWorkflow(workflow),
61
+ },
62
+ ],
63
+ warnings,
64
+ };
65
+ }
66
+
67
+ function resolveTestCommand({ projectDir, testCommand, fs, warnings }) {
68
+ const tlcPath = path.join(projectDir, '.tlc.json');
69
+ const config = readJson(fs, tlcPath, warnings);
70
+ const frameworks = config && typeof config === 'object' ? config.testFrameworks : null;
71
+
72
+ const configuredCommand = getCommandFromFrameworks(frameworks);
73
+ if (configuredCommand) {
74
+ return configuredCommand;
75
+ }
76
+
77
+ if (typeof testCommand === 'string' && testCommand.trim()) {
78
+ return testCommand.trim();
79
+ }
80
+
81
+ if (typeof config?.testCommand === 'string' && config.testCommand.trim()) {
82
+ return config.testCommand.trim();
83
+ }
84
+
85
+ return 'npm test';
86
+ }
87
+
88
+ function getCommandFromFrameworks(testFrameworks) {
89
+ if (!testFrameworks || typeof testFrameworks !== 'object') {
90
+ return '';
91
+ }
92
+
93
+ if (typeof testFrameworks.command === 'string' && testFrameworks.command.trim()) {
94
+ return testFrameworks.command.trim();
95
+ }
96
+
97
+ if (typeof testFrameworks.testCommand === 'string' && testFrameworks.testCommand.trim()) {
98
+ return testFrameworks.testCommand.trim();
99
+ }
100
+
101
+ if (typeof testFrameworks.run === 'string' && testFrameworks.run.trim()) {
102
+ return testFrameworks.run.trim();
103
+ }
104
+
105
+ if (Array.isArray(testFrameworks.run) && testFrameworks.run.length > 0) {
106
+ return testFrameworks.run.map(part => String(part)).join(' ').trim();
107
+ }
108
+
109
+ const primary = typeof testFrameworks.primary === 'string' ? testFrameworks.primary.trim().toLowerCase() : '';
110
+ if (!primary) {
111
+ return '';
112
+ }
113
+
114
+ const commands = {
115
+ vitest: 'npx vitest run',
116
+ jest: 'npx jest',
117
+ mocha: 'npx mocha',
118
+ pytest: 'pytest',
119
+ 'go-test': 'go test ./...',
120
+ };
121
+
122
+ return commands[primary] || '';
123
+ }
124
+
125
+ function readJson(fs, filePath, warnings) {
126
+ try {
127
+ const content = fs.readFileSync(filePath, 'utf8');
128
+ return JSON.parse(content);
129
+ } catch (error) {
130
+ if (error && error.code !== 'ENOENT') {
131
+ warnings.push(`Failed to parse ${filePath}: ${error.message}`);
132
+ }
133
+ return null;
134
+ }
135
+ }
136
+
137
+ function listGitHubWorkflowFiles(projectDir, fs) {
138
+ const workflowsDir = path.join(projectDir, '.github', 'workflows');
139
+
140
+ try {
141
+ return fs.readdirSync(workflowsDir)
142
+ .filter(name => /\.ya?ml$/i.test(name))
143
+ .map(name => path.posix.join('.github/workflows', name))
144
+ .sort();
145
+ } catch {
146
+ return [];
147
+ }
148
+ }
149
+
150
+ function pickWorkflowFile(workflowFiles) {
151
+ return workflowFiles.find(filePath => /ci|test|build/i.test(path.basename(filePath))) || workflowFiles[0];
152
+ }
153
+
154
+ function loadWorkflow(workflowPath, fs, warnings) {
155
+ try {
156
+ const content = fs.readFileSync(workflowPath, 'utf8');
157
+ const parsed = yaml.load(content);
158
+
159
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
160
+ return parsed;
161
+ }
162
+ } catch (error) {
163
+ warnings.push(`Failed to parse ${workflowPath}: ${error.message}`);
164
+ }
165
+
166
+ return { name: 'CI', on: { push: {}, pull_request: {} }, jobs: {} };
167
+ }
168
+
169
+ function createWorkflow({ testCommand, deployTarget }) {
170
+ return {
171
+ name: 'CI',
172
+ on: {
173
+ push: {
174
+ branches: ['main', 'master'],
175
+ },
176
+ pull_request: {
177
+ branches: ['main', 'master'],
178
+ },
179
+ },
180
+ jobs: {
181
+ test: createTestJob(testCommand, true),
182
+ deploy: createDeployJob(deployTarget),
183
+ },
184
+ };
185
+ }
186
+
187
+ function applyMissingSteps({ workflow, gaps, testCommand, deployTarget }) {
188
+ if (!workflow.jobs || typeof workflow.jobs !== 'object' || Array.isArray(workflow.jobs)) {
189
+ workflow.jobs = {};
190
+ }
191
+
192
+ if (gaps.includes('missing-test-step')) {
193
+ workflow.jobs.test = createTestJob(testCommand, true);
194
+ } else if (gaps.includes('missing-coverage-upload')) {
195
+ workflow.jobs.test = ensureTestJob(workflow.jobs.test, testCommand);
196
+ ensureCoverageStep(workflow.jobs.test);
197
+ }
198
+
199
+ if (gaps.includes('missing-deploy-step')) {
200
+ workflow.jobs.deploy = createDeployJob(deployTarget);
201
+ }
202
+ }
203
+
204
+ function ensureTestJob(job, testCommand) {
205
+ if (!job || typeof job !== 'object' || Array.isArray(job)) {
206
+ return createTestJob(testCommand, false);
207
+ }
208
+
209
+ if (!Array.isArray(job.steps)) {
210
+ job.steps = [];
211
+ }
212
+
213
+ if (!job['runs-on']) {
214
+ job['runs-on'] = 'ubuntu-latest';
215
+ }
216
+
217
+ if (!job.steps.some(step => step && step.uses === 'actions/checkout@v4')) {
218
+ job.steps.unshift({
219
+ name: 'Checkout code',
220
+ uses: 'actions/checkout@v4',
221
+ });
222
+ }
223
+
224
+ if (!job.steps.some(step => step && step.uses === 'actions/setup-node@v4')) {
225
+ job.steps.splice(1, 0, {
226
+ name: 'Setup Node.js',
227
+ uses: 'actions/setup-node@v4',
228
+ with: {
229
+ 'node-version': '20',
230
+ cache: 'npm',
231
+ },
232
+ });
233
+ }
234
+
235
+ if (!job.steps.some(step => step && /install dependencies/i.test(step.name || '') || /npm ci/.test(step.run || ''))) {
236
+ job.steps.push({
237
+ name: 'Install dependencies',
238
+ run: 'npm ci',
239
+ });
240
+ }
241
+
242
+ if (!job.steps.some(step => step && /run tests/i.test(step.name || '') || (step.run || '') === testCommand)) {
243
+ job.steps.push({
244
+ name: 'Run tests',
245
+ run: testCommand,
246
+ });
247
+ }
248
+
249
+ return job;
250
+ }
251
+
252
+ function ensureCoverageStep(job) {
253
+ if (!Array.isArray(job.steps)) {
254
+ job.steps = [];
255
+ }
256
+
257
+ if (job.steps.some(step => step && /codecov\/codecov-action@v\d+/.test(step.uses || ''))) {
258
+ return;
259
+ }
260
+
261
+ job.steps.push({
262
+ name: 'Upload coverage',
263
+ uses: 'codecov/codecov-action@v4',
264
+ });
265
+ }
266
+
267
+ function createTestJob(testCommand, withCoverage) {
268
+ const steps = [
269
+ {
270
+ name: 'Checkout code',
271
+ uses: 'actions/checkout@v4',
272
+ },
273
+ {
274
+ name: 'Setup Node.js',
275
+ uses: 'actions/setup-node@v4',
276
+ with: {
277
+ 'node-version': '20',
278
+ cache: 'npm',
279
+ },
280
+ },
281
+ {
282
+ name: 'Install dependencies',
283
+ run: 'npm ci',
284
+ },
285
+ {
286
+ name: 'Run tests',
287
+ run: testCommand,
288
+ },
289
+ ];
290
+
291
+ if (withCoverage) {
292
+ steps.push({
293
+ name: 'Upload coverage',
294
+ uses: 'codecov/codecov-action@v4',
295
+ });
296
+ }
297
+
298
+ return {
299
+ 'runs-on': 'ubuntu-latest',
300
+ steps,
301
+ };
302
+ }
303
+
304
+ function createDeployJob(deployTarget) {
305
+ return {
306
+ 'runs-on': 'ubuntu-latest',
307
+ needs: ['test'],
308
+ if: "github.ref == 'refs/heads/main'",
309
+ steps: [
310
+ {
311
+ name: 'Checkout code',
312
+ uses: 'actions/checkout@v4',
313
+ },
314
+ {
315
+ name: 'Deploy',
316
+ run: createDeployCommand(deployTarget),
317
+ },
318
+ ],
319
+ };
320
+ }
321
+
322
+ function createDeployCommand(deployTarget) {
323
+ switch (deployTarget) {
324
+ case 'docker-compose':
325
+ return [
326
+ 'IMAGE_NAME="${IMAGE_NAME:-ghcr.io/${{ github.repository }}/${{ github.event.repository.name }}}:${{ github.sha }}"',
327
+ 'docker build -t "$IMAGE_NAME" .',
328
+ 'docker push "$IMAGE_NAME"',
329
+ ].join('\n');
330
+ case 'k8s':
331
+ return 'kubectl apply -f k8s/';
332
+ default:
333
+ return 'npm run deploy';
334
+ }
335
+ }
336
+
337
+ function dumpWorkflow(workflow) {
338
+ return yaml.dump(workflow, {
339
+ noRefs: true,
340
+ lineWidth: -1,
341
+ sortKeys: false,
342
+ });
343
+ }
344
+
345
+ module.exports = {
346
+ scaffoldCI,
347
+ };
@@ -0,0 +1,157 @@
1
+ import { afterEach, describe, expect, it } from 'vitest';
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+
7
+ const { scaffoldCI } = require('./ci-scaffolder.js');
8
+
9
+ const tempDirs = [];
10
+
11
+ function createProject() {
12
+ const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ci-scaffolder-test-'));
13
+ tempDirs.push(projectDir);
14
+ return projectDir;
15
+ }
16
+
17
+ function writeFile(projectDir, relativePath, content) {
18
+ const filePath = path.join(projectDir, relativePath);
19
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
20
+ fs.writeFileSync(filePath, content);
21
+ }
22
+
23
+ afterEach(() => {
24
+ while (tempDirs.length > 0) {
25
+ fs.rmSync(tempDirs.pop(), { recursive: true, force: true });
26
+ }
27
+ });
28
+
29
+ describe('scaffoldCI', () => {
30
+ it('generates a GitHub Actions CI workflow for projects without CI', () => {
31
+ const projectDir = createProject();
32
+ writeFile(projectDir, '.tlc.json', JSON.stringify({
33
+ testFrameworks: {
34
+ run: ['npx', 'vitest', 'run'],
35
+ },
36
+ }, null, 2));
37
+
38
+ const result = scaffoldCI({
39
+ projectDir,
40
+ platform: 'github-actions',
41
+ gaps: ['no-ci'],
42
+ deployTarget: 'docker-compose',
43
+ fs,
44
+ });
45
+
46
+ expect(result.warnings).toEqual([]);
47
+ expect(result.files).toHaveLength(1);
48
+ expect(result.files[0].path).toBe('.github/workflows/ci.yml');
49
+ expect(result.files[0].content).toContain('name: CI');
50
+ expect(result.files[0].content).toContain('run: npx vitest run');
51
+ expect(result.files[0].content).toContain('codecov/codecov-action@v4');
52
+ expect(result.files[0].content).toContain('docker push "$IMAGE_NAME"');
53
+ });
54
+
55
+ it('falls back to the provided testCommand when .tlc.json does not define one', () => {
56
+ const projectDir = createProject();
57
+ writeFile(projectDir, '.tlc.json', JSON.stringify({
58
+ testFrameworks: {
59
+ primary: 'vitest',
60
+ },
61
+ }, null, 2));
62
+
63
+ const result = scaffoldCI({
64
+ projectDir,
65
+ platform: 'github-actions',
66
+ gaps: ['no-ci'],
67
+ testCommand: 'npm run test:ci',
68
+ deployTarget: 'k8s',
69
+ fs,
70
+ });
71
+
72
+ expect(result.files[0].content).toContain('run: npx vitest run');
73
+ expect(result.files[0].content).toContain('kubectl apply -f k8s/');
74
+ });
75
+
76
+ it('only adds missing GitHub Actions steps when CI already exists', () => {
77
+ const projectDir = createProject();
78
+ writeFile(
79
+ projectDir,
80
+ '.github/workflows/existing.yml',
81
+ [
82
+ 'name: Existing',
83
+ 'on: push',
84
+ 'jobs:',
85
+ ' test:',
86
+ ' runs-on: ubuntu-latest',
87
+ ' steps:',
88
+ ' - uses: actions/checkout@v4',
89
+ ' - name: Run tests',
90
+ ' run: npm test',
91
+ ].join('\n')
92
+ );
93
+
94
+ const result = scaffoldCI({
95
+ projectDir,
96
+ platform: 'github-actions',
97
+ gaps: ['missing-deploy-step', 'missing-coverage-upload'],
98
+ deployTarget: 'docker-compose',
99
+ fs,
100
+ });
101
+
102
+ expect(result.warnings).toEqual([]);
103
+ expect(result.files).toHaveLength(1);
104
+ expect(result.files[0].path).toBe('.github/workflows/existing.yml');
105
+ expect(result.files[0].content).toContain('run: npm test');
106
+ expect(result.files[0].content).toContain('codecov/codecov-action@v4');
107
+ expect(result.files[0].content).toContain('deploy:');
108
+ expect(result.files[0].content).toContain('docker push "$IMAGE_NAME"');
109
+ expect(result.files[0].content.match(/name: Run tests/g)).toHaveLength(1);
110
+ });
111
+
112
+ it('adds a new test job when the existing workflow is missing one', () => {
113
+ const projectDir = createProject();
114
+ writeFile(
115
+ projectDir,
116
+ '.github/workflows/deploy.yml',
117
+ [
118
+ 'name: Deploy only',
119
+ 'on: push',
120
+ 'jobs:',
121
+ ' deploy:',
122
+ ' runs-on: ubuntu-latest',
123
+ ' steps:',
124
+ ' - run: npm run deploy',
125
+ ].join('\n')
126
+ );
127
+
128
+ const result = scaffoldCI({
129
+ projectDir,
130
+ platform: 'github-actions',
131
+ gaps: ['missing-test-step'],
132
+ testCommand: 'npm run test:ci',
133
+ deployTarget: 'k8s',
134
+ fs,
135
+ });
136
+
137
+ expect(result.files[0].content).toContain('test:');
138
+ expect(result.files[0].content).toContain('run: npm run test:ci');
139
+ expect(result.files[0].content).not.toContain('kubectl apply -f k8s/');
140
+ });
141
+
142
+ it('returns a warning for unsupported CI platforms', () => {
143
+ const projectDir = createProject();
144
+
145
+ const result = scaffoldCI({
146
+ projectDir,
147
+ platform: 'gitlab',
148
+ gaps: ['no-ci'],
149
+ fs,
150
+ });
151
+
152
+ expect(result.files).toEqual([]);
153
+ expect(result.warnings).toEqual([
154
+ 'CI scaffolding for platform "gitlab" is not supported.',
155
+ ]);
156
+ });
157
+ });
@@ -0,0 +1,135 @@
1
+ const path = require('path');
2
+
3
+ function isDirectory(fsImpl, targetPath) {
4
+ try {
5
+ return fsImpl.statSync(targetPath).isDirectory();
6
+ } catch {
7
+ return false;
8
+ }
9
+ }
10
+
11
+ function isFile(fsImpl, targetPath) {
12
+ try {
13
+ return fsImpl.statSync(targetPath).isFile();
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ function collectDeploymentManifestEvidence({ projectDir, fs: fsImpl }) {
20
+ const evidence = [];
21
+ const ignoredDirs = new Set(['.git', 'node_modules']);
22
+ const pending = [projectDir];
23
+
24
+ while (pending.length > 0) {
25
+ const currentDir = pending.pop();
26
+ let entries = [];
27
+
28
+ try {
29
+ entries = fsImpl.readdirSync(currentDir, { withFileTypes: true });
30
+ } catch {
31
+ continue;
32
+ }
33
+
34
+ for (const entry of entries) {
35
+ const fullPath = path.join(currentDir, entry.name);
36
+
37
+ if (entry.isDirectory()) {
38
+ if (!ignoredDirs.has(entry.name)) {
39
+ pending.push(fullPath);
40
+ }
41
+ continue;
42
+ }
43
+
44
+ if (!entry.isFile()) {
45
+ continue;
46
+ }
47
+
48
+ if (!/\.(ya?ml)$/i.test(entry.name)) {
49
+ continue;
50
+ }
51
+
52
+ try {
53
+ const contents = fsImpl.readFileSync(fullPath, 'utf8');
54
+ if (/\bkind:\s*Deployment\b/.test(contents)) {
55
+ evidence.push(`kind: Deployment in ${path.relative(projectDir, fullPath)}`);
56
+ }
57
+ } catch {
58
+ // Ignore unreadable files and continue scanning.
59
+ }
60
+ }
61
+ }
62
+
63
+ evidence.sort();
64
+ return evidence;
65
+ }
66
+
67
+ function detectDeployTarget({ projectDir, fs: fsImpl }) {
68
+ const evidence = [];
69
+ const composeEvidence = [];
70
+ const k8sEvidence = [];
71
+ const dockerfileEvidence = [];
72
+
73
+ for (const composeFile of ['docker-compose.yml', 'docker-compose.yaml']) {
74
+ if (isFile(fsImpl, path.join(projectDir, composeFile))) {
75
+ composeEvidence.push(composeFile);
76
+ }
77
+ }
78
+
79
+ for (const k8sDir of ['helm', 'charts', 'k8s']) {
80
+ if (isDirectory(fsImpl, path.join(projectDir, k8sDir))) {
81
+ k8sEvidence.push(`${k8sDir}/`);
82
+ }
83
+ }
84
+
85
+ k8sEvidence.push(...collectDeploymentManifestEvidence({ projectDir, fs: fsImpl }));
86
+
87
+ if (isFile(fsImpl, path.join(projectDir, 'Dockerfile'))) {
88
+ dockerfileEvidence.push('Dockerfile');
89
+ }
90
+
91
+ evidence.push(...composeEvidence, ...k8sEvidence, ...dockerfileEvidence);
92
+
93
+ if (composeEvidence.length > 0 && k8sEvidence.length > 0) {
94
+ evidence.push('multiple deployment targets detected');
95
+ return {
96
+ target: 'unknown',
97
+ confidence: 'low',
98
+ evidence,
99
+ };
100
+ }
101
+
102
+ if (composeEvidence.length > 0) {
103
+ return {
104
+ target: 'docker-compose',
105
+ confidence: 'high',
106
+ evidence,
107
+ };
108
+ }
109
+
110
+ if (k8sEvidence.length > 0) {
111
+ return {
112
+ target: 'k8s',
113
+ confidence: 'high',
114
+ evidence,
115
+ };
116
+ }
117
+
118
+ if (dockerfileEvidence.length > 0) {
119
+ return {
120
+ target: 'vps',
121
+ confidence: 'medium',
122
+ evidence,
123
+ };
124
+ }
125
+
126
+ return {
127
+ target: 'unknown',
128
+ confidence: 'low',
129
+ evidence: [],
130
+ };
131
+ }
132
+
133
+ module.exports = {
134
+ detectDeployTarget,
135
+ };