onlyhuman 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/LICENSE +21 -0
- package/README.md +138 -2
- package/dist/cli.js +396 -0
- package/package.json +38 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Benjit87
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,139 @@
|
|
|
1
|
-
#
|
|
1
|
+
# onlyhuman
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**TL;DR**
|
|
4
|
+
- You write intent as short bullets in `spec.human`. Only you edit it.
|
|
5
|
+
- A robot (an AI agent) turns it into a detailed `spec.bot` for you to review, then into code.
|
|
6
|
+
- The syntax fixes only structure (`@ ? $`, `-`, `->`); the meaning of each line is decided semantically by the reader.
|
|
7
|
+
- Goals: trace every line of code back to a line you wrote, and make you record the spec before building.
|
|
8
|
+
- Tools: a linter, go-to-definition (human, bot, code) and highlighting, as a CLI and a VS Code extension.
|
|
9
|
+
|
|
10
|
+
A descriptive language for writing specs in plain bullets. The syntax is light on purpose: it fixes the structure of a line and leaves the meaning to the reader.
|
|
11
|
+
|
|
12
|
+
Every spec comes as a pair of files with the same name:
|
|
13
|
+
|
|
14
|
+
| File | Written by | Purpose |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `spec.human` | you, only | the intent, in short bullets |
|
|
17
|
+
| `spec.bot` | the robot (an AI agent) | the same spec in more detail, for you to review before any code is written |
|
|
18
|
+
|
|
19
|
+
## Key objectives
|
|
20
|
+
|
|
21
|
+
1. **Lineage from human to robot to code.** Every piece of code should trace back to a line you wrote. A definition header in `spec.human` leads to the same header in `spec.bot`, and that header carries a `[path][line]` pointer to the code that implements it. You can follow the chain in the editor with go-to-definition, and the linter warns when a pointer is broken (`broken-pointer`), when a spec has no partner file (`missing-robot`, `missing-human`), or when a definition is never used (`unused-definition`).
|
|
22
|
+
2. **Discipline the user to record the spec.** Intent lives in `spec.human`, and nowhere else. You are the only one who edits it, and a change to behaviour is made there, then regenerated into `spec.bot`, then reviewed, and only then built. The robot is told to ask you when something is ambiguous or wrong, so a vague line is questioned before it becomes code.
|
|
23
|
+
|
|
24
|
+
What the tools do not check: the lineage is only as good as the pointers. The linter confirms that a pointer's file exists and that the line is inside it, not that the code at that line does what the `.bot` says. Keeping `.human` honest is still your job.
|
|
25
|
+
|
|
26
|
+
## The idea: semantically driven syntax
|
|
27
|
+
|
|
28
|
+
In most languages the compiler fixes both the grammar and the meaning. Here the grammar is small and fixed, so code can check it. The meaning is decided semantically by the reader, which is the robot.
|
|
29
|
+
|
|
30
|
+
| Fixed by the syntax | Decided by the reader |
|
|
31
|
+
|---|---|
|
|
32
|
+
| `@` entity, `?` state, `$` service | what that entity, state or service is in the product |
|
|
33
|
+
| a leading `-` makes a child, indent is 2 spaces | whether a child is an attribute, a step or a part |
|
|
34
|
+
| `->` flow, `+` combine | what the flow or combination does |
|
|
35
|
+
| `==` | equal semantically or literally, the robot decides |
|
|
36
|
+
| `//` starts a comment | whether the comment is a note or an instruction |
|
|
37
|
+
| any other word | a method, a behaviour, or just a sentence |
|
|
38
|
+
|
|
39
|
+
Example:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
$Parser
|
|
43
|
+
- @Data -> tokenize -> @Tokens
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
The tools know `$Parser` is a service, `@Data` and `@Tokens` are entities, and `->` is a flow. They do not know what `tokenize` means. The robot reads it as a behaviour and works out what it should do. A line such as `- define for me` is a valid spec line for the same reason: it is a sentence, and the robot fills it in.
|
|
47
|
+
|
|
48
|
+
### What this costs
|
|
49
|
+
|
|
50
|
+
- Two readers can understand a line differently, so the result is not guaranteed to be the same every time.
|
|
51
|
+
- The linter catches structural mistakes, such as an undefined `@Name`. It cannot tell you that a line means the wrong thing.
|
|
52
|
+
|
|
53
|
+
Two rules keep this under control. The robot must ask you when a line is ambiguous or wrong, and you review `spec.bot` before any implementation starts.
|
|
54
|
+
|
|
55
|
+
## Syntax
|
|
56
|
+
|
|
57
|
+
| Syntax | Meaning |
|
|
58
|
+
|---|---|
|
|
59
|
+
| `@Name` | entity |
|
|
60
|
+
| `?Name` | state |
|
|
61
|
+
| `$Name` | service |
|
|
62
|
+
| `#text` | requirement statement |
|
|
63
|
+
| `// text` | comment, from `//` to the end of the line; a note or an instruction |
|
|
64
|
+
| `-` | starts a child of the previous header, any depth |
|
|
65
|
+
| `->` | logic flow |
|
|
66
|
+
| `+` | combine behaviours into a set |
|
|
67
|
+
| `==` | equal, semantically or literally |
|
|
68
|
+
| bare word | behaviour or attribute name |
|
|
69
|
+
|
|
70
|
+
Rules:
|
|
71
|
+
- A line with no `-` is a header and must start with a prefix (`@ $ ? # //`).
|
|
72
|
+
- Indent is 2 spaces. A tab or an odd indent is an error.
|
|
73
|
+
- A `//` is a comment only when it starts a space-separated word. `"//"`, `a//b` and `\//` are plain words.
|
|
74
|
+
- In a `.bot`, a header may end with a pointer to code: `@Token [./src/types.ts][14]`. The path is relative to the nearest folder that holds `.git` or `SKILL.md`.
|
|
75
|
+
|
|
76
|
+
The full rules, including how the robot behaves, are in [SKILL.md](SKILL.md). The spec for this tool is in [spec/spec.human](spec/spec.human).
|
|
77
|
+
|
|
78
|
+
## Workflow
|
|
79
|
+
|
|
80
|
+
1. Edit `spec/spec.human`. Only you edit `.human` files.
|
|
81
|
+
2. Say "regenerate". The robot rewrites `spec/spec.bot` from your spec.
|
|
82
|
+
3. Review `spec.bot`. Change your intent in `spec.human`, never in `spec.bot`.
|
|
83
|
+
4. Approve, and the robot implements against the reviewed `spec.bot`.
|
|
84
|
+
|
|
85
|
+
## Tools
|
|
86
|
+
|
|
87
|
+
The parser, linter, go-to-definition and highlighter work on both `.human` and `.bot` files. Requires Node 22.18 or newer.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm install
|
|
91
|
+
npm test
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Lint a file. This prints `file:line:col severity rule message` and exits 1 on errors:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
node src/cli.ts lint spec/spec.human
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Find a definition, by name or by cursor position (1-based line and column):
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
node src/cli.ts def @Token spec/spec.human
|
|
104
|
+
node src/cli.ts def spec/spec.human:15:2
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
- From a definition header in a `.human`, go-to-definition jumps to the same header in the sibling `.bot`.
|
|
108
|
+
- From a definition header in a `.bot`, it jumps to the code line in its `[path][line]`.
|
|
109
|
+
- A reference jumps to its header in the same file.
|
|
110
|
+
|
|
111
|
+
Lint rules: `no-prefix`, `orphan-child`, `bad-indent`, `indent-jump`, `undefined-reference`, `duplicate-definition`, `dangling-operator` (errors), and `unused-definition`, `missing-robot`, `missing-human`, `broken-pointer` (warnings).
|
|
112
|
+
|
|
113
|
+
### VS Code
|
|
114
|
+
|
|
115
|
+
The extension in `vscode/` gives you the same linting and go-to-definition in the editor, plus colours. Install its dependency, then open a window with it loaded:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
cd vscode && npm install
|
|
119
|
+
code --extensionDevelopmentPath=/path/to/onlyhuman/vscode /path/to/onlyhuman/spec
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Layout
|
|
123
|
+
|
|
124
|
+
| Path | Contents |
|
|
125
|
+
|---|---|
|
|
126
|
+
| `src/tokenizer.ts`, `src/parser.ts`, `src/types.ts` | text to tokens to a tree of nodes |
|
|
127
|
+
| `src/linter.ts` | lint rules |
|
|
128
|
+
| `src/definition.ts` | go-to-definition and `[path][line]` pointers |
|
|
129
|
+
| `src/files.ts` | the only file-system access: sibling file and project root |
|
|
130
|
+
| `src/cli.ts`, `src/lsp/server.ts` | command line and language server |
|
|
131
|
+
| `vscode/` | editor client and highlighting grammars |
|
|
132
|
+
| `tests/` | unit tests |
|
|
133
|
+
| `spec/` | the project's own `spec.human` and `spec.bot` |
|
|
134
|
+
|
|
135
|
+
## Limits
|
|
136
|
+
|
|
137
|
+
- Go-to-definition covers one file and its sibling only, with no cross-file lookup.
|
|
138
|
+
- The VS Code extension has been run in a development window only, not packaged.
|
|
139
|
+
- The `.bot` extension is a choice made to avoid clashing with Robot Framework's `.robot`. `SKILL.md` still refers to `*.robot` in places.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
5
|
+
import { relative } from "node:path";
|
|
6
|
+
|
|
7
|
+
// src/types.ts
|
|
8
|
+
var PREFIXES = ["@", "$", "?", "#"];
|
|
9
|
+
var SYMBOL_PREFIXES = ["@", "$", "?"];
|
|
10
|
+
|
|
11
|
+
// src/definition.ts
|
|
12
|
+
function collectSymbols(parsed) {
|
|
13
|
+
const out = [];
|
|
14
|
+
const toks = parsed.tokens;
|
|
15
|
+
for (let i = 0; i < toks.length; i++) {
|
|
16
|
+
const p = toks[i];
|
|
17
|
+
if (p.kind !== "prefix" || !SYMBOL_PREFIXES.includes(p.text)) continue;
|
|
18
|
+
const name = toks[i + 1];
|
|
19
|
+
if (!name || name.kind !== "word" || name.line !== p.line || name.col !== p.col + 1) continue;
|
|
20
|
+
const isDefinition = parsed.nodes.some((n) => n.kind === "header" && n.token === p);
|
|
21
|
+
out.push({
|
|
22
|
+
key: p.text + name.text,
|
|
23
|
+
location: { line: p.line, col: p.col, length: 1 + name.text.length },
|
|
24
|
+
isDefinition
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
function findDefinition(parsed, key) {
|
|
30
|
+
if (key.startsWith("#")) return parsed.nodes.map(requirementHeader).find((h) => h?.key === key)?.location;
|
|
31
|
+
return collectSymbols(parsed).find((s) => s.isDefinition && s.key === key)?.location;
|
|
32
|
+
}
|
|
33
|
+
function requirementHeader(node) {
|
|
34
|
+
if (node.kind !== "header" || node.tokens[0]?.kind !== "prefix" || node.tokens[0].text !== "#") return void 0;
|
|
35
|
+
const content = node.tokens.slice(1).filter((t) => t.kind !== "comment" && t.kind !== "comment_sentence" && !parsePointer(t));
|
|
36
|
+
if (!content.length) return void 0;
|
|
37
|
+
const first = node.tokens[0];
|
|
38
|
+
const last = content.at(-1);
|
|
39
|
+
return {
|
|
40
|
+
key: "#" + content.map((t) => t.text).join(" "),
|
|
41
|
+
location: { line: node.line, col: first.col, length: last.col + last.length - first.col }
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function requirementAt(parsed, line, col) {
|
|
45
|
+
const node = parsed.nodes.find((n) => n.line === line);
|
|
46
|
+
const header = node && requirementHeader(node);
|
|
47
|
+
return header && col >= header.location.col && col <= header.location.col + header.location.length ? header : void 0;
|
|
48
|
+
}
|
|
49
|
+
function tokenAt(parsed, line, col) {
|
|
50
|
+
return parsed.tokens.find((t) => t.line === line && col >= t.col && col <= t.col + t.length);
|
|
51
|
+
}
|
|
52
|
+
function parsePointer(token) {
|
|
53
|
+
if (token.kind !== "word") return void 0;
|
|
54
|
+
const m = /^\[(.+)\]\[(\d+)\]$/.exec(token.text);
|
|
55
|
+
return m ? { path: m[1], line: Number(m[2]), token } : void 0;
|
|
56
|
+
}
|
|
57
|
+
function pointerOf(node) {
|
|
58
|
+
for (const t of node.tokens) {
|
|
59
|
+
const p = parsePointer(t);
|
|
60
|
+
if (p) return p;
|
|
61
|
+
}
|
|
62
|
+
return void 0;
|
|
63
|
+
}
|
|
64
|
+
function symbolAt(parsed, line, col) {
|
|
65
|
+
const sym = collectSymbols(parsed).find(
|
|
66
|
+
(s) => s.location.line === line && col >= s.location.col && col <= s.location.col + s.location.length
|
|
67
|
+
);
|
|
68
|
+
if (!sym) return void 0;
|
|
69
|
+
const tok = tokenAt(parsed, line, col);
|
|
70
|
+
if (tok && (tok.kind === "comment" || tok.kind === "comment_sentence")) return void 0;
|
|
71
|
+
return sym;
|
|
72
|
+
}
|
|
73
|
+
function definitionAt(parsed, line, col, robot) {
|
|
74
|
+
const requirement = requirementAt(parsed, line, col);
|
|
75
|
+
if (requirement) {
|
|
76
|
+
const target = robot && findDefinition(robot, requirement.key);
|
|
77
|
+
return target ? { ...target, file: "robot" } : requirement.location;
|
|
78
|
+
}
|
|
79
|
+
const sym = symbolAt(parsed, line, col);
|
|
80
|
+
if (!sym) return void 0;
|
|
81
|
+
if (sym.isDefinition && robot) {
|
|
82
|
+
const target = findDefinition(robot, sym.key);
|
|
83
|
+
if (target) return { ...target, file: "robot" };
|
|
84
|
+
}
|
|
85
|
+
return findDefinition(parsed, sym.key);
|
|
86
|
+
}
|
|
87
|
+
function pointerAt(parsed, line, col) {
|
|
88
|
+
const method = parsed.nodes.find((n) => n.kind === "child" && n.line === line && n.token?.kind === "percent");
|
|
89
|
+
if (method) {
|
|
90
|
+
const [percent, name] = method.tokens.filter((t) => t.kind !== "bullet");
|
|
91
|
+
const on = (t) => t && col >= t.col && col <= t.col + t.length;
|
|
92
|
+
return on(percent) || on(name) ? pointerOf(method) : void 0;
|
|
93
|
+
}
|
|
94
|
+
if (requirementAt(parsed, line, col)) {
|
|
95
|
+
const node2 = parsed.nodes.find((n) => n.kind === "header" && n.line === line);
|
|
96
|
+
return node2 ? pointerOf(node2) : void 0;
|
|
97
|
+
}
|
|
98
|
+
const sym = symbolAt(parsed, line, col);
|
|
99
|
+
if (!sym?.isDefinition) return void 0;
|
|
100
|
+
const node = parsed.nodes.find((n) => n.kind === "header" && n.line === line);
|
|
101
|
+
return node ? pointerOf(node) : void 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// src/files.ts
|
|
105
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
106
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
107
|
+
|
|
108
|
+
// src/tokenizer.ts
|
|
109
|
+
var PREFIX_RE = /^([@$?#])([A-Za-z0-9_]*)(.*)$/;
|
|
110
|
+
var PERCENT_RE = /^%([A-Za-z0-9_]*)(.*)$/;
|
|
111
|
+
function tokenize(text) {
|
|
112
|
+
const tokens = [];
|
|
113
|
+
const lines = text.split(/\r\n|\n/);
|
|
114
|
+
lines.forEach((raw, i) => {
|
|
115
|
+
const line = i + 1;
|
|
116
|
+
const indent = /^[ \t]*/.exec(raw)[0];
|
|
117
|
+
let pos = indent.length;
|
|
118
|
+
let first = true;
|
|
119
|
+
const push = (kind, s, col) => tokens.push({ kind, text: s, line, col, length: s.length, indent });
|
|
120
|
+
while (pos < raw.length) {
|
|
121
|
+
if (raw[pos] === " " || raw[pos] === " ") {
|
|
122
|
+
pos++;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
let end = pos;
|
|
126
|
+
while (end < raw.length && raw[end] !== " " && raw[end] !== " ") end++;
|
|
127
|
+
const run = raw.slice(pos, end);
|
|
128
|
+
if (run.startsWith("//")) {
|
|
129
|
+
push("comment", "//", pos);
|
|
130
|
+
const rest = raw.slice(pos + 2).trim();
|
|
131
|
+
if (rest) push("comment_sentence", rest, raw.indexOf(rest, pos + 2));
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
if (run === "->") push("arrow", run, pos);
|
|
135
|
+
else if (run === "+") push("plus", run, pos);
|
|
136
|
+
else if (run === "==") push("equals", run, pos);
|
|
137
|
+
else if (run === "-") push(first ? "bullet" : "dash", run, pos);
|
|
138
|
+
else {
|
|
139
|
+
const m = PREFIX_RE.exec(run);
|
|
140
|
+
if (m && (m[2] !== "" || m[3] === "")) {
|
|
141
|
+
push("prefix", m[1], pos);
|
|
142
|
+
if (m[2]) push("word", m[2], pos + 1);
|
|
143
|
+
if (m[3]) push("word", m[3], pos + 1 + m[2].length);
|
|
144
|
+
} else if (PERCENT_RE.test(run)) {
|
|
145
|
+
const [, name, rest] = PERCENT_RE.exec(run);
|
|
146
|
+
push("percent", "%", pos);
|
|
147
|
+
if (name) push("word", name, pos + 1);
|
|
148
|
+
if (rest) push("word", rest, pos + 1 + name.length);
|
|
149
|
+
} else {
|
|
150
|
+
push("word", run, pos);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
first = false;
|
|
154
|
+
pos = end;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
return tokens;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// src/parser.ts
|
|
161
|
+
function indentLevel(indent) {
|
|
162
|
+
let level = 0;
|
|
163
|
+
let spaces = 0;
|
|
164
|
+
for (const ch of indent) {
|
|
165
|
+
if (ch === " ") level++;
|
|
166
|
+
else spaces++;
|
|
167
|
+
}
|
|
168
|
+
return level + Math.floor(spaces / 2);
|
|
169
|
+
}
|
|
170
|
+
function parse(tokens) {
|
|
171
|
+
const byLine = /* @__PURE__ */ new Map();
|
|
172
|
+
for (const t of tokens) {
|
|
173
|
+
const arr = byLine.get(t.line);
|
|
174
|
+
if (arr) arr.push(t);
|
|
175
|
+
else byLine.set(t.line, [t]);
|
|
176
|
+
}
|
|
177
|
+
const roots = [];
|
|
178
|
+
const nodes = [];
|
|
179
|
+
const stack = [];
|
|
180
|
+
for (const [line, toks] of byLine) {
|
|
181
|
+
const bullet = toks[0].kind === "bullet";
|
|
182
|
+
const node = {
|
|
183
|
+
kind: bullet ? "child" : "header",
|
|
184
|
+
token: bullet ? toks[1] : toks[0],
|
|
185
|
+
tokens: toks,
|
|
186
|
+
line,
|
|
187
|
+
indent: toks[0].indent,
|
|
188
|
+
depth: indentLevel(toks[0].indent) + (bullet ? 1 : 0),
|
|
189
|
+
children: []
|
|
190
|
+
};
|
|
191
|
+
while (stack.length && stack[stack.length - 1].depth >= node.depth) stack.pop();
|
|
192
|
+
const parent = stack[stack.length - 1];
|
|
193
|
+
if (parent && node.kind === "child") {
|
|
194
|
+
node.parent = parent;
|
|
195
|
+
parent.children.push(node);
|
|
196
|
+
} else {
|
|
197
|
+
roots.push(node);
|
|
198
|
+
}
|
|
199
|
+
nodes.push(node);
|
|
200
|
+
stack.push(node);
|
|
201
|
+
}
|
|
202
|
+
return { roots, nodes, tokens };
|
|
203
|
+
}
|
|
204
|
+
function parseText(text) {
|
|
205
|
+
return parse(tokenize(text));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// src/files.ts
|
|
209
|
+
function robotPathFor(humanPath) {
|
|
210
|
+
return humanPath.replace(/\.human$/, ".bot");
|
|
211
|
+
}
|
|
212
|
+
function humanPathFor(robotPath) {
|
|
213
|
+
return robotPath.replace(/\.bot$/, ".human");
|
|
214
|
+
}
|
|
215
|
+
function siblingPathFor(path) {
|
|
216
|
+
return path.endsWith(".bot") ? humanPathFor(path) : robotPathFor(path);
|
|
217
|
+
}
|
|
218
|
+
function siblingExists(path) {
|
|
219
|
+
const sibling = siblingPathFor(path);
|
|
220
|
+
return sibling !== path && existsSync(sibling);
|
|
221
|
+
}
|
|
222
|
+
function loadRobot(humanPath) {
|
|
223
|
+
const path = robotPathFor(humanPath);
|
|
224
|
+
return path !== humanPath && existsSync(path) ? parseText(readFileSync(path, "utf8")) : void 0;
|
|
225
|
+
}
|
|
226
|
+
function findProjectRoot(fromPath) {
|
|
227
|
+
let dir = dirname(resolve(fromPath));
|
|
228
|
+
let specParent;
|
|
229
|
+
for (; ; ) {
|
|
230
|
+
if (existsSync(join(dir, ".git"))) return dir;
|
|
231
|
+
if (!specParent && basename(dir) === "spec") specParent = dirname(dir);
|
|
232
|
+
const up = dirname(dir);
|
|
233
|
+
if (up === dir) return specParent;
|
|
234
|
+
dir = up;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function resolvePointerPath(filePath, pointerPath) {
|
|
238
|
+
const root = findProjectRoot(filePath);
|
|
239
|
+
return root ? join(root, pointerPath) : void 0;
|
|
240
|
+
}
|
|
241
|
+
function isFile(path) {
|
|
242
|
+
try {
|
|
243
|
+
return statSync(path).isFile();
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
function pointerLineCounter(filePath) {
|
|
249
|
+
if (!findProjectRoot(filePath)) return void 0;
|
|
250
|
+
return (pointerPath) => {
|
|
251
|
+
const abs = resolvePointerPath(filePath, pointerPath);
|
|
252
|
+
if (!abs || !isFile(abs)) return void 0;
|
|
253
|
+
const lines = readFileSync(abs, "utf8").split(/\r?\n/);
|
|
254
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
255
|
+
return lines.length;
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function pointerTargetExists(filePath, pointerPath) {
|
|
259
|
+
const abs = resolvePointerPath(filePath, pointerPath);
|
|
260
|
+
return abs && isFile(abs) ? abs : void 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// src/linter.ts
|
|
264
|
+
var OPERATORS = ["arrow", "plus", "equals"];
|
|
265
|
+
function lint(parsed, options = {}) {
|
|
266
|
+
const out = [];
|
|
267
|
+
const add = (rule, severity, t, message) => out.push({ rule, severity, line: t.line, col: t.col, length: t.length, message });
|
|
268
|
+
for (const node of parsed.nodes) {
|
|
269
|
+
const first = node.tokens[0];
|
|
270
|
+
const span = { line: node.line, col: 0, length: Math.max(first.col + first.length, 1) };
|
|
271
|
+
if (/\t/.test(node.indent) || node.indent.length % 2 !== 0) {
|
|
272
|
+
add("bad-indent", "error", { line: node.line, col: 0, length: node.indent.length }, "indent must be 2 spaces per level");
|
|
273
|
+
} else if (node.parent && node.depth > node.parent.depth + 1) {
|
|
274
|
+
add("indent-jump", "error", span, `indented ${node.depth - node.parent.depth} levels under its parent, expected 1`);
|
|
275
|
+
}
|
|
276
|
+
if (node.kind === "header") {
|
|
277
|
+
const ok = first.kind === "prefix" && PREFIXES.includes(first.text) || first.kind === "comment";
|
|
278
|
+
if (!ok) add("no-prefix", "error", first, "line has no - and no prefix (@ $ ? # //)");
|
|
279
|
+
} else if (!node.parent) {
|
|
280
|
+
add("orphan-child", "error", first, "- line has no header above it");
|
|
281
|
+
}
|
|
282
|
+
if (node.kind === "child" && node.tokens[1]?.kind === "dash") {
|
|
283
|
+
const content = node.tokens.slice(2).filter((t) => t.kind !== "comment" && t.kind !== "comment_sentence");
|
|
284
|
+
if (content.length) add("repeated-bullet", "error", node.tokens[1], "nest children with 2-space indentation; a listed dash must stand alone");
|
|
285
|
+
}
|
|
286
|
+
checkOperators(node, add);
|
|
287
|
+
}
|
|
288
|
+
const symbols = collectSymbols(parsed);
|
|
289
|
+
const defs = /* @__PURE__ */ new Map();
|
|
290
|
+
const referenced = /* @__PURE__ */ new Set();
|
|
291
|
+
for (const s of symbols) {
|
|
292
|
+
if (s.isDefinition) {
|
|
293
|
+
defs.set(s.key, (defs.get(s.key) ?? 0) + 1);
|
|
294
|
+
if (defs.get(s.key) > 1) add("duplicate-definition", "error", s.location, `${s.key} is already defined`);
|
|
295
|
+
} else {
|
|
296
|
+
referenced.add(s.key);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
for (const s of symbols) {
|
|
300
|
+
if (!s.isDefinition && !defs.has(s.key)) add("undefined-reference", "error", s.location, `${s.key} is not defined`);
|
|
301
|
+
}
|
|
302
|
+
const reported = /* @__PURE__ */ new Set();
|
|
303
|
+
for (const s of symbols) {
|
|
304
|
+
if (s.isDefinition && !referenced.has(s.key) && !reported.has(s.key)) {
|
|
305
|
+
reported.add(s.key);
|
|
306
|
+
add("unused-definition", "warning", s.location, `${s.key} is defined but never referenced`);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
const file = options.file;
|
|
310
|
+
if (file && !file.siblingExists) {
|
|
311
|
+
const top = { line: 1, col: 0, length: 1 };
|
|
312
|
+
if (file.path.endsWith(".human")) add("missing-robot", "warning", top, "no sibling .bot file found");
|
|
313
|
+
if (file.path.endsWith(".bot")) add("missing-human", "warning", top, "no sibling .human file found");
|
|
314
|
+
}
|
|
315
|
+
if (file?.lineCount && file.path.endsWith(".bot")) {
|
|
316
|
+
for (const node of parsed.nodes) {
|
|
317
|
+
if (node.kind !== "header") continue;
|
|
318
|
+
const ptr = pointerOf(node);
|
|
319
|
+
if (!ptr) continue;
|
|
320
|
+
const count = file.lineCount(ptr.path);
|
|
321
|
+
if (count === void 0) add("broken-pointer", "warning", ptr.token, `${ptr.path} does not exist`);
|
|
322
|
+
else if (ptr.line < 1 || ptr.line > count) {
|
|
323
|
+
add("broken-pointer", "warning", ptr.token, `${ptr.path} has ${count} lines, no line ${ptr.line}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return out.sort((a, b) => a.line - b.line || a.col - b.col);
|
|
328
|
+
}
|
|
329
|
+
function checkOperators(node, add) {
|
|
330
|
+
const ci = node.tokens.findIndex((t) => t.kind === "comment");
|
|
331
|
+
const toks = ci === -1 ? node.tokens : node.tokens.slice(0, ci);
|
|
332
|
+
const isOperand = (t) => !!t && t.kind !== "bullet" && !OPERATORS.includes(t.kind);
|
|
333
|
+
if (toks.filter((t) => t.kind !== "bullet").length === 1) return;
|
|
334
|
+
toks.forEach((t, i) => {
|
|
335
|
+
if (!OPERATORS.includes(t.kind)) return;
|
|
336
|
+
if (!isOperand(toks[i - 1])) add("dangling-operator", "error", t, `${t.text} has no left operand`);
|
|
337
|
+
else if (!isOperand(toks[i + 1])) add("dangling-operator", "error", t, `${t.text} has no right operand`);
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/cli.ts
|
|
342
|
+
var USAGE = `usage:
|
|
343
|
+
onlyhuman lint <file> *.human or *.bot
|
|
344
|
+
onlyhuman def <name> <file> e.g. onlyhuman def @Token spec.human
|
|
345
|
+
onlyhuman def <file>:<line>:<col> 1-based line and col`;
|
|
346
|
+
function load(file) {
|
|
347
|
+
return parseText(readFileSync2(file, "utf8"));
|
|
348
|
+
}
|
|
349
|
+
function main(argv) {
|
|
350
|
+
const [cmd, ...rest] = argv;
|
|
351
|
+
if (cmd === "lint" && rest.length === 1) {
|
|
352
|
+
const file = rest[0];
|
|
353
|
+
const diags = lint(load(file), {
|
|
354
|
+
file: { path: file, siblingExists: siblingExists(file), lineCount: pointerLineCounter(file) }
|
|
355
|
+
});
|
|
356
|
+
for (const d of diags) console.log(`${file}:${d.line}:${d.col + 1} ${d.severity} ${d.rule} ${d.message}`);
|
|
357
|
+
return diags.some((d) => d.severity === "error") ? 1 : 0;
|
|
358
|
+
}
|
|
359
|
+
if (cmd === "def" && rest.length === 2) {
|
|
360
|
+
const [name, file] = rest;
|
|
361
|
+
const loc = findDefinition(load(file), name);
|
|
362
|
+
if (!loc) return notFound(name);
|
|
363
|
+
console.log(`${file}:${loc.line}:${loc.col + 1}`);
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
if (cmd === "def" && rest.length === 1) {
|
|
367
|
+
const m = /^(.*):(\d+):(\d+)$/.exec(rest[0]);
|
|
368
|
+
if (m) {
|
|
369
|
+
const parsed = load(m[1]);
|
|
370
|
+
const [line, col] = [Number(m[2]), Number(m[3]) - 1];
|
|
371
|
+
const ptr = m[1].endsWith(".bot") ? pointerAt(parsed, line, col) : void 0;
|
|
372
|
+
const code = ptr && pointerTargetExists(m[1], ptr.path);
|
|
373
|
+
if (ptr && code) {
|
|
374
|
+
console.log(`${relative(process.cwd(), code)}:${ptr.line}:1`);
|
|
375
|
+
return 0;
|
|
376
|
+
}
|
|
377
|
+
const loc = definitionAt(parsed, line, col, loadRobot(m[1]));
|
|
378
|
+
if (!loc) return notFound(rest[0]);
|
|
379
|
+
const where = loc.file === "robot" ? robotPathFor(m[1]) : m[1];
|
|
380
|
+
console.log(`${where}:${loc.line}:${loc.col + 1}`);
|
|
381
|
+
return 0;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
console.error(USAGE);
|
|
385
|
+
return 2;
|
|
386
|
+
}
|
|
387
|
+
function notFound(what) {
|
|
388
|
+
console.error(`no definition for ${what}`);
|
|
389
|
+
return 1;
|
|
390
|
+
}
|
|
391
|
+
try {
|
|
392
|
+
process.exit(main(process.argv.slice(2)));
|
|
393
|
+
} catch (e) {
|
|
394
|
+
console.error(e.message);
|
|
395
|
+
process.exit(2);
|
|
396
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "onlyhuman",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "A descriptive language for writing specs in plain bullets, with a CLI linter and go-to-definition.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": { "onlyhuman": "./dist/cli.js" },
|
|
8
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/Benjit87/onlyhuman.git"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/Benjit87/onlyhuman#readme",
|
|
14
|
+
"bugs": { "url": "https://github.com/Benjit87/onlyhuman/issues" },
|
|
15
|
+
"keywords": ["onlyhuman", "spec", "specification", "language", "linter", "cli"],
|
|
16
|
+
"author": "Benjit87",
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test \"tests/*.test.ts\"",
|
|
19
|
+
"lint:spec": "node src/cli.ts lint spec/spec.human",
|
|
20
|
+
"typecheck": "tsc --noEmit",
|
|
21
|
+
"lsp": "node src/lsp/server.ts --stdio",
|
|
22
|
+
"build:cli": "esbuild src/cli.ts --bundle --platform=node --format=esm --target=node20 --outfile=dist/cli.js",
|
|
23
|
+
"prepublishOnly": "npm test && npm run typecheck && npm run build:cli"
|
|
24
|
+
},
|
|
25
|
+
"engines": { "node": ">=20" },
|
|
26
|
+
"devEngines": {
|
|
27
|
+
"runtime": {
|
|
28
|
+
"name": "node",
|
|
29
|
+
"version": ">=22.18",
|
|
30
|
+
"onFail": "error"
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.0.0",
|
|
35
|
+
"esbuild": "^0.25.0",
|
|
36
|
+
"typescript": "^5.7.0",
|
|
37
|
+
"vscode-languageserver": "^9.0.1",
|
|
38
|
+
"vscode-languageserver-textdocument": "^1.0.12"
|
|
39
|
+
}
|
|
40
|
+
}
|