naider 1.24.5 → 2.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,785 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { relative, resolve, sep } from 'node:path';
5
+ import { compile } from './index.js';
6
+
7
+ const PACKAGE_ADAPTERS = {
8
+ npm: {
9
+ description: 'Node.js packages from npm',
10
+ validate(spec) {
11
+ const match = spec.match(/^(?<name>@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._~-]*|[a-z0-9][a-z0-9._~-]*)(?:@(?<version>latest|[A-Za-z][A-Za-z0-9._-]*|[~^]?v?\d+(?:\.\d+){0,2}(?:-[A-Za-z0-9.-]+)?(?:\+[A-Za-z0-9.-]+)?))?$/);
12
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
13
+ },
14
+ install(spec, parsed) {
15
+ return { executable: 'npm', args: ['install', '--', spec], dependency: parsed.name };
16
+ },
17
+ },
18
+ pip: {
19
+ description: 'Python packages from PyPI',
20
+ validate(spec) {
21
+ const match = spec.match(/^(?<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?:\[(?<extras>[A-Za-z0-9_,.-]+)\])?(?<version>(?:===|==|~=|!=|<=|>=|<|>)[A-Za-z0-9.*!+_-]+(?:,(?:===|==|~=|!=|<=|>=|<|>)[A-Za-z0-9.*!+_-]+)*)?$/);
22
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
23
+ },
24
+ install(spec, parsed, directory, options) {
25
+ const localInterpreter = resolve(directory, '.venv', process.platform === 'win32'
26
+ ? 'Scripts/python.exe'
27
+ : 'bin/python');
28
+ const activeInterpreter = process.env.VIRTUAL_ENV
29
+ ? resolve(process.env.VIRTUAL_ENV, process.platform === 'win32'
30
+ ? 'Scripts/python.exe'
31
+ : 'bin/python')
32
+ : null;
33
+ let executable = options.pythonExecutable
34
+ || (activeInterpreter && existsSync(activeInterpreter) ? activeInterpreter : null);
35
+ if (existsSync(localInterpreter)) executable = localInterpreter;
36
+ if (!executable) {
37
+ throw new Error('Python installs require an active virtual environment or a project .venv. Create one with "python -m venv .venv", activate it, then retry.');
38
+ }
39
+ return {
40
+ executable,
41
+ args: ['-m', 'pip', 'install', '--', spec],
42
+ dependency: parsed.name,
43
+ note: 'Installs into the selected virtual environment and records the requirement in requirements.txt.',
44
+ };
45
+ },
46
+ },
47
+ cargo: {
48
+ description: 'Rust crates from crates.io',
49
+ validate(spec) {
50
+ const match = spec.match(/^(?<name>[A-Za-z0-9][A-Za-z0-9_-]*)(?:@(?<version>\*|[A-Za-z0-9.+-]+))?$/);
51
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
52
+ },
53
+ install(spec, parsed) {
54
+ return { executable: 'cargo', args: ['add', spec], dependency: parsed.name };
55
+ },
56
+ },
57
+ go: {
58
+ description: 'Go modules',
59
+ validate(spec) {
60
+ const match = spec.match(/^(?<name>[A-Za-z0-9.-]+(?:\/[A-Za-z0-9._~-]+)+(?:\/[A-Za-z0-9._~-]+)*)(?:@(?<version>v[0-9][A-Za-z0-9.+-]*))?$/);
61
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
62
+ },
63
+ install(spec, parsed) {
64
+ return { executable: 'go', args: ['get', spec], dependency: parsed.name };
65
+ },
66
+ },
67
+ dotnet: {
68
+ description: '.NET packages from NuGet',
69
+ validate(spec) {
70
+ const match = spec.match(/^(?<name>[A-Za-z0-9][A-Za-z0-9._-]*)(?:@(?<version>[0-9][A-Za-z0-9.+-]*))?$/);
71
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
72
+ },
73
+ install(spec, parsed) {
74
+ return {
75
+ executable: 'dotnet',
76
+ args: ['add', 'package', parsed.name, ...(parsed.version ? ['--version', parsed.version] : [])],
77
+ dependency: parsed.name,
78
+ };
79
+ },
80
+ },
81
+ composer: {
82
+ description: 'PHP packages from Packagist',
83
+ validate(spec) {
84
+ const match = spec.match(/^(?<name>[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)(?::(?<version>[A-Za-z0-9.*^~<>=!| -]+))?$/);
85
+ return match && !spec.includes('--') ? { name: match.groups.name, version: match.groups.version } : null;
86
+ },
87
+ install(spec, parsed) {
88
+ return { executable: 'composer', args: ['require', '--', spec], dependency: parsed.name };
89
+ },
90
+ },
91
+ gem: {
92
+ description: 'Ruby gems from RubyGems via Bundler',
93
+ validate(spec) {
94
+ const match = spec.match(/^(?<name>[A-Za-z0-9][A-Za-z0-9_.-]*)(?:@(?<version>[0-9][A-Za-z0-9.+-]*))?$/);
95
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
96
+ },
97
+ install(spec, parsed) {
98
+ return {
99
+ executable: 'bundle',
100
+ args: ['add', parsed.name, ...(parsed.version ? ['--version', parsed.version] : [])],
101
+ dependency: parsed.name,
102
+ note: 'Uses Bundler to update Gemfile and Gemfile.lock; the project must have a Gemfile.',
103
+ };
104
+ },
105
+ },
106
+ dart: {
107
+ description: 'Dart packages from pub.dev',
108
+ validate(spec) {
109
+ const match = spec.match(/^(?<name>[a-z][a-z0-9_]*)(?::(?<version>(?:\^|>=|>|<=|<)?[0-9][A-Za-z0-9.+-]*))?$/);
110
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
111
+ },
112
+ install(spec, parsed) {
113
+ return { executable: 'dart', args: ['pub', 'add', spec], dependency: parsed.name };
114
+ },
115
+ },
116
+ maven: {
117
+ description: 'Java and Kotlin artifacts from Maven Central',
118
+ validate(spec) {
119
+ const match = spec.match(/^(?<group>[A-Za-z0-9_.-]+):(?<name>[A-Za-z0-9_.-]+)(?::(?<version>[A-Za-z0-9_.+-]+))?$/);
120
+ return match ? { name: `${match.groups.group}:${match.groups.name}`, version: match.groups.version } : null;
121
+ },
122
+ install(spec, parsed) {
123
+ return {
124
+ executable: 'mvn',
125
+ args: ['dependency:get', `-Dartifact=${spec}`],
126
+ dependency: parsed.name,
127
+ note: 'Downloaded to the Maven cache; add the artifact to pom.xml to use it in a project.',
128
+ };
129
+ },
130
+ },
131
+ conan: {
132
+ description: 'C and C++ packages from Conan Center',
133
+ validate(spec) {
134
+ const match = spec.match(/^(?<name>[A-Za-z0-9][A-Za-z0-9_.+-]*)(?:\/(?<version>[A-Za-z0-9.*+_-]+))?$/);
135
+ return match ? { name: match.groups.name, version: match.groups.version } : null;
136
+ },
137
+ install(spec, parsed) {
138
+ return {
139
+ executable: 'conan',
140
+ args: ['install', `--requires=${spec}`, '--build=missing'],
141
+ dependency: parsed.name,
142
+ note: 'Conan generates dependency metadata; configure the generated toolchain or build integration before use.',
143
+ };
144
+ },
145
+ },
146
+ };
147
+
148
+ const PACKAGE_FILES = [
149
+ ['package.json', 'Node.js / npm'],
150
+ ['pyproject.toml', 'Python / pip'],
151
+ ['requirements.txt', 'Python / pip'],
152
+ ['Cargo.toml', 'Rust / Cargo'],
153
+ ['go.mod', 'Go modules'],
154
+ ['*.csproj', '.NET / NuGet'],
155
+ ['*.sln', '.NET / NuGet'],
156
+ ['composer.json', 'PHP / Composer'],
157
+ ['Gemfile', 'Ruby / Bundler'],
158
+ ['pubspec.yaml', 'Dart / pub'],
159
+ ['Package.swift', 'Swift Package Manager'],
160
+ ['pom.xml', 'Java / Maven'],
161
+ ['build.gradle', 'Gradle'],
162
+ ['build.gradle.kts', 'Gradle'],
163
+ ['conanfile.py', 'C/C++ / Conan'],
164
+ ['conanfile.txt', 'C/C++ / Conan'],
165
+ ];
166
+
167
+ const TOOL_COMMANDS = [
168
+ ['node', ['--version']],
169
+ ['npm', ['--version']],
170
+ [process.platform === 'win32' ? 'py' : 'python3', ['--version']],
171
+ ['cargo', ['--version']],
172
+ ['go', ['version']],
173
+ ['dotnet', ['--version']],
174
+ ['composer', ['--version']],
175
+ ['gem', ['--version']],
176
+ ['bundle', ['--version']],
177
+ ['dart', ['--version']],
178
+ ['mvn', ['--version']],
179
+ ['gradle', ['--version']],
180
+ ['conan', ['--version']],
181
+ ['swift', ['--version']],
182
+ ];
183
+
184
+ export function listPackageAdapters() {
185
+ return Object.entries(PACKAGE_ADAPTERS).map(([name, adapter]) => ({
186
+ name,
187
+ description: adapter.description,
188
+ }));
189
+ }
190
+
191
+ export function checkNaideSource(source, sourceFile) {
192
+ try {
193
+ const result = compile(source, { typeCheck: true, sourceFile });
194
+ const diagnostics = [
195
+ ...result.typeErrors.errors.map(diagnostic => ({
196
+ severity: 'error',
197
+ line: diagnostic.line,
198
+ message: diagnostic.message,
199
+ })),
200
+ ...result.typeErrors.warnings.map(diagnostic => ({
201
+ severity: 'warning',
202
+ line: diagnostic.line,
203
+ message: diagnostic.message,
204
+ })),
205
+ ];
206
+ return {
207
+ ok: result.typeErrors.errors.length === 0,
208
+ diagnostics,
209
+ };
210
+ } catch (error) {
211
+ const location = error.message.match(/line (\d+)(?::(\d+))?/);
212
+ return {
213
+ ok: false,
214
+ diagnostics: [{
215
+ severity: 'error',
216
+ ...(location ? {
217
+ line: Number(location[1]),
218
+ ...(location[2] ? { column: Number(location[2]) } : {}),
219
+ } : {}),
220
+ message: error.message.split('\n')[0],
221
+ }],
222
+ };
223
+ }
224
+ }
225
+
226
+ export function recordPackageDependency(directory, plan) {
227
+ const projectDirectory = resolve(directory);
228
+ const path = resolve(projectDirectory, 'naide.pkg.json');
229
+ let manifest;
230
+
231
+ if (existsSync(path)) {
232
+ let parsed;
233
+ try {
234
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
235
+ } catch (error) {
236
+ throw new Error(`naide.pkg.json is not valid JSON: ${error.message}`);
237
+ }
238
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
239
+ throw new Error('naide.pkg.json must contain a JSON object.');
240
+ }
241
+ manifest = parsed;
242
+ } else {
243
+ const packageJson = readPackageJson(projectDirectory);
244
+ if (packageJson.error) {
245
+ throw new Error(`package.json is not valid JSON: ${packageJson.error}`);
246
+ }
247
+ manifest = {
248
+ name: packageJson.value?.name || 'my-naide-project',
249
+ version: '1.0.0',
250
+ description: '',
251
+ main: 'index.naide',
252
+ keywords: ['naide'],
253
+ exports: {},
254
+ dependencies: {},
255
+ languageDependencies: {},
256
+ };
257
+ }
258
+
259
+ if (manifest.dependencies === undefined) manifest.dependencies = {};
260
+ if (!manifest.dependencies || typeof manifest.dependencies !== 'object' || Array.isArray(manifest.dependencies)) {
261
+ throw new Error('naide.pkg.json "dependencies" must be an object.');
262
+ }
263
+ let pipRequirementsUpdate = null;
264
+ if (plan.manager === 'pip') {
265
+ const requirementsPath = resolve(projectDirectory, 'requirements.txt');
266
+ const requirements = existsSync(requirementsPath) ? readFileSync(requirementsPath, 'utf8') : '';
267
+ const normalizeName = name => name.toLowerCase().replace(/[-_.]+/g, '-');
268
+ const packageName = plan.dependency.match(/^[A-Za-z0-9][A-Za-z0-9._-]*/)?.[0];
269
+ if (!packageName) throw new Error(`Could not determine the Python package name from "${plan.dependency}".`);
270
+ const lines = requirements.split(/\r?\n/);
271
+ let found = false;
272
+ const updatedLines = lines.map(line => {
273
+ const existingName = line.trim().match(/^([A-Za-z0-9][A-Za-z0-9._-]*)/)?.[1];
274
+ if (!existingName || normalizeName(existingName) !== normalizeName(packageName)) return line;
275
+ found = true;
276
+ return plan.spec;
277
+ });
278
+ if (!found) updatedLines.push(plan.spec);
279
+ const output = updatedLines.join('\n').replace(/\n*$/, '\n');
280
+ if (output !== requirements) pipRequirementsUpdate = { path: requirementsPath, output };
281
+ }
282
+
283
+ if (plan.manager === 'npm') {
284
+ manifest.dependencies[plan.dependency] = plan.spec;
285
+ } else {
286
+ if (manifest.languageDependencies === undefined) manifest.languageDependencies = {};
287
+ if (!manifest.languageDependencies
288
+ || typeof manifest.languageDependencies !== 'object'
289
+ || Array.isArray(manifest.languageDependencies)) {
290
+ throw new Error('naide.pkg.json "languageDependencies" must be an object.');
291
+ }
292
+ manifest.languageDependencies[plan.manager] ||= {};
293
+ if (typeof manifest.languageDependencies[plan.manager] !== 'object'
294
+ || Array.isArray(manifest.languageDependencies[plan.manager])) {
295
+ throw new Error(`naide.pkg.json "languageDependencies.${plan.manager}" must be an object.`);
296
+ }
297
+ manifest.languageDependencies[plan.manager][plan.dependency] = plan.spec;
298
+ }
299
+
300
+ const updates = [
301
+ ...(pipRequirementsUpdate ? [pipRequirementsUpdate] : []),
302
+ { path, output: `${JSON.stringify(manifest, null, 2)}\n` },
303
+ ];
304
+ const staged = [];
305
+ const replaced = [];
306
+ try {
307
+ for (const update of updates) {
308
+ const temporaryPath = `${update.path}.naide-${randomUUID()}.tmp`;
309
+ const stagedUpdate = { ...update, temporaryPath };
310
+ staged.push(stagedUpdate);
311
+ const fileDescriptor = openSync(temporaryPath, 'wx');
312
+ try {
313
+ writeFileSync(fileDescriptor, update.output, 'utf8');
314
+ } finally {
315
+ closeSync(fileDescriptor);
316
+ }
317
+ }
318
+
319
+ for (const update of staged) {
320
+ const backupPath = `${update.path}.naide-${randomUUID()}.bak`;
321
+ const hadOriginal = existsSync(update.path);
322
+ if (hadOriginal) renameSync(update.path, backupPath);
323
+ replaced.push({ ...update, backupPath, hadOriginal, installed: false });
324
+ renameSync(update.temporaryPath, update.path);
325
+ replaced.at(-1).installed = true;
326
+ }
327
+ } catch (error) {
328
+ const rollbackErrors = [];
329
+ for (const update of replaced.reverse()) {
330
+ try {
331
+ if (update.installed && existsSync(update.path)) unlinkSync(update.path);
332
+ if (update.hadOriginal && existsSync(update.backupPath)) renameSync(update.backupPath, update.path);
333
+ } catch (rollbackError) {
334
+ rollbackErrors.push(rollbackError);
335
+ }
336
+ }
337
+ if (rollbackErrors.length > 0) {
338
+ throw new AggregateError([error, ...rollbackErrors], 'Could not fully roll back package metadata updates.');
339
+ }
340
+ throw error;
341
+ } finally {
342
+ for (const update of staged) {
343
+ if (existsSync(update.temporaryPath)) unlinkSync(update.temporaryPath);
344
+ }
345
+ }
346
+ for (const update of replaced) {
347
+ if (update.hadOriginal) {
348
+ try {
349
+ unlinkSync(update.backupPath);
350
+ } catch (error) {
351
+ throw new Error(`Package metadata was updated, but backup cleanup failed for ${update.backupPath}: ${error.message}`);
352
+ }
353
+ }
354
+ }
355
+
356
+ return path;
357
+ }
358
+
359
+ export function spawnNativeTool(plan, directory, options = {}) {
360
+ const spawnCommand = options.spawnCommand || spawn;
361
+ const projectDirectory = resolve(directory);
362
+ if (options.platform === 'win32' || (!options.platform && process.platform === 'win32')) {
363
+ if (!plan.executable.toLowerCase().endsWith('.exe')) {
364
+ const args = [plan.executable, ...plan.args];
365
+ if (args.some(argument => /["%\r\n\0]/.test(argument))) {
366
+ throw new Error('Windows script-based package managers do not accept quotes, percent signs, or line breaks in arguments.');
367
+ }
368
+ const commandLine = args.map(argument => `"${argument}"`).join(' ');
369
+ return spawnCommand(process.env.ComSpec || 'cmd.exe', [
370
+ '/d', '/v:off', '/s', '/c', `"${commandLine}"`,
371
+ ], {
372
+ cwd: projectDirectory,
373
+ windowsHide: true,
374
+ windowsVerbatimArguments: true,
375
+ ...(options.stdio ? { stdio: options.stdio } : {}),
376
+ });
377
+ }
378
+ }
379
+ return spawnCommand(plan.executable, plan.args, {
380
+ cwd: projectDirectory,
381
+ windowsHide: true,
382
+ ...(options.stdio ? { stdio: options.stdio } : {}),
383
+ });
384
+ }
385
+
386
+ export function runPackageInstall(plan, options = {}) {
387
+ const directory = resolve(options.directory || process.cwd());
388
+ const spawnCommand = options.spawnCommand
389
+ || ((packagePlan, cwd) => spawnNativeTool(packagePlan, cwd, { stdio: 'inherit' }));
390
+
391
+ return new Promise((resolveResult, rejectResult) => {
392
+ let child;
393
+ try {
394
+ child = spawnCommand(plan, directory);
395
+ } catch (error) {
396
+ rejectResult(error);
397
+ return;
398
+ }
399
+
400
+ let settled = false;
401
+ child.once('error', error => {
402
+ if (settled) return;
403
+ settled = true;
404
+ rejectResult(error);
405
+ });
406
+ child.once('close', code => {
407
+ if (settled) return;
408
+ settled = true;
409
+ resolveResult(code ?? 1);
410
+ });
411
+ });
412
+ }
413
+
414
+ export function createPackageInstallPlan(manager, spec, options = {}) {
415
+ const adapter = PACKAGE_ADAPTERS[manager];
416
+ if (!adapter) {
417
+ throw new Error(`Unsupported package manager "${manager}". Supported: ${Object.keys(PACKAGE_ADAPTERS).join(', ')}`);
418
+ }
419
+ if (typeof spec !== 'string' || spec.length === 0 || spec.length > 256) {
420
+ throw new Error('Package spec must be a non-empty string of at most 256 characters.');
421
+ }
422
+ if (spec.trim() !== spec || /[\r\n\0]/.test(spec)) {
423
+ throw new Error('Package spec cannot contain leading/trailing whitespace or line breaks.');
424
+ }
425
+ const parsed = adapter.validate(spec);
426
+ if (!parsed) {
427
+ throw new Error(`Invalid ${manager} package spec "${spec}". Use a registry package name and optional version; URLs and local paths are not supported.`);
428
+ }
429
+ return { ...adapter.install(spec, parsed, options.directory || process.cwd(), options), manager, spec };
430
+ }
431
+
432
+ function fileMatches(directory, pattern) {
433
+ if (!pattern.startsWith('*')) return existsSync(resolve(directory, pattern));
434
+ const suffix = pattern.slice(1);
435
+ try {
436
+ return readdirSync(directory).some(name => name.endsWith(suffix));
437
+ } catch (error) {
438
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
439
+ throw error;
440
+ }
441
+ }
442
+
443
+ function readPackageJson(directory, manifestName = 'package.json') {
444
+ const path = resolve(directory, manifestName);
445
+ if (!existsSync(path)) return { path, value: null, error: null, present: false };
446
+ try {
447
+ return { path, value: JSON.parse(readFileSync(path, 'utf8')), error: null, present: true };
448
+ } catch (error) {
449
+ return { path, value: null, error: error.message, present: true };
450
+ }
451
+ }
452
+
453
+ function findNaideFiles(directory) {
454
+ const files = [];
455
+ const ignoredDirectories = new Set(['.git', 'dist', 'node_modules']);
456
+
457
+ function visit(currentDirectory) {
458
+ for (const entry of readdirSync(currentDirectory, { withFileTypes: true })) {
459
+ if (entry.isDirectory()) {
460
+ if (!ignoredDirectories.has(entry.name)) visit(resolve(currentDirectory, entry.name));
461
+ } else if (entry.isFile() && entry.name.endsWith('.naide')) {
462
+ if (currentDirectory === directory && entry.name === 'SPEC.naide') continue;
463
+ const absolutePath = resolve(currentDirectory, entry.name);
464
+ files.push({
465
+ path: relative(directory, absolutePath).split(sep).join('/'),
466
+ absolutePath,
467
+ });
468
+ }
469
+ }
470
+ }
471
+
472
+ visit(directory);
473
+ return files.sort((left, right) => left.path.localeCompare(right.path));
474
+ }
475
+
476
+ export function inspectProject(directory = process.cwd()) {
477
+ const projectDirectory = resolve(directory);
478
+ const manifests = PACKAGE_FILES
479
+ .filter(([pattern]) => fileMatches(projectDirectory, pattern))
480
+ .map(([, ecosystem]) => ecosystem);
481
+ const packageJson = readPackageJson(projectDirectory);
482
+ const errors = [];
483
+ const warnings = [];
484
+ const checks = [];
485
+
486
+ if (packageJson.error) {
487
+ errors.push(`package.json is not valid JSON: ${packageJson.error}`);
488
+ } else if (packageJson.present && (
489
+ !packageJson.value
490
+ || typeof packageJson.value !== 'object'
491
+ || Array.isArray(packageJson.value)
492
+ )) {
493
+ errors.push('package.json must contain a JSON object.');
494
+ } else if (packageJson.present) {
495
+ checks.push('package.json parses successfully');
496
+ if (packageJson.value.name !== undefined && typeof packageJson.value.name !== 'string') {
497
+ errors.push('package.json "name" must be a string.');
498
+ } else if (!packageJson.value.name) {
499
+ warnings.push('package.json has no project name.');
500
+ }
501
+ for (const section of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) {
502
+ const dependencies = packageJson.value[section];
503
+ if (dependencies !== undefined
504
+ && (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies))) {
505
+ errors.push(`package.json "${section}" must be an object of package names and version strings.`);
506
+ } else if (dependencies) {
507
+ for (const [name, version] of Object.entries(dependencies)) {
508
+ if (typeof version !== 'string' || version.length === 0) {
509
+ errors.push(`package.json "${section}.${name}" must be a non-empty version string.`);
510
+ }
511
+ }
512
+ }
513
+ }
514
+ if (packageJson.value.scripts !== undefined
515
+ && (!packageJson.value.scripts
516
+ || typeof packageJson.value.scripts !== 'object'
517
+ || Array.isArray(packageJson.value.scripts))) {
518
+ errors.push('package.json "scripts" must be an object of command strings.');
519
+ } else if (packageJson.value.scripts) {
520
+ for (const [name, command] of Object.entries(packageJson.value.scripts)) {
521
+ if (typeof command !== 'string' || command.length === 0) {
522
+ errors.push(`package.json "scripts.${name}" must be a non-empty command string.`);
523
+ }
524
+ }
525
+ }
526
+ const dependencies = {
527
+ ...(packageJson.value.dependencies && typeof packageJson.value.dependencies === 'object'
528
+ && !Array.isArray(packageJson.value.dependencies) ? packageJson.value.dependencies : {}),
529
+ ...(packageJson.value.devDependencies && typeof packageJson.value.devDependencies === 'object'
530
+ && !Array.isArray(packageJson.value.devDependencies) ? packageJson.value.devDependencies : {}),
531
+ };
532
+ if (Object.keys(dependencies).length > 0
533
+ && !['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock']
534
+ .some(name => existsSync(resolve(projectDirectory, name)))) {
535
+ warnings.push('Dependencies are declared but no npm-compatible lockfile was found.');
536
+ }
537
+ if (packageJson.value.scripts?.test) {
538
+ checks.push('A project test script is configured (run it with npm test).');
539
+ }
540
+ }
541
+
542
+ let sourceFiles = [];
543
+ try {
544
+ sourceFiles = findNaideFiles(projectDirectory);
545
+ } catch (error) {
546
+ errors.push(`Cannot inspect project directory: ${error.message}`);
547
+ }
548
+ const naideFiles = sourceFiles.map(file => file.path);
549
+ if (naideFiles.length > 0) {
550
+ checks.push(`${naideFiles.length} .naide source file(s) found.`);
551
+ for (const file of sourceFiles) {
552
+ try {
553
+ const source = readFileSync(file.absolutePath, 'utf8');
554
+ const result = checkNaideSource(source, file.path);
555
+ for (const diagnostic of result.diagnostics) {
556
+ const formatted = `${file.path}${diagnostic.line ? `:${diagnostic.line}` : ''} ${diagnostic.message}`;
557
+ if (diagnostic.severity === 'error') errors.push(formatted);
558
+ else warnings.push(formatted);
559
+ }
560
+ if (result.ok) {
561
+ const hasWarnings = result.diagnostics.some(diagnostic => diagnostic.severity === 'warning');
562
+ checks.push(`${file.path} compiles${hasWarnings ? ' with type-check warnings' : ' and passes type checks'}.`);
563
+ }
564
+ } catch (error) {
565
+ errors.push(`${file.path} could not be compiled: ${error.message.split('\n')[0]}`);
566
+ }
567
+ }
568
+ }
569
+ if (existsSync(resolve(projectDirectory, 'SPEC.naide'))) {
570
+ checks.push('SPEC.naide language reference excluded from source checks.');
571
+ }
572
+ if (manifests.length === 0) warnings.push('No supported package-manager manifest was found in this directory.');
573
+ if (manifests.includes('Python / pip')
574
+ && !process.env.VIRTUAL_ENV
575
+ && !existsSync(resolve(projectDirectory, '.venv'))) {
576
+ warnings.push('Python packages should be installed in a virtual environment (.venv) to avoid modifying the system Python.');
577
+ }
578
+
579
+ return {
580
+ directory: projectDirectory,
581
+ manifests: [...new Set(manifests)],
582
+ naideFiles,
583
+ checks,
584
+ errors,
585
+ warnings,
586
+ ok: errors.length === 0,
587
+ };
588
+ }
589
+
590
+ export function getProjectTestPlans(directory = process.cwd()) {
591
+ const project = resolve(directory);
592
+ const plans = [];
593
+ const packageJson = readPackageJson(project);
594
+ let entries;
595
+ try {
596
+ entries = readdirSync(project, { withFileTypes: true }).map(entry => entry.name);
597
+ } catch (error) {
598
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return plans;
599
+ throw error;
600
+ }
601
+
602
+ if (!packageJson.error && packageJson.value?.scripts?.test) {
603
+ plans.push({ manager: 'npm', executable: 'npm', args: ['test'], label: 'npm test' });
604
+ }
605
+ if (existsSync(resolve(project, 'Cargo.toml'))) {
606
+ plans.push({ manager: 'cargo', executable: 'cargo', args: ['test'], label: 'cargo test' });
607
+ }
608
+ if (existsSync(resolve(project, 'go.mod'))) {
609
+ plans.push({ manager: 'go', executable: 'go', args: ['test', './...'], label: 'go test ./...' });
610
+ }
611
+ if (entries.some(name => name.endsWith('.sln') || name.endsWith('.csproj'))) {
612
+ plans.push({ manager: 'dotnet', executable: 'dotnet', args: ['test'], label: 'dotnet test' });
613
+ }
614
+ if (existsSync(resolve(project, 'pom.xml'))) {
615
+ plans.push({ manager: 'maven', executable: 'mvn', args: ['test'], label: 'mvn test' });
616
+ }
617
+ if (entries.some(name => name === 'build.gradle' || name === 'build.gradle.kts')) {
618
+ plans.push({ manager: 'gradle', executable: 'gradle', args: ['test'], label: 'gradle test' });
619
+ }
620
+ if (existsSync(resolve(project, 'Package.swift'))) {
621
+ plans.push({ manager: 'swift', executable: 'swift', args: ['test'], label: 'swift test' });
622
+ }
623
+ if (existsSync(resolve(project, 'pubspec.yaml')) && entries.includes('test')) {
624
+ plans.push({ manager: 'dart', executable: 'dart', args: ['test'], label: 'dart test' });
625
+ }
626
+ const composerJson = readPackageJson(project, 'composer.json');
627
+ if (!composerJson.error && composerJson.value?.scripts?.test) {
628
+ plans.push({ manager: 'composer', executable: 'composer', args: ['test'], label: 'composer test' });
629
+ }
630
+ if (existsSync(resolve(project, 'Gemfile')) && existsSync(resolve(project, 'Rakefile'))) {
631
+ plans.push({ manager: 'bundle', executable: 'bundle', args: ['exec', 'rake', 'test'], label: 'bundle exec rake test' });
632
+ }
633
+ if (['pyproject.toml', 'pytest.ini', 'tox.ini'].some(name => existsSync(resolve(project, name)))
634
+ && entries.some(name => name === 'test' || name === 'tests')) {
635
+ const localPython = resolve(project, '.venv', process.platform === 'win32' ? 'Scripts/python.exe' : 'bin/python');
636
+ const python = existsSync(localPython)
637
+ ? localPython
638
+ : process.platform === 'win32' ? 'py' : 'python3';
639
+ plans.push({
640
+ manager: 'pip',
641
+ executable: python,
642
+ args: [...(python === 'py' ? ['-m'] : ['-m']), 'pytest'],
643
+ label: 'python -m pytest',
644
+ });
645
+ }
646
+ return plans;
647
+ }
648
+
649
+ function checkToolVersion(executable, args) {
650
+ let command = executable;
651
+ let commandArgs = args;
652
+ if (executable === 'npm' && process.platform === 'win32') {
653
+ const npmCandidates = [
654
+ process.env.npm_execpath,
655
+ resolve(process.execPath, '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
656
+ resolve(process.execPath, '..', '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
657
+ ].filter(Boolean);
658
+ const npmCli = npmCandidates.find(path => existsSync(path) && /\.(?:c|m)?js$/i.test(path));
659
+ if (npmCli) {
660
+ command = process.execPath;
661
+ commandArgs = [npmCli, ...args];
662
+ }
663
+ }
664
+ return new Promise(resolveResult => {
665
+ let stdout = '';
666
+ let stderr = '';
667
+ let settled = false;
668
+ const finish = (available, error = null) => {
669
+ if (settled) return;
670
+ settled = true;
671
+ clearTimeout(timeout);
672
+ resolveResult({
673
+ executable,
674
+ available,
675
+ version: available ? `${stdout}${stderr}`.trim().split(/\r?\n/, 1)[0] : null,
676
+ error,
677
+ });
678
+ };
679
+ const child = spawnNativeTool({ manager: executable === 'npm' ? 'npm' : null, executable: command, args: commandArgs }, process.cwd());
680
+ const timeout = setTimeout(() => {
681
+ child.kill();
682
+ finish(false, 'ETIMEDOUT');
683
+ }, 1500);
684
+ child.stdout.setEncoding('utf8');
685
+ child.stderr.setEncoding('utf8');
686
+ child.stdout.on('data', chunk => { stdout += chunk; });
687
+ child.stderr.on('data', chunk => { stderr += chunk; });
688
+ child.once('error', error => finish(false, error.code || 'ERROR'));
689
+ child.once('close', code => finish(code === 0, code === 0 ? null : `EXIT_${code}`));
690
+ });
691
+ }
692
+
693
+ function spawnProjectTool(plan, directory) {
694
+ if (plan.manager === 'npm' && process.platform === 'win32') {
695
+ const npmCandidates = [
696
+ process.env.npm_execpath,
697
+ resolve(process.execPath, '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
698
+ resolve(process.execPath, '..', '..', 'node_modules', 'npm', 'bin', 'npm-cli.js'),
699
+ ].filter(Boolean);
700
+ const npmCli = npmCandidates.find(path => existsSync(path) && /\.(?:c|m)?js$/i.test(path));
701
+ if (npmCli) return spawn(process.execPath, [npmCli, ...plan.args], { cwd: directory, windowsHide: true });
702
+ }
703
+ return spawnNativeTool(plan, directory);
704
+ }
705
+
706
+ export async function runProjectTests(directory = process.cwd(), options = {}) {
707
+ const project = resolve(directory);
708
+ const plans = getProjectTestPlans(project);
709
+ const outcomes = [];
710
+
711
+ for (const plan of plans) {
712
+ const result = await new Promise(resolveResult => {
713
+ let stdout = '';
714
+ let stderr = '';
715
+ let settled = false;
716
+ let timeout = null;
717
+ const append = (current, chunk) => current.length >= 1024 * 1024
718
+ ? current
719
+ : current + chunk.slice(0, 1024 * 1024 - current.length);
720
+ const finish = (code, error = null) => {
721
+ if (settled) return;
722
+ settled = true;
723
+ clearTimeout(timeout);
724
+ resolveResult({ ...plan, code, error, stdout, stderr });
725
+ };
726
+
727
+ let child;
728
+ try {
729
+ child = spawnProjectTool(plan, project);
730
+ } catch (error) {
731
+ finish(127, error.message);
732
+ return;
733
+ }
734
+ timeout = setTimeout(() => {
735
+ child.kill();
736
+ finish(124, 'Test command timed out after 10 minutes.');
737
+ }, options.timeoutMs || 10 * 60 * 1000);
738
+ child.stdout.setEncoding('utf8');
739
+ child.stderr.setEncoding('utf8');
740
+ child.stdout.on('data', chunk => { stdout = append(stdout, chunk); });
741
+ child.stderr.on('data', chunk => { stderr = append(stderr, chunk); });
742
+ child.once('error', error => finish(127, error.message));
743
+ child.once('close', code => finish(code ?? 1));
744
+ });
745
+ outcomes.push(result);
746
+ }
747
+
748
+ return {
749
+ directory: project,
750
+ plans,
751
+ outcomes,
752
+ ok: plans.length > 0 && outcomes.every(outcome => outcome.code === 0),
753
+ };
754
+ }
755
+
756
+ export async function getDoctorReport(directory = process.cwd()) {
757
+ const project = inspectProject(directory);
758
+ const tools = await Promise.all(TOOL_COMMANDS.map(([executable, args]) => checkToolVersion(executable, args)));
759
+ const requirements = new Map([
760
+ ['Node.js / npm', ['node', 'npm']],
761
+ ['Python / pip', [process.platform === 'win32' ? 'py' : 'python3']],
762
+ ['Rust / Cargo', ['cargo']],
763
+ ['Go modules', ['go']],
764
+ ['.NET / NuGet', ['dotnet']],
765
+ ['PHP / Composer', ['composer']],
766
+ ['Ruby / Bundler', ['bundle']],
767
+ ['Dart / pub', ['dart']],
768
+ ['Swift Package Manager', ['swift']],
769
+ ['Java / Maven', ['mvn']],
770
+ ['Gradle', ['gradle']],
771
+ ['C/C++ / Conan', ['conan']],
772
+ ]);
773
+ const requiredTools = [...new Set(project.manifests.flatMap(manifest => requirements.get(manifest) || []))];
774
+ const missingRequiredTools = requiredTools.filter(name => !tools.some(tool => tool.executable === name && tool.available));
775
+ for (const name of missingRequiredTools) project.errors.push(`Required ecosystem tool "${name}" was not found.`);
776
+ project.ok = project.errors.length === 0;
777
+
778
+ return {
779
+ node: { version: process.version, supported: Number(process.versions.node.split('.')[0]) >= 18 },
780
+ platform: `${process.platform} ${process.arch}`,
781
+ tools,
782
+ requiredTools,
783
+ project,
784
+ };
785
+ }