heeded 0.0.0-stage → 0.1.1
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 +147 -2
- package/dist/check.js +495 -0
- package/dist/claude-code.js +250 -0
- package/dist/cli.js +258 -0
- package/dist/judge.js +359 -0
- package/dist/model.js +112 -0
- package/dist/redact.js +21 -0
- package/dist/report.js +186 -0
- package/dist/rules.js +402 -0
- package/dist/shell.js +182 -0
- package/package.json +23 -4
package/dist/shell.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// A shell command line read the way a shell reads it, without running one: its simple commands, each with what it
|
|
2
|
+
// does (its quoted text emptied, heredoc bodies out) and what it carries (that quoted text and those bodies). Rules
|
|
3
|
+
// about running something look at the first; rules about a commit message look at the second; a search for a word, or
|
|
4
|
+
// a file written with it, does neither.
|
|
5
|
+
/** Where the substitution opened at `i` ends: past its closing `)` or backtick, its quotes and nested parentheses read
|
|
6
|
+
* as a shell reads them; the end of the line when it is left open. */
|
|
7
|
+
function substitution(line, i) {
|
|
8
|
+
let j = i + 1;
|
|
9
|
+
if (line[i] === '`') {
|
|
10
|
+
while (j < line.length && line[j] !== '`')
|
|
11
|
+
j += line[j] === '\\' ? 2 : 1;
|
|
12
|
+
}
|
|
13
|
+
else {
|
|
14
|
+
for (let depth = 0; j < line.length; j++) {
|
|
15
|
+
const c = line[j];
|
|
16
|
+
if (c === '\\')
|
|
17
|
+
j++;
|
|
18
|
+
else if (c === "'" || c === '"')
|
|
19
|
+
j = quoted(line, j).end - 1;
|
|
20
|
+
else if (c === '`')
|
|
21
|
+
j = substitution(line, j).end - 1;
|
|
22
|
+
else if (c === '(')
|
|
23
|
+
depth++;
|
|
24
|
+
else if (c === ')' && --depth === 0)
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const end = Math.min(j + 1, line.length);
|
|
29
|
+
// $(( … )) is arithmetic, not a command.
|
|
30
|
+
const inner = line.startsWith('$((', i) ? '' : line.slice(line[i] === '`' ? i + 1 : i + 2, j);
|
|
31
|
+
return { end, text: line.slice(i, end), inner };
|
|
32
|
+
}
|
|
33
|
+
/** The quoted text opened at `i`: where it ends (past its closing quote, or the end of the line), its content, the
|
|
34
|
+
* quotes as they stay in what the command does (one when it is left open), and the substitutions in a `"…"`. */
|
|
35
|
+
function quoted(line, i) {
|
|
36
|
+
const open = line[i] === '$' ? "$'" : line[i];
|
|
37
|
+
const close = open.at(-1);
|
|
38
|
+
const subs = [];
|
|
39
|
+
let j = i + open.length;
|
|
40
|
+
while (j < line.length && line[j] !== close) {
|
|
41
|
+
// In "…" a backslash escapes only these, and $( … ) and backticks run commands; in $'…' a backslash escapes
|
|
42
|
+
// anything; in '…' nothing is special.
|
|
43
|
+
if (open === '"' && (line.startsWith('$(', j) || line[j] === '`')) {
|
|
44
|
+
const sub = substitution(line, j);
|
|
45
|
+
subs.push(sub);
|
|
46
|
+
j = sub.end;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (line[j] === '\\' && (open === "$'" || (open === '"' && '"\\$`'.includes(line[j + 1] ?? ''))))
|
|
50
|
+
j++;
|
|
51
|
+
j++;
|
|
52
|
+
}
|
|
53
|
+
const closed = j < line.length;
|
|
54
|
+
return { end: closed ? j + 1 : line.length, content: line.slice(i + open.length, j), quotes: closed ? open + close : open, subs };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Heredoc bodies taken out of the raw lines, each with the offset of its `<<` in the text left. Found before quotes
|
|
58
|
+
* are read, since a heredoc inside `"$( … )"` (how commit messages are often written) holds any quotes; and only with
|
|
59
|
+
* its closing line, so `$((1 << 2))` is not one.
|
|
60
|
+
*/
|
|
61
|
+
function heredocs(line) {
|
|
62
|
+
const lines = line.replace(/\r\n?/g, '\n').split('\n');
|
|
63
|
+
const kept = [];
|
|
64
|
+
const bodies = [];
|
|
65
|
+
// A closing line not found from some line on is not found from any later one: each is looked for once.
|
|
66
|
+
const missing = new Map();
|
|
67
|
+
const closing = (word, tabs, from) => {
|
|
68
|
+
const key = `${tabs}${word}`;
|
|
69
|
+
if ((missing.get(key) ?? Infinity) <= from)
|
|
70
|
+
return -1;
|
|
71
|
+
for (let k = from; k < lines.length; k++)
|
|
72
|
+
if ((tabs ? lines[k].replace(/^\t+/, '') : lines[k]) === word)
|
|
73
|
+
return k;
|
|
74
|
+
missing.set(key, from);
|
|
75
|
+
return -1;
|
|
76
|
+
};
|
|
77
|
+
let offset = 0;
|
|
78
|
+
for (let i = 0; i < lines.length; i++) {
|
|
79
|
+
let next = i + 1;
|
|
80
|
+
let line = '';
|
|
81
|
+
let from = 0;
|
|
82
|
+
// `\EOF` is a heredoc like `'EOF'`; a line that is a comment opens none.
|
|
83
|
+
const markers = /^\s*#/.test(lines[i]) ? [] : lines[i].matchAll(/(?<!<)<<(?!<)(-?)\s*\\?(['"]?)([\w.-]+)\2/g);
|
|
84
|
+
for (const m of markers) {
|
|
85
|
+
const end = closing(m[3], m[1] === '-', next);
|
|
86
|
+
if (end < 0)
|
|
87
|
+
continue;
|
|
88
|
+
// The marker is kept without its quotes: they only say how the body is expanded.
|
|
89
|
+
line += lines[i].slice(from, m.index);
|
|
90
|
+
bodies.push({ at: offset + line.length, body: lines.slice(next, end).join('\n') });
|
|
91
|
+
line += `<<${m[1]}${m[3]}`;
|
|
92
|
+
from = m.index + m[0].length;
|
|
93
|
+
next = end + 1;
|
|
94
|
+
}
|
|
95
|
+
line += lines[i].slice(from);
|
|
96
|
+
kept.push(line);
|
|
97
|
+
offset += line.length + 1;
|
|
98
|
+
i = next - 1;
|
|
99
|
+
}
|
|
100
|
+
return { text: kept.join('\n'), bodies };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* A command that runs what it carries: a shell given `-c` (`bash -c '…'`, `docker exec app sh -c '…'`) or only options
|
|
104
|
+
* and a heredoc (`sh <<EOF`), `ssh`, `eval`; wherever it sits in the command, its path optional. A shell given a script
|
|
105
|
+
* (`bash run.sh "x"`) is not one.
|
|
106
|
+
*/
|
|
107
|
+
const SHELL = String.raw `(?:^|\s)(?:\S*/)?(?:sh|bash|zsh|dash)`;
|
|
108
|
+
const WRAPPER = new RegExp(String.raw `${SHELL}\s(?:.*\s)?-[a-zA-Z]*c\b|${SHELL}(?:\s+-\S+)*\s*(?:$|<<)|(?:^|\s)(?:ssh|eval)\s`);
|
|
109
|
+
/** Shell syntax that may open a simple command without being part of what it runs. */
|
|
110
|
+
const SYNTAX = /^(?:[({!]\s*|(?:if|then|else|elif|do|while|until)\s+)+/;
|
|
111
|
+
export function commands(raw) {
|
|
112
|
+
const { text: line, bodies } = heredocs(raw);
|
|
113
|
+
const out = [];
|
|
114
|
+
let does = '';
|
|
115
|
+
let carried = [];
|
|
116
|
+
let inner = []; // the commands of its substitutions
|
|
117
|
+
let start = 0;
|
|
118
|
+
const close = (end, next) => {
|
|
119
|
+
// Heredoc bodies first: a commit message written through one is what the command carries, from its first line.
|
|
120
|
+
const carries = [...bodies.filter((b) => b.at >= start && b.at < end).map((b) => b.body), ...carried].filter(Boolean);
|
|
121
|
+
const command = does.trim().replace(SYNTAX, '');
|
|
122
|
+
if (command) {
|
|
123
|
+
out.push({ does: command, carries: carries.join('\n'), text: line.slice(start, end).trim() });
|
|
124
|
+
if (WRAPPER.test(command))
|
|
125
|
+
for (const c of carries)
|
|
126
|
+
out.push(...commands(c));
|
|
127
|
+
out.push(...inner);
|
|
128
|
+
}
|
|
129
|
+
does = '';
|
|
130
|
+
carried = [];
|
|
131
|
+
inner = [];
|
|
132
|
+
start = next;
|
|
133
|
+
};
|
|
134
|
+
let i = 0;
|
|
135
|
+
while (i < line.length) {
|
|
136
|
+
const c = line[i];
|
|
137
|
+
const two = line.slice(i, i + 2);
|
|
138
|
+
if (c === "'" || c === '"' || two === "$'") {
|
|
139
|
+
const q = quoted(line, i);
|
|
140
|
+
does += q.quotes;
|
|
141
|
+
for (const sub of q.subs)
|
|
142
|
+
inner.push(...commands(sub.inner));
|
|
143
|
+
// A quoted text that is one substitution carries what its commands carry: `"$(printf 'feat: x')"` carries the
|
|
144
|
+
// message, not the command that prints it.
|
|
145
|
+
const only = q.subs.length === 1 && q.content.trim() === q.subs[0].text;
|
|
146
|
+
carried.push(only ? commands(q.subs[0].inner).map((x) => x.carries).filter(Boolean).join('\n') : q.content);
|
|
147
|
+
i = q.end;
|
|
148
|
+
}
|
|
149
|
+
else if (two === '$(' || c === '`') {
|
|
150
|
+
const sub = substitution(line, i);
|
|
151
|
+
does += sub.text;
|
|
152
|
+
inner.push(...commands(sub.inner));
|
|
153
|
+
i = sub.end;
|
|
154
|
+
}
|
|
155
|
+
else if (c === '\\') {
|
|
156
|
+
// A backslash before a line break continues the line; before anything else it escapes it.
|
|
157
|
+
if (line[i + 1] !== '\n')
|
|
158
|
+
does += two;
|
|
159
|
+
i += 2;
|
|
160
|
+
}
|
|
161
|
+
else if (c === '#' && (i === start || /\s/.test(line[i - 1]))) {
|
|
162
|
+
// A comment runs to the end of its line.
|
|
163
|
+
while (i < line.length && line[i] !== '\n')
|
|
164
|
+
i++;
|
|
165
|
+
}
|
|
166
|
+
else if (two === '&&' || two === '||' || two === '|&') {
|
|
167
|
+
close(i, i + 2);
|
|
168
|
+
i += 2;
|
|
169
|
+
}
|
|
170
|
+
else if (c === ';' || c === '\n' || (c === '|' && line[i - 1] !== '>')
|
|
171
|
+
|| (c === '&' && !/[<>]/.test(line[i - 1] ?? '') && line[i + 1] !== '>')) {
|
|
172
|
+
close(i, i + 1);
|
|
173
|
+
i++;
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
does += c;
|
|
177
|
+
i++;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
close(line.length, line.length);
|
|
181
|
+
return out;
|
|
182
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "heeded",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "did your coding agent follow its instructions?",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Francesco Musso",
|
|
7
|
+
"repository": { "type": "git", "url": "git+https://github.com/frmusso/heeded.git" },
|
|
8
|
+
"bugs": "https://github.com/frmusso/heeded/issues",
|
|
9
|
+
"homepage": "https://github.com/frmusso/heeded#readme",
|
|
10
|
+
"keywords": ["claude-code", "claude", "coding-agent", "claude-md", "instructions", "transcripts", "cli"],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": { "heeded": "dist/cli.js" },
|
|
13
|
+
"files": ["dist"],
|
|
14
|
+
"engines": { "node": ">=22" },
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "node -e \"fs.rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
|
|
17
|
+
"prepack": "npm run build",
|
|
18
|
+
"typecheck": "tsc",
|
|
19
|
+
"test": "node --test --test-concurrency=1 'tests/**/*.test.ts'"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"@types/node": "22.20.5",
|
|
23
|
+
"typescript": "7.0.2"
|
|
24
|
+
}
|
|
25
|
+
}
|