omkit 0.8.17 → 0.8.19
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/README.md +313 -222
- package/dist/cli/ui/views/OmList.d.ts +8 -4
- package/dist/cli/ui/views/OmList.js +21 -7
- package/dist/cli/ui/views/SearchableList.d.ts +7 -1
- package/dist/cli/ui/views/SearchableList.js +14 -4
- package/dist/client/check.js +5 -8
- package/dist/client/discovery.js +16 -11
- package/dist/client/outline.d.ts +7 -0
- package/dist/client/outline.js +20 -0
- package/dist/client/registry.d.ts +6 -0
- package/dist/client/tsCache.d.ts +27 -0
- package/dist/client/tsCache.js +103 -0
- package/dist/output/writers/report/report.css +16 -20
- package/dist/output/writers/report/report.js +143 -157
- package/package.json +1 -1
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
2
|
import type { DiscoveredOm } from "../../../client/registry.ts";
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
4
|
+
* The picker: a searchable, arrow-navigable list of oms; Enter runs the highlighted one.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* Each row is the om's name and its summary (or, without one, its path); the search matches
|
|
7
|
+
* both. Under the list, the highlighted om in full: its summary, where it is defined, and the
|
|
8
|
+
* actions its body calls with their tags — the same outline the skill file shows.
|
|
9
|
+
*
|
|
10
|
+
* Rows are ordered by {@link byNesting} from the working directory — the same root the paths
|
|
11
|
+
* are rendered from, so the oms nearest the top of the project come first, deeper ones after,
|
|
12
|
+
* and any shown as an absolute path last.
|
|
9
13
|
*/
|
|
10
14
|
export declare function OmList({ oms, onSelect, }: {
|
|
11
15
|
oms: DiscoveredOm[];
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { useMemo } from "react";
|
|
4
|
-
import { Text } from "ink";
|
|
4
|
+
import { Box, Text, useStdout } from "ink";
|
|
5
5
|
import { SearchableList } from "./SearchableList.js";
|
|
6
6
|
import { byNesting } from "../../../client/order.js";
|
|
7
7
|
/** Show the om's path relative to `root` when it's inside it; else the absolute path. */
|
|
@@ -9,17 +9,31 @@ function displayPath(file, root) {
|
|
|
9
9
|
const rel = path.relative(root, file);
|
|
10
10
|
return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel : file;
|
|
11
11
|
}
|
|
12
|
+
function truncate(text, max) {
|
|
13
|
+
return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
|
|
14
|
+
}
|
|
12
15
|
/**
|
|
13
|
-
*
|
|
16
|
+
* The picker: a searchable, arrow-navigable list of oms; Enter runs the highlighted one.
|
|
17
|
+
*
|
|
18
|
+
* Each row is the om's name and its summary (or, without one, its path); the search matches
|
|
19
|
+
* both. Under the list, the highlighted om in full: its summary, where it is defined, and the
|
|
20
|
+
* actions its body calls with their tags — the same outline the skill file shows.
|
|
14
21
|
*
|
|
15
|
-
* Rows are ordered by {@link byNesting} from the working directory — the same root the
|
|
16
|
-
*
|
|
17
|
-
*
|
|
22
|
+
* Rows are ordered by {@link byNesting} from the working directory — the same root the paths
|
|
23
|
+
* are rendered from, so the oms nearest the top of the project come first, deeper ones after,
|
|
24
|
+
* and any shown as an absolute path last.
|
|
18
25
|
*/
|
|
19
26
|
export function OmList({ oms, onSelect, }) {
|
|
20
27
|
const root = process.cwd();
|
|
21
28
|
const sorted = useMemo(() => [...oms].sort(byNesting(root)), [oms, root]);
|
|
22
|
-
|
|
29
|
+
const { stdout } = useStdout();
|
|
30
|
+
const columns = stdout?.columns || 100;
|
|
31
|
+
// Column width so summaries line up; capped so one long name can't push them off-screen.
|
|
23
32
|
const nameWidth = Math.min(30, oms.reduce((w, o) => Math.max(w, o.name.length), 0));
|
|
24
|
-
|
|
33
|
+
const restWidth = Math.max(20, columns - nameWidth - 6);
|
|
34
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [_jsx(Text, { bold: true, children: "omkit" }), _jsx(Text, { dimColor: true, children: ` · ${oms.length} om${oms.length === 1 ? "" : "s"}` })] }), _jsx(SearchableList, { items: sorted, getKey: (o) => `${o.file}\0${o.name}`, getSearchText: (o) => `${o.name} ${o.summary ?? ""}`, onSelect: onSelect, alwaysShowSearch: true, searchLabel: "search \u203A ", searchPlaceholder: "type to filter by name or summary", renderRow: (o, selected) => (_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { color: selected ? "cyan" : undefined, children: selected ? "❯ " : " " }), _jsx(Text, { bold: selected, color: selected ? "cyan" : undefined, children: o.name.padEnd(nameWidth) }), " ", _jsx(Text, { dimColor: true, children: truncate(o.summary ?? displayPath(o.file, root), restWidth) })] })), renderDetail: (o) => (o ? _jsx(OmDetail, { om: o, root: root }) : null) }), _jsx(Text, { dimColor: true, children: "\u2191\u2193 move \u00B7 enter run \u00B7 esc clears search \u00B7 ctrl+c quit" })] }));
|
|
35
|
+
}
|
|
36
|
+
/** The highlighted om in full. */
|
|
37
|
+
function OmDetail({ om, root }) {
|
|
38
|
+
return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", paddingX: 1, marginTop: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: om.name }), om.summary ? (_jsx(Text, { children: om.summary })) : (_jsxs(Text, { dimColor: true, italic: true, children: ["No summary. Add .describe(", "{ summary }", ") to the om."] })), _jsx(Text, { dimColor: true, children: `${displayPath(om.file, root)}:${om.line}` }), om.calls?.length ? (_jsxs(Text, { wrap: "wrap", children: [_jsx(Text, { dimColor: true, children: "calls " }), om.calls.map((c, i) => (_jsxs(Text, { children: [i > 0 ? _jsx(Text, { dimColor: true, children: " \u2192 " }) : null, c.name, c.tag ? _jsx(Text, { dimColor: true, children: ` [${c.tag}]` }) : null] }, `${c.name}\0${c.tag ?? ""}`)))] })) : null] }));
|
|
25
39
|
}
|
|
@@ -14,6 +14,12 @@ export interface SearchableListProps<T> {
|
|
|
14
14
|
max?: number;
|
|
15
15
|
/** Prefix for the search line. Default "search: ". */
|
|
16
16
|
searchLabel?: string;
|
|
17
|
+
/** Keep the search line on screen even when the list fits (it shows a placeholder while empty). */
|
|
18
|
+
alwaysShowSearch?: boolean;
|
|
19
|
+
/** Dim text shown on an empty, always-visible search line. */
|
|
20
|
+
searchPlaceholder?: string;
|
|
21
|
+
/** Drawn under the list for the highlighted item (`undefined` when nothing matches). */
|
|
22
|
+
renderDetail?: (item: T | undefined) => ReactNode;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
19
25
|
* A searchable, arrow-navigable list with a scrolling viewport. Owns search, selection, and
|
|
@@ -21,4 +27,4 @@ export interface SearchableListProps<T> {
|
|
|
21
27
|
* `search:` line and the `+N more` footer only appear once the list overflows the viewport
|
|
22
28
|
* (or the user starts typing), so short lists stay a plain arrow list.
|
|
23
29
|
*/
|
|
24
|
-
export declare function SearchableList<T>({ items, getKey, getSearchText, renderRow, onSelect, initialIndex, max, searchLabel, }: SearchableListProps<T>): ReactElement;
|
|
30
|
+
export declare function SearchableList<T>({ items, getKey, getSearchText, renderRow, onSelect, initialIndex, max, searchLabel, alwaysShowSearch, searchPlaceholder, renderDetail, }: SearchableListProps<T>): ReactElement;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useRef, useState } from "react";
|
|
3
3
|
import { Box, Text, useInput } from "ink";
|
|
4
4
|
/** Hard cap on visible rows, regardless of `max`. */
|
|
@@ -9,7 +9,7 @@ const MAX_VISIBLE = 15;
|
|
|
9
9
|
* `search:` line and the `+N more` footer only appear once the list overflows the viewport
|
|
10
10
|
* (or the user starts typing), so short lists stay a plain arrow list.
|
|
11
11
|
*/
|
|
12
|
-
export function SearchableList({ items, getKey, getSearchText, renderRow, onSelect, initialIndex = 0, max = MAX_VISIBLE, searchLabel = "search: ", }) {
|
|
12
|
+
export function SearchableList({ items, getKey, getSearchText, renderRow, onSelect, initialIndex = 0, max = MAX_VISIBLE, searchLabel = "search: ", alwaysShowSearch, searchPlaceholder, renderDetail, }) {
|
|
13
13
|
// Refs hold the authoritative query/index: Ink can deliver several key events before React
|
|
14
14
|
// re-renders, so the Enter handler must read refs, not a possibly-stale render closure. State
|
|
15
15
|
// mirrors the refs only to drive the display.
|
|
@@ -38,6 +38,12 @@ export function SearchableList({ items, getKey, getSearchText, renderRow, onSele
|
|
|
38
38
|
if (picked)
|
|
39
39
|
onSelect(picked);
|
|
40
40
|
}
|
|
41
|
+
else if (key.escape) {
|
|
42
|
+
queryRef.current = "";
|
|
43
|
+
indexRef.current = 0;
|
|
44
|
+
setQuery("");
|
|
45
|
+
setIndex(0);
|
|
46
|
+
}
|
|
41
47
|
else if (key.backspace || key.delete) {
|
|
42
48
|
queryRef.current = queryRef.current.slice(0, -1);
|
|
43
49
|
indexRef.current = 0;
|
|
@@ -61,6 +67,10 @@ export function SearchableList({ items, getKey, getSearchText, renderRow, onSele
|
|
|
61
67
|
const above = start;
|
|
62
68
|
const below = filtered.length - (start + window);
|
|
63
69
|
const overflows = items.length > Math.min(max, MAX_VISIBLE);
|
|
64
|
-
const showSearch = overflows || query.length > 0;
|
|
65
|
-
return (_jsxs(Box, { flexDirection: "column", children: [showSearch ? (
|
|
70
|
+
const showSearch = alwaysShowSearch || overflows || query.length > 0;
|
|
71
|
+
return (_jsxs(Box, { flexDirection: "column", children: [showSearch ? (_jsx(SearchLine, { label: searchLabel, query: query, placeholder: searchPlaceholder })) : null, above > 0 ? _jsx(Text, { dimColor: true, children: `↑ ${above} more` }) : null, filtered.length === 0 ? _jsx(Text, { dimColor: true, children: "no matches" }) : null, visible.map((it, vi) => (_jsx(Box, { children: renderRow(it, start + vi === selected) }, getKey(it)))), below > 0 ? _jsx(Text, { dimColor: true, children: `+${below} more` }) : null, renderDetail ? renderDetail(filtered[selected]) : null] }));
|
|
72
|
+
}
|
|
73
|
+
/** The search line: what has been typed so far, or a dim hint while it is empty. */
|
|
74
|
+
function SearchLine({ label, query, placeholder, }) {
|
|
75
|
+
return (_jsxs(Text, { dimColor: true, children: [label, query ? _jsx(Text, { color: "cyan", children: query }) : placeholder] }));
|
|
66
76
|
}
|
package/dist/client/check.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ts from "typescript";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { checkedProgram } from "./tsCache.js";
|
|
3
4
|
/** Typecheck the project (`tsc --noEmit`) and return per-file diagnostics. */
|
|
4
5
|
export function runCheck(tsconfigPath) {
|
|
5
6
|
const configFile = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
|
|
@@ -7,24 +8,20 @@ export function runCheck(tsconfigPath) {
|
|
|
7
8
|
return [{ file: tsconfigPath, line: 0, message: msg(configFile.error) }];
|
|
8
9
|
}
|
|
9
10
|
const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, path.resolve(path.dirname(tsconfigPath)));
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
options: { ...parsed.options, noEmit: true },
|
|
13
|
-
});
|
|
11
|
+
const checked = checkedProgram(parsed, tsconfigPath);
|
|
12
|
+
const program = checked.program;
|
|
14
13
|
const canonical = (f) => ts.sys.useCaseSensitiveFileNames ? path.normalize(f) : path.normalize(f).toLowerCase();
|
|
15
14
|
const fileSet = new Set(parsed.fileNames.map((f) => canonical(f)));
|
|
16
15
|
const out = [];
|
|
17
16
|
for (const sf of program.getSourceFiles()) {
|
|
18
17
|
if (!fileSet.has(canonical(sf.fileName)))
|
|
19
18
|
continue;
|
|
20
|
-
for (const d of [
|
|
21
|
-
...program.getSyntacticDiagnostics(sf),
|
|
22
|
-
...program.getSemanticDiagnostics(sf),
|
|
23
|
-
]) {
|
|
19
|
+
for (const d of [...program.getSyntacticDiagnostics(sf), ...checked.semanticDiagnostics(sf)]) {
|
|
24
20
|
const line = d.start !== undefined ? sf.getLineAndCharacterOfPosition(d.start).line + 1 : 0;
|
|
25
21
|
out.push({ file: sf.fileName, line, message: msg(d) });
|
|
26
22
|
}
|
|
27
23
|
}
|
|
24
|
+
checked.save();
|
|
28
25
|
return out;
|
|
29
26
|
}
|
|
30
27
|
function msg(d) {
|
package/dist/client/discovery.js
CHANGED
|
@@ -6,7 +6,8 @@ import { omHash } from "../foundation/ids.js";
|
|
|
6
6
|
import { fsSafe } from "../foundation/fsSafe.js";
|
|
7
7
|
import { canonicalPath } from "../foundation/canonicalPath.js";
|
|
8
8
|
import { tsxLoader } from "./runner.js";
|
|
9
|
-
import { chainRoot, importedNames, outlineBody } from "./outline.js";
|
|
9
|
+
import { chainRoot, importedNames, outlineBody, summaryOf } from "./outline.js";
|
|
10
|
+
import { checkedProgram } from "./tsCache.js";
|
|
10
11
|
/**
|
|
11
12
|
* Statically scan the project described by `tsconfigPath` for runnable oms and inspectable
|
|
12
13
|
* actions. Type errors in the user's oms/actions degrade to `warnings` (an editor-in-progress
|
|
@@ -19,10 +20,8 @@ export function discover(tsconfigPath) {
|
|
|
19
20
|
const warnings = [];
|
|
20
21
|
const { parsed, warnings: configWarnings } = loadProject(tsconfigPath);
|
|
21
22
|
warnings.push(...configWarnings);
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
options: { ...parsed.options, noEmit: true },
|
|
25
|
-
});
|
|
23
|
+
const checked = checkedProgram(parsed, tsconfigPath);
|
|
24
|
+
const program = checked.program;
|
|
26
25
|
const fileSet = new Set(parsed.fileNames.map((f) => canonical(f)));
|
|
27
26
|
for (const sf of program.getSourceFiles()) {
|
|
28
27
|
if (!fileSet.has(canonical(sf.fileName)))
|
|
@@ -32,7 +31,7 @@ export function discover(tsconfigPath) {
|
|
|
32
31
|
// it, `omHash` hashes it, and Node caches modules by it. Canonicalise once here so the
|
|
33
32
|
// whole chain agrees with the filesystem, and with the paths a real run reports.
|
|
34
33
|
const file = canonicalPath(sf.fileName);
|
|
35
|
-
for (const d of
|
|
34
|
+
for (const d of checked.semanticDiagnostics(sf))
|
|
36
35
|
warnings.push(formatDiagnostic(d));
|
|
37
36
|
const names = omkitImports(sf);
|
|
38
37
|
const outlines = outlinesIn(sf, names.om, importedNames(sf));
|
|
@@ -42,11 +41,12 @@ export function discover(tsconfigPath) {
|
|
|
42
41
|
(node.expression.text === names.om || node.expression.text === names.step)) {
|
|
43
42
|
const arg = node.arguments[0];
|
|
44
43
|
if (arg && ts.isStringLiteralLike(arg)) {
|
|
45
|
-
const calls = outlines.get(arg.text);
|
|
44
|
+
const { calls, summary } = outlines.get(arg.text) ?? {};
|
|
46
45
|
oms.push({
|
|
47
46
|
name: arg.text,
|
|
48
47
|
file,
|
|
49
48
|
line: lineOf(sf, node),
|
|
49
|
+
...(summary !== undefined ? { summary } : {}),
|
|
50
50
|
...(calls?.length ? { calls } : {}),
|
|
51
51
|
});
|
|
52
52
|
}
|
|
@@ -73,10 +73,12 @@ export function discover(tsconfigPath) {
|
|
|
73
73
|
}
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
+
checked.save();
|
|
76
77
|
return { oms, actions, warnings };
|
|
77
78
|
}
|
|
78
79
|
/**
|
|
79
|
-
* Outline every `om("name")….run(body)` chain in a file, keyed by om name
|
|
80
|
+
* Outline every `om("name")….run(body)` chain in a file, keyed by om name, along with the
|
|
81
|
+
* chain's literal `.describe({ summary })`.
|
|
80
82
|
*
|
|
81
83
|
* Collected in its own pass rather than alongside the `om(...)` match, because a body is only
|
|
82
84
|
* reachable from the `.run` end of the chain and the om name only from the other — and the
|
|
@@ -93,9 +95,12 @@ function outlinesIn(sf, omName, imported) {
|
|
|
93
95
|
const root = chainRoot(node);
|
|
94
96
|
const body = node.arguments[0];
|
|
95
97
|
const name = root?.call.arguments[0];
|
|
96
|
-
if (root?.name === omName && name && ts.isStringLiteralLike(name)
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
if (root?.name === omName && name && ts.isStringLiteralLike(name)) {
|
|
99
|
+
const summary = summaryOf(root);
|
|
100
|
+
outlines.set(name.text, {
|
|
101
|
+
...(body && ts.isFunctionLike(body) ? { calls: outlineBody(body, imported) } : {}),
|
|
102
|
+
...(summary !== undefined ? { summary } : {}),
|
|
103
|
+
});
|
|
99
104
|
}
|
|
100
105
|
}
|
|
101
106
|
ts.forEachChild(node, visit);
|
package/dist/client/outline.d.ts
CHANGED
|
@@ -33,3 +33,10 @@ export declare function chainRoot(node: ts.CallExpression): ChainRoot | undefine
|
|
|
33
33
|
* not follow indirection. It answers "roughly what happens in here" and nothing stronger.
|
|
34
34
|
*/
|
|
35
35
|
export declare function outlineBody(body: ts.Node, imported: ReadonlySet<string>): OmCall[];
|
|
36
|
+
/**
|
|
37
|
+
* The `.tag("…")` on a chain, taken nearest its root. `calls` is outermost-first, so the last
|
|
38
|
+
* match is the innermost — the tag attached directly to the action rather than to something
|
|
39
|
+
* further along the chain.
|
|
40
|
+
*/
|
|
41
|
+
/** The `summary` of a `.describe({ summary: "…" })` in the chain, when it is a string literal. */
|
|
42
|
+
export declare function summaryOf(chain: ChainRoot): string | undefined;
|
package/dist/client/outline.js
CHANGED
|
@@ -86,6 +86,26 @@ export function outlineBody(body, imported) {
|
|
|
86
86
|
* match is the innermost — the tag attached directly to the action rather than to something
|
|
87
87
|
* further along the chain.
|
|
88
88
|
*/
|
|
89
|
+
/** The `summary` of a `.describe({ summary: "…" })` in the chain, when it is a string literal. */
|
|
90
|
+
export function summaryOf(chain) {
|
|
91
|
+
for (const call of chain.calls) {
|
|
92
|
+
const callee = call.expression;
|
|
93
|
+
if (!ts.isPropertyAccessExpression(callee) || callee.name.text !== "describe")
|
|
94
|
+
continue;
|
|
95
|
+
const arg = call.arguments[0];
|
|
96
|
+
if (!arg || !ts.isObjectLiteralExpression(arg))
|
|
97
|
+
continue;
|
|
98
|
+
for (const prop of arg.properties) {
|
|
99
|
+
if (ts.isPropertyAssignment(prop) &&
|
|
100
|
+
ts.isIdentifier(prop.name) &&
|
|
101
|
+
prop.name.text === "summary" &&
|
|
102
|
+
ts.isStringLiteralLike(prop.initializer)) {
|
|
103
|
+
return prop.initializer.text;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
89
109
|
function tagOf(chain) {
|
|
90
110
|
let tag;
|
|
91
111
|
for (const call of chain.calls) {
|
|
@@ -13,6 +13,12 @@ export interface DiscoveredOm {
|
|
|
13
13
|
readonly file: string;
|
|
14
14
|
/** 1-based line of the `om(...)` call (informational). */
|
|
15
15
|
readonly line: number;
|
|
16
|
+
/**
|
|
17
|
+
* The `.describe({ summary })` text, read statically — present only when it is written as a
|
|
18
|
+
* string literal. The authoritative value (whatever the expression evaluates to) comes from
|
|
19
|
+
* the import-based pass, `discoverRegistrations`.
|
|
20
|
+
*/
|
|
21
|
+
readonly summary?: string;
|
|
16
22
|
/**
|
|
17
23
|
* A static approximation of what the body calls, in source order. Absent when the walk
|
|
18
24
|
* found nothing recognisable. Never a contract — see {@link import("./outline.ts").outlineBody}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
/**
|
|
3
|
+
* A type-checking view of the project that remembers the last check. Discovery and
|
|
4
|
+
* `omkit check` both build a full program and ask for every om/action file's semantic
|
|
5
|
+
* diagnostics; this keeps TypeScript's incremental state in a `.tsbuildinfo` file between
|
|
6
|
+
* processes, so a file whose content (and dependencies) didn't change isn't checked again.
|
|
7
|
+
*
|
|
8
|
+
* The program itself is still rebuilt every time — the saving is the checker's work, which
|
|
9
|
+
* grows with the project's own files. The cache is best-effort throughout: a missing,
|
|
10
|
+
* stale, or unwritable file only costs a full check, never a wrong answer (TypeScript
|
|
11
|
+
* validates the stored state against each file's content hash).
|
|
12
|
+
*/
|
|
13
|
+
export interface CheckedProgram {
|
|
14
|
+
readonly program: ts.Program;
|
|
15
|
+
/** Semantic diagnostics for `sf` — from the cache when nothing it depends on changed. */
|
|
16
|
+
semanticDiagnostics(sf: ts.SourceFile): readonly ts.Diagnostic[];
|
|
17
|
+
/** Persist what this check learned. Never throws. */
|
|
18
|
+
save(): void;
|
|
19
|
+
}
|
|
20
|
+
export declare function checkedProgram(parsed: ts.ParsedCommandLine, tsconfigPath: string): CheckedProgram;
|
|
21
|
+
/**
|
|
22
|
+
* Where a project's build info lives: `node_modules/.cache/omkit/` nearest the tsconfig —
|
|
23
|
+
* the usual home for tool caches, already ignored by git — else the OS temp folder.
|
|
24
|
+
* Keyed by the tsconfig and the TypeScript version, so two projects (or a TS upgrade)
|
|
25
|
+
* never share state. `OMKIT_CACHE_DIR` moves it; `OMKIT_NO_CACHE=1` turns it off.
|
|
26
|
+
*/
|
|
27
|
+
export declare function buildInfoFile(tsconfigPath: string): string | undefined;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { canonicalPath } from "../foundation/canonicalPath.js";
|
|
7
|
+
export function checkedProgram(parsed, tsconfigPath) {
|
|
8
|
+
const file = buildInfoFile(tsconfigPath);
|
|
9
|
+
if (!file)
|
|
10
|
+
return uncached(parsed);
|
|
11
|
+
const options = {
|
|
12
|
+
...parsed.options,
|
|
13
|
+
noEmit: true,
|
|
14
|
+
incremental: true,
|
|
15
|
+
// TypeScript compares this against its own forward-slashed spelling of the path
|
|
16
|
+
tsBuildInfoFile: file.replace(/\\/g, "/"),
|
|
17
|
+
// the user's own composite/outDir settings describe their build, not this check
|
|
18
|
+
composite: false,
|
|
19
|
+
};
|
|
20
|
+
let builder;
|
|
21
|
+
try {
|
|
22
|
+
builder = ts.createIncrementalProgram({
|
|
23
|
+
rootNames: parsed.fileNames,
|
|
24
|
+
options,
|
|
25
|
+
host: ts.createIncrementalCompilerHost(options),
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// a cache TypeScript can't read back (corrupt, foreign) — drop it and check from scratch
|
|
30
|
+
fs.rmSync(file, { force: true });
|
|
31
|
+
return uncached(parsed);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
program: builder.getProgram(),
|
|
35
|
+
semanticDiagnostics: (sf) => builder.getSemanticDiagnostics(sf),
|
|
36
|
+
save() {
|
|
37
|
+
try {
|
|
38
|
+
// with `noEmit`, emitting writes only the build info
|
|
39
|
+
builder.emit(undefined, (target, text) => writeAtomic(target, text));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// a cache that can't be written is just a cache miss next time
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function uncached(parsed) {
|
|
48
|
+
const program = ts.createProgram({
|
|
49
|
+
rootNames: parsed.fileNames,
|
|
50
|
+
options: { ...parsed.options, noEmit: true },
|
|
51
|
+
});
|
|
52
|
+
return { program, semanticDiagnostics: (sf) => program.getSemanticDiagnostics(sf), save() { } };
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Where a project's build info lives: `node_modules/.cache/omkit/` nearest the tsconfig —
|
|
56
|
+
* the usual home for tool caches, already ignored by git — else the OS temp folder.
|
|
57
|
+
* Keyed by the tsconfig and the TypeScript version, so two projects (or a TS upgrade)
|
|
58
|
+
* never share state. `OMKIT_CACHE_DIR` moves it; `OMKIT_NO_CACHE=1` turns it off.
|
|
59
|
+
*/
|
|
60
|
+
export function buildInfoFile(tsconfigPath) {
|
|
61
|
+
if (process.env.OMKIT_NO_CACHE === "1")
|
|
62
|
+
return undefined;
|
|
63
|
+
const config = canonicalPath(path.resolve(tsconfigPath));
|
|
64
|
+
const key = createHash("sha1").update(`${config}\0${ts.version}`).digest("hex").slice(0, 12);
|
|
65
|
+
const name = `${path.basename(config, ".json")}-${key}.tsbuildinfo`;
|
|
66
|
+
for (const dir of candidateDirs(path.dirname(config))) {
|
|
67
|
+
try {
|
|
68
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
69
|
+
return path.join(dir, name);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// not writable — try the next place
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
function candidateDirs(from) {
|
|
78
|
+
if (process.env.OMKIT_CACHE_DIR)
|
|
79
|
+
return [path.resolve(process.env.OMKIT_CACHE_DIR)];
|
|
80
|
+
const out = [];
|
|
81
|
+
for (let dir = from;; dir = path.dirname(dir)) {
|
|
82
|
+
if (fs.existsSync(path.join(dir, "node_modules"))) {
|
|
83
|
+
out.push(path.join(dir, "node_modules", ".cache", "omkit"));
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (path.dirname(dir) === dir)
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
out.push(path.join(os.tmpdir(), "omkit-cache"));
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
/** Write via a temp file + rename, so a concurrent reader (a picker, a check) never sees half a file. */
|
|
93
|
+
function writeAtomic(target, text) {
|
|
94
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
95
|
+
fs.writeFileSync(tmp, text);
|
|
96
|
+
try {
|
|
97
|
+
fs.renameSync(tmp, target);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
fs.rmSync(tmp, { force: true });
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -164,7 +164,8 @@ button { font: inherit; color: inherit; }
|
|
|
164
164
|
|
|
165
165
|
.chart { position: relative; min-width: 0; }
|
|
166
166
|
.chart svg { display: block; overflow: hidden; }
|
|
167
|
-
|
|
167
|
+
/* the row's hit target already spans the label — letting it through keeps the marks under it hoverable */
|
|
168
|
+
.chart .bar-label { font-size: 12px; fill: var(--bar-text); font-weight: 500; pointer-events: none; }
|
|
168
169
|
.chart .hit { fill: transparent; cursor: pointer; }
|
|
169
170
|
.chart .hit:hover + .bar { filter: brightness(1.15); }
|
|
170
171
|
.chart .bar-label.outside { fill: var(--text); }
|
|
@@ -190,7 +191,7 @@ button { font: inherit; color: inherit; }
|
|
|
190
191
|
.flow-seq.parallel { stroke-dasharray: 1 4; stroke-linecap: round; stroke-width: 2; opacity: 0.8; }
|
|
191
192
|
.flow-arrowhead { fill: var(--text-3); }
|
|
192
193
|
.flow-par { fill: var(--text-3); font-size: 10px; font-style: italic; }
|
|
193
|
-
.flow-shot-t { fill: var(--text-3); font-size: 10.5px; pointer-events: none; }
|
|
194
|
+
.flow-shot-t { fill: var(--text-3); font-size: 10.5px; pointer-events: none; stroke: var(--panel); stroke-width: 4px; paint-order: stroke; }
|
|
194
195
|
.flow-card text.sub { fill: var(--text-3); font-size: 11px; }
|
|
195
196
|
.flow-card .tchip text { fill: var(--text); font-size: 10.5px; }
|
|
196
197
|
.flow-card .tchip.more text { fill: var(--text-3); }
|
|
@@ -224,12 +225,12 @@ button { font: inherit; color: inherit; }
|
|
|
224
225
|
.acc-b > .params { margin-bottom: 8px; }
|
|
225
226
|
.acc-b .section-title:first-child { margin-top: 0; }
|
|
226
227
|
.acc-file { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; color: var(--text-2); }
|
|
227
|
-
.
|
|
228
|
-
.
|
|
229
|
-
.
|
|
230
|
-
.
|
|
231
|
-
.
|
|
232
|
-
.
|
|
228
|
+
.dshots { display: grid; gap: 6px; }
|
|
229
|
+
.dshot { display: grid; grid-template-columns: 64px minmax(0, 1fr) auto; align-items: center; gap: 10px; padding: 4px; border: 1px solid transparent; border-radius: 8px; cursor: pointer; }
|
|
230
|
+
.dshot:hover { border-color: var(--accent); background: var(--accent-soft); }
|
|
231
|
+
.dshot .thumb { width: 64px; }
|
|
232
|
+
.dshot .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
233
|
+
.dshot .t { color: var(--text-3); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
233
234
|
.kv { display: grid; grid-template-columns: 92px minmax(0, 1fr); gap: 6px 10px; margin: 2px 0 12px; }
|
|
234
235
|
.kv .k { color: var(--text-2); }
|
|
235
236
|
.kv .v { min-width: 0; overflow-wrap: anywhere; font-variant-numeric: tabular-nums; }
|
|
@@ -472,18 +473,13 @@ a.icon-btn { text-decoration: none; }
|
|
|
472
473
|
#app.hl-on .flow-card.err-path rect.box { stroke: var(--fail); stroke-width: 1.2; stroke-dasharray: 4 3; }
|
|
473
474
|
#app.hl-on .tree .row.err { background: rgba(239, 68, 68, 0.1); }
|
|
474
475
|
.chart g.r, .flow-card, .tree .row { transition: opacity 0.15s; }
|
|
475
|
-
/* cross-node links:
|
|
476
|
-
.
|
|
477
|
-
.
|
|
478
|
-
.
|
|
479
|
-
.
|
|
480
|
-
.
|
|
481
|
-
|
|
482
|
-
.flow-xlink { cursor: pointer; }
|
|
483
|
-
.flow-xlink path { fill: none; stroke-width: 1.4; stroke-dasharray: 4 4; opacity: 0.8; }
|
|
484
|
-
.flow-xlink path.hit { stroke: transparent; stroke-width: 12; stroke-dasharray: none; }
|
|
485
|
-
.flow-xlink:hover path:not(.hit) { stroke-width: 2.6; opacity: 1; }
|
|
486
|
-
.flow-xlink text { font-size: 10.5px; fill: var(--text-2); stroke: var(--panel); stroke-width: 3px; paint-order: stroke; }
|
|
476
|
+
/* cross-node links: hollow points on the caller's row, chips in the flow */
|
|
477
|
+
.chart circle.mark.link { stroke-width: 2; }
|
|
478
|
+
.xwire { stroke-width: 1.2; stroke-dasharray: 3 3; opacity: 0.55; pointer-events: none; }
|
|
479
|
+
.xwire.hot { stroke-width: 2; opacity: 1; }
|
|
480
|
+
.fwire { fill: none; stroke-width: 1.2; stroke-dasharray: 3 3; opacity: 0.5; pointer-events: none; }
|
|
481
|
+
.fwire.hot { stroke-width: 2; opacity: 1; }
|
|
482
|
+
.flow-shots .shot rect[stroke] { stroke-width: 1.5; }
|
|
487
483
|
|
|
488
484
|
@media (max-width: 1100px) {
|
|
489
485
|
.bottom { grid-template-columns: minmax(0, 1fr); }
|