forgedock 1.0.22 → 1.1.2
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/README.md +255 -211
- package/bin/batch-runner.mjs +339 -0
- package/bin/cinema.mjs +320 -0
- package/bin/demo.mjs +92 -7
- package/bin/engine/phases.mjs +168 -0
- package/bin/engine/projector.mjs +32 -0
- package/bin/engine/reconcile.mjs +11 -0
- package/bin/engine/runlog.mjs +79 -0
- package/bin/engine/state.mjs +32 -0
- package/bin/engine-cli.mjs +222 -0
- package/bin/engine.mjs +138 -0
- package/bin/forgedock.mjs +896 -1540
- package/bin/hooks/interactive-engine.mjs +509 -0
- package/bin/hooks/pre-tool-use.mjs +498 -0
- package/bin/hooks/subagent-stop-enforce.mjs +197 -0
- package/bin/journey.mjs +841 -0
- package/bin/registry.mjs +139 -0
- package/bin/report.mjs +447 -0
- package/bin/runner.mjs +136 -20
- package/bin/settings-hook.mjs +346 -0
- package/bin/tui.mjs +6 -3
- package/commands/adopt.md +4 -1
- package/commands/analytics.md +4 -1
- package/commands/audit-agents.md +110 -27
- package/commands/audit.md +20 -1
- package/commands/autopilot.md +84 -12
- package/commands/changelog.md +2 -1
- package/commands/ci-audit.md +2 -1
- package/commands/cleanup.md +161 -1
- package/commands/compat-audit.md +2 -1
- package/commands/deploy-info.md +63 -4
- package/commands/design-bench.md +6 -1
- package/commands/design-render-critique-loop.md +2 -1
- package/commands/design.md +1 -0
- package/commands/diagnose.md +2 -1
- package/commands/explain.md +2 -1
- package/commands/forge-stats.md +122 -2
- package/commands/forgedock-init.md +1 -1
- package/commands/geo-audit.md +3 -0
- package/commands/incident-response.md +2 -1
- package/commands/issue.md +24 -3
- package/commands/milestone.md +77 -12
- package/commands/optimize.md +2 -1
- package/commands/orchestrate/config.md +67 -0
- package/commands/orchestrate/phase-1-resolve.md +159 -0
- package/commands/orchestrate/phase-2-triage.md +126 -0
- package/commands/orchestrate/phase-2.5-synthesis.md +156 -0
- package/commands/orchestrate/phase-3-dependency.md +591 -0
- package/commands/orchestrate/phase-4-execution.md +962 -0
- package/commands/orchestrate/phase-5-cleanup.md +47 -0
- package/commands/orchestrate/phase-6-report.md +198 -0
- package/commands/orchestrate/safety.md +62 -0
- package/commands/orchestrate.md +25 -2027
- package/commands/pipeline-health/config.md +34 -0
- package/commands/pipeline-health/phase-1-context.md +180 -0
- package/commands/pipeline-health/phase-2-metrics.md +1729 -0
- package/commands/pipeline-health/phase-3-analyze.md +204 -0
- package/commands/pipeline-health/phase-4-proposals.md +117 -0
- package/commands/pipeline-health/phase-5-report.md +284 -0
- package/commands/pipeline-health/phase-6-summary.md +52 -0
- package/commands/pipeline-health.md +25 -2401
- package/commands/pipeline-resume.md +24 -1
- package/commands/pipeline-status.md +65 -0
- package/commands/qa-sweep.md +4 -1
- package/commands/quality-gate.md +326 -42
- package/commands/replay.md +2 -1
- package/commands/review-pr-agents/api.md +135 -0
- package/commands/review-pr-agents/auth.md +137 -0
- package/commands/review-pr-agents/billing.md +101 -0
- package/commands/review-pr-agents/concurrency.md +145 -0
- package/commands/review-pr-agents/database.md +103 -0
- package/commands/review-pr-agents/frontend.md +89 -0
- package/commands/review-pr-agents/infra.md +385 -0
- package/commands/review-pr-agents/protocols.md +185 -0
- package/commands/review-pr-agents/scraper.md +185 -0
- package/commands/review-pr-agents/security.md +406 -0
- package/commands/review-pr-agents.md +45 -1722
- package/commands/review-pr-staging.md +196 -40
- package/commands/review-pr.md +612 -56
- package/commands/rollback.md +2 -1
- package/commands/scope.md +2 -1
- package/commands/security-audit.md +2 -1
- package/commands/signal-planner.md +623 -0
- package/commands/sync-ecosystem.md +1 -0
- package/commands/test-gate.md +17 -1
- package/commands/validate.md +2 -1
- package/commands/work-on/build/architect.md +28 -0
- package/commands/work-on/build/context.md +2 -0
- package/commands/work-on/build/implement.md +19 -5
- package/commands/work-on/build/validate.md +62 -6
- package/commands/work-on/build.md +187 -30
- package/commands/work-on/close.md +132 -5
- package/commands/work-on/decompose.md +19 -1
- package/commands/work-on/investigate.md +117 -2
- package/commands/work-on/review.md +48 -4
- package/commands/work-on-monolithic.md +8 -2
- package/commands/work-on.md +247 -7
- package/docs/BRAND.md +16 -5
- package/docs/CI.md +2 -2
- package/docs/CODEX.md +36 -1
- package/docs/CONFIG.md +44 -42
- package/docs/FORGE-PROTOCOL.md +15 -1064
- package/docs/anthropic-devrel-outreach.md +2 -2
- package/docs/articles/command-decomposition-token-savings.md +214 -0
- package/docs/articles/model-release-playbook.md +131 -0
- package/docs/articles/per-repo-adaptive-scripts-token-savings.md +27 -25
- package/docs/claude-breakpoints.json +75 -5
- package/docs/design/abc-benchmark.md +1 -1
- package/docs/design/design-spec-schema.md +1 -1
- package/docs/design/divergent-generation.md +1 -1
- package/docs/design/eval-gate-runbook.md +178 -0
- package/docs/design/show-hn-draft.md +26 -1
- package/docs/design/token-cost-baseline-1298.md +59 -0
- package/docs/eval/README.md +117 -0
- package/docs/eval/corpus/seed-v1.json +6 -0
- package/docs/eval/published/claude-sonnet-4-6-2026-07-04.json +33 -0
- package/docs/site/annotations-explained.md +24 -0
- package/docs/site/command-learning-path.md +51 -14
- package/docs/site/command-reference.md +339 -113
- package/docs/site/for-companies.md +95 -0
- package/docs/site/forge-annotation-protocol.md +12 -256
- package/docs/site/getting-started.md +58 -12
- package/docs/site/github-is-the-memory.md +113 -0
- package/docs/site/how-it-works.md +3 -13
- package/docs/site/index.md +10 -2
- package/docs/spec/eval-run-result.md +147 -0
- package/docs/spec/forge-protocol-v1.md +441 -18
- package/docs/spec/label-state-machine.md +55 -0
- package/docs/spec/review-protocol.md +158 -0
- package/docs/spec-graph-schema.md +28 -0
- package/docs/superpowers/plans/2026-07-03-cinematic-onboarding-tui.md +2339 -0
- package/docs/superpowers/plans/2026-07-03-durable-execution-engine.md +1223 -0
- package/docs/superpowers/specs/2026-07-03-cinematic-onboarding-tui-design.md +157 -0
- package/docs/superpowers/specs/2026-07-03-durable-execution-engine-design.md +194 -0
- package/docs/superpowers/specs/2026-07-04-economic-self-governance-design.md +181 -0
- package/docs/superpowers/specs/2026-07-04-provenance-trail-design.md +189 -0
- package/examples/forgedock-demo/README.md +41 -22
- package/examples/forgedock-demo/issues/06-bug-tags-type-validation.md +43 -0
- package/examples/forgedock-demo/issues/07-bug-negative-offset-pagination.md +42 -0
- package/examples/forgedock-demo/issues/08-bug-patch-mass-assignment.md +49 -0
- package/examples/forgedock-demo/issues/09-bug-count-injection.md +47 -0
- package/examples/forgedock-demo/issues/10-feature-sort-param.md +39 -0
- package/examples/forgedock-demo/issues/11-feature-patch-tags.md +37 -0
- package/examples/forgedock-demo/issues/12-feature-bulk-create.md +42 -0
- package/examples/forgedock-demo/issues/13-feature-archive-notes.md +46 -0
- package/examples/forgedock-demo/issues/14-refactor-extract-title-validation.md +41 -0
- package/examples/forgedock-demo/issues/15-refactor-protected-route-flag.md +43 -0
- package/examples/forgedock-demo/issues/16-refactor-extract-store.md +45 -0
- package/examples/forgedock-demo/issues/17-perf-tags-dedup-set.md +35 -0
- package/examples/forgedock-demo/issues/18-perf-remove-redundant-sort.md +37 -0
- package/examples/forgedock-demo/issues/19-docs-api-reference-full.md +53 -0
- package/examples/forgedock-demo/issues/20-docs-intentional-flaw-convention.md +46 -0
- package/examples/forgedock-demo/scripts/smoke.js +18 -1
- package/examples/forgedock-demo/src/db.js +36 -11
- package/examples/forgedock-demo/src/routes/notes.js +106 -9
- package/examples/forgedock-demo/src/server.js +4 -1
- package/package.json +12 -4
- package/packages/protocol/LICENSE +21 -0
- package/packages/protocol/src/cli.js +135 -0
- package/packages/protocol/src/emit.js +146 -0
- package/packages/protocol/src/index.js +43 -0
- package/packages/protocol/src/parse.js +229 -0
- package/packages/protocol/src/types.js +171 -0
- package/packages/protocol/src/validate.js +122 -0
- package/scripts/bench-topology-cost.mjs +372 -0
- package/scripts/check-command-docs-drift.sh +198 -0
- package/scripts/check-protocol-restatements.sh +176 -0
- package/scripts/check-registry/README.md +72 -0
- package/scripts/check-registry/manifest.json +6 -0
- package/scripts/code-index.sh +454 -0
- package/scripts/conformance-check.mjs +168 -0
- package/scripts/doctor-pipeline-state.sh +484 -0
- package/scripts/eval-gate-baseline.json +17 -0
- package/scripts/eval-gate-scorecard.mjs +242 -0
- package/scripts/eval-scorecard-aggregator.mjs +252 -0
- package/scripts/flaky-quarantine.sh +241 -0
- package/scripts/forge-annotation.sh +567 -0
- package/scripts/issue-dedup.sh +236 -0
- package/scripts/validate-annotation-node.mjs +233 -0
- package/scripts/verify-env-vars.sh +18 -2
- package/scripts/verify-host-headers.sh +4 -4
- package/scripts/verify-route-registration.sh +2 -2
- package/scripts/worktree-lifecycle.sh +202 -0
- package/templates/devdocs/agent/using-forgedock.md +14 -0
package/bin/registry.mjs
CHANGED
|
@@ -376,3 +376,142 @@ export async function clearNudgeSeen(dir) {
|
|
|
376
376
|
delete registry.nudgeSeen[absDir];
|
|
377
377
|
});
|
|
378
378
|
}
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// Claude Code version detection and breakpoints resolution (issue #1252)
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
// execFileSync is not imported at the top of this module (it only imports fs
|
|
385
|
+
// builtins). Import it lazily here using createRequire — safe in Node ESM.
|
|
386
|
+
import { createRequire } from "module";
|
|
387
|
+
const _require = createRequire(import.meta.url);
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Detect the installed Claude Code version by running `claude --version`.
|
|
391
|
+
*
|
|
392
|
+
* Returns the version string (e.g. "2.1.197") or null if Claude Code is not
|
|
393
|
+
* installed or the version cannot be parsed. Never throws — callers can safely
|
|
394
|
+
* check for null and degrade gracefully.
|
|
395
|
+
*
|
|
396
|
+
* @returns {string | null} Semver-style version string or null.
|
|
397
|
+
*/
|
|
398
|
+
export function detectClaudeVersion() {
|
|
399
|
+
try {
|
|
400
|
+
const { execFileSync } = _require("child_process");
|
|
401
|
+
const raw = execFileSync("claude", ["--version"], {
|
|
402
|
+
encoding: "utf-8",
|
|
403
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
404
|
+
timeout: 5000,
|
|
405
|
+
}).trim();
|
|
406
|
+
// Claude Code outputs e.g. "Claude Code 2.1.197" or just "2.1.197"
|
|
407
|
+
const m = raw.match(/(\d+\.\d+\.\d+)/);
|
|
408
|
+
return m ? m[1] : null;
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Compare two semver-style version strings (e.g. "2.1.152" vs "2.1.197").
|
|
416
|
+
*
|
|
417
|
+
* Returns:
|
|
418
|
+
* -1 if a < b
|
|
419
|
+
* 0 if a === b
|
|
420
|
+
* 1 if a > b
|
|
421
|
+
*
|
|
422
|
+
* Non-numeric components are compared lexicographically. Handles missing
|
|
423
|
+
* components by treating them as 0.
|
|
424
|
+
*
|
|
425
|
+
* @param {string} a
|
|
426
|
+
* @param {string} b
|
|
427
|
+
* @returns {-1 | 0 | 1}
|
|
428
|
+
*/
|
|
429
|
+
export function compareVersions(a, b) {
|
|
430
|
+
const pa = String(a).split(".").map((s) => parseInt(s, 10) || 0);
|
|
431
|
+
const pb = String(b).split(".").map((s) => parseInt(s, 10) || 0);
|
|
432
|
+
const len = Math.max(pa.length, pb.length);
|
|
433
|
+
for (let i = 0; i < len; i++) {
|
|
434
|
+
const va = pa[i] ?? 0;
|
|
435
|
+
const vb = pb[i] ?? 0;
|
|
436
|
+
if (va < vb) return -1;
|
|
437
|
+
if (va > vb) return 1;
|
|
438
|
+
}
|
|
439
|
+
return 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Load and parse docs/claude-breakpoints.json relative to FORGE_HOME.
|
|
444
|
+
*
|
|
445
|
+
* Returns the parsed breakpoints array or an empty array on any error (file
|
|
446
|
+
* missing, malformed JSON, etc.). Consumers must pass the FORGE_HOME path.
|
|
447
|
+
*
|
|
448
|
+
* @param {string} forgeHome - Absolute path to the ForgeDock repo root.
|
|
449
|
+
* @returns {Array<object>} Array of breakpoint objects.
|
|
450
|
+
*/
|
|
451
|
+
export function loadBreakpoints(forgeHome) {
|
|
452
|
+
try {
|
|
453
|
+
const bpPath = join(forgeHome, "docs", "claude-breakpoints.json");
|
|
454
|
+
const raw = readFileSync(bpPath, "utf-8");
|
|
455
|
+
const parsed = JSON.parse(raw);
|
|
456
|
+
return Array.isArray(parsed.breakpoints) ? parsed.breakpoints : [];
|
|
457
|
+
} catch {
|
|
458
|
+
return [];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Resolve all breakpoints applicable to a given Claude Code version.
|
|
464
|
+
*
|
|
465
|
+
* A breakpoint is "applicable" if its version is > the supplied version,
|
|
466
|
+
* meaning the user is on an older version and will be affected by this
|
|
467
|
+
* breakpoint. This surfaces the set of features/changes the user is missing.
|
|
468
|
+
*
|
|
469
|
+
* If no version is supplied, all breakpoints are returned (useful for
|
|
470
|
+
* listing the full registry).
|
|
471
|
+
*
|
|
472
|
+
* Results are sorted by version ascending.
|
|
473
|
+
*
|
|
474
|
+
* @param {Array<object>} breakpoints - Array from loadBreakpoints().
|
|
475
|
+
* @param {string | null} installedVersion - The user's installed Claude Code version, or null.
|
|
476
|
+
* @returns {Array<object>} Breakpoints the user is missing or all breakpoints if no version.
|
|
477
|
+
*/
|
|
478
|
+
export function resolveBreakpoints(breakpoints, installedVersion) {
|
|
479
|
+
if (!installedVersion) return [...breakpoints].sort((a, b) => compareVersions(a.version, b.version));
|
|
480
|
+
return breakpoints
|
|
481
|
+
.filter((bp) => compareVersions(bp.version, installedVersion) > 0)
|
|
482
|
+
.sort((a, b) => compareVersions(a.version, b.version));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Resolve a single breakpoint by feature_key.
|
|
487
|
+
*
|
|
488
|
+
* Returns the breakpoint object with the matching `feature_key`, or null
|
|
489
|
+
* if not found. Useful for conditional logic: "does this install support
|
|
490
|
+
* effort_levels?" → `resolveBreakpoint(bps, 'effort_levels')`.
|
|
491
|
+
*
|
|
492
|
+
* @param {Array<object>} breakpoints - Array from loadBreakpoints().
|
|
493
|
+
* @param {string} featureKey - The `feature_key` value to look up.
|
|
494
|
+
* @returns {object | null} The matching breakpoint or null.
|
|
495
|
+
*/
|
|
496
|
+
export function resolveBreakpoint(breakpoints, featureKey) {
|
|
497
|
+
return breakpoints.find((bp) => bp.feature_key === featureKey) ?? null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Check whether a detected Claude Code version meets or exceeds the version
|
|
502
|
+
* required for a given feature_key.
|
|
503
|
+
*
|
|
504
|
+
* Returns true if the feature is available, false if not, and null if the
|
|
505
|
+
* feature_key is unknown or the installed version is not detectable.
|
|
506
|
+
*
|
|
507
|
+
* @param {Array<object>} breakpoints - Array from loadBreakpoints().
|
|
508
|
+
* @param {string} featureKey - Feature to check.
|
|
509
|
+
* @param {string | null} installedVersion - Detected Claude Code version.
|
|
510
|
+
* @returns {boolean | null}
|
|
511
|
+
*/
|
|
512
|
+
export function hasFeature(breakpoints, featureKey, installedVersion) {
|
|
513
|
+
if (!installedVersion) return null;
|
|
514
|
+
const bp = resolveBreakpoint(breakpoints, featureKey);
|
|
515
|
+
if (!bp) return null;
|
|
516
|
+
return compareVersions(installedVersion, bp.version) >= 0;
|
|
517
|
+
}
|
package/bin/report.mjs
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bin/report.mjs — ForgeDock pipeline impact report
|
|
6
|
+
*
|
|
7
|
+
* Computes a 30-day (default) summary of pipeline-driven activity for a
|
|
8
|
+
* forge-managed repo:
|
|
9
|
+
* - Issues closed and share with FORGE annotations
|
|
10
|
+
* - Median and p90 open→close time
|
|
11
|
+
* - Merged PR count and share with Closes/Fixes/Resolves cross-references
|
|
12
|
+
* - review-finding and workflow:invalid issue counts
|
|
13
|
+
* - Machine-filed intent share (bot/app authored issues)
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* npx forgedock report [--days 30] [--md] [--json] [--quiet]
|
|
17
|
+
*
|
|
18
|
+
* Flags:
|
|
19
|
+
* --days N Look-back window in days (default: 30)
|
|
20
|
+
* --md Emit paste-ready Markdown block instead of terminal summary
|
|
21
|
+
* --json Emit raw JSON for scripting (supersedes --md)
|
|
22
|
+
* --quiet Suppress the optional ForgeDock fleet pointer in --md output
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { execSync, execFileSync } from "child_process";
|
|
26
|
+
import { readFileSync, existsSync } from "fs";
|
|
27
|
+
import { resolve, dirname, join } from "path";
|
|
28
|
+
import { fileURLToPath } from "url";
|
|
29
|
+
|
|
30
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
31
|
+
const __dirname = dirname(__filename);
|
|
32
|
+
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
// Colours (copied from tui.mjs to keep this module self-contained)
|
|
35
|
+
// ---------------------------------------------------------------------------
|
|
36
|
+
const RESET = "\x1b[0m";
|
|
37
|
+
const BOLD = "\x1b[1m";
|
|
38
|
+
const DIM = "\x1b[2m";
|
|
39
|
+
const GREEN = "\x1b[32m";
|
|
40
|
+
const YELLOW = "\x1b[33m";
|
|
41
|
+
const CYAN = "\x1b[36m";
|
|
42
|
+
const RED = "\x1b[31m";
|
|
43
|
+
const dim = (s) => `${DIM}${s}${RESET}`;
|
|
44
|
+
const bold = (s) => `${BOLD}${s}${RESET}`;
|
|
45
|
+
const green = (s) => `${GREEN}${s}${RESET}`;
|
|
46
|
+
const cyan = (s) => `${CYAN}${s}${RESET}`;
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Helpers
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a `gh` command via execFileSync (no shell — argv array only) and
|
|
54
|
+
* return parsed JSON, or null on failure.
|
|
55
|
+
*
|
|
56
|
+
* MUST receive an argv array, never a shell string — forge.yaml-derived
|
|
57
|
+
* values (owner/repo) flow into these arguments and must never be
|
|
58
|
+
* interpolated into a shell command string. See #1586.
|
|
59
|
+
*
|
|
60
|
+
* Exported (in addition to the default CLI entry point `runReport`) so
|
|
61
|
+
* tests can exercise the real no-shell execution path directly.
|
|
62
|
+
*/
|
|
63
|
+
export function ghJson(args) {
|
|
64
|
+
try {
|
|
65
|
+
const out = execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
|
|
66
|
+
return JSON.parse(out.trim());
|
|
67
|
+
} catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Validate that a forge.yaml-derived owner/repo value is safe to use as a
|
|
74
|
+
* `gh -R owner/repo` argument. GitHub usernames/orgs and repo names are
|
|
75
|
+
* restricted to alphanumerics, hyphens, underscores, and dots.
|
|
76
|
+
*
|
|
77
|
+
* Exported for the same testability reason as `ghJson` above.
|
|
78
|
+
*/
|
|
79
|
+
export function isValidGhIdentifier(value) {
|
|
80
|
+
return typeof value === "string" && /^[\w.-]+$/.test(value);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Read and parse forge.yaml from cwd, returning the project section.
|
|
85
|
+
* Returns null if not found or unparseable.
|
|
86
|
+
*/
|
|
87
|
+
function readForgeYaml(cwd) {
|
|
88
|
+
const candidates = [join(cwd, "forge.yaml"), join(cwd, ".forge.yaml")];
|
|
89
|
+
for (const p of candidates) {
|
|
90
|
+
if (existsSync(p)) {
|
|
91
|
+
try {
|
|
92
|
+
// Minimal inline parser — avoids external deps
|
|
93
|
+
const raw = readFileSync(p, "utf-8");
|
|
94
|
+
const result = {};
|
|
95
|
+
let currentSection = null;
|
|
96
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
97
|
+
if (/^\s*#/.test(line) || /^\s*$/.test(line)) continue;
|
|
98
|
+
const sectionMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(?:#.*)?$/);
|
|
99
|
+
if (sectionMatch) { currentSection = sectionMatch[1]; result[currentSection] = {}; continue; }
|
|
100
|
+
const topMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s+"([^"]*)"(?:\s*#.*)?$|^([a-zA-Z_][a-zA-Z0-9_]*):\s+([^#\n]+?)(?:\s*#.*)?$/);
|
|
101
|
+
if (topMatch && !line.startsWith(" ") && !line.startsWith("\t")) {
|
|
102
|
+
const key = topMatch[1] ?? topMatch[3];
|
|
103
|
+
const value = topMatch[2] ?? topMatch[4]?.trim();
|
|
104
|
+
if (key && value !== undefined) { currentSection = null; result[key] = value; }
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (currentSection && /^\s+/.test(line)) {
|
|
108
|
+
const nestedMatch = line.match(/^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s+"([^"]*)"(?:\s*#.*)?$|^\s+([a-zA-Z_][a-zA-Z0-9_]*):\s+([^#\n]+?)(?:\s*#.*)?$/);
|
|
109
|
+
if (nestedMatch) {
|
|
110
|
+
const key = nestedMatch[1] ?? nestedMatch[3];
|
|
111
|
+
const value = nestedMatch[2] ?? nestedMatch[4]?.trim();
|
|
112
|
+
if (key && value !== undefined && typeof result[currentSection] === "object") {
|
|
113
|
+
result[currentSection][key] = value;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compute median of a sorted numeric array.
|
|
129
|
+
*
|
|
130
|
+
* Exported (in addition to `p90`/`fmtMinutes` below) so tests can exercise
|
|
131
|
+
* the real math directly instead of reimplementing it. See #1592.
|
|
132
|
+
*/
|
|
133
|
+
export function median(sorted) {
|
|
134
|
+
if (!sorted.length) return null;
|
|
135
|
+
const mid = Math.floor(sorted.length / 2);
|
|
136
|
+
return sorted.length % 2 !== 0
|
|
137
|
+
? sorted[mid]
|
|
138
|
+
: (sorted[mid - 1] + sorted[mid]) / 2;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Compute p90 of a sorted numeric array.
|
|
143
|
+
*/
|
|
144
|
+
export function p90(sorted) {
|
|
145
|
+
if (!sorted.length) return null;
|
|
146
|
+
const idx = Math.ceil(sorted.length * 0.9) - 1;
|
|
147
|
+
return sorted[Math.max(0, idx)];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Format minutes as a human-readable duration.
|
|
152
|
+
*/
|
|
153
|
+
export function fmtMinutes(mins) {
|
|
154
|
+
if (mins === null || mins === undefined) return "n/a";
|
|
155
|
+
if (mins < 60) return `${Math.round(mins)}m`;
|
|
156
|
+
if (mins < 1440) return `${Math.round(mins / 60)}h`;
|
|
157
|
+
return `${Math.round(mins / 1440)}d`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Check whether gh is authenticated.
|
|
162
|
+
*/
|
|
163
|
+
function isGhAuthenticated() {
|
|
164
|
+
try {
|
|
165
|
+
execSync("gh auth status", { stdio: "pipe" });
|
|
166
|
+
return true;
|
|
167
|
+
} catch {
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
// Main report computation
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Pure computation of window-scoped issue/PR statistics from already-fetched
|
|
178
|
+
* GitHub API data. Contains no I/O — extracted from `computeStats()` so it
|
|
179
|
+
* can be unit-tested directly against fixture data instead of being
|
|
180
|
+
* reimplemented inline in the test file. See #1592.
|
|
181
|
+
*
|
|
182
|
+
* @param {object} args
|
|
183
|
+
* @param {object[]} args.closedIssues - Raw `gh issue list --state closed` JSON.
|
|
184
|
+
* @param {object[]} args.mergedPRs - Raw `gh pr list --state merged` JSON.
|
|
185
|
+
* @param {string} args.since - ISO timestamp; window lower bound.
|
|
186
|
+
* @param {string} args.ghRepo - `owner/repo` string, echoed back in the result.
|
|
187
|
+
* @param {number} args.days - Look-back window in days, echoed back in the result.
|
|
188
|
+
*/
|
|
189
|
+
export function computeWindowStats({ closedIssues, mergedPRs, since, ghRepo, days }) {
|
|
190
|
+
const windowIssues = closedIssues.filter((i) => i.closedAt && i.closedAt >= since);
|
|
191
|
+
|
|
192
|
+
// Annotated issues (contain FORGE:INVESTIGATOR or FORGE:TRAJECTORY)
|
|
193
|
+
const annotatedCount = windowIssues.filter(
|
|
194
|
+
(i) => (i.body && (i.body.includes("FORGE:INVESTIGATOR") || i.body.includes("FORGE:TRAJECTORY")))
|
|
195
|
+
).length;
|
|
196
|
+
|
|
197
|
+
// Time-to-close in minutes
|
|
198
|
+
const ttcMinutes = windowIssues
|
|
199
|
+
.filter((i) => i.createdAt && i.closedAt)
|
|
200
|
+
.map((i) => (new Date(i.closedAt) - new Date(i.createdAt)) / 60000)
|
|
201
|
+
.filter((m) => m >= 0)
|
|
202
|
+
.sort((a, b) => a - b);
|
|
203
|
+
|
|
204
|
+
const medianTtc = median(ttcMinutes);
|
|
205
|
+
const p90Ttc = p90(ttcMinutes);
|
|
206
|
+
|
|
207
|
+
// Machine-filed issues (bot/app authors)
|
|
208
|
+
const machineFiled = windowIssues.filter((i) => {
|
|
209
|
+
const t = (i.author?.type || "").toLowerCase();
|
|
210
|
+
return t === "bot" || t === "app" || (i.author?.login || "").endsWith("[bot]");
|
|
211
|
+
}).length;
|
|
212
|
+
|
|
213
|
+
// workflow:invalid issues
|
|
214
|
+
const invalidCount = windowIssues.filter((i) =>
|
|
215
|
+
(i.labels || []).some((l) => l.name === "workflow:invalid")
|
|
216
|
+
).length;
|
|
217
|
+
|
|
218
|
+
// review-finding issues
|
|
219
|
+
const reviewFindingCount = windowIssues.filter((i) =>
|
|
220
|
+
(i.labels || []).some((l) => l.name === "review-finding")
|
|
221
|
+
).length;
|
|
222
|
+
|
|
223
|
+
const windowPRs = mergedPRs.filter((p) => p.mergedAt && p.mergedAt >= since);
|
|
224
|
+
|
|
225
|
+
// PRs that reference an issue (Closes/Fixes/Resolves #N)
|
|
226
|
+
const linkedPRs = windowPRs.filter((p) =>
|
|
227
|
+
p.body && /(?:closes?|fixes?|resolves?)\s+#\d+/i.test(p.body)
|
|
228
|
+
).length;
|
|
229
|
+
|
|
230
|
+
// Check if counts are approximate (search-indexed — flag if limit was hit)
|
|
231
|
+
const issueApprox = closedIssues.length >= 500;
|
|
232
|
+
const prApprox = mergedPRs.length >= 500;
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
repo: ghRepo,
|
|
236
|
+
days,
|
|
237
|
+
since,
|
|
238
|
+
issues: {
|
|
239
|
+
closed: windowIssues.length,
|
|
240
|
+
approx: issueApprox,
|
|
241
|
+
annotated: annotatedCount,
|
|
242
|
+
annotatedPct: windowIssues.length
|
|
243
|
+
? Math.round((annotatedCount / windowIssues.length) * 100)
|
|
244
|
+
: 0,
|
|
245
|
+
medianTtc,
|
|
246
|
+
p90Ttc,
|
|
247
|
+
machineFiled,
|
|
248
|
+
machineFiledPct: windowIssues.length
|
|
249
|
+
? Math.round((machineFiled / windowIssues.length) * 100)
|
|
250
|
+
: 0,
|
|
251
|
+
invalid: invalidCount,
|
|
252
|
+
reviewFindings: reviewFindingCount,
|
|
253
|
+
},
|
|
254
|
+
prs: {
|
|
255
|
+
merged: windowPRs.length,
|
|
256
|
+
approx: prApprox,
|
|
257
|
+
linked: linkedPRs,
|
|
258
|
+
linkedPct: windowPRs.length
|
|
259
|
+
? Math.round((linkedPRs / windowPRs.length) * 100)
|
|
260
|
+
: 0,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Fetch and compute all stats for the given repo and window.
|
|
267
|
+
* Returns a structured result object. Throws on hard failures.
|
|
268
|
+
*/
|
|
269
|
+
async function computeStats({ owner, repo, days }) {
|
|
270
|
+
const since = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
|
|
271
|
+
const ghRepo = `${owner}/${repo}`;
|
|
272
|
+
|
|
273
|
+
const closedIssues = ghJson([
|
|
274
|
+
"issue", "list", "-R", ghRepo, "--state", "closed", "--limit", "500",
|
|
275
|
+
"--json", "number,title,body,labels,createdAt,closedAt,author",
|
|
276
|
+
]) ?? [];
|
|
277
|
+
|
|
278
|
+
const mergedPRs = ghJson([
|
|
279
|
+
"pr", "list", "-R", ghRepo, "--state", "merged", "--limit", "500",
|
|
280
|
+
"--json", "number,title,body,mergedAt",
|
|
281
|
+
]) ?? [];
|
|
282
|
+
|
|
283
|
+
return computeWindowStats({ closedIssues, mergedPRs, since, ghRepo, days });
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Output renderers
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function renderTerminal(stats) {
|
|
291
|
+
const { repo, days, issues, prs } = stats;
|
|
292
|
+
const approxIssue = issues.approx ? "~" : "";
|
|
293
|
+
const approxPr = prs.approx ? "~" : "";
|
|
294
|
+
|
|
295
|
+
const lines = [
|
|
296
|
+
"",
|
|
297
|
+
bold(` ForgeDock Pipeline Impact Report`),
|
|
298
|
+
dim(` ${repo} · last ${days} days`),
|
|
299
|
+
"",
|
|
300
|
+
bold(" Issues"),
|
|
301
|
+
` Closed: ${green(approxIssue + issues.closed)}`,
|
|
302
|
+
` Pipeline-driven: ${approxIssue}${issues.annotated} (${issues.annotatedPct}% carry FORGE annotations)`,
|
|
303
|
+
` Median time: ${fmtMinutes(issues.medianTtc)}`,
|
|
304
|
+
` p90 time: ${fmtMinutes(issues.p90Ttc)}`,
|
|
305
|
+
` Machine-filed: ${issues.machineFiled} (${issues.machineFiledPct}%)`,
|
|
306
|
+
` Review findings: ${issues.reviewFindings}`,
|
|
307
|
+
` Invalid/discarded:${issues.invalid}`,
|
|
308
|
+
"",
|
|
309
|
+
bold(" Pull Requests"),
|
|
310
|
+
` Merged: ${green(approxPr + prs.merged)}`,
|
|
311
|
+
` Linked to issue: ${approxPr}${prs.linked} (${prs.linkedPct}%)`,
|
|
312
|
+
"",
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
return lines.join("\n");
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function renderMarkdown(stats, quiet) {
|
|
319
|
+
const { repo, days, issues, prs } = stats;
|
|
320
|
+
const approxIssue = issues.approx ? "~" : "";
|
|
321
|
+
const approxPr = prs.approx ? "~" : "";
|
|
322
|
+
|
|
323
|
+
const lines = [
|
|
324
|
+
`## ForgeDock Pipeline Impact — last ${days} days`,
|
|
325
|
+
``,
|
|
326
|
+
`**Repo:** \`${repo}\``,
|
|
327
|
+
``,
|
|
328
|
+
`### Issues`,
|
|
329
|
+
`| Metric | Value |`,
|
|
330
|
+
`|--------|-------|`,
|
|
331
|
+
`| Closed | ${approxIssue}${issues.closed} |`,
|
|
332
|
+
`| Pipeline-driven (FORGE annotations) | ${approxIssue}${issues.annotated} (${issues.annotatedPct}%) |`,
|
|
333
|
+
`| Median open→close | ${fmtMinutes(issues.medianTtc)} |`,
|
|
334
|
+
`| p90 open→close | ${fmtMinutes(issues.p90Ttc)} |`,
|
|
335
|
+
`| Machine-filed intent | ${issues.machineFiled} (${issues.machineFiledPct}%) |`,
|
|
336
|
+
`| Defects caught by review | ${issues.reviewFindings} |`,
|
|
337
|
+
`| Invalid/discarded | ${issues.invalid} |`,
|
|
338
|
+
``,
|
|
339
|
+
`### Pull Requests`,
|
|
340
|
+
`| Metric | Value |`,
|
|
341
|
+
`|--------|-------|`,
|
|
342
|
+
`| Merged | ${approxPr}${prs.merged} |`,
|
|
343
|
+
`| Linked to issue | ${approxPr}${prs.linked} (${prs.linkedPct}%) |`,
|
|
344
|
+
``,
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
if (!quiet) {
|
|
348
|
+
lines.push(
|
|
349
|
+
`---`,
|
|
350
|
+
`*Generated by [ForgeDock](https://github.com/RapierCraftStudios/ForgeDock). Cross-repo fleet view: [forgedock.com/fleet](https://forgedock.com/fleet)*`,
|
|
351
|
+
``,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return lines.join("\n");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
// CLI entry point
|
|
360
|
+
// ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
export async function runReport(args) {
|
|
363
|
+
// Parse flags
|
|
364
|
+
let days = 30;
|
|
365
|
+
let mdMode = false;
|
|
366
|
+
let jsonMode = false;
|
|
367
|
+
let quiet = false;
|
|
368
|
+
|
|
369
|
+
for (let i = 0; i < args.length; i++) {
|
|
370
|
+
const a = args[i];
|
|
371
|
+
if (a === "--days" || a === "-d") { days = parseInt(args[++i], 10) || 30; }
|
|
372
|
+
else if (a.startsWith("--days=")) { days = parseInt(a.slice(7), 10) || 30; }
|
|
373
|
+
else if (a === "--md") { mdMode = true; }
|
|
374
|
+
else if (a === "--json") { jsonMode = true; }
|
|
375
|
+
else if (a === "--quiet" || a === "-q") { quiet = true; }
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Auth check
|
|
379
|
+
if (!isGhAuthenticated()) {
|
|
380
|
+
process.stderr.write(
|
|
381
|
+
`\n ${RED}Error: gh CLI is not authenticated.${RESET}\n` +
|
|
382
|
+
` Fix: run ${cyan("gh auth login")} and try again.\n\n`
|
|
383
|
+
);
|
|
384
|
+
process.exit(1);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Resolve owner/repo from forge.yaml
|
|
388
|
+
const config = readForgeYaml(process.cwd());
|
|
389
|
+
if (!config || !config.project) {
|
|
390
|
+
process.stderr.write(
|
|
391
|
+
`\n ${RED}Error: forge.yaml not found or missing project section.${RESET}\n` +
|
|
392
|
+
` Fix: run ${cyan("npx forgedock init")} to generate it.\n\n`
|
|
393
|
+
);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const project = config.project;
|
|
398
|
+
const owner = project.owner;
|
|
399
|
+
const repo = project.repo;
|
|
400
|
+
|
|
401
|
+
if (!owner || !repo) {
|
|
402
|
+
process.stderr.write(
|
|
403
|
+
`\n ${RED}Error: forge.yaml is missing project.owner or project.repo.${RESET}\n` +
|
|
404
|
+
` Fix: run ${cyan("npx forgedock init")} to regenerate it.\n\n`
|
|
405
|
+
);
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Validate owner/repo before they ever reach a subprocess call — a hostile
|
|
410
|
+
// forge.yaml (e.g. `owner: "x; echo pwned"`) must be rejected here, not
|
|
411
|
+
// passed through. See #1586.
|
|
412
|
+
if (!isValidGhIdentifier(owner) || !isValidGhIdentifier(repo)) {
|
|
413
|
+
process.stderr.write(
|
|
414
|
+
`\n ${RED}Error: forge.yaml project.owner/project.repo contains invalid characters.${RESET}\n` +
|
|
415
|
+
` Fix: owner and repo must match ${cyan("/^[\\w.-]+$/")} (letters, digits, ., _, -).\n\n`
|
|
416
|
+
);
|
|
417
|
+
process.exit(1);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Check for ForgeDock history
|
|
421
|
+
const testCheck = ghJson(["issue", "list", "-R", `${owner}/${repo}`, "--limit", "1", "--state", "closed", "--json", "number"]) ?? [];
|
|
422
|
+
if (!testCheck.length) {
|
|
423
|
+
process.stdout.write(
|
|
424
|
+
`\n ${YELLOW}No closed issues found in ${owner}/${repo}.${RESET}\n` +
|
|
425
|
+
` Run ${cyan("/work-on")} on your first issue to start building a pipeline history.\n\n`
|
|
426
|
+
);
|
|
427
|
+
process.exit(0);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Compute stats
|
|
431
|
+
let stats;
|
|
432
|
+
try {
|
|
433
|
+
stats = await computeStats({ owner, repo, days });
|
|
434
|
+
} catch (err) {
|
|
435
|
+
process.stderr.write(`\n ${RED}Error computing stats: ${err.message}${RESET}\n\n`);
|
|
436
|
+
process.exit(1);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// Output
|
|
440
|
+
if (jsonMode) {
|
|
441
|
+
process.stdout.write(JSON.stringify(stats, null, 2) + "\n");
|
|
442
|
+
} else if (mdMode) {
|
|
443
|
+
process.stdout.write(renderMarkdown(stats, quiet) + "\n");
|
|
444
|
+
} else {
|
|
445
|
+
process.stdout.write(renderTerminal(stats) + "\n");
|
|
446
|
+
}
|
|
447
|
+
}
|