agent-harness-kit 0.6.0 → 0.8.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.
Files changed (37) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +29 -0
  4. package/bin/cli.mjs +15 -1
  5. package/package.json +1 -1
  6. package/src/core/detect-stack.mjs +16 -0
  7. package/src/core/doctor.mjs +23 -0
  8. package/src/core/render-templates.mjs +198 -6
  9. package/src/templates/.claude/hooks/hooks.json +111 -0
  10. package/src/templates/.claude/settings.json.hbs +1 -1
  11. package/src/templates/.claude/skills/doc-drift-scan/SKILL.md +15 -10
  12. package/src/templates/.claude/skills/doc-drift-scan/scripts/scan-paths.mjs +64 -0
  13. package/src/templates/.claude/skills/garbage-collection/SKILL.md.hbs +14 -5
  14. package/src/templates/.claude/skills/garbage-collection/scripts/gc-classify.mjs +77 -0
  15. package/src/templates/.claude/skills/inspect-module/SKILL.md.hbs +17 -14
  16. package/src/templates/.claude/skills/inspect-module/scripts/module-summary.mjs +144 -0
  17. package/src/templates/CLAUDE.md.hbs +10 -6
  18. package/src/templates/CLAUDE.md.vi.hbs +74 -0
  19. package/src/templates/_adapter-kotlin/harness/structural-check.mjs.hbs +286 -0
  20. package/src/templates/_adapter-rust/harness/structural-check.mjs.hbs +292 -100
  21. package/src/templates/_adapter-swift/harness/structural-check.mjs.hbs +285 -0
  22. package/src/templates/harness.config.json.hbs +5 -3
  23. package/src/templates/scripts/_lib/approx-tokens.mjs +48 -0
  24. package/src/templates/scripts/_lib/json-pick.mjs +278 -0
  25. package/src/templates/scripts/harness-report.mjs +95 -1
  26. package/src/templates/scripts/notify-on-block.sh.hbs +73 -0
  27. package/src/templates/scripts/pre-compact.sh.hbs +121 -0
  28. package/src/templates/scripts/pre-push.sh +28 -3
  29. package/src/templates/scripts/precompletion-checklist.sh.hbs +131 -22
  30. package/src/templates/scripts/pretooluse-bash-guard.sh.hbs +146 -0
  31. package/src/templates/scripts/session-end.sh.hbs +48 -0
  32. package/src/templates/scripts/session-start.sh.hbs +139 -0
  33. package/src/templates/scripts/statusline.mjs +63 -0
  34. package/src/templates/scripts/structural-test-on-edit.sh.hbs +31 -8
  35. package/src/templates/scripts/telemetry-on-skill.sh +32 -10
  36. package/src/templates/scripts/userprompt-guard.sh.hbs +100 -0
  37. package/src/templates/.claude/hooks/hooks.json.hbs +0 -39
@@ -0,0 +1,285 @@
1
+ // harness/structural-check.mjs — forward-only layer enforcement for Swift.
2
+ //
3
+ // Reads harness.config.json. For each domain, walks every .swift file
4
+ // under the domain root (excluding .build/, DerivedData/, Pods/, .git/)
5
+ // and asserts no `import` statement pulls in a layer that comes BEFORE
6
+ // the source layer.
7
+ //
8
+ // Layer resolution mirrors the Rust adapter:
9
+ // * Source layer = first directory segment under <root>, run through
10
+ // `layerDirPattern` (default `{layer}`).
11
+ // * Target layer = first identifier after `import` (or after
12
+ // `import struct | class | enum | protocol | typealias
13
+ // | func | var | let`), looked up by the configured
14
+ // layer list or `useIdentPattern` (default `{layer}`).
15
+ //
16
+ // Skip-zones handled by stripNonCode():
17
+ // - line comment `// ... \n`
18
+ // - block comment `/* ... */` (Swift allows nesting)
19
+ // - regular string `"..."` (with `\"` and `\\` escapes)
20
+ // - extended string `#"..."#` and `##"..."##` (with N hashes)
21
+ // - multi-line string `""" ... """`
22
+ //
23
+ // Exit codes:
24
+ // 0 — clean (or only baselined violations; or first-run baseline write)
25
+ // 2 — new violations found
26
+
27
+ import {
28
+ readFileSync,
29
+ writeFileSync,
30
+ existsSync,
31
+ mkdirSync,
32
+ readdirSync,
33
+ } from "node:fs";
34
+ import { join, relative, sep } from "node:path";
35
+
36
+ const repoRoot = process.cwd();
37
+ const SKIP_DIRS = new Set([
38
+ ".git",
39
+ ".build",
40
+ "DerivedData",
41
+ "Pods",
42
+ "node_modules",
43
+ ".harness",
44
+ "Carthage",
45
+ ]);
46
+
47
+ function readConfig() {
48
+ return JSON.parse(readFileSync(join(repoRoot, "harness.config.json"), "utf8"));
49
+ }
50
+ function readBaseline() {
51
+ const path = join(repoRoot, ".harness/structural-baseline.json");
52
+ if (!existsSync(path)) return { exists: false, set: new Set() };
53
+ try {
54
+ const arr = JSON.parse(readFileSync(path, "utf8"));
55
+ return { exists: true, set: new Set(Array.isArray(arr) ? arr : []) };
56
+ } catch {
57
+ return { exists: true, set: new Set() };
58
+ }
59
+ }
60
+
61
+ function* walkSwiftFiles(root) {
62
+ if (!existsSync(root)) return;
63
+ for (const ent of readdirSync(root, { withFileTypes: true })) {
64
+ if (SKIP_DIRS.has(ent.name) || ent.name.startsWith(".")) continue;
65
+ const full = join(root, ent.name);
66
+ if (ent.isDirectory()) yield* walkSwiftFiles(full);
67
+ else if (ent.isFile() && ent.name.endsWith(".swift")) yield full;
68
+ }
69
+ }
70
+
71
+ // stripNonCode — blank out comments + strings, preserve newlines.
72
+ // Block comments nest. Triple-quoted strings span multiple lines.
73
+ // Hashed strings allow embedded `"` (Swift's "raw string" form).
74
+ export function stripNonCode(src) {
75
+ const n = src.length;
76
+ const out = new Array(n);
77
+ let i = 0;
78
+ while (i < n) {
79
+ const c = src[i];
80
+ const next = src[i + 1];
81
+ if (c === "/" && next === "/") {
82
+ while (i < n && src[i] !== "\n") {
83
+ out[i] = src[i] === "\n" ? "\n" : " ";
84
+ i++;
85
+ }
86
+ continue;
87
+ }
88
+ if (c === "/" && next === "*") {
89
+ let depth = 1;
90
+ out[i] = " ";
91
+ out[i + 1] = " ";
92
+ i += 2;
93
+ while (i < n && depth > 0) {
94
+ if (src[i] === "/" && src[i + 1] === "*") {
95
+ depth++;
96
+ out[i] = " ";
97
+ out[i + 1] = " ";
98
+ i += 2;
99
+ } else if (src[i] === "*" && src[i + 1] === "/") {
100
+ depth--;
101
+ out[i] = " ";
102
+ out[i + 1] = " ";
103
+ i += 2;
104
+ } else {
105
+ out[i] = src[i] === "\n" ? "\n" : " ";
106
+ i++;
107
+ }
108
+ }
109
+ continue;
110
+ }
111
+ // Hashed string: #"..."# / ##"..."## etc. May enclose triple-quoted form.
112
+ if (c === "#") {
113
+ let hashes = 0;
114
+ let j = i;
115
+ while (src[j] === "#") {
116
+ hashes++;
117
+ j++;
118
+ }
119
+ if (src[j] === '"') {
120
+ // Check for triple-quoted variant: #"""..."""#
121
+ const triple = src.slice(j, j + 3) === '"""';
122
+ const opener = triple ? '"""' : '"';
123
+ const closeStr = opener + "#".repeat(hashes);
124
+ for (let k = i; k < j + opener.length; k++) out[k] = " ";
125
+ let m = j + opener.length;
126
+ while (m < n) {
127
+ if (src.slice(m, m + closeStr.length) === closeStr) {
128
+ for (let k = m; k < m + closeStr.length; k++) out[k] = " ";
129
+ m += closeStr.length;
130
+ break;
131
+ }
132
+ out[m] = src[m] === "\n" ? "\n" : " ";
133
+ m++;
134
+ }
135
+ i = m;
136
+ continue;
137
+ }
138
+ }
139
+ // Triple-quoted string """..."""
140
+ if (c === '"' && next === '"' && src[i + 2] === '"') {
141
+ out[i] = out[i + 1] = out[i + 2] = " ";
142
+ let m = i + 3;
143
+ while (m < n) {
144
+ if (src[m] === '"' && src[m + 1] === '"' && src[m + 2] === '"') {
145
+ out[m] = out[m + 1] = out[m + 2] = " ";
146
+ m += 3;
147
+ break;
148
+ }
149
+ out[m] = src[m] === "\n" ? "\n" : " ";
150
+ m++;
151
+ }
152
+ i = m;
153
+ continue;
154
+ }
155
+ // Regular string
156
+ if (c === '"') {
157
+ out[i] = " ";
158
+ i++;
159
+ while (i < n) {
160
+ if (src[i] === "\\" && i + 1 < n) {
161
+ out[i] = " ";
162
+ out[i + 1] = " ";
163
+ i += 2;
164
+ continue;
165
+ }
166
+ if (src[i] === '"') {
167
+ out[i] = " ";
168
+ i++;
169
+ break;
170
+ }
171
+ out[i] = src[i] === "\n" ? "\n" : " ";
172
+ i++;
173
+ }
174
+ continue;
175
+ }
176
+ out[i] = c;
177
+ i++;
178
+ }
179
+ return out.join("");
180
+ }
181
+
182
+ function layerOf(relPath, cfg) {
183
+ for (const d of cfg.domains) {
184
+ const altPrefix = d.root + "/";
185
+ const sepPrefix = d.root + sep;
186
+ let stripped;
187
+ if (relPath.startsWith(altPrefix)) stripped = relPath.slice(altPrefix.length);
188
+ else if (relPath.startsWith(sepPrefix)) stripped = relPath.slice(sepPrefix.length);
189
+ else continue;
190
+ const first = stripped.split(/[\/\\]/)[0];
191
+ const pattern = d.layerDirPattern || "{layer}";
192
+ const layer = resolveFromPattern(first, pattern, d.layers);
193
+ if (layer) return { layer, domain: d };
194
+ }
195
+ return null;
196
+ }
197
+
198
+ function resolveFromPattern(ident, pattern, layers) {
199
+ if (pattern === "{layer}") return layers.includes(ident) ? ident : null;
200
+ const [prefix, suffix] = pattern.split("{layer}");
201
+ const pre = (prefix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
202
+ const suf = (suffix || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
203
+ const re = new RegExp(`^${pre}(.+?)${suf}$`);
204
+ const m = ident.match(re);
205
+ if (m && layers.includes(m[1])) return m[1];
206
+ return null;
207
+ }
208
+
209
+ // `import [submodule-kind] <ident>[.<ident>]?`
210
+ const IMPORT_RE =
211
+ /\bimport\s+(?:struct\s+|class\s+|enum\s+|protocol\s+|typealias\s+|func\s+|var\s+|let\s+)?([A-Za-z_][A-Za-z0-9_]*)/g;
212
+
213
+ function findImportLayers(code, domain) {
214
+ const out = [];
215
+ const ident = domain.useIdentPattern || "{layer}";
216
+ IMPORT_RE.lastIndex = 0;
217
+ let m;
218
+ while ((m = IMPORT_RE.exec(code)) !== null) {
219
+ const layer = resolveFromPattern(m[1], ident, domain.layers);
220
+ if (layer) {
221
+ const line = code.slice(0, m.index).split("\n").length;
222
+ out.push({ layer, line });
223
+ }
224
+ }
225
+ return out;
226
+ }
227
+
228
+ function main() {
229
+ const cfg = readConfig();
230
+ const { exists: baselineExists, set: baselineSet } = readBaseline();
231
+ const violations = [];
232
+ for (const d of cfg.domains) {
233
+ const rootDir = join(repoRoot, d.root);
234
+ for (const file of walkSwiftFiles(rootDir)) {
235
+ const relPath = relative(repoRoot, file);
236
+ const src = layerOf(relPath, cfg);
237
+ if (!src) continue;
238
+ const srcIdx = src.domain.layers.indexOf(src.layer);
239
+ const content = readFileSync(file, "utf8");
240
+ const code = stripNonCode(content);
241
+ for (const { layer: tgtLayer, line } of findImportLayers(code, src.domain)) {
242
+ const tgtIdx = src.domain.layers.indexOf(tgtLayer);
243
+ if (tgtIdx === -1) continue;
244
+ if (srcIdx < tgtIdx) {
245
+ const key = `${relPath}::${tgtLayer}`;
246
+ if (baselineSet.has(key)) continue;
247
+ violations.push({
248
+ file: relPath,
249
+ line,
250
+ from: src.layer,
251
+ to: tgtLayer,
252
+ domain: src.domain.name,
253
+ key,
254
+ });
255
+ }
256
+ }
257
+ }
258
+ }
259
+
260
+ if (!baselineExists && violations.length > 0) {
261
+ mkdirSync(join(repoRoot, ".harness"), { recursive: true });
262
+ const keys = [...new Set(violations.map((v) => v.key))].sort();
263
+ writeFileSync(
264
+ join(repoRoot, ".harness/structural-baseline.json"),
265
+ JSON.stringify(keys, null, 2) + "\n",
266
+ );
267
+ console.log(`✓ structural test: baselined ${keys.length} existing violation(s).`);
268
+ process.exit(0);
269
+ }
270
+ if (violations.length === 0) {
271
+ console.log("✓ structural test passed");
272
+ process.exit(0);
273
+ }
274
+ for (const v of violations) {
275
+ console.error(`✖ ${v.file}:${v.line} layer=${v.from} → ${v.to} (must be forward-only)`);
276
+ }
277
+ console.error(`\n${violations.length} new layer violation(s). Fix the import direction.`);
278
+ if (cfg.domains.length > 0) {
279
+ console.error(`Layer order for domain "${cfg.domains[0].name}": ${cfg.domains[0].layers.join(" → ")}`);
280
+ }
281
+ process.exit(2);
282
+ }
283
+
284
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
285
+ if (isMain) main();
@@ -7,14 +7,14 @@
7
7
  "domains": [
8
8
  {
9
9
  "name": "default",
10
- "root": "{{#if isPython}}app{{else}}{{#if isGo}}internal{{else}}src{{/if}}{{/if}}",
10
+ "root": "{{#if isPython}}app{{else}}{{#if isGo}}internal{{else}}{{#if isSwift}}Sources{{else}}{{#if isKotlin}}src/main/kotlin{{else}}src{{/if}}{{/if}}{{/if}}{{/if}}",
11
11
  "layers": [{{#each layers}}"{{this}}"{{#unless @last}}, {{/unless}}{{/each}}]
12
12
  }
13
13
  ],
14
14
  "providers": ["auth", "telemetry", "feature-flags"],
15
15
  "goldenPrinciples": "docs/golden-principles.md",
16
16
  "structuralTest": {
17
- "engine": "{{#if isPython}}libcst{{else}}{{#if isGo}}go-parser{{else}}{{#if isRust}}rust-regex{{else}}ts-morph{{/if}}{{/if}}{{/if}}",
17
+ "engine": "{{#if isPython}}libcst{{else}}{{#if isGo}}go-parser{{else}}{{#if isRust}}rust-lexer{{else}}{{#if isSwift}}swift-lexer{{else}}{{#if isKotlin}}kotlin-lexer{{else}}ts-morph{{/if}}{{/if}}{{/if}}{{/if}}{{/if}}",
18
18
  "configPath": ".harness/structural-test.config.json",
19
19
  "blockOnViolation": true
20
20
  },
@@ -30,7 +30,9 @@
30
30
  },
31
31
  "claudeMd": {
32
32
  "path": "CLAUDE.md",
33
- "maxInstructions": 200
33
+ "maxInstructions": 200,
34
+ "maxTokens": 0,
35
+ "humanLanguage": "{{humanLanguage}}"
34
36
  },
35
37
  "recovery": {
36
38
  "headless": false
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ // approx-tokens.mjs — heuristic token counter for CLAUDE.md cap.
3
+ //
4
+ // Why this exists: the original `claudeMd.maxInstructions` cap counts
5
+ // bullets and numbered list items. For ASCII-heavy English content, 200
6
+ // bullets ≈ ~3000 tokens (the threshold HumanLayer measured). But for
7
+ // CJK or accent-heavy languages (Vietnamese, Thai), the same 200 bullets
8
+ // can carry 2–3× more tokens, so the bullet count alone can let drift
9
+ // past the model's "follow CLAUDE.md reliably" budget without firing.
10
+ //
11
+ // Heuristic: Anthropic's tokenizer charges roughly:
12
+ // - 1 token per ~4 ASCII chars (English/code)
13
+ // - 1 token per ~2 chars for non-ASCII (Vietnamese, CJK, etc.)
14
+ //
15
+ // We approximate by walking code points, classifying each into
16
+ // "latin-1" (codepoint < 0x100, ~4 chars/token) vs "other" (~2 chars/
17
+ // token), and dividing accordingly. Off by maybe 10–20% vs the real
18
+ // tokenizer — close enough for a Stop-hook cap.
19
+ //
20
+ // Output: a single integer (the approximate token count).
21
+
22
+ import { readFileSync } from "node:fs";
23
+
24
+ const path = process.argv[2];
25
+ if (!path) {
26
+ console.error("usage: approx-tokens.mjs <file>");
27
+ process.exit(1);
28
+ }
29
+
30
+ let text;
31
+ try {
32
+ text = readFileSync(path, "utf8");
33
+ } catch (e) {
34
+ console.error(`approx-tokens: cannot read ${path}: ${e.message}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ console.log(approxTokens(text));
39
+
40
+ export function approxTokens(text) {
41
+ let latin = 0;
42
+ let other = 0;
43
+ for (const ch of text) {
44
+ if (ch.codePointAt(0) < 0x100) latin++;
45
+ else other++;
46
+ }
47
+ return Math.ceil(latin / 4 + other / 2);
48
+ }
@@ -0,0 +1,278 @@
1
+ #!/usr/bin/env node
2
+ // json-pick.mjs — tiny jq-subset for Stop hook + pre-push hook.
3
+ //
4
+ // Why this exists: harness hooks shell out to `jq` for path queries against
5
+ // harness.config.json. On minimal CI images and on Windows without WSL+brew,
6
+ // `jq` is missing — the hooks used to silently skip the whole check (return
7
+ // 0 from a stub branch), which left the user unprotected. This script is a
8
+ // pure-Node fallback so a `node` binary is the only hard requirement (which
9
+ // the kit already has via the `engines` field).
10
+ //
11
+ // Usage:
12
+ // node json-pick.mjs <jq-expr> <file>
13
+ // node json-pick.mjs <jq-expr> < json-on-stdin
14
+ //
15
+ // Supported subset (the only constructs the hooks actually use today):
16
+ // . — identity
17
+ // .a.b.c — dotted path
18
+ // .a[3] — array index
19
+ // .a[] — iterate array (one value per line)
20
+ // .a | length — length of array / object / string
21
+ // .a // "fallback" — alternative when missing/null/false
22
+ // .a // empty — emit nothing when missing (jq idiom)
23
+ // length — length of root value
24
+ //
25
+ // Output format matches `jq -r`: strings are unquoted, booleans/numbers are
26
+ // raw. Missing/null prints empty string when used with `// default` else
27
+ // prints `null` (same as `jq -r`).
28
+ //
29
+ // Exit codes:
30
+ // 0 — value resolved (may be empty string)
31
+ // 1 — parse / IO error
32
+ //
33
+ // Hard-coded subset by design. Adding new operators here forces an update to
34
+ // the hook scripts, which keeps the surface auditable.
35
+
36
+ import { readFileSync } from "node:fs";
37
+
38
+ const args = process.argv.slice(2);
39
+ if (args.length === 0) {
40
+ console.error("usage: json-pick.mjs <expr> [<file>]");
41
+ process.exit(1);
42
+ }
43
+ const expr = args[0];
44
+ const file = args[1];
45
+
46
+ let raw;
47
+ try {
48
+ raw = file ? readFileSync(file, "utf8") : readFileSync(0, "utf8");
49
+ } catch (e) {
50
+ console.error(`json-pick: cannot read ${file ?? "stdin"}: ${e.message}`);
51
+ process.exit(1);
52
+ }
53
+
54
+ let root;
55
+ try {
56
+ root = JSON.parse(raw);
57
+ } catch (e) {
58
+ console.error(`json-pick: invalid JSON in ${file ?? "stdin"}: ${e.message}`);
59
+ process.exit(1);
60
+ }
61
+
62
+ function tokenize(src) {
63
+ const toks = [];
64
+ let i = 0;
65
+ while (i < src.length) {
66
+ const c = src[i];
67
+ if (c === " " || c === "\t" || c === "\n") { i++; continue; }
68
+ if (c === ".") { toks.push({ t: "dot" }); i++; continue; }
69
+ if (c === "|") { toks.push({ t: "pipe" }); i++; continue; }
70
+ if (c === "[") { toks.push({ t: "lbrack" }); i++; continue; }
71
+ if (c === "]") { toks.push({ t: "rbrack" }); i++; continue; }
72
+ if (c === "/" && src[i + 1] === "/") { toks.push({ t: "fallback" }); i += 2; continue; }
73
+ if (c === '"') {
74
+ let j = i + 1, val = "";
75
+ while (j < src.length && src[j] !== '"') {
76
+ if (src[j] === "\\" && j + 1 < src.length) { val += src[j + 1]; j += 2; continue; }
77
+ val += src[j]; j++;
78
+ }
79
+ if (src[j] !== '"') throw new Error("unterminated string");
80
+ toks.push({ t: "string", v: val });
81
+ i = j + 1; continue;
82
+ }
83
+ if (/[0-9-]/.test(c)) {
84
+ let j = i;
85
+ while (j < src.length && /[0-9-]/.test(src[j])) j++;
86
+ toks.push({ t: "number", v: parseInt(src.slice(i, j), 10) });
87
+ i = j; continue;
88
+ }
89
+ if (/[a-zA-Z_]/.test(c)) {
90
+ let j = i;
91
+ while (j < src.length && /[a-zA-Z0-9_]/.test(src[j])) j++;
92
+ const word = src.slice(i, j);
93
+ if (word === "length" || word === "null" || word === "true" || word === "false") {
94
+ toks.push({ t: "keyword", v: word });
95
+ } else {
96
+ toks.push({ t: "ident", v: word });
97
+ }
98
+ i = j; continue;
99
+ }
100
+ throw new Error(`unexpected char '${c}' at ${i}`);
101
+ }
102
+ return toks;
103
+ }
104
+
105
+ // Parse and apply against root. Returns array of result rows (each printed on
106
+ // its own line) — `[]` may produce many, `.` produces one.
107
+ function evalExpr(src, value) {
108
+ const toks = tokenize(src);
109
+ let p = 0;
110
+ const peek = () => toks[p];
111
+ const eat = (t) => {
112
+ if (!toks[p] || toks[p].t !== t) throw new Error(`expected ${t}, got ${toks[p]?.t ?? "EOF"}`);
113
+ return toks[p++];
114
+ };
115
+
116
+ // pipeline = primary ( "|" filter )* and `// default` is parsed at the path level.
117
+ // We unify "// default" handling: any path can have `// LITERAL` appended.
118
+ function parsePrimary() {
119
+ // Special case: starting with `length` alone → length of root.
120
+ if (peek() && peek().t === "keyword" && peek().v === "length") {
121
+ p++;
122
+ return { kind: "length", inner: { kind: "identity" } };
123
+ }
124
+ return parsePath();
125
+ }
126
+
127
+ function parsePath() {
128
+ // Must start with "."
129
+ if (!peek() || peek().t !== "dot") throw new Error("expr must start with '.'");
130
+ p++;
131
+ const steps = [];
132
+ // Allow `.` alone (identity).
133
+ while (peek()) {
134
+ const cur = peek();
135
+ if (cur.t === "ident") {
136
+ steps.push({ kind: "key", v: cur.v });
137
+ p++;
138
+ // optional `.next` chain
139
+ while (peek() && peek().t === "dot" && toks[p + 1] && toks[p + 1].t === "ident") {
140
+ p++; // dot
141
+ steps.push({ kind: "key", v: toks[p].v });
142
+ p++;
143
+ }
144
+ continue;
145
+ }
146
+ if (cur.t === "lbrack") {
147
+ p++;
148
+ if (peek() && peek().t === "rbrack") {
149
+ steps.push({ kind: "iter" });
150
+ p++;
151
+ } else if (peek() && peek().t === "number") {
152
+ const n = toks[p].v;
153
+ p++;
154
+ eat("rbrack");
155
+ steps.push({ kind: "index", v: n });
156
+ } else {
157
+ throw new Error("expected number or ']' after '['");
158
+ }
159
+ // dot after `]` is allowed: `.a[0].b`
160
+ if (peek() && peek().t === "dot") {
161
+ p++;
162
+ }
163
+ continue;
164
+ }
165
+ break;
166
+ }
167
+ return { kind: "path", steps };
168
+ }
169
+
170
+ let node = parsePrimary();
171
+
172
+ // ( | length )* and ( // literal )?
173
+ while (peek()) {
174
+ if (peek().t === "pipe") {
175
+ p++;
176
+ if (peek() && peek().t === "keyword" && peek().v === "length") {
177
+ p++;
178
+ node = { kind: "length", inner: node };
179
+ } else {
180
+ throw new Error("only 'length' is supported after '|'");
181
+ }
182
+ continue;
183
+ }
184
+ if (peek().t === "fallback") {
185
+ p++;
186
+ const lit = peek();
187
+ if (!lit) throw new Error("expected literal after //");
188
+ let v;
189
+ let drop = false;
190
+ if (lit.t === "string") v = lit.v;
191
+ else if (lit.t === "number") v = lit.v;
192
+ else if (lit.t === "keyword") {
193
+ if (lit.v === "true") v = true;
194
+ else if (lit.v === "false") v = false;
195
+ else if (lit.v === "null") v = null;
196
+ else throw new Error("unexpected literal");
197
+ } else if (lit.t === "ident" && lit.v === "empty") {
198
+ drop = true;
199
+ } else {
200
+ throw new Error("expected literal after //");
201
+ }
202
+ p++;
203
+ node = { kind: "fallback", inner: node, default: v, drop };
204
+ continue;
205
+ }
206
+ break;
207
+ }
208
+
209
+ if (p !== toks.length) {
210
+ throw new Error("trailing tokens");
211
+ }
212
+
213
+ // Now interpret.
214
+ function run(n, v) {
215
+ if (n.kind === "identity") return [v];
216
+ if (n.kind === "path") {
217
+ let cur = [v];
218
+ for (const step of n.steps) {
219
+ const next = [];
220
+ for (const item of cur) {
221
+ if (step.kind === "key") {
222
+ next.push(item == null ? null : item[step.v]);
223
+ } else if (step.kind === "index") {
224
+ next.push(Array.isArray(item) ? item[step.v] : null);
225
+ } else if (step.kind === "iter") {
226
+ if (Array.isArray(item)) for (const e of item) next.push(e);
227
+ else if (item && typeof item === "object") for (const e of Object.values(item)) next.push(e);
228
+ else next.push(null);
229
+ }
230
+ }
231
+ cur = next;
232
+ }
233
+ if (n.steps.length === 0) return [v]; // bare `.`
234
+ return cur;
235
+ }
236
+ if (n.kind === "length") {
237
+ const inner = run(n.inner, v);
238
+ return inner.map((x) => {
239
+ if (x == null) return 0;
240
+ if (Array.isArray(x)) return x.length;
241
+ if (typeof x === "string") return x.length;
242
+ if (typeof x === "object") return Object.keys(x).length;
243
+ return 0;
244
+ });
245
+ }
246
+ if (n.kind === "fallback") {
247
+ const inner = run(n.inner, v);
248
+ const out = [];
249
+ for (const x of inner) {
250
+ const missing = x === null || x === undefined || x === false;
251
+ if (missing && n.drop) continue;
252
+ out.push(missing ? n.default : x);
253
+ }
254
+ return out;
255
+ }
256
+ throw new Error("unknown node kind");
257
+ }
258
+
259
+ return run(node, value);
260
+ }
261
+
262
+ let rows;
263
+ try {
264
+ rows = evalExpr(expr, root);
265
+ } catch (e) {
266
+ console.error(`json-pick: ${e.message} (expr: ${expr})`);
267
+ process.exit(1);
268
+ }
269
+
270
+ for (const r of rows) {
271
+ if (r === null || r === undefined) {
272
+ console.log("null");
273
+ } else if (typeof r === "string") {
274
+ console.log(r);
275
+ } else {
276
+ console.log(JSON.stringify(r));
277
+ }
278
+ }