@kitschpatrol/chagent 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/cli.js +16 -4
- package/dist/lib/bumpp.d.ts +1 -1
- package/dist/lib/bumpp.js +1 -1
- package/dist/lib/index.d.ts +8 -3
- package/dist/lib/index.js +2 -2
- package/dist/{lib-advgvAk1.js → lib-DVZXk4YE.js} +18 -10
- package/dist/{review-ClBbl31P.js → review-Bl10DPO7.js} +602 -311
- package/dist/{types-COQP1I5w.d.ts → types-B-sACYE9.d.ts} +27 -3
- package/package.json +1 -1
- package/readme.md +37 -30
|
@@ -9,13 +9,13 @@ import semver from "semver";
|
|
|
9
9
|
import { parse } from "yaml";
|
|
10
10
|
import crossSpawn from "cross-spawn";
|
|
11
11
|
import { existsSync } from "node:fs";
|
|
12
|
-
import { pathToFileURL } from "node:url";
|
|
13
12
|
import { cosmiconfig } from "cosmiconfig";
|
|
13
|
+
import { pathToFileURL } from "node:url";
|
|
14
14
|
import { isCancel, select } from "@clack/prompts";
|
|
15
15
|
|
|
16
16
|
// package.json
|
|
17
17
|
var name = "@kitschpatrol/chagent";
|
|
18
|
-
var version = "0.
|
|
18
|
+
var version = "0.5.0";
|
|
19
19
|
var engines = { "node": "^24.16.0 || >=26.3.0" };
|
|
20
20
|
|
|
21
21
|
// src/lib/defaults.ts
|
|
@@ -25,6 +25,7 @@ const defaults = {
|
|
|
25
25
|
effort: "xhigh",
|
|
26
26
|
github: true,
|
|
27
27
|
maxInputChars: 8e4,
|
|
28
|
+
rootChangelog: "summary",
|
|
28
29
|
timeout: 20
|
|
29
30
|
};
|
|
30
31
|
const defaultModels = {
|
|
@@ -236,6 +237,7 @@ async function readBlobs(root, objects) {
|
|
|
236
237
|
const LOCKFILE_PATH = /(?:^|\/)(?:pnpm-lock\.yaml|package-lock\.json|yarn\.lock|bun\.lockb?|deno\.lock|uv\.lock|poetry\.lock|pdm\.lock|Pipfile\.lock|Cargo\.lock|Gemfile\.lock|composer\.lock|go\.sum)$/v;
|
|
237
238
|
const TAG_DECORATION = "tag: refs/tags/";
|
|
238
239
|
const GITHUB_REMOTE = /github\.com[:\/]([^\/]+\/[^\/]+?)(?:\.git)?$/v;
|
|
240
|
+
const SHORT_VERSION = /^\d+(?:\.\d+)?$/v;
|
|
239
241
|
const manifestSchema$1 = z.object({
|
|
240
242
|
dependencies: z.record(z.string(), z.string()).optional(),
|
|
241
243
|
engines: z.object({ node: z.string().optional() }).optional(),
|
|
@@ -392,6 +394,29 @@ function packageInfo(manifest, directory, fallback) {
|
|
|
392
394
|
version: manifest?.version
|
|
393
395
|
};
|
|
394
396
|
}
|
|
397
|
+
/**
|
|
398
|
+
* Read a version, with how many of its parts are spelled out. Short versions
|
|
399
|
+
* such as `1` or `1.2` name the first release of their series.
|
|
400
|
+
*/
|
|
401
|
+
function parseVersion(value) {
|
|
402
|
+
const version = semver.valid(value);
|
|
403
|
+
if (version !== null) return {
|
|
404
|
+
precision: 3,
|
|
405
|
+
version
|
|
406
|
+
};
|
|
407
|
+
if (!SHORT_VERSION.test(value)) return;
|
|
408
|
+
const parts = value.split(".");
|
|
409
|
+
const padded = semver.valid([
|
|
410
|
+
...parts,
|
|
411
|
+
"0",
|
|
412
|
+
"0"
|
|
413
|
+
].slice(0, 3).join("."));
|
|
414
|
+
return padded === null ? void 0 : {
|
|
415
|
+
precision: parts.length,
|
|
416
|
+
version: padded
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
/** Read the version a tag names for a package, using its tag patterns. */
|
|
395
420
|
function tagVersion(tag, pkg, patterns) {
|
|
396
421
|
const templates = patterns ?? [
|
|
397
422
|
"{name}@{version}",
|
|
@@ -402,10 +427,23 @@ function tagVersion(tag, pkg, patterns) {
|
|
|
402
427
|
for (const pattern of templates) {
|
|
403
428
|
const [prefix, suffix = ""] = pattern.replaceAll("{name}", () => pkg.name).split("{version}", 2);
|
|
404
429
|
if (prefix === void 0 || !tag.startsWith(prefix) || !tag.endsWith(suffix)) continue;
|
|
405
|
-
const
|
|
406
|
-
const
|
|
407
|
-
if (
|
|
430
|
+
const parsed = parseVersion(tag.slice(prefix.length, suffix === "" ? void 0 : -suffix.length));
|
|
431
|
+
const bareNumber = parsed?.precision === 1 && patterns === void 0 && !prefix.endsWith("v");
|
|
432
|
+
if (parsed !== void 0 && !bareNumber) return parsed;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* Check that a short tag such as `v1` or `v1.2` precedes every more precise tag
|
|
437
|
+
* in its series. Floating tags that follow the latest release fail this check.
|
|
438
|
+
*/
|
|
439
|
+
async function leadsSeries(root, candidate, candidates) {
|
|
440
|
+
const major = semver.major(candidate.version);
|
|
441
|
+
const minor = semver.minor(candidate.version);
|
|
442
|
+
for (const other of candidates) {
|
|
443
|
+
if (other.precision <= candidate.precision || semver.major(other.version) !== major || candidate.precision === 2 && semver.minor(other.version) !== minor || semver.lt(other.version, candidate.version)) continue;
|
|
444
|
+
if (other.tag.commit === candidate.tag.commit || !await isAncestor(root, candidate.tag.commit, other.tag.commit)) return false;
|
|
408
445
|
}
|
|
446
|
+
return true;
|
|
409
447
|
}
|
|
410
448
|
/**
|
|
411
449
|
* Predict the tag of a release that is not tagged yet, so its heading can link
|
|
@@ -486,21 +524,15 @@ function pnpmWorkspaceGlobs(text) {
|
|
|
486
524
|
if (!workspace.success) throw new Error(`Invalid pnpm-workspace.yaml:\n${z.prettifyError(workspace.error)}`, { cause: workspace.error });
|
|
487
525
|
return workspace.data?.packages;
|
|
488
526
|
}
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const workspaceText = await readOptional(path.join(root, "pnpm-workspace.yaml"));
|
|
492
|
-
if (workspaceText !== void 0) patterns = pnpmWorkspaceGlobs(workspaceText) ?? patterns;
|
|
493
|
-
patterns = config.packages ?? patterns;
|
|
527
|
+
/** Find directories matching globs, as POSIX paths relative to the root. */
|
|
528
|
+
async function matchDirectories(root, patterns, exclude = []) {
|
|
494
529
|
const directories = [];
|
|
495
|
-
const
|
|
496
|
-
const negative = patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1));
|
|
497
|
-
const matches = glob(positive, {
|
|
530
|
+
const matches = glob(patterns, {
|
|
498
531
|
cwd: root,
|
|
499
532
|
exclude: [
|
|
500
533
|
"**/node_modules/**",
|
|
501
534
|
"**/.git/**",
|
|
502
|
-
...
|
|
503
|
-
...config.exclude ?? []
|
|
535
|
+
...exclude
|
|
504
536
|
],
|
|
505
537
|
withFileTypes: true
|
|
506
538
|
});
|
|
@@ -508,6 +540,27 @@ async function workspacePackages(root, manifest, config) {
|
|
|
508
540
|
if (!entry.isDirectory()) continue;
|
|
509
541
|
directories.push(path.relative(root, path.join(entry.parentPath, entry.name)).split(path.sep).join("/"));
|
|
510
542
|
}
|
|
543
|
+
return directories;
|
|
544
|
+
}
|
|
545
|
+
/** Mark the packages that remain boundaries but get no changelog. */
|
|
546
|
+
async function markSkipped(root, packages, patterns) {
|
|
547
|
+
const skipped = /* @__PURE__ */ new Set();
|
|
548
|
+
for (const pattern of patterns) {
|
|
549
|
+
const matches = (await matchDirectories(root, [pattern])).filter((directory) => packages.some((pkg) => pkg.directory === directory));
|
|
550
|
+
if (matches.length === 0) throw new Error(`skipPackage pattern "${pattern}" matches no workspace package directory. Use a directory glob relative to the repository root, such as "packages/docs"; rootChangelog controls the root changelog.`);
|
|
551
|
+
for (const directory of matches) skipped.add(directory);
|
|
552
|
+
}
|
|
553
|
+
return packages.map((pkg) => skipped.has(pkg.directory) ? {
|
|
554
|
+
...pkg,
|
|
555
|
+
skipped: true
|
|
556
|
+
} : pkg);
|
|
557
|
+
}
|
|
558
|
+
async function workspacePackages(root, manifest, config) {
|
|
559
|
+
let patterns = Array.isArray(manifest?.workspaces) ? manifest.workspaces : manifest?.workspaces?.packages ?? [];
|
|
560
|
+
const workspaceText = await readOptional(path.join(root, "pnpm-workspace.yaml"));
|
|
561
|
+
if (workspaceText !== void 0) patterns = pnpmWorkspaceGlobs(workspaceText) ?? patterns;
|
|
562
|
+
patterns = config.packages ?? patterns;
|
|
563
|
+
const directories = await matchDirectories(root, patterns.filter((pattern) => !pattern.startsWith("!")), [...patterns.filter((pattern) => pattern.startsWith("!")).map((pattern) => pattern.slice(1)), ...config.exclude ?? []]);
|
|
511
564
|
const packages = [];
|
|
512
565
|
for (const directory of directories.toSorted()) {
|
|
513
566
|
if (!(await realpath(path.join(root, directory))).startsWith(root + path.sep)) throw new Error(`Workspace package escapes the repository: ${directory}`);
|
|
@@ -515,30 +568,40 @@ async function workspacePackages(root, manifest, config) {
|
|
|
515
568
|
if (text === void 0 && config.packages === void 0) continue;
|
|
516
569
|
packages.push(packageInfo(text === void 0 ? void 0 : parseManifest(text, `${directory}/package.json`), directory, path.posix.basename(directory)));
|
|
517
570
|
}
|
|
518
|
-
return packages;
|
|
571
|
+
return markSkipped(root, packages, config.skipPackage ?? []);
|
|
572
|
+
}
|
|
573
|
+
/** Select the workspace packages that get changelogs of their own. */
|
|
574
|
+
function describedPackages(repository) {
|
|
575
|
+
return repository.packages.filter((pkg) => pkg.skipped !== true);
|
|
519
576
|
}
|
|
520
577
|
async function taggedReleases(repository, pkg, config) {
|
|
521
578
|
const candidates = [];
|
|
522
579
|
for (const tag of repository.tags) {
|
|
523
|
-
const
|
|
524
|
-
if (
|
|
525
|
-
|
|
526
|
-
|
|
580
|
+
const match = tagVersion(tag.name, pkg, config.tagPatterns);
|
|
581
|
+
if (match !== void 0) candidates.push({
|
|
582
|
+
...match,
|
|
583
|
+
tag
|
|
527
584
|
});
|
|
528
585
|
}
|
|
529
586
|
const isRoot = pkg.directory === ".";
|
|
530
587
|
const manifests = isRoot ? /* @__PURE__ */ new Map() : await manifestsAt(repository.root, pkg.directory, candidates.map(({ tag }) => tag.commit));
|
|
531
|
-
const
|
|
532
|
-
for (const
|
|
588
|
+
const owned = [];
|
|
589
|
+
for (const candidate of candidates) {
|
|
533
590
|
if (!isRoot) {
|
|
534
|
-
const manifest = manifests.get(tag.commit);
|
|
535
|
-
if (manifest?.version !== void 0 && semver.valid(manifest.version) !== version) continue;
|
|
591
|
+
const manifest = manifests.get(candidate.tag.commit);
|
|
592
|
+
if (manifest?.version !== void 0 && semver.valid(manifest.version) !== candidate.version) continue;
|
|
536
593
|
if (manifest === void 0 && await git(repository.root, [
|
|
537
594
|
"cat-file",
|
|
538
595
|
"-t",
|
|
539
|
-
`${tag.commit}:${pkg.directory}`
|
|
596
|
+
`${candidate.tag.commit}:${pkg.directory}`
|
|
540
597
|
], true) !== "tree") continue;
|
|
541
598
|
}
|
|
599
|
+
owned.push(candidate);
|
|
600
|
+
}
|
|
601
|
+
const releases = [];
|
|
602
|
+
for (const candidate of owned) {
|
|
603
|
+
if (candidate.precision < 3 && !await leadsSeries(repository.root, candidate, owned)) continue;
|
|
604
|
+
const { tag, version } = candidate;
|
|
542
605
|
const existing = releases.find((release) => release.version === version);
|
|
543
606
|
if (existing && existing.ref !== tag.commit) throw new Error(`Multiple reachable tags disagree about ${pkg.name} ${version}. Configure tagPatterns to select a release series.`);
|
|
544
607
|
if (existing) continue;
|
|
@@ -618,13 +681,25 @@ manager, manifest format, or directory layout. Use snapshots at those refs, not
|
|
|
618
681
|
uncommitted files or newer code at HEAD. Respect repository.directory and
|
|
619
682
|
repository.excludeDirectories when deciding which changes belong in this entry;
|
|
620
683
|
you may inspect related files for context. The supplied evidence is a starting
|
|
621
|
-
point, not a limit on your research.
|
|
684
|
+
point, not a limit on your research. Supplied commits leave out lockfiles and
|
|
685
|
+
directories named build, coverage, dist, or changelog at the top of a package,
|
|
686
|
+
which usually hold generated output. Judge those directories yourself: when one
|
|
687
|
+
holds a project's source rather than generated output, its changes belong in the
|
|
688
|
+
changelog like any others.
|
|
622
689
|
|
|
623
690
|
When mode is initialize, reconstruct this release as part of the project's full
|
|
624
|
-
historical changelog. When mode is update, cover all missing changes in the
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
691
|
+
historical changelog. When mode is update, cover all missing changes in the
|
|
692
|
+
supplied range. When mode is revise, the existing entries are your own earlier
|
|
693
|
+
draft of this release, describing it up to repository.from, and the supplied
|
|
694
|
+
range is what has happened since. Return the complete entries for the release as
|
|
695
|
+
it now stands; they replace the draft. Carry over what still holds, fold later
|
|
696
|
+
fixes and refinements into the entries they affect, combine entries that turn
|
|
697
|
+
out to describe one change, and drop an entry only when the range reverts or
|
|
698
|
+
supersedes it. Keep the wording of entries that need no change. In every mode,
|
|
699
|
+
review every commit, diff, and associated PR in the supplied range before
|
|
700
|
+
deciding what to include. A changelog records the evolution of the project,
|
|
701
|
+
including maintenance releases; it is not just a list of new application
|
|
702
|
+
features.
|
|
628
703
|
|
|
629
704
|
Cover distinct changes to behavior, public APIs and types, CLI flags and output,
|
|
630
705
|
runtime compatibility, installation and distribution, documentation and examples,
|
|
@@ -653,9 +728,16 @@ Give distinct changes separate readable bullets;
|
|
|
653
728
|
do not compress unrelated features or breaking changes into one long entry. Keep
|
|
654
729
|
each entry concise without sacrificing coverage. Consolidate related commits,
|
|
655
730
|
not unrelated changes. There is no three-entry limit for package scope, including
|
|
656
|
-
a single-package repository's root changelog.
|
|
657
|
-
|
|
658
|
-
|
|
731
|
+
a single-package repository's root changelog.
|
|
732
|
+
|
|
733
|
+
Only scope=repository asks for a summary: an entry in a monorepo's root
|
|
734
|
+
changelog, which the caller ends with links to the package changelogs that hold
|
|
735
|
+
the details. Write what a reader of the whole repository most needs to know, in
|
|
736
|
+
at most three entries, rather than repeating package entries one by one. Combine
|
|
737
|
+
related changes across packages and name the packages they affect. When
|
|
738
|
+
repository.directory is a package, summarize that package's release; otherwise
|
|
739
|
+
summarize the repository, including changes outside any package. Do not write
|
|
740
|
+
the links yourself.
|
|
659
741
|
|
|
660
742
|
Use commit messages and PRs to understand intent, then check the changed files
|
|
661
743
|
and diffs. Inspect public interfaces at both ends of the range before claiming
|
|
@@ -694,25 +776,37 @@ Put breaking public changes in the appropriate standard category and set breakin
|
|
|
694
776
|
to true; do not create a Breaking Changes category.
|
|
695
777
|
Descriptions are single-line Markdown suitable for a list item,
|
|
696
778
|
without a bullet prefix, heading, process explanation, or introductory paragraph.
|
|
697
|
-
Every change must cite at least one evidence ID supplied for its own
|
|
698
|
-
|
|
699
|
-
|
|
779
|
+
Every change must cite, in sources, at least one evidence ID supplied for its own
|
|
780
|
+
release or the full ID of a commit within that release's range. Prefer the commit
|
|
781
|
+
that made the change, including one found through your own Git research; use the
|
|
782
|
+
release's range ID when no single commit establishes it. When mode is revise,
|
|
783
|
+
cite the draft evidence ID for an entry carried over or rewritten from the
|
|
784
|
+
existing entries, along with any commit that changed it. Include a PR
|
|
700
785
|
link only when its URL and association are supplied. Do not fabricate links.
|
|
701
786
|
|
|
702
|
-
Existing entries are provided
|
|
703
|
-
|
|
704
|
-
|
|
787
|
+
Existing entries are provided for deduplication and preservation, and when mode
|
|
788
|
+
is revise, as the draft to rewrite. They are not independent evidence. Unless
|
|
789
|
+
mode is revise, omit changes already expressed by those entries, even if you
|
|
790
|
+
would phrase them differently. GitHub release-note bodies are deliberately
|
|
705
791
|
absent because this output will itself become GitHub release notes.
|
|
706
792
|
Do not read existing changelog files, generated release-note files, or GitHub
|
|
707
793
|
release bodies as source material, even when researching the repository directly.
|
|
708
794
|
|
|
795
|
+
When styleReference is present, it is a recent release section of the changelog
|
|
796
|
+
these entries will join. Match its writing conventions: voice and tense,
|
|
797
|
+
capitalization, terminal punctuation, and how code, issues, and PRs are
|
|
798
|
+
referenced. It is a style sample and nothing more. It describes another release,
|
|
799
|
+
so it is not evidence, and its length and choice of categories say nothing about
|
|
800
|
+
this one.
|
|
801
|
+
|
|
709
802
|
Before returning, check coverage across behavior/API, compatibility, dependencies,
|
|
710
803
|
documentation, build/test/CI, and distribution. Do not drop a change just because
|
|
711
804
|
its commit is called chore, docs, build, or dependency updates. Return an empty
|
|
712
805
|
changes array only when the evidence is entirely release-version bookkeeping,
|
|
713
806
|
lockfile-only resolution changes, generated artifacts, formatting noise, or
|
|
714
|
-
changes already covered by existing entries.
|
|
715
|
-
|
|
807
|
+
changes already covered by existing entries. When mode is revise, an empty array
|
|
808
|
+
removes the draft, so return one only when nothing in the draft still holds. Do
|
|
809
|
+
not pad the result with unsupported changes.`;
|
|
716
810
|
/** Invalidates cached drafts whenever the instructions change. */
|
|
717
811
|
const instructionVersion = hash(instructions);
|
|
718
812
|
|
|
@@ -735,8 +829,14 @@ const efforts = [
|
|
|
735
829
|
"xhigh",
|
|
736
830
|
"max"
|
|
737
831
|
];
|
|
832
|
+
const rootChangelogs = [
|
|
833
|
+
"summary",
|
|
834
|
+
"package",
|
|
835
|
+
"none"
|
|
836
|
+
];
|
|
738
837
|
|
|
739
838
|
// src/lib/agents.ts
|
|
839
|
+
const COMMIT_ID = /^[\da-f]{7,64}$/iv;
|
|
740
840
|
const CONTROL_CHARACTERS = /\p{Cc}/v;
|
|
741
841
|
const HEADING = /^#{1,6}\s/v;
|
|
742
842
|
const LIST_MARKER = /^(?:[\-*]|\d+\.)(?:\s+|$)/v;
|
|
@@ -755,13 +855,10 @@ const responseJsonSchema = JSON.stringify(z.toJSONSchema(responseSchema, { targe
|
|
|
755
855
|
function normalizeDescription(description) {
|
|
756
856
|
return description.replaceAll(WHITESPACE_RUN, " ").trim().replace(LIST_MARKER, "");
|
|
757
857
|
}
|
|
758
|
-
function
|
|
759
|
-
const ids = new Set(release.evidence.map((item) => item.id));
|
|
858
|
+
function validateDescriptions(changes, id) {
|
|
760
859
|
return changes.map((change) => {
|
|
761
|
-
const unknown = change.sources.filter((source) => !ids.has(source));
|
|
762
|
-
if (unknown.length > 0) throw new Error(`Agent output for ${release.id} cites evidence that was not supplied: ${unknown.join(", ")}.`);
|
|
763
860
|
const description = normalizeDescription(change.description);
|
|
764
|
-
if (description === "" || CONTROL_CHARACTERS.test(description) || HEADING.test(description) || description.includes("<!--")) throw new Error(`Agent output for ${
|
|
861
|
+
if (description === "" || CONTROL_CHARACTERS.test(description) || HEADING.test(description) || description.includes("<!--")) throw new Error(`Agent output for ${id} has an empty description, heading, or hidden markup: ${JSON.stringify(change.description)}.`);
|
|
765
862
|
return {
|
|
766
863
|
...change,
|
|
767
864
|
description
|
|
@@ -769,8 +866,8 @@ function validateChanges(changes, release) {
|
|
|
769
866
|
});
|
|
770
867
|
}
|
|
771
868
|
/**
|
|
772
|
-
* Validate structure, completeness, release identity
|
|
773
|
-
*
|
|
869
|
+
* Validate structure, completeness, and release identity before accepting any
|
|
870
|
+
* adapter's output. Citations are checked separately, by `verifyCitations`.
|
|
774
871
|
*
|
|
775
872
|
* @returns The change sets, with descriptions normalized.
|
|
776
873
|
*/
|
|
@@ -784,10 +881,53 @@ function validateResponse(value, releases) {
|
|
|
784
881
|
const omitted = expected.keys().filter((id) => !ids.includes(id)).toArray();
|
|
785
882
|
if (omitted.length > 0) throw new Error(`Agent output omitted requested releases: ${omitted.join(", ")}.`);
|
|
786
883
|
return response.data.releases.map(({ changes, id }) => ({
|
|
787
|
-
changes:
|
|
884
|
+
changes: validateDescriptions(changes, id),
|
|
788
885
|
id
|
|
789
886
|
}));
|
|
790
887
|
}
|
|
888
|
+
/** List every commit in a release's range. */
|
|
889
|
+
async function rangeCommits(root, { repository }) {
|
|
890
|
+
const { from, to } = repository;
|
|
891
|
+
return (await git(root, ["rev-list", from === void 0 ? to : `${from}..${to}`])).split("\n");
|
|
892
|
+
}
|
|
893
|
+
async function verifyChanges(root, release, changes) {
|
|
894
|
+
const supplied = new Set(release.evidence.map((item) => item.id));
|
|
895
|
+
let commits;
|
|
896
|
+
const verified = [];
|
|
897
|
+
for (const change of changes) {
|
|
898
|
+
const sources = /* @__PURE__ */ new Set();
|
|
899
|
+
for (const source of change.sources) if (supplied.has(source)) sources.add(source);
|
|
900
|
+
else if (COMMIT_ID.test(source)) {
|
|
901
|
+
commits ??= await rangeCommits(root, release);
|
|
902
|
+
const matches = commits.filter((commit) => commit.startsWith(source.toLowerCase()));
|
|
903
|
+
if (matches.length === 1) sources.add(matches[0]);
|
|
904
|
+
}
|
|
905
|
+
if (sources.size === 0) throw new Error(`Agent output for ${release.id} cites no supplied evidence or commit in its range: ${change.sources.join(", ")}. Cite an evidence ID supplied for that release, or the ID of a commit within its range.`);
|
|
906
|
+
verified.push({
|
|
907
|
+
...change,
|
|
908
|
+
sources: [...sources]
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
return verified;
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Check citations against Git instead of trusting them. A change may cite
|
|
915
|
+
* evidence supplied for its release or any commit within that release's range,
|
|
916
|
+
* which lets an agent cite what its own research found. Abbreviated commit IDs
|
|
917
|
+
* are completed, and sources that hold up are kept when others do not. Pass the
|
|
918
|
+
* requested releases with their complete evidence.
|
|
919
|
+
*
|
|
920
|
+
* @throws {Error} When a change is left without a source.
|
|
921
|
+
*/
|
|
922
|
+
async function verifyCitations(root, changeSets, releases) {
|
|
923
|
+
const requested = new Map(releases.map((release) => [release.id, release]));
|
|
924
|
+
const verified = [];
|
|
925
|
+
for (const { changes, id } of changeSets) verified.push({
|
|
926
|
+
changes: await verifyChanges(root, requested.get(id), changes),
|
|
927
|
+
id
|
|
928
|
+
});
|
|
929
|
+
return verified;
|
|
930
|
+
}
|
|
791
931
|
function prompt({ releases, repair, repositoryRoot }) {
|
|
792
932
|
const correction = repair === void 0 ? "" : `\n\nYour previous response failed validation: ${repair.error}\nPrevious response (JSON):\n${JSON.stringify(repair.response)}\nReturn the complete corrected JSON object. Keep your previous findings, and repeat only the research the correction needs.`;
|
|
793
933
|
return `${instructions}${correction}\n\nInput data (JSON):\n${JSON.stringify({
|
|
@@ -911,6 +1051,83 @@ function createAgent(name) {
|
|
|
911
1051
|
};
|
|
912
1052
|
}
|
|
913
1053
|
|
|
1054
|
+
// src/lib/config.ts
|
|
1055
|
+
const configSchema = z.strictObject({
|
|
1056
|
+
agent: z.enum(agentNames).optional(),
|
|
1057
|
+
cache: z.boolean().optional(),
|
|
1058
|
+
effort: z.enum(efforts).optional(),
|
|
1059
|
+
exclude: z.array(z.string()).optional(),
|
|
1060
|
+
github: z.boolean().optional(),
|
|
1061
|
+
/** Maximum evidence characters per agent request. */
|
|
1062
|
+
maxInputChars: z.number().int().min(4e3).optional(),
|
|
1063
|
+
model: z.string().min(1).optional(),
|
|
1064
|
+
/** Workspace directory globs, overriding manifest workspace discovery. */
|
|
1065
|
+
packages: z.array(z.string()).optional(),
|
|
1066
|
+
/**
|
|
1067
|
+
* What a monorepo's root changelog records: a summary of package releases,
|
|
1068
|
+
* the root package's own changes, or nothing.
|
|
1069
|
+
*/
|
|
1070
|
+
rootChangelog: z.enum(rootChangelogs).optional(),
|
|
1071
|
+
/** Workspace package directory globs that get no changelog of their own. */
|
|
1072
|
+
skipPackage: z.array(z.string()).optional(),
|
|
1073
|
+
/** Templates containing {version} and optionally {name}. */
|
|
1074
|
+
tagPatterns: z.array(z.string().includes("{version}")).optional(),
|
|
1075
|
+
/** Minutes each agent session may run. */
|
|
1076
|
+
timeout: z.number().positive().max(maxTimeoutMinutes).optional()
|
|
1077
|
+
});
|
|
1078
|
+
const resolvedConfigSchema = configSchema.required({
|
|
1079
|
+
agent: true,
|
|
1080
|
+
cache: true,
|
|
1081
|
+
effort: true,
|
|
1082
|
+
github: true,
|
|
1083
|
+
maxInputChars: true,
|
|
1084
|
+
rootChangelog: true,
|
|
1085
|
+
timeout: true
|
|
1086
|
+
}).transform(({ timeout, ...config }) => ({
|
|
1087
|
+
...config,
|
|
1088
|
+
timeoutMs: timeout * 6e4
|
|
1089
|
+
}));
|
|
1090
|
+
function invalidConfig(source, error) {
|
|
1091
|
+
return new Error(`Invalid options in ${source}:\n${z.prettifyError(error)}`, { cause: error });
|
|
1092
|
+
}
|
|
1093
|
+
/** Define optional repository configuration with TypeScript completion. */
|
|
1094
|
+
function defineConfig(config) {
|
|
1095
|
+
return config;
|
|
1096
|
+
}
|
|
1097
|
+
/** Load configuration from the repository root or an explicitly selected file. */
|
|
1098
|
+
async function loadConfig(root, file) {
|
|
1099
|
+
const explorer = cosmiconfig("chagent", {
|
|
1100
|
+
searchPlaces: [
|
|
1101
|
+
"chagent.config.ts",
|
|
1102
|
+
"chagent.config.mjs",
|
|
1103
|
+
"chagent.config.js",
|
|
1104
|
+
"chagent.config.json",
|
|
1105
|
+
"package.json"
|
|
1106
|
+
],
|
|
1107
|
+
searchStrategy: "none"
|
|
1108
|
+
});
|
|
1109
|
+
const result = file === void 0 ? await explorer.search(root) : await explorer.load(file);
|
|
1110
|
+
if (!result) {
|
|
1111
|
+
log.debug("No configuration file found.");
|
|
1112
|
+
return {};
|
|
1113
|
+
}
|
|
1114
|
+
log.debug(`Loaded configuration from ${result.filepath}.`);
|
|
1115
|
+
const parsed = configSchema.safeParse(result.config);
|
|
1116
|
+
if (!parsed.success) throw invalidConfig(result.filepath, parsed.error);
|
|
1117
|
+
return parsed.data;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Apply configuration layers over the defaults; later layers win and undefined
|
|
1121
|
+
* values do not.
|
|
1122
|
+
*/
|
|
1123
|
+
function resolveConfig(...layers) {
|
|
1124
|
+
const merged = { ...defaults };
|
|
1125
|
+
for (const layer of layers) for (const [key, value] of Object.entries(layer)) if (value !== void 0) merged[key] = value;
|
|
1126
|
+
const parsed = resolvedConfigSchema.safeParse(merged);
|
|
1127
|
+
if (!parsed.success) throw invalidConfig("generation settings", parsed.error);
|
|
1128
|
+
return parsed.data;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
914
1131
|
// src/lib/format.ts
|
|
915
1132
|
const FORMATTER_PACKAGES = ["@kitschpatrol/shared-config", "@kitschpatrol/prettier-config"];
|
|
916
1133
|
const manifestSchema = z.object({ exports: z.object({ ".": z.object({ import: z.string() }) }) });
|
|
@@ -1013,9 +1230,11 @@ async function writeChangelog(plan) {
|
|
|
1013
1230
|
const edit = plan.files.find((file) => file.path === checkpoint.path);
|
|
1014
1231
|
try {
|
|
1015
1232
|
const written = edit === void 0 ? void 0 : await readOptional(edit.path);
|
|
1233
|
+
const edited = edit !== void 0 && changelogHash(edit.after) !== checkpoint.contentHash;
|
|
1016
1234
|
await writeCache(path.join(stateDirectory, "checkpoints"), checkpoint.path, {
|
|
1017
1235
|
...checkpoint,
|
|
1018
|
-
contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written)
|
|
1236
|
+
contentHash: written === void 0 ? checkpoint.contentHash : changelogHash(written),
|
|
1237
|
+
generated: checkpoint.generated && !edited
|
|
1019
1238
|
});
|
|
1020
1239
|
} catch {
|
|
1021
1240
|
plan.diagnostics.push("Could not save incremental metadata; the next run will recheck the full release range.");
|
|
@@ -1050,75 +1269,6 @@ async function acquireLock(file) {
|
|
|
1050
1269
|
}
|
|
1051
1270
|
}
|
|
1052
1271
|
|
|
1053
|
-
// src/lib/config.ts
|
|
1054
|
-
const configSchema = z.strictObject({
|
|
1055
|
-
agent: z.enum(agentNames).optional(),
|
|
1056
|
-
cache: z.boolean().optional(),
|
|
1057
|
-
effort: z.enum(efforts).optional(),
|
|
1058
|
-
exclude: z.array(z.string()).optional(),
|
|
1059
|
-
github: z.boolean().optional(),
|
|
1060
|
-
/** Maximum evidence characters per agent request. */
|
|
1061
|
-
maxInputChars: z.number().int().min(4e3).optional(),
|
|
1062
|
-
model: z.string().min(1).optional(),
|
|
1063
|
-
/** Workspace directory globs, overriding manifest workspace discovery. */
|
|
1064
|
-
packages: z.array(z.string()).optional(),
|
|
1065
|
-
/** Templates containing {version} and optionally {name}. */
|
|
1066
|
-
tagPatterns: z.array(z.string().includes("{version}")).optional(),
|
|
1067
|
-
/** Minutes each agent session may run. */
|
|
1068
|
-
timeout: z.number().positive().max(maxTimeoutMinutes).optional()
|
|
1069
|
-
});
|
|
1070
|
-
const resolvedConfigSchema = configSchema.required({
|
|
1071
|
-
agent: true,
|
|
1072
|
-
cache: true,
|
|
1073
|
-
effort: true,
|
|
1074
|
-
github: true,
|
|
1075
|
-
maxInputChars: true,
|
|
1076
|
-
timeout: true
|
|
1077
|
-
}).transform(({ timeout, ...config }) => ({
|
|
1078
|
-
...config,
|
|
1079
|
-
timeoutMs: timeout * 6e4
|
|
1080
|
-
}));
|
|
1081
|
-
function invalidConfig(source, error) {
|
|
1082
|
-
return new Error(`Invalid options in ${source}:\n${z.prettifyError(error)}`, { cause: error });
|
|
1083
|
-
}
|
|
1084
|
-
/** Define optional repository configuration with TypeScript completion. */
|
|
1085
|
-
function defineConfig(config) {
|
|
1086
|
-
return config;
|
|
1087
|
-
}
|
|
1088
|
-
/** Load configuration from the repository root or an explicitly selected file. */
|
|
1089
|
-
async function loadConfig(root, file) {
|
|
1090
|
-
const explorer = cosmiconfig("chagent", {
|
|
1091
|
-
searchPlaces: [
|
|
1092
|
-
"chagent.config.ts",
|
|
1093
|
-
"chagent.config.mjs",
|
|
1094
|
-
"chagent.config.js",
|
|
1095
|
-
"chagent.config.json",
|
|
1096
|
-
"package.json"
|
|
1097
|
-
],
|
|
1098
|
-
searchStrategy: "none"
|
|
1099
|
-
});
|
|
1100
|
-
const result = file === void 0 ? await explorer.search(root) : await explorer.load(file);
|
|
1101
|
-
if (!result) {
|
|
1102
|
-
log.debug("No configuration file found.");
|
|
1103
|
-
return {};
|
|
1104
|
-
}
|
|
1105
|
-
log.debug(`Loaded configuration from ${result.filepath}.`);
|
|
1106
|
-
const parsed = configSchema.safeParse(result.config);
|
|
1107
|
-
if (!parsed.success) throw invalidConfig(result.filepath, parsed.error);
|
|
1108
|
-
return parsed.data;
|
|
1109
|
-
}
|
|
1110
|
-
/**
|
|
1111
|
-
* Apply configuration layers over the defaults; later layers win and undefined
|
|
1112
|
-
* values do not.
|
|
1113
|
-
*/
|
|
1114
|
-
function resolveConfig(...layers) {
|
|
1115
|
-
const merged = { ...defaults };
|
|
1116
|
-
for (const layer of layers) for (const [key, value] of Object.entries(layer)) if (value !== void 0) merged[key] = value;
|
|
1117
|
-
const parsed = resolvedConfigSchema.safeParse(merged);
|
|
1118
|
-
if (!parsed.success) throw invalidConfig("generation settings", parsed.error);
|
|
1119
|
-
return parsed.data;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
1272
|
// src/lib/changelog.ts
|
|
1123
1273
|
const LINE_BOUNDARY = /(?<=\n)/v;
|
|
1124
1274
|
const FENCE = /^\s{0,3}(`{3,}|~{3,})/v;
|
|
@@ -1130,9 +1280,14 @@ const WHITESPACE = /\s+/gv;
|
|
|
1130
1280
|
const TERMINAL_PUNCTUATION = /[.!]$/v;
|
|
1131
1281
|
const NEWLINE = /\r?\n/v;
|
|
1132
1282
|
const BULLET = /^\s*[\-*] /v;
|
|
1283
|
+
const ENTRY = /^\s*[\-*] /mv;
|
|
1284
|
+
const STYLE_REFERENCE_LENGTH = 2e3;
|
|
1133
1285
|
const CATEGORY_HEADING = /^### /mv;
|
|
1286
|
+
const PACKAGES_HEADING = /^### Packages\s*$/mv;
|
|
1287
|
+
const LINK_TARGET = /\]\(([^\)]*)\)/gv;
|
|
1134
1288
|
const SEMVER_LINE = /^.*\[Semantic Versioning\].*(?:\r?\n|$)/mv;
|
|
1135
1289
|
const UNRELEASED_REFERENCE = /^\[unreleased\]:[^\r\n]*(?:\r?\n)?/imv;
|
|
1290
|
+
const V_PREFIX = /^v\d/v;
|
|
1136
1291
|
const changelogHeader = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),\nand this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n";
|
|
1137
1292
|
/** The body of a release section without any changes to record. */
|
|
1138
1293
|
const noNotableChanges = "No notable changes.";
|
|
@@ -1201,6 +1356,21 @@ function parseChangelog(content) {
|
|
|
1201
1356
|
sections
|
|
1202
1357
|
};
|
|
1203
1358
|
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Pick the newest release section with entries, to show an agent how this
|
|
1361
|
+
* changelog is written. Long sections are cut at a line boundary.
|
|
1362
|
+
*/
|
|
1363
|
+
function styleReference({ sections }) {
|
|
1364
|
+
const body = sections.filter(({ version }) => version !== "Unreleased").map((section) => withoutPackageLinks(section.body)).find((entries) => ENTRY.test(entries));
|
|
1365
|
+
if (body === void 0) return;
|
|
1366
|
+
const lines = body.trim().split(LINE_BOUNDARY);
|
|
1367
|
+
let sample = "";
|
|
1368
|
+
for (const line of lines) {
|
|
1369
|
+
if (sample.length + line.length > STYLE_REFERENCE_LENGTH) break;
|
|
1370
|
+
sample += line;
|
|
1371
|
+
}
|
|
1372
|
+
return sample.trim() === "" ? void 0 : sample.trimEnd();
|
|
1373
|
+
}
|
|
1204
1374
|
function normalized(description) {
|
|
1205
1375
|
return description.trim().toLowerCase().replaceAll(WHITESPACE, " ").replace(TERMINAL_PUNCTUATION, "");
|
|
1206
1376
|
}
|
|
@@ -1240,6 +1410,46 @@ function laterCategoryIndex(body, category) {
|
|
|
1240
1410
|
const indexes = categories.slice(categories.indexOf(category) + 1).map((item) => categoryHeading(body, item)?.index).filter((index) => index !== void 0);
|
|
1241
1411
|
return indexes.length === 0 ? void 0 : Math.min(...indexes);
|
|
1242
1412
|
}
|
|
1413
|
+
/** Locate the package links that end a root summary's section. */
|
|
1414
|
+
function packagesBlock(body) {
|
|
1415
|
+
const heading = PACKAGES_HEADING.exec(body);
|
|
1416
|
+
if (heading === null) return;
|
|
1417
|
+
const after = heading.index + heading[0].length;
|
|
1418
|
+
const next = CATEGORY_HEADING.exec(body.slice(after));
|
|
1419
|
+
return {
|
|
1420
|
+
start: heading.index,
|
|
1421
|
+
end: next === null ? body.length : after + next.index
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
/** Remove the package links from a root summary's section, leaving its entries. */
|
|
1425
|
+
function withoutPackageLinks(body) {
|
|
1426
|
+
const block = packagesBlock(body);
|
|
1427
|
+
return block === void 0 ? body : body.slice(0, block.start) + body.slice(block.end);
|
|
1428
|
+
}
|
|
1429
|
+
/**
|
|
1430
|
+
* Check whether a section links to other package releases than it should.
|
|
1431
|
+
* Targets are compared, since a formatter may restyle the list around them.
|
|
1432
|
+
*/
|
|
1433
|
+
function packageLinksDiffer(body, links) {
|
|
1434
|
+
const block = body === void 0 ? void 0 : packagesBlock(body);
|
|
1435
|
+
const targets = body === void 0 || block === void 0 ? [] : body.slice(block.start, block.end).matchAll(LINK_TARGET).map((match) => match[1]).toArray();
|
|
1436
|
+
return !isDeepStrictEqual(targets, links.map(({ url }) => url));
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* Merge new changes into a section's draft, or replace a revised draft with
|
|
1440
|
+
* them, and end the section with its package links.
|
|
1441
|
+
*/
|
|
1442
|
+
function sectionBody({ changes, existingBody = "", links, revise }, newline) {
|
|
1443
|
+
const draft = links === void 0 ? existingBody : withoutPackageLinks(existingBody);
|
|
1444
|
+
const body = mergeChanges(revise === true ? "" : draft, changes, newline);
|
|
1445
|
+
if (links === void 0 || links.length === 0) return body;
|
|
1446
|
+
const list = links.map(({ label, url }) => `- [${label}](${url})`);
|
|
1447
|
+
return body.trimEnd() + newline + newline + [
|
|
1448
|
+
"### Packages",
|
|
1449
|
+
"",
|
|
1450
|
+
...list
|
|
1451
|
+
].join(newline);
|
|
1452
|
+
}
|
|
1243
1453
|
/** Render a release heading with an optional comparison link. */
|
|
1244
1454
|
function releaseLink(github, release) {
|
|
1245
1455
|
if (github === void 0) return;
|
|
@@ -1260,12 +1470,16 @@ function updateMarkdown(original, entries, nodeException, github) {
|
|
|
1260
1470
|
const draft = parsed.sections.find((section) => section.version === "Unreleased");
|
|
1261
1471
|
const released = parsed.sections.filter((section) => section.version !== "Unreleased").map((section) => content.slice(section.start, section.end));
|
|
1262
1472
|
let draftText = draft === void 0 ? "" : content.slice(draft.start, draft.end);
|
|
1263
|
-
for (const
|
|
1473
|
+
for (const entry of entries) {
|
|
1474
|
+
const { existingBody, release } = entry;
|
|
1264
1475
|
const link = releaseLink(github, release);
|
|
1265
1476
|
const heading = `## ${link === void 0 ? release.version : `[${release.version}]`}${release.date === void 0 ? "" : ` - ${release.date}`}`;
|
|
1266
|
-
let body =
|
|
1477
|
+
let body = sectionBody(entry, newline);
|
|
1267
1478
|
if (body.trim() === "") {
|
|
1268
|
-
if (release.version === "Unreleased")
|
|
1479
|
+
if (release.version === "Unreleased") {
|
|
1480
|
+
if (existingBody !== void 0) draftText = "";
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1269
1483
|
body = newline + noNotableChanges + newline;
|
|
1270
1484
|
}
|
|
1271
1485
|
const section = heading + newline + newline + body.trim() + newline + newline;
|
|
@@ -1302,8 +1516,15 @@ function addNodeNote(header, newline, nodeException) {
|
|
|
1302
1516
|
}
|
|
1303
1517
|
return header;
|
|
1304
1518
|
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Read a heading's version the way default tag patterns read a tag, so `1.2`
|
|
1521
|
+
* and `v1` name the first release of their series and a bare `1` names none.
|
|
1522
|
+
*/
|
|
1305
1523
|
function normalizeVersion(raw) {
|
|
1306
|
-
|
|
1524
|
+
if (raw.toLowerCase() === "unreleased") return "Unreleased";
|
|
1525
|
+
const prefixed = V_PREFIX.test(raw);
|
|
1526
|
+
const parsed = parseVersion(prefixed ? raw.slice(1) : raw);
|
|
1527
|
+
return parsed === void 0 || !prefixed && parsed.precision === 1 ? raw : parsed.version;
|
|
1307
1528
|
}
|
|
1308
1529
|
function releaseHeadingVersion(line) {
|
|
1309
1530
|
const match = RELEASE_HEADING.exec(line.trimEnd());
|
|
@@ -1319,20 +1540,32 @@ function sectionSpacing(content, newline) {
|
|
|
1319
1540
|
|
|
1320
1541
|
// src/lib/evidence.ts
|
|
1321
1542
|
const COMMIT_MARKER = "";
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
1543
|
+
const OUTPUT_DIRECTORY = /^(?:build|changelog|coverage|dist)(?:\/|$)/iv;
|
|
1544
|
+
const NEVER_SOURCE = /(?:^|\/)(?:changelog(?:\.md)?$|node_modules(?:\/|$))/iv;
|
|
1324
1545
|
/**
|
|
1325
|
-
*
|
|
1326
|
-
*
|
|
1327
|
-
* dependency ranges.
|
|
1546
|
+
* Changelogs are this tool's own output, installed dependencies are another
|
|
1547
|
+
* project's source, and lockfiles record resolved versions, not declared
|
|
1548
|
+
* dependency ranges. None of them is evidence of a change.
|
|
1328
1549
|
*/
|
|
1329
|
-
function
|
|
1550
|
+
function excluded(file) {
|
|
1551
|
+
return LOCKFILE_PATH.test(file) || NEVER_SOURCE.test(file);
|
|
1552
|
+
}
|
|
1553
|
+
/**
|
|
1554
|
+
* Recognize directories at the top of a package that usually hold generated
|
|
1555
|
+
* output, so `packages/build` and `src/dist` remain source. Supplied commits
|
|
1556
|
+
* leave them out, but they still count as changes to research: only the agent
|
|
1557
|
+
* can tell a build artifact from a project whose source lives in `coverage`.
|
|
1558
|
+
*/
|
|
1559
|
+
function likelyGenerated(file, packages) {
|
|
1330
1560
|
const owner = packages.findLast(({ directory }) => file.startsWith(`${directory}/`));
|
|
1331
1561
|
const relative = owner === void 0 ? file : file.slice(owner.directory.length + 1);
|
|
1332
|
-
return
|
|
1562
|
+
return OUTPUT_DIRECTORY.test(relative);
|
|
1333
1563
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1564
|
+
/**
|
|
1565
|
+
* A package's directory and, transitively, those of the workspace packages it
|
|
1566
|
+
* depends on at a ref.
|
|
1567
|
+
*/
|
|
1568
|
+
async function packageScope(repository, pkg, ref) {
|
|
1336
1569
|
const directories = /* @__PURE__ */ new Set([pkg.directory]);
|
|
1337
1570
|
const visit = async (current) => {
|
|
1338
1571
|
const manifest = await manifestAt(repository.root, current.directory, ref);
|
|
@@ -1399,10 +1632,13 @@ async function commitsInRange(root, range, paths) {
|
|
|
1399
1632
|
}
|
|
1400
1633
|
return commits.values().toArray();
|
|
1401
1634
|
}
|
|
1402
|
-
/**
|
|
1635
|
+
/**
|
|
1636
|
+
* Collect a release's starting evidence, or nothing when its range holds only
|
|
1637
|
+
* changelogs, lockfiles, and version bookkeeping.
|
|
1638
|
+
*/
|
|
1403
1639
|
async function collectEvidence(repository, pkg, release, excludeDirectories = []) {
|
|
1404
1640
|
const { packages, root } = repository;
|
|
1405
|
-
const paths = [...(await
|
|
1641
|
+
const paths = [...(pkg.directory === "." ? ["."] : await packageScope(repository, pkg, release.ref)).map((directory) => `:(literal)${directory}`), ...excludeDirectories.map((directory) => `:(exclude,literal)${directory}`)];
|
|
1406
1642
|
const range = release.previousRef === void 0 ? release.ref : `${release.previousRef}..${release.ref}`;
|
|
1407
1643
|
const from = release.previousRef ?? await git(root, [
|
|
1408
1644
|
"hash-object",
|
|
@@ -1421,20 +1657,25 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
|
|
|
1421
1657
|
release.ref,
|
|
1422
1658
|
"--",
|
|
1423
1659
|
...paths
|
|
1424
|
-
])).split("\0").filter((file) => file !== "" && !
|
|
1660
|
+
])).split("\0").filter((file) => file !== "" && !excluded(file));
|
|
1425
1661
|
const rangeBlobs = await readBlobs(root, manifestObjects(candidates, from, release.ref));
|
|
1426
1662
|
const changed = new Set(candidates.filter((file) => !isManifest(file) || !isVersionOnlyEdit(rangeBlobs.get(`${from}:${file}`), rangeBlobs.get(`${release.ref}:${file}`))));
|
|
1427
1663
|
if (changed.size === 0) return [];
|
|
1428
1664
|
const sourceCommits = (await commitsInRange(root, range, paths)).map((item) => ({
|
|
1429
1665
|
...item,
|
|
1430
|
-
files: item.files.filter((file) => !
|
|
1666
|
+
files: item.files.filter((file) => !excluded(file) && !likelyGenerated(file, packages))
|
|
1431
1667
|
}));
|
|
1432
1668
|
const commitBlobs = await readBlobs(root, sourceCommits.flatMap(({ files, id }) => manifestObjects(files, `${id}^`, id)));
|
|
1433
|
-
const evidence =
|
|
1669
|
+
const evidence = [{
|
|
1670
|
+
id: `range:${from}..${release.ref}`,
|
|
1671
|
+
kind: "range",
|
|
1672
|
+
text: `Research the complete Git range ${range} using git log, git diff, git show, and git ls-tree. The supplied commits are a starting point, not a complete description of changes. Follow the scope and exclusions in repository.`
|
|
1673
|
+
}];
|
|
1674
|
+
for (const { files, id, message } of sourceCommits) if (files.some((file) => changed.has(file)) && hasSourceChanges(files, id, commitBlobs)) evidence.push({
|
|
1434
1675
|
id,
|
|
1435
1676
|
kind: "commit",
|
|
1436
1677
|
text: `${message}\nFiles: ${files.join(", ")}`
|
|
1437
|
-
})
|
|
1678
|
+
});
|
|
1438
1679
|
if (release.pending === true) {
|
|
1439
1680
|
const file = path.posix.join(pkg.directory, "package.json");
|
|
1440
1681
|
const diff = await git(root, [
|
|
@@ -1451,11 +1692,6 @@ async function collectEvidence(repository, pkg, release, excludeDirectories = []
|
|
|
1451
1692
|
text: diff
|
|
1452
1693
|
});
|
|
1453
1694
|
}
|
|
1454
|
-
if (evidence.length > 0) evidence.unshift({
|
|
1455
|
-
id: `range:${from}..${release.ref}`,
|
|
1456
|
-
kind: "range",
|
|
1457
|
-
text: `Research the complete Git range ${range} using git log, git diff, git show, and git ls-tree. The supplied commits are a starting point, not a complete description of changes. Follow the scope and exclusions in repository.`
|
|
1458
|
-
});
|
|
1459
1695
|
return evidence;
|
|
1460
1696
|
}
|
|
1461
1697
|
function githubUnavailable(error) {
|
|
@@ -1592,7 +1828,7 @@ function releaseBatches(requests, limit) {
|
|
|
1592
1828
|
for (const release of requests) {
|
|
1593
1829
|
const minimum = {
|
|
1594
1830
|
...release,
|
|
1595
|
-
evidence: release.evidence.filter((item) => item
|
|
1831
|
+
evidence: release.evidence.filter((item) => required(item))
|
|
1596
1832
|
};
|
|
1597
1833
|
const length = JSON.stringify(minimum).length + 1;
|
|
1598
1834
|
if (size + length > limit && batch.length > 0) {
|
|
@@ -1608,7 +1844,7 @@ function releaseBatches(requests, limit) {
|
|
|
1608
1844
|
const allowance = Math.max(0, Math.floor((limit - JSON.stringify(items).length) / items.length));
|
|
1609
1845
|
const seeded = items.map((item) => ({
|
|
1610
1846
|
...item,
|
|
1611
|
-
evidence: [...item.evidence, ...researchEvidence(full.get(item.id).filter((entry) => entry
|
|
1847
|
+
evidence: [...item.evidence, ...researchEvidence(full.get(item.id).filter((entry) => !required(entry)), allowance + 2)]
|
|
1612
1848
|
}));
|
|
1613
1849
|
let remaining = limit - JSON.stringify(seeded).length;
|
|
1614
1850
|
for (const item of seeded) {
|
|
@@ -1621,6 +1857,10 @@ function releaseBatches(requests, limit) {
|
|
|
1621
1857
|
return seeded;
|
|
1622
1858
|
});
|
|
1623
1859
|
}
|
|
1860
|
+
/** Keep what a request cannot do without: its range, and a draft it may cite. */
|
|
1861
|
+
function required(item) {
|
|
1862
|
+
return item.kind === "range" || item.kind === "draft";
|
|
1863
|
+
}
|
|
1624
1864
|
/** Bound the starting context; adapters can read the complete range themselves. */
|
|
1625
1865
|
function researchEvidence(evidence, limit) {
|
|
1626
1866
|
const seed = [];
|
|
@@ -1635,19 +1875,32 @@ function researchEvidence(evidence, limit) {
|
|
|
1635
1875
|
}
|
|
1636
1876
|
|
|
1637
1877
|
// src/lib/jobs.ts
|
|
1878
|
+
/** Lets a revised entry cite the draft it came from. */
|
|
1879
|
+
const draftEvidence = {
|
|
1880
|
+
id: "draft",
|
|
1881
|
+
kind: "draft",
|
|
1882
|
+
text: "The existing entries, which describe this release up to repository.from. Cite this ID for entries carried over or rewritten from them."
|
|
1883
|
+
};
|
|
1884
|
+
/** Check whether a package's changelog is a monorepo's root summary. */
|
|
1885
|
+
function summarizesPackages(context, pkg) {
|
|
1886
|
+
return pkg.directory === "." && context.repository.packages.length > 0 && context.options.rootChangelog === "summary";
|
|
1887
|
+
}
|
|
1638
1888
|
/**
|
|
1639
1889
|
* Prepare one release without launching a model, so repository research can be
|
|
1640
1890
|
* shared.
|
|
1641
1891
|
*/
|
|
1642
|
-
async function prepareRelease(context, pkg, release,
|
|
1892
|
+
async function prepareRelease(context, pkg, release, details) {
|
|
1643
1893
|
const { options, repository } = context;
|
|
1894
|
+
const { existingEntries = "", from, mode, onlyRepositoryFiles = false, styleReference } = details;
|
|
1644
1895
|
options.signal?.throwIfAborted();
|
|
1645
|
-
const excludeDirectories =
|
|
1896
|
+
const excludeDirectories = await excludedPackages(context, pkg, release.ref, onlyRepositoryFiles);
|
|
1897
|
+
const evidence = await collectEvidence(repository, pkg, from === void 0 ? release : {
|
|
1898
|
+
...release,
|
|
1899
|
+
previousRef: from
|
|
1900
|
+
}, excludeDirectories);
|
|
1901
|
+
if (mode === "revise" && evidence.length > 0) evidence.splice(1, 0, draftEvidence);
|
|
1646
1902
|
return {
|
|
1647
|
-
evidence
|
|
1648
|
-
...release,
|
|
1649
|
-
previousRef: from
|
|
1650
|
-
}, excludeDirectories),
|
|
1903
|
+
evidence,
|
|
1651
1904
|
existingEntries,
|
|
1652
1905
|
id: `${pkg.directory}@${release.version}`,
|
|
1653
1906
|
initialRelease: release.previousRef === void 0 && from === void 0,
|
|
@@ -1659,10 +1912,25 @@ async function prepareRelease(context, pkg, release, existingEntries, mode, from
|
|
|
1659
1912
|
from: from ?? release.previousRef,
|
|
1660
1913
|
to: release.ref
|
|
1661
1914
|
},
|
|
1662
|
-
scope: pkg
|
|
1915
|
+
scope: summarizesPackages(context, pkg) ? "repository" : "package",
|
|
1916
|
+
styleReference,
|
|
1663
1917
|
version: release.version
|
|
1664
1918
|
};
|
|
1665
1919
|
}
|
|
1920
|
+
/**
|
|
1921
|
+
* Select the package directories a release leaves out: every package when
|
|
1922
|
+
* describing repository files alone, skipped packages in a whole-repository
|
|
1923
|
+
* summary, or, when the root is a package itself, those it does not depend on.
|
|
1924
|
+
*/
|
|
1925
|
+
async function excludedPackages(context, pkg, ref, onlyRepositoryFiles) {
|
|
1926
|
+
const { options, repository } = context;
|
|
1927
|
+
const directories = repository.packages.map((item) => item.directory);
|
|
1928
|
+
if (onlyRepositoryFiles || directories.length === 0) return directories;
|
|
1929
|
+
if (pkg.directory !== ".") return [];
|
|
1930
|
+
if (options.rootChangelog === "summary") return repository.packages.filter((item) => item.skipped === true).map((item) => item.directory);
|
|
1931
|
+
const scope = new Set(await packageScope(repository, pkg, ref));
|
|
1932
|
+
return directories.filter((directory) => !scope.has(directory));
|
|
1933
|
+
}
|
|
1666
1934
|
function cacheKey(context, release) {
|
|
1667
1935
|
const { effort, maxInputChars, model } = context.options;
|
|
1668
1936
|
return JSON.stringify({
|
|
@@ -1711,9 +1979,11 @@ function progressMessage(pending, sessions) {
|
|
|
1711
1979
|
}
|
|
1712
1980
|
/**
|
|
1713
1981
|
* Run one agent session. A long session is worth saving, so invalid output gets
|
|
1714
|
-
* one correction request before the run fails.
|
|
1982
|
+
* one correction request before the run fails. Citations are checked against
|
|
1983
|
+
* the releases as they were before their evidence was trimmed to fit, since an
|
|
1984
|
+
* agent researching a range can cite what trimming left out.
|
|
1715
1985
|
*/
|
|
1716
|
-
async function runSession(context, releases) {
|
|
1986
|
+
async function runSession(context, releases, untrimmed) {
|
|
1717
1987
|
const { agent, options, repository } = context;
|
|
1718
1988
|
const request = {
|
|
1719
1989
|
releases,
|
|
@@ -1726,24 +1996,24 @@ async function runSession(context, releases) {
|
|
|
1726
1996
|
}
|
|
1727
1997
|
};
|
|
1728
1998
|
log.debug(`Starting a ${agent.name} session for ${releases.map(({ id }) => id).join(", ")} with model ${options.model ?? "default"}, effort ${options.effort}, and a ${options.timeoutMs} ms timeout.`);
|
|
1999
|
+
const accept = async (value) => verifyCitations(repository.root, validateResponse(value, releases), untrimmed);
|
|
1729
2000
|
const started = performance.now();
|
|
1730
2001
|
const response = await agent.generate(request);
|
|
1731
2002
|
log.debug(`The ${agent.name} session finished in ${Math.round(performance.now() - started)} ms.`);
|
|
1732
2003
|
try {
|
|
1733
|
-
return
|
|
2004
|
+
return await accept(response);
|
|
1734
2005
|
} catch (error) {
|
|
1735
2006
|
const message = error instanceof Error ? error.message : String(error);
|
|
1736
2007
|
log.warn(`Requesting a correction of invalid agent output. ${message}`);
|
|
1737
2008
|
options.onProgress?.("Requesting a correction of invalid agent output.");
|
|
1738
2009
|
options.signal?.throwIfAborted();
|
|
1739
|
-
|
|
2010
|
+
return accept(await agent.generate({
|
|
1740
2011
|
...request,
|
|
1741
2012
|
repair: {
|
|
1742
2013
|
error: message,
|
|
1743
2014
|
response
|
|
1744
2015
|
}
|
|
1745
|
-
});
|
|
1746
|
-
return validateResponse(corrected, releases);
|
|
2016
|
+
}));
|
|
1747
2017
|
}
|
|
1748
2018
|
}
|
|
1749
2019
|
/**
|
|
@@ -1776,7 +2046,7 @@ async function generateReleases(context, requests) {
|
|
|
1776
2046
|
options.onProgress?.(status);
|
|
1777
2047
|
for (const releases of batches) {
|
|
1778
2048
|
options.signal?.throwIfAborted();
|
|
1779
|
-
const changeSets = await runSession(context, releases);
|
|
2049
|
+
const changeSets = await runSession(context, releases, releases.map(({ id }) => unabridged.get(id)));
|
|
1780
2050
|
for (const { changes, id } of changeSets) {
|
|
1781
2051
|
results.set(id, changes);
|
|
1782
2052
|
await saveChanges(context, unabridged.get(id), changes);
|
|
@@ -1789,8 +2059,9 @@ async function generateReleases(context, requests) {
|
|
|
1789
2059
|
const checkpointSchema = z.object({
|
|
1790
2060
|
baseRef: z.string(),
|
|
1791
2061
|
contentHash: z.string(),
|
|
2062
|
+
generated: z.boolean(),
|
|
1792
2063
|
head: z.string(),
|
|
1793
|
-
instructionVersion: z.
|
|
2064
|
+
instructionVersion: z.string(),
|
|
1794
2065
|
path: z.string()
|
|
1795
2066
|
});
|
|
1796
2067
|
const HEADING_PREFIX = /^#{1,3}\s+/v;
|
|
@@ -1799,27 +2070,43 @@ const ANCHOR_PUNCTUATION = /[^\p{L}\p{N}_\s\-]/gv;
|
|
|
1799
2070
|
const ANCHOR_SPACE = /\s/gv;
|
|
1800
2071
|
/**
|
|
1801
2072
|
* Remember what this run covered, so the next one can research only newer
|
|
1802
|
-
* commits.
|
|
2073
|
+
* commits, and whether the draft it leaves is entirely generated.
|
|
1803
2074
|
*/
|
|
1804
|
-
function recordCheckpoint(context, file, content, release) {
|
|
2075
|
+
function recordCheckpoint(context, file, content, release, generated) {
|
|
1805
2076
|
if (release === void 0 || !context.options.cache) return;
|
|
1806
2077
|
context.plan.checkpoints.push({
|
|
1807
2078
|
baseRef: release.previousRef ?? "initial",
|
|
1808
2079
|
contentHash: changelogHash(content),
|
|
2080
|
+
generated,
|
|
1809
2081
|
head: context.repository.head,
|
|
1810
2082
|
instructionVersion,
|
|
1811
2083
|
path: file
|
|
1812
2084
|
});
|
|
1813
2085
|
}
|
|
1814
2086
|
/**
|
|
1815
|
-
*
|
|
1816
|
-
*
|
|
2087
|
+
* Read what the last run recorded about a changelog, when the file still reads
|
|
2088
|
+
* as that run left it.
|
|
1817
2089
|
*/
|
|
1818
|
-
async function
|
|
2090
|
+
async function readCheckpoint(context, file, content) {
|
|
1819
2091
|
if (content === void 0 || !context.options.cache) return;
|
|
1820
2092
|
const result = checkpointSchema.safeParse(await readCache(path.join(context.repository.gitDirectory, "chagent", "checkpoints"), file));
|
|
1821
|
-
|
|
1822
|
-
|
|
2093
|
+
return result.success && result.data.contentHash === changelogHash(content) ? result.data : void 0;
|
|
2094
|
+
}
|
|
2095
|
+
/**
|
|
2096
|
+
* Find the commit a release's entries already cover, when the checkpoint
|
|
2097
|
+
* describes the same release.
|
|
2098
|
+
*/
|
|
2099
|
+
async function incrementalBase(context, checkpoint, release) {
|
|
2100
|
+
if (checkpoint?.instructionVersion !== instructionVersion || checkpoint.baseRef !== (release.previousRef ?? "initial")) return;
|
|
2101
|
+
return await isAncestor(context.repository.root, checkpoint.head, release.ref) ? checkpoint.head : void 0;
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
2104
|
+
* Decide whether the draft a run leaves is entirely generated: one that was
|
|
2105
|
+
* before stays so, and a draft written from nothing or revised becomes so.
|
|
2106
|
+
*/
|
|
2107
|
+
function leavesGeneratedDraft(entries, previously) {
|
|
2108
|
+
const draft = entries.find((entry) => entry.release.version === "Unreleased");
|
|
2109
|
+
return previously || draft !== void 0 && (draft.existingBody === void 0 || draft.revise === true);
|
|
1823
2110
|
}
|
|
1824
2111
|
/** Select the releases newer than anything the changelog already records. */
|
|
1825
2112
|
function missingReleases(releases, content = "") {
|
|
@@ -1836,8 +2123,28 @@ function missingReleases(releases, content = "") {
|
|
|
1836
2123
|
function releaseContext(options, pkg) {
|
|
1837
2124
|
return pkg.directory === options.releaseDirectory ? options.release : void 0;
|
|
1838
2125
|
}
|
|
1839
|
-
/**
|
|
1840
|
-
async function
|
|
2126
|
+
/** Prepare the request for a release the changelog still needs. */
|
|
2127
|
+
async function prepareWork(context, pkg, release, details) {
|
|
2128
|
+
const { checkpoint, existingBody, mode } = details;
|
|
2129
|
+
const from = existingBody === void 0 ? void 0 : await incrementalBase(context, checkpoint, release);
|
|
2130
|
+
const revise = from !== void 0 && checkpoint?.generated === true;
|
|
2131
|
+
return {
|
|
2132
|
+
existingBody,
|
|
2133
|
+
release,
|
|
2134
|
+
request: await prepareRelease(context, pkg, release, {
|
|
2135
|
+
existingEntries: existingBody !== void 0 && summarizesPackages(context, pkg) ? withoutPackageLinks(existingBody) : existingBody,
|
|
2136
|
+
from,
|
|
2137
|
+
mode: revise ? "revise" : mode,
|
|
2138
|
+
styleReference: details.styleReference
|
|
2139
|
+
})
|
|
2140
|
+
};
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Resolve a package's releases and prepare requests for those its changelog
|
|
2144
|
+
* still needs. Summaries cover those releases alone, or with `allSummaries`,
|
|
2145
|
+
* every release, so that a root changelog can link to any of them.
|
|
2146
|
+
*/
|
|
2147
|
+
async function preparePackage(context, pkg, allSummaries = false) {
|
|
1841
2148
|
const { repository } = context;
|
|
1842
2149
|
const file = await findChangelog(path.join(repository.root, pkg.directory));
|
|
1843
2150
|
const content = context.options.rebuild ? void 0 : file.content;
|
|
@@ -1845,15 +2152,17 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
|
|
|
1845
2152
|
const releases = await resolveReleases(repository, pkg, context.options, releaseContext(context.options, pkg));
|
|
1846
2153
|
const missing = missingReleases(releases, content);
|
|
1847
2154
|
log.debug(`${pkg.name}: ${releases.length} releases, missing [${missing.map((release) => release.version).join(", ")}], changelog ${file.path}.`);
|
|
1848
|
-
const
|
|
2155
|
+
const parsed = parseChangelog(content ?? "");
|
|
2156
|
+
const draft = parsed.sections.find((section) => section.version === "Unreleased");
|
|
2157
|
+
const checkpoint = draft === void 0 ? void 0 : await readCheckpoint(context, file.path, content);
|
|
1849
2158
|
const newest = releases.findLast((release) => release.version !== "Unreleased");
|
|
1850
2159
|
const draftPromoted = draft !== void 0 && newest !== void 0 && missing.includes(newest);
|
|
1851
2160
|
const work = [];
|
|
1852
2161
|
const summaries = [];
|
|
1853
2162
|
for (const release of releases) {
|
|
1854
2163
|
const shouldWrite = missing.includes(release);
|
|
1855
|
-
if (!shouldWrite && !
|
|
1856
|
-
|
|
2164
|
+
if (!shouldWrite && !allSummaries) continue;
|
|
2165
|
+
summaries.push({
|
|
1857
2166
|
changelogPath: file.path,
|
|
1858
2167
|
changes: [],
|
|
1859
2168
|
date: release.date,
|
|
@@ -1862,41 +2171,49 @@ async function preparePackage(context, pkg, aggregateHistory = false, recordedAt
|
|
|
1862
2171
|
ref: release.ref,
|
|
1863
2172
|
tag: release.tag,
|
|
1864
2173
|
version: release.version
|
|
1865
|
-
};
|
|
1866
|
-
summaries.push(summary);
|
|
1867
|
-
if (!shouldWrite && recordedAtRoot?.(summary) === true) continue;
|
|
1868
|
-
const existingBody = (draftPromoted ? release === newest : release.version === "Unreleased") ? draft?.body : void 0;
|
|
1869
|
-
const from = existingBody === void 0 ? void 0 : await incrementalBase(context, file.path, content, release);
|
|
1870
|
-
const request = await prepareRelease(context, pkg, release, existingBody ?? "", mode, from);
|
|
1871
|
-
work.push({
|
|
1872
|
-
existingBody,
|
|
1873
|
-
release,
|
|
1874
|
-
request,
|
|
1875
|
-
shouldWrite
|
|
1876
2174
|
});
|
|
2175
|
+
if (!shouldWrite) continue;
|
|
2176
|
+
const ownsDraft = draftPromoted ? release === newest : release.version === "Unreleased";
|
|
2177
|
+
work.push(await prepareWork(context, pkg, release, {
|
|
2178
|
+
checkpoint,
|
|
2179
|
+
existingBody: ownsDraft ? draft?.body : void 0,
|
|
2180
|
+
mode,
|
|
2181
|
+
styleReference: styleReference(parsed)
|
|
2182
|
+
}));
|
|
1877
2183
|
}
|
|
2184
|
+
const exception = await hasNodeException(repository, pkg, releases);
|
|
1878
2185
|
return {
|
|
1879
2186
|
content,
|
|
1880
|
-
|
|
2187
|
+
draftGenerated: checkpoint?.generated === true,
|
|
2188
|
+
exception,
|
|
1881
2189
|
file,
|
|
2190
|
+
pkg,
|
|
1882
2191
|
releases,
|
|
1883
2192
|
summaries,
|
|
1884
2193
|
work
|
|
1885
2194
|
};
|
|
1886
2195
|
}
|
|
1887
|
-
/**
|
|
1888
|
-
|
|
2196
|
+
/**
|
|
2197
|
+
* Write a package's edit into the plan and complete its summaries. A root
|
|
2198
|
+
* summary passes `linksFor` to end each section it writes with links to the
|
|
2199
|
+
* package releases that section covers.
|
|
2200
|
+
*/
|
|
2201
|
+
function renderPackage(context, prepared, results, linksFor) {
|
|
1889
2202
|
const { plan, repository } = context;
|
|
1890
2203
|
const { content, exception, file, releases, summaries, work } = prepared;
|
|
1891
2204
|
const entries = [];
|
|
1892
2205
|
const changesByVersion = /* @__PURE__ */ new Map();
|
|
1893
|
-
for (const { existingBody, release, request
|
|
2206
|
+
for (const { existingBody, release, request } of work) {
|
|
1894
2207
|
const changes = results.get(request.id) ?? [];
|
|
2208
|
+
const links = linksFor?.(release);
|
|
2209
|
+
const revise = request.mode === "revise" && results.has(request.id);
|
|
1895
2210
|
changesByVersion.set(release.version, changes);
|
|
1896
|
-
if (
|
|
2211
|
+
if (revise || release.version !== "Unreleased" || changes.length > 0 || links !== void 0 && packageLinksDiffer(existingBody, links)) entries.push({
|
|
1897
2212
|
changes,
|
|
1898
2213
|
existingBody,
|
|
1899
|
-
|
|
2214
|
+
links,
|
|
2215
|
+
release,
|
|
2216
|
+
revise
|
|
1900
2217
|
});
|
|
1901
2218
|
}
|
|
1902
2219
|
const after = updateMarkdown(content, entries, exception, repository.github);
|
|
@@ -1905,7 +2222,7 @@ function renderPackage(context, prepared, results) {
|
|
|
1905
2222
|
before: file.content,
|
|
1906
2223
|
path: file.path
|
|
1907
2224
|
});
|
|
1908
|
-
recordCheckpoint(context, file.path, after, releases.at(-1));
|
|
2225
|
+
recordCheckpoint(context, file.path, after, releases.at(-1), leavesGeneratedDraft(entries, prepared.draftGenerated));
|
|
1909
2226
|
const { sections } = parseChangelog(after);
|
|
1910
2227
|
return summaries.map((summary) => ({
|
|
1911
2228
|
...summary,
|
|
@@ -1934,74 +2251,40 @@ function missingPackageSummaries(summaries, content = "") {
|
|
|
1934
2251
|
}
|
|
1935
2252
|
|
|
1936
2253
|
// src/lib/workspace.ts
|
|
2254
|
+
/** Link to a package's release section, when its changelog has one. */
|
|
1937
2255
|
function packageLink(repository, summary) {
|
|
1938
|
-
|
|
1939
|
-
const
|
|
1940
|
-
return
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
category: "Maintenance",
|
|
1951
|
-
description: `${link}: See the package changelog.`,
|
|
1952
|
-
sources: []
|
|
1953
|
-
});
|
|
1954
|
-
else for (const change of highlights) changes.push({
|
|
1955
|
-
...change,
|
|
1956
|
-
description: `${link}: ${change.description}`
|
|
1957
|
-
});
|
|
1958
|
-
}
|
|
1959
|
-
return changes;
|
|
1960
|
-
}
|
|
1961
|
-
function promotePackageDraft(content, summary, repository) {
|
|
1962
|
-
const changes = [];
|
|
1963
|
-
const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased");
|
|
1964
|
-
if (content === void 0 || draft === void 0) return {
|
|
1965
|
-
changes,
|
|
1966
|
-
content
|
|
1967
|
-
};
|
|
1968
|
-
const source = `[${summary.packageName} Unreleased](`;
|
|
1969
|
-
const target = `[${summary.packageName} ${summary.version}](${packageLink(repository, summary)})`;
|
|
1970
|
-
const remaining = [];
|
|
1971
|
-
let category = "Changed";
|
|
1972
|
-
for (const line of draft.body.split(LINE_BOUNDARY)) {
|
|
1973
|
-
category = categories.find((item) => line.trimEnd() === `### ${item}`) ?? category;
|
|
1974
|
-
const breaking = line.startsWith("- **Breaking:** ");
|
|
1975
|
-
const prefix = breaking ? "- **Breaking:** " : "- ";
|
|
1976
|
-
const end = line.indexOf(")", prefix.length + source.length);
|
|
1977
|
-
if (end === -1 || !line.startsWith(prefix + source)) {
|
|
1978
|
-
remaining.push(line);
|
|
1979
|
-
continue;
|
|
1980
|
-
}
|
|
1981
|
-
changes.push({
|
|
1982
|
-
breaking,
|
|
1983
|
-
category,
|
|
1984
|
-
description: target + line.slice(end + 1).trimEnd(),
|
|
1985
|
-
sources: []
|
|
1986
|
-
});
|
|
1987
|
-
}
|
|
1988
|
-
if (changes.length === 0) return {
|
|
1989
|
-
changes,
|
|
1990
|
-
content
|
|
1991
|
-
};
|
|
1992
|
-
const replacement = remaining.some((line) => line.trim() !== "" && categories.every((item) => line.trimEnd() !== `### ${item}`)) ? draft.heading + remaining.join("") : "";
|
|
1993
|
-
return {
|
|
1994
|
-
changes,
|
|
1995
|
-
content: content.slice(0, draft.start) + replacement + content.slice(draft.end)
|
|
1996
|
-
};
|
|
1997
|
-
}
|
|
1998
|
-
async function prepareIndependent(context, summaries) {
|
|
2256
|
+
if (summary.anchor === void 0) return [];
|
|
2257
|
+
const target = `${path.relative(repository.root, summary.changelogPath).split(path.sep).join("/")}#${summary.anchor}`;
|
|
2258
|
+
return [{
|
|
2259
|
+
label: `${summary.packageName} ${summary.version}`,
|
|
2260
|
+
url: repository.github === void 0 ? target : `https://github.com/${repository.github}/blob/HEAD/${target}`
|
|
2261
|
+
}];
|
|
2262
|
+
}
|
|
2263
|
+
/** Name the root's summary of a package release. */
|
|
2264
|
+
function summaryId(summary) {
|
|
2265
|
+
return `.@${summary.packageName}@${summary.version}`;
|
|
2266
|
+
}
|
|
2267
|
+
async function prepareIndependent(context, packages) {
|
|
1999
2268
|
const { repository } = context;
|
|
2000
2269
|
const file = await findChangelog(repository.root);
|
|
2001
2270
|
const content = context.options.rebuild ? void 0 : file.content;
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2271
|
+
const parsed = parseChangelog(content ?? "");
|
|
2272
|
+
const details = {
|
|
2273
|
+
mode: content === void 0 ? "initialize" : "update",
|
|
2274
|
+
styleReference: styleReference(parsed)
|
|
2275
|
+
};
|
|
2276
|
+
const releases = [];
|
|
2277
|
+
for (const item of packages) for (const summary of missingPackageSummaries(item.summaries, content)) {
|
|
2278
|
+
const release = item.releases.find((candidate) => candidate.version === summary.version);
|
|
2279
|
+
const request = await prepareRelease(context, item.pkg, release, details);
|
|
2280
|
+
releases.push({
|
|
2281
|
+
...request,
|
|
2282
|
+
evidence: request.evidence.filter((evidence) => evidence.kind === "range"),
|
|
2283
|
+
id: summaryId(summary),
|
|
2284
|
+
scope: "repository"
|
|
2285
|
+
});
|
|
2286
|
+
}
|
|
2287
|
+
const releaseRefs = new Set(packages.flatMap((item) => item.summaries).filter((summary) => summary.version !== "Unreleased").map((summary) => summary.ref));
|
|
2005
2288
|
const release = {
|
|
2006
2289
|
previousRef: (await git(repository.root, [
|
|
2007
2290
|
"rev-list",
|
|
@@ -2011,44 +2294,54 @@ async function prepareIndependent(context, summaries) {
|
|
|
2011
2294
|
ref: repository.head,
|
|
2012
2295
|
version: "Unreleased"
|
|
2013
2296
|
};
|
|
2014
|
-
const draft =
|
|
2015
|
-
const
|
|
2297
|
+
const draft = parsed.sections.find((section) => section.version === "Unreleased")?.body;
|
|
2298
|
+
const checkpoint = await readCheckpoint(context, file.path, content);
|
|
2299
|
+
const from = await incrementalBase(context, checkpoint, release);
|
|
2300
|
+
const draftGenerated = checkpoint?.generated === true;
|
|
2016
2301
|
return {
|
|
2017
2302
|
content,
|
|
2303
|
+
draft,
|
|
2304
|
+
draftGenerated,
|
|
2018
2305
|
file,
|
|
2019
|
-
|
|
2020
|
-
|
|
2306
|
+
releases,
|
|
2307
|
+
unreleased: {
|
|
2308
|
+
release,
|
|
2309
|
+
request: await prepareRelease(context, repository.rootPackage, release, {
|
|
2310
|
+
...details,
|
|
2311
|
+
existingEntries: draft === void 0 ? void 0 : withoutPackageLinks(draft),
|
|
2312
|
+
from,
|
|
2313
|
+
mode: draft !== void 0 && from !== void 0 && draftGenerated ? "revise" : details.mode,
|
|
2314
|
+
onlyRepositoryFiles: true
|
|
2315
|
+
})
|
|
2316
|
+
}
|
|
2021
2317
|
};
|
|
2022
2318
|
}
|
|
2023
|
-
|
|
2319
|
+
/**
|
|
2320
|
+
* Write a section for each package release the root does not record yet, and
|
|
2321
|
+
* keep the draft's links to packages with unreleased changes current.
|
|
2322
|
+
*/
|
|
2323
|
+
function renderIndependent(context, prepared, results, summaries, exception) {
|
|
2024
2324
|
const { plan, repository } = context;
|
|
2025
|
-
const { file } = prepared;
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
const label = `${summary.packageName}@${summary.version}`;
|
|
2030
|
-
const promoted = promotePackageDraft(content, summary, repository);
|
|
2031
|
-
content = promoted.content;
|
|
2032
|
-
entries.push({
|
|
2033
|
-
changes: [...promoted.changes, ...aggregateChanges(repository, [summary])],
|
|
2034
|
-
release: {
|
|
2035
|
-
date: summary.date,
|
|
2036
|
-
ref: summary.ref,
|
|
2037
|
-
tag: summary.tag,
|
|
2038
|
-
version: label
|
|
2039
|
-
}
|
|
2040
|
-
});
|
|
2041
|
-
}
|
|
2042
|
-
const unreleased = aggregateChanges(repository, summaries.filter((summary) => summary.version === "Unreleased"));
|
|
2043
|
-
const draft = parseChangelog(content ?? "").sections.find((section) => section.version === "Unreleased")?.body;
|
|
2044
|
-
unreleased.push(...results.get(prepared.request.id) ?? []);
|
|
2045
|
-
if (unreleased.length > 0) entries.push({
|
|
2046
|
-
changes: unreleased,
|
|
2047
|
-
existingBody: draft,
|
|
2325
|
+
const { content, draft, file, unreleased } = prepared;
|
|
2326
|
+
const entries = Array.from(missingPackageSummaries(summaries, content), (summary) => ({
|
|
2327
|
+
changes: results.get(summaryId(summary)) ?? [],
|
|
2328
|
+
links: packageLink(repository, summary),
|
|
2048
2329
|
release: {
|
|
2049
|
-
|
|
2050
|
-
|
|
2330
|
+
date: summary.date,
|
|
2331
|
+
ref: summary.ref,
|
|
2332
|
+
tag: summary.tag,
|
|
2333
|
+
version: `${summary.packageName}@${summary.version}`
|
|
2051
2334
|
}
|
|
2335
|
+
}));
|
|
2336
|
+
const changes = results.get(unreleased.request.id) ?? [];
|
|
2337
|
+
const links = summaries.filter((summary) => summary.version === "Unreleased").flatMap((summary) => packageLink(repository, summary));
|
|
2338
|
+
const revise = unreleased.request.mode === "revise" && results.has(unreleased.request.id);
|
|
2339
|
+
if (revise || changes.length > 0 || packageLinksDiffer(draft, links)) entries.push({
|
|
2340
|
+
changes,
|
|
2341
|
+
existingBody: draft,
|
|
2342
|
+
links,
|
|
2343
|
+
release: unreleased.release,
|
|
2344
|
+
revise
|
|
2052
2345
|
});
|
|
2053
2346
|
const after = updateMarkdown(content, entries, exception, repository.github);
|
|
2054
2347
|
if (after !== file.content) plan.files.push({
|
|
@@ -2056,7 +2349,7 @@ function aggregateIndependent(context, prepared, results, summaries, exception)
|
|
|
2056
2349
|
before: file.content,
|
|
2057
2350
|
path: file.path
|
|
2058
2351
|
});
|
|
2059
|
-
recordCheckpoint(context, file.path, after, prepared.
|
|
2352
|
+
recordCheckpoint(context, file.path, after, unreleased.release, leavesGeneratedDraft(entries, prepared.draftGenerated));
|
|
2060
2353
|
}
|
|
2061
2354
|
/**
|
|
2062
2355
|
* Find package releases whose own section records no notable changes, so a
|
|
@@ -2070,18 +2363,6 @@ function quietReleases(plan, packages) {
|
|
|
2070
2363
|
}
|
|
2071
2364
|
return quiet;
|
|
2072
2365
|
}
|
|
2073
|
-
function aggregateShared(context, prepared, results, exception, quiet) {
|
|
2074
|
-
const { plan, repository } = context;
|
|
2075
|
-
renderPackage(context, prepared, results);
|
|
2076
|
-
const rootEdit = plan.files.find((file) => path.dirname(file.path) === repository.root);
|
|
2077
|
-
if (rootEdit === void 0) return;
|
|
2078
|
-
const additions = prepared.summaries.map((summary) => ({
|
|
2079
|
-
changes: aggregateChanges(repository, plan.releases.filter((item) => item.version === summary.version && !quiet.has(`${item.changelogPath}@${item.version}`))),
|
|
2080
|
-
existingBody: parseChangelog(rootEdit.after).sections.find((section) => section.version === summary.version)?.body,
|
|
2081
|
-
release: prepared.releases.find((item) => item.version === summary.version)
|
|
2082
|
-
}));
|
|
2083
|
-
rootEdit.after = updateMarkdown(prepared.content, additions, exception || prepared.exception, repository.github);
|
|
2084
|
-
}
|
|
2085
2366
|
/** Generate package changelogs and the root changelog that summarizes them. */
|
|
2086
2367
|
async function generateWorkspace(context) {
|
|
2087
2368
|
const { options, plan, repository } = context;
|
|
@@ -2089,21 +2370,24 @@ async function generateWorkspace(context) {
|
|
|
2089
2370
|
const shared = rootReleases.some((release) => release.version !== "Unreleased");
|
|
2090
2371
|
const rootFile = await findChangelog(repository.root);
|
|
2091
2372
|
const rootContent = options.rebuild ? void 0 : rootFile.content;
|
|
2092
|
-
const
|
|
2093
|
-
const needsPackageHistory = rootMissing === void 0 || rootMissing.values().some((version) => version !== "Unreleased");
|
|
2094
|
-
const recordedAtRoot = (summary) => rootMissing === void 0 ? rootContent !== void 0 && missingPackageSummaries([summary], rootContent).length === 0 : !rootMissing.has(summary.version);
|
|
2373
|
+
const linksHistory = !shared || missingReleases(rootReleases, rootContent).some((release) => release.version !== "Unreleased");
|
|
2095
2374
|
const packages = [];
|
|
2096
|
-
for (const pkg of repository
|
|
2375
|
+
for (const pkg of describedPackages(repository)) packages.push(await preparePackage(context, pkg, linksHistory));
|
|
2097
2376
|
const root = shared ? await preparePackage(context, repository.rootPackage) : void 0;
|
|
2098
|
-
const independent = shared ? void 0 : await prepareIndependent(context, packages
|
|
2377
|
+
const independent = shared ? void 0 : await prepareIndependent(context, packages);
|
|
2099
2378
|
const requests = [...packages, ...root === void 0 ? [] : [root]].flatMap((pkg) => pkg.work.map((item) => item.request));
|
|
2100
|
-
if (independent !== void 0) requests.push(independent.request);
|
|
2379
|
+
if (independent !== void 0) requests.push(...independent.releases, independent.unreleased.request);
|
|
2101
2380
|
const results = await generateReleases(context, requests);
|
|
2102
2381
|
const summaries = packages.flatMap((pkg) => renderPackage(context, pkg, results));
|
|
2103
2382
|
plan.releases.push(...summaries);
|
|
2104
2383
|
const exception = packages.some((pkg) => pkg.exception);
|
|
2105
|
-
if (root !== void 0)
|
|
2106
|
-
|
|
2384
|
+
if (root !== void 0) {
|
|
2385
|
+
const quiet = quietReleases(plan, packages);
|
|
2386
|
+
renderPackage(context, {
|
|
2387
|
+
...root,
|
|
2388
|
+
exception: exception || root.exception
|
|
2389
|
+
}, results, (release) => summaries.filter((summary) => summary.version === release.version && summary.ref === release.ref && !quiet.has(`${summary.changelogPath}@${summary.version}`)).flatMap((summary) => packageLink(repository, summary)));
|
|
2390
|
+
} else if (independent !== void 0) renderIndependent(context, independent, results, summaries, exception);
|
|
2107
2391
|
}
|
|
2108
2392
|
|
|
2109
2393
|
// src/lib/generate.ts
|
|
@@ -2127,6 +2411,7 @@ async function generateChangelog(options = {}) {
|
|
|
2127
2411
|
log.debug(`Resolved options: ${JSON.stringify(config)}`);
|
|
2128
2412
|
const repository = await inspectRepository(root, config);
|
|
2129
2413
|
log.debug(`Repository ${root} at ${repository.head}: ${repository.tags.length} reachable tags, ${repository.packages.length} workspace packages.`);
|
|
2414
|
+
if (config.rootChangelog === "none" && describedPackages(repository).length === 0) throw new Error("The root changelog is set to \"none\", but no workspace packages remain to describe, so there is no changelog to generate. Choose another root changelog mode, or configure packages that are not skipped.");
|
|
2130
2415
|
const agent = typeof selected === "object" ? selected : createAgent(config.agent);
|
|
2131
2416
|
const plan = {
|
|
2132
2417
|
checkpoints: [],
|
|
@@ -2154,14 +2439,20 @@ async function generateChangelog(options = {}) {
|
|
|
2154
2439
|
plan,
|
|
2155
2440
|
repository
|
|
2156
2441
|
};
|
|
2157
|
-
if (repository.packages.length ===
|
|
2158
|
-
|
|
2159
|
-
const results = await generateReleases(context, prepared.work.map((item) => item.request));
|
|
2160
|
-
plan.releases.push(...renderPackage(context, prepared, results));
|
|
2161
|
-
} else await generateWorkspace(context);
|
|
2442
|
+
if (repository.packages.length > 0 && config.rootChangelog === "summary") await generateWorkspace(context);
|
|
2443
|
+
else await generatePackages(context);
|
|
2162
2444
|
if (rebuild) keepEmptyRebuilds(plan);
|
|
2163
2445
|
return plan;
|
|
2164
2446
|
}
|
|
2447
|
+
/** Generate each changelog on its own, with no root summary. */
|
|
2448
|
+
async function generatePackages(context) {
|
|
2449
|
+
const { options, plan, repository } = context;
|
|
2450
|
+
const packages = options.rootChangelog === "none" ? describedPackages(repository) : [repository.rootPackage, ...describedPackages(repository)];
|
|
2451
|
+
const prepared = [];
|
|
2452
|
+
for (const pkg of packages) prepared.push(await preparePackage(context, pkg));
|
|
2453
|
+
const results = await generateReleases(context, prepared.flatMap((item) => item.work.map((work) => work.request)));
|
|
2454
|
+
for (const item of prepared) plan.releases.push(...renderPackage(context, item, results));
|
|
2455
|
+
}
|
|
2165
2456
|
/** A rebuild that finds nothing to record must not erase an existing changelog. */
|
|
2166
2457
|
function keepEmptyRebuilds(plan) {
|
|
2167
2458
|
plan.files = plan.files.filter((edit) => {
|
|
@@ -2322,4 +2613,4 @@ async function reviewChangelog(plan, options = {}) {
|
|
|
2322
2613
|
}
|
|
2323
2614
|
}
|
|
2324
2615
|
|
|
2325
|
-
export {
|
|
2616
|
+
export { defaultModels as C, version as E, setLogger as S, maxTimeoutMinutes as T, manifestAt as _, writeChangelog as a, isAncestor as b, resolveConfig as c, efforts as d, rootChangelogs as f, localDate as g, isVersionOnlyChange as h, generateChangelog as i, createAgent as l, inspectRepository as m, previewChangelog as n, defineConfig as o, LOCKFILE_PATH as p, reviewChangelog as r, loadConfig as s, assertReviewTerminal as t, agentNames as u, tagVersion as v, defaults as w, runCommand as x, git as y };
|