@thedecipherist/claude-packs 1.0.0 → 1.1.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/CHANGELOG.md +74 -0
- package/README.md +275 -18
- package/bin/claude-packs.mjs +18 -10
- package/configurations/base/config.json +1 -1
- package/configurations/cpp/config.json +2 -1
- package/configurations/full-stack/config.json +2 -1
- package/configurations/go/config.json +3 -1
- package/configurations/node/config.json +3 -1
- package/configurations/python/config.json +3 -1
- package/configurations/rust/config.json +106 -0
- package/configurations/synth-creator/config.json +1 -1
- package/global/hooks/lib/shell-writes.mjs +2 -293
- package/lib/build-queue.mjs +22 -0
- package/lib/claude-md.mjs +4 -2
- package/lib/config-cli.mjs +1 -1
- package/lib/coverage.mjs +10 -2
- package/lib/doctor.mjs +14 -3
- package/lib/fix-plan.mjs +2 -1
- package/lib/found-folders.mjs +25 -0
- package/lib/global.mjs +3 -3
- package/lib/hook-proof.mjs +24 -7
- package/lib/kit-command.mjs +14 -0
- package/lib/layout.mjs +27 -3
- package/lib/logs.mjs +45 -5
- package/lib/new.mjs +1 -1
- package/lib/packs.mjs +4 -2
- package/lib/rule-tests.mjs +42 -0
- package/lib/scan-detect.mjs +41 -5
- package/lib/scan.mjs +5 -2
- package/lib/suggest.mjs +16 -3
- package/lib/sync.mjs +5 -2
- package/lib/workorder.mjs +26 -0
- package/package.json +1 -1
- package/packs/audio-core/hooks/param-id-lock.mjs +59 -16
- package/packs/audio-core/pack.json +5 -0
- package/packs/audio-core/rules/a-control-is-proved-through-its-input.md +26 -0
- package/packs/audio-core/rules/cpu-worst-case-per-block.md +3 -1
- package/packs/audio-core/rules/house-control-behaviours.md +1 -1
- package/packs/audio-core/rules/scoped-no-denormals.md +1 -1
- package/packs/audio-core/rules/straight-on-and-aligned.md +1 -1
- package/packs/audio-core/rules/ui-matches-reference-look.md +2 -0
- package/packs/audio-core/skills/analyse-reference-look/SKILL.md +25 -11
- package/packs/audio-core/skills/prove-control/SKILL.md +2 -0
- package/packs/base/checks/one-walk-per-template.json +1 -1
- package/packs/base/hooks/code-shape.mjs +10 -6
- package/packs/base/hooks/dispatch-once.mjs +58 -21
- package/packs/base/hooks/folder-law.mjs +3 -0
- package/packs/base/hooks/git-guard.mjs +22 -109
- package/packs/base/hooks/lesson-checks.mjs +9 -1
- package/packs/base/hooks/lib/code-shapes.mjs +11 -6
- package/packs/base/hooks/lib/crash-policy.mjs +47 -0
- package/packs/base/hooks/lib/dup-index.mjs +6 -1
- package/packs/base/hooks/lib/dup.mjs +2 -2
- package/packs/base/hooks/lib/event-log.mjs +4 -3
- package/packs/base/hooks/lib/git-adds.mjs +2 -1
- package/packs/base/hooks/lib/git-auth.mjs +44 -0
- package/packs/base/hooks/lib/git-guard-selftest.mjs +93 -0
- package/packs/base/hooks/lib/languages.mjs +5 -2
- package/packs/base/hooks/lib/layout.mjs +6 -2
- package/packs/base/hooks/lib/making-check-selftest.mjs +3 -0
- package/packs/base/hooks/lib/near-copies.mjs +1 -1
- package/packs/base/hooks/lib/program-read.mjs +6 -3
- package/packs/base/hooks/lib/rust-scan.mjs +139 -0
- package/packs/base/hooks/lib/shape-lists.mjs +31 -0
- package/packs/base/hooks/lib/shape-prose.mjs +78 -0
- package/packs/base/hooks/lib/shape.mjs +18 -39
- package/packs/base/hooks/lib/turn.mjs +6 -1
- package/packs/base/hooks/lib/unread-writes.mjs +24 -0
- package/packs/base/hooks/making-check.mjs +12 -7
- package/packs/base/hooks/run-hooks.mjs +15 -7
- package/packs/base/hooks/shape-law.mjs +11 -4
- package/packs/base/hooks/suite-once.mjs +22 -6
- package/packs/base/rules/a-control-is-seen-to-fire.md +1 -1
- package/packs/base/rules/behaviour-lives-with-its-kind.md +1 -1
- package/packs/base/rules/bug-fix-starts-red.md +1 -1
- package/packs/base/rules/field-map-over-if-runs.md +1 -1
- package/packs/base/rules/instances-by-index-identities-by-id.md +2 -2
- package/packs/base/rules/lookup-over-if-chains.md +1 -1
- package/packs/base/rules/plan-and-logs.md +1 -1
- package/packs/base/rules/refusals-are-judged.md +1 -0
- package/packs/base/rules/test-the-template-once.md +2 -1
- package/packs/base/rules/unattended-runs.md +15 -0
- package/packs/base/skills/new-project/SKILL.md +23 -0
- package/packs/base/skills/write-a-workorder/SKILL.md +41 -0
- package/packs/cpp/checks/cpp-find-with-the-library.json +2 -2
- package/packs/go/pack.json +5 -0
- package/packs/hook-proof/rules/a-refusal-is-exit-2.md +2 -2
- package/packs/instrument-shell/rules/render-the-extremes.md +2 -2
- package/packs/midi/checks/position-not-integrated.json +5 -1
- package/packs/postgres/checks/pg-bind-values.json +33 -0
- package/packs/postgres/checks/pg-concurrently-in-txn.json +18 -0
- package/packs/postgres/checks/pg-fk-not-valid.json +18 -0
- package/packs/postgres/checks/pg-index-concurrently.json +18 -0
- package/packs/postgres/checks/pg-no-float-money.json +19 -0
- package/packs/postgres/checks/pg-no-money.json +18 -0
- package/packs/postgres/checks/pg-no-required-column.json +18 -0
- package/packs/postgres/checks/pg-no-volatile-default.json +18 -0
- package/packs/postgres/checks/pg-not-exists.json +18 -0
- package/packs/postgres/checks/pg-timestamptz.json +18 -0
- package/packs/postgres/hooks/db-wipe-guard.mjs +95 -0
- package/packs/postgres/pack.json +48 -0
- package/packs/postgres/rules/postgres-migrations-never-stop-traffic.md +28 -0
- package/packs/postgres/rules/postgres-one-pool-per-process.md +21 -0
- package/packs/postgres/rules/postgres-queries-are-null-safe-and-bounded.md +26 -0
- package/packs/postgres/rules/postgres-types-say-what-they-hold.md +22 -0
- package/packs/postgres/rules/postgres-values-are-bound.md +130 -0
- package/packs/postgres/skills/postgres-safe-migration/SKILL.md +45 -0
- package/packs/reverse-engineering/rules/addresses-name-their-space.md +1 -0
- package/packs/reverse-engineering/rules/reproduction-or-original.md +1 -1
- package/packs/reverse-engineering/skills/gather-sources/SKILL.md +1 -0
- package/packs/rust/checks/rust-allow-needs-reason.json +16 -0
- package/packs/rust/checks/rust-blocking-in-async.json +32 -0
- package/packs/rust/checks/rust-if-chain-is-a-match.json +32 -0
- package/packs/rust/checks/rust-static-mut.json +32 -0
- package/packs/rust/checks/rust-string-as-error.json +34 -0
- package/packs/rust/checks/rust-stub-left-in.json +32 -0
- package/packs/rust/checks/rust-unsafe-needs-safety.json +31 -0
- package/packs/rust/checks/rust-unwrap-in-library.json +34 -0
- package/packs/rust/checks/rust-wildcard-dependency.json +16 -0
- package/packs/rust/clippy.toml +5 -0
- package/packs/rust/pack.json +27 -0
- package/packs/rust/rules/rust-async-never-blocks.md +20 -0
- package/packs/rust/rules/rust-borrow-before-you-clone.md +19 -0
- package/packs/rust/rules/rust-errors-are-returned.md +22 -0
- package/packs/rust/rules/rust-lints-are-the-gate.md +23 -0
- package/packs/rust/rules/rust-repeats-become-templates.md +28 -0
- package/packs/rust/rules/rust-unsafe-is-proved.md +21 -0
- package/packs/rust/skills/rust-extract-template/SKILL.md +50 -0
- package/packs/rust/skills/rust-port/SKILL.md +22 -0
- package/packs/rust/skills/rust-review/SKILL.md +24 -0
- package/packs/rust/tools/rust-lints.toml +19 -0
- package/packs/sound-design/skills/bounce-a-plugin/SKILL.md +40 -0
- package/packs/sound-design/tools/vst3host/build.sh +73 -0
- package/packs/sound-design/tools/vst3host/host_events.h +101 -0
- package/packs/sound-design/tools/vst3host/options.h +172 -0
- package/packs/sound-design/tools/vst3host/render.cpp +245 -0
- package/packs/sound-design/tools/vst3host/session.h +241 -0
- package/packs/synth-creator/hooks/build-guard.mjs +7 -7
- package/packs/synth-creator/hooks/protect-vendor.mjs +2 -1
- package/packs/synth-creator/hooks/stage-guard.mjs +1 -1
- package/packs/synth-creator/rules/timing-from-synthcore.md +1 -1
- package/packs/synth-creator/skills/build-through-the-queue/SKILL.md +4 -2
- package/packs/synth-creator/tools/buildq.mjs +74 -0
- package/prompts/workorder/PROMPT.md +53 -0
- package/prompts/workorder/run.sh +36 -0
- package/prompts/workorder/workorder.md +59 -0
- package/global/hooks/lib/program-read.mjs +0 -196
- package/global/hooks/lib/program-writes.mjs +0 -177
|
@@ -104,6 +104,9 @@ export function selftest(hookUrl) {
|
|
|
104
104
|
['a brief in a notes folder naming a concept (KWB-19)', turn([W('notes/brief.md', '# Brief: the mixer, every slot\n')]), 0],
|
|
105
105
|
['source using a concept the making line does not name (KWB-19)', turn([W('src/mix/level.cpp', mixerCode)]), 2],
|
|
106
106
|
['mixer code with a making line naming the mixer', turn([W('src/mix/level.cpp', mixerCode)], { making: 'making: the mixer level; concepts: mixer (one); reuse: none' }), 0],
|
|
107
|
+
['what a turn landed is over (KF-34)', turn([W('src/mix/level.cpp', mixerCode), use('Bash', { command: 'git merge --ff-only work/s1' })]), 0],
|
|
108
|
+
['a write after the landing is judged (KF-34)', turn([use('Bash', { command: 'git merge --ff-only work/s1' }), W('src/mix/level.cpp', mixerCode)]), 2],
|
|
109
|
+
['the shape word the rule asks for is no concept (KF-24)', turn([W('src/mix/level.cpp', mixerCode)], { making: 'making: x; concepts: slot, many; mixer, one; reuse: none' }), 0],
|
|
107
110
|
];
|
|
108
111
|
return verdicts('making-check', cases);
|
|
109
112
|
}
|
|
@@ -24,7 +24,7 @@ export function fingerprint(text, ext) {
|
|
|
24
24
|
export function definesOf(text, ext) {
|
|
25
25
|
const lang = languageOf(ext);
|
|
26
26
|
if (!lang?.define) return [];
|
|
27
|
-
const code = lang.blank(text);
|
|
27
|
+
const code = (lang.ownDefinitions ?? ((c) => c))(lang.blank(text));
|
|
28
28
|
return [...code.matchAll(lang.define)].map((m) => [m.slice(1).find(Boolean), code.slice(0, m.index).split('\n').length]).filter(([name]) => name);
|
|
29
29
|
}
|
|
30
30
|
|
|
@@ -114,6 +114,9 @@ function chain(expr) {
|
|
|
114
114
|
|
|
115
115
|
const str = (v) => (v && (v.t === 'str' || v.t === 'path') ? v.v : null);
|
|
116
116
|
const joinPaths = (parts) => (parts.some((p) => p === null) ? null : parts.reduce((a, b) => (b.startsWith('/') ? b : a ? `${a.replace(/\/$/, '')}/${b}` : b), ''));
|
|
117
|
+
// A path made of a part the reader cannot work out (a helper's own parameter) is unknown, never a path of null (KF-40: read_text on one
|
|
118
|
+
// crashed sixteen hooks).
|
|
119
|
+
const pathOf = (v) => (v === null ? null : { t: 'path', v });
|
|
117
120
|
const PATH_FNS = /^(?:(?:pathlib\.)?(?:Pure)?(?:Posix)?Path|os\.path\.join|path\.join|path\.resolve|join|resolve|File\.join|os\.path\.expanduser|os\.path\.abspath|path\.normalize)$/;
|
|
118
121
|
const READS = /^(?:(?:fs|fsp|fs\.promises)\.)?readFileSync$|^File\.read$|^IO\.read$/;
|
|
119
122
|
|
|
@@ -129,7 +132,7 @@ export function evaluate(expr, ctx) {
|
|
|
129
132
|
const slash = splitTop(e, '/');
|
|
130
133
|
if (slash.length > 1) {
|
|
131
134
|
const vs = slash.map((p) => evaluate(p, ctx));
|
|
132
|
-
return vs[0]?.t === 'path' ?
|
|
135
|
+
return vs[0]?.t === 'path' ? pathOf(joinPaths(vs.map(str))) : null;
|
|
133
136
|
}
|
|
134
137
|
}
|
|
135
138
|
const c = chain(e);
|
|
@@ -166,7 +169,7 @@ function callFn(name, args, ctx) {
|
|
|
166
169
|
const vs = args.filter((a) => !/^\w+\s*=[^=]/.test(a)).map((a) => evaluate(a, ctx));
|
|
167
170
|
if (/(?:^|\.)Path\.home$/.test(name)) return process.env.HOME ? { t: 'path', v: process.env.HOME } : null;
|
|
168
171
|
if (/(?:^|\.)Path\.cwd$|^process\.cwd$|^os\.getcwd$/.test(name)) return { t: 'path', v: ctx.at('.') };
|
|
169
|
-
if (PATH_FNS.test(name)) return vs.length ?
|
|
172
|
+
if (PATH_FNS.test(name)) return vs.length ? pathOf(joinPaths(vs.map(str))) : null;
|
|
170
173
|
if (READS.test(name) || /^(?:io\.|codecs\.)?open$/.test(name)) {
|
|
171
174
|
const p = str(vs[0]);
|
|
172
175
|
if (p === null) return null;
|
|
@@ -179,7 +182,7 @@ function callFn(name, args, ctx) {
|
|
|
179
182
|
function callMethod(base, m, args, ctx) {
|
|
180
183
|
const vs = args.map((a) => evaluate(a, ctx));
|
|
181
184
|
if (['expanduser', 'resolve', 'absolute'].includes(m)) return base.t === 'path' ? base : null;
|
|
182
|
-
if (m === 'joinpath') return
|
|
185
|
+
if (m === 'joinpath') return pathOf(joinPaths([base.v, ...vs.map(str)]));
|
|
183
186
|
if ((m === 'read_text' && base.t === 'path') || (m === 'read' && base.t === 'file')) {
|
|
184
187
|
ctx.reads?.add(ctx.at(base.v));
|
|
185
188
|
return { t: 'str', v: ctx.readText(ctx.at(base.v)) ?? '' };
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Rust's own reading (KWB-75). C's blanker takes a lifetime for a char literal and blanks the rest of its line, and a generic blanker takes '"' for a
|
|
2
|
+
// string opening. Nested block comments and raw strings have no form in either. Offsets are kept, so a match's line is the file's line.
|
|
3
|
+
// blankRust(src) comments go; a string or char literal keeps two quotes, so it stays one token
|
|
4
|
+
// withoutTests(code) on blanked code: what `#[cfg(test)]` or a `#[test]` attribute marks goes too, since Rust's unit tests live in the file they test
|
|
5
|
+
// ownDefinitions(code) on blanked code: tests, impls and traits go, leaving the free functions
|
|
6
|
+
// lookupMatches(text) every `match` that is a table of literals on one enum, for dispatch-once
|
|
7
|
+
const spaces = (s) => s.replace(/[^\n]/g, ' ');
|
|
8
|
+
const quoted = (s) => (s.length >= 2 ? `"${spaces(s.slice(1, -1))}"` : spaces(s));
|
|
9
|
+
|
|
10
|
+
function commentEnd(src, start) {
|
|
11
|
+
let depth = 1;
|
|
12
|
+
let i = start + 2;
|
|
13
|
+
while (i < src.length && depth > 0) {
|
|
14
|
+
if (src.startsWith('/*', i)) { depth += 1; i += 2; }
|
|
15
|
+
else if (src.startsWith('*/', i)) { depth -= 1; i += 2; }
|
|
16
|
+
else i += 1;
|
|
17
|
+
}
|
|
18
|
+
return i;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function stringEnd(src, start) {
|
|
22
|
+
let i = start + 1;
|
|
23
|
+
while (i < src.length && src[i] !== '"') i += src[i] === '\\' ? 2 : 1;
|
|
24
|
+
return Math.min(i + 1, src.length);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// r"..", r#".."#, br#".."#, cr#".."#: nothing inside escapes, and the hashes close it. Returns where the opening quote is, and the end, or null.
|
|
28
|
+
function rawString(src, i) {
|
|
29
|
+
if (/\w/.test(src[i - 1] ?? '')) return null;
|
|
30
|
+
const open = src.slice(i, i + 80).match(/^(?:b|c)?r(#*)"/);
|
|
31
|
+
if (!open) return null;
|
|
32
|
+
const close = src.indexOf(`"${open[1]}`, i + open[0].length);
|
|
33
|
+
return { quote: i + open[0].length - 1, end: close === -1 ? src.length : close + 1 + open[1].length };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A quote opens a char literal only when one character, or one escape, then closes it; otherwise it is a lifetime or a label.
|
|
37
|
+
function charEnd(src, i) {
|
|
38
|
+
if (src[i + 1] === '\\') { const e = src.indexOf("'", i + 3); return e === -1 ? null : e + 1; }
|
|
39
|
+
const one = String.fromCodePoint(src.codePointAt(i + 1) ?? 32);
|
|
40
|
+
return src[i + 1 + one.length] === "'" ? i + 2 + one.length : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function blankRust(src) {
|
|
44
|
+
let out = '';
|
|
45
|
+
let i = 0;
|
|
46
|
+
const take = (end, shown) => { out += shown(src.slice(i, end)); i = end; };
|
|
47
|
+
while (i < src.length) {
|
|
48
|
+
const raw = src[i] === 'r' || src[i] === 'b' || src[i] === 'c' ? rawString(src, i) : null;
|
|
49
|
+
const char = src[i] === "'" ? charEnd(src, i) : null;
|
|
50
|
+
if (src.startsWith('//', i)) { const e = src.indexOf('\n', i); take(e === -1 ? src.length : e, spaces); }
|
|
51
|
+
else if (src.startsWith('/*', i)) take(commentEnd(src, i), spaces);
|
|
52
|
+
else if (raw) { out += spaces(src.slice(i, raw.quote)); i = raw.quote; take(raw.end, quoted); }
|
|
53
|
+
else if (src[i] === '"') take(stringEnd(src, i), quoted);
|
|
54
|
+
else if (char) take(char, quoted);
|
|
55
|
+
else { out += src[i]; i += 1; }
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TEST_MARK = /#\[\s*(?:cfg\s*\(\s*(?:all\s*\(\s*)?test\b[^\]]*\)|(?:\w+::)*test\b[^\]]*)\s*\]/g;
|
|
61
|
+
|
|
62
|
+
// The end of the item an attribute marks: its closing brace, or the `;` of an item with no body.
|
|
63
|
+
function itemEnd(code, start) {
|
|
64
|
+
let round = 0;
|
|
65
|
+
for (let i = start; i < code.length; i += 1) {
|
|
66
|
+
const c = code[i];
|
|
67
|
+
if (c === '(' || c === '[') round += 1;
|
|
68
|
+
else if (c === ')' || c === ']') round -= 1;
|
|
69
|
+
else if (c === ';' && round === 0) return i + 1;
|
|
70
|
+
else if (c === '{' && round === 0) {
|
|
71
|
+
let depth = 0;
|
|
72
|
+
for (let j = i; j < code.length; j += 1) {
|
|
73
|
+
if (code[j] === '{') depth += 1;
|
|
74
|
+
else if (code[j] === '}' && --depth === 0) return j + 1;
|
|
75
|
+
}
|
|
76
|
+
return code.length;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return code.length;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function blankItems(code, mark) {
|
|
83
|
+
let out = '';
|
|
84
|
+
let at = 0;
|
|
85
|
+
for (const m of code.matchAll(mark)) {
|
|
86
|
+
if (m.index < at) continue;
|
|
87
|
+
const end = itemEnd(code, m.index + m[0].length);
|
|
88
|
+
out += code.slice(at, m.index) + spaces(code.slice(m.index, end));
|
|
89
|
+
at = end;
|
|
90
|
+
}
|
|
91
|
+
return out + code.slice(at);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const withoutTests = (code) => blankItems(code, TEST_MARK);
|
|
95
|
+
|
|
96
|
+
// A method lives in its type's namespace: `from_str` on a second type is the trait doing its job, and `is_none` on two types is two methods (KWB-81).
|
|
97
|
+
// Only a free function can be made again by name. A method copied is caught by its statements, whatever it is called.
|
|
98
|
+
const TYPE_ITEM = /^[ \t]*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?(?:impl|trait)\b[^{;]*(?=\{)/gm;
|
|
99
|
+
export const ownDefinitions = (code) => blankItems(withoutTests(code), TYPE_ITEM);
|
|
100
|
+
|
|
101
|
+
// What a `match` body holds, arm by arm: the patterns, and what the arm gives.
|
|
102
|
+
function armsOf(body) {
|
|
103
|
+
const arms = [];
|
|
104
|
+
let depth = 0;
|
|
105
|
+
let from = 0;
|
|
106
|
+
let head = null;
|
|
107
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
108
|
+
const c = body[i];
|
|
109
|
+
if ('({['.includes(c)) depth += 1;
|
|
110
|
+
else if (')}]'.includes(c)) depth -= 1;
|
|
111
|
+
else if (depth === 0 && head === null && body.startsWith('=>', i)) { head = body.slice(from, i); from = i + 2; i += 1; }
|
|
112
|
+
else if (depth === 0 && head !== null && c === ',') { arms.push({ head, value: body.slice(from, i).trim() }); head = null; from = i + 1; }
|
|
113
|
+
}
|
|
114
|
+
if (head !== null) arms.push({ head, value: body.slice(from).trim() });
|
|
115
|
+
return arms;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const LITERAL = /^(?:"\s*"|-?\d[\w.]*)$/; // a table of true and false is a predicate, not data about the kind
|
|
119
|
+
const MIN_VARIANTS = 3;
|
|
120
|
+
const NOT_A_KIND = new Set(['Self', 'Option', 'Result', 'Ordering']); // the standard library's own enums, and an enum inside its own impl
|
|
121
|
+
|
|
122
|
+
// Every `match` that is only a lookup table: each variant of one enum to a plain value. `owned`: this file declares the enum or has its impl.
|
|
123
|
+
// A match that does real work on an enum is good Rust, in any number of files: the compiler holds every one to every variant. A lookup table is
|
|
124
|
+
// data about the kind, and belongs with the kind. Measured on eight crates: a rule on every match would have refused 7 to 12 in 100 of them.
|
|
125
|
+
export function lookupMatches(text) {
|
|
126
|
+
const code = withoutTests(blankRust(text));
|
|
127
|
+
const owns = new Set([...code.matchAll(/\b(?:enum|impl(?:<[^>{]*>)?)\s+([A-Z]\w*)/g)].map((m) => m[1]));
|
|
128
|
+
const found = [];
|
|
129
|
+
for (const m of code.matchAll(/\bmatch\s[^{;]+?(?=\{)/g)) {
|
|
130
|
+
const open = m.index + m[0].length;
|
|
131
|
+
const arms = armsOf(code.slice(open + 1, itemEnd(code, open) - 1));
|
|
132
|
+
if (!arms.length || !arms.every((a) => LITERAL.test(a.value))) continue;
|
|
133
|
+
const paths = arms.flatMap((a) => [...a.head.matchAll(/\b([A-Z]\w*)::([A-Z]\w*)/g)]);
|
|
134
|
+
const kind = paths[0]?.[1];
|
|
135
|
+
const labels = new Set(paths.filter((p) => p[1] === kind).map((p) => p[2]));
|
|
136
|
+
if (kind && labels.size >= MIN_VARIANTS && !NOT_A_KIND.has(kind)) found.push({ kind, labels, owned: owns.has(kind) });
|
|
137
|
+
}
|
|
138
|
+
return found;
|
|
139
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// shape-lists: consecutive lines (or ;/, separated pieces of one line) that differ only in one number running over every instance: a hand-listed set, one line
|
|
2
|
+
// per instance. Split out of shape.mjs at its line limit.
|
|
3
|
+
// handLists(lines, c, out, seen): adds a finding to out for each such set of the concept c
|
|
4
|
+
import { classify, isExampleLine, named, parts } from './shape.mjs';
|
|
5
|
+
|
|
6
|
+
const templatesOf = (t) => [...t.matchAll(/(?<![\w.])\d+(?![\w.])/g)].map((m) => ({ tpl: t.slice(0, m.index) + '#' + t.slice(m.index + m[0].length), v: Number(m[0]) }));
|
|
7
|
+
|
|
8
|
+
export function handLists(lines, c, out, seen) {
|
|
9
|
+
if (!c.count || c.count < 2 || c.shape !== 'many') return;
|
|
10
|
+
const mentions = (t) => parts(t).some((p) => c.words.includes(p)) || c.arrays.some((a) => named(t, a));
|
|
11
|
+
const runOver = (pieces, at, whole = null) => {
|
|
12
|
+
for (let i = 0; i + c.count <= pieces.length; i += 1) {
|
|
13
|
+
for (const first of templatesOf(pieces[i].trim())) {
|
|
14
|
+
if (first.v > 1 || !mentions(whole ?? first.tpl)) continue;
|
|
15
|
+
let ok = true;
|
|
16
|
+
for (let k = 1; k < c.count && ok; k += 1) ok = templatesOf(pieces[i + k].trim()).some((t) => t.tpl === first.tpl && t.v === first.v + k);
|
|
17
|
+
if (ok) return at(i);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
};
|
|
22
|
+
const lineNo = runOver(lines, (i) => i);
|
|
23
|
+
if (lineNo !== null && !isExampleLine(lines[lineNo]) && !seen.has(`${lineNo}|${c.name}`)) {
|
|
24
|
+
out.push({ line: lineNo + 1, text: lines[lineNo].trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one line per ${c.name}: write one loop over every ${c.name}, or the generator` });
|
|
25
|
+
}
|
|
26
|
+
lines.forEach((l, i) => {
|
|
27
|
+
if (seen.has(`${i}|${c.name}`) || isExampleLine(l) || classify(l, c, null)) return;
|
|
28
|
+
const pieces = l.split(/[;,]/);
|
|
29
|
+
if (pieces.length >= c.count && runOver(pieces, () => true, l)) out.push({ line: i + 1, text: l.trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one piece per ${c.name}: write one loop over every ${c.name}, or the generator` });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// shape-prose: what in a line is English, arithmetic or a name, and so names no instance (KF-13, KF-17, KF-20, KF-28, KF-36: 14 of shape-law's 25 refusals on the
|
|
2
|
+
// MPX 500 were wrong, on words like these). Each test is narrow on purpose: the same line with the bare form stays refused (test/kf-refusals.test.mjs holds both).
|
|
3
|
+
// blankMessages(line, file) -> line with a sentence in a double-quoted string blanked, same length; a short key ("knob 1") stays
|
|
4
|
+
// isFirstMatch(line, m) "the first program with this name", "each bank's first program", the words "first program" in quotes
|
|
5
|
+
// isSourceRole(line, m) a comment quoting a role a source gives: "[MAN] Edit knob 1 selects"
|
|
6
|
+
// isKnownName(role, word, shape) "EDIT knob", when editKnob is a concept or a listed selector
|
|
7
|
+
// isLongerName(line, end) "MIDI Program Change": the word starts a longer proper name
|
|
8
|
+
// isSum(before) "x + 1 == program": the number is an operand, not what is compared
|
|
9
|
+
// isLibraryCall(line, at, shape) juce::MidiMessage::programChange (1, ...): a library's call, or one on a listed value type
|
|
10
|
+
// declaredAmount(root, name, text) a call whose declared first parameter is an amount: turnProgram (int steps, ...)
|
|
11
|
+
import { execFileSync } from 'node:child_process';
|
|
12
|
+
import { extname } from 'node:path';
|
|
13
|
+
import { languageOf } from './languages.mjs';
|
|
14
|
+
|
|
15
|
+
const SENTENCE_WORDS = 3;
|
|
16
|
+
const LIBRARIES = new Set(['juce', 'std', 'boost', 'absl', 'Qt']);
|
|
17
|
+
const AMOUNT = /^(?:steps?|detents?|clicks?|notches|ticks?|amount|delta|by|count|n|offset|increment|distance)$/i;
|
|
18
|
+
const SOURCE_FILES = ['*.h', '*.hh', '*.hpp', '*.c', '*.cc', '*.cpp', '*.mm', '*.js', '*.mjs', '*.ts', '*.tsx', '*.py', '*.go', '*.rs', '*.java', '*.cs', '*.swift', '*.kt'];
|
|
19
|
+
|
|
20
|
+
export function blankMessages(line, file) {
|
|
21
|
+
if (!languageOf(extname(file ?? ''))) return line;
|
|
22
|
+
return line.replace(/"(?:[^"\\]|\\.)*"/g, (s) => (s.trim().split(/\s+/).length >= SENTENCE_WORDS ? ' '.repeat(s.length) : s));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isFirstMatch(line, m) {
|
|
26
|
+
if (!/^first\b/i.test(m[0])) return false;
|
|
27
|
+
const before = line.slice(0, m.index);
|
|
28
|
+
const after = line.slice(m.index + m[0].length);
|
|
29
|
+
if (before.endsWith('"') && after.startsWith('"')) return true;
|
|
30
|
+
return /(?:'s|\b[Aa]|\b[Ee]ach|\b[Ee]very|\b[Aa]ny)\s+$/.test(before) || /^(?:'s)?\s+(?:with|that|which|whose|where|matching|found|a|an)\b/.test(after);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isSourceRole(line, m) {
|
|
34
|
+
const isComment = /^\s*(?:\/\/|\/\*|\*|#)/.test(line);
|
|
35
|
+
return isComment && /\[[A-Z]{2,6}\]/.test(line.slice(0, m.index)) && /^\s+[a-z]+s\b/.test(line.slice(m.index + m[0].length));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const named = new WeakMap();
|
|
39
|
+
function namesOf(shape) {
|
|
40
|
+
if (!named.has(shape)) {
|
|
41
|
+
const rows = Object.values(shape.concepts);
|
|
42
|
+
named.set(shape, new Set(rows.flatMap((c) => [c.name, ...c.selectors, ...c.constants]).map((n) => n.toLowerCase())));
|
|
43
|
+
}
|
|
44
|
+
return named.get(shape);
|
|
45
|
+
}
|
|
46
|
+
export const isKnownName = (role, word, shape) => namesOf(shape).has(`${role}${word}`.toLowerCase());
|
|
47
|
+
|
|
48
|
+
export const isLongerName = (line, end) => /^\s+[A-Z][a-z]/.test(line.slice(end));
|
|
49
|
+
export const isSum = (before) => /[+\-*/%]\s*$/.test(before);
|
|
50
|
+
|
|
51
|
+
export function isLibraryCall(line, at, shape) {
|
|
52
|
+
const path = line.slice(0, at).match(/([A-Za-z_]\w*)::(?:[A-Za-z_]\w*::)*$/);
|
|
53
|
+
if (!path) return false;
|
|
54
|
+
const last = line.slice(0, at).match(/([A-Za-z_]\w*)::$/)?.[1];
|
|
55
|
+
return LIBRARIES.has(path[1]) || shape.values?.has(last);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// The first parameter of each declaration of `name`, from the text being written and the project's files.
|
|
59
|
+
function firstParameters(root, name, text) {
|
|
60
|
+
const shape = new RegExp(`\\b${name}\\s*\\(\\s*(?:const\\s+)?(?:[\\w:<>]+[\\s&*]+)+(\\w+)\\s*[,)=]`, 'g');
|
|
61
|
+
let found = '';
|
|
62
|
+
try {
|
|
63
|
+
found = execFileSync('git', ['-C', root, 'grep', '--untracked', '-hE', `\\b${name}[[:space:]]*\\(`, '--', ...SOURCE_FILES], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 });
|
|
64
|
+
} catch {
|
|
65
|
+
found = ''; // no match, or no repository: nothing declares it, so the call stays an instance
|
|
66
|
+
}
|
|
67
|
+
return [...`${text}\n${found}`.matchAll(shape)].map((m) => m[1]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const amounts = new Map();
|
|
71
|
+
export function declaredAmount(root, name, text) {
|
|
72
|
+
const key = `${root}\0${name}`;
|
|
73
|
+
if (!amounts.has(key)) {
|
|
74
|
+
const params = firstParameters(root, name, text);
|
|
75
|
+
amounts.set(key, params.length > 0 && params.every((p) => AMOUNT.test(p)));
|
|
76
|
+
}
|
|
77
|
+
return amounts.get(key);
|
|
78
|
+
}
|
|
@@ -22,6 +22,8 @@ import { logRefusal } from './hook-log.mjs';
|
|
|
22
22
|
import { readFileSync } from 'node:fs';
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { matchesAny } from './glob.mjs';
|
|
25
|
+
import { blankMessages, isFirstMatch, isKnownName, isLibraryCall, isLongerName, isSourceRole, isSum } from './shape-prose.mjs';
|
|
26
|
+
import { handLists } from './shape-lists.mjs';
|
|
25
27
|
|
|
26
28
|
export const SHAPE_FILE = 'design/shape.json';
|
|
27
29
|
export const ASK = 'example, rule, or constant?';
|
|
@@ -113,7 +115,7 @@ export function isExampleLine(line) {
|
|
|
113
115
|
return OPENS.test(rest) || EXAMPLE_CELL.test(line);
|
|
114
116
|
}
|
|
115
117
|
|
|
116
|
-
const named = (line, name) => new RegExp(`(?<![\\w])${esc(name)}(?![\\w])`).test(line);
|
|
118
|
+
export const named = (line, name) => new RegExp(`(?<![\\w])${esc(name)}(?![\\w])`).test(line);
|
|
117
119
|
export function classify(line, concept, shape) {
|
|
118
120
|
const c = typeof concept === 'string' ? shape?.concepts?.[concept] : concept;
|
|
119
121
|
if (isExampleLine(line)) return 'example';
|
|
@@ -165,15 +167,17 @@ const ORDINALS = ['first', 'second', 'third', 'fourth', 'fifth', 'sixth', 'seven
|
|
|
165
167
|
const NOT_ROLES = new Set(['ALL', 'EACH', 'EVERY', 'ANY', 'ONE', 'NEW', 'THE', 'NO', 'THIS', 'THAT', 'SAME', 'OTHER', 'OWN', 'NEXT', 'MANY', 'NOT', 'AND', 'OR', 'TODO', 'NOTE', 'FIXME', 'IN', 'ON', 'OF', 'TO', 'A', 'AN', 'EMPTY', 'SELECTED', 'OPEN', 'CURRENT',
|
|
166
168
|
'PASS', 'FAIL', 'FAILED', 'RED', 'GREEN', 'OK', 'SKIP', 'WARN', 'ERROR']); // a gate's verdict before the word ("FAIL slot 2"; KWB-15)
|
|
167
169
|
|
|
168
|
-
function lineHits(line, c, shape, aliases, inSwitch) {
|
|
170
|
+
function lineHits(line, c, shape, aliases, inSwitch, file) {
|
|
169
171
|
const hits = [];
|
|
170
|
-
const add = (instance, why) => hits.push({ instance: String(instance), why });
|
|
172
|
+
const add = (instance, why, call) => hits.push({ instance: String(instance), why, call });
|
|
173
|
+
const words = blankMessages(line, file); // what is read as words: a sentence in a string is text (KF-17)
|
|
171
174
|
for (const re of c.patterns) for (const m of line.matchAll(re)) add(m[1] ?? m[0], `matches the pattern ${re.source}`);
|
|
172
175
|
const W = c.words.map(esc).sort((a, b) => b.length - a.length).join('|');
|
|
173
|
-
const num = new RegExp(`(?<![A-Za-z0-9])(${W})(?:[ _#-]|\\s+(?:no\\.?|number|#)\\s*)?(\\d+)(
|
|
176
|
+
const num = new RegExp(`(?<![A-Za-z0-9])(${W})(?:[ _#-]|\\s+(?:no\\.?|number|#)\\s*)?(\\d+)(?![\\dxX])((?:\\s*(?:-|–|\\.\\.|to|through)\\s*)(\\d+))?`, 'gi'); // 0x18000 is an address (KF-13)
|
|
174
177
|
// A multi-word all-caps label ("AIM EFFECTS", "NOTES MONO") names a control, not an instance (KWB-22): the caps word before it.
|
|
175
178
|
const labelBefore = (at) => { const w = /([A-Z][A-Z0-9]+)\s+$/.exec(line.slice(0, at)); return w !== null && !NOT_ROLES.has(w[1]); };
|
|
176
|
-
for (const m of
|
|
179
|
+
for (const m of words.matchAll(num)) {
|
|
180
|
+
if (isSourceRole(line, m)) continue; // KF-28
|
|
177
181
|
if (c.count !== null && !m[3] && Number(m[2]) > c.count) continue; // past the count: a value, not an instance (KWB-22)
|
|
178
182
|
if (m[1] === m[1].toUpperCase() && labelBefore(m.index)) continue; // AIM EFFECTS 100: a control's value
|
|
179
183
|
if (m[3] && c.count !== null) {
|
|
@@ -186,13 +190,15 @@ function lineHits(line, c, shape, aliases, inSwitch) {
|
|
|
186
190
|
for (const m of line.matchAll(camel)) add(m[2], `names it by number in an identifier ("${m[0]}")`);
|
|
187
191
|
const S = c.single.map(esc).join('|');
|
|
188
192
|
const ord = new RegExp(`(?<![A-Za-z0-9])(${ORDINALS.join('|')}|\\d+(?:st|nd|rd|th))[\\s_-]*(${S})(?![a-z])`, 'gi');
|
|
189
|
-
for (const m of
|
|
193
|
+
for (const m of words.matchAll(ord)) {
|
|
194
|
+
if (isFirstMatch(words, m)) continue; // KF-17
|
|
190
195
|
const o = m[1].toLowerCase();
|
|
191
196
|
add(ORDINALS.includes(o) ? ORDINALS.indexOf(o) + 1 : parseInt(o, 10), `names it by ordinal ("${m[0].trim()}")`);
|
|
192
197
|
}
|
|
193
198
|
if (c.shape === 'many') {
|
|
194
199
|
const role = new RegExp(`(?<![A-Za-z0-9_])([A-Z][A-Z0-9]+)\\s+(${c.single.flatMap((w) => [esc(w), esc(cap(w))]).join('|')})(?![A-Za-z0-9_])`, 'g');
|
|
195
|
-
|
|
200
|
+
const notARole = (m) => NOT_ROLES.has(m[1]) || c.words.includes(m[1].toLowerCase()) || c.names.has(m[1]) || labelBefore(m.index) || isKnownName(m[1], m[2], shape) || isLongerName(words, m.index + m[0].length);
|
|
201
|
+
for (const m of words.matchAll(role)) if (!notARole(m)) add(m[1], `names it by a role ("${m[0]}"): the instance is a job or a value, not a ${c.name}`);
|
|
196
202
|
}
|
|
197
203
|
for (const m of line.matchAll(/([A-Za-z_]\w*)\s*(?:\[\s*(\d+)\s*\]|\.at\(\s*(\d+)\s*\))/g)) {
|
|
198
204
|
const lead = line.slice(0, m.index).trimEnd();
|
|
@@ -203,43 +209,16 @@ function lineHits(line, c, shape, aliases, inSwitch) {
|
|
|
203
209
|
}
|
|
204
210
|
const EXPR = '[A-Za-z_]\\w*(?:(?:\\.|->|::)[A-Za-z_]\\w*|\\(\\)|\\[\\w*\\])*';
|
|
205
211
|
for (const m of line.matchAll(new RegExp(`(${EXPR})\\s*(?:===?|!==?)\\s*(\\d+)(?![\\w.])`, 'g'))) if (conceptOfExpr(m[1], shape, aliases) === c.name) add(m[2], `compares a ${c.name} with a number ("${m[0]}")`);
|
|
206
|
-
for (const m of line.matchAll(new RegExp(`(?<![\\w.])(\\d+)\\s*(?:===?|!==?)\\s*(${EXPR})`, 'g'))) if (conceptOfExpr(m[2], shape, aliases) === c.name) add(m[1], `compares a ${c.name} with a number ("${m[0]}")`);
|
|
212
|
+
for (const m of line.matchAll(new RegExp(`(?<![\\w.])(\\d+)\\s*(?:===?|!==?)\\s*(${EXPR})`, 'g'))) if (conceptOfExpr(m[2], shape, aliases) === c.name && !isSum(line.slice(0, m.index))) add(m[1], `compares a ${c.name} with a number ("${m[0]}")`);
|
|
207
213
|
if (inSwitch === c.name) for (const m of line.matchAll(/\bcase\s+(\d+)\s*:/g)) add(m[1], `a case for one ${c.name} ("${m[0]}")`);
|
|
208
214
|
for (const m of line.matchAll(/(?<![\w])([A-Za-z_]\w*)\s*\(\s*(\d+)\s*[,)]/g)) {
|
|
209
215
|
const ps = parts(m[1]);
|
|
210
|
-
if (
|
|
216
|
+
if (isLibraryCall(line, m.index, shape)) continue; // KF-20
|
|
217
|
+
if (ps.some((p) => c.single.includes(p)) && !ps.some((p) => c.plural.has(p) || COUNTISH.has(p))) add(m[2], `drives one ${c.name} by number ("${m[0].replace(/[,)]$/, '')}")`, m[1]);
|
|
211
218
|
}
|
|
212
219
|
return hits;
|
|
213
220
|
}
|
|
214
221
|
|
|
215
|
-
// Consecutive lines (or ;/, separated pieces of one line) that differ only in one number running over every instance:
|
|
216
|
-
// a hand-listed set, one line per instance.
|
|
217
|
-
function handLists(lines, c, out, seen) {
|
|
218
|
-
if (!c.count || c.count < 2 || c.shape !== 'many') return;
|
|
219
|
-
const mentions = (t) => parts(t).some((p) => c.words.includes(p)) || c.arrays.some((a) => named(t, a));
|
|
220
|
-
const templates = (t) => [...t.matchAll(/(?<![\w.])\d+(?![\w.])/g)].map((m) => ({ tpl: t.slice(0, m.index) + '#' + t.slice(m.index + m[0].length), v: Number(m[0]) }));
|
|
221
|
-
const runOver = (pieces, at, whole = null) => {
|
|
222
|
-
for (let i = 0; i + c.count <= pieces.length; i += 1) {
|
|
223
|
-
for (const first of templates(pieces[i].trim())) {
|
|
224
|
-
if (first.v > 1 || !mentions(whole ?? first.tpl)) continue;
|
|
225
|
-
let ok = true;
|
|
226
|
-
for (let k = 1; k < c.count && ok; k += 1) ok = templates(pieces[i + k].trim()).some((t) => t.tpl === first.tpl && t.v === first.v + k);
|
|
227
|
-
if (ok) return at(i);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
return null;
|
|
231
|
-
};
|
|
232
|
-
const lineNo = runOver(lines, (i) => i);
|
|
233
|
-
if (lineNo !== null && !isExampleLine(lines[lineNo]) && !seen.has(`${lineNo}|${c.name}`)) {
|
|
234
|
-
out.push({ line: lineNo + 1, text: lines[lineNo].trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one line per ${c.name}: write one loop over every ${c.name}, or the generator` });
|
|
235
|
-
}
|
|
236
|
-
lines.forEach((l, i) => {
|
|
237
|
-
if (seen.has(`${i}|${c.name}`) || isExampleLine(l) || classify(l, c, null)) return;
|
|
238
|
-
const pieces = l.split(/[;,]/);
|
|
239
|
-
if (pieces.length >= c.count && runOver(pieces, () => true, l)) out.push({ line: i + 1, text: l.trim(), concept: c.name, instance: 'each', why: `a hand-listed set, one piece per ${c.name}: write one loop over every ${c.name}, or the generator` });
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
222
|
export function findInstanceLiterals(text, file, shape, { only = null } = {}) {
|
|
244
223
|
if (!shape || !text) return [];
|
|
245
224
|
const concepts = Object.values(shape.concepts).filter((c) => c.shape === 'many' || (c.shape === 'one' && c.id));
|
|
@@ -259,11 +238,11 @@ export function findInstanceLiterals(text, file, shape, { only = null } = {}) {
|
|
|
259
238
|
// only 'brief': an example or a declared constant read by name excuses it (the number rule's third form), a selector does not
|
|
260
239
|
if (cls === 'example' || (cls === 'constant' && only === 'brief') || (cls && only !== 'example' && only !== 'brief')) continue;
|
|
261
240
|
const once = new Set();
|
|
262
|
-
for (const h of lineHits(line, c, shape, aliases, inSwitch)) {
|
|
241
|
+
for (const h of lineHits(line, c, shape, aliases, inSwitch, file)) {
|
|
263
242
|
if (once.has(h.instance)) continue;
|
|
264
243
|
once.add(h.instance);
|
|
265
244
|
seen.add(`${i}|${c.name}`);
|
|
266
|
-
out.push({ line: i + 1, text: shown[i].trim(), concept: c.name, instance: h.instance, why: h.why });
|
|
245
|
+
out.push({ line: i + 1, text: shown[i].trim(), concept: c.name, instance: h.instance, why: h.why, call: h.call });
|
|
267
246
|
}
|
|
268
247
|
}
|
|
269
248
|
});
|
|
@@ -66,9 +66,14 @@ export function lastTurn(path) {
|
|
|
66
66
|
return { text: text.join('\n'), uses: uses.filter((u) => !refused.has(u.id)) };
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// A landing ends a step. Compaction is no prompt, so one turn can hold a whole run, and its last step's making line says nothing of the steps before: the
|
|
70
|
+
// MPX 500 was sent back for files written hours earlier, at a landing (KF-34). What a turn landed is over; only the writes after its last landing are judged.
|
|
71
|
+
const LANDING = /(?:^|[;&|\n])\s*git\s+(?:-C\s+\S+\s+)?merge\s+(?:\S+\s+)*?--ff-only\b/;
|
|
72
|
+
const sinceLanding = (uses) => uses.slice(uses.findLastIndex((u) => u.name === 'Bash' && LANDING.test(u.input.command ?? '')) + 1);
|
|
73
|
+
|
|
69
74
|
export function writesOfTurn(turn, cwd = process.cwd()) {
|
|
70
75
|
const out = [];
|
|
71
|
-
for (const u of turn.uses) {
|
|
76
|
+
for (const u of sinceLanding(turn.uses)) {
|
|
72
77
|
const i = u.input;
|
|
73
78
|
const at = (p) => resolve(u.cwd ?? cwd, p);
|
|
74
79
|
if (u.name === 'Write' && i.file_path) out.push({ path: at(i.file_path), text: i.content ?? '', tool: u.name });
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// unread-writes: an inline program can write a source file in a way no hook can read, and every content law lets an unknown text through.
|
|
2
|
+
// The MPX 500's edits through a local helper passed no law, and nothing said so (KF-40). When a command passed, this names what it wrote
|
|
3
|
+
// unread, where the author sees it, so a pass is never taken for a judgement.
|
|
4
|
+
// unreadWrites(ev, root) -> '' | JSON for the hook's stdout
|
|
5
|
+
import { extname, relative } from 'node:path';
|
|
6
|
+
import { languageOf } from './languages.mjs';
|
|
7
|
+
import { insideProject } from './project.mjs';
|
|
8
|
+
import { writesOf } from './writes.mjs';
|
|
9
|
+
|
|
10
|
+
const INLINE = /\b(?:python[\d.]*|pypy3?|node|nodejs|bun|perl|ruby)\b[^\n|;&]*(?:\s-[A-Za-z]*[ceE]\s|\s-\s|<<)/;
|
|
11
|
+
const CONTENT_OPS = new Set(['write', 'edit', 'append']);
|
|
12
|
+
|
|
13
|
+
export function unreadWrites(ev, root) {
|
|
14
|
+
const command = ev.tool_input?.command;
|
|
15
|
+
if (ev.tool_name !== 'Bash' || ev.hook_event_name !== 'PreToolUse' || !INLINE.test(command ?? '')) return '';
|
|
16
|
+
const cwd = ev.cwd ?? root;
|
|
17
|
+
const unread = writesOf(ev, cwd).filter((w) => w.after === null && CONTENT_OPS.has(w.op) && languageOf(extname(w.path)) && insideProject(w.path, cwd)).map((w) => relative(cwd, w.path));
|
|
18
|
+
if (!unread.length) return '';
|
|
19
|
+
const files = unread.join(', ');
|
|
20
|
+
return JSON.stringify({
|
|
21
|
+
systemMessage: `not judged: this command writes ${files} in a way the hooks could not read`,
|
|
22
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: `The hooks could not read what this command writes to ${files}, so no content law judged it. Use the Edit or Write tool for source files, where every law applies.` },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -22,12 +22,13 @@ import { copyGlobs, excludeGlobs, FIX, isGenerated, isSource, real } from './lib
|
|
|
22
22
|
import { judgeWrites } from './lib/dup-index.mjs';
|
|
23
23
|
import { matchesAny } from './lib/glob.mjs';
|
|
24
24
|
import { readLayout } from './lib/layout.mjs';
|
|
25
|
-
import { checkoutRoot, insideProject, ROOT } from './lib/project.mjs';
|
|
25
|
+
import { checkoutRoot, claudeRoot, insideProject, ROOT } from './lib/project.mjs';
|
|
26
26
|
import { readShape } from './lib/shape.mjs';
|
|
27
27
|
import { lastTurn, writesOfTurn } from './lib/turn.mjs';
|
|
28
28
|
import { asHook, isMain } from './lib/hook-log.mjs';
|
|
29
29
|
|
|
30
30
|
const MAX_SENDS = 3;
|
|
31
|
+
const NOT_CONCEPTS = new Set(['none', 'no', '-', 'n/a', 'one', 'many', 'ids']);
|
|
31
32
|
// the folder kinds a layout marks as code; a layout that marks none: any source file outside its docs folders
|
|
32
33
|
const CODE_KINDS = new Set(['code', 'engine', 'ui', 'params', 'tests']);
|
|
33
34
|
const NOT_CODE = ['build/**', 'inbox/**', 'docs/**', '**/*.md', '**/*.txt', '**/*.json'];
|
|
@@ -54,7 +55,8 @@ const OVERRIDE = [
|
|
|
54
55
|
];
|
|
55
56
|
|
|
56
57
|
const splitName = (name) => name.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2').replace(/([A-Za-z])(\d)/g, '$1 $2').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
|
|
57
|
-
|
|
58
|
+
// each identifier's parts, and the identifier whole: a concept named editPage is the word editpage, not the words edit and page (KF-19, KF-39)
|
|
59
|
+
const partsOf = (text) => new Set((text.match(/[A-Za-z_][A-Za-z0-9_]*/g) ?? []).flatMap((w) => [...splitName(w), w.toLowerCase()]));
|
|
58
60
|
const rowOf = (c) => c?.row ?? c ?? {};
|
|
59
61
|
const wordsOf = (name, c) => [name.toLowerCase(), ...(c?.words ?? rowOf(c).words ?? []).map((w) => String(w).toLowerCase())];
|
|
60
62
|
|
|
@@ -82,10 +84,11 @@ export function unknownConcepts(making, shape) {
|
|
|
82
84
|
// bracketed text goes first, nested brackets too, so a ";" inside a shape ("slot (many, 4; slotId(i, field))") does not end the list (KWB-17)
|
|
83
85
|
let flat = making;
|
|
84
86
|
for (let prev; prev !== flat;) { prev = flat; flat = flat.replace(/\([^()]*\)/g, ' '); }
|
|
85
|
-
|
|
86
|
-
|
|
87
|
+
// the list runs to `reuse:` or the line's end, and a shape word after a concept ("editKnob, many") is what the rule asks for (KF-24)
|
|
88
|
+
for (const m of flat.matchAll(/\bconcepts?\s*:\s*(.*?)(?=\breuse\s*:|\||$)/gim)) {
|
|
89
|
+
for (const item of m[1].split(/[,;]/)) {
|
|
87
90
|
const w = item.trim().split(/\s+/)[0]?.toLowerCase().replace(/[^a-z0-9_-]/g, '');
|
|
88
|
-
if (w && !
|
|
91
|
+
if (w && !NOT_CONCEPTS.has(w) && !known.has(w)) out.push(w);
|
|
89
92
|
}
|
|
90
93
|
}
|
|
91
94
|
return out;
|
|
@@ -180,8 +183,10 @@ export function check(event, cwd = event.cwd ?? process.cwd()) {
|
|
|
180
183
|
}
|
|
181
184
|
}
|
|
182
185
|
|
|
183
|
-
// 2. once-only on the source the turn wrote
|
|
184
|
-
|
|
186
|
+
// 2. once-only on the source the turn wrote. The tools' writes passed once-only itself when it is installed; running its detector again on them at
|
|
187
|
+
// every Stop cost over the kit's own 500 ms line (KF-33). The shell's writes stay: much of what they write is unknown at write time.
|
|
188
|
+
const atWrite = existsSync(join(claudeRoot(join(cwd, 'x')), '.claude/hooks/once-only.mjs'));
|
|
189
|
+
const sources = [...new Set(judged.filter((w) => isSource(w.path) && existsSync(w.path) && !(atWrite && w.tool !== 'Bash')).map((w) => w.path))]
|
|
185
190
|
.filter((p) => !matchesAny(rel(p), copyGlobs(layout)) && !isGenerated(readFileSync(p, 'utf8')));
|
|
186
191
|
if (sources.length) {
|
|
187
192
|
// once-only's index finds the candidates, jscpd confirms on those files alone (lib/dup-index.mjs; KWB-24: no whole-repo scan)
|
|
@@ -4,8 +4,9 @@
|
|
|
4
4
|
// names the runner and its hooks in one command: node ".../run-hooks.mjs" .claude/hooks/a.mjs .claude/hooks/b.mjs
|
|
5
5
|
// Each hook module exports hook(text) (the event's JSON text, as stdin was) and returns its exit code; the runner calls
|
|
6
6
|
// them in turn, catches each one's stdout and stderr, writes each one's timing line to .claude/hooks.log (lib/hook-log.mjs)
|
|
7
|
-
// and its own (hook "run-hooks": the whole process, node's start-up included), and exits
|
|
8
|
-
//
|
|
7
|
+
// and its own (hook "run-hooks": the whole process, node's start-up included), and exits 2 if any hook refused. A hook that
|
|
8
|
+
// throws, or exits with anything but 0 or 2, crashed, and lib/crash-policy.mjs decides what the call does (KF-18: a crash
|
|
9
|
+
// on a tool call refuses once, and says so). lib/unread-writes.mjs says when no hook could read what a command writes (KF-40).
|
|
9
10
|
// Every hook still runs on its own too (node .claude/hooks/a.mjs, its selftest, a project's hand-made entry).
|
|
10
11
|
// node run-hooks.mjs <hook path or name>... (stdin: the event)
|
|
11
12
|
// node run-hooks.mjs --selftest
|
|
@@ -13,8 +14,10 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, writeSync
|
|
|
13
14
|
import { tmpdir } from 'node:os';
|
|
14
15
|
import { basename, dirname, isAbsolute, join } from 'node:path';
|
|
15
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
|
+
import { crashVerdict } from './lib/crash-policy.mjs';
|
|
16
18
|
import { logCall } from './lib/event-log.mjs';
|
|
17
19
|
import { appendRotated, isMain, logHook, projectRoot, readHookLog } from './lib/hook-log.mjs';
|
|
20
|
+
import { unreadWrites } from './lib/unread-writes.mjs';
|
|
18
21
|
|
|
19
22
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
20
23
|
export const RUNNER = 'run-hooks';
|
|
@@ -77,9 +80,12 @@ export async function runAll(args, text, root = projectRoot()) {
|
|
|
77
80
|
logHook(root, { event: ev.hook_event_name, hook: name, ms, code: c, tool: ev.tool_name ?? ev.source });
|
|
78
81
|
judged.push({ hook: name, ms, code: c, reason: err.slice(before).join(''), out: out.join('') });
|
|
79
82
|
if (c === 2) code = 2;
|
|
80
|
-
else if (c !== 0 && code === 0) code = c;
|
|
81
83
|
}
|
|
82
84
|
logCall(root, text, judged, appendRotated, performance.now());
|
|
85
|
+
const crash = crashVerdict(root, ev, judged);
|
|
86
|
+
if (crash.stderr) err.push(crash.stderr);
|
|
87
|
+
if (crash.code === 2) code = 2;
|
|
88
|
+
outs.push(...[crash.stdout, code === 0 ? unreadWrites(ev, root) : ''].filter(Boolean));
|
|
83
89
|
return { code, stdout: mergeOut(outs), stderr: err.join('') };
|
|
84
90
|
}
|
|
85
91
|
|
|
@@ -100,15 +106,17 @@ async function selftest() {
|
|
|
100
106
|
const r2 = await runAll([ok, no], JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Write' }), dir);
|
|
101
107
|
cases.push(['no refusal: exit 0', r2.code === 0 && r2.stdout === '{"systemMessage":"a"}']);
|
|
102
108
|
const r3 = await runAll([boom, ok], bash, dir);
|
|
103
|
-
cases.push(['a hook that throws
|
|
109
|
+
cases.push(['a hook that throws refuses the call, says so, and the next still runs', r3.code === 2 && r3.stderr.includes('throws crashed') && r3.stdout.includes('"a"')]);
|
|
110
|
+
const r4 = await runAll([boom, ok], bash, dir);
|
|
111
|
+
cases.push(['the same call again goes through, and says so', r4.code === 0 && r4.stdout.includes('throws crashed')]);
|
|
104
112
|
const rows = readHookLog(dir);
|
|
105
|
-
cases.push(['one timing line per hook per call', rows.length ===
|
|
113
|
+
cases.push(['one timing line per hook per call', rows.length === 9 && rows.filter((r) => r.hook === 'refuses').map((r) => r.code).join() === '2,0']);
|
|
106
114
|
cases.push(['the line names the event and the tool', rows[0].event === 'PreToolUse' && rows[0].tool === 'Bash']);
|
|
107
115
|
const lines = (f) => readFileSync(join(dir, '.claude/logs', f), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
|
108
116
|
const refused = lines('refusals.jsonl');
|
|
109
|
-
cases.push(['every call is a line of events.jsonl', lines('events.jsonl').length ===
|
|
117
|
+
cases.push(['every call is a line of events.jsonl', lines('events.jsonl').length === 4]);
|
|
110
118
|
cases.push(['a refusal keeps its reason and what was tried', refused[0].hook === 'refuses' && refused[0].reason.includes('BLOCKED by refuses') && refused[0].command === 'rm x']);
|
|
111
|
-
cases.push(['a crash is kept
|
|
119
|
+
cases.push(['a crash is kept, in its own log', !refused.some((r) => r.hook === 'throws') && lines('crashes.jsonl').some((r) => r.hook === 'throws' && r.code === 1)]);
|
|
112
120
|
rmSync(dir, { recursive: true, force: true });
|
|
113
121
|
const bad = cases.filter(([, good]) => !good);
|
|
114
122
|
for (const [label] of bad) console.error(`run-hooks selftest FAILED: ${label}`);
|