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/CHANGELOG.md +23 -0
- package/dist/docstrings.d.ts +148 -0
- package/dist/docstrings.d.ts.map +1 -0
- package/dist/docstrings.js +1278 -0
- package/dist/docstrings.js.map +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +195 -4
- package/dist/index.js.map +1 -1
- package/manifest.json +1 -1
- package/package.json +24 -10
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, {
|
|
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
|
-
|
|
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.
|
|
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
|
}
|