plankit-cli 1.4.0 → 1.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.
- package/README.md +73 -0
- package/package.json +1 -1
- package/src/analyzer/dotnetScanner.js +111 -0
- package/src/analyzer/engine.js +290 -0
- package/src/analyzer/vueScanner.js +200 -0
- package/src/cli.js +116 -0
- package/src/dashboard/dashboard.js +272 -0
- package/src/server/clientHtml.js +674 -0
- package/src/server/server.js +155 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fast static analyzer for Vue SFC (.vue) and JS/TS files.
|
|
6
|
+
* Uses lightweight block extraction and heuristic parsing without external dependencies.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export function analyzeVueFile(filePath, content = null) {
|
|
10
|
+
const code = content !== null ? content : fs.readFileSync(filePath, 'utf8');
|
|
11
|
+
const lines = code.split(/\r?\n/);
|
|
12
|
+
const lineCount = lines.length;
|
|
13
|
+
|
|
14
|
+
const templateBlock = extractBlock(code, 'template');
|
|
15
|
+
const scriptBlock = extractBlock(code, 'script');
|
|
16
|
+
const styleBlocks = extractAllBlocks(code, 'style');
|
|
17
|
+
|
|
18
|
+
const isScriptSetup = /<script\s+[^>]*setup/i.test(code);
|
|
19
|
+
const hasScript = scriptBlock !== null || isScriptSetup;
|
|
20
|
+
const scriptContent = scriptBlock ? scriptBlock.content : '';
|
|
21
|
+
const scriptAttributes = scriptBlock ? scriptBlock.attributes : '';
|
|
22
|
+
|
|
23
|
+
// Language detection
|
|
24
|
+
const isTs = /lang=["'](?:ts|typescript)["']/i.test(code) ||
|
|
25
|
+
filePath.endsWith('.ts') ||
|
|
26
|
+
filePath.endsWith('.tsx');
|
|
27
|
+
const language = isTs ? 'ts' : 'js';
|
|
28
|
+
|
|
29
|
+
// Vue style detection (Options API, Composition API, or <script setup>)
|
|
30
|
+
let vueStyle = 'unknown';
|
|
31
|
+
if (isScriptSetup) {
|
|
32
|
+
vueStyle = 'script_setup';
|
|
33
|
+
} else if (hasScript) {
|
|
34
|
+
if (/\bsetup\s*\([^)]*\)\s*\{/i.test(scriptContent) || /\bdefineComponent\s*\(/i.test(scriptContent)) {
|
|
35
|
+
vueStyle = 'composition';
|
|
36
|
+
} else if (/\b(?:data\s*\(\)|methods\s*:|computed\s*:|watch\s*:)/i.test(scriptContent)) {
|
|
37
|
+
vueStyle = 'options';
|
|
38
|
+
} else {
|
|
39
|
+
vueStyle = 'transitional';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// State detection
|
|
44
|
+
const hasPinia = /\b(?:use\w+Store|defineStore)\b/i.test(code);
|
|
45
|
+
const hasVuex = /\b(?:this\.\$store|mapState|mapGetters|mapActions|mapMutations|createNamespacedHelpers)\b/i.test(code);
|
|
46
|
+
const hasComposable = /\buse(?!Store\b)\w+\s*\(/i.test(code);
|
|
47
|
+
const hasLocal = /\b(?:ref|reactive|shallowRef)\s*\(/i.test(code);
|
|
48
|
+
|
|
49
|
+
let state = 'local';
|
|
50
|
+
if (hasPinia && hasVuex) {
|
|
51
|
+
state = 'mixed';
|
|
52
|
+
} else if (hasPinia) {
|
|
53
|
+
state = 'pinia';
|
|
54
|
+
} else if (hasVuex) {
|
|
55
|
+
state = 'vuex';
|
|
56
|
+
} else if (hasComposable) {
|
|
57
|
+
state = 'composable';
|
|
58
|
+
} else if (hasLocal) {
|
|
59
|
+
state = 'local';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Data access detection
|
|
63
|
+
let dataAccess = 'unknown';
|
|
64
|
+
if (/\baxios\.(?:get|post|put|delete|patch)\b/i.test(code)) {
|
|
65
|
+
dataAccess = 'axios_component';
|
|
66
|
+
} else if (/\buseQuery\b|\buseMutation\b/i.test(code)) {
|
|
67
|
+
dataAccess = 'query_library';
|
|
68
|
+
} else if (/\b(?:useFetch|\$fetch)\b/i.test(code)) {
|
|
69
|
+
dataAccess = 'fetch_composable';
|
|
70
|
+
} else if (/\b(?:api|service|client)\.\w+\b/i.test(code)) {
|
|
71
|
+
dataAccess = 'api_service';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Styling analysis
|
|
75
|
+
const stylingTypes = new Set();
|
|
76
|
+
let hasScoped = false;
|
|
77
|
+
let hasGlobal = false;
|
|
78
|
+
for (const style of styleBlocks) {
|
|
79
|
+
const isScoped = /\bscoped\b/i.test(style.attributes);
|
|
80
|
+
const isModule = /\bmodule\b/i.test(style.attributes);
|
|
81
|
+
const isScss = /lang=["'](?:scss|sass)["']/i.test(style.attributes);
|
|
82
|
+
|
|
83
|
+
if (isModule) stylingTypes.add('modules');
|
|
84
|
+
if (isScoped) {
|
|
85
|
+
hasScoped = true;
|
|
86
|
+
stylingTypes.add(isScss ? 'scoped_scss' : 'scoped_css');
|
|
87
|
+
} else {
|
|
88
|
+
hasGlobal = true;
|
|
89
|
+
stylingTypes.add(isScss ? 'global_scss' : 'global_css');
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (templateBlock && /\bclass=["'][^"']*\b(?:flex|grid|p-|m-|text-|bg-|w-|h-)\b/i.test(templateBlock.content)) {
|
|
93
|
+
stylingTypes.add('utility');
|
|
94
|
+
}
|
|
95
|
+
const styling = stylingTypes.size > 0 ? Array.from(stylingTypes) : ['scoped_css'];
|
|
96
|
+
|
|
97
|
+
// Design token discovery (hex colors, font sizes, margins, padding)
|
|
98
|
+
const tokens = extractTokens(code);
|
|
99
|
+
|
|
100
|
+
// Anti-patterns and hotspots
|
|
101
|
+
const hotspots = [];
|
|
102
|
+
if (lineCount > 400) {
|
|
103
|
+
hotspots.push({ type: 'god_component', message: `Large SFC (${lineCount} lines)` });
|
|
104
|
+
}
|
|
105
|
+
if (/\b(?:this\.\$)/.test(code)) {
|
|
106
|
+
hotspots.push({ type: 'deprecated_global_property', message: 'Uses legacy this.$ properties' });
|
|
107
|
+
}
|
|
108
|
+
if (/::v-deep|>>>|\/deep\//.test(code)) {
|
|
109
|
+
hotspots.push({ type: 'deprecated_deep_selector', message: 'Uses deprecated deep CSS selector (::v-deep or /deep/)' });
|
|
110
|
+
}
|
|
111
|
+
if (/\bmixins\s*:\s*\[/i.test(code)) {
|
|
112
|
+
hotspots.push({ type: 'vue_mixins', message: 'Uses Vue mixins (replace with composables)' });
|
|
113
|
+
}
|
|
114
|
+
if (/\bfilters\s*:\s*\{/i.test(code)) {
|
|
115
|
+
hotspots.push({ type: 'vue_filters', message: 'Uses Vue 2 filters (not supported in Vue 3)' });
|
|
116
|
+
}
|
|
117
|
+
if (/\b\$on\b|\b\$emit\b.*eventBus|new\s+Vue\(\)/i.test(code)) {
|
|
118
|
+
hotspots.push({ type: 'event_bus', message: 'Uses global event bus' });
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Maturity classification
|
|
122
|
+
let maturity = 'preferred';
|
|
123
|
+
if (vueStyle === 'options' || hotspots.some((h) => h.type === 'vue_mixins' || h.type === 'vue_filters')) {
|
|
124
|
+
maturity = 'legacy';
|
|
125
|
+
} else if (vueStyle === 'composition' || language === 'js' || state === 'vuex' || hasGlobal) {
|
|
126
|
+
maturity = 'transitional';
|
|
127
|
+
} else if (vueStyle === 'script_setup' && language === 'ts') {
|
|
128
|
+
maturity = 'preferred';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
filePath,
|
|
133
|
+
fileName: path.basename(filePath),
|
|
134
|
+
lineCount,
|
|
135
|
+
language,
|
|
136
|
+
vueStyle,
|
|
137
|
+
state,
|
|
138
|
+
dataAccess,
|
|
139
|
+
styling,
|
|
140
|
+
hasScoped,
|
|
141
|
+
hasGlobal,
|
|
142
|
+
tokens,
|
|
143
|
+
hotspots,
|
|
144
|
+
maturity
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function extractBlock(code, tag) {
|
|
149
|
+
const openRegex = new RegExp(`<${tag}(\\s+[^>]*)?>`, 'i');
|
|
150
|
+
const match = openRegex.exec(code);
|
|
151
|
+
if (!match) return null;
|
|
152
|
+
|
|
153
|
+
const startIndex = match.index + match[0].length;
|
|
154
|
+
const closeTag = `</${tag}>`;
|
|
155
|
+
const endIndex = code.indexOf(closeTag, startIndex);
|
|
156
|
+
if (endIndex === -1) {
|
|
157
|
+
return {
|
|
158
|
+
attributes: match[1] || '',
|
|
159
|
+
content: code.slice(startIndex)
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
attributes: match[1] || '',
|
|
165
|
+
content: code.slice(startIndex, endIndex)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function extractAllBlocks(code, tag) {
|
|
170
|
+
const blocks = [];
|
|
171
|
+
const regex = new RegExp(`<${tag}(\\s+[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'gi');
|
|
172
|
+
let match;
|
|
173
|
+
while ((match = regex.exec(code)) !== null) {
|
|
174
|
+
blocks.push({
|
|
175
|
+
attributes: match[1] || '',
|
|
176
|
+
content: match[2]
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return blocks;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function extractTokens(code) {
|
|
183
|
+
const hexColors = new Set();
|
|
184
|
+
const hexRegex = /#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})\b/g;
|
|
185
|
+
let match;
|
|
186
|
+
while ((match = hexRegex.exec(code)) !== null) {
|
|
187
|
+
hexColors.add(match[0].toLowerCase());
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const spacingValues = new Set();
|
|
191
|
+
const spacingRegex = /(?:margin|padding|gap|top|bottom|left|right):\s*([0-9]+(?:px|rem|em))/gi;
|
|
192
|
+
while ((match = spacingRegex.exec(code)) !== null) {
|
|
193
|
+
spacingValues.add(match[1].toLowerCase());
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
hexColors: Array.from(hexColors),
|
|
198
|
+
spacing: Array.from(spacingValues)
|
|
199
|
+
};
|
|
200
|
+
}
|
package/src/cli.js
CHANGED
|
@@ -23,6 +23,9 @@ import {
|
|
|
23
23
|
normalizeFramework,
|
|
24
24
|
parseFrameworksInput
|
|
25
25
|
} from './commandBundles.js';
|
|
26
|
+
import { runAnalysis, formatReport } from './analyzer/engine.js';
|
|
27
|
+
import { loadDashboardData, renderStaticDashboard, runInteractiveDashboard } from './dashboard/dashboard.js';
|
|
28
|
+
import { startServer } from './server/server.js';
|
|
26
29
|
|
|
27
30
|
export async function runCli(args, context = {}) {
|
|
28
31
|
const io = createIo(context);
|
|
@@ -50,6 +53,19 @@ export async function runCli(args, context = {}) {
|
|
|
50
53
|
case 'status':
|
|
51
54
|
showStatus(parsed, io);
|
|
52
55
|
break;
|
|
56
|
+
case 'scan':
|
|
57
|
+
case 'health':
|
|
58
|
+
case 'audit':
|
|
59
|
+
await scanRepository(parsed, io);
|
|
60
|
+
break;
|
|
61
|
+
case 'ui':
|
|
62
|
+
case 'dashboard':
|
|
63
|
+
await showDashboard(parsed, io);
|
|
64
|
+
break;
|
|
65
|
+
case 'serve':
|
|
66
|
+
case 'web':
|
|
67
|
+
await startWebServer(parsed, io);
|
|
68
|
+
break;
|
|
53
69
|
case 'archive':
|
|
54
70
|
archiveFeature(parsed, io);
|
|
55
71
|
break;
|
|
@@ -365,6 +381,99 @@ function archiveFeature(parsed, io) {
|
|
|
365
381
|
io.stdout(`Feature "${featureName}" archived.`);
|
|
366
382
|
}
|
|
367
383
|
|
|
384
|
+
async function scanRepository(parsed, io) {
|
|
385
|
+
const { config } = loadConfig(io.cwd);
|
|
386
|
+
const targetDir = parsed.positionals[0] ? path.resolve(io.cwd, parsed.positionals[0]) : io.cwd;
|
|
387
|
+
const report = await runAnalysis(targetDir, {
|
|
388
|
+
artifactsDir: config.artifactsDir
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
if (parsed.flags.json) {
|
|
392
|
+
io.stdout(JSON.stringify(report, null, 2));
|
|
393
|
+
} else {
|
|
394
|
+
io.stdout(formatReport(report, io.isTTY));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
if (parsed.flags.threshold) {
|
|
398
|
+
const threshold = Number.parseInt(parsed.flags.threshold, 10);
|
|
399
|
+
if (Number.isInteger(threshold) && report.score.total < threshold) {
|
|
400
|
+
throw new Error(`Health score ${report.score.total} is below required threshold ${threshold}`);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function showDashboard(parsed, io) {
|
|
406
|
+
const { config } = loadConfig(io.cwd);
|
|
407
|
+
const data = loadDashboardData(io.cwd, config);
|
|
408
|
+
|
|
409
|
+
if (!io.isTTY || parsed.flags['no-tui']) {
|
|
410
|
+
renderStaticDashboard(data, io);
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
await runInteractiveDashboard(io.cwd, io, config, {
|
|
415
|
+
implement: async (featureName, phaseNum) => {
|
|
416
|
+
prepareImplementation({ positionals: [featureName, String(phaseNum)], flags: {} }, io);
|
|
417
|
+
},
|
|
418
|
+
clarify: async (featureName) => {
|
|
419
|
+
clarifyFeature({ positionals: [featureName], flags: {} }, io);
|
|
420
|
+
},
|
|
421
|
+
review: async (featureName) => {
|
|
422
|
+
reviewFeature({ positionals: [featureName], flags: { 'skip-test': true, 'skip-build': true } }, io);
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async function startWebServer(parsed, io) {
|
|
428
|
+
const { config } = loadConfig(io.cwd);
|
|
429
|
+
let port = null;
|
|
430
|
+
|
|
431
|
+
const rawPort = parsed.flags.port || parsed.flags.p;
|
|
432
|
+
if (rawPort !== undefined) {
|
|
433
|
+
const parsedNum = Number.parseInt(rawPort, 10);
|
|
434
|
+
if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
|
|
435
|
+
throw new Error(`Invalid port "${rawPort}". Please provide a valid port between 1 and 65535.`);
|
|
436
|
+
}
|
|
437
|
+
port = parsedNum;
|
|
438
|
+
} else if (io.isTTY && io.promptChoice) {
|
|
439
|
+
const answer = await io.promptChoice('Enter port for PlanKit Web Dashboard (default: 4200): ', ['4200']);
|
|
440
|
+
if (answer && answer.trim()) {
|
|
441
|
+
const parsedNum = Number.parseInt(answer.trim(), 10);
|
|
442
|
+
if (!Number.isInteger(parsedNum) || parsedNum < 0 || parsedNum > 65535) {
|
|
443
|
+
throw new Error(`Invalid port "${answer}". Please provide a valid port between 1 and 65535.`);
|
|
444
|
+
}
|
|
445
|
+
port = parsedNum;
|
|
446
|
+
} else {
|
|
447
|
+
port = 4200;
|
|
448
|
+
}
|
|
449
|
+
} else {
|
|
450
|
+
port = 4200;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
io.stdout(`Starting PlanKit Web Dashboard on port ${port}...`);
|
|
454
|
+
|
|
455
|
+
const { server, port: actualPort } = await startServer({
|
|
456
|
+
cwd: io.cwd,
|
|
457
|
+
config,
|
|
458
|
+
port,
|
|
459
|
+
io
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
const url = `http://localhost:${actualPort}`;
|
|
463
|
+
io.stdout('');
|
|
464
|
+
io.stdout('======================================================');
|
|
465
|
+
io.stdout(` PlanKit Web Dashboard running at: ${url}`);
|
|
466
|
+
io.stdout('======================================================');
|
|
467
|
+
io.stdout('Press Ctrl+C to stop.');
|
|
468
|
+
|
|
469
|
+
// Keep process alive if interactive/standalone
|
|
470
|
+
if (!parsed.flags['no-keep-alive'] && parsed.flags['keep-alive'] !== false) {
|
|
471
|
+
await new Promise(() => {});
|
|
472
|
+
} else {
|
|
473
|
+
server.close();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
368
477
|
function moveFeatureToArchive(cwd, config, featureName, dryRun = false) {
|
|
369
478
|
const sourceDir = requireFeatureDir(cwd, config, featureName);
|
|
370
479
|
const targetDir = safeJoin(cwd, config.artifactsDir, 'archived', featureName);
|
|
@@ -734,6 +843,9 @@ Commands:
|
|
|
734
843
|
implement <feature> <phase> Validate phase context and prepare phase output artifact
|
|
735
844
|
review <feature> Run verification and archive a completed feature
|
|
736
845
|
status [--json] Show active and archived features
|
|
846
|
+
scan [dir] Run static analysis and generate artifacts/.plankit-index.json
|
|
847
|
+
ui Launch interactive feature progress dashboard
|
|
848
|
+
serve [--port <n>] Launch visual local web dashboard companion (alias: web)
|
|
737
849
|
archive <feature> Move a feature to archived without running verification
|
|
738
850
|
version Show the installed PlankKit CLI version
|
|
739
851
|
help Show this help message
|
|
@@ -746,6 +858,10 @@ Options:
|
|
|
746
858
|
--framework <name> Select framework command suite during init
|
|
747
859
|
(angular, vue, dotnet, none, all; default: none)
|
|
748
860
|
--frameworks <list> Comma-separated list of framework suites during init
|
|
861
|
+
--port <number> Specify port for web dashboard (default: 4200)
|
|
862
|
+
--threshold <score> Fail scan if health score is below threshold
|
|
863
|
+
--json Output scan report or status as JSON
|
|
864
|
+
--no-tui Render static summary table instead of interactive dashboard
|
|
749
865
|
--global-gemini-skills Also install Gemini skills under the user profile
|
|
750
866
|
--phases 3 Generate N default phases during plan
|
|
751
867
|
--phases "Design,Build,Test" Generate named phases during plan
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { listDirectories, safeJoin } from '../config.js';
|
|
5
|
+
import { runAnalysis, formatReport } from '../analyzer/engine.js';
|
|
6
|
+
|
|
7
|
+
export function loadDashboardData(cwd, config) {
|
|
8
|
+
const artifactsDir = safeJoin(cwd, config.artifactsDir || 'artifacts');
|
|
9
|
+
const currentDir = path.join(artifactsDir, 'current');
|
|
10
|
+
const archivedDir = path.join(artifactsDir, 'archived');
|
|
11
|
+
|
|
12
|
+
const activeNames = listDirectories(currentDir);
|
|
13
|
+
const archivedNames = listDirectories(archivedDir);
|
|
14
|
+
|
|
15
|
+
const activeFeatures = activeNames.map((name) => {
|
|
16
|
+
const featureDir = path.join(currentDir, name);
|
|
17
|
+
const phasesDir = path.join(featureDir, 'phases');
|
|
18
|
+
const outputsDir = path.join(featureDir, 'outputs');
|
|
19
|
+
|
|
20
|
+
const phaseFiles = fs.existsSync(phasesDir)
|
|
21
|
+
? fs.readdirSync(phasesDir).filter((f) => f.startsWith('phase-') && f.endsWith('.md')).sort()
|
|
22
|
+
: [];
|
|
23
|
+
const outputFiles = fs.existsSync(outputsDir)
|
|
24
|
+
? fs.readdirSync(outputsDir).filter((f) => f.startsWith('phase-') && f.endsWith('.md')).sort()
|
|
25
|
+
: [];
|
|
26
|
+
|
|
27
|
+
let state = null;
|
|
28
|
+
const stateFile = path.join(featureDir, 'plankit.json');
|
|
29
|
+
if (fs.existsSync(stateFile)) {
|
|
30
|
+
try {
|
|
31
|
+
state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
32
|
+
} catch {
|
|
33
|
+
state = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const phases = phaseFiles.map((pf, idx) => {
|
|
38
|
+
const num = idx + 1;
|
|
39
|
+
const expectedOutput = `phase-${num}-output.md`;
|
|
40
|
+
const hasOutput = outputFiles.includes(expectedOutput);
|
|
41
|
+
return {
|
|
42
|
+
number: num,
|
|
43
|
+
specFile: pf,
|
|
44
|
+
hasOutput,
|
|
45
|
+
status: hasOutput ? 'DONE' : (num === outputFiles.length + 1 ? 'IN_PROGRESS' : 'PENDING')
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const completedCount = phases.filter((p) => p.hasOutput).length;
|
|
50
|
+
const totalCount = phases.length || 1;
|
|
51
|
+
const progressPercent = Math.round((completedCount / totalCount) * 100);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
name,
|
|
55
|
+
phases,
|
|
56
|
+
phaseCount: phases.length,
|
|
57
|
+
completedCount,
|
|
58
|
+
progressPercent,
|
|
59
|
+
state
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
activeFeatures,
|
|
65
|
+
archivedCount: archivedNames.length,
|
|
66
|
+
archivedNames
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function renderStaticDashboard(data, io) {
|
|
71
|
+
io.stdout('================================================================');
|
|
72
|
+
io.stdout(' PlanKit Feature Management Matrix ');
|
|
73
|
+
io.stdout('================================================================');
|
|
74
|
+
io.stdout(`Active Features: ${data.activeFeatures.length} | Archived Features: ${data.archivedCount}`);
|
|
75
|
+
io.stdout('');
|
|
76
|
+
|
|
77
|
+
if (data.activeFeatures.length === 0) {
|
|
78
|
+
io.stdout(' No active features found in artifacts/current/.');
|
|
79
|
+
io.stdout(' Run "plankit plan <feature-name>" to start a feature.');
|
|
80
|
+
io.stdout('================================================================');
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
io.stdout(padRight('Feature Name', 25) + padRight('Phases', 12) + padRight('Progress', 20) + 'Status');
|
|
85
|
+
io.stdout('-'.repeat(64));
|
|
86
|
+
|
|
87
|
+
data.activeFeatures.forEach((feat) => {
|
|
88
|
+
const progressText = `${progressBar(feat.progressPercent, 10)} ${feat.progressPercent}%`;
|
|
89
|
+
const statusText = feat.completedCount === feat.phaseCount ? 'READY_FOR_REVIEW' : `PHASE_${feat.completedCount + 1}`;
|
|
90
|
+
io.stdout(padRight(feat.name, 25) + padRight(`${feat.completedCount}/${feat.phaseCount}`, 12) + padRight(progressText, 20) + statusText);
|
|
91
|
+
|
|
92
|
+
feat.phases.forEach((p) => {
|
|
93
|
+
const check = p.hasOutput ? '[x]' : '[ ]';
|
|
94
|
+
io.stdout(` ${check} Phase ${p.number}: ${p.specFile} (${p.status})`);
|
|
95
|
+
});
|
|
96
|
+
io.stdout('');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
io.stdout('================================================================');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function runInteractiveDashboard(cwd, io, config, handlers = {}) {
|
|
103
|
+
const isTTY = Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
104
|
+
const data = loadDashboardData(cwd, config);
|
|
105
|
+
|
|
106
|
+
if (!isTTY) {
|
|
107
|
+
renderStaticDashboard(data, io);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let selectedIndex = 0;
|
|
112
|
+
let statusMessage = 'Use [↑/↓] or [j/k] to navigate. Press [q] to exit.';
|
|
113
|
+
|
|
114
|
+
function refresh() {
|
|
115
|
+
const freshData = loadDashboardData(cwd, config);
|
|
116
|
+
console.clear();
|
|
117
|
+
console.log('\x1b[1m================================================================\x1b[0m');
|
|
118
|
+
console.log('\x1b[1m\x1b[36m PlanKit Interactive Feature Dashboard \x1b[0m');
|
|
119
|
+
console.log('\x1b[1m================================================================\x1b[0m');
|
|
120
|
+
console.log(`Active Features: \x1b[32m${freshData.activeFeatures.length}\x1b[0m | Archived Features: \x1b[33m${freshData.archivedCount}\x1b[0m`);
|
|
121
|
+
console.log('');
|
|
122
|
+
|
|
123
|
+
if (freshData.activeFeatures.length === 0) {
|
|
124
|
+
console.log(' No active features in artifacts/current/.');
|
|
125
|
+
console.log(' Press [p] to plan a new feature, or [q] to exit.');
|
|
126
|
+
console.log('\x1b[1m================================================================\x1b[0m');
|
|
127
|
+
console.log(`Status: ${statusMessage}`);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (selectedIndex >= freshData.activeFeatures.length) {
|
|
132
|
+
selectedIndex = freshData.activeFeatures.length - 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
freshData.activeFeatures.forEach((feat, index) => {
|
|
136
|
+
const isSelected = index === selectedIndex;
|
|
137
|
+
const pointer = isSelected ? '\x1b[36m▶ \x1b[0m' : ' ';
|
|
138
|
+
const nameFmt = isSelected ? `\x1b[1m\x1b[36m${padRight(feat.name, 22)}\x1b[0m` : padRight(feat.name, 22);
|
|
139
|
+
const progress = `${progressBar(feat.progressPercent, 10)} ${feat.progressPercent}%`;
|
|
140
|
+
console.log(`${pointer}${nameFmt} ${padRight(progress, 18)} (${feat.completedCount}/${feat.phaseCount} phases)`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
console.log('');
|
|
144
|
+
const selectedFeature = freshData.activeFeatures[selectedIndex];
|
|
145
|
+
if (selectedFeature) {
|
|
146
|
+
console.log(`\x1b[1mPhase Breakdown for "${selectedFeature.name}":\x1b[0m`);
|
|
147
|
+
selectedFeature.phases.forEach((p) => {
|
|
148
|
+
const badge = p.hasOutput ? '\x1b[32m[COMPLETED]\x1b[0m' : '\x1b[33m[PENDING]\x1b[0m';
|
|
149
|
+
console.log(` Phase ${p.number}: ${padRight(p.specFile, 32)} ${badge}`);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
console.log('');
|
|
154
|
+
console.log('\x1b[1m----------------------------------------------------------------\x1b[0m');
|
|
155
|
+
console.log('\x1b[2mActions: [i] Implement Next Phase | [c] Clarify | [r] Review | [s] Scan | [q] Quit\x1b[0m');
|
|
156
|
+
console.log(`\x1b[33m${statusMessage}\x1b[0m`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return new Promise((resolve) => {
|
|
160
|
+
readline.emitKeypressEvents(process.stdin);
|
|
161
|
+
if (process.stdin.setRawMode) {
|
|
162
|
+
process.stdin.setRawMode(true);
|
|
163
|
+
}
|
|
164
|
+
process.stdin.resume();
|
|
165
|
+
|
|
166
|
+
refresh();
|
|
167
|
+
|
|
168
|
+
const onKeypress = async (str, key) => {
|
|
169
|
+
if ((key && key.ctrl && key.name === 'c') || (key && key.name === 'q')) {
|
|
170
|
+
cleanup();
|
|
171
|
+
console.clear();
|
|
172
|
+
io.stdout('Exited PlanKit Dashboard.');
|
|
173
|
+
resolve();
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const freshData = loadDashboardData(cwd, config);
|
|
178
|
+
const currentFeature = freshData.activeFeatures[selectedIndex];
|
|
179
|
+
|
|
180
|
+
if (key && (key.name === 'up' || key.name === 'k')) {
|
|
181
|
+
if (selectedIndex > 0) selectedIndex -= 1;
|
|
182
|
+
statusMessage = `Selected ${freshData.activeFeatures[selectedIndex]?.name || ''}`;
|
|
183
|
+
refresh();
|
|
184
|
+
} else if (key && (key.name === 'down' || key.name === 'j')) {
|
|
185
|
+
if (selectedIndex < freshData.activeFeatures.length - 1) selectedIndex += 1;
|
|
186
|
+
statusMessage = `Selected ${freshData.activeFeatures[selectedIndex]?.name || ''}`;
|
|
187
|
+
refresh();
|
|
188
|
+
} else if (str === 's') {
|
|
189
|
+
cleanup();
|
|
190
|
+
console.clear();
|
|
191
|
+
io.stdout('Running PlanKit Static Analysis...\n');
|
|
192
|
+
const report = await runAnalysis(cwd, { artifactsDir: config.artifactsDir });
|
|
193
|
+
io.stdout(formatReport(report, true));
|
|
194
|
+
io.stdout('\nPress any key to return to dashboard...');
|
|
195
|
+
process.stdin.once('data', () => {
|
|
196
|
+
readline.emitKeypressEvents(process.stdin);
|
|
197
|
+
if (process.stdin.setRawMode) process.stdin.setRawMode(true);
|
|
198
|
+
process.stdin.resume();
|
|
199
|
+
process.stdin.on('keypress', onKeypress);
|
|
200
|
+
statusMessage = 'Completed static analysis scan.';
|
|
201
|
+
refresh();
|
|
202
|
+
});
|
|
203
|
+
} else if (str === 'i' && currentFeature && handlers.implement) {
|
|
204
|
+
const nextPhase = currentFeature.completedCount + 1;
|
|
205
|
+
if (nextPhase > currentFeature.phaseCount) {
|
|
206
|
+
statusMessage = `Feature "${currentFeature.name}" has completed all phases! Press [r] to review.`;
|
|
207
|
+
refresh();
|
|
208
|
+
} else {
|
|
209
|
+
cleanup();
|
|
210
|
+
console.clear();
|
|
211
|
+
io.stdout(`Preparing Phase ${nextPhase} for "${currentFeature.name}"...\n`);
|
|
212
|
+
try {
|
|
213
|
+
await handlers.implement(currentFeature.name, nextPhase);
|
|
214
|
+
statusMessage = `Phase ${nextPhase} prepared successfully for "${currentFeature.name}".`;
|
|
215
|
+
} catch (err) {
|
|
216
|
+
statusMessage = `Error: ${err.message}`;
|
|
217
|
+
}
|
|
218
|
+
resume();
|
|
219
|
+
}
|
|
220
|
+
} else if (str === 'c' && currentFeature && handlers.clarify) {
|
|
221
|
+
cleanup();
|
|
222
|
+
console.clear();
|
|
223
|
+
try {
|
|
224
|
+
await handlers.clarify(currentFeature.name);
|
|
225
|
+
statusMessage = `Clarifications prepared for "${currentFeature.name}".`;
|
|
226
|
+
} catch (err) {
|
|
227
|
+
statusMessage = `Error: ${err.message}`;
|
|
228
|
+
}
|
|
229
|
+
resume();
|
|
230
|
+
} else if (str === 'r' && currentFeature && handlers.review) {
|
|
231
|
+
cleanup();
|
|
232
|
+
console.clear();
|
|
233
|
+
try {
|
|
234
|
+
await handlers.review(currentFeature.name);
|
|
235
|
+
statusMessage = `Feature "${currentFeature.name}" reviewed and archived.`;
|
|
236
|
+
} catch (err) {
|
|
237
|
+
statusMessage = `Review error: ${err.message}`;
|
|
238
|
+
}
|
|
239
|
+
resume();
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
function cleanup() {
|
|
244
|
+
process.stdin.removeListener('keypress', onKeypress);
|
|
245
|
+
if (process.stdin.setRawMode) {
|
|
246
|
+
process.stdin.setRawMode(false);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resume() {
|
|
251
|
+
readline.emitKeypressEvents(process.stdin);
|
|
252
|
+
if (process.stdin.setRawMode) {
|
|
253
|
+
process.stdin.setRawMode(true);
|
|
254
|
+
}
|
|
255
|
+
process.stdin.resume();
|
|
256
|
+
process.stdin.on('keypress', onKeypress);
|
|
257
|
+
refresh();
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
process.stdin.on('keypress', onKeypress);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function progressBar(percent, length = 10) {
|
|
265
|
+
const filled = Math.round((percent / 100) * length);
|
|
266
|
+
const empty = length - filled;
|
|
267
|
+
return `[${'='.repeat(filled)}${'-'.repeat(empty)}]`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function padRight(str, len) {
|
|
271
|
+
return String(str).padEnd(len, ' ');
|
|
272
|
+
}
|