dev-loops 0.4.0 → 0.5.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/skills/dev-loop/SKILL.md +16 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/CHANGELOG.md +43 -0
- package/README.md +1 -0
- package/agents/refiner.agent.md +1 -0
- package/package.json +5 -3
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/skills/dev-loop/SKILL.md +11 -1
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
|
@@ -3,6 +3,7 @@ import { setTimeout as delay } from "node:timers/promises";
|
|
|
3
3
|
import { buildParseError, formatCliError, isCopilotLogin, isDirectCliRun, parseJsonText, parseReviewThreads } from "../_core-helpers.mjs";
|
|
4
4
|
import { parseArgs } from "node:util";
|
|
5
5
|
import { parsePositiveInteger, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
7
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
7
8
|
import {
|
|
8
9
|
DEFAULT_POLL_INTERVAL_MS,
|
|
@@ -24,6 +25,10 @@ Required:
|
|
|
24
25
|
Output (stdout, JSON):
|
|
25
26
|
{ "ok": true, "status": "changed"|"timeout"|"idle", "repo": "...", "pr": N, "attempts": N,
|
|
26
27
|
"newComments": [...], "newReviews": [...], "newIssueComments": [...] }
|
|
28
|
+
Concise mode:
|
|
29
|
+
--concise, --summary Human-readable summary: status, attempts, new-activity
|
|
30
|
+
counts, AND the current round's new Copilot comment bodies.
|
|
31
|
+
${JQ_OUTPUT_USAGE}
|
|
27
32
|
Activity statuses:
|
|
28
33
|
changed Fresh Copilot review activity found (check newComments/newReviews/newIssueComments)
|
|
29
34
|
timeout Watch period elapsed with no fresh Copilot activity
|
|
@@ -91,7 +96,14 @@ function rejectRemovedFlag(token) {
|
|
|
91
96
|
export function parseWatchCliArgs(argv) {
|
|
92
97
|
const { tokens } = parseArgs({
|
|
93
98
|
args: [...argv],
|
|
94
|
-
options: {
|
|
99
|
+
options: {
|
|
100
|
+
help: { type: "boolean", short: "h" },
|
|
101
|
+
repo: { type: "string" },
|
|
102
|
+
pr: { type: "string" },
|
|
103
|
+
concise: { type: "boolean" },
|
|
104
|
+
summary: { type: "boolean" },
|
|
105
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
106
|
+
},
|
|
95
107
|
allowPositionals: true,
|
|
96
108
|
strict: false,
|
|
97
109
|
tokens: true,
|
|
@@ -102,6 +114,9 @@ export function parseWatchCliArgs(argv) {
|
|
|
102
114
|
pr: undefined,
|
|
103
115
|
pollIntervalMs: DEFAULT_POLL_INTERVAL_MS,
|
|
104
116
|
timeoutMs: COPILOT_REVIEW_WAIT_TIMEOUT_MS,
|
|
117
|
+
concise: false,
|
|
118
|
+
jq: undefined,
|
|
119
|
+
silent: false,
|
|
105
120
|
};
|
|
106
121
|
for (const token of tokens) {
|
|
107
122
|
if (token.kind === "positional") {
|
|
@@ -125,6 +140,18 @@ export function parseWatchCliArgs(argv) {
|
|
|
125
140
|
options.pr = parsePositiveInteger(requireTokenValue(token, parseError), "--pr", parseError);
|
|
126
141
|
continue;
|
|
127
142
|
}
|
|
143
|
+
if (token.name === "concise" || token.name === "summary") {
|
|
144
|
+
options.concise = true;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (token.name === "jq") {
|
|
148
|
+
options.jq = requireTokenValue(token, parseError);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (token.name === "silent") {
|
|
152
|
+
options.silent = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
128
155
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
129
156
|
}
|
|
130
157
|
if (options.repo === undefined || options.pr === undefined) {
|
|
@@ -311,6 +338,37 @@ export function buildPollDelayMs(watchStartedAtMs, timeoutMs, pollIntervalMs, at
|
|
|
311
338
|
const scheduledAtMs = watchStartedAtMs + Math.min(timeoutMs, attempt * pollIntervalMs);
|
|
312
339
|
return Math.max(0, scheduledAtMs - nowMs);
|
|
313
340
|
}
|
|
341
|
+
// Human-readable concise summary of a probe result, including the current
|
|
342
|
+
// round's new Copilot comment bodies (the field `loop info --pr` omits).
|
|
343
|
+
export function formatProbeConcise(result) {
|
|
344
|
+
const newComments = result.newComments ?? [];
|
|
345
|
+
const newReviews = result.newReviews ?? [];
|
|
346
|
+
const newIssueComments = result.newIssueComments ?? [];
|
|
347
|
+
const lines = [
|
|
348
|
+
`Copilot probe: PR #${result.pr} (${result.repo})`,
|
|
349
|
+
` status: ${result.status}`,
|
|
350
|
+
` attempts: ${result.attempts}`,
|
|
351
|
+
` new threadComments: ${newComments.length}`,
|
|
352
|
+
` new reviews: ${newReviews.length}`,
|
|
353
|
+
` new issueComments: ${newIssueComments.length}`,
|
|
354
|
+
];
|
|
355
|
+
const bodies = [
|
|
356
|
+
...newReviews.map((r) => ({ kind: "review", body: r.body })),
|
|
357
|
+
...newComments.map((c) => ({ kind: "threadComment", body: c.body })),
|
|
358
|
+
...newIssueComments.map((c) => ({ kind: "issueComment", body: c.body })),
|
|
359
|
+
].filter((entry) => typeof entry.body === "string" && entry.body.trim().length > 0);
|
|
360
|
+
if (bodies.length > 0) {
|
|
361
|
+
lines.push(" new Copilot comment bodies this round:");
|
|
362
|
+
for (const entry of bodies) {
|
|
363
|
+
const indented = entry.body.trim().split("\n").map((l) => ` ${l}`).join("\n");
|
|
364
|
+
lines.push(` [${entry.kind}]`);
|
|
365
|
+
lines.push(indented);
|
|
366
|
+
}
|
|
367
|
+
} else {
|
|
368
|
+
lines.push(" new Copilot comment bodies this round: (none)");
|
|
369
|
+
}
|
|
370
|
+
return lines.join("\n");
|
|
371
|
+
}
|
|
314
372
|
export async function runCli(
|
|
315
373
|
argv = process.argv.slice(2),
|
|
316
374
|
{
|
|
@@ -325,10 +383,18 @@ export async function runCli(
|
|
|
325
383
|
return;
|
|
326
384
|
}
|
|
327
385
|
const result = await watchCopilotReview(options, { env, ghCommand });
|
|
328
|
-
|
|
386
|
+
if (options.concise && options.jq === undefined && !options.silent) {
|
|
387
|
+
stdout.write(`${formatProbeConcise(result)}\n`);
|
|
388
|
+
return result.ok === false ? 1 : 0;
|
|
389
|
+
}
|
|
390
|
+
return emitResult(result, { jq: options.jq, silent: options.silent, stdout });
|
|
329
391
|
}
|
|
330
392
|
if (isDirectCliRun(import.meta.url)) {
|
|
331
|
-
runCli().
|
|
393
|
+
runCli().then((code) => {
|
|
394
|
+
if (typeof code === "number") {
|
|
395
|
+
process.exitCode = code;
|
|
396
|
+
}
|
|
397
|
+
}).catch((error) => {
|
|
332
398
|
process.stderr.write(`${formatCliError(error)}\n`);
|
|
333
399
|
process.exitCode = 1;
|
|
334
400
|
});
|
|
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { buildParseError, formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
|
|
4
4
|
import { loadDevLoopConfig, resolveGateConfig, resolveRefinementConfig } from "@dev-loops/core/config";
|
|
5
5
|
import { parseArgs } from "node:util";
|
|
6
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
6
7
|
import { parsePrNumber, requireTokenValue, runChild } from "../_cli-primitives.mjs";
|
|
7
8
|
import { truncateText } from "@dev-loops/core/bash-exit-one";
|
|
8
9
|
import { parseRepoSlug } from "@dev-loops/core/github/repo-slug";
|
|
@@ -105,9 +106,11 @@ exists on a different head SHA (the old comment is stale for the current head).
|
|
|
105
106
|
Error output (stderr, JSON):
|
|
106
107
|
{ "ok": false, "error": "...", "usage": "..." }
|
|
107
108
|
{ "ok": false, "error": "..." }
|
|
109
|
+
${JQ_OUTPUT_USAGE}
|
|
108
110
|
Exit codes:
|
|
109
111
|
0 Success
|
|
110
|
-
1 Argument error, gh failure, or contradictory gate evidence
|
|
112
|
+
1 Argument error, gh failure, or contradictory gate evidence
|
|
113
|
+
2 Invalid --jq filter`.trim();
|
|
111
114
|
const parseError = buildParseError(USAGE);
|
|
112
115
|
function rejectRemovedFlag(token) {
|
|
113
116
|
throw parseError(
|
|
@@ -258,6 +261,7 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
258
261
|
"findings-severity-counts": { type: "string" },
|
|
259
262
|
"execution-mode": { type: "string" },
|
|
260
263
|
"inline-reason": { type: "string" },
|
|
264
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
261
265
|
},
|
|
262
266
|
allowPositionals: true,
|
|
263
267
|
strict: false,
|
|
@@ -277,6 +281,8 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
277
281
|
findingsSeverityCounts: undefined,
|
|
278
282
|
executionMode: undefined,
|
|
279
283
|
inlineReason: undefined,
|
|
284
|
+
jq: undefined,
|
|
285
|
+
silent: false,
|
|
280
286
|
};
|
|
281
287
|
for (const token of tokens) {
|
|
282
288
|
if (token.kind === "positional") {
|
|
@@ -385,6 +391,14 @@ export function parseUpsertCheckpointVerdictCliArgs(argv) {
|
|
|
385
391
|
options.inlineReason = smartTruncate(reason, MAX_GATE_COMMENT_EXCERPT_LENGTH);
|
|
386
392
|
continue;
|
|
387
393
|
}
|
|
394
|
+
if (token.name === "jq") {
|
|
395
|
+
options.jq = requireTokenValue(token, parseError);
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (token.name === "silent") {
|
|
399
|
+
options.silent = true;
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
388
402
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
389
403
|
}
|
|
390
404
|
// Default execution mode to inline_single_agent when omitted. inlineReason is
|
|
@@ -1286,10 +1300,11 @@ async function main() {
|
|
|
1286
1300
|
const result = await upsertCheckpointVerdict(options);
|
|
1287
1301
|
// Emit the inline-execution warning only on success so the JSON error
|
|
1288
1302
|
// envelope on stderr stays clean and machine-parseable on failures.
|
|
1289
|
-
|
|
1303
|
+
// Suppress it under --silent, which contracts to zero output (exit code only).
|
|
1304
|
+
if (inlineWarning && !options.silent) {
|
|
1290
1305
|
process.stderr.write(`${inlineWarning}\n`);
|
|
1291
1306
|
}
|
|
1292
|
-
process.
|
|
1307
|
+
process.exitCode = emitResult(result, { jq: options.jq, silent: options.silent });
|
|
1293
1308
|
} catch (error) {
|
|
1294
1309
|
process.stderr.write(`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`);
|
|
1295
1310
|
process.exitCode = 1;
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// Shared --jq / --silent output helper for the JSON-emitting dev-loops scripts
|
|
2
|
+
// (issue #981, subsumes #963). One helper applied uniformly so the loop never
|
|
3
|
+
// falls back to `gh api | python3` or inline `node -e` to read tool JSON.
|
|
4
|
+
//
|
|
5
|
+
// Convention (also documented in skills/dev-loop/SKILL.md):
|
|
6
|
+
// prefer the dev-loops subcommand
|
|
7
|
+
// -> `--silent`/`-s` for a yes/no status check (zero output, exit code only)
|
|
8
|
+
// -> `--jq <filter>` to extract a field from the result
|
|
9
|
+
// -> `gh --jq` on a raw gh call
|
|
10
|
+
// -> NEVER `| python3` or `node -e`.
|
|
11
|
+
//
|
|
12
|
+
// jq subset (NOT full jq — fails closed clearly on anything outside this):
|
|
13
|
+
// . identity
|
|
14
|
+
// .field field access (also .a.b.c chains)
|
|
15
|
+
// .field[] iterate an array field
|
|
16
|
+
// .[] iterate an array / object values
|
|
17
|
+
// .[N] / .field[N] index access
|
|
18
|
+
// a | b pipe
|
|
19
|
+
// select(<pred>) filter the current value(s) by a predicate
|
|
20
|
+
// length count of array/string/object
|
|
21
|
+
// keys sorted object keys (or array indices)
|
|
22
|
+
// predicates inside select: .x == <lit>, !=, <, <=, >, >=, and a bare path
|
|
23
|
+
// (truthy test). <lit> is a JSON string/number/true/false/null.
|
|
24
|
+
// Anything else -> JqFilterError (fail closed).
|
|
25
|
+
|
|
26
|
+
export class JqFilterError extends Error {
|
|
27
|
+
constructor(message) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "JqFilterError";
|
|
30
|
+
this.isJqFilterError = true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A jq "stream" is an array of values (jq filters are many-valued). We thread an
|
|
35
|
+
// array through each pipe stage. Identity is [value].
|
|
36
|
+
|
|
37
|
+
function parseLiteral(token) {
|
|
38
|
+
const trimmed = token.trim();
|
|
39
|
+
if (trimmed === "true") return { ok: true, value: true };
|
|
40
|
+
if (trimmed === "false") return { ok: true, value: false };
|
|
41
|
+
if (trimmed === "null") return { ok: true, value: null };
|
|
42
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return { ok: true, value: Number(trimmed) };
|
|
43
|
+
if (
|
|
44
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"') && trimmed.length >= 2) ||
|
|
45
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'") && trimmed.length >= 2)
|
|
46
|
+
) {
|
|
47
|
+
return { ok: true, value: trimmed.slice(1, -1) };
|
|
48
|
+
}
|
|
49
|
+
return { ok: false };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Resolve a single-value path expression like `.`, `.a.b`, `.a[0].b`, `.[2]`.
|
|
53
|
+
// Returns undefined for a missing path (jq yields null; we map missing->undefined
|
|
54
|
+
// then callers coerce). Throws JqFilterError on malformed path syntax.
|
|
55
|
+
function resolvePath(value, path) {
|
|
56
|
+
const trimmed = path.trim();
|
|
57
|
+
if (trimmed === "") {
|
|
58
|
+
// An empty path is never valid jq (no empty filter). Fail closed so
|
|
59
|
+
// malformed predicates like `select()` or `==5` don't pass as identity.
|
|
60
|
+
throw new JqFilterError("Empty path expression");
|
|
61
|
+
}
|
|
62
|
+
if (trimmed === ".") return value;
|
|
63
|
+
if (!trimmed.startsWith(".")) {
|
|
64
|
+
throw new JqFilterError(`Unsupported path (must start with '.'): ${path}`);
|
|
65
|
+
}
|
|
66
|
+
// Tokenize into .field and [index] steps.
|
|
67
|
+
const stepRe = /\.([A-Za-z_][A-Za-z0-9_]*)|\[(\d+)\]/g;
|
|
68
|
+
let cursor = value;
|
|
69
|
+
// Root index access (`.[N]`) starts with a leading '.' that is not part of any
|
|
70
|
+
// step token; consume it so the first `[N]` match aligns at the cursor.
|
|
71
|
+
let lastIndex = trimmed.startsWith(".[") ? 1 : 0;
|
|
72
|
+
stepRe.lastIndex = lastIndex;
|
|
73
|
+
let match;
|
|
74
|
+
let consumedAny = false;
|
|
75
|
+
while ((match = stepRe.exec(trimmed)) !== null) {
|
|
76
|
+
if (match.index !== lastIndex) {
|
|
77
|
+
throw new JqFilterError(`Unsupported path syntax near: ${trimmed.slice(lastIndex)}`);
|
|
78
|
+
}
|
|
79
|
+
lastIndex = stepRe.lastIndex;
|
|
80
|
+
consumedAny = true;
|
|
81
|
+
if (cursor === undefined || cursor === null) {
|
|
82
|
+
cursor = undefined;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (match[1] !== undefined) {
|
|
86
|
+
cursor = typeof cursor === "object" && !Array.isArray(cursor) ? cursor[match[1]] : undefined;
|
|
87
|
+
} else {
|
|
88
|
+
const idx = Number(match[2]);
|
|
89
|
+
cursor = Array.isArray(cursor) ? cursor[idx] : undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!consumedAny || lastIndex !== trimmed.length) {
|
|
93
|
+
throw new JqFilterError(`Unsupported path syntax: ${path}`);
|
|
94
|
+
}
|
|
95
|
+
return cursor;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function evaluatePredicate(value, predicate) {
|
|
99
|
+
const cmpRe = /^(.*?)(==|!=|<=|>=|<|>)(.*)$/;
|
|
100
|
+
const m = predicate.match(cmpRe);
|
|
101
|
+
if (!m) {
|
|
102
|
+
// Bare path => truthy test.
|
|
103
|
+
const resolved = resolvePath(value, predicate);
|
|
104
|
+
return resolved !== undefined && resolved !== null && resolved !== false;
|
|
105
|
+
}
|
|
106
|
+
const [, leftRaw, op, rightRaw] = m;
|
|
107
|
+
// jq maps a missing path to null; resolvePath yields undefined -> normalize.
|
|
108
|
+
const resolvedLeft = resolvePath(value, leftRaw);
|
|
109
|
+
const left = resolvedLeft === undefined ? null : resolvedLeft;
|
|
110
|
+
const lit = parseLiteral(rightRaw);
|
|
111
|
+
if (!lit.ok) {
|
|
112
|
+
throw new JqFilterError(`Unsupported predicate operand: ${rightRaw.trim()}`);
|
|
113
|
+
}
|
|
114
|
+
const right = lit.value;
|
|
115
|
+
switch (op) {
|
|
116
|
+
case "==":
|
|
117
|
+
return left === right;
|
|
118
|
+
case "!=":
|
|
119
|
+
return left !== right;
|
|
120
|
+
case "<":
|
|
121
|
+
case "<=":
|
|
122
|
+
case ">":
|
|
123
|
+
case ">=": {
|
|
124
|
+
// jq never coerces across types (number < string always); JS would. Fail
|
|
125
|
+
// closed when operand types differ so numeric-string fields don't misfire.
|
|
126
|
+
if (typeof left !== typeof right) {
|
|
127
|
+
throw new JqFilterError(`Cannot order-compare ${typeof left} and ${typeof right}`);
|
|
128
|
+
}
|
|
129
|
+
if (op === "<") return left < right;
|
|
130
|
+
if (op === "<=") return left <= right;
|
|
131
|
+
if (op === ">") return left > right;
|
|
132
|
+
return left >= right;
|
|
133
|
+
}
|
|
134
|
+
default:
|
|
135
|
+
throw new JqFilterError(`Unsupported operator: ${op}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Apply one pipe stage to a stream (array of values), returning a new stream.
|
|
140
|
+
function applyStage(stream, rawStage) {
|
|
141
|
+
const stage = rawStage.trim();
|
|
142
|
+
if (stage === "") {
|
|
143
|
+
throw new JqFilterError("Empty filter stage");
|
|
144
|
+
}
|
|
145
|
+
if (stage === "length") {
|
|
146
|
+
return stream.map((v) => {
|
|
147
|
+
if (Array.isArray(v) || typeof v === "string") return v.length;
|
|
148
|
+
if (v && typeof v === "object") return Object.keys(v).length;
|
|
149
|
+
throw new JqFilterError("length: input must be array, string, or object");
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (stage === "keys") {
|
|
153
|
+
return stream.map((v) => {
|
|
154
|
+
if (Array.isArray(v)) return v.map((_, i) => i);
|
|
155
|
+
if (v && typeof v === "object") return Object.keys(v).sort();
|
|
156
|
+
throw new JqFilterError("keys: input must be array or object");
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
const selectMatch = stage.match(/^select\((.*)\)$/);
|
|
160
|
+
if (selectMatch) {
|
|
161
|
+
return stream.filter((v) => evaluatePredicate(v, selectMatch[1]));
|
|
162
|
+
}
|
|
163
|
+
// Top-level comparison expression yields a boolean per input (e.g. .x=="y").
|
|
164
|
+
if (/(==|!=|<=|>=|<|>)/.test(stage)) {
|
|
165
|
+
return stream.map((v) => evaluatePredicate(v, stage));
|
|
166
|
+
}
|
|
167
|
+
// Iteration: a path ending in [] (incl. bare .[]).
|
|
168
|
+
if (stage === ".[]") {
|
|
169
|
+
return stream.flatMap((v) => iterate(v));
|
|
170
|
+
}
|
|
171
|
+
if (stage.endsWith("[]")) {
|
|
172
|
+
const base = stage.slice(0, -2);
|
|
173
|
+
return stream.flatMap((v) => iterate(resolvePath(v, base)));
|
|
174
|
+
}
|
|
175
|
+
// Plain path access (single value per input).
|
|
176
|
+
return stream.map((v) => resolvePath(v, stage));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function iterate(v) {
|
|
180
|
+
if (Array.isArray(v)) return v;
|
|
181
|
+
if (v && typeof v === "object") return Object.values(v);
|
|
182
|
+
throw new JqFilterError("Cannot iterate (.[]) over a non-array/object value");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Split on top-level pipes, ignoring | inside quotes or parentheses.
|
|
186
|
+
function splitPipes(filter) {
|
|
187
|
+
const parts = [];
|
|
188
|
+
let depth = 0;
|
|
189
|
+
let quote = null;
|
|
190
|
+
let current = "";
|
|
191
|
+
for (const ch of filter) {
|
|
192
|
+
if (quote) {
|
|
193
|
+
current += ch;
|
|
194
|
+
if (ch === quote) quote = null;
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (ch === '"' || ch === "'") {
|
|
198
|
+
quote = ch;
|
|
199
|
+
current += ch;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (ch === "(") depth += 1;
|
|
203
|
+
if (ch === ")") depth -= 1;
|
|
204
|
+
if (ch === "|" && depth === 0) {
|
|
205
|
+
parts.push(current);
|
|
206
|
+
current = "";
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
current += ch;
|
|
210
|
+
}
|
|
211
|
+
parts.push(current);
|
|
212
|
+
return parts;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Evaluate a jq-subset filter against a value. Returns the jq "stream" (array of
|
|
216
|
+
// result values). Throws JqFilterError on unsupported syntax.
|
|
217
|
+
export function evaluateJqFilter(value, filter) {
|
|
218
|
+
if (typeof filter !== "string" || filter.trim() === "") {
|
|
219
|
+
throw new JqFilterError("Empty --jq filter");
|
|
220
|
+
}
|
|
221
|
+
let stream = [value];
|
|
222
|
+
for (const stage of splitPipes(filter)) {
|
|
223
|
+
stream = applyStage(stream, stage);
|
|
224
|
+
}
|
|
225
|
+
return stream;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// jq truthiness for --silent: matches `jq -e` exit semantics — the status is
|
|
229
|
+
// based on the LAST output value (empty output is falsy; a value is truthy
|
|
230
|
+
// unless it is null or false).
|
|
231
|
+
function streamIsTruthy(stream) {
|
|
232
|
+
if (stream.length === 0) return false;
|
|
233
|
+
const last = stream[stream.length - 1];
|
|
234
|
+
return last !== null && last !== false && last !== undefined;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderJqStream(stream) {
|
|
238
|
+
// jq prints each value on its own line; single value => just that value.
|
|
239
|
+
return stream
|
|
240
|
+
.map((v) => (typeof v === "string" ? v : JSON.stringify(v ?? null)))
|
|
241
|
+
.join("\n");
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Standard option block to merge into a parseArgs `options` map so every script
|
|
245
|
+
// exposes the same flags.
|
|
246
|
+
export const JQ_OUTPUT_PARSE_OPTIONS = {
|
|
247
|
+
jq: { type: "string" },
|
|
248
|
+
silent: { type: "boolean", short: "s" },
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Shared USAGE fragment so every script documents the flags identically.
|
|
252
|
+
export const JQ_OUTPUT_USAGE = `Output filtering:
|
|
253
|
+
--jq <filter> Apply a jq-subset filter to the result and print it
|
|
254
|
+
(field access, .[]/.[N], pipes, select(...), ==,!=,<,<=,>,>=, length, keys).
|
|
255
|
+
Invalid filter fails closed (stderr + exit 2).
|
|
256
|
+
--silent, -s Suppress stdout; map result to exit code only
|
|
257
|
+
(0 = pass/truthy, 1 = fail/falsy). Composes with --jq as a predicate.`;
|
|
258
|
+
|
|
259
|
+
// Apply --jq / --silent to a result object and emit. Returns the exit code the
|
|
260
|
+
// CLI should use (0 success / truthy, 1 falsy or non-ok, 2 invalid filter).
|
|
261
|
+
// Without jq/silent the result is printed verbatim as JSON (unchanged shape).
|
|
262
|
+
//
|
|
263
|
+
// ok: success of the underlying result (defaults to result.ok !== false).
|
|
264
|
+
export function emitResult(
|
|
265
|
+
result,
|
|
266
|
+
{
|
|
267
|
+
jq = undefined,
|
|
268
|
+
silent = false,
|
|
269
|
+
stdout = process.stdout,
|
|
270
|
+
stderr = process.stderr,
|
|
271
|
+
ok = result?.ok !== false,
|
|
272
|
+
} = {},
|
|
273
|
+
) {
|
|
274
|
+
if (jq !== undefined) {
|
|
275
|
+
let stream;
|
|
276
|
+
try {
|
|
277
|
+
stream = evaluateJqFilter(result, jq);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (error instanceof JqFilterError) {
|
|
280
|
+
// Fail closed, distinct from a clean "predicate false". Exit 2.
|
|
281
|
+
stderr.write(`${JSON.stringify({ ok: false, error: `--jq: ${error.message}` })}\n`);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
throw error;
|
|
285
|
+
}
|
|
286
|
+
if (silent) {
|
|
287
|
+
return streamIsTruthy(stream) ? 0 : 1;
|
|
288
|
+
}
|
|
289
|
+
stdout.write(`${renderJqStream(stream)}\n`);
|
|
290
|
+
return ok ? 0 : 1;
|
|
291
|
+
}
|
|
292
|
+
if (silent) {
|
|
293
|
+
return ok ? 0 : 1;
|
|
294
|
+
}
|
|
295
|
+
stdout.write(`${JSON.stringify(result)}\n`);
|
|
296
|
+
return ok ? 0 : 1;
|
|
297
|
+
}
|