plankit-cli 1.4.0 → 1.5.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 +51 -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 +59 -0
- package/src/dashboard/dashboard.js +272 -0
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ plankit review my-feature
|
|
|
64
64
|
| `plankit implement <feature> <phase>` | Validate phase context (spec + prior outputs) and prepare `outputs/phase-N-output.md`. |
|
|
65
65
|
| `plankit review <feature>` | Run verification commands, mark README archived, and move the feature to `artifacts/archived/`. |
|
|
66
66
|
| `plankit status [--json]` | List active and archived features. |
|
|
67
|
+
| `plankit scan [dir] [--json] [--threshold <n>]` | Run zero-dependency static analysis, audit health (0-100), extract design tokens, and generate `artifacts/.plankit-index.json`. |
|
|
68
|
+
| `plankit ui [--no-tui]` | Launch interactive terminal feature progress matrix & action dashboard. |
|
|
67
69
|
| `plankit archive <feature>` | Move a feature to archived without running verification. |
|
|
68
70
|
| `plankit version` / `--version` / `-v` | Print the installed version (`plankit-cli vX.Y.Z`). |
|
|
69
71
|
| `plankit help` / `--help` / `-h` | Show usage. |
|
|
@@ -79,6 +81,9 @@ plankit review my-feature
|
|
|
79
81
|
| `--agent <name>` | Select your coding agent during `init` (`opencode`, `gemini`, `codex`, `cursor`; default `opencode`). |
|
|
80
82
|
| `--framework <name>` | Select framework command suite during `init` (`angular`, `vue`, `dotnet`, `none`, `all`; default `none`). |
|
|
81
83
|
| `--frameworks <list>` | Comma-separated list of framework command suites (e.g. `vue,dotnet`). |
|
|
84
|
+
| `--threshold <score>` | Fail `scan` command with exit code 1 if health score is below threshold (CI quality gate). |
|
|
85
|
+
| `--json` | Output `scan` or `status` as machine-readable JSON. |
|
|
86
|
+
| `--no-tui` | Render static ASCII table instead of interactive dashboard for `ui` command. |
|
|
82
87
|
|
|
83
88
|
### Plan options
|
|
84
89
|
|
|
@@ -164,6 +169,52 @@ plankit init --agent cursor
|
|
|
164
169
|
|
|
165
170
|
---
|
|
166
171
|
|
|
172
|
+
## Static analysis engine (`plankit scan`)
|
|
173
|
+
|
|
174
|
+
PlanKit ships with an ultra-fast, **zero-dependency static analyzer** that audits codebase modernization health without burning LLM tokens:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
# Run audit and output terminal report + save artifacts/.plankit-index.json
|
|
178
|
+
plankit scan
|
|
179
|
+
|
|
180
|
+
# Output machine-readable JSON metrics
|
|
181
|
+
plankit scan --json
|
|
182
|
+
|
|
183
|
+
# CI Quality Gate: fail if health score drops below threshold
|
|
184
|
+
plankit scan --threshold 80
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### What it checks:
|
|
188
|
+
- **Vue SFC Architecture**: Options API vs `<script setup>` ratio, TypeScript vs JavaScript, Pinia vs Vuex vs composables.
|
|
189
|
+
- **Design Token Mining**: Automatically detects repeated hex colors, font sizes, margins, and paddings across templates and styles.
|
|
190
|
+
- **.NET Architecture**: Target frameworks, layer dependency boundaries (Clean Architecture / Vertical Slices), and Minimal APIs vs Controllers.
|
|
191
|
+
- **Modernization Hotspots**: Flags god components (> 400 lines), deprecated deep selectors (`::v-deep`, `/deep/`), mixins, and layer boundary violations.
|
|
192
|
+
- **Index Generation**: Saves `artifacts/.plankit-index.json` so AI agents can query repository intelligence immediately.
|
|
193
|
+
|
|
194
|
+
---
|
|
195
|
+
|
|
196
|
+
## Interactive feature dashboard (`plankit ui`)
|
|
197
|
+
|
|
198
|
+
Launch a terminal UI to visualize active features, phase completion status, and trigger development workflows with single keystrokes:
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
# Launch interactive TUI
|
|
202
|
+
plankit ui
|
|
203
|
+
|
|
204
|
+
# Non-interactive / CI ASCII table output
|
|
205
|
+
plankit ui --no-tui
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
### Interactive Actions:
|
|
209
|
+
- **`[↑/↓]` or `[j/k]`**: Navigate between active features.
|
|
210
|
+
- **`[i]`**: Prepare and validate implementation context for the next pending phase.
|
|
211
|
+
- **`[c]`**: Generate a design alignment and clarification artifact.
|
|
212
|
+
- **`[r]`**: Review, verify DoD, and archive completed feature.
|
|
213
|
+
- **`[s]`**: Run the repository static scan and view live health scorecard.
|
|
214
|
+
- **`[q]`**: Exit dashboard.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
167
218
|
## Framework command suites
|
|
168
219
|
|
|
169
220
|
During `plankit init`, PlanKit prompts you to choose optional framework-specific command suites (or pass `--framework <name>` / `--frameworks <names>`):
|
package/package.json
CHANGED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fast static analyzer for .NET projects and solutions (.sln, .csproj, .cs).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function analyzeCsprojFile(filePath, content = null) {
|
|
9
|
+
const code = content !== null ? content : fs.readFileSync(filePath, 'utf8');
|
|
10
|
+
const fileName = path.basename(filePath);
|
|
11
|
+
const projectName = fileName.replace(/\.csproj$/i, '');
|
|
12
|
+
|
|
13
|
+
// Target Framework
|
|
14
|
+
const tfMatch = /<TargetFramework(?:s)?>([^<]+)<\/TargetFramework(?:s)?>/i.exec(code);
|
|
15
|
+
const targetFramework = tfMatch ? tfMatch[1].trim() : 'unknown';
|
|
16
|
+
|
|
17
|
+
// Nullable
|
|
18
|
+
const nullableMatch = /<Nullable>([^<]+)<\/Nullable>/i.exec(code);
|
|
19
|
+
const nullable = nullableMatch ? nullableMatch[1].trim().toLowerCase() === 'enable' : false;
|
|
20
|
+
|
|
21
|
+
// Project References
|
|
22
|
+
const projectReferences = [];
|
|
23
|
+
const refRegex = /<ProjectReference\s+Include=["']([^"']+)["']/gi;
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = refRegex.exec(code)) !== null) {
|
|
26
|
+
const refPath = match[1].replace(/\\/g, '/');
|
|
27
|
+
const refProject = path.basename(refPath).replace(/\.csproj$/i, '');
|
|
28
|
+
projectReferences.push(refProject);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Package References
|
|
32
|
+
const packageReferences = [];
|
|
33
|
+
const pkgRegex = /<PackageReference\s+Include=["']([^"']+)["'](?:\s+Version=["']([^"']+)["'])?/gi;
|
|
34
|
+
while ((match = pkgRegex.exec(code)) !== null) {
|
|
35
|
+
packageReferences.push({
|
|
36
|
+
name: match[1],
|
|
37
|
+
version: match[2] || 'unknown'
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Determine Layer (Clean Architecture heuristics)
|
|
42
|
+
let layer = 'other';
|
|
43
|
+
const lowerName = projectName.toLowerCase();
|
|
44
|
+
if (lowerName.includes('domain') || lowerName.includes('core')) {
|
|
45
|
+
layer = 'domain';
|
|
46
|
+
} else if (lowerName.includes('application') || lowerName.includes('usecase')) {
|
|
47
|
+
layer = 'application';
|
|
48
|
+
} else if (lowerName.includes('infrastructure') || lowerName.includes('data') || lowerName.includes('persistence')) {
|
|
49
|
+
layer = 'infrastructure';
|
|
50
|
+
} else if (lowerName.includes('api') || lowerName.includes('web') || lowerName.includes('server')) {
|
|
51
|
+
layer = 'api';
|
|
52
|
+
} else if (lowerName.includes('test')) {
|
|
53
|
+
layer = 'tests';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Layer Boundary Violations
|
|
57
|
+
const violations = [];
|
|
58
|
+
if (layer === 'domain') {
|
|
59
|
+
for (const ref of projectReferences) {
|
|
60
|
+
const lowerRef = ref.toLowerCase();
|
|
61
|
+
if (lowerRef.includes('infra') || lowerRef.includes('api') || lowerRef.includes('web') || lowerRef.includes('app')) {
|
|
62
|
+
violations.push({
|
|
63
|
+
type: 'domain_layer_violation',
|
|
64
|
+
message: `Domain project "${projectName}" illegally references outer layer "${ref}"`
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
} else if (layer === 'application') {
|
|
69
|
+
for (const ref of projectReferences) {
|
|
70
|
+
const lowerRef = ref.toLowerCase();
|
|
71
|
+
if (lowerRef.includes('infra') || lowerRef.includes('api') || lowerRef.includes('web')) {
|
|
72
|
+
violations.push({
|
|
73
|
+
type: 'application_layer_violation',
|
|
74
|
+
message: `Application project "${projectName}" illegally references outer layer "${ref}"`
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
filePath,
|
|
82
|
+
projectName,
|
|
83
|
+
targetFramework,
|
|
84
|
+
nullable,
|
|
85
|
+
layer,
|
|
86
|
+
projectReferences,
|
|
87
|
+
packageReferences,
|
|
88
|
+
violations
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function analyzeCSharpFile(filePath, content = null) {
|
|
93
|
+
const code = content !== null ? content : fs.readFileSync(filePath, 'utf8');
|
|
94
|
+
const lines = code.split(/\r?\n/).length;
|
|
95
|
+
|
|
96
|
+
const isMinimalApi = /\bapp\.Map(?:Get|Post|Put|Delete|Patch|Group)\b/.test(code) ||
|
|
97
|
+
/\bgroup\.Map(?:Get|Post|Put|Delete|Patch)\b/.test(code);
|
|
98
|
+
const isController = /:\s*(?:ControllerBase|Controller)\b/.test(code) || /\[ApiController\]/.test(code);
|
|
99
|
+
const hasFileScopedNamespace = /^namespace\s+[\w.]+\s*;/m.test(code);
|
|
100
|
+
const hasPrimaryConstructor = /(?:public|internal|private)\s+(?:class|record|struct)\s+\w+\s*\([^)]*\)\s*(?::|\{)/.test(code);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
filePath,
|
|
104
|
+
fileName: path.basename(filePath),
|
|
105
|
+
lines,
|
|
106
|
+
isMinimalApi,
|
|
107
|
+
isController,
|
|
108
|
+
hasFileScopedNamespace,
|
|
109
|
+
hasPrimaryConstructor
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { analyzeVueFile } from './vueScanner.js';
|
|
4
|
+
import { analyzeCSharpFile, analyzeCsprojFile } from './dotnetScanner.js';
|
|
5
|
+
|
|
6
|
+
const IGNORED_DIRS = new Set([
|
|
7
|
+
'node_modules',
|
|
8
|
+
'.git',
|
|
9
|
+
'dist',
|
|
10
|
+
'build',
|
|
11
|
+
'bin',
|
|
12
|
+
'obj',
|
|
13
|
+
'coverage',
|
|
14
|
+
'.gemini',
|
|
15
|
+
'.opencode',
|
|
16
|
+
'.codex',
|
|
17
|
+
'artifacts'
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
export async function runAnalysis(targetDir, options = {}) {
|
|
21
|
+
const rootDir = path.resolve(targetDir);
|
|
22
|
+
const files = collectScannableFiles(rootDir);
|
|
23
|
+
|
|
24
|
+
const vueResults = [];
|
|
25
|
+
const csprojResults = [];
|
|
26
|
+
const csharpResults = [];
|
|
27
|
+
|
|
28
|
+
for (const file of files) {
|
|
29
|
+
const ext = path.extname(file).toLowerCase();
|
|
30
|
+
if (ext === '.vue') {
|
|
31
|
+
vueResults.push(analyzeVueFile(file));
|
|
32
|
+
} else if (ext === '.csproj') {
|
|
33
|
+
csprojResults.push(analyzeCsprojFile(file));
|
|
34
|
+
} else if (ext === '.cs') {
|
|
35
|
+
csharpResults.push(analyzeCSharpFile(file));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const score = calculateHealthScore(vueResults, csprojResults, csharpResults);
|
|
40
|
+
const tokenCandidates = aggregateTokens(vueResults);
|
|
41
|
+
const hotspots = aggregateHotspots(vueResults, csprojResults);
|
|
42
|
+
|
|
43
|
+
const report = {
|
|
44
|
+
timestamp: new Date().toISOString(),
|
|
45
|
+
rootDir,
|
|
46
|
+
totalFilesScanned: files.length,
|
|
47
|
+
score,
|
|
48
|
+
vue: summarizeVueMetrics(vueResults),
|
|
49
|
+
dotnet: summarizeDotnetMetrics(csprojResults, csharpResults),
|
|
50
|
+
designTokens: tokenCandidates,
|
|
51
|
+
hotspots
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (options.saveIndex !== false) {
|
|
55
|
+
saveIndexFile(rootDir, report, options.artifactsDir || 'artifacts');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return report;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function formatReport(report, useColors = true) {
|
|
62
|
+
const c = useColors
|
|
63
|
+
? {
|
|
64
|
+
reset: '\x1b[0m',
|
|
65
|
+
bold: '\x1b[1m',
|
|
66
|
+
green: '\x1b[32m',
|
|
67
|
+
yellow: '\x1b[33m',
|
|
68
|
+
red: '\x1b[31m',
|
|
69
|
+
cyan: '\x1b[36m',
|
|
70
|
+
dim: '\x1b[2m'
|
|
71
|
+
}
|
|
72
|
+
: {
|
|
73
|
+
reset: '',
|
|
74
|
+
bold: '',
|
|
75
|
+
green: '',
|
|
76
|
+
yellow: '',
|
|
77
|
+
red: '',
|
|
78
|
+
cyan: '',
|
|
79
|
+
dim: ''
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const scoreColor = report.score.total >= 80 ? c.green : (report.score.total >= 50 ? c.yellow : c.red);
|
|
83
|
+
const out = [];
|
|
84
|
+
|
|
85
|
+
out.push(`${c.bold}======================================================${c.reset}`);
|
|
86
|
+
out.push(`${c.bold} PlanKit Repository Health Audit ${c.reset}`);
|
|
87
|
+
out.push(`${c.bold}======================================================${c.reset}`);
|
|
88
|
+
out.push(`Root Directory: ${c.cyan}${report.rootDir}${c.reset}`);
|
|
89
|
+
out.push(`Files Scanned: ${report.totalFilesScanned}`);
|
|
90
|
+
out.push(`Health Score: ${scoreColor}${c.bold}${report.score.total} / 100${c.reset} (${report.score.grade})`);
|
|
91
|
+
out.push('');
|
|
92
|
+
|
|
93
|
+
// Vue summary if Vue files detected
|
|
94
|
+
if (report.vue && report.vue.totalSfc > 0) {
|
|
95
|
+
out.push(`${c.bold}Vue Architecture & Modernization Metrics:${c.reset}`);
|
|
96
|
+
out.push(` SFC Count: ${report.vue.totalSfc}`);
|
|
97
|
+
out.push(` <script setup> Ratio: ${c.green}${report.vue.scriptSetupPercent}%${c.reset} (${report.vue.scriptSetupCount}/${report.vue.totalSfc})`);
|
|
98
|
+
out.push(` TypeScript Ratio: ${c.green}${report.vue.tsPercent}%${c.reset} (${report.vue.tsCount}/${report.vue.totalSfc})`);
|
|
99
|
+
out.push(` Pinia / Modern State: ${report.vue.piniaCount} files (Vuex legacy: ${report.vue.vuexCount})`);
|
|
100
|
+
out.push(` Maturity Distribution: ${c.green}Preferred: ${report.vue.maturity.preferred}${c.reset} | ${c.yellow}Transitional: ${report.vue.maturity.transitional}${c.reset} | ${c.red}Legacy: ${report.vue.maturity.legacy}${c.reset}`);
|
|
101
|
+
out.push('');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// .NET summary if .NET files detected
|
|
105
|
+
if (report.dotnet && (report.dotnet.projectsCount > 0 || report.dotnet.csharpFilesCount > 0)) {
|
|
106
|
+
out.push(`${c.bold}.NET Solution Architecture Metrics:${c.reset}`);
|
|
107
|
+
out.push(` Projects: ${report.dotnet.projectsCount}`);
|
|
108
|
+
out.push(` C# Source Files: ${report.dotnet.csharpFilesCount}`);
|
|
109
|
+
out.push(` Minimal APIs: ${report.dotnet.minimalApiCount} | Controllers: ${report.dotnet.controllerCount}`);
|
|
110
|
+
if (report.dotnet.layerViolations.length > 0) {
|
|
111
|
+
out.push(` ${c.red}Layer Boundary Violations:${c.reset} ${report.dotnet.layerViolations.length}`);
|
|
112
|
+
}
|
|
113
|
+
out.push('');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Token candidates
|
|
117
|
+
if (report.designTokens && report.designTokens.frequentColors.length > 0) {
|
|
118
|
+
out.push(`${c.bold}Top Discovered Design Token Candidates (Colors):${c.reset}`);
|
|
119
|
+
for (const item of report.designTokens.frequentColors.slice(0, 5)) {
|
|
120
|
+
out.push(` ${c.cyan}${item.color}${c.reset} (used in ${item.count} component/style rules)`);
|
|
121
|
+
}
|
|
122
|
+
out.push('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Hotspots
|
|
126
|
+
if (report.hotspots.length > 0) {
|
|
127
|
+
out.push(`${c.bold}Modernization Hotspots & Anti-Patterns (${report.hotspots.length}):${c.reset}`);
|
|
128
|
+
for (const h of report.hotspots.slice(0, 8)) {
|
|
129
|
+
out.push(` - ${c.yellow}${h.file}${c.reset}: ${h.message}`);
|
|
130
|
+
}
|
|
131
|
+
if (report.hotspots.length > 8) {
|
|
132
|
+
out.push(` ${c.dim}... and ${report.hotspots.length - 8} more in artifacts/.plankit-index.json${c.reset}`);
|
|
133
|
+
}
|
|
134
|
+
out.push('');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
out.push(`${c.bold}======================================================${c.reset}`);
|
|
138
|
+
out.push(`Index saved to ${c.cyan}artifacts/.plankit-index.json${c.reset}`);
|
|
139
|
+
return out.join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function collectScannableFiles(dir, files = []) {
|
|
143
|
+
if (!fs.existsSync(dir)) return files;
|
|
144
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
145
|
+
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith('.')) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const fullPath = path.join(dir, entry.name);
|
|
151
|
+
if (entry.isDirectory()) {
|
|
152
|
+
collectScannableFiles(fullPath, files);
|
|
153
|
+
} else if (entry.isFile()) {
|
|
154
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
155
|
+
if (['.vue', '.csproj', '.cs', '.ts', '.js'].includes(ext)) {
|
|
156
|
+
files.push(fullPath);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return files;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function calculateHealthScore(vueResults, csprojResults, csharpResults) {
|
|
165
|
+
let score = 100;
|
|
166
|
+
|
|
167
|
+
if (vueResults.length > 0) {
|
|
168
|
+
const total = vueResults.length;
|
|
169
|
+
const scriptSetup = vueResults.filter((r) => r.vueStyle === 'script_setup').length;
|
|
170
|
+
const ts = vueResults.filter((r) => r.language === 'ts').length;
|
|
171
|
+
const legacy = vueResults.filter((r) => r.maturity === 'legacy').length;
|
|
172
|
+
const vuex = vueResults.filter((r) => r.state === 'vuex').length;
|
|
173
|
+
|
|
174
|
+
// Deduct for non-script setup ratio
|
|
175
|
+
score -= Math.round(((total - scriptSetup) / total) * 35);
|
|
176
|
+
// Deduct for JS ratio (lack of TS)
|
|
177
|
+
score -= Math.round(((total - ts) / total) * 20);
|
|
178
|
+
// Deduct for legacy Vuex
|
|
179
|
+
if (vuex > 0) {
|
|
180
|
+
score -= Math.min(15, vuex * 3);
|
|
181
|
+
}
|
|
182
|
+
// Deduct for legacy anti-patterns (mixins, filters, etc.)
|
|
183
|
+
score -= Math.min(20, legacy * 4);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (csprojResults.length > 0) {
|
|
187
|
+
const violations = csprojResults.flatMap((r) => r.violations).length;
|
|
188
|
+
score -= Math.min(25, violations * 10);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const finalScore = Math.max(0, Math.min(100, score));
|
|
192
|
+
let grade = 'A';
|
|
193
|
+
if (finalScore < 60) grade = 'F';
|
|
194
|
+
else if (finalScore < 70) grade = 'D';
|
|
195
|
+
else if (finalScore < 80) grade = 'C';
|
|
196
|
+
else if (finalScore < 90) grade = 'B';
|
|
197
|
+
|
|
198
|
+
return { total: finalScore, grade };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function summarizeVueMetrics(vueResults) {
|
|
202
|
+
const totalSfc = vueResults.length;
|
|
203
|
+
if (totalSfc === 0) return null;
|
|
204
|
+
|
|
205
|
+
const scriptSetupCount = vueResults.filter((r) => r.vueStyle === 'script_setup').length;
|
|
206
|
+
const tsCount = vueResults.filter((r) => r.language === 'ts').length;
|
|
207
|
+
const piniaCount = vueResults.filter((r) => r.state === 'pinia').length;
|
|
208
|
+
const vuexCount = vueResults.filter((r) => r.state === 'vuex').length;
|
|
209
|
+
|
|
210
|
+
const maturity = {
|
|
211
|
+
preferred: vueResults.filter((r) => r.maturity === 'preferred').length,
|
|
212
|
+
transitional: vueResults.filter((r) => r.maturity === 'transitional').length,
|
|
213
|
+
legacy: vueResults.filter((r) => r.maturity === 'legacy').length
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
totalSfc,
|
|
218
|
+
scriptSetupCount,
|
|
219
|
+
scriptSetupPercent: Math.round((scriptSetupCount / totalSfc) * 100),
|
|
220
|
+
tsCount,
|
|
221
|
+
tsPercent: Math.round((tsCount / totalSfc) * 100),
|
|
222
|
+
piniaCount,
|
|
223
|
+
vuexCount,
|
|
224
|
+
maturity
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function summarizeDotnetMetrics(csprojResults, csharpResults) {
|
|
229
|
+
if (csprojResults.length === 0 && csharpResults.length === 0) return null;
|
|
230
|
+
|
|
231
|
+
const layerViolations = csprojResults.flatMap((r) => r.violations);
|
|
232
|
+
const minimalApiCount = csharpResults.filter((r) => r.isMinimalApi).length;
|
|
233
|
+
const controllerCount = csharpResults.filter((r) => r.isController).length;
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
projectsCount: csprojResults.length,
|
|
237
|
+
csharpFilesCount: csharpResults.length,
|
|
238
|
+
minimalApiCount,
|
|
239
|
+
controllerCount,
|
|
240
|
+
layerViolations
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function aggregateTokens(vueResults) {
|
|
245
|
+
const colorCounts = new Map();
|
|
246
|
+
const spacingCounts = new Map();
|
|
247
|
+
|
|
248
|
+
for (const r of vueResults) {
|
|
249
|
+
for (const color of r.tokens.hexColors) {
|
|
250
|
+
colorCounts.set(color, (colorCounts.get(color) || 0) + 1);
|
|
251
|
+
}
|
|
252
|
+
for (const spacing of r.tokens.spacing) {
|
|
253
|
+
spacingCounts.set(spacing, (spacingCounts.get(spacing) || 0) + 1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const frequentColors = Array.from(colorCounts.entries())
|
|
258
|
+
.map(([color, count]) => ({ color, count }))
|
|
259
|
+
.sort((a, b) => b.count - a.count);
|
|
260
|
+
|
|
261
|
+
const frequentSpacing = Array.from(spacingCounts.entries())
|
|
262
|
+
.map(([spacing, count]) => ({ spacing, count }))
|
|
263
|
+
.sort((a, b) => b.count - a.count);
|
|
264
|
+
|
|
265
|
+
return { frequentColors, frequentSpacing };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function aggregateHotspots(vueResults, csprojResults) {
|
|
269
|
+
const hotspots = [];
|
|
270
|
+
for (const r of vueResults) {
|
|
271
|
+
for (const h of r.hotspots) {
|
|
272
|
+
hotspots.push({ file: path.basename(r.filePath), relativePath: r.filePath, ...h });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const p of csprojResults) {
|
|
276
|
+
for (const v of p.violations) {
|
|
277
|
+
hotspots.push({ file: path.basename(p.filePath), relativePath: p.filePath, ...v });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return hotspots;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function saveIndexFile(rootDir, report, artifactsDirName = 'artifacts') {
|
|
284
|
+
const targetDir = path.join(rootDir, artifactsDirName);
|
|
285
|
+
if (!fs.existsSync(targetDir)) {
|
|
286
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
287
|
+
}
|
|
288
|
+
const indexPath = path.join(targetDir, '.plankit-index.json');
|
|
289
|
+
fs.writeFileSync(indexPath, JSON.stringify(report, null, 2), 'utf8');
|
|
290
|
+
}
|
|
@@ -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,8 @@ 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';
|
|
26
28
|
|
|
27
29
|
export async function runCli(args, context = {}) {
|
|
28
30
|
const io = createIo(context);
|
|
@@ -50,6 +52,15 @@ export async function runCli(args, context = {}) {
|
|
|
50
52
|
case 'status':
|
|
51
53
|
showStatus(parsed, io);
|
|
52
54
|
break;
|
|
55
|
+
case 'scan':
|
|
56
|
+
case 'health':
|
|
57
|
+
case 'audit':
|
|
58
|
+
await scanRepository(parsed, io);
|
|
59
|
+
break;
|
|
60
|
+
case 'ui':
|
|
61
|
+
case 'dashboard':
|
|
62
|
+
await showDashboard(parsed, io);
|
|
63
|
+
break;
|
|
53
64
|
case 'archive':
|
|
54
65
|
archiveFeature(parsed, io);
|
|
55
66
|
break;
|
|
@@ -365,6 +376,49 @@ function archiveFeature(parsed, io) {
|
|
|
365
376
|
io.stdout(`Feature "${featureName}" archived.`);
|
|
366
377
|
}
|
|
367
378
|
|
|
379
|
+
async function scanRepository(parsed, io) {
|
|
380
|
+
const { config } = loadConfig(io.cwd);
|
|
381
|
+
const targetDir = parsed.positionals[0] ? path.resolve(io.cwd, parsed.positionals[0]) : io.cwd;
|
|
382
|
+
const report = await runAnalysis(targetDir, {
|
|
383
|
+
artifactsDir: config.artifactsDir
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
if (parsed.flags.json) {
|
|
387
|
+
io.stdout(JSON.stringify(report, null, 2));
|
|
388
|
+
} else {
|
|
389
|
+
io.stdout(formatReport(report, io.isTTY));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (parsed.flags.threshold) {
|
|
393
|
+
const threshold = Number.parseInt(parsed.flags.threshold, 10);
|
|
394
|
+
if (Number.isInteger(threshold) && report.score.total < threshold) {
|
|
395
|
+
throw new Error(`Health score ${report.score.total} is below required threshold ${threshold}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function showDashboard(parsed, io) {
|
|
401
|
+
const { config } = loadConfig(io.cwd);
|
|
402
|
+
const data = loadDashboardData(io.cwd, config);
|
|
403
|
+
|
|
404
|
+
if (!io.isTTY || parsed.flags['no-tui']) {
|
|
405
|
+
renderStaticDashboard(data, io);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
await runInteractiveDashboard(io.cwd, io, config, {
|
|
410
|
+
implement: async (featureName, phaseNum) => {
|
|
411
|
+
prepareImplementation({ positionals: [featureName, String(phaseNum)], flags: {} }, io);
|
|
412
|
+
},
|
|
413
|
+
clarify: async (featureName) => {
|
|
414
|
+
clarifyFeature({ positionals: [featureName], flags: {} }, io);
|
|
415
|
+
},
|
|
416
|
+
review: async (featureName) => {
|
|
417
|
+
reviewFeature({ positionals: [featureName], flags: { 'skip-test': true, 'skip-build': true } }, io);
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
368
422
|
function moveFeatureToArchive(cwd, config, featureName, dryRun = false) {
|
|
369
423
|
const sourceDir = requireFeatureDir(cwd, config, featureName);
|
|
370
424
|
const targetDir = safeJoin(cwd, config.artifactsDir, 'archived', featureName);
|
|
@@ -734,6 +788,8 @@ Commands:
|
|
|
734
788
|
implement <feature> <phase> Validate phase context and prepare phase output artifact
|
|
735
789
|
review <feature> Run verification and archive a completed feature
|
|
736
790
|
status [--json] Show active and archived features
|
|
791
|
+
scan [dir] Run static analysis and generate artifacts/.plankit-index.json
|
|
792
|
+
ui Launch interactive feature progress dashboard
|
|
737
793
|
archive <feature> Move a feature to archived without running verification
|
|
738
794
|
version Show the installed PlankKit CLI version
|
|
739
795
|
help Show this help message
|
|
@@ -746,6 +802,9 @@ Options:
|
|
|
746
802
|
--framework <name> Select framework command suite during init
|
|
747
803
|
(angular, vue, dotnet, none, all; default: none)
|
|
748
804
|
--frameworks <list> Comma-separated list of framework suites during init
|
|
805
|
+
--threshold <score> Fail scan if health score is below threshold
|
|
806
|
+
--json Output scan report or status as JSON
|
|
807
|
+
--no-tui Render static summary table instead of interactive dashboard
|
|
749
808
|
--global-gemini-skills Also install Gemini skills under the user profile
|
|
750
809
|
--phases 3 Generate N default phases during plan
|
|
751
810
|
--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
|
+
}
|