nou-cli 0.0.0-stage → 0.2.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/.env.example +24 -0
- package/README.md +163 -2
- package/dist/grammars/tree-sitter-bash.wasm +0 -0
- package/dist/grammars/tree-sitter-c.wasm +0 -0
- package/dist/grammars/tree-sitter-css.wasm +0 -0
- package/dist/grammars/tree-sitter-go.wasm +0 -0
- package/dist/grammars/tree-sitter-html.wasm +0 -0
- package/dist/grammars/tree-sitter-java.wasm +0 -0
- package/dist/grammars/tree-sitter-javascript.wasm +0 -0
- package/dist/grammars/tree-sitter-json.wasm +0 -0
- package/dist/grammars/tree-sitter-python.wasm +0 -0
- package/dist/grammars/tree-sitter-rust.wasm +0 -0
- package/dist/grammars/tree-sitter-toml.wasm +0 -0
- package/dist/grammars/tree-sitter-tsx.wasm +0 -0
- package/dist/grammars/tree-sitter-typescript.wasm +0 -0
- package/dist/grammars/tree-sitter-yaml.wasm +0 -0
- package/dist/index.js +84 -0
- package/dist/src/agent/index.js +246 -0
- package/dist/src/commands/index.js +125 -0
- package/dist/src/config/index.js +39 -0
- package/dist/src/context/index.js +53 -0
- package/dist/src/diagnostics/index.js +235 -0
- package/dist/src/diagnostics/process.js +64 -0
- package/dist/src/diagnostics/providers.js +103 -0
- package/dist/src/diagnostics/types.js +1 -0
- package/dist/src/env.js +12 -0
- package/dist/src/git/index.js +94 -0
- package/dist/src/git/tools.js +52 -0
- package/dist/src/history/index.js +113 -0
- package/dist/src/lsp/client.js +169 -0
- package/dist/src/lsp/index.js +169 -0
- package/dist/src/plugins/index.js +134 -0
- package/dist/src/providers/anthropic.js +109 -0
- package/dist/src/providers/ids.js +3 -0
- package/dist/src/providers/index.js +46 -0
- package/dist/src/providers/ollama.js +58 -0
- package/dist/src/providers/openai.js +107 -0
- package/dist/src/providers/types.js +2 -0
- package/dist/src/syntax/index.js +240 -0
- package/dist/src/tools/index.js +100 -0
- package/dist/src/ui/App.js +671 -0
- package/dist/src/ui/Chat.js +97 -0
- package/dist/src/ui/ChatInput.js +128 -0
- package/dist/src/ui/CodeView.js +100 -0
- package/dist/src/ui/Editor.js +234 -0
- package/dist/src/ui/FileTree.js +143 -0
- package/dist/src/ui/Header.js +9 -0
- package/dist/src/ui/Pane.js +8 -0
- package/dist/src/ui/ProblemsView.js +71 -0
- package/dist/src/ui/QuickOpen.js +91 -0
- package/dist/src/ui/Settings.js +165 -0
- package/dist/src/ui/text.js +100 -0
- package/dist/src/ui/theme.js +41 -0
- package/dist/src/update/index.js +102 -0
- package/dist/src/util/index.js +9 -0
- package/package.json +69 -4
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import { readdirSync } from "fs";
|
|
4
|
+
import { exec } from "child_process";
|
|
5
|
+
export const IGNORED = new Set(["node_modules", ".git", "dist", "build", ".next", "__pycache__", ".venv"]);
|
|
6
|
+
function resolveInProject(filePath) {
|
|
7
|
+
const fullPath = path.resolve(process.cwd(), filePath);
|
|
8
|
+
const rel = path.relative(process.cwd(), fullPath);
|
|
9
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
10
|
+
throw new Error(`${filePath} is outside the project folder`);
|
|
11
|
+
}
|
|
12
|
+
return fullPath;
|
|
13
|
+
}
|
|
14
|
+
export function toRelative(filePath) {
|
|
15
|
+
return path.relative(process.cwd(), path.resolve(process.cwd(), filePath)).replace(/\\/g, "/");
|
|
16
|
+
}
|
|
17
|
+
export async function readFile(filepath) {
|
|
18
|
+
try {
|
|
19
|
+
const fullPath = path.resolve(process.cwd(), filepath);
|
|
20
|
+
const content = await fs.readFile(fullPath, "utf-8");
|
|
21
|
+
return content;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return "Error: File not found or could not be read.";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
;
|
|
28
|
+
export function runCommand(command) {
|
|
29
|
+
// Async so the UI keeps rendering while the command runs.
|
|
30
|
+
return new Promise((resolve) => {
|
|
31
|
+
exec(command, { cwd: process.cwd(), timeout: 120_000, maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
32
|
+
if (error) {
|
|
33
|
+
const reason = error.killed ? "timed out after 2 minutes" : `Exit Code ${error.code ?? "?"}`;
|
|
34
|
+
resolve(`Error (${reason}): ${stderr || error.message}${stdout ? `
|
|
35
|
+
${stdout}` : ""}`);
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
resolve(stdout);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function fileTree(dir = ".", maxDepth = 3) {
|
|
44
|
+
const lines = [];
|
|
45
|
+
const walk = (current, depth, prefix) => {
|
|
46
|
+
if (depth > maxDepth)
|
|
47
|
+
return;
|
|
48
|
+
let entries;
|
|
49
|
+
try {
|
|
50
|
+
entries = readdirSync(current, { withFileTypes: true })
|
|
51
|
+
.filter((e) => !IGNORED.has(e.name))
|
|
52
|
+
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
for (const entry of entries) {
|
|
58
|
+
lines.push(`${prefix}${entry.name}${entry.isDirectory() ? "/" : ""}`);
|
|
59
|
+
if (entry.isDirectory())
|
|
60
|
+
walk(path.join(current, entry.name), depth + 1, prefix + " ");
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
walk(path.resolve(process.cwd(), dir), 1, "");
|
|
64
|
+
return lines.join("\n") || "(empty)";
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Undoes common model mistakes in write_file content without touching real code:
|
|
68
|
+
* - a whole file sent as one line with literal "\n" escapes (no real newlines at all) is unescaped;
|
|
69
|
+
* files that already have real newlines are left alone, so "\n" inside strings and regexes survives
|
|
70
|
+
* - a whole file wrapped in a markdown code fence is unwrapped
|
|
71
|
+
* Indentation and blank lines are kept; a final newline is added if missing.
|
|
72
|
+
*/
|
|
73
|
+
export function cleanContent(content) {
|
|
74
|
+
let text = content;
|
|
75
|
+
if (!text.includes("\n") && text.includes("\\n")) {
|
|
76
|
+
text = text.replace(/\\r\\n|\\n/g, "\n").replace(/\\t/g, "\t");
|
|
77
|
+
}
|
|
78
|
+
const fenced = text.match(/^\s*```[\w+-]*\r?\n([\s\S]*?)\r?\n```\s*$/);
|
|
79
|
+
if (fenced)
|
|
80
|
+
text = fenced[1];
|
|
81
|
+
return text.endsWith("\n") || text === "" ? text : `${text}\n`;
|
|
82
|
+
}
|
|
83
|
+
async function readOrEmpty(fullPath) {
|
|
84
|
+
try {
|
|
85
|
+
return await fs.readFile(fullPath, "utf-8");
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/** Works out what write_file would do, without touching the disk. */
|
|
92
|
+
export async function proposeWrite(filePath, content) {
|
|
93
|
+
const fullPath = resolveInProject(filePath);
|
|
94
|
+
const before = await readOrEmpty(fullPath);
|
|
95
|
+
return { path: toRelative(filePath), before: before ?? "", after: cleanContent(content), isNew: before === null };
|
|
96
|
+
}
|
|
97
|
+
/** Works out what edit_file would do, without touching the disk. */
|
|
98
|
+
export async function proposeEdit(filePath, oldString, newString) {
|
|
99
|
+
const fullPath = resolveInProject(filePath);
|
|
100
|
+
const before = await readOrEmpty(fullPath);
|
|
101
|
+
if (before === null)
|
|
102
|
+
throw new Error(`${filePath} does not exist. Use write_file to create it.`);
|
|
103
|
+
// Models often send LF while Windows files use CRLF, so match on normalized text.
|
|
104
|
+
const eol = before.includes("\r\n") ? "\r\n" : "\n";
|
|
105
|
+
const normalized = before.replace(/\r\n/g, "\n");
|
|
106
|
+
const target = oldString.replace(/\r\n/g, "\n");
|
|
107
|
+
const count = target ? normalized.split(target).length - 1 : 0;
|
|
108
|
+
if (count === 0)
|
|
109
|
+
throw new Error("old_string was not found in the file. Read the file and copy the text exactly.");
|
|
110
|
+
if (count > 1)
|
|
111
|
+
throw new Error(`old_string appears ${count} times. Include more surrounding lines so it is unique.`);
|
|
112
|
+
const after = normalized.replace(target, () => newString.replace(/\r\n/g, "\n")).replace(/\n/g, eol);
|
|
113
|
+
return { path: toRelative(filePath), before, after, isNew: false };
|
|
114
|
+
}
|
|
115
|
+
export async function applyChange(change) {
|
|
116
|
+
try {
|
|
117
|
+
const fullPath = resolveInProject(change.path);
|
|
118
|
+
await fs.mkdir(path.dirname(fullPath), { recursive: true });
|
|
119
|
+
await fs.writeFile(fullPath, change.after, "utf-8");
|
|
120
|
+
return `Successfully ${change.isNew ? "created" : "updated"} ${change.path}`;
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
return `Failed to write file: ${error.message}`;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
2
|
+
import os from "os";
|
|
3
|
+
import path from "path";
|
|
4
|
+
export const NOU_HOME = path.join(os.homedir(), ".nou");
|
|
5
|
+
const CONFIG_PATH = path.join(NOU_HOME, "config.json");
|
|
6
|
+
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
7
|
+
export const AUTO_SAVE = ["delay", "focus", "off"];
|
|
8
|
+
export const DEFAULT_SETTINGS = {
|
|
9
|
+
model: "",
|
|
10
|
+
effort: "high",
|
|
11
|
+
followMode: true,
|
|
12
|
+
autoApproveEdits: false,
|
|
13
|
+
autoApproveCommands: false,
|
|
14
|
+
gitContext: true,
|
|
15
|
+
autoSave: "delay",
|
|
16
|
+
plugins: {},
|
|
17
|
+
trustedPlugins: {},
|
|
18
|
+
};
|
|
19
|
+
export function loadSettings() {
|
|
20
|
+
try {
|
|
21
|
+
const saved = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
22
|
+
return { ...DEFAULT_SETTINGS, ...saved, plugins: { ...saved.plugins }, trustedPlugins: { ...saved.trustedPlugins } };
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return { ...DEFAULT_SETTINGS, plugins: {}, trustedPlugins: {} };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export function saveSettings(patch) {
|
|
29
|
+
const next = { ...loadSettings(), ...patch };
|
|
30
|
+
try {
|
|
31
|
+
if (!existsSync(NOU_HOME))
|
|
32
|
+
mkdirSync(NOU_HOME, { recursive: true });
|
|
33
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// settings are best effort; the session still uses them
|
|
37
|
+
}
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { highlight } from "cli-highlight";
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileTree, readFile } from "../commands/index.js";
|
|
5
|
+
import { complete } from "../providers/index.js";
|
|
6
|
+
import * as p from "@clack/prompts";
|
|
7
|
+
export const sugarfy = (str) => {
|
|
8
|
+
return highlight(str, { language: "typescript", ignoreIllegals: true });
|
|
9
|
+
};
|
|
10
|
+
export async function generateContext(contextPath, choice) {
|
|
11
|
+
const s = p.spinner();
|
|
12
|
+
s.start("Analyzing project structure to create NOU.md...");
|
|
13
|
+
const dirStructure = fileTree(".", 2);
|
|
14
|
+
const packageJson = await readFile("package.json");
|
|
15
|
+
let content;
|
|
16
|
+
try {
|
|
17
|
+
content = await complete(choice, `I am starting a new project. Based on these files: \n${dirStructure}\n\nAnd this package.json: \n${packageJson}\n\nPlease generate a NOU.md file that summarizes:
|
|
18
|
+
1. What this project is.
|
|
19
|
+
2. Commands to build, test, and run it.
|
|
20
|
+
3. Tech stack used.
|
|
21
|
+
Output ONLY the markdown content.`);
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
s.stop(`Skipped NOU.md: ${error.message}`);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
await fs.writeFile(contextPath, content);
|
|
28
|
+
s.stop("NOU.md generated! Nou is now context-aware.");
|
|
29
|
+
}
|
|
30
|
+
;
|
|
31
|
+
export async function ensureProjectContext(choice) {
|
|
32
|
+
const contextPath = path.join(process.cwd(), "NOU.md");
|
|
33
|
+
try {
|
|
34
|
+
await fs.access(contextPath);
|
|
35
|
+
const nonEmpty = (await fs.readFile(contextPath, "utf-8")).trim().length > 0;
|
|
36
|
+
if (!nonEmpty) {
|
|
37
|
+
await generateContext(contextPath, choice);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
await generateContext(contextPath, choice);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function loadPermanentContext() {
|
|
45
|
+
const contextPath = path.join(process.cwd(), "NOU.md");
|
|
46
|
+
try {
|
|
47
|
+
const content = await fs.readFile(contextPath, "utf-8");
|
|
48
|
+
return `\n\nPROJECT SPECIFIC RULES (from NOU.md):\n${content}`;
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return "";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { diffLines } from "diff";
|
|
4
|
+
const ORDER = { error: 0, warning: 1, info: 2 };
|
|
5
|
+
export const sortDiagnostics = (list) => [...list].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || ORDER[a.severity] - ORDER[b.severity]);
|
|
6
|
+
export function formatDiagnostic(d, withFile = false) {
|
|
7
|
+
return `${withFile ? `${d.file}:` : "line "}${d.line}:${d.col} ${d.severity}: ${d.message}${d.code ? ` (${d.source} ${d.code})` : ` (${d.source})`}`;
|
|
8
|
+
}
|
|
9
|
+
/** Problems in `after` that weren't in `before`, matched by checker + code + message (line numbers shift). */
|
|
10
|
+
export function newProblems(before, after) {
|
|
11
|
+
const key = (d) => `${d.source}|${d.code ?? ""}|${d.message}`;
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
for (const d of before)
|
|
14
|
+
seen.set(key(d), (seen.get(key(d)) ?? 0) + 1);
|
|
15
|
+
return after.filter((d) => {
|
|
16
|
+
const left = seen.get(key(d)) ?? 0;
|
|
17
|
+
if (left > 0) {
|
|
18
|
+
seen.set(key(d), left - 1);
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
return true;
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** tsc and the TypeScript language server can report the same error; keep one. */
|
|
25
|
+
function dedupe(list) {
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
return list.filter((d) => {
|
|
28
|
+
const key = `${d.file}|${d.line}|${d.col}|${d.message}`;
|
|
29
|
+
if (seen.has(key))
|
|
30
|
+
return false;
|
|
31
|
+
seen.add(key);
|
|
32
|
+
return true;
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Line numbers in `after` that an edit added or changed, plus the line where text was removed. */
|
|
36
|
+
export function touchedLines(before, after) {
|
|
37
|
+
const touched = new Set();
|
|
38
|
+
let line = 1;
|
|
39
|
+
for (const part of diffLines(before.replace(/\r\n/g, "\n"), after.replace(/\r\n/g, "\n"))) {
|
|
40
|
+
const count = part.count ?? part.value.split("\n").length - 1;
|
|
41
|
+
if (part.added)
|
|
42
|
+
for (let i = 0; i < count; i++)
|
|
43
|
+
touched.add(line + i);
|
|
44
|
+
if (part.removed)
|
|
45
|
+
touched.add(line);
|
|
46
|
+
else
|
|
47
|
+
line += count;
|
|
48
|
+
}
|
|
49
|
+
return touched;
|
|
50
|
+
}
|
|
51
|
+
function readText(file) {
|
|
52
|
+
try {
|
|
53
|
+
return readFileSync(path.resolve(process.cwd(), file), "utf-8");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
/** Runs checkers, caches their results per file, and tells the UI when anything changes. */
|
|
60
|
+
export class DiagnosticsManager {
|
|
61
|
+
providers = [];
|
|
62
|
+
availability = new Map();
|
|
63
|
+
/** file -> provider id -> problems */
|
|
64
|
+
results = new Map();
|
|
65
|
+
projectTimer = null;
|
|
66
|
+
/** Project checks run one at a time; a queued run is shared by everyone who asks while it waits. */
|
|
67
|
+
projectChain = Promise.resolve();
|
|
68
|
+
projectQueued = null;
|
|
69
|
+
/** Whether a full project check has finished, i.e. we know which problems already existed. */
|
|
70
|
+
projectBaseline = false;
|
|
71
|
+
running = new Set();
|
|
72
|
+
listeners = new Set();
|
|
73
|
+
setProviders(providers) {
|
|
74
|
+
const ids = new Set(providers.map((p) => p.id));
|
|
75
|
+
// Drop results from checkers that were switched off.
|
|
76
|
+
for (const byProvider of this.results.values()) {
|
|
77
|
+
for (const id of [...byProvider.keys()])
|
|
78
|
+
if (!ids.has(id))
|
|
79
|
+
byProvider.delete(id);
|
|
80
|
+
}
|
|
81
|
+
this.providers = providers;
|
|
82
|
+
this.emit();
|
|
83
|
+
}
|
|
84
|
+
subscribe(listener) {
|
|
85
|
+
this.listeners.add(listener);
|
|
86
|
+
return () => this.listeners.delete(listener);
|
|
87
|
+
}
|
|
88
|
+
emit() {
|
|
89
|
+
for (const l of this.listeners)
|
|
90
|
+
l();
|
|
91
|
+
}
|
|
92
|
+
/** Names of checkers currently running, for the status bar. */
|
|
93
|
+
get busy() {
|
|
94
|
+
return [...this.running];
|
|
95
|
+
}
|
|
96
|
+
async isAvailable(p) {
|
|
97
|
+
if (!this.availability.has(p.id)) {
|
|
98
|
+
try {
|
|
99
|
+
this.availability.set(p.id, await p.available());
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
this.availability.set(p.id, false);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return this.availability.get(p.id);
|
|
106
|
+
}
|
|
107
|
+
async activeProviders() {
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const p of this.providers)
|
|
110
|
+
if (await this.isAvailable(p))
|
|
111
|
+
out.push(p);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
async runProvider(p, file, text) {
|
|
115
|
+
this.running.add(p.name);
|
|
116
|
+
this.emit();
|
|
117
|
+
try {
|
|
118
|
+
return await p.check(file, text);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
return [];
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
this.running.delete(p.name);
|
|
125
|
+
this.emit();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
store(file, providerId, list) {
|
|
129
|
+
const byProvider = this.results.get(file) ?? new Map();
|
|
130
|
+
byProvider.set(providerId, list);
|
|
131
|
+
this.results.set(file, byProvider);
|
|
132
|
+
}
|
|
133
|
+
/** Checks one file (unsaved `text` if given, otherwise what's on disk) with the per-file checkers. */
|
|
134
|
+
async checkFile(file, text) {
|
|
135
|
+
const content = text ?? readText(file);
|
|
136
|
+
if (content === null)
|
|
137
|
+
return [];
|
|
138
|
+
const providers = (await this.activeProviders()).filter((p) => !p.projectWide && p.matches(file));
|
|
139
|
+
await Promise.all(providers.map(async (p) => this.store(file, p.id, await this.runProvider(p, file, content))));
|
|
140
|
+
this.emit();
|
|
141
|
+
return this.get(file);
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Runs project-wide checkers (like tsc) and replaces their results everywhere. Runs never overlap (they'd
|
|
145
|
+
* overwrite each other); asking while one is queued reuses it, since it will see the latest files anyway.
|
|
146
|
+
*/
|
|
147
|
+
checkProject() {
|
|
148
|
+
if (this.projectQueued)
|
|
149
|
+
return this.projectQueued;
|
|
150
|
+
const run = this.projectChain.then(() => {
|
|
151
|
+
this.projectQueued = null;
|
|
152
|
+
return this.runProject();
|
|
153
|
+
});
|
|
154
|
+
this.projectQueued = run;
|
|
155
|
+
this.projectChain = run.catch(() => { });
|
|
156
|
+
return run;
|
|
157
|
+
}
|
|
158
|
+
async runProject() {
|
|
159
|
+
const providers = (await this.activeProviders()).filter((p) => p.projectWide);
|
|
160
|
+
await Promise.all(providers.map(async (p) => {
|
|
161
|
+
const list = await this.runProvider(p, ".", "");
|
|
162
|
+
for (const byProvider of this.results.values())
|
|
163
|
+
byProvider.delete(p.id);
|
|
164
|
+
const byFile = new Map();
|
|
165
|
+
for (const d of list)
|
|
166
|
+
byFile.set(d.file, [...(byFile.get(d.file) ?? []), d]);
|
|
167
|
+
for (const [file, items] of byFile)
|
|
168
|
+
this.store(file, p.id, items);
|
|
169
|
+
}));
|
|
170
|
+
this.projectBaseline = true;
|
|
171
|
+
this.emit();
|
|
172
|
+
}
|
|
173
|
+
/** Debounced checkProject, for after saves. */
|
|
174
|
+
scheduleProject(delay = 1500) {
|
|
175
|
+
if (this.projectTimer)
|
|
176
|
+
clearTimeout(this.projectTimer);
|
|
177
|
+
this.projectTimer = setTimeout(() => void this.checkProject(), delay);
|
|
178
|
+
}
|
|
179
|
+
/** Checks text without storing it, e.g. a proposed edit. Only checkers that accept unsaved text. */
|
|
180
|
+
async checkText(file, text) {
|
|
181
|
+
const providers = (await this.activeProviders()).filter((p) => p.checksText && !p.projectWide && p.matches(file));
|
|
182
|
+
return (await Promise.all(providers.map((p) => this.runProvider(p, file, text)))).flat();
|
|
183
|
+
}
|
|
184
|
+
get(file) {
|
|
185
|
+
return dedupe(sortDiagnostics([...(this.results.get(file)?.values() ?? [])].flat()));
|
|
186
|
+
}
|
|
187
|
+
all() {
|
|
188
|
+
return dedupe(sortDiagnostics([...this.results.values()].flatMap((m) => [...m.values()].flat())));
|
|
189
|
+
}
|
|
190
|
+
counts(file) {
|
|
191
|
+
const list = file ? this.get(file) : this.all();
|
|
192
|
+
return {
|
|
193
|
+
errors: list.filter((d) => d.severity === "error").length,
|
|
194
|
+
warnings: list.filter((d) => d.severity === "warning").length,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
/** Per-file counts, for markers in the file tree. */
|
|
198
|
+
fileCounts() {
|
|
199
|
+
const out = new Map();
|
|
200
|
+
for (const d of this.all()) {
|
|
201
|
+
const c = out.get(d.file) ?? { errors: 0, warnings: 0 };
|
|
202
|
+
if (d.severity === "error")
|
|
203
|
+
c.errors++;
|
|
204
|
+
else if (d.severity === "warning")
|
|
205
|
+
c.warnings++;
|
|
206
|
+
out.set(d.file, c);
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* After Nou's edit: the problems it introduced in that file, compared with the file before.
|
|
212
|
+
* Checks both versions with per-file checkers, and re-runs project-wide ones.
|
|
213
|
+
*/
|
|
214
|
+
async introducedBy(file, before, after) {
|
|
215
|
+
const projectIds = (await this.activeProviders()).filter((p) => p.projectWide && p.matches(file)).map((p) => p.id);
|
|
216
|
+
const hadBaseline = this.projectBaseline;
|
|
217
|
+
const projectBefore = this.getFrom(file, projectIds);
|
|
218
|
+
// One after the other: language servers hold a single copy of each document.
|
|
219
|
+
const beforeList = await this.checkText(file, before);
|
|
220
|
+
const afterList = await this.checkFile(file, after);
|
|
221
|
+
if (projectIds.length)
|
|
222
|
+
await this.checkProject();
|
|
223
|
+
let projectNew = newProblems(projectBefore, this.getFrom(file, projectIds));
|
|
224
|
+
if (!hadBaseline) {
|
|
225
|
+
// No check finished before this edit, so old and new problems look the same.
|
|
226
|
+
// Only blame the edit for problems on lines it actually touched.
|
|
227
|
+
const touched = touchedLines(before, after);
|
|
228
|
+
projectNew = projectNew.filter((d) => touched.has(d.line));
|
|
229
|
+
}
|
|
230
|
+
return [...newProblems(beforeList, afterList), ...projectNew];
|
|
231
|
+
}
|
|
232
|
+
getFrom(file, providerIds) {
|
|
233
|
+
return providerIds.flatMap((id) => this.results.get(file)?.get(id) ?? []);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { createRequire } from "module";
|
|
4
|
+
import path from "path";
|
|
5
|
+
/** Runs a program without a shell (safe with any file name), optionally piping text to stdin. */
|
|
6
|
+
export function run(command, args, options = {}) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let stdout = "";
|
|
9
|
+
let stderr = "";
|
|
10
|
+
let child;
|
|
11
|
+
try {
|
|
12
|
+
child = spawn(command, args, { cwd: process.cwd(), windowsHide: true });
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
resolve({ code: -1, stdout: "", stderr: error.message });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const timer = setTimeout(() => child.kill(), options.timeout ?? 60_000);
|
|
19
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
20
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
21
|
+
child.on("error", (error) => {
|
|
22
|
+
clearTimeout(timer);
|
|
23
|
+
resolve({ code: -1, stdout, stderr: error.message });
|
|
24
|
+
});
|
|
25
|
+
child.on("close", (code) => {
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
resolve({ code, stdout, stderr });
|
|
28
|
+
});
|
|
29
|
+
if (options.input !== undefined)
|
|
30
|
+
child.stdin.end(options.input);
|
|
31
|
+
else
|
|
32
|
+
child.stdin.end();
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
/** Whether a program is on PATH (e.g. `ruff`). */
|
|
36
|
+
export function onPath(command) {
|
|
37
|
+
try {
|
|
38
|
+
return spawnSync(command, ["--version"], { windowsHide: true, timeout: 5000 }).status === 0;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Finds a CLI installed in the user's project (not Nou's own copy), e.g. their ESLint,
|
|
46
|
+
* returning the JS entry point so it can be run with `node` on every OS.
|
|
47
|
+
*/
|
|
48
|
+
export function projectBin(pkg, bin = pkg) {
|
|
49
|
+
try {
|
|
50
|
+
const require = createRequire(path.join(process.cwd(), "package.json"));
|
|
51
|
+
const manifestPath = require.resolve(`${pkg}/package.json`);
|
|
52
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
53
|
+
const rel = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[bin];
|
|
54
|
+
if (!rel)
|
|
55
|
+
return null;
|
|
56
|
+
const full = path.join(path.dirname(manifestPath), rel);
|
|
57
|
+
// Only use it if it's really the project's install, not something hoisted from Nou itself.
|
|
58
|
+
const inside = path.relative(process.cwd(), full);
|
|
59
|
+
return existsSync(full) && !inside.startsWith("..") && !path.isAbsolute(inside) ? full : null;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { onPath, projectBin, run } from "./process.js";
|
|
4
|
+
const rel = (file) => path.relative(process.cwd(), path.resolve(process.cwd(), file)).replace(/\\/g, "/");
|
|
5
|
+
/** ESLint, using the project's own install and config, so results match `npm run lint`. */
|
|
6
|
+
export const eslintProvider = {
|
|
7
|
+
id: "eslint",
|
|
8
|
+
name: "ESLint",
|
|
9
|
+
checksText: true,
|
|
10
|
+
matches: (file) => /\.(m|c)?(j|t)sx?$/.test(file),
|
|
11
|
+
available: () => projectBin("eslint") !== null,
|
|
12
|
+
async check(file, text) {
|
|
13
|
+
const bin = projectBin("eslint");
|
|
14
|
+
if (!bin)
|
|
15
|
+
return [];
|
|
16
|
+
const result = await run(process.execPath, [bin, "--stdin", "--stdin-filename", file, "--format", "json"], { input: text, timeout: 60_000 });
|
|
17
|
+
let reports;
|
|
18
|
+
try {
|
|
19
|
+
reports = JSON.parse(result.stdout);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
return reports.flatMap((report) => (report.messages ?? [])
|
|
25
|
+
// "File ignored by config" isn't a real problem.
|
|
26
|
+
.filter((m) => !(m.ruleId === null && /ignored/i.test(m.message)))
|
|
27
|
+
.map((m) => ({
|
|
28
|
+
file: rel(file),
|
|
29
|
+
line: m.line ?? 1,
|
|
30
|
+
col: m.column ?? 1,
|
|
31
|
+
...(m.endLine ? { endLine: m.endLine } : {}),
|
|
32
|
+
...(m.endColumn ? { endCol: m.endColumn } : {}),
|
|
33
|
+
severity: m.severity === 2 ? "error" : "warning",
|
|
34
|
+
message: m.message,
|
|
35
|
+
source: "eslint",
|
|
36
|
+
...(m.ruleId ? { code: m.ruleId } : {}),
|
|
37
|
+
})));
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
/** The TypeScript compiler over the whole project (type errors need the full picture). */
|
|
41
|
+
export const typescriptProvider = {
|
|
42
|
+
id: "typescript",
|
|
43
|
+
name: "TypeScript",
|
|
44
|
+
checksText: false,
|
|
45
|
+
projectWide: true,
|
|
46
|
+
matches: (file) => /\.(m|c)?tsx?$/.test(file),
|
|
47
|
+
available: () => existsSync(path.join(process.cwd(), "tsconfig.json")) && projectBin("typescript", "tsc") !== null,
|
|
48
|
+
async check() {
|
|
49
|
+
const bin = projectBin("typescript", "tsc");
|
|
50
|
+
if (!bin)
|
|
51
|
+
return [];
|
|
52
|
+
const result = await run(process.execPath, [bin, "--noEmit", "-p", ".", "--pretty", "false"], { timeout: 180_000 });
|
|
53
|
+
const diagnostics = [];
|
|
54
|
+
for (const line of result.stdout.split(/\r?\n/)) {
|
|
55
|
+
const m = line.match(/^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/);
|
|
56
|
+
if (m) {
|
|
57
|
+
diagnostics.push({
|
|
58
|
+
file: rel(m[1]),
|
|
59
|
+
line: Number(m[2]),
|
|
60
|
+
col: Number(m[3]),
|
|
61
|
+
severity: m[4] === "error" ? "error" : "warning",
|
|
62
|
+
message: m[6],
|
|
63
|
+
source: "tsc",
|
|
64
|
+
code: m[5],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
else if (line.startsWith(" ") && diagnostics.length) {
|
|
68
|
+
diagnostics[diagnostics.length - 1].message += ` ${line.trim()}`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return diagnostics;
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
let ruffInstalled;
|
|
75
|
+
/** Ruff for Python, if it's installed. */
|
|
76
|
+
export const ruffProvider = {
|
|
77
|
+
id: "ruff",
|
|
78
|
+
name: "Ruff (Python)",
|
|
79
|
+
checksText: true,
|
|
80
|
+
matches: (file) => /\.pyi?$/.test(file),
|
|
81
|
+
available: () => (ruffInstalled ??= onPath("ruff")),
|
|
82
|
+
async check(file, text) {
|
|
83
|
+
const result = await run("ruff", ["check", "--output-format", "json", "--stdin-filename", file, "-"], { input: text, timeout: 30_000 });
|
|
84
|
+
let items;
|
|
85
|
+
try {
|
|
86
|
+
items = JSON.parse(result.stdout);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return [];
|
|
90
|
+
}
|
|
91
|
+
return items.map((item) => ({
|
|
92
|
+
file: rel(file),
|
|
93
|
+
line: item.location?.row ?? 1,
|
|
94
|
+
col: item.location?.column ?? 1,
|
|
95
|
+
...(item.end_location ? { endLine: item.end_location.row, endCol: item.end_location.column } : {}),
|
|
96
|
+
// Syntax errors (no code / E9) and pyflakes (F) are real bugs; style rules are warnings.
|
|
97
|
+
severity: !item.code || /^(E9|F)/.test(item.code) ? "error" : "warning",
|
|
98
|
+
message: item.message,
|
|
99
|
+
source: "ruff",
|
|
100
|
+
...(item.code ? { code: item.code } : {}),
|
|
101
|
+
}));
|
|
102
|
+
},
|
|
103
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/src/env.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { existsSync } from "fs";
|
|
2
|
+
// This module must be imported before anything else (index.ts imports it first).
|
|
3
|
+
// npm only warns about an unsupported Node version, so check here, before Ink loads and fails confusingly.
|
|
4
|
+
const [major, minor] = process.versions.node.split(".").map(Number);
|
|
5
|
+
if (major < 22 || (major === 22 && minor < 12)) {
|
|
6
|
+
console.error(`Nou needs Node.js 22.12 or newer (you have ${process.versions.node}). Get it from https://nodejs.org`);
|
|
7
|
+
process.exit(1);
|
|
8
|
+
}
|
|
9
|
+
// Load the project's .env (API keys, base URLs). Providers read their settings when they load,
|
|
10
|
+
// so loading .env later would be too late for them.
|
|
11
|
+
if (existsSync(".env"))
|
|
12
|
+
process.loadEnvFile(".env");
|