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 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,74 @@ 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
+
218
+ ## Visual web dashboard companion (`plankit serve`)
219
+
220
+ Launch an interactive, local browser companion on your choice of port:
221
+
222
+ ```bash
223
+ # Launch on default port 4200 (prompts if run interactively without flag)
224
+ plankit serve
225
+
226
+ # Specify custom port
227
+ plankit serve --port 5000
228
+ plankit web -p 8080
229
+ ```
230
+
231
+ ### Features:
232
+ - **Feature Matrix & Progress Board**: Visual cards with completion percentages and modal document viewers for specs and outputs.
233
+ - **Architecture Health Scorecard**: Live breakdown of Vue `<script setup>` ratio, TypeScript ratio, Pinia stores, and .NET Clean Architecture violations.
234
+ - **Design Token Palette**: Interactive color swatches with one-click hex copying and rule usage counts mined across the repository.
235
+ - **Hotspots Backlog**: Filterable table of god components, deprecated deep selectors, mixins, and legacy code.
236
+ - **Zero Dependencies**: Powered by native `node:http`, starts in under 20ms.
237
+
238
+ ---
239
+
167
240
  ## Framework command suites
168
241
 
169
242
  During `plankit init`, PlanKit prompts you to choose optional framework-specific command suites (or pass `--framework <name>` / `--frameworks <names>`):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plankit-cli",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "PlanKit Command Suite CLI for AI Coding Assistants (Antigravity, OpenCode, Codex, Cursor)",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -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
+ }