@objectstack/cli 2.0.7 → 3.0.1

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.
@@ -0,0 +1,303 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import { Command } from 'commander';
4
+ import chalk from 'chalk';
5
+ import { loadConfig } from '../utils/config.js';
6
+ import {
7
+ printHeader,
8
+ printSuccess,
9
+ printWarning,
10
+ printError,
11
+ printInfo,
12
+ printStep,
13
+ createTimer,
14
+ } from '../utils/format.js';
15
+
16
+ // ─── Types ──────────────────────────────────────────────────────────
17
+
18
+ type Severity = 'error' | 'warning' | 'suggestion';
19
+
20
+ interface LintIssue {
21
+ severity: Severity;
22
+ rule: string;
23
+ message: string;
24
+ path: string;
25
+ fix?: string;
26
+ }
27
+
28
+ // ─── Rules ──────────────────────────────────────────────────────────
29
+
30
+ const SNAKE_CASE_RE = /^[a-z][a-z0-9_]*$/;
31
+
32
+ function checkSnakeCase(value: string, path: string, label: string): LintIssue | null {
33
+ if (!SNAKE_CASE_RE.test(value)) {
34
+ return {
35
+ severity: 'error',
36
+ rule: 'naming/snake-case',
37
+ message: `${label} "${value}" must be snake_case`,
38
+ path,
39
+ fix: value.replace(/([A-Z]+)([A-Z][a-z])/g, '$1_$2').replace(/([a-z\d])([A-Z])/g, '$1_$2').toLowerCase().replace(/^_/, '').replace(/-/g, '_'),
40
+ };
41
+ }
42
+ return null;
43
+ }
44
+
45
+ function checkLabelExists(item: any, path: string, kind: string): LintIssue | null {
46
+ if (!item.label) {
47
+ return {
48
+ severity: 'error',
49
+ rule: 'required/label',
50
+ message: `${kind} "${item.name || '?'}" is missing a label`,
51
+ path,
52
+ };
53
+ }
54
+ return null;
55
+ }
56
+
57
+ function checkLabelCase(label: string, path: string): LintIssue | null {
58
+ if (label && label[0] !== label[0].toUpperCase()) {
59
+ return {
60
+ severity: 'warning',
61
+ rule: 'convention/label-case',
62
+ message: `Label "${label}" should start with an uppercase letter`,
63
+ path,
64
+ fix: label.charAt(0).toUpperCase() + label.slice(1),
65
+ };
66
+ }
67
+ return null;
68
+ }
69
+
70
+ // ─── Lint Engine ────────────────────────────────────────────────────
71
+
72
+ function lintConfig(config: any): LintIssue[] {
73
+ const issues: LintIssue[] = [];
74
+
75
+ const push = (issue: LintIssue | null) => {
76
+ if (issue) issues.push(issue);
77
+ };
78
+
79
+ // ── Objects ──
80
+ const objects: any[] = Array.isArray(config.objects) ? config.objects : [];
81
+
82
+ for (let i = 0; i < objects.length; i++) {
83
+ const obj = objects[i];
84
+ const objPath = `objects[${i}]`;
85
+
86
+ // Object name must be snake_case
87
+ if (obj.name) {
88
+ push(checkSnakeCase(obj.name, `${objPath}.name`, 'Object name'));
89
+ }
90
+
91
+ // Object must have label
92
+ push(checkLabelExists(obj, `${objPath}.label`, 'Object'));
93
+
94
+ // Object label conventions
95
+ if (obj.label) {
96
+ push(checkLabelCase(obj.label, `${objPath}.label`));
97
+ }
98
+
99
+ // Fields
100
+ if (obj.fields && typeof obj.fields === 'object') {
101
+ const fieldNames = Object.keys(obj.fields);
102
+
103
+ if (fieldNames.length === 0) {
104
+ issues.push({
105
+ severity: 'warning',
106
+ rule: 'structure/empty-fields',
107
+ message: `Object "${obj.name || '?'}" has an empty fields map`,
108
+ path: `${objPath}.fields`,
109
+ });
110
+ }
111
+
112
+ for (const fieldName of fieldNames) {
113
+ const field = obj.fields[fieldName];
114
+ const fieldPath = `${objPath}.fields.${fieldName}`;
115
+
116
+ // Field key must be snake_case
117
+ push(checkSnakeCase(fieldName, fieldPath, 'Field name'));
118
+
119
+ // Field must have label
120
+ if (field && typeof field === 'object') {
121
+ push(checkLabelExists({ ...field, name: fieldName }, `${fieldPath}.label`, 'Field'));
122
+ if (field.label) {
123
+ push(checkLabelCase(field.label, `${fieldPath}.label`));
124
+ }
125
+ }
126
+ }
127
+ } else if (!obj.fields) {
128
+ issues.push({
129
+ severity: 'error',
130
+ rule: 'structure/no-fields',
131
+ message: `Object "${obj.name || '?'}" has no fields defined`,
132
+ path: `${objPath}.fields`,
133
+ });
134
+ }
135
+ }
136
+
137
+ // ── Views ──
138
+ const views: any[] = Array.isArray(config.views) ? config.views : [];
139
+ for (let i = 0; i < views.length; i++) {
140
+ const view = views[i];
141
+ const viewPath = `views[${i}]`;
142
+ if (view.name) {
143
+ push(checkSnakeCase(view.name, `${viewPath}.name`, 'View name'));
144
+ }
145
+ push(checkLabelExists(view, `${viewPath}.label`, 'View'));
146
+ if (view.label) {
147
+ push(checkLabelCase(view.label, `${viewPath}.label`));
148
+ }
149
+ }
150
+
151
+ // ── Apps ──
152
+ const apps: any[] = Array.isArray(config.apps) ? config.apps : [];
153
+ for (let i = 0; i < apps.length; i++) {
154
+ const app = apps[i];
155
+ const appPath = `apps[${i}]`;
156
+ if (app.name) {
157
+ push(checkSnakeCase(app.name, `${appPath}.name`, 'App name'));
158
+ }
159
+ push(checkLabelExists(app, `${appPath}.label`, 'App'));
160
+ if (app.label) {
161
+ push(checkLabelCase(app.label, `${appPath}.label`));
162
+ }
163
+ }
164
+
165
+ // ── Flows ──
166
+ const flows: any[] = Array.isArray(config.flows) ? config.flows : [];
167
+ for (let i = 0; i < flows.length; i++) {
168
+ const flow = flows[i];
169
+ const flowPath = `flows[${i}]`;
170
+ if (flow.name) {
171
+ push(checkSnakeCase(flow.name, `${flowPath}.name`, 'Flow name'));
172
+ }
173
+ }
174
+
175
+ // ── Agents ──
176
+ const agents: any[] = Array.isArray(config.agents) ? config.agents : [];
177
+ for (let i = 0; i < agents.length; i++) {
178
+ const agent = agents[i];
179
+ const agentPath = `agents[${i}]`;
180
+ if (agent.name) {
181
+ push(checkSnakeCase(agent.name, `${agentPath}.name`, 'Agent name'));
182
+ }
183
+ }
184
+
185
+ return issues;
186
+ }
187
+
188
+ // ─── Command ────────────────────────────────────────────────────────
189
+
190
+ export const lintCommand = new Command('lint')
191
+ .description('Check ObjectStack configuration for style and convention issues')
192
+ .argument('[config]', 'Configuration file path')
193
+ .option('--json', 'Output as JSON')
194
+ .option('--fix', 'Show what would be fixed (dry-run)')
195
+ .action(async (configPath, options) => {
196
+ const timer = createTimer();
197
+
198
+ if (!options.json) {
199
+ printHeader('Lint');
200
+ printStep('Loading configuration...');
201
+ }
202
+
203
+ try {
204
+ const { config, absolutePath } = await loadConfig(configPath);
205
+
206
+ if (!options.json) {
207
+ printInfo(`Config: ${chalk.white(absolutePath)}`);
208
+ }
209
+
210
+ const issues = lintConfig(config);
211
+
212
+ // ── JSON output ──
213
+ if (options.json) {
214
+ const errors = issues.filter((i) => i.severity === 'error');
215
+ const warnings = issues.filter((i) => i.severity === 'warning');
216
+ const suggestions = issues.filter((i) => i.severity === 'suggestion');
217
+ console.log(JSON.stringify({
218
+ passed: errors.length === 0,
219
+ total: issues.length,
220
+ errors: errors.length,
221
+ warnings: warnings.length,
222
+ suggestions: suggestions.length,
223
+ issues,
224
+ duration: timer.elapsed(),
225
+ }, null, 2));
226
+ if (errors.length > 0) process.exit(1);
227
+ return;
228
+ }
229
+
230
+ console.log('');
231
+
232
+ if (issues.length === 0) {
233
+ printSuccess(`All checks passed ${chalk.dim(`(${timer.display()})`)}`);
234
+ console.log('');
235
+ return;
236
+ }
237
+
238
+ // Group by severity
239
+ const errors = issues.filter((i) => i.severity === 'error');
240
+ const warnings = issues.filter((i) => i.severity === 'warning');
241
+ const suggestions = issues.filter((i) => i.severity === 'suggestion');
242
+
243
+ const printIssue = (issue: LintIssue) => {
244
+ const color =
245
+ issue.severity === 'error' ? chalk.red :
246
+ issue.severity === 'warning' ? chalk.yellow :
247
+ chalk.blue;
248
+ const icon =
249
+ issue.severity === 'error' ? '✗' :
250
+ issue.severity === 'warning' ? '⚠' :
251
+ 'ℹ';
252
+
253
+ console.log(` ${color(icon)} ${color(issue.message)}`);
254
+ console.log(chalk.dim(` ${issue.rule} at ${issue.path}`));
255
+ if (options.fix && issue.fix) {
256
+ console.log(chalk.green(` → fix: ${issue.fix}`));
257
+ }
258
+ };
259
+
260
+ if (errors.length > 0) {
261
+ console.log(chalk.bold.red(` Errors (${errors.length})`));
262
+ errors.forEach(printIssue);
263
+ console.log('');
264
+ }
265
+
266
+ if (warnings.length > 0) {
267
+ console.log(chalk.bold.yellow(` Warnings (${warnings.length})`));
268
+ warnings.forEach(printIssue);
269
+ console.log('');
270
+ }
271
+
272
+ if (suggestions.length > 0) {
273
+ console.log(chalk.bold.blue(` Suggestions (${suggestions.length})`));
274
+ suggestions.forEach(printIssue);
275
+ console.log('');
276
+ }
277
+
278
+ // Summary
279
+ const parts: string[] = [];
280
+ if (errors.length > 0) parts.push(chalk.red(`${errors.length} error(s)`));
281
+ if (warnings.length > 0) parts.push(chalk.yellow(`${warnings.length} warning(s)`));
282
+ if (suggestions.length > 0) parts.push(chalk.blue(`${suggestions.length} suggestion(s)`));
283
+ console.log(` ${parts.join(', ')} ${chalk.dim(`(${timer.display()})`)}`);
284
+
285
+ if (options.fix) {
286
+ console.log('');
287
+ printInfo('Dry-run mode: no files were modified.');
288
+ }
289
+
290
+ console.log('');
291
+
292
+ if (errors.length > 0) process.exit(1);
293
+
294
+ } catch (error: any) {
295
+ if (options.json) {
296
+ console.log(JSON.stringify({ error: error.message }));
297
+ process.exit(1);
298
+ }
299
+ console.log('');
300
+ printError(error.message || String(error));
301
+ process.exit(1);
302
+ }
303
+ });