@pmelab/gtd 51.0.1 → 52.1.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/.claude-plugin/plugin.json +1 -1
- package/README.md +46 -40
- package/dist/chunks/{reserved-CUkk9XgL.mjs → reserved-DxB5CROj.mjs} +73 -38
- package/dist/chunks/{review-BaCXlIT2.mjs → review-CKMSdvgW.mjs} +11 -9
- package/dist/chunks/{steering-DtUVUz9l.mjs → steering-CLhu8C7q.mjs} +262 -52
- package/dist/chunks/{tail-CX6Pdsql.mjs → tail-QZc7Uz2U.mjs} +1 -1
- package/dist/gtd.bundle.mjs +198 -43
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +510 -147
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +228 -222
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +570 -317
- package/package.json +5 -4
- package/src/steering/FORMATS.md +4 -1
- package/src/steering/SteeringFormat.ts +7 -1
- package/src/steering/contract.test.ts +2 -1
- package/src/steering/fixtures/review/sectioned.md +31 -0
- package/src/steering/fixtures/review/sectioned.read.json +119 -0
- package/src/steering/index.ts +4 -0
- package/src/steering/qa.ts +1 -1
- package/src/steering/review.test.ts +270 -4
- package/src/steering/review.ts +328 -69
- package/src/testkit/steps.ts +13 -0
- package/src/workflows/feature/acceptance-criteria.feature +159 -3
- package/src/workflows/feature/continuation-cap.feature +4 -0
- package/src/workflows/feature/continuation-rebuild.feature +4 -0
- package/src/workflows/feature/continuation-slices.feature +4 -0
- package/src/workflows/feature/continuation.feature +4 -0
- package/src/workflows/feature/criteria.test.ts +60 -1
- package/src/workflows/feature/criteria.ts +87 -8
- package/src/workflows/feature/fast-path.feature +5 -6
- package/src/workflows/feature/feature.test.ts +6 -5
- package/src/workflows/feature/feature.ts +1 -2
- package/src/workflows/feature/flag-mode.feature +5 -6
- package/src/workflows/feature/green.ts +22 -0
- package/src/workflows/feature/hands-on-skip.feature +384 -0
- package/src/workflows/feature/lens-models.feature +4 -4
- package/src/workflows/feature/lens-review.feature +10 -8
- package/src/workflows/feature/naive-acceptance.feature +4 -0
- package/src/workflows/feature/one-step-land.feature +5 -6
- package/src/workflows/feature/planning.ts +2 -1
- package/src/workflows/feature/prototype-rounds.feature +5 -1
- package/src/workflows/feature/prototype.feature +18 -9
- package/src/workflows/feature/prototype.ts +48 -17
- package/src/workflows/feature/refactor-absorbs.feature +7 -8
- package/src/workflows/feature/refactor-first.feature +7 -8
- package/src/workflows/feature/refactor-lens-models.feature +4 -0
- package/src/workflows/feature/refactor-rebuild.feature +11 -12
- package/src/workflows/feature/refactor-review.feature +72 -72
- package/src/workflows/feature/review-answers.feature +94 -5
- package/src/workflows/feature/review-gate.feature +23 -53
- package/src/workflows/feature/reviewer-walkthrough.feature +33 -12
- package/src/workflows/feature/risk-tier.feature +32 -0
- package/src/workflows/feature/seam-rejection.feature +9 -6
- package/src/workflows/feature/seam.feature +9 -6
- package/src/workflows/feature/seam.test.ts +5 -3
- package/src/workflows/feature/seam.ts +2 -3
- package/src/workflows/feature/severity-budget.feature +11 -7
- package/src/workflows/feature/severity-sorted-review.feature +54 -54
- package/src/workflows/feature/slice-findings.feature +9 -15
- package/src/workflows/feature/slicing.feature +36 -24
- package/src/workflows/feature/standards.feature +4 -4
- package/src/workflows/feature/tier-pin.feature +4 -0
- package/src/workflows/shared/review.ts +26 -6
- package/src/workflows/shared/text.test.ts +85 -11
- package/src/workflows/shared/text.ts +95 -42
- package/src/workflows/feature/coverage.feature +0 -165
- package/src/workflows/feature/coverage.ts +0 -120
package/src/steering/review.ts
CHANGED
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
toLspPositionFromOffset,
|
|
36
36
|
type HeadingBlock,
|
|
37
37
|
} from "./MarkdownTree.js"
|
|
38
|
-
import { FREE_TEXT_PLACEHOLDER } from "./qa.js"
|
|
38
|
+
import { FREE_TEXT_PLACEHOLDER, normalizeFreeTextAnswer } from "./qa.js"
|
|
39
39
|
import type {
|
|
40
40
|
SteeringAnchor,
|
|
41
41
|
SteeringAnnotateResult,
|
|
@@ -79,6 +79,9 @@ interface Changeset {
|
|
|
79
79
|
readonly answers?: readonly AnswerOption[]
|
|
80
80
|
/** The tier and reason gtd ranked the chunk with, from its leading severity line. */
|
|
81
81
|
readonly severity?: { readonly label: string; readonly line: number }
|
|
82
|
+
readonly section?: ReviewSection
|
|
83
|
+
/** 0-based line of the `Tick exactly one:` lead, when the chunk has answer boxes. */
|
|
84
|
+
readonly answerLine?: number
|
|
82
85
|
}
|
|
83
86
|
|
|
84
87
|
interface AnswerOption {
|
|
@@ -553,6 +556,7 @@ const parseChunkBody = (
|
|
|
553
556
|
lines: readonly string[],
|
|
554
557
|
title: string,
|
|
555
558
|
body: readonly RootContent[],
|
|
559
|
+
answerable: boolean,
|
|
556
560
|
): {
|
|
557
561
|
readonly description: string
|
|
558
562
|
readonly descriptionNodes: readonly SteeringViewNode[]
|
|
@@ -560,11 +564,13 @@ const parseChunkBody = (
|
|
|
560
564
|
readonly files: readonly ReviewFile[]
|
|
561
565
|
readonly errors: readonly SteeringFinding[]
|
|
562
566
|
readonly answers?: readonly AnswerOption[]
|
|
567
|
+
readonly answerLine?: number
|
|
563
568
|
readonly severity?: { readonly label: string; readonly line: number } | undefined
|
|
564
569
|
/** Whether a task box outside the answer list exists. */
|
|
565
570
|
readonly boxed: boolean
|
|
566
571
|
} => {
|
|
567
|
-
|
|
572
|
+
// A walkthrough chunk takes no decision: boxes there are not answers.
|
|
573
|
+
const answer = answerable ? answerListOf(content, body) : undefined
|
|
568
574
|
const isAnswer = (n: RootContent): boolean =>
|
|
569
575
|
answer !== undefined && (n === answer.list || n === body[answer.lead])
|
|
570
576
|
const firstPointerIndex = body.findIndex(
|
|
@@ -596,40 +602,81 @@ const parseChunkBody = (
|
|
|
596
602
|
descriptionInline,
|
|
597
603
|
files,
|
|
598
604
|
errors,
|
|
599
|
-
...(answer === undefined
|
|
605
|
+
...(answer === undefined
|
|
606
|
+
? {}
|
|
607
|
+
: {
|
|
608
|
+
answers: answer.options,
|
|
609
|
+
answerLine: toLspPosition(body[answer.lead]!.position!.start).line,
|
|
610
|
+
}),
|
|
600
611
|
...(severity === undefined ? {} : { severity }),
|
|
601
612
|
boxed: body.some((n) => !isAnswer(n) && taskItems(n).length > 0),
|
|
602
613
|
}
|
|
603
614
|
}
|
|
604
615
|
|
|
605
|
-
|
|
616
|
+
export type ReviewSection = "findings" | "walkthrough"
|
|
617
|
+
|
|
618
|
+
/** The `## ` headings gtd groups the review's chunks under; any other `## ` is a chunk of its own. */
|
|
619
|
+
export const REVIEW_SECTIONS: Readonly<Record<ReviewSection, string>> = {
|
|
620
|
+
findings: "Findings",
|
|
621
|
+
walkthrough: "Walkthrough",
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
const sectionOf = (content: string, node: RootContent): ReviewSection | undefined => {
|
|
625
|
+
if (node.type !== "heading" || node.depth !== 2) return undefined
|
|
626
|
+
const text = headingText(content, node)
|
|
627
|
+
return (Object.keys(REVIEW_SECTIONS) as ReviewSection[]).find((s) => REVIEW_SECTIONS[s] === text)
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** One chunk heading node with its raw body block nodes (up to the next heading as deep or shallower). */
|
|
606
631
|
interface ChunkBlock extends HeadingBlock {
|
|
607
632
|
readonly headingLine: number
|
|
633
|
+
readonly section?: ReviewSection
|
|
608
634
|
}
|
|
609
635
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
636
|
+
interface SectionBlock extends HeadingBlock {
|
|
637
|
+
readonly section: ReviewSection
|
|
638
|
+
readonly headingLine: number
|
|
639
|
+
readonly body: RootContent[]
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/** The nodes after `from` up to the next heading at `depth` or shallower. */
|
|
643
|
+
const bodyFrom = (children: readonly RootContent[], from: number, depth: number): RootContent[] => {
|
|
644
|
+
const end = children.findIndex((n, i) => i >= from && n.type === "heading" && n.depth <= depth)
|
|
645
|
+
return children.slice(from, end === -1 ? undefined : end)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Splits the tree's top-level nodes into chunk blocks — a `## ` line found only
|
|
650
|
+
* inside a fence never becomes a heading node at all. A document with a
|
|
651
|
+
* `## Findings` or `## Walkthrough` heading nests `### ` chunks under them;
|
|
652
|
+
* one without is the flat layout, every `## ` a chunk.
|
|
653
|
+
*/
|
|
654
|
+
const splitChunks = (
|
|
655
|
+
tree: Root,
|
|
656
|
+
content: string,
|
|
657
|
+
): { readonly chunks: readonly ChunkBlock[]; readonly sections: readonly SectionBlock[] } => {
|
|
658
|
+
const sectioned = tree.children.some((n) => sectionOf(content, n) !== undefined)
|
|
659
|
+
const chunks: ChunkBlock[] = []
|
|
660
|
+
const sections: SectionBlock[] = []
|
|
661
|
+
let section: SectionBlock | undefined
|
|
662
|
+
tree.children.forEach((node, i) => {
|
|
663
|
+
if (node.type !== "heading") return
|
|
664
|
+
const headingLine = toLspPosition(node.position!.start).line
|
|
665
|
+
if (sectioned && node.depth <= 2) {
|
|
666
|
+
const s = sectionOf(content, node)
|
|
667
|
+
section = s === undefined ? undefined : { heading: node, headingLine, section: s, body: [] }
|
|
668
|
+
if (section !== undefined) {
|
|
669
|
+
sections.push(section)
|
|
670
|
+
return
|
|
671
|
+
}
|
|
629
672
|
}
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
673
|
+
const depth = section === undefined ? 2 : 3
|
|
674
|
+
if (node.depth !== depth) return
|
|
675
|
+
const body = bodyFrom(tree.children, i + 1, depth)
|
|
676
|
+
chunks.push({ heading: node, headingLine, body, ...(section && { section: section.section }) })
|
|
677
|
+
section?.body.push(node, ...body)
|
|
678
|
+
})
|
|
679
|
+
return { chunks, sections }
|
|
633
680
|
}
|
|
634
681
|
|
|
635
682
|
const parseChangesets = (
|
|
@@ -639,7 +686,7 @@ const parseChangesets = (
|
|
|
639
686
|
): { readonly changesets: readonly Changeset[]; readonly errors: readonly SteeringFinding[] } => {
|
|
640
687
|
const changesets: Changeset[] = []
|
|
641
688
|
const errors: SteeringFinding[] = []
|
|
642
|
-
for (const { heading, headingLine, body } of splitChunks(tree)) {
|
|
689
|
+
for (const { heading, headingLine, body, section } of splitChunks(tree, content).chunks) {
|
|
643
690
|
const title = headingText(content, heading)
|
|
644
691
|
const {
|
|
645
692
|
description,
|
|
@@ -647,10 +694,11 @@ const parseChangesets = (
|
|
|
647
694
|
descriptionInline,
|
|
648
695
|
files,
|
|
649
696
|
answers,
|
|
697
|
+
answerLine,
|
|
650
698
|
severity,
|
|
651
699
|
boxed,
|
|
652
700
|
errors: bodyErrors,
|
|
653
|
-
} = parseChunkBody(content, lines, title, body)
|
|
701
|
+
} = parseChunkBody(content, lines, title, body, section !== "walkthrough")
|
|
654
702
|
errors.push(...bodyErrors)
|
|
655
703
|
// A prose-only chunk is a note gtd composed; a box with no pointer is a pointer gone wrong.
|
|
656
704
|
if (files.length === 0 && boxed) {
|
|
@@ -667,8 +715,9 @@ const parseChangesets = (
|
|
|
667
715
|
descriptionInline,
|
|
668
716
|
files,
|
|
669
717
|
headingLine,
|
|
670
|
-
...(answers === undefined ? {} : { answers }),
|
|
718
|
+
...(answers === undefined ? {} : { answers, answerLine: answerLine! }),
|
|
671
719
|
...(severity === undefined ? {} : { severity }),
|
|
720
|
+
...(section === undefined ? {} : { section }),
|
|
672
721
|
})
|
|
673
722
|
}
|
|
674
723
|
if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" })
|
|
@@ -754,7 +803,9 @@ const isHunkPointerItem = (content: string, item: ListItem): boolean => {
|
|
|
754
803
|
const reviewClearTicks = (content: string): string => {
|
|
755
804
|
const tree = parseMarkdown(content)
|
|
756
805
|
const answers = new Set(
|
|
757
|
-
splitChunks(tree).flatMap(
|
|
806
|
+
splitChunks(tree, content).chunks.flatMap(
|
|
807
|
+
({ body }) => answerListOf(content, body)?.list.children ?? [],
|
|
808
|
+
),
|
|
758
809
|
)
|
|
759
810
|
const offsets = taskItems(tree)
|
|
760
811
|
.filter(
|
|
@@ -798,51 +849,102 @@ const toggleFilePointer = (content: string, line: number): SteeringEdit | undefi
|
|
|
798
849
|
const chunkEndLines = (content: string): ReadonlyMap<number, number> => {
|
|
799
850
|
const tree = parseMarkdown(content)
|
|
800
851
|
const map = new Map<number, number>()
|
|
801
|
-
for (const block of splitChunks(tree
|
|
852
|
+
for (const block of splitChunks(tree, content).chunks)
|
|
853
|
+
map.set(block.headingLine, blockEndLine(block))
|
|
802
854
|
return map
|
|
803
855
|
}
|
|
804
856
|
|
|
857
|
+
const CHOICE_NAMES: Readonly<Record<ReviewChoice, string>> = {
|
|
858
|
+
fix: ANSWER_FIX,
|
|
859
|
+
wontfix: ANSWER_WONT_FIX,
|
|
860
|
+
answer: "",
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/** A finding's decision as an outline node of its own; none for a chunk with no answer boxes. */
|
|
864
|
+
const decisionNodes = (
|
|
865
|
+
lines: readonly string[],
|
|
866
|
+
chunk: Changeset,
|
|
867
|
+
end: number,
|
|
868
|
+
): SteeringOutlineNode[] => {
|
|
869
|
+
if (chunk.answerLine === undefined) return []
|
|
870
|
+
const d = decisionOf(chunk)
|
|
871
|
+
return [
|
|
872
|
+
{
|
|
873
|
+
name: `Decision: ${d === undefined ? "open" : CHOICE_NAMES[d.choice] || d.text}`,
|
|
874
|
+
range: spanRange(lines, chunk.answerLine, Math.max(chunk.answerLine, end)),
|
|
875
|
+
selectionRange: lineRange(lines, chunk.answerLine),
|
|
876
|
+
},
|
|
877
|
+
]
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
const isOpenChunk = (chunk: Changeset, markers: number): boolean =>
|
|
881
|
+
chunk.files.some((file) => !file.checked) ||
|
|
882
|
+
markers > 0 ||
|
|
883
|
+
(chunk.answers !== undefined && decisionOf(chunk) === undefined)
|
|
884
|
+
|
|
805
885
|
/**
|
|
806
|
-
* Document outline for `.gtd/REVIEW.md`: the
|
|
807
|
-
*
|
|
808
|
-
*
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
* human's own comment vanish the moment they tick the last box. A chunk's
|
|
812
|
-
* footnotes are `leaf: true` children; a chunk with none has no children.
|
|
886
|
+
* Document outline for `.gtd/REVIEW.md`: the chunks still left to review — an
|
|
887
|
+
* unchecked hunk, a footnote (so a human's comment never vanishes with the
|
|
888
|
+
* last tick) or an open decision. A finding's decision is a node of its own.
|
|
889
|
+
* In the sectioned layout `Findings` and `Walkthrough` are containers, and
|
|
890
|
+
* every other `## ` (`Fixed automatically`, …) stays listed.
|
|
813
891
|
*/
|
|
814
892
|
const reviewOutline = (content: string): readonly SteeringOutlineNode[] => {
|
|
815
893
|
const { changesets } = parseReviewDoc(content)
|
|
894
|
+
const { sections } = splitChunks(parseMarkdown(content), content)
|
|
816
895
|
const { markers, definitions } = parseFootnotes(content)
|
|
817
896
|
const definitionByName = new Map(definitions.map((d) => [d.name, d.body]))
|
|
818
897
|
const lines = content.split(/\r?\n/)
|
|
819
898
|
const endLines = chunkEndLines(content)
|
|
820
899
|
const threads = threadOutlineNodes(content)
|
|
821
900
|
const nested = new Set<SteeringOutlineNode>()
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
901
|
+
const chunkNodes = changesets.flatMap((chunk) => {
|
|
902
|
+
const start = chunk.headingLine
|
|
903
|
+
const end = Math.max(start, endLines.get(start) ?? start)
|
|
904
|
+
const checkedCount = chunk.files.filter((file) => file.checked).length
|
|
905
|
+
const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end)
|
|
906
|
+
const chunkThreads = threads
|
|
907
|
+
.filter((t) => t.markerLine !== undefined && t.markerLine >= start && t.markerLine <= end)
|
|
908
|
+
.map((t) => t.node)
|
|
909
|
+
chunkThreads.forEach((n) => nested.add(n))
|
|
910
|
+
const children = [
|
|
911
|
+
...decisionNodes(lines, chunk, end),
|
|
912
|
+
...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
|
|
913
|
+
...chunkThreads,
|
|
914
|
+
]
|
|
915
|
+
// In the sectioned layout every other `## ` is a section of its own, always listed.
|
|
916
|
+
const listed = sections.length > 0 && chunk.section === undefined
|
|
917
|
+
if (!listed && !isOpenChunk(chunk, chunkMarkers.length)) return []
|
|
918
|
+
const node: SteeringOutlineNode = {
|
|
919
|
+
name:
|
|
920
|
+
chunk.files.length === 0
|
|
921
|
+
? chunk.title
|
|
922
|
+
: `${chunk.title} (${checkedCount}/${chunk.files.length})`,
|
|
923
|
+
range: spanRange(lines, start, end),
|
|
924
|
+
selectionRange: lineRange(lines, start),
|
|
925
|
+
...(children.length > 0 ? { children } : {}),
|
|
926
|
+
}
|
|
927
|
+
return [{ line: start, section: chunk.section, node }]
|
|
928
|
+
})
|
|
929
|
+
const containers = sections.map((section) => ({
|
|
930
|
+
line: section.headingLine,
|
|
931
|
+
section: undefined,
|
|
932
|
+
node: {
|
|
933
|
+
name: REVIEW_SECTIONS[section.section],
|
|
934
|
+
range: spanRange(lines, section.headingLine, blockEndLine(section)),
|
|
935
|
+
selectionRange: lineRange(lines, section.headingLine),
|
|
936
|
+
children: chunkNodes
|
|
937
|
+
.filter((c) => c.section === section.section && c.line > section.headingLine)
|
|
938
|
+
.filter((c) => {
|
|
939
|
+
const next = sections.find((o) => o.headingLine > section.headingLine)
|
|
940
|
+
return next === undefined || c.line < next.headingLine
|
|
941
|
+
})
|
|
942
|
+
.map((c) => c.node),
|
|
943
|
+
} satisfies SteeringOutlineNode,
|
|
944
|
+
}))
|
|
945
|
+
const nodes = [...chunkNodes.filter((c) => c.section === undefined), ...containers]
|
|
946
|
+
.sort((a, b) => a.line - b.line)
|
|
947
|
+
.map((c) => c.node)
|
|
846
948
|
return [...nodes, ...threads.map((t) => t.node).filter((n) => !nested.has(n))]
|
|
847
949
|
}
|
|
848
950
|
|
|
@@ -902,6 +1004,7 @@ const reviewActions: SteeringFormat["actions"] = (content, range) => {
|
|
|
902
1004
|
const tree = parseMarkdown(content)
|
|
903
1005
|
const lines = content.split(/\r?\n/)
|
|
904
1006
|
const cursorLine = range.start.line
|
|
1007
|
+
const endLines = chunkEndLines(content)
|
|
905
1008
|
const actions: Array<{ readonly title: string; readonly edits: readonly SteeringEdit[] }> = [
|
|
906
1009
|
...threadReplyActions(content, range.start),
|
|
907
1010
|
]
|
|
@@ -921,10 +1024,11 @@ const reviewActions: SteeringFormat["actions"] = (content, range) => {
|
|
|
921
1024
|
}
|
|
922
1025
|
}
|
|
923
1026
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1027
|
+
// A sectioned chunk ends at its own last block, never at the next section's heading.
|
|
1028
|
+
const chunkEnd =
|
|
1029
|
+
chunk.section === undefined
|
|
1030
|
+
? Math.max(chunk.headingLine, (changesets[i + 1]?.headingLine ?? lines.length) - 1)
|
|
1031
|
+
: (endLines.get(chunk.headingLine) ?? chunk.headingLine)
|
|
928
1032
|
if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
|
|
929
1033
|
const edits = toggleChunkEdits(content, chunk.headingLine)
|
|
930
1034
|
if (edits.length > 0) {
|
|
@@ -1008,7 +1112,7 @@ const hunkLinkFor = (content: string, item: ListItem): SteeringLink | undefined
|
|
|
1008
1112
|
const reviewDocumentLinks = (content: string): readonly SteeringLink[] => {
|
|
1009
1113
|
const tree = parseMarkdown(content)
|
|
1010
1114
|
const links: SteeringLink[] = []
|
|
1011
|
-
for (const block of splitChunks(tree)) {
|
|
1115
|
+
for (const block of splitChunks(tree, content).chunks) {
|
|
1012
1116
|
for (const item of block.body.flatMap((n) => taskItems(n))) {
|
|
1013
1117
|
const link = hunkLinkFor(content, item)
|
|
1014
1118
|
if (link) links.push(link)
|
|
@@ -1030,6 +1134,17 @@ const reviewView = (content: string): SteeringView => {
|
|
|
1030
1134
|
body: chunk.descriptionNodes,
|
|
1031
1135
|
...(chunk.severity === undefined ? {} : { status: chunk.severity.label }),
|
|
1032
1136
|
anchor: { kind: "chunk", index: chunkIndex },
|
|
1137
|
+
...(chunk.section === undefined ? {} : { group: REVIEW_SECTIONS[chunk.section] }),
|
|
1138
|
+
...(chunk.answers === undefined
|
|
1139
|
+
? {}
|
|
1140
|
+
: {
|
|
1141
|
+
answered: decisionOf(chunk) !== undefined,
|
|
1142
|
+
decision: chunk.answers.map((option, index) => ({
|
|
1143
|
+
title: option.text,
|
|
1144
|
+
checked: option.checked,
|
|
1145
|
+
anchor: { kind: "option" as const, questionIndex: chunkIndex, index },
|
|
1146
|
+
})),
|
|
1147
|
+
}),
|
|
1033
1148
|
...noteAt(chunk.headingLine),
|
|
1034
1149
|
children: chunk.files.map((file, index) => ({
|
|
1035
1150
|
title: pointerDisplay(file),
|
|
@@ -1158,8 +1273,68 @@ const setFileTickEdits = (
|
|
|
1158
1273
|
return edits
|
|
1159
1274
|
}
|
|
1160
1275
|
|
|
1161
|
-
/**
|
|
1276
|
+
/**
|
|
1277
|
+
* A finding's answer boxes are radio: ticking one unticks the others, and
|
|
1278
|
+
* `text` replaces the ticked box's label in the same edit set — the phone's
|
|
1279
|
+
* free-text answer, normalized as `qa`'s is.
|
|
1280
|
+
*/
|
|
1281
|
+
const decisionEdits = (
|
|
1282
|
+
content: string,
|
|
1283
|
+
chunkIndex: number,
|
|
1284
|
+
index: number,
|
|
1285
|
+
opts: { readonly checked?: boolean; readonly text?: string },
|
|
1286
|
+
): SteeringAnnotateResult => {
|
|
1287
|
+
const items = answerItemsOf(content, chunkIndex)
|
|
1288
|
+
const target = items[index]
|
|
1289
|
+
if (target === undefined) return { ok: false, reason: "anchor-not-found" }
|
|
1290
|
+
const checked = opts.checked ?? true
|
|
1291
|
+
const ticks = items.flatMap((item) => {
|
|
1292
|
+
const want = item === target ? checked : !checked && item.checked === true
|
|
1293
|
+
return (item.checked === true) === want ? [] : tickEdit(content, item, want)
|
|
1294
|
+
})
|
|
1295
|
+
const text = opts.text === undefined ? [] : labelEdit(content, target, opts.text)
|
|
1296
|
+
return { ok: true, edits: [...ticks, ...text] }
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
/** A finding's answer boxes; none for a walkthrough chunk or a stale index. */
|
|
1300
|
+
const answerItemsOf = (content: string, chunkIndex: number): readonly ListItem[] => {
|
|
1301
|
+
const chunk = splitChunks(parseMarkdown(content), content).chunks[chunkIndex]
|
|
1302
|
+
if (chunk === undefined || chunk.section === "walkthrough") return []
|
|
1303
|
+
return answerListOf(content, chunk.body)?.list.children ?? []
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const tickEdit = (content: string, item: ListItem, checked: boolean): SteeringEdit[] => {
|
|
1307
|
+
const offset = checkboxOffset(content, item)
|
|
1308
|
+
if (offset === undefined) return []
|
|
1309
|
+
const start = toLspPositionFromOffset(content, offset)
|
|
1310
|
+
return [
|
|
1311
|
+
{
|
|
1312
|
+
range: { start, end: { ...start, character: start.character + 1 } },
|
|
1313
|
+
newText: checked ? "x" : " ",
|
|
1314
|
+
},
|
|
1315
|
+
]
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
/** Replaces a box's label, from after its marker to the end of its first paragraph. */
|
|
1319
|
+
const labelEdit = (content: string, item: ListItem, text: string): SteeringEdit[] => {
|
|
1320
|
+
const from = firstParagraphContentOffset(item)
|
|
1321
|
+
const to = item.children.find((c) => c.type === "paragraph")?.position?.end.offset
|
|
1322
|
+
if (from === undefined || to === undefined) return []
|
|
1323
|
+
return [
|
|
1324
|
+
{
|
|
1325
|
+
range: {
|
|
1326
|
+
start: toLspPositionFromOffset(content, from),
|
|
1327
|
+
end: toLspPositionFromOffset(content, to),
|
|
1328
|
+
},
|
|
1329
|
+
newText: normalizeFreeTextAnswer(text),
|
|
1330
|
+
},
|
|
1331
|
+
]
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/** A `hunk` anchor sets one tick; a `chunk` anchor sets every hunk beneath it to the SAME target state — never `toggleChunkEdits`'s majority-flip heuristic, since the caller already knows the state it wants. An `option` anchor is a finding's decision. */
|
|
1162
1335
|
const reviewApply: SteeringFormat["apply"] = (content, anchor, opts) => {
|
|
1336
|
+
if (anchor.kind === "option")
|
|
1337
|
+
return decisionEdits(content, anchor.questionIndex, anchor.index, opts)
|
|
1163
1338
|
const { changesets } = parseReviewDoc(content)
|
|
1164
1339
|
const checked = opts.checked ?? true
|
|
1165
1340
|
if (anchor.kind === "hunk") {
|
|
@@ -1383,7 +1558,7 @@ export const reviewDecisions = (before: string, after: string): readonly ReviewD
|
|
|
1383
1558
|
})
|
|
1384
1559
|
}
|
|
1385
1560
|
|
|
1386
|
-
/** Each
|
|
1561
|
+
/** Each chunk in document order: its title, 0-based heading line and how many hunk pointers it holds. */
|
|
1387
1562
|
export const reviewChunks = (
|
|
1388
1563
|
content: string,
|
|
1389
1564
|
): readonly {
|
|
@@ -1392,12 +1567,14 @@ export const reviewChunks = (
|
|
|
1392
1567
|
pointers: number
|
|
1393
1568
|
/** 0-based line of the chunk's severity line, when it has one. */
|
|
1394
1569
|
severityLine?: number
|
|
1570
|
+
section?: ReviewSection
|
|
1395
1571
|
files: readonly { path: string; line?: number; rangeEnd?: number }[]
|
|
1396
1572
|
}[] =>
|
|
1397
1573
|
parseReviewDoc(content).changesets.map((c) => ({
|
|
1398
1574
|
title: c.title,
|
|
1399
1575
|
line: c.headingLine,
|
|
1400
1576
|
pointers: c.files.length,
|
|
1577
|
+
...(c.section === undefined ? {} : { section: c.section }),
|
|
1401
1578
|
...(c.severity === undefined ? {} : { severityLine: c.severity.line }),
|
|
1402
1579
|
files: c.files.map((f) => ({
|
|
1403
1580
|
path: f.path,
|
|
@@ -1405,3 +1582,85 @@ export const reviewChunks = (
|
|
|
1405
1582
|
...(f.rangeEnd === undefined ? {} : { rangeEnd: f.rangeEnd }),
|
|
1406
1583
|
})),
|
|
1407
1584
|
}))
|
|
1585
|
+
|
|
1586
|
+
/** The `## Findings` / `## Walkthrough` headings in `content`, with their 0-based lines. */
|
|
1587
|
+
export const reviewSections = (
|
|
1588
|
+
content: string,
|
|
1589
|
+
): readonly { readonly section: ReviewSection; readonly line: number }[] =>
|
|
1590
|
+
splitChunks(parseMarkdown(content), content).sections.map((s) => ({
|
|
1591
|
+
section: s.section,
|
|
1592
|
+
line: s.headingLine,
|
|
1593
|
+
}))
|
|
1594
|
+
|
|
1595
|
+
const isBlankLine = (line: string | undefined): boolean => line !== undefined && line.trim() === ""
|
|
1596
|
+
|
|
1597
|
+
/** How many lines a chunk's heading takes: two for a setext heading and its underline. */
|
|
1598
|
+
const headingSize = ({ heading, headingLine }: ChunkBlock): number =>
|
|
1599
|
+
toLspPosition(heading.position!.end).line - headingLine + 1
|
|
1600
|
+
|
|
1601
|
+
/** \`block\` with its heading an ATX \`### \` on one line, and every heading in its body one level deeper. */
|
|
1602
|
+
const demoted = (chunk: ChunkBlock, block: readonly string[]): string[] => {
|
|
1603
|
+
const out = [...block]
|
|
1604
|
+
for (const node of chunk.body) {
|
|
1605
|
+
if (node.type !== "heading" || node.depth < 3 || node.depth > 5) continue
|
|
1606
|
+
const at = toLspPosition(node.position!.start).line - chunk.headingLine
|
|
1607
|
+
out[at] = out[at]!.replace(/^( {0,3})#/, "$1##")
|
|
1608
|
+
}
|
|
1609
|
+
const size = headingSize(chunk)
|
|
1610
|
+
const title = size > 1 ? `### ${out[0]!.trim()}` : out[0]!.replace(/^ {0,3}##/, "###")
|
|
1611
|
+
return [title, ...out.slice(size)]
|
|
1612
|
+
}
|
|
1613
|
+
|
|
1614
|
+
/** A chunk's \`size\` heading lines, then its body with the blank lines around it trimmed. */
|
|
1615
|
+
const chunkText = (block: readonly string[], size: number): string => {
|
|
1616
|
+
const rest = block.slice(size)
|
|
1617
|
+
while (rest.length > 0 && isBlankLine(rest[0])) rest.shift()
|
|
1618
|
+
while (rest.length > 0 && isBlankLine(rest.at(-1))) rest.pop()
|
|
1619
|
+
const heading = block.slice(0, size).join("\n")
|
|
1620
|
+
return rest.length === 0 ? heading : `${heading}\n\n${rest.join("\n")}`
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
/**
|
|
1624
|
+
* A flat review regrouped: every chunk with answer boxes under `## Findings`
|
|
1625
|
+
* and every chunk titled in `walkthrough` under `## Walkthrough`, each in its
|
|
1626
|
+
* current order and one heading level deeper. Any other chunk stays a `## ` of
|
|
1627
|
+
* its own, before the sections when it came before them, after otherwise. An
|
|
1628
|
+
* already sectioned review comes back unchanged.
|
|
1629
|
+
*/
|
|
1630
|
+
export const sectionedReview = (content: string, walkthrough: ReadonlySet<string>): string => {
|
|
1631
|
+
const tree = parseMarkdown(content)
|
|
1632
|
+
const { chunks, sections } = splitChunks(tree, content)
|
|
1633
|
+
if (sections.length > 0 || chunks.length === 0) return content
|
|
1634
|
+
const { changesets } = parseReviewDoc(content)
|
|
1635
|
+
const lines = content.split("\n")
|
|
1636
|
+
const sectionOfChunk = (i: number): ReviewSection | undefined =>
|
|
1637
|
+
changesets[i]!.answers !== undefined
|
|
1638
|
+
? "findings"
|
|
1639
|
+
: walkthrough.has(changesets[i]!.title)
|
|
1640
|
+
? "walkthrough"
|
|
1641
|
+
: undefined
|
|
1642
|
+
const text = (i: number, demote: boolean): string => {
|
|
1643
|
+
const chunk = chunks[i]!
|
|
1644
|
+
const block = lines.slice(chunk.headingLine, chunks[i + 1]?.headingLine ?? lines.length)
|
|
1645
|
+
return chunkText(demote ? demoted(chunk, block) : block, demote ? 1 : headingSize(chunk))
|
|
1646
|
+
}
|
|
1647
|
+
const grouped = chunks.map((_, i) => sectionOfChunk(i))
|
|
1648
|
+
const first = grouped.findIndex((g) => g !== undefined)
|
|
1649
|
+
if (first === -1) return content
|
|
1650
|
+
const others = (keep: (i: number) => boolean): string[] =>
|
|
1651
|
+
chunks.flatMap((_, i) => (grouped[i] === undefined && keep(i) ? [text(i, false)] : []))
|
|
1652
|
+
const section = (s: ReviewSection): string[] => {
|
|
1653
|
+
const members = chunks.flatMap((_, i) => (grouped[i] === s ? [text(i, true)] : []))
|
|
1654
|
+
return members.length === 0 ? [] : [`## ${REVIEW_SECTIONS[s]}`, ...members]
|
|
1655
|
+
}
|
|
1656
|
+
const head = lines.slice(0, chunks[0]!.headingLine).join("\n").trim()
|
|
1657
|
+
return `${[
|
|
1658
|
+
head,
|
|
1659
|
+
...others((i) => i < first),
|
|
1660
|
+
...section("findings"),
|
|
1661
|
+
...section("walkthrough"),
|
|
1662
|
+
...others((i) => i > first),
|
|
1663
|
+
]
|
|
1664
|
+
.filter((p) => p !== "")
|
|
1665
|
+
.join("\n\n")}\n`
|
|
1666
|
+
}
|
package/src/testkit/steps.ts
CHANGED
|
@@ -149,6 +149,10 @@ Given("the working tree is committed as {string}", (world: TestkitWorld, message
|
|
|
149
149
|
world.repo().commit(message)
|
|
150
150
|
})
|
|
151
151
|
|
|
152
|
+
Given("the working tree is committed with the message:", (world: TestkitWorld, message: string) => {
|
|
153
|
+
world.repo().commit(message)
|
|
154
|
+
})
|
|
155
|
+
|
|
152
156
|
Given("step {string} changes nothing", (world: TestkitWorld, step: string) => {
|
|
153
157
|
world.act(step, () => undefined)
|
|
154
158
|
})
|
|
@@ -180,6 +184,15 @@ Given(
|
|
|
180
184
|
},
|
|
181
185
|
)
|
|
182
186
|
|
|
187
|
+
Given(
|
|
188
|
+
"step {string} appends to {string}:",
|
|
189
|
+
(world: TestkitWorld, step: string, path: string, text: string) => {
|
|
190
|
+
world.act(step, (_, repo) => {
|
|
191
|
+
repo.write(path, `${(repo.read(path) ?? "").trimEnd()}\n\n${withNewline(text)}`)
|
|
192
|
+
})
|
|
193
|
+
},
|
|
194
|
+
)
|
|
195
|
+
|
|
183
196
|
Given("step {string} deletes {string}", (world: TestkitWorld, step: string, path: string) => {
|
|
184
197
|
world.act(step, (_, repo) => repo.remove(path))
|
|
185
198
|
})
|