newo 3.6.2 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +37 -3
  2. package/README.md +61 -0
  3. package/dist/cli/commands/check.d.ts +3 -0
  4. package/dist/cli/commands/check.js +15 -0
  5. package/dist/cli/commands/format.d.ts +3 -0
  6. package/dist/cli/commands/format.js +105 -0
  7. package/dist/cli/commands/help.js +13 -0
  8. package/dist/cli/commands/lint.d.ts +3 -0
  9. package/dist/cli/commands/lint.js +195 -0
  10. package/dist/cli-new/di/tokens.d.ts +1 -1
  11. package/dist/cli.js +45 -9
  12. package/dist/lint/config.d.ts +4 -0
  13. package/dist/lint/config.js +14 -0
  14. package/dist/lint/discovery.d.ts +34 -0
  15. package/dist/lint/discovery.js +112 -0
  16. package/dist/lint/live-schema.d.ts +20 -0
  17. package/dist/lint/live-schema.js +52 -0
  18. package/dist/lint/reporters/index.d.ts +4 -0
  19. package/dist/lint/reporters/index.js +19 -0
  20. package/dist/lint/reporters/json.d.ts +3 -0
  21. package/dist/lint/reporters/json.js +6 -0
  22. package/dist/lint/reporters/sarif.d.ts +3 -0
  23. package/dist/lint/reporters/sarif.js +47 -0
  24. package/dist/lint/reporters/text.d.ts +3 -0
  25. package/dist/lint/reporters/text.js +51 -0
  26. package/dist/lint/reporters/types.d.ts +6 -0
  27. package/dist/lint/reporters/types.js +2 -0
  28. package/dist/sync/conversations.d.ts +1 -1
  29. package/dist/sync/conversations.js +240 -193
  30. package/package.json +3 -1
  31. package/src/cli/commands/check.ts +21 -0
  32. package/src/cli/commands/format.ts +131 -0
  33. package/src/cli/commands/help.ts +13 -0
  34. package/src/cli/commands/lint.ts +246 -0
  35. package/src/cli.ts +50 -9
  36. package/src/lint/config.ts +17 -0
  37. package/src/lint/discovery.ts +148 -0
  38. package/src/lint/live-schema.ts +62 -0
  39. package/src/lint/reporters/index.ts +22 -0
  40. package/src/lint/reporters/json.ts +12 -0
  41. package/src/lint/reporters/sarif.ts +59 -0
  42. package/src/lint/reporters/text.ts +58 -0
  43. package/src/lint/reporters/types.ts +7 -0
  44. package/src/sync/conversations.ts +265 -212
@@ -0,0 +1,246 @@
1
+ /**
2
+ * `newo lint` - static analysis over Guidance / Jinja / NSL / NSLG files.
3
+ *
4
+ * Wraps newo-dsl-analyzer with newo-cli's customer/format/hash primitives.
5
+ * Exit codes:
6
+ * 0 clean (or only warnings below --max-warnings)
7
+ * 1 lint errors found, or warning threshold exceeded
8
+ * 2 unexpected runtime failure (handled by handleCliError)
9
+ */
10
+ import fs from 'fs-extra';
11
+ import path from 'path';
12
+ import {
13
+ createLinter,
14
+ type LinterOptions,
15
+ type ProjectLintReport,
16
+ type RuleSeverity,
17
+ } from 'newo-dsl-analyzer';
18
+
19
+ import { selectSingleCustomer } from '../customer-selection.js';
20
+ import { handleCliError } from '../errors.js';
21
+ import { resolveFormat } from '../../format/detect.js';
22
+ import { discoverCustomerFiles, discoverFromPath, defaultRoot } from '../../lint/discovery.js';
23
+ import { loadNewoLintConfig } from '../../lint/config.js';
24
+ import { refreshLiveSchema, loadCachedLiveSchema } from '../../lint/live-schema.js';
25
+ import { pickReporter } from '../../lint/reporters/index.js';
26
+ import type { MultiCustomerConfig, CliArgs, CustomerConfig } from '../../types.js';
27
+
28
+ interface LintArgs {
29
+ positional: string[];
30
+ formatVersion: string | undefined;
31
+ reporter: string;
32
+ maxWarnings: number;
33
+ quiet: boolean;
34
+ rules: string[];
35
+ noRules: string[];
36
+ changed: boolean;
37
+ live: boolean;
38
+ customer: string | undefined;
39
+ }
40
+
41
+ export async function handleLintCommand(
42
+ customerConfig: MultiCustomerConfig,
43
+ args: CliArgs,
44
+ verbose: boolean,
45
+ ): Promise<void> {
46
+ try {
47
+ const lintArgs = parseArgs(args);
48
+ const report = await run(customerConfig, lintArgs, verbose);
49
+ const reporter = pickReporter(lintArgs.reporter);
50
+ const output = reporter.write(report);
51
+ if (output.trim().length > 0) process.stdout.write(output + '\n');
52
+
53
+ const exitCode = determineExitCode(report, lintArgs);
54
+ if (exitCode !== 0) process.exit(exitCode);
55
+ } catch (err) {
56
+ handleCliError(err, 'lint');
57
+ }
58
+ }
59
+
60
+ function parseArgs(args: CliArgs): LintArgs {
61
+ const positional = args._.slice(1).filter((p): p is string => typeof p === 'string');
62
+
63
+ // Multiple flag shapes: --rule=E100, --rule E100,W100
64
+ // Disabling rules uses --rule-off (not --no-rule) because minimist treats
65
+ // `--no-X` as `X: false` and swallows the next positional argument.
66
+ const rules = collectCsv(args.rule);
67
+ const noRules = collectCsv(args['rule-off']);
68
+
69
+ return {
70
+ positional,
71
+ formatVersion: args.format as string | undefined,
72
+ reporter: (args.reporter as string | undefined) ?? (args['output-format'] as string | undefined) ?? 'text',
73
+ maxWarnings: parseIntOr(args['max-warnings'], Number.POSITIVE_INFINITY),
74
+ quiet: Boolean(args.quiet),
75
+ rules,
76
+ noRules,
77
+ changed: Boolean(args.changed),
78
+ live: Boolean(args.live),
79
+ customer: args.customer as string | undefined,
80
+ };
81
+ }
82
+
83
+ async function run(
84
+ customerConfig: MultiCustomerConfig,
85
+ args: LintArgs,
86
+ verbose: boolean,
87
+ ): Promise<ProjectLintReport> {
88
+ const config = loadNewoLintConfig();
89
+
90
+ // With explicit positional paths AND no customer/live flag, skip customer
91
+ // selection entirely - lint operates purely on the given filesystem paths.
92
+ const hasCustomerContext =
93
+ args.customer !== undefined ||
94
+ args.live ||
95
+ Object.keys(customerConfig.customers ?? {}).length > 0;
96
+
97
+ const { selectedCustomer, allCustomers, isMultiCustomer } = hasCustomerContext
98
+ ? selectSingleCustomer(customerConfig, args.customer)
99
+ : { selectedCustomer: null, allCustomers: [] as CustomerConfig[], isMultiCustomer: false };
100
+
101
+ const targetCustomer = selectedCustomer ?? (isMultiCustomer ? null : allCustomers[0] ?? null);
102
+
103
+ const schemas = await resolveSchemas(targetCustomer, args, verbose);
104
+
105
+ const ruleOverrides: Record<string, RuleSeverity> = {
106
+ ...(config.rules ?? {}),
107
+ };
108
+ for (const code of args.noRules) ruleOverrides[code] = 'off';
109
+ // --rule enables; we map unknown codes to 'warning' to avoid silently accepting typos.
110
+ for (const code of args.rules) {
111
+ if (!(code in ruleOverrides) || ruleOverrides[code] === 'off') {
112
+ ruleOverrides[code] = 'warning';
113
+ }
114
+ }
115
+
116
+ const linterOpts: LinterOptions = {
117
+ rules: ruleOverrides,
118
+ ...(schemas !== undefined ? { schemas } : {}),
119
+ };
120
+ const linter = createLinter(linterOpts);
121
+
122
+ const files = await resolveFiles(targetCustomer, allCustomers, args, isMultiCustomer);
123
+ if (files.length === 0) {
124
+ return { results: [], errorCount: 0, warningCount: 0 };
125
+ }
126
+
127
+ let errorCount = 0;
128
+ let warningCount = 0;
129
+ const results = [];
130
+ for (const file of files) {
131
+ const source = await fs.readFile(file.absPath, 'utf8');
132
+ const result = linter.lint(source, file.absPath);
133
+ for (const d of result.diagnostics) {
134
+ if (d.severity === 'error') errorCount++;
135
+ else if (d.severity === 'warning') warningCount++;
136
+ }
137
+ if (args.quiet) {
138
+ result.diagnostics = result.diagnostics.filter(d => d.severity === 'error');
139
+ }
140
+ results.push(result);
141
+ }
142
+ return { results, errorCount, warningCount: args.quiet ? 0 : warningCount };
143
+ }
144
+
145
+ async function resolveSchemas(
146
+ customer: CustomerConfig | null,
147
+ args: LintArgs,
148
+ verbose: boolean,
149
+ ): Promise<LinterOptions['schemas']> {
150
+ if (!customer) return 'bundled';
151
+
152
+ if (args.live) {
153
+ if (verbose) console.log(`Refreshing live schemas for ${customer.idn}...`);
154
+ const snapshot = await refreshLiveSchema(customer);
155
+ return { kind: 'inline', actions: snapshot.actions };
156
+ }
157
+
158
+ // Auto-use cached live snapshot if it exists (faster, always specific to
159
+ // the customer's actual NEWO account state). Fall back to bundled.
160
+ const cached = await loadCachedLiveSchema(customer.idn);
161
+ if (cached) {
162
+ if (verbose) {
163
+ const age = Math.round((Date.now() - Date.parse(cached.fetchedAt)) / 1000 / 60);
164
+ console.log(`Using cached schemas for ${customer.idn} (${age} min old). Use --live to refresh.`);
165
+ }
166
+ return { kind: 'inline', actions: cached.actions };
167
+ }
168
+
169
+ return 'bundled';
170
+ }
171
+
172
+ async function resolveFiles(
173
+ selected: CustomerConfig | null,
174
+ all: CustomerConfig[],
175
+ args: LintArgs,
176
+ isMultiCustomer: boolean,
177
+ ): ReturnType<typeof discoverFromPath> {
178
+ // Explicit positional paths beat everything else.
179
+ if (args.positional.length > 0) {
180
+ const files = [];
181
+ for (const p of args.positional) {
182
+ const discovered = await discoverFromPath(p, {
183
+ ...(args.formatVersion ? { format: toFormatVersion(args.formatVersion) } : {}),
184
+ });
185
+ files.push(...discovered);
186
+ }
187
+ return files;
188
+ }
189
+
190
+ if (selected) {
191
+ const formatVersion = resolveFormat(selected.idn, args.formatVersion).version;
192
+ return discoverCustomerFiles(selected, {
193
+ format: formatVersion,
194
+ changedOnly: args.changed,
195
+ });
196
+ }
197
+
198
+ if (isMultiCustomer) {
199
+ const files = [];
200
+ for (const customer of all) {
201
+ const formatVersion = resolveFormat(customer.idn, args.formatVersion).version;
202
+ const customerFiles = await discoverCustomerFiles(customer, {
203
+ format: formatVersion,
204
+ changedOnly: args.changed,
205
+ });
206
+ files.push(...customerFiles);
207
+ }
208
+ return files;
209
+ }
210
+
211
+ // No customer context - lint cwd / newo_customers/ directly.
212
+ return discoverFromPath(defaultRoot(), {
213
+ ...(args.formatVersion ? { format: toFormatVersion(args.formatVersion) } : {}),
214
+ });
215
+ }
216
+
217
+ function determineExitCode(report: ProjectLintReport, args: LintArgs): number {
218
+ if (report.errorCount > 0) return 1;
219
+ if (report.warningCount > args.maxWarnings) return 1;
220
+ return 0;
221
+ }
222
+
223
+ function collectCsv(value: unknown): string[] {
224
+ if (value === undefined || value === null) return [];
225
+ const items = Array.isArray(value) ? value : [value];
226
+ return items
227
+ .flatMap((v: unknown) => String(v).split(','))
228
+ .map(s => s.trim())
229
+ .filter(s => s.length > 0);
230
+ }
231
+
232
+ function parseIntOr(value: unknown, fallback: number): number {
233
+ if (typeof value === 'number' && Number.isFinite(value)) return value;
234
+ if (typeof value === 'string') {
235
+ const parsed = Number.parseInt(value, 10);
236
+ if (!Number.isNaN(parsed)) return parsed;
237
+ }
238
+ return fallback;
239
+ }
240
+
241
+ function toFormatVersion(v: string): 'cli_v1' | 'newo_v2' {
242
+ return v === 'newo_v2' ? 'newo_v2' : 'cli_v1';
243
+ }
244
+
245
+ // Silence unused path import warning; path is used via discovery helpers.
246
+ void path;
package/src/cli.ts CHANGED
@@ -47,19 +47,14 @@ import { handleWatchCommand } from './cli/commands/watch.js';
47
47
  import { handleDiffCommand } from './cli/commands/diff.js';
48
48
  import { handleLogsCommand } from './cli/commands/logs.js';
49
49
  import { handleExportCommand } from './cli/commands/export.js';
50
+ import { handleLintCommand } from './cli/commands/lint.js';
51
+ import { handleFormatCommand } from './cli/commands/format.js';
52
+ import { handleCheckCommand } from './cli/commands/check.js';
50
53
  import type { CliArgs, NewoApiError } from './types.js';
51
54
 
52
55
  dotenv.config();
53
56
 
54
57
  async function main(): Promise<void> {
55
- try {
56
- // Initialize and validate environment at startup
57
- initializeEnvironment();
58
- } catch (error: unknown) {
59
- console.error('Environment validation failed:', error instanceof Error ? error.message : String(error));
60
- process.exit(1);
61
- }
62
-
63
58
  const args = minimist(process.argv.slice(2)) as CliArgs;
64
59
  const cmd = args._[0];
65
60
  const verbose = Boolean(args.verbose || args.v);
@@ -72,12 +67,46 @@ async function main(): Promise<void> {
72
67
 
73
68
  if (verbose) console.log(`🔍 Command parsed: "${cmd}"`);
74
69
 
75
- // Handle help command first
70
+ // Handle help command first - no env or customer config needed
76
71
  if (!cmd || ['help', '-h', '--help'].includes(cmd)) {
77
72
  handleHelpCommand();
78
73
  return;
79
74
  }
80
75
 
76
+ // Offline commands: lint/format/check don't need NEWO credentials
77
+ // UNLESS the user passes --customer or --live (both touch the API).
78
+ const isOfflineLint =
79
+ (cmd === 'lint' || cmd === 'format' || cmd === 'check') &&
80
+ !args.customer &&
81
+ !args.live;
82
+
83
+ if (isOfflineLint) {
84
+ try {
85
+ const emptyConfig: import('./types.js').MultiCustomerConfig = { customers: {} };
86
+ switch (cmd) {
87
+ case 'lint':
88
+ await handleLintCommand(emptyConfig, args, verbose);
89
+ return;
90
+ case 'format':
91
+ await handleFormatCommand(emptyConfig, args, verbose);
92
+ return;
93
+ case 'check':
94
+ await handleCheckCommand(emptyConfig, args, verbose);
95
+ return;
96
+ }
97
+ } catch (error: unknown) {
98
+ handleCliError(error, cmd);
99
+ }
100
+ }
101
+
102
+ // All other commands (and customer-scoped lint/format/check) require env + customer config.
103
+ try {
104
+ initializeEnvironment();
105
+ } catch (error: unknown) {
106
+ console.error('Environment validation failed:', error instanceof Error ? error.message : String(error));
107
+ process.exit(1);
108
+ }
109
+
81
110
  // Handle list-customers command (doesn't need full customer config)
82
111
  if (cmd === 'list-customers') {
83
112
  try {
@@ -249,6 +278,18 @@ async function main(): Promise<void> {
249
278
  await handleDiffCommand(customerConfig, args, verbose);
250
279
  break;
251
280
 
281
+ case 'lint':
282
+ await handleLintCommand(customerConfig, args, verbose);
283
+ break;
284
+
285
+ case 'format':
286
+ await handleFormatCommand(customerConfig, args, verbose);
287
+ break;
288
+
289
+ case 'check':
290
+ await handleCheckCommand(customerConfig, args, verbose);
291
+ break;
292
+
252
293
  default:
253
294
  console.error('Unknown command:', cmd);
254
295
  console.error('Run "newo --help" for usage information');
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Lint config resolution for the newo CLI.
3
+ *
4
+ * Looks for `.neworc.yaml` / `.neworc.yml` / `.neworc.json` starting at
5
+ * the cwd and walking up to the filesystem root. Thin wrapper around
6
+ * newo-dsl-analyzer's `loadConfig` so consumers can override location
7
+ * per command if they need to.
8
+ */
9
+ import path from 'path';
10
+ import { loadConfig as analyzerLoadConfig } from 'newo-dsl-analyzer';
11
+ import type { NewoLintConfig } from 'newo-dsl-analyzer';
12
+
13
+ export type { NewoLintConfig } from 'newo-dsl-analyzer';
14
+
15
+ export function loadNewoLintConfig(startDir: string = process.cwd()): NewoLintConfig {
16
+ return analyzerLoadConfig(path.resolve(startDir)) ?? {};
17
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * File discovery for `newo lint` / `newo format` / `newo check`.
3
+ *
4
+ * Walks a customer's tree (or any directory passed on the CLI), filters
5
+ * by format-aware extensions, and optionally narrows to files changed
6
+ * since the last push by consulting `.newo/{customer}/hashes.json`.
7
+ */
8
+ import fs from 'fs-extra';
9
+ import path from 'path';
10
+ import { NEWO_CUSTOMERS_DIR, customerDir } from '../fsutil.js';
11
+ import { loadHashes } from '../hash.js';
12
+ import { sha256 } from '../hash.js';
13
+ import {
14
+ ALL_SCRIPT_EXTENSIONS,
15
+ type FormatVersion,
16
+ CLI_V1_EXTENSIONS,
17
+ NEWO_V2_EXTENSIONS,
18
+ } from '../format/types.js';
19
+ import type { CustomerConfig } from '../types.js';
20
+
21
+ export interface DiscoveryOptions {
22
+ /** If set, restrict to one format's extensions only. */
23
+ format?: FormatVersion;
24
+ /** If true, include only files that hash-differ from `.newo/{customer}/hashes.json`. */
25
+ changedOnly?: boolean;
26
+ /** Additional absolute paths to skip. */
27
+ ignore?: string[];
28
+ }
29
+
30
+ export interface DiscoveredFile {
31
+ /** Absolute path on disk. */
32
+ absPath: string;
33
+ /** Path relative to the customer root (for display / reports). */
34
+ relPath: string;
35
+ /** Extension (including leading dot). */
36
+ ext: string;
37
+ }
38
+
39
+ /**
40
+ * Discover script files under a customer's tree.
41
+ * Respects format when given (else walks all recognized extensions).
42
+ */
43
+ export async function discoverCustomerFiles(
44
+ customer: CustomerConfig,
45
+ opts: DiscoveryOptions = {},
46
+ ): Promise<DiscoveredFile[]> {
47
+ const root = customerDir(customer.idn);
48
+ if (!(await fs.pathExists(root))) return [];
49
+
50
+ const exts = pickExtensions(opts.format);
51
+ const ignoreSet = new Set(opts.ignore ?? []);
52
+
53
+ const hits = await walkForExtensions(root, exts, ignoreSet);
54
+
55
+ if (!opts.changedOnly) {
56
+ return hits.map(absPath => toDiscoveredFile(absPath, root));
57
+ }
58
+
59
+ const stored = await loadHashes(customer.idn);
60
+ const changed: DiscoveredFile[] = [];
61
+ for (const absPath of hits) {
62
+ const current = sha256(await fs.readFile(absPath, 'utf8'));
63
+ if (stored[absPath] !== current) {
64
+ changed.push(toDiscoveredFile(absPath, root));
65
+ }
66
+ }
67
+ return changed;
68
+ }
69
+
70
+ /**
71
+ * Discover files under an arbitrary directory.
72
+ * Used when the user passes explicit paths to `newo lint some/dir`.
73
+ */
74
+ export async function discoverFromPath(
75
+ inputPath: string,
76
+ opts: DiscoveryOptions = {},
77
+ ): Promise<DiscoveredFile[]> {
78
+ const abs = path.resolve(inputPath);
79
+ if (!(await fs.pathExists(abs))) return [];
80
+
81
+ const exts = pickExtensions(opts.format);
82
+ const ignoreSet = new Set(opts.ignore ?? []);
83
+ const stat = await fs.stat(abs);
84
+
85
+ let hits: string[];
86
+ if (stat.isFile()) {
87
+ hits = exts.includes(path.extname(abs)) ? [abs] : [];
88
+ } else {
89
+ hits = await walkForExtensions(abs, exts, ignoreSet);
90
+ }
91
+
92
+ const root = stat.isDirectory() ? abs : path.dirname(abs);
93
+ return hits.map(p => toDiscoveredFile(p, root));
94
+ }
95
+
96
+ function pickExtensions(format?: FormatVersion): string[] {
97
+ if (!format) return [...ALL_SCRIPT_EXTENSIONS];
98
+ const map = format === 'newo_v2' ? NEWO_V2_EXTENSIONS : CLI_V1_EXTENSIONS;
99
+ return Object.values(map);
100
+ }
101
+
102
+ function toDiscoveredFile(absPath: string, root: string): DiscoveredFile {
103
+ return {
104
+ absPath,
105
+ relPath: path.relative(root, absPath),
106
+ ext: path.extname(absPath),
107
+ };
108
+ }
109
+
110
+ async function walkForExtensions(
111
+ dir: string,
112
+ exts: string[],
113
+ ignore: Set<string>,
114
+ ): Promise<string[]> {
115
+ const out: string[] = [];
116
+ const stack: string[] = [dir];
117
+ while (stack.length > 0) {
118
+ const current = stack.pop()!;
119
+ if (ignore.has(current)) continue;
120
+ let entries: fs.Dirent[];
121
+ try {
122
+ entries = await fs.readdir(current, { withFileTypes: true });
123
+ } catch {
124
+ continue;
125
+ }
126
+ for (const entry of entries) {
127
+ // Skip hidden dirs, node_modules, and the .newo state directory.
128
+ if (entry.name.startsWith('.')) continue;
129
+ if (entry.name === 'node_modules') continue;
130
+ const full = path.join(current, entry.name);
131
+ if (ignore.has(full)) continue;
132
+ if (entry.isDirectory()) {
133
+ stack.push(full);
134
+ } else if (exts.includes(path.extname(entry.name))) {
135
+ out.push(full);
136
+ }
137
+ }
138
+ }
139
+ return out.sort();
140
+ }
141
+
142
+ /**
143
+ * Default root for lint invocations with no path arguments - the
144
+ * `newo_customers/` directory at the cwd.
145
+ */
146
+ export function defaultRoot(): string {
147
+ return NEWO_CUSTOMERS_DIR;
148
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Live schema refresh: hits `/api/v1/script/actions` via the existing
3
+ * NEWO api client, caches the response to `.newo/{customer}/actions.json`,
4
+ * and returns an object shaped for `createLinter({ schemas: { kind: 'inline', ... }})`.
5
+ */
6
+ import fs from 'fs-extra';
7
+ import path from 'path';
8
+ import { customerStateDir } from '../fsutil.js';
9
+ import type { CustomerConfig, ScriptAction } from '../types.js';
10
+ import { getValidAccessToken } from '../auth.js';
11
+ import { makeClient, getScriptActions } from '../api.js';
12
+
13
+ export interface LiveSchemaSnapshot {
14
+ actions: Array<{ name: string; [k: string]: unknown }>;
15
+ fetchedAt: string;
16
+ }
17
+
18
+ export async function liveSchemaCachePath(customerIdn: string): Promise<string> {
19
+ const dir = customerStateDir(customerIdn);
20
+ await fs.ensureDir(dir);
21
+ return path.join(dir, 'actions.json');
22
+ }
23
+
24
+ /**
25
+ * Fetch the current action catalog from NEWO and cache it.
26
+ * Returns a snapshot ready to pass to `createLinter`.
27
+ */
28
+ export async function refreshLiveSchema(
29
+ customer: CustomerConfig,
30
+ ): Promise<LiveSchemaSnapshot> {
31
+ const token = await getValidAccessToken(customer);
32
+ const client = await makeClient(false, token);
33
+ const actions = await getScriptActions(client);
34
+ const snapshot: LiveSchemaSnapshot = {
35
+ actions: actions.map((a: ScriptAction) => ({
36
+ name: a.idn ?? a.title,
37
+ title: a.title,
38
+ ...(a.idn !== undefined ? { idn: a.idn } : {}),
39
+ arguments: a.arguments,
40
+ })),
41
+ fetchedAt: new Date().toISOString(),
42
+ };
43
+ const cachePath = await liveSchemaCachePath(customer.idn);
44
+ await fs.writeJson(cachePath, snapshot, { spaces: 2 });
45
+ return snapshot;
46
+ }
47
+
48
+ /**
49
+ * Load the cached snapshot if present. Returns null when the cache is
50
+ * missing or corrupt (caller should fall back to bundled schemas).
51
+ */
52
+ export async function loadCachedLiveSchema(
53
+ customerIdn: string,
54
+ ): Promise<LiveSchemaSnapshot | null> {
55
+ const cachePath = await liveSchemaCachePath(customerIdn);
56
+ if (!(await fs.pathExists(cachePath))) return null;
57
+ try {
58
+ return (await fs.readJson(cachePath)) as LiveSchemaSnapshot;
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
@@ -0,0 +1,22 @@
1
+ import { textReporter } from './text.js';
2
+ import { jsonReporter } from './json.js';
3
+ import { sarifReporter } from './sarif.js';
4
+ import type { Reporter, ReporterName } from './types.js';
5
+
6
+ export type { Reporter, ReporterName } from './types.js';
7
+
8
+ export function pickReporter(name: ReporterName | string | undefined): Reporter {
9
+ switch (name) {
10
+ case 'json':
11
+ return jsonReporter;
12
+ case 'sarif':
13
+ return sarifReporter;
14
+ case 'text':
15
+ case undefined:
16
+ case '':
17
+ return textReporter;
18
+ default:
19
+ console.warn(`Unknown --format value '${name}', defaulting to text.`);
20
+ return textReporter;
21
+ }
22
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Machine-readable JSON reporter. Matches the ProjectLintReport shape
3
+ * exactly - consumers can parse with any JSON tool.
4
+ */
5
+ import type { ProjectLintReport } from 'newo-dsl-analyzer';
6
+ import type { Reporter } from './types.js';
7
+
8
+ export const jsonReporter: Reporter = {
9
+ write(report: ProjectLintReport): string {
10
+ return JSON.stringify(report, null, 2);
11
+ },
12
+ };
@@ -0,0 +1,59 @@
1
+ /**
2
+ * SARIF 2.1.0 reporter - lets GitHub Advanced Security / Code Scanning
3
+ * pick up newo-lint findings alongside CodeQL and other linters.
4
+ *
5
+ * Spec: https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
6
+ */
7
+ import type { ProjectLintReport, Diagnostic } from 'newo-dsl-analyzer';
8
+ import type { Reporter } from './types.js';
9
+
10
+ export const sarifReporter: Reporter = {
11
+ write(report: ProjectLintReport): string {
12
+ const sarif = {
13
+ $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json',
14
+ version: '2.1.0',
15
+ runs: [
16
+ {
17
+ tool: {
18
+ driver: {
19
+ name: 'newo-lint',
20
+ informationUri: 'https://github.com/sabbah13/newo-cli',
21
+ rules: [] as Array<{ id: string }>,
22
+ },
23
+ },
24
+ results: report.results.flatMap(r =>
25
+ r.diagnostics.map(d => buildResult(r.filePath, d)),
26
+ ),
27
+ },
28
+ ],
29
+ };
30
+ return JSON.stringify(sarif, null, 2);
31
+ },
32
+ };
33
+
34
+ function buildResult(filePath: string, d: Diagnostic): Record<string, unknown> {
35
+ return {
36
+ ruleId: d.code,
37
+ level: d.severity === 'error' ? 'error' : d.severity === 'warning' ? 'warning' : 'note',
38
+ message: { text: d.message },
39
+ locations: [
40
+ {
41
+ physicalLocation: {
42
+ artifactLocation: { uri: toUri(filePath) },
43
+ region: {
44
+ startLine: d.range.start.line,
45
+ startColumn: d.range.start.column,
46
+ endLine: d.range.end.line,
47
+ endColumn: d.range.end.column,
48
+ },
49
+ },
50
+ },
51
+ ],
52
+ };
53
+ }
54
+
55
+ function toUri(absPath: string): string {
56
+ // SARIF artifact URIs should be workspace-relative when possible.
57
+ const rel = absPath.replace(process.cwd() + '/', '');
58
+ return rel.replace(/\\/g, '/');
59
+ }