@nm-lang/cli 0.2.0 → 0.3.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/LICENSE.txt CHANGED
@@ -42,7 +42,7 @@ CANNOT BE EXCLUDED UNDER APPLICABLE LAW ARE NOT AFFECTED.
42
42
 
43
43
  6. Permission requests
44
44
  For additional permissions, contact the publisher through:
45
- https://quantumsoftware-tech.vercel.app/en/#contact
45
+ https://quantumsoftware.tech/en/#contact
46
46
 
47
47
  Türkçe açıklama
48
48
  Resmî dört npm paketi kişisel, eğitim ve ticari amaçlarla ücretsiz kurulup
package/README.md CHANGED
@@ -3,27 +3,32 @@
3
3
  N/M 0.2.0 Preview command-line tools for running, formatting, linting, testing,
4
4
  exporting and managing local N/M workspaces.
5
5
 
6
+ Package release **0.3.0** targets the N/M **0.2.0** language contract.
7
+
6
8
  ## Install / Kurulum
7
9
 
8
10
  Requires Node.js 20 or newer. Release checks use Node.js 22.23.1.
9
11
 
10
12
  ```sh
11
- npm install --global @nm-lang/cli@next
13
+ npm install --global @nm-lang/cli
12
14
  nm --version
13
15
  ```
14
16
 
15
17
  Or run without a global install:
16
18
 
17
19
  ```sh
18
- npx --package=@nm-lang/cli@next nm --version
20
+ npx --package=@nm-lang/cli nm --version
19
21
  ```
20
22
 
21
- The `next` tag selects the preview channel. To pin this version after publication,
22
- replace `@next` with `@0.2.0`. npm installs the matching Core dependency.
23
+ An untagged install selects npm's `latest` channel. To pin this version after
24
+ publication, append `@0.3.0` to the package name. The N/M language remains
25
+ Preview independently of the npm channel. npm installs the matching Core dependency.
23
26
  No repository checkout or TypeScript build is needed to use a published package.
24
27
 
25
- Türkçe: `next` önizleme kanalını seçer. Yayından sonra `@0.2.0` ile sürümü
26
- sabitleyebilirsiniz. Core bağımlılığı otomatik kurulur; depoyu indirmeniz gerekmez.
28
+ Türkçe: Etiketsiz kurulum npm'in `latest` kanalını seçer. Yayından sonra
29
+ `@0.3.0` ile sürümü sabitleyebilirsiniz. N/M dili, npm kanalından bağımsız
30
+ olarak Önizleme durumundadır. Core bağımlılığı otomatik kurulur; depoyu
31
+ indirmeniz gerekmez.
27
32
 
28
33
  ## First program / İlk program
29
34
 
@@ -51,6 +56,67 @@ simülasyondan gelir; gerçek kuantum donanımı kanıtı değildir.
51
56
 
52
57
  ## Language options / Dil seçenekleri
53
58
 
59
+ ### Selected tests and CI reports / Test seçimi ve CI raporları
60
+
61
+ The `0.3.0` package adds test selection, watch mode and JSON, JUnit and SARIF
62
+ reports for CI integrations.
63
+
64
+ Türkçe: `0.3.0` paketi test seçimini, izleme modunu ve CI entegrasyonları için
65
+ JSON, JUnit ve SARIF raporlarını içerir.
66
+
67
+ ```sh
68
+ nm test main.nm --name "Bell contract" --json
69
+ nm test main.nm --id nmtest-0123456789abcdef
70
+ nm test main.nm --report-format junit --output results.xml
71
+ nm test main.nm --report-format sarif --output results.sarif
72
+ nm test main.nm --watch --json
73
+ ```
74
+
75
+ `--name` matches the exact declared name; repeat `--name` or `--id` to select a
76
+ union. Copy real IDs from a JSON report for the same input path and source lines.
77
+ Selection happens before execution, preserving surrounding setup and original
78
+ source locations; unknown/stale IDs or names fail with `NM-TEST-SELECT-001`.
79
+ Direct selection of a nested check fails with `NM-TEST-SELECT-002`; select its
80
+ top-level enclosing check. Source syntax and capability validation still cover
81
+ the complete file, including its original register allocation and qubit limits.
82
+ Empty suites and failed execution exit 1.
83
+
84
+ `--report-format json|junit|sarif` writes that format to stdout. `--output` also
85
+ replaces the explicitly named `.json`, `.xml` or `.sarif` report on each run;
86
+ parent directories must already exist. The default output remains human readable.
87
+ `--json` cannot be combined with a non-JSON report format.
88
+
89
+ `--watch` is available in the Node command-line host. It runs immediately, then
90
+ debounces source and workspace-manifest changes, including imported modules,
91
+ clears the workspace cache and queues runs without overlap. Ctrl+C/SIGTERM closes
92
+ watchers. With `--json`, stdout is NDJSON: each `event:"run"` has a sequence,
93
+ exitCode, report (or null for invocation errors) and diagnostics; lifecycle errors
94
+ and stopping also have event objects. JUnit/SARIF watch mode requires `--output`
95
+ and prints run summaries while replacing the report file. The exit status follows
96
+ the latest run; signal termination uses 130/143.
97
+
98
+ Türkçe: Tam test adı veya mevcut rapordaki kimlikle seçim yapın; seçenekler
99
+ tekrarlanabilir. Seçilmeyen test blokları yürütülmez, ortak kurulum korunur.
100
+ İç içe kontrolleri doğrudan seçmek yerine üst kontrolü seçin. Kaynağın tamamındaki
101
+ kayıt tahsisi ve qubit sınırları seçimden sonra da geçerlidir. İzleme modu bağımlı
102
+ `.nm` dosyaları ve manifest değişince yeniden çalışır. `--output` belirtilen rapor
103
+ dosyasını her çalıştırmada yeniler.
104
+
105
+ ### Toolchain doctor / Araç zinciri kontrolü
106
+
107
+ The `0.3.0` CLI provides `nm doctor --json` and `nm doctor --locale tr`.
108
+ It compares the loaded CLI/Core and locally resolvable LSP/MCP packages, reporting
109
+ package, language and protocol versions separately. Missing optional packages and
110
+ the VS Code installation remain unobserved; this is not a check of running servers.
111
+ Exit code 1 means an observed mismatch or failed package probe; 0 may still mean
112
+ partial coverage. The report format is `nm-toolchain-doctor` version `0.1`.
113
+
114
+ Türkçe: `nm doctor` yüklenen CLI/Core ile yerelden çözümlenebilen LSP/MCP
115
+ paketlerini karşılaştırır. Paket, dil ve protokol sürümleri ayrı değerlendirilir.
116
+ Gözlenmeyen isteğe bağlı araçlar ve VS Code kurulumu doğrulanmış sayılmaz.
117
+ Çıkış kodu 1 uyuşmazlık veya paket sorgusu hatasıdır; 0 kısmi gözlem anlamına da
118
+ gelebilir. Bu komutlar `0.3.0` paketinin parçasıdır.
119
+
54
120
  New CLI invocations enable the default language profile: classical functions,
55
121
  typed values/Result, module visibility, parameter groups and compute/apply.
56
122
  Use `--legacy-features` for the previous opt-in behavior. Other experimental
@@ -59,9 +125,9 @@ features still require their own flags; export/backend limits remain enforced.
59
125
  Türkçe: Yeni CLI çağrılarında varsayılan dil profili açıktır. Eski davranış için
60
126
  `--legacy-features` kullanın. Diğer deneysel özellikler ayrıca açılmalıdır.
61
127
 
62
- - [CLI documentation](https://quantumsoftware-tech.vercel.app/en/docs/nm-language/cli)
63
- - [Türkçe CLI rehberi](https://quantumsoftware-tech.vercel.app/tr/docs/nm-language/cli)
64
- - [Support / Destek](https://quantumsoftware-tech.vercel.app/en/#contact)
128
+ - [CLI documentation](https://quantumsoftware.tech/en/docs/nm-language/cli)
129
+ - [Türkçe CLI rehberi](https://quantumsoftware.tech/tr/docs/nm-language/cli)
130
+ - [Support / Destek](https://quantumsoftware.tech/en/#contact)
65
131
 
66
132
  ## License / Lisans
67
133
 
@@ -0,0 +1,172 @@
1
+ import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath, pathToFileURL } from "node:url";
4
+
5
+ /** Read only the selected runtime and locally resolvable package entries. */
6
+ export async function observeNMDoctorRuntime({ cliPackageRoot, workspaceRoot, runtimeSource, runtimePath, runtimeModule, versionManifest }) {
7
+ const cliMetadata = readMetadata(cliPackageRoot, "@nm-lang/cli");
8
+ const source = runtimeSource === "repository-source" ? "repository-source" : "package-artifact";
9
+ const cli = {
10
+ id: "cli", availability: cliMetadata ? "observed" : "unavailable", source,
11
+ packageVersion: cliMetadata?.version, languageVersion: versionManifest?.languageVersion,
12
+ corePackageVersion: versionManifest?.packageVersion,
13
+ protocols: pickStrings({ cliContract: runtimeModule.NM_CLI_CONTRACT_VERSION }),
14
+ evidence: [path.join(cliPackageRoot, "package.json"), runtimePath],
15
+ ...(!cliMetadata ? { reason: "invalid-cli-package-metadata" } : {}),
16
+ };
17
+ const coreRoot = runtimeSource === "repository-source" ? path.join(workspaceRoot, "packages/nm-core") : runtimeSource === "core-package-fallback" ? path.dirname(path.dirname(runtimePath)) : undefined;
18
+ const coreMetadata = coreRoot ? readMetadata(coreRoot, "@nm-lang/core") : undefined;
19
+ const core = {
20
+ id: "core", availability: versionManifest && (!coreRoot || coreMetadata) ? "observed" : "unavailable",
21
+ source: runtimeSource === "repository-source" ? "repository-source" : runtimeSource === "core-package-fallback" ? "package-artifact" : "bundled-core",
22
+ packageVersion: coreMetadata?.version ?? versionManifest?.packageVersion,
23
+ corePackageVersion: versionManifest?.packageVersion,
24
+ languageVersion: versionManifest?.languageVersion,
25
+ capabilities: pickStrings(versionManifest?.capabilities ?? {}),
26
+ evidence: [runtimeSource === "repository-source" ? path.join(workspaceRoot, "lib/nm/version.ts") : runtimePath, ...(coreRoot ? [path.join(coreRoot, "package.json")] : [])],
27
+ ...(!versionManifest ? { reason: "loaded-core-manifest-not-exported" } : coreRoot && !coreMetadata ? { reason: "invalid-core-package-metadata" } : {}),
28
+ };
29
+ const optional = [];
30
+ for (const [id, packageName, directory] of [
31
+ ["language-server", "@nm-lang/language-server", "nm-language-server"],
32
+ ["mcp", "@nm-lang/mcp", "nm-mcp"],
33
+ ]) {
34
+ optional.push(await observeLocalComponent({ id, packageName, directory, workspaceRoot }));
35
+ }
36
+ return { components: [cli, core, ...optional, {
37
+ id: "vscode", availability: "not-observed", source: "none",
38
+ reason: "editor-installation-and-running-servers-not-queried",
39
+ }] };
40
+ }
41
+
42
+ async function observeLocalComponent({ id, packageName, directory, workspaceRoot }) {
43
+ let entryPath;
44
+ let packageRoot;
45
+ const resolutionRoot = path.dirname(fileURLToPath(import.meta.url));
46
+ try {
47
+ // This helper ships inside CLI/bin; native ESM resolution therefore uses
48
+ // the same package scope as the CLI, including import-only export maps.
49
+ entryPath = fileURLToPath(import.meta.resolve(packageName));
50
+ packageRoot = findPackageRoot(entryPath, packageName, resolutionRoot);
51
+ } catch {
52
+ const present = findLocalPackageRoot(resolutionRoot, packageName);
53
+ if (present) return {
54
+ id, availability: "unavailable", source: "package-artifact",
55
+ evidence: [path.join(present, "package.json")],
56
+ reason: readMetadata(present, packageName) ? "local-package-entry-not-resolvable" : "invalid-local-package-metadata",
57
+ };
58
+ // Explicit repository sibling only; never search user homes, PATH, or global installs.
59
+ if (workspaceRoot) {
60
+ const candidate = path.join(workspaceRoot, "packages", directory);
61
+ if (existsSync(path.join(candidate, "package.json"))) {
62
+ packageRoot = candidate;
63
+ entryPath = path.join(candidate, "dist/index.js");
64
+ }
65
+ }
66
+ }
67
+ if (!packageRoot) {
68
+ const present = findLocalPackageRoot(resolutionRoot, packageName);
69
+ if (present) return { id, availability: "unavailable", source: "package-artifact",
70
+ evidence: [present], reason: "invalid-local-package-metadata" };
71
+ return { id, availability: "not-installed", source: "none", reason: "package-not-resolvable-from-cli" };
72
+ }
73
+ const metadataPath = path.join(packageRoot, "package.json");
74
+ const metadata = readMetadata(packageRoot, packageName);
75
+ const evidence = [metadataPath, entryPath];
76
+ if (!metadata) return { id, availability: "unavailable", source: "package-artifact", evidence, reason: "invalid-local-package-metadata" };
77
+ if (!existsSync(entryPath)) {
78
+ const repositoryArtifact = workspaceRoot && sameDirectory(packageRoot, path.join(workspaceRoot, "packages", directory));
79
+ return { id, availability: repositoryArtifact ? "not-observed" : "unavailable",
80
+ source: repositoryArtifact ? "repository-source" : "package-artifact", packageVersion: metadata.version,
81
+ evidence, reason: repositoryArtifact ? "repository-package-artifact-not-built" : "local-package-artifact-missing" };
82
+ }
83
+ try {
84
+ const runtime = await import(pathToFileURL(entryPath).href);
85
+ // Generated wrappers re-export the manifest from the very Core they import.
86
+ const manifest = runtime.nmVersionManifest;
87
+ if (!manifest) throw new Error("loaded-core-manifest-not-exported");
88
+ let protocols;
89
+ let languageVersion;
90
+ let serverVersion;
91
+ if (id === "language-server") {
92
+ const responses = runtime.handleNMLanguageServerMessage(
93
+ runtime.createNMLanguageServerState({ requireInitialize: true }),
94
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: { capabilities: {} } },
95
+ );
96
+ const response = responses.find((item) => item.id === 1);
97
+ if (response?.error || !response?.result?.serverInfo) throw new Error("initialize-failed");
98
+ languageVersion = response.result.serverInfo.version;
99
+ serverVersion = response.result.serverInfo.version;
100
+ protocols = pickStrings({ lspContract: response.result.capabilities?.experimental?.nmContractVersion });
101
+ } else {
102
+ const response = runtime.handleNMMCPMessage(
103
+ { jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: runtime.NM_MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo: { name: "nm-doctor", version: "0.1" } } },
104
+ runtime.createNMMCPServerState({ requireInitialize: true }),
105
+ );
106
+ if (response?.error || !response?.result?.serverInfo) throw new Error("initialize-failed");
107
+ serverVersion = response.result.serverInfo.version;
108
+ languageVersion = manifest?.languageVersion;
109
+ protocols = pickStrings({ mcpContract: runtime.NM_MCP_CONTRACT_VERSION, mcpProtocol: response.result.protocolVersion, mcpToolSchema: runtime.NM_MCP_TOOL_SCHEMA_VERSION });
110
+ }
111
+ return {
112
+ id, availability: "observed", source: "package-artifact", packageVersion: metadata.version,
113
+ languageVersion, corePackageVersion: manifest?.packageVersion, serverVersion,
114
+ protocols, capabilities: manifest ? pickStrings(manifest.capabilities ?? {}) : undefined,
115
+ evidence,
116
+ };
117
+ } catch {
118
+ return { id, availability: "unavailable", source: "package-artifact", packageVersion: metadata.version, evidence, reason: "local-package-runtime-probe-failed" };
119
+ }
120
+ }
121
+
122
+ function findLocalPackageRoot(start, packageName) {
123
+ let directory = start;
124
+ // Only Node's ancestor node_modules locations; no NODE_PATH/global/home scan.
125
+ for (let depth = 0; depth < 64; depth++) {
126
+ const candidate = path.join(directory, "node_modules", packageName);
127
+ try { if (statSync(candidate).isDirectory()) return candidate; } catch { /* absent local package */ }
128
+ const parent = path.dirname(directory);
129
+ if (parent === directory) break;
130
+ directory = parent;
131
+ }
132
+ }
133
+
134
+ function sameDirectory(left, right) {
135
+ try {
136
+ const normalize = (value) => process.platform === "win32" ? value.toLowerCase() : value;
137
+ return normalize(realpathSync(left)) === normalize(realpathSync(right));
138
+ } catch { return false; }
139
+ }
140
+
141
+ function findPackageRoot(entryPath, expectedName, resolutionRoot) {
142
+ const localRoot = findLocalPackageRoot(resolutionRoot, expectedName);
143
+ if (!localRoot) return undefined;
144
+ try {
145
+ // An export may sit below unnamed package.json scopes or many directories.
146
+ // Anchor its metadata to the locally selected package, including symlinks,
147
+ // rather than accepting a nested scope or an enclosing repository manifest.
148
+ const packageRoot = realpathSync(localRoot);
149
+ const entryExists = existsSync(entryPath);
150
+ const resolvedEntry = entryExists ? realpathSync(entryPath) : path.resolve(entryPath);
151
+ const containsEntry = (root) => {
152
+ const relative = path.relative(root, resolvedEntry);
153
+ return relative !== "" && relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
154
+ };
155
+ if (!containsEntry(packageRoot) && (entryExists || !containsEntry(path.resolve(localRoot)))) return undefined;
156
+ return readMetadata(packageRoot, expectedName) ? packageRoot : undefined;
157
+ } catch { return undefined; }
158
+ }
159
+
160
+ function readMetadata(directory, expectedName) {
161
+ try {
162
+ const metadataPath = path.join(directory, "package.json");
163
+ if (statSync(metadataPath).size > 65_536) return undefined;
164
+ const value = JSON.parse(readFileSync(metadataPath, "utf8").replace(/^\uFEFF/u, ""));
165
+ if (value.name !== expectedName || typeof value.version !== "string") return undefined;
166
+ return { version: value.version };
167
+ } catch { return undefined; }
168
+ }
169
+
170
+ function pickStrings(value) {
171
+ return Object.fromEntries(Object.entries(value).filter(([, item]) => typeof item === "string"));
172
+ }
package/bin/nm.mjs CHANGED
@@ -10,6 +10,10 @@ let NM_WORKSPACE_MAX_DOCUMENTS;
10
10
  let NM_WORKSPACE_MAX_SOURCE_BYTES;
11
11
  let nmWorkspaceDiagnosticCodes;
12
12
  let getNMWorkspaceModuleName;
13
+ let loadedRuntime;
14
+ let loadedVersionManifest;
15
+ let runtimeSource;
16
+ let runtimePath;
13
17
  const workspaceRoot = path.resolve(
14
18
  path.dirname(fileURLToPath(import.meta.url)),
15
19
  "../../..",
@@ -36,6 +40,10 @@ if (isPackedVersionCommand) {
36
40
  ]);
37
41
  installWorkspaceAliasResolver(Module);
38
42
  const jiti = createJiti(import.meta.url);
43
+ loadedRuntime = jiti(workspaceCLIPath);
44
+ loadedVersionManifest = jiti(path.join(workspaceRoot, "lib/nm/version.ts")).nmVersionManifest;
45
+ runtimeSource = "repository-source";
46
+ runtimePath = workspaceCLIPath;
39
47
  ({
40
48
  runNMCLI,
41
49
  validateNMWorkspaceManifestJsonText,
@@ -43,7 +51,7 @@ if (isPackedVersionCommand) {
43
51
  NM_WORKSPACE_MAX_SOURCE_BYTES,
44
52
  nmWorkspaceDiagnosticCodes,
45
53
  getNMWorkspaceModuleName,
46
- } = jiti(workspaceCLIPath));
54
+ } = loadedRuntime);
47
55
  } else {
48
56
  try {
49
57
  ({
@@ -53,7 +61,10 @@ if (isPackedVersionCommand) {
53
61
  NM_WORKSPACE_MAX_SOURCE_BYTES,
54
62
  nmWorkspaceDiagnosticCodes,
55
63
  getNMWorkspaceModuleName,
56
- } = await import("../dist/index.js"));
64
+ } = loadedRuntime = await import("../dist/index.js"));
65
+ loadedVersionManifest = loadedRuntime.nmVersionManifest;
66
+ runtimeSource = "cli-package-artifact";
67
+ runtimePath = fileURLToPath(new URL("../dist/index.js", import.meta.url));
57
68
  } catch {
58
69
  ({
59
70
  runNMCLI,
@@ -62,7 +73,10 @@ if (isPackedVersionCommand) {
62
73
  NM_WORKSPACE_MAX_SOURCE_BYTES,
63
74
  nmWorkspaceDiagnosticCodes,
64
75
  getNMWorkspaceModuleName,
65
- } = await import("../../nm-core/dist/index.js"));
76
+ } = loadedRuntime = await import("../../nm-core/dist/index.js"));
77
+ loadedVersionManifest = loadedRuntime.nmVersionManifest;
78
+ runtimeSource = "core-package-fallback";
79
+ runtimePath = fileURLToPath(new URL("../../nm-core/dist/index.js", import.meta.url));
66
80
  }
67
81
  }
68
82
  const packageAction = cliArgs[0] === "package" ? cliArgs[1] : undefined;
@@ -78,7 +92,14 @@ if (isPackedVersionCommand) {
78
92
  packageAction === "verify"
79
93
  ? createConfiguredPackageVerifier(crypto)
80
94
  : undefined;
81
- const result = runNMCLI(cliArgs, {
95
+ const watching = cliArgs.includes("--watch");
96
+ const executionArgs = cliArgs.filter(arg => arg !== "--watch");
97
+ const runOptions = {
98
+ ...(cliArgs[0] === "doctor" ? { doctorRuntime: await (await import("./doctor-runtime.mjs")).observeNMDoctorRuntime({
99
+ cliPackageRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."),
100
+ workspaceRoot: existsSync(workspaceCLIPath) ? workspaceRoot : undefined,
101
+ runtimeSource, runtimePath, runtimeModule: loadedRuntime, versionManifest: loadedVersionManifest,
102
+ }) } : {}),
82
103
  readFile: (filePath) => readFileSync(filePath, "utf8"),
83
104
  readWorkspaceModules: (entryPath) => getWorkspaceIndex(entryPath).modules,
84
105
  readWorkspaceDocuments: (entryPath) =>
@@ -91,15 +112,24 @@ if (isPackedVersionCommand) {
91
112
  }),
92
113
  packageSigner,
93
114
  packageVerifier,
94
- });
95
-
96
- if (result.stdout.length > 0) {
97
- process.stdout.write(`${result.stdout.join("\n")}\n`);
98
- }
99
- if (result.stderr.length > 0) {
100
- process.stderr.write(`${result.stderr.join("\n")}\n`);
115
+ };
116
+ const reportFormat = executionArgs[executionArgs.indexOf("--report-format") + 1];
117
+ const reportFile = executionArgs.includes("--output") ? executionArgs[executionArgs.indexOf("--output") + 1] : undefined;
118
+ if (watching && (executionArgs[0] !== "test" || !executionArgs[1] || executionArgs[1].startsWith("--") || cliArgs.filter(arg => arg === "--watch").length !== 1 || executionArgs.includes("--report-format") && reportFormat !== "json" && !reportFile)) {
119
+ process.stderr.write("--watch requires nm test <file>; JUnit/SARIF watch reports require --output.\n");
120
+ process.exitCode = 1;
121
+ } else if (watching) {
122
+ await (await import("./test-watch.mjs")).watchNMTests({
123
+ entryPath: executionArgs[1], asJson: executionArgs.includes("--json"), reportFormat, reportFile,
124
+ workspaceRoot: () => { const entryDirectory = path.dirname(path.resolve(executionArgs[1])); const manifest = findWorkspaceManifest(entryDirectory); return manifest ? path.dirname(manifest) : entryDirectory; },
125
+ run: () => { workspaceCache.clear(); return runNMCLI(executionArgs, runOptions); },
126
+ });
127
+ } else {
128
+ const result = runNMCLI(cliArgs, runOptions);
129
+ if (result.stdout.length > 0) process.stdout.write(`${result.stdout.join("\n")}\n`);
130
+ if (result.stderr.length > 0) process.stderr.write(`${result.stderr.join("\n")}\n`);
131
+ process.exitCode = result.exitCode;
101
132
  }
102
- process.exitCode = result.exitCode;
103
133
  }
104
134
 
105
135
  function getWorkspaceIndex(entryPath) {
@@ -0,0 +1,84 @@
1
+ import { watch } from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /** One execution at a time, including a queued rerun when changes arrive mid-run. */
5
+ export async function watchNMTests({ entryPath, run, workspaceRoot, asJson, reportFile, reportFormat, debounceMs = 150 }) {
6
+ let timer, running = false, dirty = false, stopped = false, sequence = 0;
7
+ let watchers = [], watchedRoot;
8
+ const ignored = new Set(["node_modules", ".git", ".next", "dist", ".qa"]);
9
+ const comparable = value => process.platform === "win32" ? value.toLowerCase() : value;
10
+ const canonical = value => comparable(path.resolve(value));
11
+ const entry = canonical(entryPath);
12
+ const reportPath = reportFile ? canonical(reportFile) : undefined;
13
+ const emit = (event) => process.stdout.write(asJson ? `${JSON.stringify(event)}\n` : `${event.event === "run" ? `N/M watch run ${event.sequence}: exit ${event.exitCode}` : `N/M watch: ${event.event}${event.message ? `: ${event.message}` : ""}`}\n`);
14
+ const closeWatchers = () => { for (const watcher of watchers) watcher.close(); watchers = []; };
15
+ const changed = (filename, manifestOnly = false, event = "change", directory = watchedRoot) => {
16
+ if (stopped) return;
17
+ const name = filename?.toString() ?? "";
18
+ const matchName = comparable(name);
19
+ if (matchName.split(/[\\/]/u).some(part => ignored.has(part))) return;
20
+ const changedPath = name ? canonical(path.join(directory, name)) : undefined;
21
+ if (changedPath && changedPath === reportPath) return;
22
+ const entryChanged = changedPath === entry;
23
+ const manifestChanged = path.basename(matchName) === "nm-workspace.json";
24
+ // Native watchers can report a moved/deleted directory without reporting its
25
+ // individual .nm children. Rescan structural events and unknown filenames.
26
+ // Explicit entry paths are valid even when they do not use lowercase .nm.
27
+ if (!entryChanged && (manifestOnly ? !manifestChanged : name && event !== "rename" && !matchName.endsWith(".nm") && !manifestChanged)) return;
28
+ dirty = true;
29
+ clearTimeout(timer);
30
+ timer = setTimeout(() => void execute(), debounceMs);
31
+ };
32
+ const installWatchers = () => {
33
+ const root = workspaceRoot();
34
+ const rootPath = canonical(root);
35
+ if (watchedRoot && rootPath === canonical(watchedRoot) && watchers.length) return;
36
+ closeWatchers(); watchedRoot = root;
37
+ const attach = (directory, recursive, manifestOnly) => {
38
+ const watcher = watch(directory, { recursive }, (event, filename) => changed(filename, manifestOnly, event, directory));
39
+ watcher.on("error", error => { emit({ event: "error", message: error.message }); stop(1); });
40
+ watchers.push(watcher);
41
+ };
42
+ attach(root, true, false);
43
+ // A newly created nearer/ancestor manifest can change the workspace roots.
44
+ for (let directory = path.dirname(path.resolve(entryPath)); ; directory = path.dirname(directory)) {
45
+ if (canonical(directory) !== rootPath) attach(directory, false, true);
46
+ if (path.dirname(directory) === directory) break;
47
+ }
48
+ };
49
+ const stop = (code) => {
50
+ if (stopped) return;
51
+ stopped = true; clearTimeout(timer); closeWatchers();
52
+ process.off("SIGINT", onInterrupt); process.off("SIGTERM", onTerminate);
53
+ process.exitCode = code;
54
+ emit({ event: "stopped", exitCode: code });
55
+ };
56
+ const onInterrupt = () => stop(130), onTerminate = () => stop(143);
57
+ const execute = async () => {
58
+ if (stopped || running) return;
59
+ running = true; dirty = false;
60
+ try {
61
+ const result = await run();
62
+ if (stopped) return;
63
+ process.exitCode = result.exitCode;
64
+ sequence++;
65
+ if (asJson) {
66
+ let report;
67
+ try { report = JSON.parse(result.stdout.join("\n")); } catch { /* Invocation failures have no report. */ }
68
+ emit({ event: "run", sequence, exitCode: result.exitCode, report: report ?? null, diagnostics: result.stderr });
69
+ } else {
70
+ emit({ event: "run", sequence, exitCode: result.exitCode });
71
+ if (reportFile && reportFormat && reportFormat !== "json") process.stdout.write(`report file: ${reportFile}\n`);
72
+ else if (result.stdout.length) process.stdout.write(`${result.stdout.join("\n")}\n`);
73
+ if (result.stderr.length) process.stderr.write(`${result.stderr.join("\n")}\n`);
74
+ }
75
+ installWatchers();
76
+ } catch (error) { emit({ event: "error", message: error.message }); stop(1); }
77
+ finally { running = false; if (dirty && !stopped) { clearTimeout(timer); timer = setTimeout(() => void execute(), debounceMs); } }
78
+ };
79
+ process.on("SIGINT", onInterrupt); process.on("SIGTERM", onTerminate);
80
+ // Install before the first run so asynchronous runs cannot miss source changes.
81
+ try { installWatchers(); await execute(); }
82
+ catch (error) { emit({ event: "error", message: error.message }); stop(1); }
83
+ return { dispose: () => stop(process.exitCode ?? 0) };
84
+ }
package/dist/index.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export { getNMWorkspaceModuleName, evaluateNMLanguagePreviewReadiness, NM_CLI_CONTRACT_VERSION, NM_LANGUAGE_PREVIEW_READINESS_FORMAT, NM_LANGUAGE_PREVIEW_READINESS_VERSION, NM_WORKSPACE_MAX_DOCUMENTS, NM_WORKSPACE_MAX_SOURCE_BYTES, nmCliCommandContracts, nmCliCommands, nmCliExitCodeContract, nmLanguagePreviewReadiness, nmLanguageReleaseChangelog, nmVersionManifest, nmWorkspaceDiagnosticCodes, runNMCLI, validateNMWorkspaceManifestJsonText } from "@nm-lang/core/cli";
2
- export type { NMCapabilityStatus, NMCLICommand, NMCLICommandContract, NMCLIDeterminism, NMCLIExportTarget, NMCLIOptions, NMCLIOutputKind, NMCLIResult, NMLanguagePreviewReadiness, NMLanguageReleaseChange, NMVersionManifest } from "@nm-lang/core/cli";
1
+ export { NM_DOCTOR_REPORT_FORMAT, NM_DOCTOR_REPORT_VERSION, createNMDoctorReport, formatNMDoctorReport, nmDoctorExpectedVersions, getNMWorkspaceModuleName, evaluateNMLanguagePreviewReadiness, NM_CLI_CONTRACT_VERSION, NM_LANGUAGE_PREVIEW_READINESS_FORMAT, NM_LANGUAGE_PREVIEW_READINESS_VERSION, NM_WORKSPACE_MAX_DOCUMENTS, NM_WORKSPACE_MAX_SOURCE_BYTES, nmCliCommandContracts, nmCliCommands, nmCliExitCodeContract, nmLanguagePreviewReadiness, nmLanguageReleaseChangelog, nmVersionManifest, nmWorkspaceDiagnosticCodes, runNMCLI, validateNMWorkspaceManifestJsonText } from "@nm-lang/core/cli";
2
+ export type { NMDoctorCheck, NMDoctorComponentId, NMDoctorObservation, NMDoctorReport, NMDoctorRuntimeObservation, NMCapabilityStatus, NMCLICommand, NMCLICommandContract, NMCLIDeterminism, NMCLIExportTarget, NMCLIOptions, NMCLIOutputKind, NMCLIResult, NMLanguagePreviewReadiness, NMLanguageReleaseChange, NMVersionManifest } from "@nm-lang/core/cli";