@githolon/dsl 0.51.0 → 0.52.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/lsp_server.mjs +43 -0
- package/package.json +5 -2
- package/src/check_package_main.ts +47 -7
- package/src/law_source_map.ts +146 -0
- package/src/lsp_main.ts +14 -0
- package/src/lsp_server.ts +404 -0
package/lsp_server.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
3
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
4
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
5
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
6
|
+
|
|
7
|
+
// `githolon lsp` launcher — the twin of `check_package.mjs`: resolve `tsx` (the caller's, else the
|
|
8
|
+
// DSL's own dependency) and run `src/lsp_main.ts` under it. The server imports the tenant's TS domain
|
|
9
|
+
// modules IN-PROCESS on every re-check (needs the tsx ESM loader). stdio is inherited so the LSP wire
|
|
10
|
+
// (stdin/stdout, JSON-RPC) passes through untouched; the editor spawns this and talks to it directly.
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
16
|
+
|
|
17
|
+
const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const MAIN = path.join(DSL_DIR, "src", "lsp_main.ts");
|
|
19
|
+
|
|
20
|
+
function resolvePkgDir(name, fromDir) {
|
|
21
|
+
try {
|
|
22
|
+
const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
|
|
23
|
+
return path.dirname(req.resolve(`${name}/package.json`));
|
|
24
|
+
} catch {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
|
|
30
|
+
if (!tsxDir) {
|
|
31
|
+
console.error("githolon lsp: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
|
|
35
|
+
const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
|
|
36
|
+
const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
|
|
37
|
+
|
|
38
|
+
// spawn (not execFileSync) so the long-lived server's stdio streams stay connected for the session.
|
|
39
|
+
const child = spawn(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
cwd: process.cwd(),
|
|
42
|
+
});
|
|
43
|
+
child.on("exit", (code) => process.exit(code ?? 0));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -47,7 +47,8 @@
|
|
|
47
47
|
"!dart/.dart_tool",
|
|
48
48
|
"!dart/build",
|
|
49
49
|
"compile_package.mjs",
|
|
50
|
-
"check_package.mjs"
|
|
50
|
+
"check_package.mjs",
|
|
51
|
+
"lsp_server.mjs"
|
|
51
52
|
],
|
|
52
53
|
"publishConfig": {
|
|
53
54
|
"access": "public"
|
|
@@ -59,6 +60,8 @@
|
|
|
59
60
|
"dependencies": {
|
|
60
61
|
"esbuild": "0.24.2",
|
|
61
62
|
"tsx": "^4.19.2",
|
|
63
|
+
"vscode-languageserver": "^9.0.1",
|
|
64
|
+
"vscode-languageserver-textdocument": "^1.0.12",
|
|
62
65
|
"zod": "^4.4.3"
|
|
63
66
|
},
|
|
64
67
|
"devDependencies": {
|
|
@@ -17,6 +17,7 @@ import { pathToFileURL } from "node:url";
|
|
|
17
17
|
import { composeDomainModule, type Mod } from "./build_package.js";
|
|
18
18
|
import type { DomainModule } from "./codegen_dart.js";
|
|
19
19
|
import { checkDomainModules, formatFindings, hasRefusal, type Finding } from "./law_check.js";
|
|
20
|
+
import { LawSourceMap } from "./law_source_map.js";
|
|
20
21
|
import {
|
|
21
22
|
currentEvolveLaw,
|
|
22
23
|
evolveDiff,
|
|
@@ -48,28 +49,46 @@ function fail(msg: string): never {
|
|
|
48
49
|
* applies the secondary-ensure auto-declare). Returns the composed modules ready for the twins.
|
|
49
50
|
*/
|
|
50
51
|
export async function loadComposedModules(configArg: string): Promise<DomainModule[]> {
|
|
52
|
+
return (await loadComposedModulesWithSources(configArg)).modules;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Like {@link loadComposedModules}, but ALSO returns the domain-key → ORDERED module file paths map
|
|
57
|
+
* (the source map's input — the composed DomainModule does not retain source paths, so the check/LSP
|
|
58
|
+
* lane threads them through). The config is the one place that names both the domain key and its files.
|
|
59
|
+
*/
|
|
60
|
+
export async function loadComposedModulesWithSources(
|
|
61
|
+
configArg: string,
|
|
62
|
+
): Promise<{ modules: DomainModule[]; domainFiles: Map<string, string[]> }> {
|
|
51
63
|
const configPath = path.resolve(process.cwd(), configArg);
|
|
52
64
|
if (!existsSync(configPath)) fail(`config not found: ${configPath}`);
|
|
53
65
|
const cfgDir = path.dirname(configPath);
|
|
54
|
-
|
|
66
|
+
// Cache-bust the config import so a re-load (the LSP re-check) sees edits, not the first-import cache.
|
|
67
|
+
const cfgUrl = `${pathToFileURL(configPath).href}?t=${Date.now()}`;
|
|
68
|
+
const cfgModule = (await import(cfgUrl)) as { default?: PackageConfig };
|
|
55
69
|
const cfg = cfgModule.default;
|
|
56
70
|
if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
|
|
57
71
|
fail(`config must default-export { name, domains: [{ key, modules }, ...] }`);
|
|
58
72
|
}
|
|
59
73
|
const composed: DomainModule[] = [];
|
|
74
|
+
const domainFiles = new Map<string, string[]>();
|
|
60
75
|
for (const d of cfg.domains) {
|
|
61
76
|
if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
|
|
62
77
|
fail(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
|
|
63
78
|
}
|
|
64
79
|
const mods: Mod[] = [];
|
|
80
|
+
const files: string[] = [];
|
|
65
81
|
for (const rel of d.modules) {
|
|
66
82
|
const abs = path.resolve(cfgDir, rel);
|
|
67
83
|
if (!existsSync(abs)) fail(`domain '${d.key}': module not found: ${abs}`);
|
|
68
|
-
|
|
84
|
+
files.push(abs);
|
|
85
|
+
// Cache-bust so a re-import (the LSP re-check after an edit) re-evaluates the edited module.
|
|
86
|
+
mods.push((await import(`${pathToFileURL(abs).href}?t=${Date.now()}`)) as Mod);
|
|
69
87
|
}
|
|
88
|
+
domainFiles.set(d.key, files);
|
|
70
89
|
composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
|
|
71
90
|
}
|
|
72
|
-
return composed;
|
|
91
|
+
return { modules: composed, domainFiles };
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
/**
|
|
@@ -148,7 +167,9 @@ async function main(): Promise<void> {
|
|
|
148
167
|
? argv[againstIdx + 1]
|
|
149
168
|
: argv.find((a) => a.startsWith("--against="))?.slice("--against=".length);
|
|
150
169
|
|
|
151
|
-
const
|
|
170
|
+
const asJson = argv.includes("--json");
|
|
171
|
+
|
|
172
|
+
const { modules, domainFiles } = await loadComposedModulesWithSources(configArg);
|
|
152
173
|
const findings: Finding[] = [...checkDomainModules(modules)];
|
|
153
174
|
|
|
154
175
|
// ── EVOLVE PREVIEW (--against): diff the current build against a prior law + pre-fire the gate. ──
|
|
@@ -158,19 +179,38 @@ async function main(): Promise<void> {
|
|
|
158
179
|
fail(`--against needs a path: --against <build/<name>.deploy.json | build dir | nomos.stable-ids.json>`);
|
|
159
180
|
}
|
|
160
181
|
const lock = loadCwdLockfile(configArg);
|
|
161
|
-
if (lock === undefined) {
|
|
182
|
+
if (lock === undefined && !asJson) {
|
|
162
183
|
console.log(
|
|
163
184
|
"githolon check: --against — no nomos.stable-ids.json in the config dir; the current law's stable ids " +
|
|
164
185
|
"fall back to NAMES (a rename would read as removal+add). Run `githolon compile` first for a sid-accurate preview.",
|
|
165
186
|
);
|
|
166
187
|
}
|
|
167
188
|
const prior = loadPriorEvolveLaw(againstArg);
|
|
168
|
-
if (prior.note !== undefined) console.log(`githolon check: --against note — ${prior.note}`);
|
|
189
|
+
if (prior.note !== undefined && !asJson) console.log(`githolon check: --against note — ${prior.note}`);
|
|
169
190
|
const current = currentEvolveLaw(modules, lock);
|
|
170
191
|
const diff = evolveDiff(prior.law, current);
|
|
171
192
|
findings.push(...diff.findings);
|
|
172
193
|
if (Object.keys(diff.dispositions).length > 0) evolveDispositions = diff.dispositions;
|
|
173
|
-
console.log(`githolon check: evolve preview against ${prior.source}`);
|
|
194
|
+
if (!asJson) console.log(`githolon check: evolve preview against ${prior.source}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── --json: emit the findings as a machine-readable array (the LSP + tooling surface). Each finding
|
|
198
|
+
// is enriched with its source {file, line, column} when the source map can locate the declaration.
|
|
199
|
+
if (asJson) {
|
|
200
|
+
const srcMap = new LawSourceMap(domainFiles);
|
|
201
|
+
const out = findings.map((f) => {
|
|
202
|
+
const loc = srcMap.locate(f.domain, f.directiveId);
|
|
203
|
+
return {
|
|
204
|
+
domain: f.domain,
|
|
205
|
+
...(f.directiveId !== undefined ? { directiveId: f.directiveId } : {}),
|
|
206
|
+
level: f.level,
|
|
207
|
+
what: f.what,
|
|
208
|
+
fix: f.fix,
|
|
209
|
+
...(loc !== undefined ? { file: loc.file, line: loc.line, column: loc.column, fallback: loc.fallback } : {}),
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
process.stdout.write(JSON.stringify(out) + "\n");
|
|
213
|
+
process.exit(hasRefusal(findings) ? 1 : 0);
|
|
174
214
|
}
|
|
175
215
|
|
|
176
216
|
console.log(`githolon check: ${findings.length} finding(s)`);
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SOURCE MAPPING for `githolon check` findings (architecture/author_time_gate.md, slice 3 — the LSP).
|
|
8
|
+
*
|
|
9
|
+
* A {@link Finding} carries `{domain, directiveId?}` but no source LOCATION — the probe knows WHICH
|
|
10
|
+
* directive/aggregate/read is at fault, not WHERE it is written. This module recovers the location by
|
|
11
|
+
* scanning the domain's own TS module source text for the DECLARATION of the named entity:
|
|
12
|
+
* `directive("<id>")`, `aggregate("<Id>", …)`, `query("<id>")`, `count("<id>")`, `sum(...)`, … — a
|
|
13
|
+
* regex on the STRING LITERAL, robust across formatting (multi-line, spacing, quote style).
|
|
14
|
+
*
|
|
15
|
+
* The id in a finding may name a directive, a read decl (query/count/sum/min/max/exists/spatial), a
|
|
16
|
+
* workspace invariant, or — for a domain-wide finding with no `directiveId` — an aggregate. We do NOT
|
|
17
|
+
* know which kind, so we scan for the id as the FIRST string argument of ANY declaration call. When
|
|
18
|
+
* nothing resolves (a domain-wide finding, an id we can't find, an exotic layout) we fall back to
|
|
19
|
+
* line 1 of the domain's FIRST module — a squiggle at the top of the file beats no squiggle.
|
|
20
|
+
*
|
|
21
|
+
* Pure + synchronous: the caller supplies the domain→module-file-paths map (the config knows it — the
|
|
22
|
+
* composed DomainModule does not retain source paths, so the check/LSP lane threads them through) and
|
|
23
|
+
* the file READS are memoized per source map instance.
|
|
24
|
+
*/
|
|
25
|
+
import { readFileSync } from "node:fs";
|
|
26
|
+
|
|
27
|
+
/** A resolved 1-based source location (LSP-friendly: `line`/`column` are 1-based, editors 0-base them). */
|
|
28
|
+
export interface SourceLocation {
|
|
29
|
+
/** Absolute path of the module file the declaration lives in. */
|
|
30
|
+
readonly file: string;
|
|
31
|
+
/** 1-based line of the declaration's id string literal (or 1 on fallback). */
|
|
32
|
+
readonly line: number;
|
|
33
|
+
/** 1-based column of the id string literal's opening quote (or 1 on fallback). */
|
|
34
|
+
readonly column: number;
|
|
35
|
+
/** True when this is the top-of-file fallback (the id could not be located precisely). */
|
|
36
|
+
readonly fallback: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The declaration VERBS whose first string argument names an entity a finding can point at. */
|
|
40
|
+
const DECL_VERBS = [
|
|
41
|
+
"directive",
|
|
42
|
+
"aggregate",
|
|
43
|
+
"query",
|
|
44
|
+
"count",
|
|
45
|
+
"sum",
|
|
46
|
+
"min",
|
|
47
|
+
"max",
|
|
48
|
+
"exists",
|
|
49
|
+
"spatial",
|
|
50
|
+
"workspaceInvariant",
|
|
51
|
+
"valueObject",
|
|
52
|
+
"derived",
|
|
53
|
+
"combined",
|
|
54
|
+
"first",
|
|
55
|
+
"take",
|
|
56
|
+
"relation",
|
|
57
|
+
] as const;
|
|
58
|
+
|
|
59
|
+
/** Escape a string for literal use inside a RegExp. */
|
|
60
|
+
function escapeRe(s: string): string {
|
|
61
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Compute the 1-based {line, column} of a character offset in `text` (column = chars into the line + 1). */
|
|
65
|
+
function offsetToLineCol(text: string, offset: number): { line: number; column: number } {
|
|
66
|
+
let line = 1;
|
|
67
|
+
let lineStart = 0;
|
|
68
|
+
for (let i = 0; i < offset; i++) {
|
|
69
|
+
if (text.charCodeAt(i) === 10 /* \n */) {
|
|
70
|
+
line++;
|
|
71
|
+
lineStart = i + 1;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return { line, column: offset - lineStart + 1 };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A source map over a single domain's module files. Reads + memoizes the source text lazily and
|
|
79
|
+
* resolves an entity id (or an aggregate/whole-domain fallback) to a {@link SourceLocation}.
|
|
80
|
+
*/
|
|
81
|
+
export class DomainSourceMap {
|
|
82
|
+
private readonly texts = new Map<string, string | null>();
|
|
83
|
+
|
|
84
|
+
/** `files` — the ORDERED module paths composing the domain (config order; first is the fallback anchor). */
|
|
85
|
+
constructor(private readonly files: readonly string[]) {}
|
|
86
|
+
|
|
87
|
+
private text(file: string): string | null {
|
|
88
|
+
if (!this.texts.has(file)) {
|
|
89
|
+
try {
|
|
90
|
+
this.texts.set(file, readFileSync(file, "utf8"));
|
|
91
|
+
} catch {
|
|
92
|
+
this.texts.set(file, null);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return this.texts.get(file) ?? null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The top-of-file fallback: line 1, column 1 of the domain's first readable module. */
|
|
99
|
+
private fallbackLocation(): SourceLocation | undefined {
|
|
100
|
+
const file = this.files[0];
|
|
101
|
+
if (file === undefined) return undefined;
|
|
102
|
+
return { file, line: 1, column: 1, fallback: true };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Locate the declaration of `id` across the domain's modules — the first `<verb>("<id>"…)` /
|
|
107
|
+
* `<verb>('<id>'…)` / <verb>(`<id>`…) match, scanned in config module order. Falls back to the top
|
|
108
|
+
* of the first module when `id` is undefined (a domain-wide finding) or cannot be located.
|
|
109
|
+
*/
|
|
110
|
+
locate(id: string | undefined): SourceLocation | undefined {
|
|
111
|
+
if (id !== undefined) {
|
|
112
|
+
const idRe = escapeRe(id);
|
|
113
|
+
// <verb> ( <optional ws> <quote> id <same quote> — the id as the first string arg of a decl call.
|
|
114
|
+
const verbs = DECL_VERBS.join("|");
|
|
115
|
+
const re = new RegExp(`\\b(?:${verbs})\\s*\\(\\s*(["'\`])${idRe}\\1`, "g");
|
|
116
|
+
for (const file of this.files) {
|
|
117
|
+
const text = this.text(file);
|
|
118
|
+
if (text === null) continue;
|
|
119
|
+
const m = re.exec(text);
|
|
120
|
+
if (m !== null) {
|
|
121
|
+
// Column points at the opening QUOTE of the id literal (m.index is at the verb).
|
|
122
|
+
const quoteOffset = m.index + m[0].length - id.length - 1;
|
|
123
|
+
const { line, column } = offsetToLineCol(text, quoteOffset);
|
|
124
|
+
return { file, line, column, fallback: false };
|
|
125
|
+
}
|
|
126
|
+
re.lastIndex = 0;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return this.fallbackLocation();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** A source map over a whole package — one {@link DomainSourceMap} per domain key. */
|
|
134
|
+
export class LawSourceMap {
|
|
135
|
+
private readonly perDomain = new Map<string, DomainSourceMap>();
|
|
136
|
+
|
|
137
|
+
/** `domainFiles` — domain key → its ORDERED module file paths (absolute), as the config declares them. */
|
|
138
|
+
constructor(domainFiles: ReadonlyMap<string, readonly string[]>) {
|
|
139
|
+
for (const [domain, files] of domainFiles) this.perDomain.set(domain, new DomainSourceMap([...files]));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Resolve a finding's `{domain, directiveId?}` to a source location, or `undefined` when unmappable. */
|
|
143
|
+
locate(domain: string, id?: string): SourceLocation | undefined {
|
|
144
|
+
return this.perDomain.get(domain)?.locate(id);
|
|
145
|
+
}
|
|
146
|
+
}
|
package/src/lsp_main.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `githolon lsp` (stdio) — the language-server entry. Starts the author-time-gate server
|
|
8
|
+
* ({@link startServer}); it speaks LSP over stdin/stdout to any editor (VS Code, Neovim, …).
|
|
9
|
+
* Run under tsx (the launcher `lsp_server.mjs`) so it can import the tenant's TS domain modules
|
|
10
|
+
* in-process. See architecture/author_time_gate.md (slice 3).
|
|
11
|
+
*/
|
|
12
|
+
import { startServer } from "./lsp_server.js";
|
|
13
|
+
|
|
14
|
+
startServer();
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
2
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
3
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
4
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `githolon lsp` — THE AUTHOR-TIME GATE AS A LANGUAGE SERVER (architecture/author_time_gate.md,
|
|
8
|
+
* slice 3). A thin `vscode-languageserver` (stdio) wrapper over the SAME static-twin machinery
|
|
9
|
+
* `githolon check` runs: on open/save/change of a `.ts` under a `nomos.package.mjs`'s domain graph,
|
|
10
|
+
* it composes the package IN-PROCESS (cache-busted TS re-import under tsx — no per-keystroke process
|
|
11
|
+
* spawn), runs {@link checkDomainModules}, maps findings to source ranges ({@link LawSourceMap}), and
|
|
12
|
+
* publishes diagnostics (refuse→Error, warn→Warning, note→Information).
|
|
13
|
+
*
|
|
14
|
+
* IN-PROCESS, not subprocess: spawning `check_package.mjs` (tsx+node cold start ≈ 150–300 ms) per
|
|
15
|
+
* keystroke is too slow for red squiggles. The check machinery is pure TS we import directly; the
|
|
16
|
+
* only cost is re-evaluating changed modules, which the `?t=` cache-bust query forces (proven under
|
|
17
|
+
* tsx). Composition is memoized per config keyed by the module files' mtimes so an UNCHANGED package
|
|
18
|
+
* re-checks without re-importing (the cheap warm win).
|
|
19
|
+
*
|
|
20
|
+
* Multi-root: each document resolves to its NEAREST `nomos.package.mjs` (walk up), so a repo with
|
|
21
|
+
* several packages diagnoses each against its own config.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
import { pathToFileURL, fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
createConnection,
|
|
29
|
+
Diagnostic,
|
|
30
|
+
DiagnosticSeverity,
|
|
31
|
+
DidChangeConfigurationNotification,
|
|
32
|
+
StreamMessageReader,
|
|
33
|
+
StreamMessageWriter,
|
|
34
|
+
TextDocuments,
|
|
35
|
+
TextDocumentSyncKind,
|
|
36
|
+
type InitializeParams,
|
|
37
|
+
} from "vscode-languageserver/node.js";
|
|
38
|
+
import { TextDocument } from "vscode-languageserver-textdocument";
|
|
39
|
+
|
|
40
|
+
import { composeDomainModule, type Mod } from "./build_package.js";
|
|
41
|
+
import type { DomainModule } from "./codegen_dart.js";
|
|
42
|
+
import { checkDomainModules, type Finding } from "./law_check.js";
|
|
43
|
+
import { LawSourceMap } from "./law_source_map.js";
|
|
44
|
+
import {
|
|
45
|
+
currentEvolveLaw,
|
|
46
|
+
evolveDiff,
|
|
47
|
+
evolveLawFromDeployJson,
|
|
48
|
+
evolveLawFromLockfile,
|
|
49
|
+
type EvolveLaw,
|
|
50
|
+
type StableIdLockfile,
|
|
51
|
+
} from "./evolve_preview.js";
|
|
52
|
+
|
|
53
|
+
interface DomainConfig {
|
|
54
|
+
key: string;
|
|
55
|
+
name?: string;
|
|
56
|
+
modules: string[];
|
|
57
|
+
}
|
|
58
|
+
interface PackageConfig {
|
|
59
|
+
name: string;
|
|
60
|
+
domains: DomainConfig[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A finding enriched with the resolved source file (absolute) it belongs to. */
|
|
64
|
+
interface LocatedFinding {
|
|
65
|
+
readonly finding: Finding;
|
|
66
|
+
readonly file: string;
|
|
67
|
+
readonly line: number; // 1-based
|
|
68
|
+
readonly column: number; // 1-based
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The result of one package check pass. */
|
|
72
|
+
export interface CheckPassResult {
|
|
73
|
+
/** Findings across the package, each located to a source file. */
|
|
74
|
+
readonly located: LocatedFinding[];
|
|
75
|
+
/** A load/compose error (bad config, module that failed to import) — reported on the config file. */
|
|
76
|
+
readonly error?: string;
|
|
77
|
+
/** The absolute module file paths in the package (so the caller can clear stale diagnostics). */
|
|
78
|
+
readonly files: string[];
|
|
79
|
+
/** The config file path (the anchor for a whole-package load error). */
|
|
80
|
+
readonly configPath: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Walk up from `startDir` to find the nearest `nomos.package.mjs`; `undefined` at the filesystem root. */
|
|
84
|
+
export function findNearestConfig(startDir: string): string | undefined {
|
|
85
|
+
let dir = startDir;
|
|
86
|
+
for (;;) {
|
|
87
|
+
const candidate = path.join(dir, "nomos.package.mjs");
|
|
88
|
+
if (existsSync(candidate)) return candidate;
|
|
89
|
+
const parent = path.dirname(dir);
|
|
90
|
+
if (parent === dir) return undefined;
|
|
91
|
+
dir = parent;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A composed package + its provenance, memoized per config keyed by the module files' mtimes. */
|
|
96
|
+
interface ComposedCache {
|
|
97
|
+
readonly mtimeKey: string;
|
|
98
|
+
readonly modules: DomainModule[];
|
|
99
|
+
readonly domainFiles: Map<string, string[]>;
|
|
100
|
+
}
|
|
101
|
+
const composedCache = new Map<string, ComposedCache>();
|
|
102
|
+
|
|
103
|
+
function mtimeKey(files: readonly string[]): string {
|
|
104
|
+
return files
|
|
105
|
+
.map((f) => {
|
|
106
|
+
try {
|
|
107
|
+
return `${f}:${statSync(f).mtimeMs}`;
|
|
108
|
+
} catch {
|
|
109
|
+
return `${f}:missing`;
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
.join("|");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Compose a package's domain modules IN-PROCESS from its config. Cache-busts each `import()` so an
|
|
117
|
+
* edited module re-evaluates, and memoizes the whole composition by the module files' mtimes so an
|
|
118
|
+
* unchanged package returns instantly. Throws on a bad config / a module that fails to import.
|
|
119
|
+
*/
|
|
120
|
+
async function composePackage(
|
|
121
|
+
configPath: string,
|
|
122
|
+
): Promise<{ modules: DomainModule[]; domainFiles: Map<string, string[]> }> {
|
|
123
|
+
const cfgDir = path.dirname(configPath);
|
|
124
|
+
const cfgModule = (await import(`${pathToFileURL(configPath).href}?t=${Date.now()}`)) as {
|
|
125
|
+
default?: PackageConfig;
|
|
126
|
+
};
|
|
127
|
+
const cfg = cfgModule.default;
|
|
128
|
+
if (!cfg || typeof cfg.name !== "string" || !Array.isArray(cfg.domains) || cfg.domains.length === 0) {
|
|
129
|
+
throw new Error("config must default-export { name, domains: [{ key, modules }, ...] }");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Resolve every module file first so we can compute the mtime cache key before importing.
|
|
133
|
+
const perDomainFiles: { key: string; name: string | undefined; files: string[] }[] = [];
|
|
134
|
+
const allFiles: string[] = [];
|
|
135
|
+
for (const d of cfg.domains) {
|
|
136
|
+
if (typeof d.key !== "string" || !Array.isArray(d.modules) || d.modules.length === 0) {
|
|
137
|
+
throw new Error(`each domain needs { key, modules: [path, ...] }; got ${JSON.stringify(d)}`);
|
|
138
|
+
}
|
|
139
|
+
const files: string[] = [];
|
|
140
|
+
for (const rel of d.modules) {
|
|
141
|
+
const abs = path.resolve(cfgDir, rel);
|
|
142
|
+
if (!existsSync(abs)) throw new Error(`domain '${d.key}': module not found: ${abs}`);
|
|
143
|
+
files.push(abs);
|
|
144
|
+
allFiles.push(abs);
|
|
145
|
+
}
|
|
146
|
+
perDomainFiles.push({ key: d.key, name: d.name, files });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const key = `${statSync(configPath).mtimeMs}::${mtimeKey(allFiles)}`;
|
|
150
|
+
const cached = composedCache.get(configPath);
|
|
151
|
+
if (cached !== undefined && cached.mtimeKey === key) {
|
|
152
|
+
return { modules: cached.modules, domainFiles: cached.domainFiles };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const composed: DomainModule[] = [];
|
|
156
|
+
const domainFiles = new Map<string, string[]>();
|
|
157
|
+
for (const d of perDomainFiles) {
|
|
158
|
+
const mods: Mod[] = [];
|
|
159
|
+
for (const abs of d.files) {
|
|
160
|
+
mods.push((await import(`${pathToFileURL(abs).href}?t=${Date.now()}`)) as Mod);
|
|
161
|
+
}
|
|
162
|
+
domainFiles.set(d.key, d.files);
|
|
163
|
+
composed.push(composeDomainModule({ name: d.name ?? d.key, domain: d.key, modules: mods }));
|
|
164
|
+
}
|
|
165
|
+
composedCache.set(configPath, { mtimeKey: key, modules: composed, domainFiles });
|
|
166
|
+
return { modules: composed, domainFiles };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Load the `--against` prior evolve law from a deploy.json / build dir / lockfile (LSP variant — throws). */
|
|
170
|
+
function loadPriorEvolveLaw(arg: string, cfgDir: string): EvolveLaw | undefined {
|
|
171
|
+
const abs = path.isAbsolute(arg) ? arg : path.resolve(cfgDir, arg);
|
|
172
|
+
if (!existsSync(abs)) return undefined;
|
|
173
|
+
let file = abs;
|
|
174
|
+
if (statSync(abs).isDirectory()) {
|
|
175
|
+
const buildDir = path.join(abs, "build");
|
|
176
|
+
const scan = (d: string): string | undefined => {
|
|
177
|
+
if (!existsSync(d)) return undefined;
|
|
178
|
+
const hit = readdirSync(d).find((f) => f.endsWith(".deploy.json"));
|
|
179
|
+
return hit !== undefined ? path.join(d, hit) : undefined;
|
|
180
|
+
};
|
|
181
|
+
const deploy = scan(abs) ?? scan(buildDir);
|
|
182
|
+
const lock = path.join(abs, "nomos.stable-ids.json");
|
|
183
|
+
if (deploy !== undefined) file = deploy;
|
|
184
|
+
else if (existsSync(lock)) file = lock;
|
|
185
|
+
else return undefined;
|
|
186
|
+
}
|
|
187
|
+
let parsed: unknown;
|
|
188
|
+
try {
|
|
189
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
190
|
+
} catch {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
const p = parsed as Record<string, unknown>;
|
|
194
|
+
if (p.domains !== undefined && p.interface === undefined && p.packageUsda === undefined) {
|
|
195
|
+
return evolveLawFromLockfile(parsed as StableIdLockfile);
|
|
196
|
+
}
|
|
197
|
+
return evolveLawFromDeployJson(parsed as never);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Read the committed `nomos.stable-ids.json` lockfile beside the config (the CURRENT identity). */
|
|
201
|
+
function loadCwdLockfile(cfgDir: string): StableIdLockfile | undefined {
|
|
202
|
+
const lockPath = path.join(cfgDir, "nomos.stable-ids.json");
|
|
203
|
+
if (!existsSync(lockPath)) return undefined;
|
|
204
|
+
try {
|
|
205
|
+
return JSON.parse(readFileSync(lockPath, "utf8")) as StableIdLockfile;
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Run the full author-time check for one package config IN-PROCESS and locate every finding. `against`
|
|
213
|
+
* (optional, from `nomos.lsp.against`) folds the evolve-preview findings in. Never throws — a load
|
|
214
|
+
* error is returned as {@link CheckPassResult.error} so the LSP can surface it on the config file.
|
|
215
|
+
*/
|
|
216
|
+
export async function runCheckPass(configPath: string, against?: string): Promise<CheckPassResult> {
|
|
217
|
+
const cfgDir = path.dirname(configPath);
|
|
218
|
+
let modules: DomainModule[];
|
|
219
|
+
let domainFiles: Map<string, string[]>;
|
|
220
|
+
try {
|
|
221
|
+
({ modules, domainFiles } = await composePackage(configPath));
|
|
222
|
+
} catch (e) {
|
|
223
|
+
return { located: [], error: (e as Error).message, files: [], configPath };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const findings: Finding[] = [...checkDomainModules(modules)];
|
|
227
|
+
|
|
228
|
+
if (against !== undefined && against.trim().length > 0) {
|
|
229
|
+
try {
|
|
230
|
+
const prior = loadPriorEvolveLaw(against, cfgDir);
|
|
231
|
+
if (prior !== undefined) {
|
|
232
|
+
const current = currentEvolveLaw(modules, loadCwdLockfile(cfgDir));
|
|
233
|
+
findings.push(...evolveDiff(prior, current).findings);
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
/* evolve preview is best-effort — never block the base check */
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const srcMap = new LawSourceMap(domainFiles);
|
|
241
|
+
const located: LocatedFinding[] = findings.map((f) => {
|
|
242
|
+
const loc = srcMap.locate(f.domain, f.directiveId);
|
|
243
|
+
return {
|
|
244
|
+
finding: f,
|
|
245
|
+
file: loc?.file ?? configPath,
|
|
246
|
+
line: loc?.line ?? 1,
|
|
247
|
+
column: loc?.column ?? 1,
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const files: string[] = [];
|
|
252
|
+
for (const fs of domainFiles.values()) files.push(...fs);
|
|
253
|
+
return { located, files, configPath };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Map a finding level to an LSP diagnostic severity. */
|
|
257
|
+
function severityOf(level: Finding["level"]): DiagnosticSeverity {
|
|
258
|
+
return level === "refuse"
|
|
259
|
+
? DiagnosticSeverity.Error
|
|
260
|
+
: level === "warn"
|
|
261
|
+
? DiagnosticSeverity.Warning
|
|
262
|
+
: DiagnosticSeverity.Information;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Build the diagnostic for a located finding — a range covering the declaration line's token. */
|
|
266
|
+
function diagnosticFor(lf: LocatedFinding): Diagnostic {
|
|
267
|
+
const line = Math.max(0, lf.line - 1);
|
|
268
|
+
const col = Math.max(0, lf.column - 1);
|
|
269
|
+
return {
|
|
270
|
+
severity: severityOf(lf.finding.level),
|
|
271
|
+
range: { start: { line, character: col }, end: { line, character: col + 1 } },
|
|
272
|
+
source: "githolon",
|
|
273
|
+
message: `${lf.finding.what} — fix: ${lf.finding.fix}`,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Group located findings by absolute file path into LSP diagnostics. */
|
|
278
|
+
export function groupDiagnostics(located: readonly LocatedFinding[]): Map<string, Diagnostic[]> {
|
|
279
|
+
const byFile = new Map<string, Diagnostic[]>();
|
|
280
|
+
for (const lf of located) {
|
|
281
|
+
const list = byFile.get(lf.file) ?? [];
|
|
282
|
+
list.push(diagnosticFor(lf));
|
|
283
|
+
byFile.set(lf.file, list);
|
|
284
|
+
}
|
|
285
|
+
return byFile;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Start the language server on stdio. Exported so the launcher (`lsp_main.ts`) and tests can drive it;
|
|
290
|
+
* the connection is created from `process.stdin`/`process.stdout` by default.
|
|
291
|
+
*/
|
|
292
|
+
export function startServer(): void {
|
|
293
|
+
// Bind explicitly to stdio (an editor may or may not pass `--stdio`; the streams are unambiguous).
|
|
294
|
+
const connection = createConnection(
|
|
295
|
+
new StreamMessageReader(process.stdin),
|
|
296
|
+
new StreamMessageWriter(process.stdout),
|
|
297
|
+
);
|
|
298
|
+
const documents = new TextDocuments(TextDocument);
|
|
299
|
+
|
|
300
|
+
let against: string | undefined;
|
|
301
|
+
// Per config: the set of files we last published diagnostics for (to clear stale ones on re-check).
|
|
302
|
+
const publishedByConfig = new Map<string, Set<string>>();
|
|
303
|
+
const debounceTimers = new Map<string, NodeJS.Timeout>();
|
|
304
|
+
|
|
305
|
+
connection.onInitialize((params: InitializeParams) => {
|
|
306
|
+
const init = params.initializationOptions as { against?: string } | undefined;
|
|
307
|
+
if (init?.against !== undefined) against = init.against;
|
|
308
|
+
return {
|
|
309
|
+
capabilities: {
|
|
310
|
+
textDocumentSync: TextDocumentSyncKind.Incremental,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
connection.onInitialized(() => {
|
|
316
|
+
void connection.client
|
|
317
|
+
.register(DidChangeConfigurationNotification.type, undefined)
|
|
318
|
+
.catch(() => {/* client without config support — the initializationOptions value stands */});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
connection.onDidChangeConfiguration((change) => {
|
|
322
|
+
const settings = change.settings as { nomos?: { lsp?: { against?: string } } } | undefined;
|
|
323
|
+
const next = settings?.nomos?.lsp?.against;
|
|
324
|
+
if (next !== undefined) {
|
|
325
|
+
against = next;
|
|
326
|
+
// Re-check every open TS document under a config.
|
|
327
|
+
for (const doc of documents.all()) scheduleCheck(doc.uri, 0);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
/** Run the check for the config that owns `uri` and publish/clear diagnostics across the package. */
|
|
332
|
+
async function checkForUri(uri: string): Promise<void> {
|
|
333
|
+
let fsPath: string;
|
|
334
|
+
try {
|
|
335
|
+
fsPath = fileURLToPath(uri);
|
|
336
|
+
} catch {
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (!fsPath.endsWith(".ts")) return;
|
|
340
|
+
const configPath = findNearestConfig(path.dirname(fsPath));
|
|
341
|
+
if (configPath === undefined) return;
|
|
342
|
+
|
|
343
|
+
const result = await runCheckPass(configPath, against);
|
|
344
|
+
|
|
345
|
+
if (result.error !== undefined) {
|
|
346
|
+
// A whole-package load error → one diagnostic on the config file.
|
|
347
|
+
connection.sendDiagnostics({
|
|
348
|
+
uri: pathToFileURL(configPath).href,
|
|
349
|
+
diagnostics: [
|
|
350
|
+
{
|
|
351
|
+
severity: DiagnosticSeverity.Error,
|
|
352
|
+
range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
|
|
353
|
+
source: "githolon",
|
|
354
|
+
message: `githolon check could not load the package: ${result.error}`,
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
});
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
// Clear any prior config-file load error.
|
|
361
|
+
connection.sendDiagnostics({ uri: pathToFileURL(configPath).href, diagnostics: [] });
|
|
362
|
+
|
|
363
|
+
const byFile = groupDiagnostics(result.located);
|
|
364
|
+
const nowPublished = new Set<string>();
|
|
365
|
+
for (const [file, diags] of byFile) {
|
|
366
|
+
connection.sendDiagnostics({ uri: pathToFileURL(file).href, diagnostics: diags });
|
|
367
|
+
nowPublished.add(file);
|
|
368
|
+
}
|
|
369
|
+
// Clear diagnostics for files that had findings last time but are now clean.
|
|
370
|
+
const prev = publishedByConfig.get(configPath) ?? new Set<string>();
|
|
371
|
+
for (const file of prev) {
|
|
372
|
+
if (!nowPublished.has(file)) {
|
|
373
|
+
connection.sendDiagnostics({ uri: pathToFileURL(file).href, diagnostics: [] });
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// Also make sure every module file with NO findings is cleared (fresh-open case).
|
|
377
|
+
for (const file of result.files) {
|
|
378
|
+
if (!nowPublished.has(file)) {
|
|
379
|
+
connection.sendDiagnostics({ uri: pathToFileURL(file).href, diagnostics: [] });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
publishedByConfig.set(configPath, nowPublished);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function scheduleCheck(uri: string, delayMs: number): void {
|
|
386
|
+
const existing = debounceTimers.get(uri);
|
|
387
|
+
if (existing !== undefined) clearTimeout(existing);
|
|
388
|
+
debounceTimers.set(
|
|
389
|
+
uri,
|
|
390
|
+
setTimeout(() => {
|
|
391
|
+
debounceTimers.delete(uri);
|
|
392
|
+
void checkForUri(uri).catch((e) => connection.console.error(`githolon lsp: ${String(e)}`));
|
|
393
|
+
}, delayMs),
|
|
394
|
+
);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Open + save are immediate; change is debounced (~500 ms) so we don't re-check every keystroke.
|
|
398
|
+
documents.onDidOpen((e) => scheduleCheck(e.document.uri, 0));
|
|
399
|
+
documents.onDidSave((e) => scheduleCheck(e.document.uri, 0));
|
|
400
|
+
documents.onDidChangeContent((e) => scheduleCheck(e.document.uri, 500));
|
|
401
|
+
|
|
402
|
+
documents.listen(connection);
|
|
403
|
+
connection.listen();
|
|
404
|
+
}
|