@xynogen/pix-runtime 0.13.1 → 0.14.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xynogen/pix-runtime",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Pix shared runtime — versioned pix.json config, atomic persistence, typed change events",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -38,7 +38,8 @@
38
38
  "./os": "./src/os.ts",
39
39
  "./audio": "./src/audio.ts",
40
40
  "./safe-path": "./src/safe-path.ts",
41
- "./which": "./src/which.ts"
41
+ "./which": "./src/which.ts",
42
+ "./ast": "./src/ast/index.ts"
42
43
  },
43
44
  "keywords": [
44
45
  "pi",
@@ -63,9 +64,18 @@
63
64
  },
64
65
  "peerDependencies": {
65
66
  "@earendil-works/pi-coding-agent": "*",
66
- "@earendil-works/pi-tui": "*"
67
+ "@earendil-works/pi-tui": "*",
68
+ "@ast-grep/napi": "^0.45.0"
67
69
  },
68
70
  "dependencies": {
69
71
  "xxhash-wasm": "1.1.0"
72
+ },
73
+ "peerDependenciesMeta": {
74
+ "@ast-grep/napi": {
75
+ "optional": true
76
+ }
77
+ },
78
+ "devDependencies": {
79
+ "@ast-grep/napi": "^0.45.0"
70
80
  }
71
81
  }
@@ -0,0 +1,219 @@
1
+ /**
2
+ * engine.ts — the lazy @ast-grep/napi loader and language map.
3
+ *
4
+ * The native addon loads on first tool call, never at extension registration.
5
+ * The addon bundles six grammars: ts, tsx, js, jsx, css, html. We map file
6
+ * extensions to language aliases. Non-bundled languages use grammars.ts, which
7
+ * loads cached grammar packages or installs them only after user consent.
8
+ *
9
+ * Loader adapted from pi-lens `clients/deps/ast-grep-napi.ts` (MIT). See
10
+ * packages/pix-diagnostics/LICENSE.pi-lens.
11
+ *
12
+ * `@ast-grep/napi` is an optional peer: pix-runtime never forces the native addon on
13
+ * every install. Packages that parse (pix-astgrep, pix-graph) depend on it; without it
14
+ * `loadEngine()` returns undefined and callers degrade.
15
+ * ponytail: resolution assumes npm hoists napi next to pix-runtime (the flat Pi install).
16
+ */
17
+
18
+ import { createRequire } from "node:module";
19
+ import { extname } from "node:path";
20
+ import { pathToFileURL } from "node:url";
21
+
22
+ // The typed shape of the addon accessor we use (parse → root → findAll).
23
+ export interface SgEdit {
24
+ startPos: number;
25
+ endPos: number;
26
+ insertedText: string;
27
+ }
28
+ export interface SgNode {
29
+ text(): string;
30
+ kind(): string;
31
+ range(): { start: SgPos; end: SgPos };
32
+ findAll(matcher: string | { rule: Record<string, unknown> }): SgNode[];
33
+ children(): SgNode[];
34
+ isNamed(): boolean;
35
+ /** Child stored under a tree-sitter field name (`name`, `body`, `function`, …). */
36
+ field(name: string): SgNode | null;
37
+ replace(text: string): SgEdit;
38
+ commitEdits(edits: SgEdit[]): string;
39
+ getMatch(name: string): SgNode | null;
40
+ getMultipleMatches(name: string): SgNode[];
41
+ }
42
+ interface SgPos {
43
+ line: number;
44
+ column: number;
45
+ index: number;
46
+ }
47
+ interface SgRoot {
48
+ root(): SgNode;
49
+ }
50
+ interface LangApi {
51
+ parse(source: string): SgRoot;
52
+ }
53
+ interface AstGrepNapi {
54
+ ts: LangApi;
55
+ tsx: LangApi;
56
+ js: LangApi;
57
+ jsx: LangApi;
58
+ css: LangApi;
59
+ html: LangApi;
60
+ /** Top-level parse for a dynamically registered language, keyed by alias. */
61
+ parse(lang: string, source: string): SgRoot;
62
+ registerDynamicLanguage(langs: Record<string, unknown>): void;
63
+ }
64
+
65
+ const _require = createRequire(import.meta.url);
66
+ let cached: AstGrepNapi | undefined;
67
+ let loadPromise: Promise<AstGrepNapi | undefined> | undefined;
68
+
69
+ /** Load the native addon once. Returns `undefined` when it cannot load. */
70
+ export async function loadEngine(): Promise<AstGrepNapi | undefined> {
71
+ if (cached) return cached;
72
+ if (loadPromise) return loadPromise;
73
+ loadPromise = (async () => {
74
+ try {
75
+ const entry = _require.resolve("@ast-grep/napi");
76
+ // SAFETY: the addon has no bundled ESM types; the accessors we use
77
+ // (ts/tsx/js/jsx/css/html .parse) are verified at runtime by tests.
78
+ const mod = (await import(pathToFileURL(entry).href)) as unknown as AstGrepNapi;
79
+ cached = mod;
80
+ return mod;
81
+ } catch {
82
+ return undefined;
83
+ } finally {
84
+ loadPromise = undefined;
85
+ }
86
+ })();
87
+ return loadPromise;
88
+ }
89
+
90
+ /** The six grammars the napi addon parses in-process without any package. */
91
+ export const LANGUAGES = ["typescript", "tsx", "javascript", "jsx", "css", "html"] as const;
92
+ export type Language = (typeof LANGUAGES)[number];
93
+ const BUNDLED = new Set<string>(LANGUAGES);
94
+
95
+ /** True when napi parses this alias in-process (no `@ast-grep/lang-*` needed). */
96
+ export function isBundled(lang: string): lang is Language {
97
+ return BUNDLED.has(lang);
98
+ }
99
+
100
+ /**
101
+ * Supported ast-grep language aliases. The six bundled ones parse in-process;
102
+ * the rest have published `@ast-grep/lang-*` packages (see grammars.ts).
103
+ * This intentionally excludes languages without an installable grammar.
104
+ */
105
+ export const ALL_LANGUAGES = [
106
+ "typescript",
107
+ "tsx",
108
+ "javascript",
109
+ "jsx",
110
+ "css",
111
+ "html",
112
+ "python",
113
+ "go",
114
+ "rust",
115
+ "java",
116
+ "c",
117
+ "cpp",
118
+ "csharp",
119
+ "ruby",
120
+ "php",
121
+ "swift",
122
+ "kotlin",
123
+ "scala",
124
+ "lua",
125
+ "elixir",
126
+ "json",
127
+ "yaml",
128
+ "bash",
129
+ "haskell",
130
+ ] as const;
131
+ export type AnyLanguage = (typeof ALL_LANGUAGES)[number];
132
+
133
+ // File extension → language alias, from the ast-grep language table.
134
+ const EXT_TO_LANG: Record<string, AnyLanguage> = {
135
+ ".ts": "typescript",
136
+ ".mts": "typescript",
137
+ ".cts": "typescript",
138
+ ".tsx": "tsx",
139
+ ".js": "javascript",
140
+ ".mjs": "javascript",
141
+ ".cjs": "javascript",
142
+ ".jsx": "jsx",
143
+ ".css": "css",
144
+ ".html": "html",
145
+ ".htm": "html",
146
+ ".xhtml": "html",
147
+ ".py": "python",
148
+ ".pyi": "python",
149
+ ".go": "go",
150
+ ".rs": "rust",
151
+ ".java": "java",
152
+ ".c": "c",
153
+ ".h": "c",
154
+ ".cc": "cpp",
155
+ ".cpp": "cpp",
156
+ ".cxx": "cpp",
157
+ ".hpp": "cpp",
158
+ ".hh": "cpp",
159
+ ".cu": "cpp",
160
+ ".cs": "csharp",
161
+ ".rb": "ruby",
162
+ ".gemspec": "ruby",
163
+ ".php": "php",
164
+ ".swift": "swift",
165
+ ".kt": "kotlin",
166
+ ".kts": "kotlin",
167
+ ".scala": "scala",
168
+ ".sc": "scala",
169
+ ".sbt": "scala",
170
+ ".lua": "lua",
171
+ ".ex": "elixir",
172
+ ".exs": "elixir",
173
+ ".json": "json",
174
+ ".yml": "yaml",
175
+ ".yaml": "yaml",
176
+ ".sh": "bash",
177
+ ".bash": "bash",
178
+ ".zsh": "bash",
179
+ ".ksh": "bash",
180
+ ".hs": "haskell",
181
+ };
182
+
183
+ /** Map a file path to any ast-grep language alias, or `undefined`. */
184
+ export function languageForFile(path: string): AnyLanguage | undefined {
185
+ return EXT_TO_LANG[extname(path).toLowerCase()];
186
+ }
187
+
188
+ /** Get the addon accessor for a language, or `undefined` when unsupported. */
189
+ export function accessorFor(engine: AstGrepNapi, lang: Language): LangApi {
190
+ switch (lang) {
191
+ case "typescript":
192
+ return engine.ts;
193
+ case "tsx":
194
+ return engine.tsx;
195
+ case "javascript":
196
+ return engine.js;
197
+ case "jsx":
198
+ return engine.jsx;
199
+ case "css":
200
+ return engine.css;
201
+ case "html":
202
+ return engine.html;
203
+ default:
204
+ throw new Error(`unsupported language: ${lang as string}`);
205
+ }
206
+ }
207
+
208
+ /** Parse source into a root node for one bundled language. */
209
+ export function parse(engine: AstGrepNapi, lang: Language, source: string): SgNode {
210
+ return accessorFor(engine, lang).parse(source).root();
211
+ }
212
+
213
+ /**
214
+ * Parse source for any language alias, including a dynamically registered one.
215
+ * The caller must have run `ensureLanguage(lang)` first for non-bundled ones.
216
+ */
217
+ export function parseDynamic(engine: AstGrepNapi, lang: string, source: string): SgNode {
218
+ return engine.parse(lang, source).root();
219
+ }
@@ -0,0 +1,221 @@
1
+ /**
2
+ * grammars.ts — lazy, package-owned multi-language grammar loading.
3
+ *
4
+ * The napi addon bundles six grammars (see engine.ts). Every other ast-grep
5
+ * language ships as its own `@ast-grep/lang-<x>` npm package. This module loads
6
+ * one on demand:
7
+ *
8
+ * 1. If napi already parses it (the six), no grammar is needed.
9
+ * 2. Else try to import `@ast-grep/lang-<x>` from the pix cache, then from
10
+ * the resolvable node_modules.
11
+ * 3. If absent, report `needs-install` with the exact package name. The tool
12
+ * asks the user once, then this module installs it INTO the pix cache
13
+ * (never the agent, never a global write). A later call finds it cached.
14
+ *
15
+ * The cache lives at `<agentDir>/pix-astgrep/grammars`. We install with the
16
+ * user's package manager into that private prefix, so a read-only global pix
17
+ * install still works.
18
+ */
19
+
20
+ import { existsSync } from "node:fs";
21
+ import { mkdir } from "node:fs/promises";
22
+ import { createRequire } from "node:module";
23
+ import { join } from "node:path";
24
+ import { pathToFileURL } from "node:url";
25
+ import { runTool } from "../exec.ts";
26
+ import { agentDir } from "../paths.ts";
27
+ import { isBundled, loadEngine, parse, parseDynamic, type SgNode } from "./engine.ts";
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err));
31
+
32
+ /** Canonical language alias → its `@ast-grep/lang-*` package name. */
33
+ const LANG_PACKAGE: Record<string, string> = {
34
+ python: "@ast-grep/lang-python",
35
+ go: "@ast-grep/lang-go",
36
+ rust: "@ast-grep/lang-rust",
37
+ java: "@ast-grep/lang-java",
38
+ c: "@ast-grep/lang-c",
39
+ cpp: "@ast-grep/lang-cpp",
40
+ csharp: "@ast-grep/lang-csharp",
41
+ ruby: "@ast-grep/lang-ruby",
42
+ php: "@ast-grep/lang-php",
43
+ swift: "@ast-grep/lang-swift",
44
+ kotlin: "@ast-grep/lang-kotlin",
45
+ scala: "@ast-grep/lang-scala",
46
+ lua: "@ast-grep/lang-lua",
47
+ elixir: "@ast-grep/lang-elixir",
48
+ json: "@ast-grep/lang-json",
49
+ yaml: "@ast-grep/lang-yaml",
50
+ bash: "@ast-grep/lang-bash",
51
+ haskell: "@ast-grep/lang-haskell",
52
+ };
53
+
54
+ /** Languages that need a `@ast-grep/lang-*` package (not the napi six). */
55
+ export function packageForLanguage(lang: string): string | undefined {
56
+ return LANG_PACKAGE[lang];
57
+ }
58
+
59
+ /**
60
+ * Cache root for installed grammars: `<agentDir>/pix-astgrep/grammars`. The path predates the
61
+ * move into pix-runtime; it stays so grammars users already installed keep working.
62
+ */
63
+ export function grammarCacheDir(): string {
64
+ return join(agentDir(), "pix-astgrep", "grammars");
65
+ }
66
+
67
+ // ponytail: napi registers once per process. Load all cached grammars together;
68
+ // adding a language afterward needs an agent restart, not a silently ignored registration.
69
+ const stateKey = Symbol.for("pix-astgrep.grammars");
70
+ const globals = globalThis as typeof globalThis & {
71
+ [stateKey]?: { registered: Set<string>; declined: Set<string>; registration?: Promise<void> };
72
+ };
73
+ globals[stateKey] ??= { registered: new Set(), declined: new Set() };
74
+ const state = globals[stateKey];
75
+ const { registered, declined } = state;
76
+
77
+ export type GrammarResult =
78
+ | { kind: "ready" }
79
+ | { kind: "napi" }
80
+ | { kind: "needs-install"; pkg: string }
81
+ | { kind: "declined"; pkg: string }
82
+ | { kind: "unsupported" }
83
+ | { kind: "error"; message: string };
84
+
85
+ /** Import a grammar module from the cache prefix, then this package. */
86
+ async function importGrammar(pkg: string): Promise<unknown | undefined> {
87
+ const cached = join(grammarCacheDir(), "node_modules", pkg);
88
+ const candidates = existsSync(cached) ? [cached, pkg] : [pkg];
89
+ for (const spec of candidates) {
90
+ let entry: string;
91
+ try {
92
+ entry = require.resolve(spec);
93
+ } catch (err) {
94
+ if ((err as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") continue;
95
+ throw err;
96
+ }
97
+ const mod = (await import(pathToFileURL(entry).href)) as { default?: unknown };
98
+ return mod.default ?? mod;
99
+ }
100
+ return undefined;
101
+ }
102
+
103
+ /**
104
+ * Make a language usable. Returns `ready` when registered, `napi` when the
105
+ * addon already covers it, `needs-install` with the package to install, or
106
+ * `unsupported` for an unknown alias.
107
+ */
108
+ export async function ensureLanguage(lang: string): Promise<GrammarResult> {
109
+ const engine = await loadEngine();
110
+ if (!engine) return { kind: "error", message: "ast-grep engine unavailable" };
111
+
112
+ // The six bundled grammars never need a package.
113
+ if (isBundled(lang)) {
114
+ return { kind: "napi" };
115
+ }
116
+ const pkg = packageForLanguage(lang);
117
+ if (!pkg) return { kind: "unsupported" };
118
+ if (registered.has(lang)) return { kind: "ready" };
119
+
120
+ try {
121
+ const grammar = await importGrammar(pkg);
122
+ if (!grammar) return { kind: "needs-install", pkg };
123
+ state.registration ??= (async () => {
124
+ const grammars: Record<string, unknown> = {};
125
+ for (const [alias, name] of Object.entries(LANG_PACKAGE)) {
126
+ const loaded = await importGrammar(name);
127
+ if (loaded) grammars[alias] = loaded;
128
+ }
129
+ engine.registerDynamicLanguage(grammars);
130
+ for (const alias of Object.keys(grammars)) registered.add(alias);
131
+ })();
132
+ await state.registration;
133
+ return registered.has(lang)
134
+ ? { kind: "ready" }
135
+ : {
136
+ kind: "error",
137
+ message: `${pkg} is installed, but napi's language registry is already initialized. Restart the agent to load newly installed grammars.`,
138
+ };
139
+ } catch (err) {
140
+ return { kind: "error", message: errorText(err) };
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Install a `@ast-grep/lang-*` package into the pix cache prefix. This runs a
146
+ * network install — the caller must get user consent first. It never touches a
147
+ * global path or this package's own node_modules.
148
+ */
149
+ export async function installGrammar(pkg: string): Promise<{ ok: boolean; message?: string }> {
150
+ const dir = grammarCacheDir();
151
+ try {
152
+ await mkdir(dir, { recursive: true });
153
+ // A minimal package.json so npm installs into this prefix cleanly.
154
+ const pkgJson = join(dir, "package.json");
155
+ if (!existsSync(pkgJson)) {
156
+ const { writeFile } = await import("node:fs/promises");
157
+ await writeFile(pkgJson, JSON.stringify({ name: "pix-astgrep-grammars", private: true }));
158
+ }
159
+ const result = await runTool("npm", ["install", "--save", "--prefix", dir, pkg], {
160
+ cwd: dir,
161
+ timeoutMs: 120_000,
162
+ });
163
+ if (result.code !== 0) {
164
+ return {
165
+ ok: false,
166
+ message: result.timedOut
167
+ ? "grammar installation timed out"
168
+ : result.stderr || `npm exited ${result.code}`,
169
+ };
170
+ }
171
+ return { ok: true };
172
+ } catch (err) {
173
+ return { ok: false, message: errorText(err) };
174
+ }
175
+ }
176
+
177
+ export type ParseOutcome = { kind: "ok"; root: SgNode } | GrammarResult;
178
+
179
+ /** A one-time consent callback: return true to install `pkg`. */
180
+ export type ConsentFn = (pkg: string) => Promise<boolean>;
181
+
182
+ /**
183
+ * Parse one file's source for any language alias, installing a missing grammar
184
+ * with consent. The bundled six always parse. A non-bundled language is loaded
185
+ * from the pix cache. When absent, `consent(pkg)` decides whether to install it
186
+ * INTO the cache (once per language). A declined language is remembered so the
187
+ * caller is not asked again this session.
188
+ */
189
+ export async function parseWithGrammar(
190
+ lang: string,
191
+ source: string,
192
+ consent?: ConsentFn,
193
+ ): Promise<ParseOutcome> {
194
+ const engine = await loadEngine();
195
+ if (!engine) return { kind: "error", message: "ast-grep engine unavailable" };
196
+
197
+ try {
198
+ if (isBundled(lang)) return { kind: "ok", root: parse(engine, lang, source) };
199
+ if (declined.has(lang)) {
200
+ return { kind: "declined", pkg: packageForLanguage(lang) ?? lang };
201
+ }
202
+
203
+ let ready = await ensureLanguage(lang);
204
+ if (ready.kind === "needs-install") {
205
+ if (!consent) return ready;
206
+ if (!(await consent(ready.pkg))) {
207
+ declined.add(lang);
208
+ return { kind: "declined", pkg: ready.pkg };
209
+ }
210
+ const installed = await installGrammar(ready.pkg);
211
+ if (!installed.ok) {
212
+ return { kind: "error", message: installed.message ?? `failed to install ${ready.pkg}` };
213
+ }
214
+ ready = await ensureLanguage(lang);
215
+ }
216
+ if (ready.kind !== "ready") return ready;
217
+ return { kind: "ok", root: parseDynamic(engine, lang, source) };
218
+ } catch (err) {
219
+ return { kind: "error", message: errorText(err) };
220
+ }
221
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * pix-runtime/ast — shared ast-grep parsing: the lazy napi loader, the file → language map,
3
+ * and consent-gated `@ast-grep/lang-*` grammar loading. UI-free; the install prompt lives in
4
+ * `@xynogen/pix-pretty/grammar-consent`.
5
+ */
6
+ export {
7
+ ALL_LANGUAGES,
8
+ type AnyLanguage,
9
+ isBundled,
10
+ LANGUAGES,
11
+ type Language,
12
+ languageForFile,
13
+ loadEngine,
14
+ parse,
15
+ parseDynamic,
16
+ type SgEdit,
17
+ type SgNode,
18
+ } from "./engine.ts";
19
+ export {
20
+ type ConsentFn,
21
+ ensureLanguage,
22
+ type GrammarResult,
23
+ grammarCacheDir,
24
+ installGrammar,
25
+ type ParseOutcome,
26
+ packageForLanguage,
27
+ parseWithGrammar,
28
+ } from "./grammars.ts";
package/src/lfid.ts CHANGED
@@ -20,18 +20,18 @@ export const LFID_RE = /^[a-z][a-z0-9]*-[a-z]+-[a-z]+-\d{2}$/;
20
20
  // Small curated lists: common words, no ambiguous pairs, no profanity.
21
21
  // Sized at 100 × 100 so two digits complete a 1M namespace per prefix.
22
22
  const ADJECTIVES = (
23
- "agate amber azure birch bold brave bright brisk calm cedar cinder clever cloudy coral crisp dawn drift dusk eager elm" +
24
- "ember fair fern fleet flint fresh frost garnet glad golden grand green grove happy hazy heath iris ivory jade juniper" +
25
- "keen kind lagoon larch light linden lively lotus lucid lunar maple meadow merry misty moss nectar nimble nimbus noble north" +
26
- "ocean olive opal pearl pine plain plum proud quartz quick quiet rapid reef ridge river rocky round royal rusty sable" +
23
+ "agate amber azure birch bold brave bright brisk calm cedar cinder clever cloudy coral crisp dawn drift dusk eager elm " +
24
+ "ember fair fern fleet flint fresh frost garnet glad golden grand green grove happy hazy heath iris ivory jade juniper " +
25
+ "keen kind lagoon larch light linden lively lotus lucid lunar maple meadow merry misty moss nectar nimble nimbus noble north " +
26
+ "ocean olive opal pearl pine plain plum proud quartz quick quiet rapid reef ridge river rocky round royal rusty sable " +
27
27
  "sage sandy sedge sharp shiny silent silver sleek small smart smooth solid sorrel spring steady stone sunny swift tango teal"
28
28
  ).split(" ");
29
29
 
30
30
  const NOUNS = (
31
- "adder albatross anchovy badger beagle bear beaver boar bobcat cobra condor cougar coyote crane cricket deer dove drake duck eagle" +
32
- "falcon ferret finch fisher fox frog gannet gazelle gecko goose gopher grouse gull hamster hare hawk heron hyena ibis impala" +
33
- "indigo jackal jaguar jay kelp kestrel koala kudu lark lemur llama lynx magpie manatee marmot marten meerkat mink mole mongoose" +
34
- "moose narwhal newt numbats ocelot okapi oriole osprey otter owl ox oyster panda pangolin parrot pelican penguin pigeon pika platypus" +
31
+ "adder albatross anchovy badger beagle bear beaver boar bobcat cobra condor cougar coyote crane cricket deer dove drake duck eagle " +
32
+ "falcon ferret finch fisher fox frog gannet gazelle gecko goose gopher grouse gull hamster hare hawk heron hyena ibis impala " +
33
+ "indigo jackal jaguar jay kelp kestrel koala kudu lark lemur llama lynx magpie manatee marmot marten meerkat mink mole mongoose " +
34
+ "moose narwhal newt numbats ocelot okapi oriole osprey otter owl ox oyster panda pangolin parrot pelican penguin pigeon pika platypus " +
35
35
  "porpoise possum puffin python quail quokka rabbit raven rhea robin sable salmon saola seal shark shrew skunk sloth snail sparrow"
36
36
  ).split(" ");
37
37