pm-ops 2026.7.31 → 2026.8.3

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/dist/index.js CHANGED
@@ -3,7 +3,9 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
3
3
  import { createRequire } from "node:module";
4
4
  import { devNull, homedir } from "node:os";
5
5
  import { resolve, basename, dirname, join, relative } from "node:path";
6
+ import { encode } from "@toon-format/toon";
6
7
  import { listMergeReceipts, auditMergeDriverConfiguration, auditMergeAttributeFence, findGitWorkspaceRoot, } from "@unbrained/pm-cli/sdk/merge";
8
+ import { analyzeDocstringCoverage } from "./docstrings.js";
7
9
  // ---------------------------------------------------------------------------
8
10
  // Error contract — mirror pm-cli SDK EXIT_CODE so the host treats thrown
9
11
  // CommandError as a clean non-zero exit instead of re-invoking the handler.
@@ -34,6 +36,7 @@ function renderCommandResult(context) {
34
36
  function readBool(options, ...keys) {
35
37
  return keys.some((key) => options[key] === true || options[key] === "true" || options[key] === "1");
36
38
  }
39
+ /** Read the first non-empty string option among alternative key spellings. */
37
40
  function readString(options, ...keys) {
38
41
  for (const key of keys) {
39
42
  const value = options[key];
@@ -59,7 +62,9 @@ const OPS_COMMAND_PATHS = [
59
62
  "ops audit",
60
63
  "ops metrics",
61
64
  "ops merge-receipts",
65
+ "ops docstrings",
62
66
  ];
67
+ /** Build the variadic additional-repos argument definition shared by every command. */
63
68
  function additionalRepoArguments() {
64
69
  return [{
65
70
  name: "additional-repos",
@@ -68,6 +73,7 @@ function additionalRepoArguments() {
68
73
  description: "Additional repository paths after --repos (optional).",
69
74
  }];
70
75
  }
76
+ /** Construct the repeatable --repos flag definition with a per-command description. */
71
77
  function reposFlag(description) {
72
78
  return {
73
79
  long: "--repos",
@@ -77,6 +83,7 @@ function reposFlag(description) {
77
83
  list: true,
78
84
  };
79
85
  }
86
+ /** Extract every --repos value that follows a command path in the raw argv array. */
80
87
  function cliRepoFlagValues(commandPath, argv = process.argv.slice(2)) {
81
88
  const commandTokens = commandPath.split(" ");
82
89
  let commandEnd = -1;
@@ -109,6 +116,7 @@ function cliRepoFlagValues(commandPath, argv = process.argv.slice(2)) {
109
116
  }
110
117
  return values;
111
118
  }
119
+ /** Re-insert the argv --repos values Commander dropped or truncated for a command. */
112
120
  function restoreCliRepoFlag(commandPath, context) {
113
121
  const cliValues = cliRepoFlagValues(commandPath);
114
122
  if (cliValues.length === 0)
@@ -137,6 +145,7 @@ function hasGlob(path) {
137
145
  function escapeRegexChar(char) {
138
146
  return /[.+^${}()|[\]\\]/.test(char) ? `\\${char}` : char;
139
147
  }
148
+ /** Compile one path segment with glob metacharacters into an anchored RegExp. */
140
149
  function globSegmentToRegex(segment) {
141
150
  let pattern = "";
142
151
  for (let i = 0; i < segment.length; i += 1) {
@@ -162,6 +171,7 @@ function globSegmentToRegex(segment) {
162
171
  }
163
172
  return new RegExp(`^${pattern}$`);
164
173
  }
174
+ /** Expand a glob pattern against the filesystem into a sorted list of paths. */
165
175
  function expandSimpleGlob(pattern) {
166
176
  const expanded = expandHome(pattern);
167
177
  const absolute = /^[A-Za-z]:[\\/]/.test(expanded) ? expanded : resolve(expanded);
@@ -203,6 +213,7 @@ function resolveRepos(options, args = []) {
203
213
  return repos.flatMap((r) => expandSimpleGlob(r));
204
214
  return [process.cwd()];
205
215
  }
216
+ /** Resolve the output format from --format and the host-owned --json global. */
206
217
  function resolveFormat(options, global) {
207
218
  // `--json` is a host-owned global flag: extensions must not redeclare it
208
219
  // (the host rejects the registration) and must read it from ctx.global.
@@ -231,9 +242,16 @@ function resolveFormat(options, global) {
231
242
  * operations pm-ops runs (typecheck, build, test, audit, pack:dry-run,
232
243
  * changelog:check, outdated) — all of which operate on local or public-registry
233
244
  * data.
245
+ *
246
+ * Callers may explicitly unset inherited variables by assigning `undefined`
247
+ * in `opts.env`; those entries are removed before spawning the child.
234
248
  */
235
249
  function runSync(cmd, args, opts = {}) {
236
250
  const env = { ...process.env, ...opts.env };
251
+ for (const [key, value] of Object.entries(env)) {
252
+ if (value === undefined)
253
+ delete env[key];
254
+ }
237
255
  const command = process.platform === "win32" && ["npm", "npx", "pm"].includes(cmd) ? `${cmd}.cmd` : cmd;
238
256
  if (cmd === "npm" || cmd === "npx") {
239
257
  // Prevent npm from reading the user-level .npmrc (which may contain
@@ -258,6 +276,7 @@ function runSync(cmd, args, opts = {}) {
258
276
  return { status: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "", error: r.error };
259
277
  }
260
278
  let pmInvocationCache = null;
279
+ /** Resolve and cache the pm command, falling back to this process when pm is absent. */
261
280
  function resolvePmInvocation() {
262
281
  if (pmInvocationCache)
263
282
  return pmInvocationCache;
@@ -271,6 +290,7 @@ function resolvePmInvocation() {
271
290
  pmInvocationCache = script ? { cmd: process.execPath, args: [script] } : { cmd: "pm", args: [] };
272
291
  return pmInvocationCache;
273
292
  }
293
+ /** Parse JSON, returning undefined on any syntax error instead of throwing. */
274
294
  function parseJsonSafe(text) {
275
295
  try {
276
296
  return JSON.parse(text);
@@ -279,6 +299,7 @@ function parseJsonSafe(text) {
279
299
  return undefined;
280
300
  }
281
301
  }
302
+ /** Read and JSON-parse a file, returning undefined when missing or invalid. */
282
303
  function readJsonFile(path) {
283
304
  if (!existsSync(path))
284
305
  return undefined;
@@ -289,6 +310,7 @@ function readJsonFile(path) {
289
310
  return undefined;
290
311
  }
291
312
  }
313
+ /** Strip comments and trailing commas from JSONC text to yield plain JSON. */
292
314
  function stripJsonc(input) {
293
315
  let output = "";
294
316
  let inString = false;
@@ -334,6 +356,7 @@ function stripJsonc(input) {
334
356
  }
335
357
  return output.replace(/,\s*([}\]])/g, "$1");
336
358
  }
359
+ /** Read and parse a JSONC file by stripping comments before JSON.parse. */
337
360
  function readJsoncFile(path) {
338
361
  if (!existsSync(path))
339
362
  return undefined;
@@ -350,6 +373,7 @@ function readPackageJson(repoPath) {
350
373
  function hasPmChangelogDep(pkg) {
351
374
  return Boolean((pkg?.devDependencies && "pm-changelog" in pkg.devDependencies) || (pkg?.dependencies && "pm-changelog" in pkg.dependencies));
352
375
  }
376
+ /** Resolve a tsconfig extends value to an absolute config file path. */
353
377
  function resolveExtendsPath(currentFile, value) {
354
378
  const withExtension = value.endsWith(".json") ? value : `${value}.json`;
355
379
  if (value.startsWith(".") || value.startsWith("/") || value.startsWith("~")) {
@@ -368,6 +392,7 @@ function resolveExtendsPath(currentFile, value) {
368
392
  }
369
393
  }
370
394
  }
395
+ /** Walk a tsconfig extends chain to find the inherited strict compiler setting. */
371
396
  function readTsConfigStrictSetting(path, seen = new Set()) {
372
397
  const resolved = resolve(path);
373
398
  if (seen.has(resolved))
@@ -474,6 +499,7 @@ function readBlockedCount(repoPath) {
474
499
  function isOffline() {
475
500
  return process.env.PM_OPS_OFFLINE === "1" || process.env.PM_OPS_OFFLINE === "true";
476
501
  }
502
+ /** Count outdated dependencies via npm outdated --json, null when offline or failing. */
477
503
  function countOutdated(repoPath) {
478
504
  if (isOffline())
479
505
  return null;
@@ -485,6 +511,7 @@ function countOutdated(repoPath) {
485
511
  return r.status === 0 ? 0 : null;
486
512
  return Object.keys(parsed).length;
487
513
  }
514
+ /** Read production vulnerability counts from npm audit, throwing on failure. */
488
515
  function readAudit(repoPath) {
489
516
  if (isOffline())
490
517
  return { critical: null, high: null };
@@ -507,6 +534,7 @@ function readAudit(repoPath) {
507
534
  return { critical: v.critical ?? 0, high: v.high ?? 0 };
508
535
  }
509
536
  const AUDIT_UNAVAILABLE_PREFIX = "audit unavailable:";
537
+ /** Reduce an unknown thrown value to a single human-readable diagnostic string. */
510
538
  function describeUnknownError(error) {
511
539
  if (error instanceof Error)
512
540
  return error.message;
@@ -526,6 +554,7 @@ function auditUnavailable(error) {
526
554
  function passesAuditGate(critical, diagnostics) {
527
555
  return isOffline() || (critical === 0 && !diagnostics.some((entry) => entry.startsWith(AUDIT_UNAVAILABLE_PREFIX)));
528
556
  }
557
+ /** Ask gh whether a repo is private, returning null when the query fails. */
529
558
  function ghRepoIsPrivate(repoPath) {
530
559
  if (isOffline())
531
560
  return null;
@@ -537,6 +566,7 @@ function ghRepoIsPrivate(repoPath) {
537
566
  return null;
538
567
  return raw === "true";
539
568
  }
569
+ /** Count open pull requests or issues for a repo via the gh CLI. */
540
570
  function ghOpenCount(repoPath, kind) {
541
571
  if (isOffline())
542
572
  return null;
@@ -549,6 +579,7 @@ function ghOpenCount(repoPath, kind) {
549
579
  const parsed = parseJsonSafe(r.stdout);
550
580
  return Array.isArray(parsed) ? parsed.length : null;
551
581
  }
582
+ /** Gather the full release-readiness snapshot for a single repository path. */
552
583
  function scanRepo(repoPath) {
553
584
  const errors = [];
554
585
  if (!existsSync(repoPath)) {
@@ -629,6 +660,7 @@ function scanRepo(repoPath) {
629
660
  errors,
630
661
  };
631
662
  }
663
+ /** Scan every repo in parallel and roll the snapshots into an aggregate result. */
632
664
  function scanRepos(repos, progress) {
633
665
  const results = repos.map((repo) => {
634
666
  progress(`scanning ${repo}`);
@@ -655,6 +687,7 @@ const GITHUB_HOSTED_RUNNER_PATTERN = /^(?:github-hosted|macos-[A-Za-z0-9._-]+|wi
655
687
  function leadingSpaces(value) {
656
688
  return value.match(/^\s*/)?.[0].length ?? 0;
657
689
  }
690
+ /** Strip comments, quotes, and trailing commas from a YAML scalar value. */
658
691
  function normalizeYamlScalar(value) {
659
692
  return value
660
693
  .replace(/\s+#.*$/, "")
@@ -662,6 +695,7 @@ function normalizeYamlScalar(value) {
662
695
  .trim()
663
696
  .replace(/^['"]|['"]$/g, "");
664
697
  }
698
+ /** Test whether a runs-on scalar names a GitHub-hosted runner label. */
665
699
  function hasGithubHostedRunnerScalar(value) {
666
700
  const normalized = normalizeYamlScalar(value);
667
701
  if (!normalized || normalized.includes("${{"))
@@ -681,6 +715,7 @@ function hasGithubHostedRunnerEntries(values) {
681
715
  return false;
682
716
  return entries.some((entry) => GITHUB_HOSTED_RUNNER_PATTERN.test(entry));
683
717
  }
718
+ /** Detect a GitHub-hosted runner in an inline or block runs-on workflow value. */
684
719
  function hasGithubHostedRunsOnValue(inlineValue, blockLines) {
685
720
  if (hasGithubHostedRunnerScalar(inlineValue))
686
721
  return true;
@@ -705,6 +740,7 @@ function hasGithubHostedRunsOnValue(inlineValue, blockLines) {
705
740
  }
706
741
  return directItems.some(hasGithubHostedRunnerScalar) || hasGithubHostedRunnerEntries(labelItems);
707
742
  }
743
+ /** Validate the package name against the fleet naming convention. */
708
744
  function checkNaming(name) {
709
745
  if (!name)
710
746
  return { id: "naming", severity: "error", pass: false, message: "package.json has no name" };
@@ -714,6 +750,7 @@ function checkNaming(name) {
714
750
  const pass = NAME_PATTERN.test(name);
715
751
  return { id: "naming", severity: "error", pass, message: pass ? `name "${name}" matches ^pm-[a-z][a-z0-9-]*$` : `name "${name}" does not match ^pm-[a-z][a-z0-9-]*$` };
716
752
  }
753
+ /** Verify that every required npm script is defined in package.json. */
717
754
  function checkRequiredScripts(pkg, required) {
718
755
  const scripts = pkg?.scripts ?? {};
719
756
  const missing = required.filter((s) => typeof scripts[s] !== "string");
@@ -725,6 +762,7 @@ function checkRequiredScripts(pkg, required) {
725
762
  details: missing.length > 0 ? missing : undefined,
726
763
  };
727
764
  }
765
+ /** Verify that every required workflow file exists under .github/workflows. */
728
766
  function checkRequiredWorkflows(repoPath, required) {
729
767
  const missing = required.filter((w) => !existsSync(join(repoPath, ".github", "workflows", w)));
730
768
  return {
@@ -735,6 +773,7 @@ function checkRequiredWorkflows(repoPath, required) {
735
773
  details: missing.length > 0 ? missing : undefined,
736
774
  };
737
775
  }
776
+ /** Forbid GitHub-hosted runners in the workflows of a private repository. */
738
777
  function checkPrivateNoRunners(repoPath) {
739
778
  const isPrivate = ghRepoIsPrivate(repoPath);
740
779
  if (isPrivate === null || isPrivate === false) {
@@ -786,6 +825,7 @@ function checkPrivateNoRunners(repoPath) {
786
825
  details: violations.length > 0 ? violations : undefined,
787
826
  };
788
827
  }
828
+ /** Flag any two open pm items that share an identical title. */
789
829
  function checkPmDuplicateTitles(items) {
790
830
  if (items === null)
791
831
  return { id: "pm-duplicate-titles", severity: "warning", pass: true, message: "no pm workspace — check skipped" };
@@ -806,6 +846,7 @@ function checkPmDuplicateTitles(items) {
806
846
  details: dups.length > 0 ? dups.map(([t, n]) => `${t} (${n})`) : undefined,
807
847
  };
808
848
  }
849
+ /** Confirm pm-changelog is present as a dependency and a changelog script. */
809
850
  function checkPmChangelogWired(pkg) {
810
851
  const hasDep = hasPmChangelogDep(pkg);
811
852
  const hasScript = Boolean(pkg?.scripts && typeof pkg.scripts["changelog"] === "string");
@@ -817,6 +858,7 @@ function checkPmChangelogWired(pkg) {
817
858
  message: pass ? "pm-changelog wired (dep + script)" : `pm-changelog not wired (dep: ${hasDep}, script: ${hasScript})`,
818
859
  };
819
860
  }
861
+ /** Dispatch one policy check definition to its implementation and attach severity. */
820
862
  function runPolicyCheck(def, ctx) {
821
863
  let result;
822
864
  switch (def.id) {
@@ -843,6 +885,7 @@ function runPolicyCheck(def, ctx) {
843
885
  }
844
886
  return { ...result, severity: def.severity };
845
887
  }
888
+ /** Decide whether a repo matches a policy check repo_filter expression. */
846
889
  function matchesFilter(repoPath, name, filter) {
847
890
  if (!filter)
848
891
  return true;
@@ -852,6 +895,7 @@ function matchesFilter(repoPath, name, filter) {
852
895
  return true;
853
896
  return basename(repoPath) === filter;
854
897
  }
898
+ /** Run a policy bundle across every repo and tally results by severity. */
855
899
  function runPolicy(repos, bundle, progress) {
856
900
  const by_severity = { error: 0, warning: 0, info: 0 };
857
901
  let totalPassed = 0;
@@ -900,15 +944,24 @@ function summarizeNpmError(stdout, stderr, args) {
900
944
  const lines = combined.split("\n").filter((l) => l.trim() && !l.startsWith(">"));
901
945
  return lines.slice(-3).join(" | ").slice(-2000);
902
946
  }
947
+ /** Execute one npm release step in a repo and time its pass or failure. */
903
948
  function runReleaseCheck(repoPath, name, args, progress) {
904
949
  progress(`verify ${relative(process.cwd(), repoPath) || repoPath}: ${name}`);
905
950
  const start = Date.now();
906
- const r = runSync("npm", args, { cwd: repoPath, timeoutMs: 5 * 60_000 });
951
+ const r = runSync("npm", args, {
952
+ cwd: repoPath,
953
+ timeoutMs: 5 * 60_000,
954
+ // node:test marks its process with NODE_TEST_CONTEXT. Inheriting that
955
+ // marker makes nested `node --test` commands treat the run as recursive
956
+ // and skip the repository's test files.
957
+ env: { NODE_TEST_CONTEXT: undefined },
958
+ });
907
959
  const duration_ms = Date.now() - start;
908
960
  const pass = r.status === 0;
909
961
  const error = pass ? undefined : r.error?.message ?? summarizeNpmError(r.stdout, r.stderr, args);
910
962
  return { name, pass, duration_ms, error };
911
963
  }
964
+ /** Run the release gate steps for one repo, falling back to individual scripts. */
912
965
  function verifyReleaseRepo(repoPath, progress) {
913
966
  if (!existsSync(repoPath)) {
914
967
  return {
@@ -937,6 +990,7 @@ function verifyReleaseRepo(repoPath, progress) {
937
990
  const failed = checks.filter((c) => !c.pass).length;
938
991
  return { path: repoPath, name: pkg?.name ?? null, checks, passed, failed };
939
992
  }
993
+ /** Verify the release gate across every repo and summarize pass and fail counts. */
940
994
  function verifyRelease(repos, progress) {
941
995
  const results = repos.map((r) => verifyReleaseRepo(r, progress));
942
996
  return {
@@ -944,6 +998,7 @@ function verifyRelease(repos, progress) {
944
998
  summary: { total: results.length, passed: results.filter((r) => r.failed === 0).length, failed: results.filter((r) => r.failed > 0).length },
945
999
  };
946
1000
  }
1001
+ /** Render the verify-release result as a markdown table of checks per repo. */
947
1002
  function renderVerifyReleaseMarkdown(result) {
948
1003
  const lines = [
949
1004
  "# pm-ops verify-release",
@@ -967,6 +1022,7 @@ function renderVerifyReleaseMarkdown(result) {
967
1022
  lines.push("");
968
1023
  return lines.join("\n");
969
1024
  }
1025
+ /** Assemble the ready-or-not status verdict and metrics for one repository. */
970
1026
  async function collectStatus(repoPath) {
971
1027
  if (!existsSync(repoPath)) {
972
1028
  return {
@@ -1040,6 +1096,7 @@ async function collectStatus(repoPath) {
1040
1096
  const ready = issues.filter((i) => !i.includes("high vuln") && !i.includes("pending merge receipt")).length === 0 && auditGate;
1041
1097
  return { path: repoPath, name, version, ready, issues, pm_open_items, audit_critical, audit_high, outdated_count, pending_receipts };
1042
1098
  }
1099
+ /** Collect readiness status across every repo in parallel into one result. */
1043
1100
  async function collectStatusAll(repos, progress) {
1044
1101
  const results = [];
1045
1102
  for (const repo of repos) {
@@ -1051,6 +1108,7 @@ async function collectStatusAll(repos, progress) {
1051
1108
  const totalPendingReceipts = results.reduce((sum, r) => sum + (r.pending_receipts ?? 0), 0);
1052
1109
  return { repos: results, summary: { total: results.length, ready, not_ready: results.length - ready, total_issues: totalIssues, total_pending_receipts: totalPendingReceipts } };
1053
1110
  }
1111
+ /** Render the fleet readiness status as a markdown summary table. */
1054
1112
  function renderStatusMarkdown(result) {
1055
1113
  const lines = [];
1056
1114
  lines.push("# pm-ops status");
@@ -1075,6 +1133,7 @@ function renderStatusMarkdown(result) {
1075
1133
  lines.push("");
1076
1134
  return lines.join("\n");
1077
1135
  }
1136
+ /** Gather the outdated-dependency report for a single repository path. */
1078
1137
  function collectOutdatedRepo(repoPath) {
1079
1138
  const pkg = readPackageJson(repoPath);
1080
1139
  if (isOffline()) {
@@ -1105,6 +1164,7 @@ function collectOutdatedRepo(repoPath) {
1105
1164
  }
1106
1165
  return { path: repoPath, name: pkg?.name ?? null, outdated: entries, count: entries.length };
1107
1166
  }
1167
+ /** Collect outdated-dependency reports across every repo in parallel. */
1108
1168
  function collectOutdatedAll(repos, progress) {
1109
1169
  const results = repos.map((repo) => {
1110
1170
  progress(`outdated ${repo}`);
@@ -1114,6 +1174,7 @@ function collectOutdatedAll(repos, progress) {
1114
1174
  const totalOutdated = results.reduce((sum, r) => sum + (r.count ?? 0), 0);
1115
1175
  return { repos: results, summary: { total: results.length, repos_with_outdated: withOutdated, total_outdated: totalOutdated } };
1116
1176
  }
1177
+ /** Render the outdated-dependency report as a markdown table per repo. */
1117
1178
  function renderOutdatedMarkdown(result) {
1118
1179
  const lines = [];
1119
1180
  lines.push("# pm-ops outdated");
@@ -1146,6 +1207,7 @@ function renderOutdatedMarkdown(result) {
1146
1207
  }
1147
1208
  return lines.join("\n");
1148
1209
  }
1210
+ /** Gather the production vulnerability audit for a single repository path. */
1149
1211
  function collectAuditRepo(repoPath) {
1150
1212
  const pkg = readPackageJson(repoPath);
1151
1213
  if (isOffline()) {
@@ -1167,6 +1229,7 @@ function collectAuditRepo(repoPath) {
1167
1229
  const total = v.total ?? 0;
1168
1230
  return { path: repoPath, name: pkg?.name ?? null, critical, high, moderate, low, total, ok: total === 0 };
1169
1231
  }
1232
+ /** Collect production audit reports across every repo in parallel. */
1170
1233
  function collectAuditAll(repos, progress) {
1171
1234
  const results = repos.map((repo) => {
1172
1235
  progress(`audit ${repo}`);
@@ -1179,6 +1242,7 @@ function collectAuditAll(repos, progress) {
1179
1242
  const totalHigh = results.reduce((s, r) => s + (r.high ?? 0), 0);
1180
1243
  return { repos: results, summary: { total: results.length, clean, with_vulns: withVulns, unknown, total_critical: totalCritical, total_high: totalHigh } };
1181
1244
  }
1245
+ /** Render the fleet vulnerability audit as a compact markdown table. */
1182
1246
  function renderAuditMarkdown(result) {
1183
1247
  const lines = [];
1184
1248
  lines.push("# pm-ops audit");
@@ -1423,6 +1487,7 @@ function renderMergeReceiptsMarkdown(result) {
1423
1487
  }
1424
1488
  return lines.join("\n");
1425
1489
  }
1490
+ /** Compose the combined scan, policy, and optional release report for the fleet. */
1426
1491
  function buildReport(repos, progress, includeRelease = false) {
1427
1492
  const scan = scanRepos(repos, progress);
1428
1493
  const policy = runPolicy(repos, DEFAULT_POLICY, progress);
@@ -1441,6 +1506,7 @@ function formatCount(v) {
1441
1506
  function renderMarkdownRow(cells) {
1442
1507
  return `| ${cells.join(" | ")} |`;
1443
1508
  }
1509
+ /** Render the per-repo scan snapshot as a wide markdown readiness table. */
1444
1510
  function renderScanMarkdown(result) {
1445
1511
  const lines = [];
1446
1512
  lines.push("# pm-ops scan");
@@ -1472,6 +1538,7 @@ function renderScanMarkdown(result) {
1472
1538
  lines.push("");
1473
1539
  return lines.join("\n");
1474
1540
  }
1541
+ /** Render the policy result as a markdown table of checks per repo. */
1475
1542
  function renderPolicyMarkdown(result) {
1476
1543
  const lines = [];
1477
1544
  lines.push("# pm-ops policy");
@@ -1494,6 +1561,7 @@ function renderPolicyMarkdown(result) {
1494
1561
  lines.push("");
1495
1562
  return lines.join("\n");
1496
1563
  }
1564
+ /** Render the combined report as scan, policy, and release markdown sections. */
1497
1565
  function renderReportMarkdown(result) {
1498
1566
  const sections = [];
1499
1567
  // Header with timestamp
@@ -1519,6 +1587,7 @@ function renderReportMarkdown(result) {
1519
1587
  const DAY_MS = 86_400_000;
1520
1588
  const CLOSED_STATUSES = new Set(["closed", "done", "completed", "resolved"]);
1521
1589
  const CANCELED_STATUSES = new Set(["canceled", "cancelled", "wontfix", "rejected"]);
1590
+ /** Canonicalize a raw pm status string into a stable lowercase token. */
1522
1591
  function normalizeStatus(raw) {
1523
1592
  const s = (raw ?? "").toLowerCase().trim();
1524
1593
  if (!s)
@@ -1546,6 +1615,7 @@ function parseTime(value) {
1546
1615
  const t = Date.parse(value);
1547
1616
  return Number.isFinite(t) ? t : null;
1548
1617
  }
1618
+ /** Compute the item-status and freshness metrics for one repository asynchronously. */
1549
1619
  async function computeRepoMetrics(repo, nowMs, staleThresholdDays) {
1550
1620
  const items = readAllPmItems(repo);
1551
1621
  const name = repoLabel(repo);
@@ -1636,6 +1706,7 @@ async function computeRepoMetrics(repo, nowMs, staleThresholdDays) {
1636
1706
  merge_fence_installed: merge.fence ? (merge.fence.status === "not_installed" ? 0 : 1) : 0,
1637
1707
  };
1638
1708
  }
1709
+ /** Compute metrics across every repo in parallel and time the full scrape. */
1639
1710
  async function collectMetricsAll(repos, staleThresholdDays, progress) {
1640
1711
  // A Prometheus exporter is scraped repeatedly. The module-level read caches
1641
1712
  // dedupe pm invocations *within one scrape*, but must not survive across
@@ -1663,6 +1734,7 @@ async function collectMetricsAll(repos, staleThresholdDays, progress) {
1663
1734
  function escapeLabel(value) {
1664
1735
  return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
1665
1736
  }
1737
+ /** Format one Prometheus metric sample line with sorted labels and a value. */
1666
1738
  function metricLine(name, labels, value) {
1667
1739
  const entries = Object.entries(labels);
1668
1740
  if (entries.length === 0)
@@ -1714,6 +1786,7 @@ export function disambiguateRepoLabels(repoMetrics) {
1714
1786
  used.add(label);
1715
1787
  }
1716
1788
  }
1789
+ /** Render the fleet metrics as a Prometheus text exposition with help and type. */
1717
1790
  function renderMetricsPrometheus(result) {
1718
1791
  const lines = [];
1719
1792
  const push = (name, help, type, samples) => {
@@ -1784,11 +1857,20 @@ function renderMetricsPrometheus(result) {
1784
1857
  push("pm_scrape_duration_seconds", "Time spent collecting pm metrics for this scrape.", "gauge", [metricLine("pm_scrape_duration_seconds", {}, result.scrape_duration_seconds)]);
1785
1858
  return lines.join("\n") + "\n";
1786
1859
  }
1860
+ /** Serialize a result in the requested transport format with one trailing newline. */
1861
+ function serializeResult(structured, format, formatter) {
1862
+ const body = format === "markdown"
1863
+ ? formatter()
1864
+ : format === "toon"
1865
+ ? encode(structured)
1866
+ : JSON.stringify(structured, null, 2);
1867
+ return body.endsWith("\n") ? body : `${body}\n`;
1868
+ }
1869
+ /** Emit a structured result honoring format and output, returning the host-rendered payload. */
1787
1870
  function emitResult(structured, format, outputPath, formatter) {
1788
1871
  if (outputPath) {
1789
1872
  mkdirSync(dirname(resolve(outputPath)), { recursive: true });
1790
- const body = format === "markdown" ? formatter() : `${JSON.stringify(structured, null, 2)}\n`;
1791
- writeFileSync(outputPath, body, "utf-8");
1873
+ writeFileSync(outputPath, serializeResult(structured, format, formatter), "utf-8");
1792
1874
  console.error(`pm-ops: wrote ${format} output to ${outputPath}`);
1793
1875
  return { written_to: outputPath, format };
1794
1876
  }
@@ -1798,6 +1880,74 @@ function emitResult(structured, format, outputPath, formatter) {
1798
1880
  return renderedCommandResult(`${JSON.stringify(structured, null, 2)}\n`);
1799
1881
  return renderedCommandResult(formatter());
1800
1882
  }
1883
+ /** Cap on violations retained per repo so structured output stays readable for noisy repos. */
1884
+ const DOCSTRING_VIOLATIONS_PER_REPO = 50;
1885
+ /**
1886
+ * Run the docstring analyzer across every repo and roll the per-repo reports
1887
+ * into one aggregate result. A repo with no TypeScript source is recorded as
1888
+ * an error rather than crashing the whole fleet scan.
1889
+ */
1890
+ function collectDocstringsAll(repos, progress) {
1891
+ const repoResults = repos.map((repoPath) => {
1892
+ progress(`docstrings ${relative(process.cwd(), repoPath) || repoPath}`);
1893
+ try {
1894
+ const report = analyzeDocstringCoverage({ root: repoPath });
1895
+ return {
1896
+ repo: repoPath,
1897
+ name: readPackageJson(repoPath)?.name ?? basename(repoPath),
1898
+ files_scanned: report.files_scanned,
1899
+ declarations_checked: report.declarations_checked,
1900
+ violation_count: report.violations.length,
1901
+ violations: report.violations.slice(0, DOCSTRING_VIOLATIONS_PER_REPO),
1902
+ };
1903
+ }
1904
+ catch (err) {
1905
+ return {
1906
+ repo: repoPath,
1907
+ name: basename(repoPath),
1908
+ files_scanned: 0,
1909
+ declarations_checked: 0,
1910
+ violation_count: 0,
1911
+ violations: [],
1912
+ error: err instanceof Error ? err.message : String(err),
1913
+ };
1914
+ }
1915
+ });
1916
+ const totalViolations = repoResults.reduce((sum, repo) => sum + repo.violation_count, 0);
1917
+ return {
1918
+ repos: repoResults,
1919
+ summary: {
1920
+ total: repoResults.length,
1921
+ with_violations: repoResults.filter((repo) => repo.violation_count > 0 || repo.error).length,
1922
+ total_violations: totalViolations,
1923
+ },
1924
+ };
1925
+ }
1926
+ /** Render the docstring coverage result as a markdown table with a per-repo violation breakdown. */
1927
+ function renderDocstringsMarkdown(result) {
1928
+ const lines = [
1929
+ "# pm-ops docstrings",
1930
+ "",
1931
+ `Scanned **${result.summary.total}** repo(s): **${result.summary.with_violations}** with violations, **${result.summary.total_violations}** total violation(s).`,
1932
+ "",
1933
+ renderMarkdownRow(["repo", "files", "declarations", "violations", "sample"]),
1934
+ renderMarkdownRow(["---", "---", "---", "---", "---"]),
1935
+ ];
1936
+ for (const repo of result.repos) {
1937
+ const sample = repo.violations[0]
1938
+ ? `${repo.violations[0].symbol} (${repo.violations[0].reason})`
1939
+ : repo.error ?? "clean";
1940
+ lines.push(renderMarkdownRow([
1941
+ repo.name,
1942
+ String(repo.files_scanned),
1943
+ String(repo.declarations_checked),
1944
+ repo.error ? "error" : String(repo.violation_count),
1945
+ sample.replace(/\s+/g, " ").replace(/\|/g, "\\|").slice(0, 120),
1946
+ ]));
1947
+ }
1948
+ lines.push("");
1949
+ return lines.join("\n");
1950
+ }
1801
1951
  // ---------------------------------------------------------------------------
1802
1952
  // Extension
1803
1953
  // ---------------------------------------------------------------------------
@@ -1812,7 +1962,7 @@ function emitResult(structured, format, outputPath, formatter) {
1812
1962
  const defineExtension = (module) => module;
1813
1963
  export default defineExtension({
1814
1964
  name: "pm-ops",
1815
- version: "2026.7.31",
1965
+ version: "2026.8.3",
1816
1966
  activate(api) {
1817
1967
  if (typeof api.registerRenderer === "function") {
1818
1968
  api.registerRenderer("toon", renderCommandResult);
@@ -2243,6 +2393,47 @@ export default defineExtension({
2243
2393
  return emitResult(result, format, outputPath, () => renderMergeReceiptsMarkdown(result));
2244
2394
  },
2245
2395
  });
2396
+ api.registerCommand({
2397
+ name: "ops docstrings",
2398
+ description: "Audit docstring coverage across repos with a lexer-backed, fail-closed analyzer. " +
2399
+ "Every exported declaration, every public member of an exported class, and every " +
2400
+ "non-exported function with a long body must carry a real JSDoc block comment that " +
2401
+ "adds information the identifier does not. JSDoc inside strings, templates, or " +
2402
+ "commented-out lines cannot satisfy it. Exits non-zero when any repo has violations.",
2403
+ intent: "enforce docstring coverage across many pm repositories",
2404
+ arguments: additionalRepoArguments(),
2405
+ examples: [
2406
+ "pm ops docstrings",
2407
+ "pm ops docstrings --repos ./pm-csv ./pm-github",
2408
+ "pm ops docstrings --format markdown",
2409
+ "pm ops docstrings --repos ./pm-csv --json",
2410
+ ],
2411
+ flags: [
2412
+ reposFlag("Repo paths to audit (comma-separated or repeatable; default: current dir)"),
2413
+ { long: "--format", value_name: "toon|json|markdown", description: "Output format (default: toon)" },
2414
+ { long: "--output", value_name: "file", description: "Write the rendered output to a file instead of stdout" },
2415
+ ],
2416
+ async run(ctx) {
2417
+ const options = ctx.options;
2418
+ const repos = resolveRepos(options, ctx.args);
2419
+ const format = resolveFormat(options, ctx.global);
2420
+ const outputPath = readString(options, "output");
2421
+ console.error(`pm-ops docstrings: ${repos.length} repo(s)`);
2422
+ const result = collectDocstringsAll(repos, (m) => console.error(` ${m}`));
2423
+ console.error(`docstrings: ${result.summary.total_violations} violation(s) across ${result.summary.with_violations}/${result.summary.total} repo(s)`);
2424
+ const failed = result.summary.with_violations;
2425
+ if (failed > 0) {
2426
+ if (outputPath) {
2427
+ emitResult(result, format, outputPath, () => renderDocstringsMarkdown(result));
2428
+ }
2429
+ else {
2430
+ process.stdout.write(serializeResult(result, format, () => renderDocstringsMarkdown(result)));
2431
+ }
2432
+ throw new CommandError(`docstrings: ${failed} repo(s) with violations (${result.summary.total_violations} total)`, EXIT_CODE.GENERIC_FAILURE);
2433
+ }
2434
+ return emitResult(result, format, outputPath, () => renderDocstringsMarkdown(result));
2435
+ },
2436
+ });
2246
2437
  for (const commandPath of OPS_COMMAND_PATHS) {
2247
2438
  api.registerParser(commandPath, (context) => restoreCliRepoFlag(commandPath, context));
2248
2439
  }