@usepipr/runtime 0.3.2 → 0.3.5

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.
@@ -13,17 +13,74 @@ import { Buffer as Buffer$1 } from "node:buffer";
13
13
  import { spawn } from "node:child_process";
14
14
  import { Octokit } from "@octokit/rest";
15
15
  import { existsSync, mkdirSync } from "node:fs";
16
+ //#region src/config/package-manifest.ts
17
+ function normalizePackageManifest(value) {
18
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return {};
19
+ const manifest = {};
20
+ const rawManifest = value;
21
+ for (const key of ["dependencies", "devDependencies"]) {
22
+ const dependencyMap = rawManifest[key];
23
+ if (dependencyMap === null || typeof dependencyMap !== "object" || Array.isArray(dependencyMap)) continue;
24
+ const entries = Object.entries(dependencyMap).filter((entry) => typeof entry[1] === "string");
25
+ if (entries.length > 0) manifest[key] = Object.fromEntries(entries);
26
+ }
27
+ return manifest;
28
+ }
29
+ //#endregion
30
+ //#region src/config/scaffold-versions.ts
31
+ const defaultTypesBunVersion = "1.3.14";
32
+ const defaultTypescriptVersion = "6.0.3";
33
+ const defaultScaffoldTypescriptSpec = `typescript@${defaultTypescriptVersion}`;
34
+ //#endregion
16
35
  //#region src/config/config-deps.ts
17
- const runtimeProvidedPackages = new Set(["@usepipr/sdk", "@types/bun"]);
36
+ const runtimeProvidedPackages = /* @__PURE__ */ new Set(["@usepipr/sdk", "@types/bun"]);
18
37
  async function installConfigDependencies(configDir, options = {}) {
19
38
  const packageJsonPath = path.join(configDir, "package.json");
20
- if (!await fileExists$1(packageJsonPath)) return;
21
- if (shouldSkipConfigInstall(JSON.parse(await Bun.file(packageJsonPath).text()))) return;
39
+ if (!await Bun.file(packageJsonPath).exists()) return;
40
+ const originalPackageJson = await Bun.file(packageJsonPath).text();
41
+ const manifest = normalizePackageManifest(JSON.parse(originalPackageJson));
42
+ const installablePackages = installableDependencySpecs(manifest);
43
+ if (installablePackages.length === 0) return;
22
44
  const bunLockPath = path.join(configDir, "bun.lock");
23
- if (options.frozen && !await fileExists$1(bunLockPath)) throw new Error(`${configDir}: bun.lock is required when .pipr/package.json declares dependencies. Run \`bun install\` in .pipr/ and commit bun.lock.`);
45
+ const originalBunLock = await Bun.file(bunLockPath).exists() ? await Bun.file(bunLockPath).text() : void 0;
46
+ if (options.frozen && originalBunLock === void 0) throw new Error(`${configDir}: bun.lock is required when .pipr/package.json declares dependencies. Run \`bun install\` in .pipr/ and commit bun.lock.`);
24
47
  await assertBunAvailable();
25
- const args = ["install", "--ignore-scripts"];
26
- if (options.frozen) args.push("--frozen-lockfile");
48
+ const args = configInstallArgs(installablePackages, { frozen: options.frozen === true && !hasRuntimeProvidedDependencies(manifest) });
49
+ await withSanitizedConfigInstallInputs({
50
+ packageJsonPath,
51
+ originalPackageJson,
52
+ bunLockPath,
53
+ originalBunLock
54
+ }, () => runConfigBunInstall(configDir, args));
55
+ }
56
+ function configInstallArgs(installablePackages, options) {
57
+ return [
58
+ "install",
59
+ "--ignore-scripts",
60
+ "--no-save",
61
+ ...options.frozen ? ["--frozen-lockfile"] : [],
62
+ ...isDefaultScaffoldTypescriptInstall(installablePackages) ? ["--no-verify"] : [],
63
+ ...installablePackages.map((dependency) => dependency.spec)
64
+ ];
65
+ }
66
+ function isDefaultScaffoldTypescriptInstall(installablePackages) {
67
+ return installablePackages.length === 1 && installablePackages[0]?.spec === defaultScaffoldTypescriptSpec;
68
+ }
69
+ async function withSanitizedConfigInstallInputs(inputs, install) {
70
+ let wroteInstallInputs = false;
71
+ try {
72
+ await Bun.write(inputs.packageJsonPath, sanitizedPackageJsonForConfigInstall(inputs.originalPackageJson));
73
+ wroteInstallInputs = true;
74
+ if (inputs.originalBunLock !== void 0) await Bun.write(inputs.bunLockPath, sanitizedBunLockForConfigInstall(inputs.originalBunLock));
75
+ await install();
76
+ } finally {
77
+ if (wroteInstallInputs) {
78
+ await Bun.write(inputs.packageJsonPath, inputs.originalPackageJson);
79
+ if (inputs.originalBunLock !== void 0) await Bun.write(inputs.bunLockPath, inputs.originalBunLock);
80
+ }
81
+ }
82
+ }
83
+ async function runConfigBunInstall(configDir, args) {
27
84
  const proc = Bun.spawn(["bun", ...args], {
28
85
  cwd: configDir,
29
86
  env: process.env,
@@ -33,9 +90,43 @@ async function installConfigDependencies(configDir, options = {}) {
33
90
  const [exitCode, stderr] = await Promise.all([proc.exited, new Response(proc.stderr).text()]);
34
91
  if (exitCode !== 0) throw new Error(`${configDir}: bun install failed (exit ${exitCode}).` + (stderr.trim().length > 0 ? `\n${stderr.trim()}` : ""));
35
92
  }
36
- function shouldSkipConfigInstall(manifest) {
37
- const packages = [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.devDependencies ?? {})];
38
- return packages.length === 0 || packages.every((name) => runtimeProvidedPackages.has(name));
93
+ function installableDependencySpecs(manifest) {
94
+ return dependencyEntries(manifest).filter(([name]) => !runtimeProvidedPackages.has(name)).map(([name, version]) => ({
95
+ name,
96
+ spec: `${name}@${version}`
97
+ }));
98
+ }
99
+ function hasRuntimeProvidedDependencies(manifest) {
100
+ return dependencyEntries(manifest).some(([name]) => runtimeProvidedPackages.has(name));
101
+ }
102
+ function dependencyEntries(manifest) {
103
+ return Object.entries({
104
+ ...manifest.dependencies ?? {},
105
+ ...manifest.devDependencies ?? {}
106
+ });
107
+ }
108
+ function sanitizedPackageJsonForConfigInstall(packageJson) {
109
+ const value = JSON.parse(packageJson);
110
+ for (const key of ["dependencies", "devDependencies"]) {
111
+ const dependencies = value[key];
112
+ if (dependencies === null || typeof dependencies !== "object" || Array.isArray(dependencies)) continue;
113
+ const sanitized = Object.fromEntries(Object.entries(dependencies).filter(([name]) => !runtimeProvidedPackages.has(name)));
114
+ if (Object.keys(sanitized).length === 0) delete value[key];
115
+ else value[key] = sanitized;
116
+ }
117
+ return `${JSON.stringify(value, null, 2)}\n`;
118
+ }
119
+ function sanitizedBunLockForConfigInstall(lockfile) {
120
+ let sanitized = lockfile;
121
+ for (const packageName of runtimeProvidedPackages) {
122
+ const escapedName = escapeRegExp(packageName);
123
+ sanitized = sanitized.replace(new RegExp(`^\\s*"${escapedName}":\\s*"[^"]+",\\r?\\n`, "gm"), "");
124
+ sanitized = sanitized.replace(new RegExp(`^\\s*"${escapedName}":\\s*\\[[^\\n]*\\],\\r?\\n(?:\\r?\\n)?`, "gm"), "");
125
+ }
126
+ return sanitized;
127
+ }
128
+ function escapeRegExp(value) {
129
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39
130
  }
40
131
  async function assertBunAvailable() {
41
132
  try {
@@ -48,9 +139,6 @@ async function assertBunAvailable() {
48
139
  throw new Error("bun is required on PATH to install .pipr/package.json dependencies. Install Bun from https://bun.sh");
49
140
  }
50
141
  }
51
- async function fileExists$1(filePath) {
52
- return await Bun.file(filePath).exists();
53
- }
54
142
  //#endregion
55
143
  //#region src/config/paths.ts
56
144
  function resolveContainedConfigDir(options) {
@@ -365,6 +453,8 @@ async function installTypedSdkStub(configDir) {
365
453
  });
366
454
  await mkdir(sdkRoot, { recursive: true });
367
455
  await Bun.write(path.join(sdkRoot, "package.json"), JSON.stringify({
456
+ name: "@usepipr/sdk",
457
+ version: "0.0.0-pipr-runtime",
368
458
  type: "module",
369
459
  types: "./index.d.ts",
370
460
  exports: { ".": {
@@ -425,23 +515,93 @@ const starterTsconfig = `{
425
515
  }
426
516
  `;
427
517
  //#endregion
518
+ //#region src/shared/semver.ts
519
+ const stableSemverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
520
+ function compareStableSemver(left, right) {
521
+ const leftParts = stableSemverPattern.exec(left);
522
+ const rightParts = stableSemverPattern.exec(right);
523
+ if (!leftParts || !rightParts) throw new Error(`cannot compare non-stable semver versions: ${left}, ${right}`);
524
+ return Number(leftParts[1]) - Number(rightParts[1]) || Number(leftParts[2]) - Number(rightParts[2]) || Number(leftParts[3]) - Number(rightParts[3]);
525
+ }
526
+ //#endregion
527
+ //#region src/shared/version.ts
528
+ const runtimeVersion = "0.3.5";
529
+ //#endregion
530
+ //#region src/config/version-compat.ts
531
+ async function resolveConfigVersionCompatibility(options) {
532
+ const packageJsonPath = path.join(options.configDirPath, "package.json");
533
+ let manifest;
534
+ try {
535
+ manifest = normalizePackageManifest(await Bun.file(packageJsonPath).json());
536
+ } catch (error) {
537
+ if ((error && typeof error === "object" && "code" in error ? error.code : void 0) === "ENOENT") return {
538
+ kind: "unknown",
539
+ runtimeVersion
540
+ };
541
+ throw error;
542
+ }
543
+ const sdkVersion = manifest.dependencies?.["@usepipr/sdk"] ?? manifest.devDependencies?.["@usepipr/sdk"];
544
+ if (!sdkVersion) return {
545
+ kind: "unknown",
546
+ runtimeVersion
547
+ };
548
+ const packageJsonLabel = options.configDir === "." ? "package.json" : `${options.configDir}/package.json`;
549
+ const bunLockLabel = options.configDir === "." ? "bun.lock" : `${options.configDir}/bun.lock`;
550
+ if (!stableSemverPattern.test(sdkVersion)) return {
551
+ kind: "uncomparable",
552
+ runtimeVersion,
553
+ warning: `${packageJsonLabel} declares @usepipr/sdk as ${JSON.stringify(sdkVersion)}; use an exact version to enable Pipr config version checks.`
554
+ };
555
+ if (!stableSemverPattern.test(runtimeVersion)) return {
556
+ kind: "uncomparable",
557
+ runtimeVersion,
558
+ warning: `This Pipr runtime reports version ${runtimeVersion}; skipping Pipr config version checks.`
559
+ };
560
+ const comparison = compareStableSemver(runtimeVersion, sdkVersion);
561
+ if (comparison === 0) return {
562
+ kind: "matched",
563
+ runtimeVersion,
564
+ configVersion: sdkVersion
565
+ };
566
+ if (comparison > 0) return {
567
+ kind: "runtime-newer",
568
+ runtimeVersion,
569
+ configVersion: sdkVersion,
570
+ warning: `${packageJsonLabel} pins @usepipr/sdk ${sdkVersion}, but this Pipr runtime is ${runtimeVersion}. Run \`pipr init --force\` or update ${packageJsonLabel} and ${bunLockLabel} when ready.`
571
+ };
572
+ throw new Error(`${packageJsonLabel} pins @usepipr/sdk ${sdkVersion}, but this Pipr runtime is ${runtimeVersion}. Upgrade Pipr before running this config.`);
573
+ }
574
+ //#endregion
428
575
  //#region src/config/ts-loader.ts
429
576
  async function loadTypescriptConfig(options) {
430
- const { projectDir, relativeConfigDir, configDir } = resolveContainedConfigDir(options);
577
+ const { projectDir, relativeConfigDir } = resolveContainedConfigDir(options);
431
578
  const sourceConfigPath = path.join(projectDir, "config.ts");
432
- if (!await fileExists(sourceConfigPath)) throw new Error(`${configDir}/config.ts is required. Run pipr init to create it.`);
579
+ if (!await Bun.file(sourceConfigPath).exists()) throw new Error(`No Pipr config found at ${sourceConfigPath}.\nRun \`pipr init\` to create one, or pass \`--config-dir <dir>\`.`);
580
+ const versionCompatibility = await resolveConfigVersionCompatibility({
581
+ configDirPath: projectDir,
582
+ configDir: relativeConfigDir
583
+ });
433
584
  if (options.typecheck) await typecheckTypescriptConfig(path.resolve(options.rootDir), relativeConfigDir);
434
585
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pipr-config-"));
435
586
  try {
436
587
  const tempConfigDir = path.join(tempRoot, relativeConfigDir);
437
- await copyConfigDirectory(projectDir, tempConfigDir);
588
+ await cp(projectDir, tempConfigDir, {
589
+ recursive: true,
590
+ errorOnExist: false,
591
+ force: true,
592
+ filter: (source) => {
593
+ const relative = path.relative(projectDir, source);
594
+ return relative !== "node_modules" && !relative.startsWith(`node_modules${path.sep}`);
595
+ }
596
+ });
438
597
  await prepareConfigDirectory(tempConfigDir, { frozen: true });
439
598
  const factory = (await import(`${pathToFileURL(path.join(tempConfigDir, "config.ts")).href}?pipr=${Date.now()}`)).default;
440
599
  if (!isPiprConfigFactory(factory)) throw new Error(`${sourceConfigPath}: default export must be created by definePipr()`);
441
600
  return {
442
601
  plan: buildPiprPlan(factory),
443
602
  source: sourceConfigPath,
444
- tempRoot
603
+ tempRoot,
604
+ versionCompatibility
445
605
  };
446
606
  } finally {
447
607
  await rm(tempRoot, {
@@ -465,13 +625,14 @@ async function typecheckTypescriptConfig(rootDir, relativeConfigDir) {
465
625
  force: true,
466
626
  filter: (source) => {
467
627
  const relative = path.relative(rootDir, source);
468
- const first = relative.split(path.sep)[0] ?? "";
469
- return !ignoredTypecheckRootEntries.has(first) && relative !== "bun.lock";
628
+ const parts = relative.split(path.sep);
629
+ const first = parts[0] ?? "";
630
+ return !ignoredTypecheckRootEntries.has(first) && !parts.includes("node_modules") && relative !== "bun.lock";
470
631
  }
471
632
  });
472
633
  await prepareConfigDirectory(tempConfigDir, { frozen: true });
473
634
  const tsconfigPath = path.join(tempConfigDir, "tsconfig.json");
474
- if (!await fileExists(tsconfigPath)) {
635
+ if (!await Bun.file(tsconfigPath).exists()) {
475
636
  await mkdir(tempConfigDir, { recursive: true });
476
637
  await Bun.write(tsconfigPath, starterTsconfig);
477
638
  }
@@ -484,25 +645,64 @@ async function typecheckTypescriptConfig(rootDir, relativeConfigDir) {
484
645
  }
485
646
  }
486
647
  async function typecheckTypescriptConfigWithApi(configDir, tsconfigPath) {
487
- const ts = await import("typescript");
648
+ const { ts, packageRoot } = await loadTypescriptForConfig(configDir);
488
649
  const config = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
489
650
  if (config.error) throw new Error(formatTypeScriptDiagnostics(ts, [config.error], configDir));
490
651
  const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, configDir);
491
652
  const bundledTypeRoots = [];
492
- if (!await fileExists(path.join(configDir, "node_modules", "@types", "bun", "package.json"))) try {
653
+ if (!await Bun.file(path.join(configDir, "node_modules", "@types", "bun", "package.json")).exists()) try {
493
654
  const require = createRequire(import.meta.url);
494
655
  bundledTypeRoots.push(path.dirname(path.dirname(require.resolve("@types/bun/package.json"))));
495
656
  } catch {}
496
657
  const configPath = path.join(configDir, "config.ts");
497
- const program = ts.createProgram([configPath, ...parsed.fileNames], {
658
+ const compilerOptions = {
498
659
  ...parsed.options,
499
660
  skipLibCheck: true,
500
- typeRoots: [...new Set([...parsed.options.typeRoots ?? [], ...bundledTypeRoots])],
501
- types: [...new Set([...parsed.options.types ?? [], ...bundledTypeRoots.length ? ["bun"] : []])]
502
- });
661
+ typeRoots: [.../* @__PURE__ */ new Set([...parsed.options.typeRoots ?? [], ...bundledTypeRoots])],
662
+ types: [.../* @__PURE__ */ new Set([...parsed.options.types ?? [], ...bundledTypeRoots.length ? ["bun"] : []])]
663
+ };
664
+ const compilerHost = await createTypescriptCompilerHost(ts, compilerOptions, packageRoot);
665
+ const program = ts.createProgram([configPath, ...parsed.fileNames], compilerOptions, compilerHost);
503
666
  const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)];
504
667
  if (diagnostics.length > 0) throw new Error(`TypeScript config check failed for ${path.join(configDir, "config.ts")}:\n` + formatTypeScriptDiagnostics(ts, diagnostics, configDir));
505
668
  }
669
+ async function loadTypescriptForConfig(configDir) {
670
+ const localPackageRoot = path.join(configDir, "node_modules", "typescript");
671
+ const localApiPath = path.join(localPackageRoot, "lib", "typescript.js");
672
+ if (await fileExists(localApiPath)) return {
673
+ ts: typescriptApi(await import(pathToFileURL(localApiPath).href)),
674
+ packageRoot: localPackageRoot
675
+ };
676
+ return {
677
+ ts: typescriptApi(await import("typescript")),
678
+ packageRoot: await runtimeTypescriptPackageRoot()
679
+ };
680
+ }
681
+ function typescriptApi(module) {
682
+ const maybeModule = module;
683
+ if (typeof maybeModule.createProgram === "function") return maybeModule;
684
+ if (typeof maybeModule.default?.createProgram === "function") return maybeModule.default;
685
+ throw new Error("TypeScript module does not expose createProgram");
686
+ }
687
+ async function runtimeTypescriptPackageRoot() {
688
+ try {
689
+ const require = createRequire(import.meta.url);
690
+ const packageRoot = path.dirname(require.resolve("typescript/package.json"));
691
+ return await fileExists(path.join(packageRoot, "lib", "typescript.js")) ? packageRoot : void 0;
692
+ } catch {
693
+ return;
694
+ }
695
+ }
696
+ async function createTypescriptCompilerHost(ts, compilerOptions, packageRoot) {
697
+ const compilerHost = ts.createCompilerHost(compilerOptions, true);
698
+ if (!packageRoot) return compilerHost;
699
+ const libDir = path.join(packageRoot, "lib");
700
+ const defaultLibPath = path.join(libDir, ts.getDefaultLibFileName(compilerOptions));
701
+ if (!await fileExists(defaultLibPath)) return compilerHost;
702
+ compilerHost.getDefaultLibFileName = () => defaultLibPath;
703
+ compilerHost.getDefaultLibLocation = () => libDir;
704
+ return compilerHost;
705
+ }
506
706
  function formatTypeScriptDiagnostics(ts, diagnostics, configDir) {
507
707
  return ts.formatDiagnostics(diagnostics, {
508
708
  getCanonicalFileName: (fileName) => fileName,
@@ -510,19 +710,7 @@ function formatTypeScriptDiagnostics(ts, diagnostics, configDir) {
510
710
  getNewLine: () => "\n"
511
711
  });
512
712
  }
513
- async function copyConfigDirectory(sourceDir, targetDir) {
514
- await cp(sourceDir, targetDir, {
515
- recursive: true,
516
- errorOnExist: false,
517
- force: true,
518
- filter: (source) => !isIgnoredConfigCopyPath(source, sourceDir)
519
- });
520
- }
521
- function isIgnoredConfigCopyPath(source, configDir) {
522
- const relative = path.relative(configDir, source);
523
- return relative === "node_modules" || relative.startsWith(`node_modules${path.sep}`);
524
- }
525
- const ignoredTypecheckRootEntries = new Set([
713
+ const ignoredTypecheckRootEntries = /* @__PURE__ */ new Set([
526
714
  ".fallow",
527
715
  ".git",
528
716
  ".turbo",
@@ -543,8 +731,10 @@ async function loadRuntimeProject(options) {
543
731
  settings: planToRuntimeSettings(loaded.plan, {
544
732
  source: loaded.source,
545
733
  env: options.env,
546
- requireProviderEnv: options.requireProviderEnv
547
- })
734
+ requireProviderEnv: options.requireProviderEnv,
735
+ warnings: [loaded.versionCompatibility.warning].filter((warning) => warning !== void 0)
736
+ }),
737
+ versionCompatibility: loaded.versionCompatibility
548
738
  };
549
739
  }
550
740
  async function validateProject(options) {
@@ -589,7 +779,7 @@ function planToRuntimeSettings(plan, options) {
589
779
  },
590
780
  limits: plan.limits
591
781
  },
592
- warnings: []
782
+ warnings: options.warnings ?? []
593
783
  });
594
784
  }
595
785
  function normalizeAutoResolveConfig(options, defaultProvider) {
@@ -2398,6 +2588,14 @@ type CategorizedFinding = {
2398
2588
  suggestedFix?: string;
2399
2589
  };
2400
2590
 
2591
+ type ReviewSummary = {
2592
+ headline: string;
2593
+ changeSummary: string[];
2594
+ riskLevel: "low" | "medium" | "high";
2595
+ riskSummary: string;
2596
+ reviewerFocus: string[];
2597
+ };
2598
+
2401
2599
  const categorizedFindingSchema = z.strictObject({
2402
2600
  title: z.string(),
2403
2601
  severity: z.enum(["critical", "high", "medium", "low", "nit"]),
@@ -2420,6 +2618,14 @@ const categorizedFindingSchema = z.strictObject({
2420
2618
  suggestedFix: z.string().optional(),
2421
2619
  });
2422
2620
 
2621
+ const reviewSummarySchema = z.strictObject({
2622
+ headline: z.string(),
2623
+ changeSummary: z.array(z.string()).min(1).max(4),
2624
+ riskLevel: z.enum(["low", "medium", "high"]),
2625
+ riskSummary: z.string(),
2626
+ reviewerFocus: z.array(z.string()).max(4),
2627
+ });
2628
+
2423
2629
  export default definePipr((pipr) => {
2424
2630
  const model = pipr.model({
2425
2631
  provider: "deepseek",
@@ -2433,7 +2639,7 @@ export default definePipr((pipr) => {
2433
2639
  const reviewOutput = pipr.schema({
2434
2640
  id: "review/categorized-findings",
2435
2641
  schema: z.strictObject({
2436
- summary: z.string(),
2642
+ summary: reviewSummarySchema,
2437
2643
  findings: z.array(categorizedFindingSchema),
2438
2644
  }),
2439
2645
  });
@@ -2449,6 +2655,11 @@ export default definePipr((pipr) => {
2449
2655
  medium for important follow-up, low for minor actionable improvements,
2450
2656
  and nit only for tiny but concrete issues. Include suggestedFix only when
2451
2657
  there is an exact replacement for the selected range.
2658
+
2659
+ Make summary maintainer-facing and scannable: one concrete headline, one
2660
+ to four behavior-focused change bullets, a risk level with rationale, and
2661
+ reviewer focus only for useful human follow-up. Put actionable defects in
2662
+ findings, not only in summary.
2452
2663
  \`,
2453
2664
  output: reviewOutput,
2454
2665
  tools: pipr.tools.readOnly,
@@ -2477,18 +2688,24 @@ export default definePipr((pipr) => {
2477
2688
  });
2478
2689
  await ctx.comment({
2479
2690
  main: [
2480
- result.summary,
2691
+ "## Summary",
2481
2692
  "",
2482
- "## Findings",
2693
+ \`**\${result.summary.headline}**\`,
2483
2694
  "",
2484
- findingsTable(result.findings),
2695
+ summaryTable(result.summary, result.findings.length),
2696
+ "",
2697
+ "## What Changed",
2698
+ "",
2699
+ bulletList(result.summary.changeSummary, "No changed behavior summarized."),
2700
+ "",
2701
+ "## Reviewer Focus",
2485
2702
  "",
2486
- "<details>",
2487
- "<summary>Finding rationales</summary>",
2703
+ bulletList(result.summary.reviewerFocus, "No special reviewer focus."),
2488
2704
  "",
2489
- findingRationales(result.findings),
2705
+ "## Findings",
2490
2706
  "",
2491
- "</details>",
2707
+ findingsTable(result.findings),
2708
+ ...(result.findings.length > 0 ? ["", findingRationalesBlock(result.findings)] : []),
2492
2709
  ].join("\\n"),
2493
2710
  inlineFindings,
2494
2711
  });
@@ -2499,6 +2716,23 @@ export default definePipr((pipr) => {
2499
2716
  pipr.command({ pattern: "@pipr review", permission: "write", task });
2500
2717
  });
2501
2718
 
2719
+ function summaryTable(summary: ReviewSummary, findingCount: number): string {
2720
+ return [
2721
+ "| Outcome | Risk | Risk summary |",
2722
+ "| --- | --- | --- |",
2723
+ \`| \${findingOutcome(findingCount)} | \${labelValue(summary.riskLevel)} | \${tableCell(
2724
+ summary.riskSummary,
2725
+ )} |\`,
2726
+ ].join("\\n");
2727
+ }
2728
+
2729
+ function findingOutcome(findingCount: number): string {
2730
+ if (findingCount === 0) {
2731
+ return "No findings";
2732
+ }
2733
+ return findingCount === 1 ? "1 finding" : \`\${findingCount} findings\`;
2734
+ }
2735
+
2502
2736
  function findingsTable(findings: CategorizedFinding[]): string {
2503
2737
  if (findings.length === 0) {
2504
2738
  return [
@@ -2519,22 +2753,48 @@ function findingsTable(findings: CategorizedFinding[]): string {
2519
2753
  ].join("\\n");
2520
2754
  }
2521
2755
 
2522
- function findingRationales(findings: CategorizedFinding[]): string {
2756
+ function findingRationalesBlock(findings: CategorizedFinding[]): string {
2523
2757
  if (findings.length === 0) {
2524
- return "No findings.";
2758
+ return "";
2525
2759
  }
2526
- return findings
2527
- .map((finding, index) =>
2528
- [
2529
- \`### \${index + 1}. \${finding.title}\`,
2530
- "",
2531
- \`**Severity:** \${finding.severity.charAt(0).toUpperCase() + finding.severity.slice(1)}\`,
2532
- \`**Category:** \${finding.category.replaceAll("-", " ")}\`,
2533
- "",
2534
- finding.rationale,
2535
- ].join("\\n"),
2536
- )
2537
- .join("\\n\\n");
2760
+ return [
2761
+ "<details>",
2762
+ "<summary>Finding rationales</summary>",
2763
+ "",
2764
+ findings
2765
+ .map((finding, index) =>
2766
+ [
2767
+ \`### \${index + 1}. \${finding.title}\`,
2768
+ "",
2769
+ \`**Severity:** \${labelValue(finding.severity)}\`,
2770
+ \`**Category:** \${labelValue(finding.category)}\`,
2771
+ "",
2772
+ finding.rationale,
2773
+ ].join("\\n"),
2774
+ )
2775
+ .join("\\n\\n"),
2776
+ "",
2777
+ "</details>",
2778
+ ].join("\\n");
2779
+ }
2780
+
2781
+ function bulletList(items: string[], emptyText: string): string {
2782
+ if (items.length === 0) {
2783
+ return emptyText;
2784
+ }
2785
+ return items.map((item) => \`- \${lineText(item)}\`).join("\\n");
2786
+ }
2787
+
2788
+ function labelValue(value: string): string {
2789
+ return value.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase());
2790
+ }
2791
+
2792
+ function lineText(value: string): string {
2793
+ return value.replaceAll("\\n", " ").trim();
2794
+ }
2795
+
2796
+ function tableCell(value: string): string {
2797
+ return lineText(value).replaceAll("|", "\\\\|");
2538
2798
  }
2539
2799
  `
2540
2800
  };
@@ -2552,15 +2812,25 @@ const securitySastRecipe = {
2552
2812
  configTs: `import { definePipr } from "@usepipr/sdk";
2553
2813
  import type { ReviewFinding } from "@usepipr/sdk";
2554
2814
 
2815
+ type SecuritySummary = {
2816
+ headline: string;
2817
+ riskLevel: "low" | "medium" | "high";
2818
+ riskSummary: string;
2819
+ reviewerFocus: string[];
2820
+ };
2821
+
2822
+ type SecurityRisk = {
2823
+ title: string;
2824
+ category: "auth" | "injection" | "secret" | "crypto" | "data-exposure" | "other";
2825
+ severity: "low" | "medium" | "high" | "critical";
2826
+ rationale: string;
2827
+ finding?: ReviewFinding;
2828
+ };
2829
+
2555
2830
  type SecurityReview = {
2556
- summary: string;
2557
- risks: Array<{
2558
- title: string;
2559
- category: "auth" | "injection" | "secret" | "crypto" | "data-exposure" | "other";
2560
- severity: "low" | "medium" | "high" | "critical";
2561
- rationale: string;
2562
- finding?: ReviewFinding;
2563
- }>;
2831
+ summary: SecuritySummary;
2832
+ risks: SecurityRisk[];
2833
+ diagramMermaid?: string;
2564
2834
  };
2565
2835
 
2566
2836
  export default definePipr((pipr) => {
@@ -2578,7 +2848,20 @@ export default definePipr((pipr) => {
2578
2848
  additionalProperties: false,
2579
2849
  required: ["summary", "risks"],
2580
2850
  properties: {
2581
- summary: { type: "string" },
2851
+ summary: {
2852
+ type: "object",
2853
+ additionalProperties: false,
2854
+ required: ["headline", "riskLevel", "riskSummary", "reviewerFocus"],
2855
+ properties: {
2856
+ headline: { type: "string" },
2857
+ riskLevel: { type: "string", enum: ["low", "medium", "high"] },
2858
+ riskSummary: { type: "string" },
2859
+ reviewerFocus: {
2860
+ type: "array",
2861
+ items: { type: "string" },
2862
+ },
2863
+ },
2864
+ },
2582
2865
  risks: {
2583
2866
  type: "array",
2584
2867
  items: {
@@ -2610,6 +2893,7 @@ export default definePipr((pipr) => {
2610
2893
  },
2611
2894
  },
2612
2895
  },
2896
+ diagramMermaid: { type: "string" },
2613
2897
  },
2614
2898
  },
2615
2899
  });
@@ -2621,6 +2905,11 @@ export default definePipr((pipr) => {
2621
2905
  Review for exploitable security issues only. Focus on auth bypasses,
2622
2906
  injection, unsafe deserialization, secret exposure, cryptography misuse,
2623
2907
  authorization gaps, and data exposure. Do not report hypothetical style issues.
2908
+ Make summary maintainer-facing and scannable with a concrete headline,
2909
+ risk level, risk rationale, and only useful security follow-up. Set
2910
+ diagramMermaid only when a high or critical risk has a concrete source-to-sink
2911
+ path and a small Mermaid flowchart clarifies it. Do not include Markdown
2912
+ fences in diagramMermaid.
2624
2913
  \`,
2625
2914
  output: securityOutput,
2626
2915
  tools: pipr.tools.readOnly,
@@ -2636,14 +2925,40 @@ export default definePipr((pipr) => {
2636
2925
  async run(ctx) {
2637
2926
  const manifest = await ctx.change.diffManifest({ compressed: true });
2638
2927
  const result = await ctx.pi.run(security, { manifest });
2639
- const inlineFindings = result.risks.flatMap((risk) => (risk.finding ? [risk.finding] : []));
2640
- if (result.risks.some((risk) => risk.severity === "critical" || risk.severity === "high")) {
2928
+ const inlineFindings: ReviewFinding[] = result.risks.flatMap((risk) =>
2929
+ risk.finding ? [risk.finding] : [],
2930
+ );
2931
+ const hasHighOrCriticalRisk = result.risks.some(isHighOrCriticalRisk);
2932
+ const hasConcreteHighOrCriticalRisk = result.risks.some(
2933
+ (risk) => isHighOrCriticalRisk(risk) && risk.finding,
2934
+ );
2935
+ if (hasHighOrCriticalRisk) {
2641
2936
  ctx.check.fail("High or critical security risk found.");
2642
2937
  } else {
2643
2938
  ctx.check.pass("No high or critical security risks found.");
2644
2939
  }
2645
2940
  await ctx.comment({
2646
- main: result.summary,
2941
+ main: [
2942
+ "## Summary",
2943
+ "",
2944
+ \`**\${result.summary.headline}**\`,
2945
+ "",
2946
+ securityStatusTable(result.summary, result.risks),
2947
+ "",
2948
+ result.summary.riskSummary,
2949
+ "",
2950
+ "## Reviewer Focus",
2951
+ "",
2952
+ bulletList(result.summary.reviewerFocus, "No special security follow-up."),
2953
+ "",
2954
+ "## Security Risks",
2955
+ "",
2956
+ securityRisksTable(result.risks),
2957
+ ...(result.risks.length > 0 ? ["", riskRationalesBlock(result.risks)] : []),
2958
+ ...(hasConcreteHighOrCriticalRisk && result.diagramMermaid?.trim()
2959
+ ? ["", attackPathDiagramBlock(result.diagramMermaid, hasConcreteHighOrCriticalRisk)]
2960
+ : []),
2961
+ ].join("\\n"),
2647
2962
  inlineFindings,
2648
2963
  });
2649
2964
  },
@@ -2652,6 +2967,122 @@ export default definePipr((pipr) => {
2652
2967
  pipr.on.changeRequest({ actions: ["opened", "updated", "reopened", "ready"], task });
2653
2968
  pipr.command({ pattern: "@pipr security", permission: "write", task });
2654
2969
  });
2970
+
2971
+ function securityStatusTable(summary: SecuritySummary, risks: SecurityRisk[]): string {
2972
+ return [
2973
+ "| Status | Summary risk | Max severity | Risks |",
2974
+ "| --- | --- | --- | ---: |",
2975
+ \`| \${risks.some(isHighOrCriticalRisk) ? "Fail" : "Pass"} | \${labelValue(
2976
+ summary.riskLevel,
2977
+ )} | \${maxSeverity(risks)} | \${risks.length} |\`,
2978
+ ].join("\\n");
2979
+ }
2980
+
2981
+ function securityRisksTable(risks: SecurityRisk[]): string {
2982
+ if (risks.length === 0) {
2983
+ return [
2984
+ "| Severity | Category | Title |",
2985
+ "| --- | --- | --- |",
2986
+ "| - | - | No security risks found. |",
2987
+ ].join("\\n");
2988
+ }
2989
+ return [
2990
+ "| Severity | Category | Title |",
2991
+ "| --- | --- | --- |",
2992
+ ...risks.map(
2993
+ (risk) =>
2994
+ \`| \${labelValue(risk.severity)} | \${tableCell(risk.category)} | \${tableCell(risk.title)} |\`,
2995
+ ),
2996
+ ].join("\\n");
2997
+ }
2998
+
2999
+ function riskRationalesBlock(risks: SecurityRisk[]): string {
3000
+ if (risks.length === 0) {
3001
+ return "";
3002
+ }
3003
+ return [
3004
+ "<details>",
3005
+ "<summary>Risk rationales</summary>",
3006
+ "",
3007
+ risks
3008
+ .map((risk, index) =>
3009
+ [
3010
+ \`### \${index + 1}. \${risk.title}\`,
3011
+ "",
3012
+ \`**Severity:** \${labelValue(risk.severity)}\`,
3013
+ \`**Category:** \${labelValue(risk.category)}\`,
3014
+ "",
3015
+ risk.rationale,
3016
+ ].join("\\n"),
3017
+ )
3018
+ .join("\\n\\n"),
3019
+ "",
3020
+ "</details>",
3021
+ ].join("\\n");
3022
+ }
3023
+
3024
+ function attackPathDiagramBlock(
3025
+ diagramMermaid: string | undefined,
3026
+ hasConcreteHighOrCriticalRisk: boolean,
3027
+ ): string {
3028
+ const diagram = diagramMermaid?.trim();
3029
+ if (!diagram || !hasConcreteHighOrCriticalRisk) {
3030
+ return "";
3031
+ }
3032
+ const fence = markdownFenceFor(diagram);
3033
+ return [
3034
+ "<details>",
3035
+ "<summary>Attack path diagram</summary>",
3036
+ "",
3037
+ \`\${fence}mermaid\`,
3038
+ diagram,
3039
+ fence,
3040
+ "",
3041
+ "</details>",
3042
+ ].join("\\n");
3043
+ }
3044
+
3045
+ function maxSeverity(risks: SecurityRisk[]): string {
3046
+ const order: SecurityRisk["severity"][] = ["low", "medium", "high", "critical"];
3047
+ const severity = risks.reduce<SecurityRisk["severity"] | undefined>((current, risk) => {
3048
+ if (!current || order.indexOf(risk.severity) > order.indexOf(current)) {
3049
+ return risk.severity;
3050
+ }
3051
+ return current;
3052
+ }, undefined);
3053
+ return severity ? labelValue(severity) : "None";
3054
+ }
3055
+
3056
+ function isHighOrCriticalRisk(risk: SecurityRisk): boolean {
3057
+ return risk.severity === "high" || risk.severity === "critical";
3058
+ }
3059
+
3060
+ function bulletList(items: string[], emptyText: string): string {
3061
+ if (items.length === 0) {
3062
+ return emptyText;
3063
+ }
3064
+ return items.map((item) => \`- \${lineText(item)}\`).join("\\n");
3065
+ }
3066
+
3067
+ function labelValue(value: string): string {
3068
+ return value.replaceAll("-", " ").replace(/^./, (char) => char.toUpperCase());
3069
+ }
3070
+
3071
+ function lineText(value: string): string {
3072
+ return value.replaceAll("\\n", " ").trim();
3073
+ }
3074
+
3075
+ function tableCell(value: string): string {
3076
+ return lineText(value).replaceAll("|", "\\\\|");
3077
+ }
3078
+
3079
+ function markdownFenceFor(value: string): string {
3080
+ const longestBacktickRun = Math.max(
3081
+ 0,
3082
+ ...[...value.matchAll(/\`+/g)].map((match) => match[0].length),
3083
+ );
3084
+ return "\`".repeat(Math.max(3, longestBacktickRun + 1));
3085
+ }
2655
3086
  `
2656
3087
  };
2657
3088
  //#endregion
@@ -2713,9 +3144,8 @@ function isOfficialInitRecipeId(recipe) {
2713
3144
  //#endregion
2714
3145
  //#region src/config/init.ts
2715
3146
  const supportedOfficialInitAdapters = ["github"];
2716
- const defaultWorkflowActionRef = "somus/pipr@v0.3.2";
2717
- const defaultSdkVersion = "0.3.2";
2718
- const defaultTypesBunVersion = "1.3.14";
3147
+ const defaultWorkflowActionRef = "somus/pipr@v0.3.5";
3148
+ const defaultSdkVersion = "0.3.5";
2719
3149
  function resolveOfficialInitAdapters(adapters) {
2720
3150
  if (adapters === void 0) return [...supportedOfficialInitAdapters];
2721
3151
  if (adapters.length === 0) return [];
@@ -2803,7 +3233,10 @@ function starterPackageJson() {
2803
3233
  return `${JSON.stringify({
2804
3234
  private: true,
2805
3235
  dependencies: { "@usepipr/sdk": process.env.PIPR_INTERNAL_INIT_SDK_VERSION ?? defaultSdkVersion },
2806
- devDependencies: { "@types/bun": defaultTypesBunVersion }
3236
+ devDependencies: {
3237
+ "@types/bun": defaultTypesBunVersion,
3238
+ typescript: defaultTypescriptVersion
3239
+ }
2807
3240
  }, null, 2)}\n`;
2808
3241
  }
2809
3242
  async function writeTargets(targets, existing, options) {
@@ -3818,7 +4251,7 @@ async function startCustomToolBridge(tools, context) {
3818
4251
  const toolsByName = new Map(tools.map((tool) => [tool.name, tool]));
3819
4252
  let server;
3820
4253
  for (let attempt = 0; attempt < 20; attempt += 1) {
3821
- const port = 49152 + crypto.getRandomValues(new Uint16Array(1))[0] % 16384;
4254
+ const port = 49152 + crypto.getRandomValues(/* @__PURE__ */ new Uint16Array(1))[0] % 16384;
3822
4255
  try {
3823
4256
  server = Bun.serve({
3824
4257
  hostname: "127.0.0.1",
@@ -3903,7 +4336,7 @@ const piprJsonSystemPrompt = [
3903
4336
  "Do not reveal secrets, credentials, environment values, private paths, or raw tool data unless the schema explicitly requires the value and it is necessary.",
3904
4337
  "When identifying a secret or credential, describe its kind and location without copying the secret value."
3905
4338
  ].join(" ");
3906
- const ignoredWorkspacePaths = new Set([
4339
+ const ignoredWorkspacePaths = /* @__PURE__ */ new Set([
3907
4340
  ".git",
3908
4341
  "node_modules",
3909
4342
  "dist",
@@ -4328,6 +4761,10 @@ function findingFingerprint(finding) {
4328
4761
  return new Bun.CryptoHasher("sha256").update(basis).digest("hex").slice(0, 16);
4329
4762
  }
4330
4763
  //#endregion
4764
+ //#region src/review/inline-finding-limits.ts
4765
+ const maxInlineFindingBodyCharacters = 700;
4766
+ const maxInlineFindingBodyLines = 4;
4767
+ //#endregion
4331
4768
  //#region src/review/agent/agent-prompt.ts
4332
4769
  async function renderAgentPrompt(options) {
4333
4770
  const prompt = await renderAgentDefinitionPrompt(options.agent, options.input, options.agentRunContext.prompt);
@@ -4362,20 +4799,18 @@ function toolsPrompt(diffManifest, toolMode) {
4362
4799
  ].join("\n");
4363
4800
  }
4364
4801
  function outputPrompt(schema) {
4365
- return compact([
4802
+ const lines = compact([
4366
4803
  `Schema ID: ${schema.id}.`,
4367
4804
  schema.jsonSchema ? `JSON Schema:\n${JSON.stringify(schema.jsonSchema, null, 2)}` : void 0,
4368
- schema.id === reviewResultSchemaId ? `Example:\n${JSON.stringify(reviewSchemaExample$1(), null, 2)}` : void 0,
4369
- schema.id === reviewResultSchemaId ? "`suggestedFix` is exact replacement code for the selected range. Do not include Markdown fences, prose, or labels in `suggestedFix`." : void 0,
4370
- schema.id === reviewResultSchemaId ? "GitHub applies `suggestedFix` to the selected `startLine` through `endLine`. Select the smallest contiguous line span that the replacement code should replace." : void 0,
4371
- schema.id === reviewResultSchemaId ? "If a fix changes only part of a line, select that whole line and put the full replacement line in `suggestedFix`. If a fix changes multiple lines, select exactly those original lines and put the full replacement block in `suggestedFix`." : void 0,
4372
- schema.id === reviewResultSchemaId ? "Do not select a larger enclosing block to replace a smaller statement, and do not select one line when the replacement is for a multi-line section. Omit `suggestedFix` if the exact replacement range is uncertain." : void 0,
4373
- schema.id === reviewResultSchemaId ? "Omit `suggestedFix` for broad rewrites, generated docs/pages, uncertain ranges, or changes better described in prose." : void 0,
4374
4805
  "Return exactly one JSON value matching the schema.",
4375
4806
  "The first non-whitespace character must be { or [ and the last non-whitespace character must be } or ].",
4376
- "Do not include Markdown, code fences, prose, explanations, or leading/trailing text.",
4377
- schema.id === reviewResultSchemaId ? "For inlineFindings, use only fields shown in the schema and only exact Diff Manifest commentable ranges. If no exact range applies, omit the finding." : void 0
4378
- ]).join("\n\n");
4807
+ "Do not include Markdown, code fences, prose, explanations, or leading/trailing text."
4808
+ ]);
4809
+ if (schema.id === reviewResultSchemaId) {
4810
+ lines.splice(2, 0, `Example:\n${JSON.stringify(reviewSchemaExample$1(), null, 2)}`, "`suggestedFix` is exact replacement code for the selected range. Do not include Markdown fences, prose, or labels in `suggestedFix`.", "GitHub applies `suggestedFix` to the selected `startLine` through `endLine`. Select the smallest contiguous line span that the replacement code should replace.", "If a fix changes only part of a line, select that whole line and put the full replacement line in `suggestedFix`. If a fix changes multiple lines, select exactly those original lines and put the full replacement block in `suggestedFix`.", "Do not select a larger enclosing block to replace a smaller statement, and do not select one line when the replacement is for a multi-line section. Omit `suggestedFix` if the exact replacement range is uncertain.", "Omit `suggestedFix` for broad rewrites, generated docs/pages, uncertain ranges, or changes better described in prose.");
4811
+ lines.push("For inlineFindings, use only fields shown in the schema and only exact Diff Manifest commentable ranges. If no exact range applies, omit the finding.", `For inlineFindings.body, write the exact inline comment body. Use one short paragraph, at most two sentences, and at most 700 characters. Treat 700 as a hard ceiling, not a target; prefer 250-450 characters when possible.`);
4812
+ }
4813
+ return lines.join("\n\n");
4379
4814
  }
4380
4815
  function reviewPolicyPrompt(schema) {
4381
4816
  if (schema.id !== reviewResultSchemaId) return;
@@ -4383,7 +4818,9 @@ function reviewPolicyPrompt(schema) {
4383
4818
  "Review only changed behavior.",
4384
4819
  "Report only actionable defects, security risks, regressions, or meaningful test gaps.",
4385
4820
  "Put each actionable issue in inlineFindings. Do not leave actionable defects or test gaps only in the summary.",
4386
- "Keep each inline finding body to one short paragraph, at most two sentences, and under 700 characters.",
4821
+ "Inline finding bodies are final code-review comments, not analysis notes.",
4822
+ `State the concrete defect and user-visible or runtime impact directly. Keep each body to one short paragraph, at most two sentences, and at most 700 characters.`,
4823
+ "Do not include step-by-step reasoning, broad context, praise, restated diff, alternatives, or code snippets unless they are necessary to identify the defect.",
4387
4824
  "Omit speculative, style-only, broad refactor, external-fact, and out-of-diff findings.",
4388
4825
  "Use read tools when more context is needed. If evidence is insufficient, omit the finding.",
4389
4826
  "Emit one inline finding per issue, anchored to the exact Diff Manifest commentable range.",
@@ -4798,67 +5235,17 @@ function buildRepairPrompt(options) {
4798
5235
  ].join("\n\n");
4799
5236
  }
4800
5237
  //#endregion
4801
- //#region package.json
4802
- var version = "0.3.2";
4803
- //#endregion
4804
5238
  //#region src/review/comment-branding.ts
4805
- const piprLogoUrl = "https://pipr.run/apple-touch-icon.png";
5239
+ const piprLogoUrl = "https://pipr.run/images/pipr/pipr-mark.svg";
4806
5240
  const piprRepositoryUrl = "https://github.com/somus/pipr";
4807
5241
  const mainCommentTitle = `# <img src="${piprLogoUrl}" width="22" height="22" alt=""> Pipr Review`;
4808
- const mainCommentTitles = new Set([
5242
+ const mainCommentTitles = /* @__PURE__ */ new Set([
4809
5243
  "# pipr Review",
4810
5244
  "# Pipr Review",
4811
5245
  mainCommentTitle
4812
5246
  ]);
4813
5247
  const escapedRepositoryUrl = piprRepositoryUrl.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4814
- const mainCommentAttributionPattern = new RegExp(`^<sub>Review generated by \\[Pipr\\]\\(${escapedRepositoryUrl}\\) for commit \`[^\`]+\`\\.</sub>$`);
4815
- //#endregion
4816
- //#region src/review/inline-publication-policy.ts
4817
- const maxSuggestedFixSelectedLines = 12;
4818
- const maxSuggestedFixReplacementLines = 20;
4819
- function inlinePublicationDecision(options) {
4820
- if (options.existing.markers.has(options.marker) || hasExistingInlinePublicationLocation(options.existing.locations, options.location)) return "skip";
4821
- return "post";
4822
- }
4823
- function isPublishableSuggestedFixSelection(selection) {
4824
- const suggestedLines = normalizedSuggestedFixLines(selection.suggestedFix);
4825
- const selectedLineCount = selection.endLine - selection.startLine + 1;
4826
- if (selection.side !== "RIGHT" || selection.kind === "deleted" || selectedLineCount > maxSuggestedFixSelectedLines || suggestedLines.length > maxSuggestedFixReplacementLines) return false;
4827
- const originalLines = selectedPreviewLines(selection, selectedLineCount);
4828
- return Boolean(originalLines && !hasUnchangedSelectionEdge(originalLines, suggestedLines) && !suggestionIncludesUnselectedContext(selection, selectedLineCount, suggestedLines));
4829
- }
4830
- function hasExistingInlinePublicationLocation(existing, location) {
4831
- return existing.some((comment) => {
4832
- if (comment.path !== location.path || comment.commitId !== location.commitId || comment.side !== location.side) return false;
4833
- return comment.startLine <= location.endLine && location.startLine <= comment.endLine;
4834
- });
4835
- }
4836
- function normalizedSuggestedFixLines(value) {
4837
- const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
4838
- const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
4839
- return withoutFinalNewline.length === 0 ? [] : withoutFinalNewline.split("\n");
4840
- }
4841
- function selectedPreviewLines(selection, selectedLineCount) {
4842
- if (!selection.preview) return;
4843
- const offset = selection.startLine - selection.rangeStartLine;
4844
- if (offset < 0) return;
4845
- const previewLines = selection.preview.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
4846
- if (offset + selectedLineCount > previewLines.length) return;
4847
- return previewLines.slice(offset, offset + selectedLineCount);
4848
- }
4849
- function suggestionIncludesUnselectedContext(selection, selectedLineCount, suggestedLines) {
4850
- if (!selection.preview || suggestedLines.length <= selectedLineCount) return false;
4851
- const offset = selection.startLine - selection.rangeStartLine;
4852
- if (offset < 0) return false;
4853
- const previewLines = selection.preview.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
4854
- return [offset > 0 ? previewLines[offset - 1] : void 0, previewLines[offset + selectedLineCount]].filter((line) => Boolean(line?.trim())).some((line) => suggestedLines.includes(line));
4855
- }
4856
- function hasUnchangedSelectionEdge(originalLines, suggestedLines) {
4857
- const firstLineUnchanged = originalLines[0] === suggestedLines[0];
4858
- const lastLineUnchanged = originalLines.at(-1) === suggestedLines.at(-1);
4859
- if (originalLines.length === suggestedLines.length || originalLines.length === 1) return firstLineUnchanged || lastLineUnchanged;
4860
- return firstLineUnchanged && lastLineUnchanged;
4861
- }
5248
+ const mainCommentAttributionPattern = new RegExp(`^<sub>Review generated by \\[Pipr\\]\\(${escapedRepositoryUrl}\\) for commit \`[^\`]+\`\\.(?: Config SDK \\d+\\.\\d+\\.\\d+ is behind (?:Pipr \\d+\\.\\d+\\.\\d+|\\[Pipr \\d+\\.\\d+\\.\\d+\\]\\(${escapedRepositoryUrl}/releases/tag/v\\d+\\.\\d+\\.\\d+\\))\\.)?</sub>$`);
4862
5249
  //#endregion
4863
5250
  //#region src/review/prior-state.ts
4864
5251
  const mainCommentMarker = "pipr:main-comment";
@@ -5117,8 +5504,50 @@ function hashParts(parts) {
5117
5504
  return new Bun.CryptoHasher("sha256").update(parts.join("\n")).digest("hex").slice(0, 16);
5118
5505
  }
5119
5506
  //#endregion
5507
+ //#region src/review/suggested-fix-publication-policy.ts
5508
+ const maxSuggestedFixSelectedLines = 12;
5509
+ const maxSuggestedFixReplacementLines = 20;
5510
+ function isPublishableSuggestedFixSelection(selection) {
5511
+ const suggestedLines = normalizedSuggestedFixLines(selection.suggestedFix);
5512
+ const selectedLineCount = selection.endLine - selection.startLine + 1;
5513
+ if (selection.side !== "RIGHT" || selection.kind === "deleted" || selectedLineCount > maxSuggestedFixSelectedLines || suggestedLines.length > maxSuggestedFixReplacementLines) return false;
5514
+ const originalLines = selectedPreviewLines(selection, selectedLineCount);
5515
+ if (!originalLines) return false;
5516
+ const structuralEdgePattern = /^([{}[\]()<>]+)[;,]?$/;
5517
+ const firstOriginalEdge = originalLines[0]?.trim().match(structuralEdgePattern)?.[1];
5518
+ const firstSuggestedEdge = suggestedLines[0]?.trim().match(structuralEdgePattern)?.[1];
5519
+ const lastOriginalEdge = originalLines.at(-1)?.trim().match(structuralEdgePattern)?.[1];
5520
+ const lastSuggestedEdge = suggestedLines.at(-1)?.trim().match(structuralEdgePattern)?.[1];
5521
+ return (firstOriginalEdge === void 0 || firstOriginalEdge === firstSuggestedEdge) && (lastOriginalEdge === void 0 || lastOriginalEdge === lastSuggestedEdge) && !hasUnchangedSelectionEdge(originalLines, suggestedLines) && !suggestionIncludesUnselectedContext(selection, selectedLineCount, suggestedLines);
5522
+ }
5523
+ function normalizedSuggestedFixLines(value) {
5524
+ const normalized = value.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
5525
+ const withoutFinalNewline = normalized.endsWith("\n") ? normalized.slice(0, -1) : normalized;
5526
+ return withoutFinalNewline.length === 0 ? [] : withoutFinalNewline.split("\n");
5527
+ }
5528
+ function selectedPreviewLines(selection, selectedLineCount) {
5529
+ if (!selection.preview) return;
5530
+ const offset = selection.startLine - selection.rangeStartLine;
5531
+ if (offset < 0) return;
5532
+ const previewLines = selection.preview.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
5533
+ if (offset + selectedLineCount > previewLines.length) return;
5534
+ return previewLines.slice(offset, offset + selectedLineCount);
5535
+ }
5536
+ function suggestionIncludesUnselectedContext(selection, selectedLineCount, suggestedLines) {
5537
+ if (!selection.preview || suggestedLines.length <= selectedLineCount) return false;
5538
+ const offset = selection.startLine - selection.rangeStartLine;
5539
+ if (offset < 0) return false;
5540
+ const previewLines = selection.preview.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
5541
+ return [offset > 0 ? previewLines[offset - 1] : void 0, previewLines[offset + selectedLineCount]].filter((line) => Boolean(line?.trim())).some((line) => suggestedLines.includes(line));
5542
+ }
5543
+ function hasUnchangedSelectionEdge(originalLines, suggestedLines) {
5544
+ const firstLineUnchanged = originalLines[0] === suggestedLines[0];
5545
+ const lastLineUnchanged = originalLines.at(-1) === suggestedLines.at(-1);
5546
+ if (originalLines.length === suggestedLines.length || originalLines.length === 1) return firstLineUnchanged || lastLineUnchanged;
5547
+ return firstLineUnchanged && lastLineUnchanged;
5548
+ }
5549
+ //#endregion
5120
5550
  //#region src/review/comment.ts
5121
- const runtimeVersion = version;
5122
5551
  const inlinePublicationItemSchema = z.strictObject({
5123
5552
  finding: reviewFindingSchema,
5124
5553
  range: commentableRangeSchema,
@@ -5165,6 +5594,7 @@ const threadActionSchema = z.strictObject({
5165
5594
  const threadActionsSchema = z.array(threadActionSchema);
5166
5595
  const publicationMetadataSchema = z.strictObject({
5167
5596
  runtimeVersion: z.string().min(1),
5597
+ configVersion: z.string().min(1).optional(),
5168
5598
  trustedConfigSha: z.string().min(1).optional(),
5169
5599
  trustedConfigHash: z.string().min(1).optional(),
5170
5600
  reviewedHeadSha: z.string().min(1),
@@ -5184,8 +5614,6 @@ const publicationPlanSchema = z.strictObject({
5184
5614
  reviewState: priorReviewStateSchema,
5185
5615
  threadActions: threadActionsSchema
5186
5616
  });
5187
- const maxInlineFindingBodyCharacters = 700;
5188
- const maxInlineFindingBodyLines = 4;
5189
5617
  function buildPublicationPlan(options) {
5190
5618
  const reviewState = options.reviewState ?? buildPriorReviewState({
5191
5619
  findings: options.inlineItems.map((item) => item.finding),
@@ -5202,7 +5630,7 @@ function buildPublicationPlan(options) {
5202
5630
  event: options.event,
5203
5631
  reviewState,
5204
5632
  main: options.main,
5205
- reviewedHeadSha: metadata.reviewedHeadSha
5633
+ metadata
5206
5634
  }),
5207
5635
  mainMarker: mainCommentMarker,
5208
5636
  changeNumber: options.event.change.number,
@@ -5256,9 +5684,9 @@ function findingWithPublishableBody(finding) {
5256
5684
  };
5257
5685
  }
5258
5686
  function conciseInlineFindingBody(value) {
5259
- const body = redactPotentialSecrets((value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim().split(/\n{2,}/)[0] ?? value).split("\n").slice(0, maxInlineFindingBodyLines).join("\n").trim());
5260
- if (body.length <= maxInlineFindingBodyCharacters) return body;
5261
- return `${body.slice(0, maxInlineFindingBodyCharacters).trimEnd()}...`;
5687
+ const body = redactPotentialSecrets((value.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim().split(/\n{2,}/)[0] ?? value).split("\n").slice(0, 4).join("\n").trim());
5688
+ if (body.length <= 700) return body;
5689
+ return `${body.slice(0, 700).trimEnd()}...`;
5262
5690
  }
5263
5691
  function findingWithPublishableSuggestedFix(finding, range) {
5264
5692
  if (!finding.suggestedFix) return finding;
@@ -5290,10 +5718,19 @@ function renderMainComment(options) {
5290
5718
  "",
5291
5719
  redactPotentialSecrets(options.main),
5292
5720
  "",
5293
- `<sub>Review generated by [Pipr](${piprRepositoryUrl}) for commit \`${options.reviewedHeadSha.slice(0, 7)}\`.</sub>`,
5721
+ renderMainCommentAttribution(options.metadata),
5294
5722
  ""
5295
5723
  ].join("\n");
5296
5724
  }
5725
+ function renderMainCommentAttribution(metadata) {
5726
+ const configNotice = configVersionNotice(metadata);
5727
+ return `<sub>Review generated by [Pipr](${piprRepositoryUrl}) for commit \`${metadata.reviewedHeadSha.slice(0, 7)}\`.${configNotice}</sub>`;
5728
+ }
5729
+ function configVersionNotice(metadata) {
5730
+ if (!metadata.configVersion || !stableSemverPattern.test(metadata.runtimeVersion) || !stableSemverPattern.test(metadata.configVersion) || compareStableSemver(metadata.runtimeVersion, metadata.configVersion) <= 0) return "";
5731
+ const releaseUrl = `${piprRepositoryUrl}/releases/tag/v${metadata.runtimeVersion}`;
5732
+ return ` Config SDK ${metadata.configVersion} is behind [Pipr ${metadata.runtimeVersion}](${releaseUrl}).`;
5733
+ }
5297
5734
  function renderInlineBody(finding, findingId, reviewedHeadSha) {
5298
5735
  const findingBody = startsWithStructuredMarkdown(finding.body) ? finding.body : [
5299
5736
  "**Issue**",
@@ -5725,9 +6162,9 @@ function trackResultFindingScope(state, value, paths) {
5725
6162
  const parsed = agentInlineFindingsOutputSchema.safeParse(value);
5726
6163
  if (parsed.success) state.findingScopes.set(parsed.data.inlineFindings, paths);
5727
6164
  }
5728
- function collectedReview(output) {
6165
+ function collectedReview(output, summaryBody) {
5729
6166
  return {
5730
- summary: { body: "Review completed." },
6167
+ summary: { body: summaryBody },
5731
6168
  inlineFindings: output.findings.map((item) => item.finding)
5732
6169
  };
5733
6170
  }
@@ -5768,7 +6205,8 @@ async function runTaskRuntime(options) {
5768
6205
  reason: options.emptyTasksReason,
5769
6206
  taskName: options.taskName,
5770
6207
  trustedConfigSha: options.trustedConfigSha,
5771
- trustedConfigHash: options.trustedConfigHash
6208
+ trustedConfigHash: options.trustedConfigHash,
6209
+ versionCompatibility: options.versionCompatibility
5772
6210
  });
5773
6211
  }
5774
6212
  const selectedTasks = tasks.map((task) => task.name);
@@ -5863,7 +6301,8 @@ async function runTaskRuntime(options) {
5863
6301
  });
5864
6302
  if (commandResponse) return commandResponse;
5865
6303
  assertReviewCommentOutput(output, options.commandInvocation !== void 0);
5866
- const validated = validateReviewResult(collectedReview(output), diffManifest, {
6304
+ const main = typeof output.comment.value === "string" ? output.comment.value : output.comment.value.main ?? "Review completed.";
6305
+ const validated = validateReviewResult(collectedReview(output, main), diffManifest, {
5867
6306
  expectedHeadSha: options.event.change.head.sha,
5868
6307
  pathScopeForFinding: (_finding, index) => output.findings[index]?.paths
5869
6308
  });
@@ -5877,7 +6316,7 @@ async function runTaskRuntime(options) {
5877
6316
  });
5878
6317
  const publishing = buildCommentPublishingPlan({
5879
6318
  event: options.event,
5880
- main: typeof output.comment.value === "string" ? output.comment.value : output.comment.value.main ?? "Review completed.",
6319
+ main,
5881
6320
  validated,
5882
6321
  manifest: diffManifest,
5883
6322
  maxInlineComments: config.publication.maxInlineComments,
@@ -5885,6 +6324,7 @@ async function runTaskRuntime(options) {
5885
6324
  threadActions: verifier.threadActions,
5886
6325
  metadata: {
5887
6326
  runtimeVersion,
6327
+ configVersion: options.versionCompatibility?.configVersion,
5888
6328
  trustedConfigSha: options.trustedConfigSha,
5889
6329
  trustedConfigHash: options.trustedConfigHash,
5890
6330
  reviewedHeadSha: options.event.change.head.sha,
@@ -6075,6 +6515,7 @@ function skippedTaskRuntimeResult(options) {
6075
6515
  maxInlineComments: options.config.publication.maxInlineComments,
6076
6516
  metadata: {
6077
6517
  runtimeVersion,
6518
+ configVersion: options.versionCompatibility?.configVersion,
6078
6519
  trustedConfigSha: options.trustedConfigSha,
6079
6520
  trustedConfigHash: options.trustedConfigHash,
6080
6521
  reviewedHeadSha: options.event.change.head.sha,
@@ -6143,7 +6584,7 @@ const githubRepositoryPermissionResponseSchema = z.looseObject({
6143
6584
  permission: z.string().min(1),
6144
6585
  role_name: z.string().min(1).optional()
6145
6586
  });
6146
- const permissionLevels = new Set([
6587
+ const permissionLevels = /* @__PURE__ */ new Set([
6147
6588
  "read",
6148
6589
  "triage",
6149
6590
  "write",
@@ -6802,6 +7243,18 @@ async function loadGitHubPriorMainComment(options) {
6802
7243
  }), "pipr:main-comment", options.change.change.number, ownerLogin)?.body ?? void 0;
6803
7244
  }
6804
7245
  //#endregion
7246
+ //#region src/review/inline-publication-policy.ts
7247
+ function inlinePublicationDecision(options) {
7248
+ if (options.existing.markers.has(options.marker) || hasExistingInlinePublicationLocation(options.existing.locations, options.location)) return "skip";
7249
+ return "post";
7250
+ }
7251
+ function hasExistingInlinePublicationLocation(existing, location) {
7252
+ return existing.some((comment) => {
7253
+ if (comment.path !== location.path || comment.commitId !== location.commitId || comment.side !== location.side) return false;
7254
+ return comment.startLine <= location.endLine && location.startLine <= comment.endLine;
7255
+ });
7256
+ }
7257
+ //#endregion
6805
7258
  //#region src/hosts/github/inline.ts
6806
7259
  const githubReviewCommentLocationSchema = z.strictObject({
6807
7260
  path: z.string().min(1),
@@ -7389,6 +7842,10 @@ function logTrustedRuntime(log, runtime) {
7389
7842
  tasks: runtime.plan.tasks.length,
7390
7843
  commands: runtime.plan.commands.length
7391
7844
  });
7845
+ logConfigWarnings(log, runtime.settings.warnings);
7846
+ }
7847
+ function logConfigWarnings(log, warnings) {
7848
+ for (const warning of warnings) log.warning("config warning", { warning });
7392
7849
  }
7393
7850
  function addProviderSecrets(log, config, env) {
7394
7851
  for (const provider of config.providers) log.addSecret((env ?? process.env)[provider.apiKeyEnv]);
@@ -7584,6 +8041,7 @@ async function runTrustedReviewAndPublish(options) {
7584
8041
  event: options.event,
7585
8042
  env: options.options.env,
7586
8043
  plan: options.trustedRuntime.plan,
8044
+ versionCompatibility: options.trustedRuntime.versionCompatibility,
7587
8045
  taskName: options.taskName,
7588
8046
  taskInput: options.taskInput,
7589
8047
  commandInvocation: options.commandInvocation,
@@ -7655,7 +8113,8 @@ async function runTrustedReviewAndPublish(options) {
7655
8113
  async function loadRuntimeProjectFromGitCommit(options) {
7656
8114
  const configDir = resolveContainedConfigDir(options);
7657
8115
  const files = listConfigFilesAtCommit(options.rootDir, options.commitSha, configDir.gitPath);
7658
- if (files.length === 0) throw new Error(`${configDir.configDir}/config.ts is required at base commit ${options.commitSha}`);
8116
+ const configPath = configDir.gitPath === "." ? "config.ts" : `${configDir.gitPath}/config.ts`;
8117
+ if (files.length === 0 || !files.some((file) => file.path === configPath)) throw new Error(`No Pipr config found at ${configPath} in base commit ${options.commitSha}. Run \`pipr init\` and commit the generated config.`);
7659
8118
  const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pipr-base-config-"));
7660
8119
  try {
7661
8120
  const hash = new Bun.CryptoHasher("sha256");
@@ -8193,7 +8652,10 @@ async function runInspectCommand(options) {
8193
8652
  ...options,
8194
8653
  requireProviderEnv: false
8195
8654
  });
8196
- return inspectRuntimePlan(runtime.plan, runtime.settings.source);
8655
+ return {
8656
+ ...inspectRuntimePlan(runtime.plan, runtime.settings.source),
8657
+ warnings: runtime.settings.warnings
8658
+ };
8197
8659
  }
8198
8660
  /** Loads the runtime config and pull request event without running review publication. */
8199
8661
  async function runDryRunCommand(options) {
@@ -8212,7 +8674,8 @@ async function runDryRunCommand(options) {
8212
8674
  });
8213
8675
  return {
8214
8676
  configSource: runtime.settings.source,
8215
- event
8677
+ event,
8678
+ warnings: runtime.settings.warnings
8216
8679
  };
8217
8680
  }
8218
8681
  /** Runs configured change-request tasks against local Git base and head revisions. */
@@ -8237,6 +8700,7 @@ async function runLocalReviewCommand(options) {
8237
8700
  tasks: runtime.plan.tasks.length,
8238
8701
  commands: runtime.plan.commands.length
8239
8702
  });
8703
+ if (log) logConfigWarnings(log, runtime.settings.warnings);
8240
8704
  const selectedTasks = selectLocalReviewTasks(runtime.plan);
8241
8705
  const includeWorkingTree = options.headSha === void 0;
8242
8706
  const headSha = options.headSha ?? runGit$1(["rev-parse", "HEAD"], options.rootDir).trim();
@@ -8259,6 +8723,7 @@ async function runLocalReviewCommand(options) {
8259
8723
  event,
8260
8724
  env: options.env,
8261
8725
  plan: runtime.plan,
8726
+ versionCompatibility: runtime.versionCompatibility,
8262
8727
  selectedTasks,
8263
8728
  emptyTasksReason: "No change-request tasks are configured for local review",
8264
8729
  piExecutable: options.piExecutable,
@@ -8309,6 +8774,6 @@ async function runActionCommandWithDependencies(options) {
8309
8774
  });
8310
8775
  }
8311
8776
  //#endregion
8312
- export { runInspectCommand as a, PublicationError as c, listOfficialInitRecipes as d, supportedOfficialInitRecipes as f, piThinkingLevels as g, piRequiredCliFlags as h, runInitCommand as i, isPublishableSuggestedFixSelection as l, piReadOnlyToolNames as m, runActionCommandWithDependencies as n, runLocalReviewCommand as o, piBuiltinToolNames as p, runDryRunCommand as r, runValidateCommand as s, runActionCommand as t, supportedOfficialInitAdapters as u };
8777
+ export { piRequiredCliFlags as _, runInspectCommand as a, PublicationError as c, maxInlineFindingBodyLines as d, supportedOfficialInitAdapters as f, piReadOnlyToolNames as g, piBuiltinToolNames as h, runInitCommand as i, isPublishableSuggestedFixSelection as l, supportedOfficialInitRecipes as m, runActionCommandWithDependencies as n, runLocalReviewCommand as o, listOfficialInitRecipes as p, runDryRunCommand as r, runValidateCommand as s, runActionCommand as t, maxInlineFindingBodyCharacters as u, piThinkingLevels as v };
8313
8778
 
8314
- //# sourceMappingURL=commands-CQg9QVZt.mjs.map
8779
+ //# sourceMappingURL=commands-DCpIxJkz.mjs.map