jackherd 1.24.0 → 1.25.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/CHANGELOG.md +5 -0
- package/README.md +21 -21
- package/dist/{chunk-JDO6O3UO.js → chunk-O365KHHP.js} +139 -3
- package/dist/config.d.ts +4 -0
- package/dist/config.js +4 -1
- package/dist/plan-format.d.ts +121 -3
- package/dist/plan-format.js +176 -35
- package/docs/plan-contract.md +52 -5
- package/herdr-plugin/herdr-plugin.toml +1 -1
- package/install.sh +1 -1
- package/kit/hooks/hooks/approval-gate/index.ts +2 -2
- package/kit/hooks/hooks/merge-guard/lane-plan.ts +1 -1
- package/kit/hooks/hooks/shared/board-lanes.ts +33 -12
- package/kit/hooks/hooks/shared/boards.ts +22 -6
- package/kit/workflow/skills/orchestrate/references/herd.md +3 -3
- package/kit/workflow/skills/plan/SKILL.md +3 -1
- package/kit/workflow/skills/plan/references/template-English.md +2 -0
- package/kit/workflow/skills/plan/references/template-German.md +2 -0
- package/package.json +1 -1
- package/schemas/jackherd.schema.json +7 -0
- package/src/brief/command.ts +1 -1
- package/src/brief/lane-facts.ts +2 -2
- package/src/check/command.ts +2 -1
- package/src/check/plan-structure-check.ts +68 -14
- package/src/check/settings.ts +6 -1
- package/src/check/skill-budget-defaults.ts +6 -3
- package/src/config/schema.ts +3 -0
- package/src/lane/close-board.ts +4 -4
- package/src/lane/close-command.ts +2 -2
- package/src/lane/dev-command.ts +1 -1
- package/src/lane/port-command.ts +1 -1
- package/src/plan-contract/command.ts +2 -1
- package/src/plan-contract/contract.ts +30 -9
- package/src/plan-format/bare-codes.ts +4 -4
- package/src/plan-format/dependencies.ts +5 -5
- package/src/plan-format/lanes.ts +3 -4
- package/src/plan-format/markdown.ts +125 -10
- package/src/plan-format/open-status.ts +15 -0
- package/src/plan-format/phases.ts +45 -4
- package/src/plan-format/plan-head.ts +31 -9
- package/src/plan-format/plan-structure.ts +43 -7
- package/src/plan-format/progress.ts +3 -0
- package/src/plan-format/sprint-document.ts +4 -4
- package/src/plan-format/unrecognized-sections.ts +21 -0
- package/src/plan-format/waiting.ts +13 -13
- package/src/plans/board-close.ts +25 -16
- package/src/plans/board-pick.ts +1 -1
- package/src/plans/ensure-panes.ts +2 -2
- package/src/plans/layout-summary.ts +8 -1
- package/src/plans/layout.ts +14 -5
- package/src/plans/live.ts +1 -1
- package/src/plans/settings.ts +3 -0
- package/src/plans/snapshot.ts +9 -5
- package/src/plans/sprint-export.ts +1 -1
- package/src/plans/texts.ts +3 -0
- package/src/plans/view.ts +1 -0
- package/src/resume/coordinator-read.ts +2 -2
- package/src/settled/command.ts +1 -1
- package/src/settled/settle.ts +5 -5
- package/src/sync/render.ts +20 -5
- package/src/sync/sync-plan.ts +6 -2
- package/src/sync/sync.ts +3 -2
- package/src/vocabulary/index.ts +192 -4
- package/src/watch/appointments.ts +13 -20
- package/src/watch/command.ts +5 -5
- package/src/watch/lane-progress.ts +1 -1
- package/src/watch/merge-overlap-read.ts +2 -2
- package/src/watch/plan-missing-read.ts +3 -1
|
@@ -10,16 +10,19 @@ import {
|
|
|
10
10
|
phaseCount,
|
|
11
11
|
phaseHeadings,
|
|
12
12
|
phaseLabels,
|
|
13
|
+
phasesWithoutPurpose,
|
|
13
14
|
} from '../plan-format'
|
|
14
|
-
import
|
|
15
|
+
import { lookedForSection } from '../plan-format/markdown'
|
|
16
|
+
import { type PlanLanguage, SECTION_KEY } from '../vocabulary'
|
|
15
17
|
|
|
16
|
-
/** The
|
|
18
|
+
/** The six parts of the plan contract; the head and the purpose lines are notes, never a gap. */
|
|
17
19
|
export const CONTRACT_PART = {
|
|
18
20
|
phases: 'phases',
|
|
19
21
|
tasks: 'tasks',
|
|
20
22
|
model: 'model',
|
|
21
23
|
dependencies: 'dependencies',
|
|
22
24
|
head: 'head',
|
|
25
|
+
purpose: 'purpose',
|
|
23
26
|
} as const
|
|
24
27
|
|
|
25
28
|
export type ContractPart = (typeof CONTRACT_PART)[keyof typeof CONTRACT_PART]
|
|
@@ -37,7 +40,7 @@ export type PartStatus = (typeof PART_STATUS)[keyof typeof PART_STATUS]
|
|
|
37
40
|
/** `line` is the 1-based line of the first problem, or of the part's first sign; null when the plan shows none. */
|
|
38
41
|
export type PartFinding = { part: ContractPart; status: PartStatus; line: number | null; message: string }
|
|
39
42
|
|
|
40
|
-
/** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table
|
|
43
|
+
/** `met` is false when a part is malformed, or phases, tasks or the model table are missing; a missing dependency table, a missing or stale head or a missing purpose line is a note. */
|
|
41
44
|
export type PlanContract = { parts: PartFinding[]; met: boolean }
|
|
42
45
|
|
|
43
46
|
/** Fewer phases than this need no dependency table: there is nothing to put side by side. */
|
|
@@ -64,18 +67,19 @@ const phasesPart = (headings: readonly PhaseHeading[]): PartFinding =>
|
|
|
64
67
|
? finding(CONTRACT_PART.phases, PART_STATUS.missing, null, 'no phase heading')
|
|
65
68
|
: finding(CONTRACT_PART.phases, PART_STATUS.present, headings[0].line, phaseCount(headings.length))
|
|
66
69
|
|
|
67
|
-
/** Present when no phase lacks the part (`ok`), else malformed at the first phase that does, naming them all. */
|
|
70
|
+
/** Present when no phase lacks the part (`ok`), else `unmet` (malformed by default) at the first phase that does, naming them all. */
|
|
68
71
|
const coverage = (
|
|
69
72
|
part: ContractPart,
|
|
70
73
|
without: readonly PhaseHeading[],
|
|
71
74
|
ok: { line: number | null; message: string },
|
|
72
75
|
problem: string,
|
|
76
|
+
unmet: PartStatus = PART_STATUS.malformed,
|
|
73
77
|
): PartFinding =>
|
|
74
78
|
without.length === 0
|
|
75
79
|
? finding(part, PART_STATUS.present, ok.line, ok.message)
|
|
76
80
|
: finding(
|
|
77
81
|
part,
|
|
78
|
-
|
|
82
|
+
unmet,
|
|
79
83
|
without[0].line,
|
|
80
84
|
`${problem} ${phaseLabels(
|
|
81
85
|
without.map(({ id }) => id),
|
|
@@ -108,7 +112,10 @@ const lacks = (headings: readonly PhaseHeading[], listed: ReadonlySet<string>):
|
|
|
108
112
|
|
|
109
113
|
const modelPart = (markdown: string, language: PlanLanguage, headings: readonly PhaseHeading[]): PartFinding => {
|
|
110
114
|
const rows = parseModelTable(markdown, language)
|
|
111
|
-
if (rows === null)
|
|
115
|
+
if (rows === null) {
|
|
116
|
+
const looked = lookedForSection(SECTION_KEY.model, '###', language)
|
|
117
|
+
return finding(CONTRACT_PART.model, PART_STATUS.missing, null, `no model table ${looked}`)
|
|
118
|
+
}
|
|
112
119
|
const modelled = new Set(rows.filter(isDecided).map(({ id }) => id))
|
|
113
120
|
return coverage(
|
|
114
121
|
CONTRACT_PART.model,
|
|
@@ -122,7 +129,10 @@ const dependenciesPart = (markdown: string, language: PlanLanguage, headings: re
|
|
|
122
129
|
if (headings.length < PHASES_NEEDING_DEPENDENCIES)
|
|
123
130
|
return finding(CONTRACT_PART.dependencies, PART_STATUS.present, null, 'not needed with fewer than two phases')
|
|
124
131
|
const table = parseDependencyTable(markdown, language)
|
|
125
|
-
if (table === null)
|
|
132
|
+
if (table === null) {
|
|
133
|
+
const looked = lookedForSection(SECTION_KEY.dependencies, '###', language)
|
|
134
|
+
return finding(CONTRACT_PART.dependencies, PART_STATUS.missing, null, `${NO_DEPENDENCY_TABLE} ${looked}`)
|
|
135
|
+
}
|
|
126
136
|
return coverage(
|
|
127
137
|
CONTRACT_PART.dependencies,
|
|
128
138
|
lacks(headings, new Set(table.map(({ id }) => id))),
|
|
@@ -131,8 +141,18 @@ const dependenciesPart = (markdown: string, language: PlanLanguage, headings: re
|
|
|
131
141
|
)
|
|
132
142
|
}
|
|
133
143
|
|
|
134
|
-
/**
|
|
135
|
-
const
|
|
144
|
+
/** A note: present when every `### Phase` with tasks opens with its purpose line, else missing at the first phase without, naming them all. */
|
|
145
|
+
const purposePart = (markdown: string, language: PlanLanguage): PartFinding =>
|
|
146
|
+
coverage(
|
|
147
|
+
CONTRACT_PART.purpose,
|
|
148
|
+
phasesWithoutPurpose(markdown, language),
|
|
149
|
+
{ line: null, message: 'every phase says what it is about' },
|
|
150
|
+
'no purpose line in',
|
|
151
|
+
PART_STATUS.missing,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
/** Notes, not gaps: a missing dependency table (the phases then run one after another), and the head and the purpose lines, which old plans lack. */
|
|
155
|
+
const NOTE_PARTS: readonly ContractPart[] = [CONTRACT_PART.dependencies, CONTRACT_PART.head, CONTRACT_PART.purpose]
|
|
136
156
|
|
|
137
157
|
export const isGap = ({ part, status }: PartFinding): boolean =>
|
|
138
158
|
status === PART_STATUS.malformed || (status === PART_STATUS.missing && !NOTE_PARTS.includes(part))
|
|
@@ -162,6 +182,7 @@ export const contractOf = (markdown: string, language: PlanLanguage): PlanContra
|
|
|
162
182
|
modelPart(markdown, language, headings),
|
|
163
183
|
dependenciesPart(markdown, language, headings),
|
|
164
184
|
headPart(markdown, language),
|
|
185
|
+
purposePart(markdown, language),
|
|
165
186
|
]
|
|
166
187
|
return { parts, met: !parts.some(isGap) }
|
|
167
188
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { bareCodesOf } from '../../kit/hooks/hooks/shared/plan-codes'
|
|
2
|
-
import { type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
3
|
-
import { firstTableRows, firstTableWithHeader, sectionLines, sectionStart } from './markdown'
|
|
2
|
+
import { COLUMN_KEY, type PlanLanguage, WAITING_VOCABULARY } from '../vocabulary'
|
|
3
|
+
import { columnIndex, firstTableRows, firstTableWithHeader, sectionLines, sectionStart } from './markdown'
|
|
4
4
|
|
|
5
5
|
/** A changed row of the waiting section (1-based line) whose item cell names `code` bare, outside brackets. */
|
|
6
6
|
export type RowWithBareCode = { line: number; code: string }
|
|
@@ -16,10 +16,10 @@ export const boardRowsWithBareCodes = (
|
|
|
16
16
|
previousLines: ReadonlySet<string>,
|
|
17
17
|
codes: ReadonlySet<string> | null,
|
|
18
18
|
): RowWithBareCode[] => {
|
|
19
|
-
const { heading
|
|
19
|
+
const { heading } = WAITING_VOCABULARY[language]
|
|
20
20
|
const start = sectionStart(markdown, heading)
|
|
21
21
|
const lines = sectionLines(markdown, heading)
|
|
22
|
-
const item = firstTableWithHeader(lines).header.
|
|
22
|
+
const item = columnIndex(firstTableWithHeader(lines).header, COLUMN_KEY.item, language)
|
|
23
23
|
if (item === -1) return []
|
|
24
24
|
return firstTableRows(lines).flatMap(({ index, line, cells }) => {
|
|
25
25
|
const code = previousLines.has(line) ? undefined : bareCodesOf(cells[item] ?? '', codes)[0]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
1
|
+
import { COLUMN_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
2
|
import { PHASE_ID_PREFIX, phaseIdOf, phaseLabels, spannedPhaseIds } from './lanes'
|
|
3
|
-
import { escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
3
|
+
import { columnIndex, escapeRegExp, firstTableWithHeader, subsectionLines } from './markdown'
|
|
4
4
|
import { type PhaseHeading, parsePlanPhases, phaseHeadings } from './phases'
|
|
5
5
|
import { isPhaseFinished } from './progress'
|
|
6
6
|
|
|
@@ -163,10 +163,10 @@ const releasedByFiles = (writes: readonly string[]): string[] =>
|
|
|
163
163
|
* such table or the table lacks one of its three columns, so a plan that never had one is not asked for rows.
|
|
164
164
|
*/
|
|
165
165
|
export const parseDependencyTable = (markdown: string, language: PlanLanguage): PhaseDependency[] | null => {
|
|
166
|
-
const { sections
|
|
166
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
167
167
|
const { header, rows } = firstTableWithHeader(subsectionLines(markdown, sections.dependencies))
|
|
168
|
-
const [after, writes, marker] = [
|
|
169
|
-
header
|
|
168
|
+
const [after, writes, marker] = [COLUMN_KEY.dependsOn, COLUMN_KEY.writes, COLUMN_KEY.serialMarker].map((key) =>
|
|
169
|
+
columnIndex(header, key, language),
|
|
170
170
|
)
|
|
171
171
|
if (after === -1 || writes === -1 || marker === -1) return null
|
|
172
172
|
return rows.flatMap((cells) => {
|
package/src/plan-format/lanes.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { posix } from 'node:path'
|
|
2
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
-
import { firstHeading, firstTable, firstTableRows, sectionLines, sectionStart } from './markdown'
|
|
2
|
+
import { CELL_KEY, LANE_CLOSED_WORDS, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
3
|
+
import { firstHeading, firstTable, firstTableRows, namesState, sectionLines, sectionStart } from './markdown'
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* `namedPhaseIds` holds every id the row names when `withoutRepeats` dropped some because an earlier row of the lane
|
|
@@ -156,14 +156,13 @@ export const withoutRepeats = (lane: SprintLane, step: PlanStep): PlanStep => {
|
|
|
156
156
|
export const parseSprintLanes = (markdown: string, language: PlanLanguage): SprintLane[] => {
|
|
157
157
|
const lanes = new Map<string, SprintLane>()
|
|
158
158
|
const phaseId = phaseIdPatterns(language)
|
|
159
|
-
const { laneClosed } = PLAN_VOCABULARY[language]
|
|
160
159
|
let previous: string | null = null
|
|
161
160
|
for (const cells of firstTable(sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes))) {
|
|
162
161
|
const name = laneNameOf(cells[0] ?? '', previous)
|
|
163
162
|
if (!name) continue
|
|
164
163
|
const port = Number.parseInt(cells[1] ?? '', 10)
|
|
165
164
|
const lane = lanes.get(name) ?? { name, port: Number.isNaN(port) ? null : port, steps: [], closed: false }
|
|
166
|
-
const marked = (cells[0] ?? ''
|
|
165
|
+
const marked = namesState(cells[0] ?? '', CELL_KEY.laneClosed, LANE_CLOSED_WORDS)
|
|
167
166
|
if (lane.closed && !marked && !(cells[0] ?? '').startsWith(CONTINUATION)) {
|
|
168
167
|
lane.closed = false
|
|
169
168
|
lane.steps = []
|
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CellKey,
|
|
3
|
+
type ColumnKey,
|
|
4
|
+
columnWords,
|
|
5
|
+
headingOfSection,
|
|
6
|
+
isSectionKey,
|
|
7
|
+
languagesFrom,
|
|
8
|
+
MARKER_IN_TEXT,
|
|
9
|
+
type PlanLanguage,
|
|
10
|
+
type SectionKey,
|
|
11
|
+
sectionOfHeading,
|
|
12
|
+
} from '../vocabulary'
|
|
1
13
|
import { terminalSafe } from './terminal-text'
|
|
2
14
|
|
|
3
15
|
const SECTION_END = /^##\s/
|
|
@@ -5,20 +17,120 @@ const TABLE_SEPARATOR = /^\|[\s|:-]+\|$/
|
|
|
5
17
|
|
|
6
18
|
export const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
7
19
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
20
|
+
const MARKER = /^<!--\s*jh:\s*([a-z-]+)\s*-->\s*$/
|
|
21
|
+
|
|
22
|
+
/** The key of the `<!-- jh: <key> -->` marker in `text`, wherever it stands; null when there is none. The key is not checked against any vocabulary. */
|
|
23
|
+
export const markedKeyOf = (text: string): string | null => MARKER_IN_TEXT.exec(text)?.[1] ?? null
|
|
24
|
+
|
|
25
|
+
/** `text` without its marker, trimmed: what a cell or a line says in words. */
|
|
26
|
+
export const withoutMarker = (text: string): string => text.replace(MARKER_IN_TEXT, '').trim()
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* What a reader looked for when it found no section `key`: `(looked for "### A", "### B" or <!-- jh: key -->)`, the
|
|
30
|
+
* heading of `language` first. `level` is the heading marks the reader accepts (`##` or `###`).
|
|
31
|
+
*/
|
|
32
|
+
export const lookedForSection = (key: SectionKey, level: '##' | '###', language: PlanLanguage): string => {
|
|
33
|
+
const headings = languagesFrom(language).map((candidate) => `"${level} ${headingOfSection(key, candidate)}"`)
|
|
34
|
+
return `(looked for ${headings.join(', ')} or <!-- jh: ${key} -->)`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Whether `text` carries the marker `key` or contains one of `words`: a state a cell names in any language. */
|
|
38
|
+
export const namesState = (text: string, key: CellKey, words: readonly string[]): boolean =>
|
|
39
|
+
markedKeyOf(text) === key || words.some((word) => text.includes(word))
|
|
40
|
+
|
|
41
|
+
export const SECTION_HEADING = /^##\s+(.*?)\s*$/
|
|
42
|
+
|
|
43
|
+
const markerKey = (line: string | undefined): SectionKey | null => {
|
|
44
|
+
const key = line === undefined ? undefined : MARKER.exec(line)?.[1]
|
|
45
|
+
return key !== undefined && isSectionKey(key) ? key : null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
type HeadingMatch = { pattern: RegExp; matches: (text: string, heading: string) => boolean }
|
|
49
|
+
|
|
50
|
+
const SECTION_MATCH: HeadingMatch = { pattern: SECTION_HEADING, matches: (text, heading) => text === heading }
|
|
51
|
+
const SUBSECTION_MATCH: HeadingMatch = {
|
|
52
|
+
pattern: /^(#{2,4})\s+(.*?)\s*$/,
|
|
53
|
+
matches: (text, heading) => text.startsWith(heading),
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const headingText = (line: string, { pattern }: HeadingMatch): string | undefined => pattern.exec(line)?.at(-1)
|
|
57
|
+
|
|
58
|
+
const headingIndexOf = (lines: string[], key: SectionKey, language: PlanLanguage, match: HeadingMatch): number => {
|
|
59
|
+
const marked = lines.findIndex((line, index) => match.pattern.test(line) && markerKey(lines[index + 1]) === key)
|
|
60
|
+
if (marked !== -1) return marked
|
|
61
|
+
for (const candidate of languagesFrom(language)) {
|
|
62
|
+
const heading = headingOfSection(key, candidate)
|
|
63
|
+
const found = lines.findIndex((line) => {
|
|
64
|
+
const text = headingText(line, match)
|
|
65
|
+
return text !== undefined && match.matches(text, heading)
|
|
66
|
+
})
|
|
67
|
+
if (found !== -1) return found
|
|
68
|
+
}
|
|
69
|
+
return -1
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The index of the line after the level-2 heading that names the section `key`; -1 when none does. A heading names it by the
|
|
74
|
+
* marker `<!-- jh: <key> -->` on the line directly under it (any language), else by the text of `language`, else by the
|
|
75
|
+
* text of any other known language. The marker line itself is not part of the section.
|
|
76
|
+
*/
|
|
77
|
+
export const findSectionStart = (markdown: string, key: SectionKey, language: PlanLanguage): number => {
|
|
78
|
+
const lines = markdown.split('\n')
|
|
79
|
+
const heading = headingIndexOf(lines, key, language, SECTION_MATCH)
|
|
80
|
+
if (heading === -1) return -1
|
|
81
|
+
return heading + (markerKey(lines[heading + 1]) === key ? 2 : 1)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The 0-based index of the level-2 heading that names the section `key`, found as `findSectionStart` finds it; -1 when none does. */
|
|
85
|
+
export const findSectionHeading = (markdown: string, key: SectionKey, language: PlanLanguage): number =>
|
|
86
|
+
headingIndexOf(markdown.split('\n'), key, language, SECTION_MATCH)
|
|
87
|
+
|
|
88
|
+
const untilNextSection = (rest: string[]): string[] => {
|
|
89
|
+
const end = rest.findIndex((line) => SECTION_END.test(line))
|
|
90
|
+
return end === -1 ? rest : rest.slice(0, end)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** The lines of the section `key` names (see `findSectionStart`), up to the next level-2 heading; empty when none does. */
|
|
94
|
+
export const findSection = (markdown: string, key: SectionKey, language: PlanLanguage): string[] => {
|
|
95
|
+
const start = findSectionStart(markdown, key, language)
|
|
96
|
+
return start === -1 ? [] : untilNextSection(markdown.split('\n').slice(start))
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The position of the column `key` names among the header cells; -1 when none does. A cell names it by the marker
|
|
101
|
+
* `<!-- jh: <key> -->` (any language), else by a header name of `language`, else by one of any other known language.
|
|
102
|
+
*/
|
|
103
|
+
export const columnIndex = (header: string[], key: ColumnKey, language: PlanLanguage): number => {
|
|
104
|
+
const marked = header.findIndex((cell) => markedKeyOf(cell) === key)
|
|
105
|
+
if (marked !== -1) return marked
|
|
106
|
+
const names = header.map(withoutMarker)
|
|
107
|
+
for (const candidate of languagesFrom(language)) {
|
|
108
|
+
for (const word of columnWords(key, candidate)) {
|
|
109
|
+
const found = names.indexOf(word)
|
|
110
|
+
if (found !== -1) return found
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return -1
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const exactSectionStart = (markdown: string, headingTextValue: string): number => {
|
|
117
|
+
const heading = new RegExp(`^##\\s+${escapeRegExp(headingTextValue)}\\s*$`)
|
|
11
118
|
const start = markdown.split('\n').findIndex((line) => heading.test(line))
|
|
12
119
|
return start === -1 ? -1 : start + 1
|
|
13
120
|
}
|
|
14
121
|
|
|
15
|
-
/** The
|
|
122
|
+
/** The index of the line after a level-2 heading with exactly this text, or after the one that names the same section in another language or by marker; -1 when there is none. */
|
|
123
|
+
export const sectionStart = (markdown: string, headingText: string): number => {
|
|
124
|
+
const section = sectionOfHeading(headingText)
|
|
125
|
+
return section === null
|
|
126
|
+
? exactSectionStart(markdown, headingText)
|
|
127
|
+
: findSectionStart(markdown, section.key, section.language)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The lines under that heading (see `sectionStart`), up to the next level-2 heading. */
|
|
16
131
|
export const sectionLines = (markdown: string, headingText: string): string[] => {
|
|
17
132
|
const start = sectionStart(markdown, headingText)
|
|
18
|
-
|
|
19
|
-
const rest = markdown.split('\n').slice(start)
|
|
20
|
-
const end = rest.findIndex((line) => SECTION_END.test(line))
|
|
21
|
-
return end === -1 ? rest : rest.slice(0, end)
|
|
133
|
+
return start === -1 ? [] : untilNextSection(markdown.split('\n').slice(start))
|
|
22
134
|
}
|
|
23
135
|
|
|
24
136
|
const tableCells = (line: string): string[] =>
|
|
@@ -57,8 +169,11 @@ export const firstHeading = (markdown: string, fallback: string): string =>
|
|
|
57
169
|
const HEADING = /^(#{2,4})\s+(.*?)\s*$/
|
|
58
170
|
|
|
59
171
|
/** The 0-based line index of the first level 2 to 4 heading whose text starts with `headingText` (a suffix such as `(PFLICHT ab 3 Phasen)` is allowed); -1 when there is none. */
|
|
60
|
-
export const subsectionStart = (markdown: string, headingText: string): number =>
|
|
61
|
-
|
|
172
|
+
export const subsectionStart = (markdown: string, headingText: string): number => {
|
|
173
|
+
const section = sectionOfHeading(headingText)
|
|
174
|
+
if (section !== null) return headingIndexOf(markdown.split('\n'), section.key, section.language, SUBSECTION_MATCH)
|
|
175
|
+
return markdown.split('\n').findIndex((line) => HEADING.exec(line)?.[2].startsWith(headingText))
|
|
176
|
+
}
|
|
62
177
|
|
|
63
178
|
/** The lines under that heading (see `subsectionStart`), up to the next heading of the same or a higher level. */
|
|
64
179
|
export const subsectionLines = (markdown: string, headingText: string): string[] => {
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { isStatusKey, STATUS_KEY } from '../vocabulary'
|
|
2
|
+
import { markedKeyOf, withoutMarker } from './markdown'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether a status cell of a board row says the row is still open. A status marker decides (`<!-- jh: status-open -->`
|
|
6
|
+
* is open, any other status marker is not); else an empty cell is open (a row nobody closed is not dropped in
|
|
7
|
+
* silence), and so is a cell that starts with one of the lower-case `openWords`. The words come from every known
|
|
8
|
+
* language, so a board need not match the configured one.
|
|
9
|
+
*/
|
|
10
|
+
export const isOpenStatus = (status: string, openWords: readonly string[]): boolean => {
|
|
11
|
+
const marked = markedKeyOf(status)
|
|
12
|
+
if (marked !== null && isStatusKey(marked)) return marked === STATUS_KEY.open
|
|
13
|
+
const lower = withoutMarker(status).toLowerCase()
|
|
14
|
+
return lower === '' || openWords.some((word) => lower.startsWith(word))
|
|
15
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
1
|
+
import { LINE_KEY, languagesFrom, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
2
|
import { PHASE_ID_PREFIX } from './lanes'
|
|
3
|
-
import { escapeRegExp } from './markdown'
|
|
3
|
+
import { escapeRegExp, markedKeyOf, withoutMarker } from './markdown'
|
|
4
4
|
import { terminalSafe } from './terminal-text'
|
|
5
5
|
|
|
6
6
|
/** A checkbox of a plan. `gate` is set on the ones listed under the phase's review gate, and only on those. */
|
|
@@ -29,11 +29,13 @@ export type PhaseWorkFields = {
|
|
|
29
29
|
/**
|
|
30
30
|
* `work` is set by the phase source and says nothing for a phase the worktree left as the base has it; a merged phase
|
|
31
31
|
* is touched as well. `deferred` holds the checkboxes moved to another phase; they are not among `items`, so they
|
|
32
|
-
* count neither as open nor as done.
|
|
32
|
+
* count neither as open nor as done. `purpose` is the sentence of the purpose line directly under a `### Phase`
|
|
33
|
+
* heading (`phasePurposeOf`); absent when the phase has none.
|
|
33
34
|
*/
|
|
34
35
|
export type PlanPhase = {
|
|
35
36
|
id: string
|
|
36
37
|
title: string
|
|
38
|
+
purpose?: string
|
|
37
39
|
items: ChecklistItem[]
|
|
38
40
|
deferred?: DeferredItem[]
|
|
39
41
|
} & Partial<PhaseWorkFields>
|
|
@@ -72,6 +74,30 @@ export const isOpenCheckboxLine = (line: string): boolean => line.match(CHECKBOX
|
|
|
72
74
|
export const phaseHeadingPattern = (language: PlanLanguage): RegExp =>
|
|
73
75
|
new RegExp(`^###\\s+${escapeRegExp(PLAN_VOCABULARY[language].phase)}\\s+(\\d+[a-z]?)\\b\\s*[:—–-]?\\s*(.*)$`, 'i')
|
|
74
76
|
|
|
77
|
+
/** The bold opening of a line (`**Label:**` or `**Label**:`): the label and the text after it. */
|
|
78
|
+
export const BOLD_OPENING = /^\s*\*\*([^*]+?)\s*:?\*\*\s*:?\s*(.*)$/
|
|
79
|
+
|
|
80
|
+
/** Whether the bold `label` of `line` names the purpose line: by the marker on the line, else by the word of `language`, else by that of any other known language. */
|
|
81
|
+
const namesPurpose = (line: string, label: string, language: PlanLanguage): boolean => {
|
|
82
|
+
if (markedKeyOf(line) === LINE_KEY.purpose) return true
|
|
83
|
+
const written = label.trim().toLowerCase()
|
|
84
|
+
return languagesFrom(language).some((candidate) => PLAN_VOCABULARY[candidate].phasePurpose.toLowerCase() === written)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The sentence of a purpose line, `**Worum es geht:** <sentence>`, the colon inside or outside the bold; null for any
|
|
89
|
+
* other line and for a purpose line without text after the colon.
|
|
90
|
+
*/
|
|
91
|
+
export const phasePurposeOf = (line: string, language: PlanLanguage): string | null => {
|
|
92
|
+
const opening = BOLD_OPENING.exec(line)
|
|
93
|
+
if (!opening || !namesPurpose(line, opening[1], language)) return null
|
|
94
|
+
const text = terminalSafe(withoutMarker(opening[2])).trim()
|
|
95
|
+
return text === '' ? null : text
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The first non-empty line under a heading decides whether a phase has its purpose line; once `decided`, later lines never do. */
|
|
99
|
+
export const decidesPurpose = (decided: boolean, line: string): boolean => !decided && line.trim() !== ''
|
|
100
|
+
|
|
75
101
|
const tableCells = (line: string): string[] | null => {
|
|
76
102
|
const trimmed = line.trim()
|
|
77
103
|
if (!trimmed.startsWith('|')) return null
|
|
@@ -220,6 +246,14 @@ const notePointer = (pointers: Map<PlanPhase, string>, phase: PlanPhase | null,
|
|
|
220
246
|
if (phase && pointer) pointers.set(phase, pointer)
|
|
221
247
|
}
|
|
222
248
|
|
|
249
|
+
/** Reads the first non-empty line under a `### Phase` heading as the phase's purpose; later lines and `## N —` sections never are. */
|
|
250
|
+
const notePurpose = (read: Set<PlanPhase>, phase: PlanPhase | null, line: string, language: PlanLanguage): void => {
|
|
251
|
+
if (!phase || !decidesPurpose(read.has(phase), line)) return
|
|
252
|
+
read.add(phase)
|
|
253
|
+
const purpose = phasePurposeOf(line, language)
|
|
254
|
+
if (purpose !== null) phase.purpose = purpose
|
|
255
|
+
}
|
|
256
|
+
|
|
223
257
|
const headingOf = (line: string, phaseHeading: RegExp): RegExpMatchArray | null =>
|
|
224
258
|
line.match(phaseHeading) ?? line.match(NUMBERED_SECTION)
|
|
225
259
|
|
|
@@ -244,15 +278,22 @@ export const phaseHeadings = (markdown: string, language: PlanLanguage): PhaseHe
|
|
|
244
278
|
export const parsePlanPhases = (markdown: string, language: PlanLanguage): PlanPhase[] => {
|
|
245
279
|
const phaseHeading = phaseHeadingPattern(language)
|
|
246
280
|
const pointers = new Map<PlanPhase, string>()
|
|
281
|
+
const purposeRead = new Set<PlanPhase>()
|
|
247
282
|
const { phases } = markdown.split('\n').reduce<{ phases: PlanPhase[]; open: PlanPhase | null; gate: boolean }>(
|
|
248
283
|
({ phases, open, gate }, line) => {
|
|
249
284
|
const heading = headingOf(line, phaseHeading)
|
|
250
285
|
if (heading) {
|
|
251
|
-
const phase = {
|
|
286
|
+
const phase: PlanPhase = {
|
|
287
|
+
id: (heading[1] ?? '').toLowerCase(),
|
|
288
|
+
title: terminalSafe(heading[2] ?? '').trim(),
|
|
289
|
+
items: [],
|
|
290
|
+
}
|
|
291
|
+
if (!phaseHeading.test(line)) purposeRead.add(phase)
|
|
252
292
|
return { phases: [...phases, phase], open: phase, gate: false }
|
|
253
293
|
}
|
|
254
294
|
if (PHASE_END.test(line)) return { phases, open: null, gate: false }
|
|
255
295
|
const inGate = gate || REVIEW_GATE.test(line)
|
|
296
|
+
notePurpose(purposeRead, open, line, language)
|
|
256
297
|
if (open) fileLine(open, line, inGate)
|
|
257
298
|
notePointer(pointers, open, line)
|
|
258
299
|
return { phases, open, gate: inGate }
|
|
@@ -1,5 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
HEAD_KEY,
|
|
3
|
+
HEAD_LINE,
|
|
4
|
+
type HeadLine,
|
|
5
|
+
languagesFrom,
|
|
6
|
+
PLAN_VOCABULARY,
|
|
7
|
+
type PlanLanguage,
|
|
8
|
+
SECTION_KEY,
|
|
9
|
+
} from '../vocabulary'
|
|
10
|
+
import { escapeRegExp, findSectionHeading, lookedForSection, markedKeyOf, withoutMarker } from './markdown'
|
|
3
11
|
import { parsePlanPhases } from './phases'
|
|
4
12
|
|
|
5
13
|
/** What a plan holds of its head: all of it and current, none of it, or something that no longer matches the plan. */
|
|
@@ -38,12 +46,21 @@ const HEAD_LINES = Object.values(HEAD_LINE)
|
|
|
38
46
|
|
|
39
47
|
const labelOf = (language: PlanLanguage, line: HeadLine): string => PLAN_VOCABULARY[language].head[line].toLowerCase()
|
|
40
48
|
|
|
49
|
+
/** The head line a label names: by the marker on the line, else by the label of `language`, else by that of any other known language. */
|
|
50
|
+
const labelNamed = (text: string, written: string, language: PlanLanguage): HeadLine | undefined => {
|
|
51
|
+
const marked = markedKeyOf(text)
|
|
52
|
+
const byMarker = HEAD_LINES.find((each) => HEAD_KEY[each] === marked)
|
|
53
|
+
if (byMarker) return byMarker
|
|
54
|
+
return languagesFrom(language)
|
|
55
|
+
.map((candidate) => HEAD_LINES.find((each) => labelOf(candidate, each) === written))
|
|
56
|
+
.find((each) => each !== undefined)
|
|
57
|
+
}
|
|
58
|
+
|
|
41
59
|
const entryOf = (text: string, language: PlanLanguage): { label: HeadLine; text: string } | null => {
|
|
42
60
|
const match = text.match(HEAD_ENTRY)
|
|
43
61
|
if (!match) return null
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
return label ? { label, text: match[2].trim() } : null
|
|
62
|
+
const label = labelNamed(text, match[1].trim().toLowerCase(), language)
|
|
63
|
+
return label ? { label, text: withoutMarker(match[2]) } : null
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
const phaseOf = (statusText: string | undefined, language: PlanLanguage): HeadPhase | null => {
|
|
@@ -59,8 +76,7 @@ const phaseOf = (statusText: string | undefined, language: PlanLanguage): HeadPh
|
|
|
59
76
|
*/
|
|
60
77
|
export const parsePlanHead = (markdown: string, language: PlanLanguage): PlanHead | null => {
|
|
61
78
|
const lines = markdown.split('\n')
|
|
62
|
-
const
|
|
63
|
-
const start = lines.findIndex((text) => heading.test(text))
|
|
79
|
+
const start = findSectionHeading(markdown, SECTION_KEY.forYou, language)
|
|
64
80
|
if (start === -1) return null
|
|
65
81
|
const rest = lines.slice(start + 1)
|
|
66
82
|
const end = rest.findIndex((text) => SECTION_HEADING.test(text))
|
|
@@ -107,8 +123,14 @@ const phaseMismatch = ({ current, total }: HeadPhase, states: readonly PhaseStat
|
|
|
107
123
|
*/
|
|
108
124
|
export const checkPlanHead = (markdown: string, language: PlanLanguage): HeadVerdict => {
|
|
109
125
|
const head = parsePlanHead(markdown, language)
|
|
110
|
-
|
|
111
|
-
|
|
126
|
+
if (head === null) {
|
|
127
|
+
const looked = lookedForSection(SECTION_KEY.forYou, '##', language)
|
|
128
|
+
return verdict(
|
|
129
|
+
HEAD_STATUS.missing,
|
|
130
|
+
null,
|
|
131
|
+
`no \`## ${PLAN_VOCABULARY[language].sections.forYou}\` section ${looked}`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
112
134
|
if (head.misplaced)
|
|
113
135
|
return verdict(
|
|
114
136
|
HEAD_STATUS.stale,
|
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
1
|
+
import { LINE_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
2
|
import { changedOpenLaneRows, type OpenLaneRow, phaseIdsOfCell } from './lanes'
|
|
3
|
-
import { escapeRegExp, sectionLines } from './markdown'
|
|
3
|
+
import { escapeRegExp, markedKeyOf, sectionLines } from './markdown'
|
|
4
4
|
import {
|
|
5
|
+
BOLD_OPENING,
|
|
6
|
+
decidesPurpose,
|
|
5
7
|
deferralTarget,
|
|
6
8
|
endsPhase,
|
|
7
9
|
isCheckboxLine,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
type PlanPhase,
|
|
12
14
|
parsePlanPhases,
|
|
13
15
|
phaseHeadingPattern,
|
|
16
|
+
phasePurposeOf,
|
|
14
17
|
} from './phases'
|
|
15
18
|
import { terminalSafe } from './terminal-text'
|
|
16
19
|
|
|
@@ -26,10 +29,21 @@ export type PhaseWithOpenBoxes = PhaseWithoutGate & { openLines: number[] }
|
|
|
26
29
|
/** A `PhaseWithOpenBoxes` whose tasks are all ticked on the base branch. */
|
|
27
30
|
export type MergedPhaseWithOpenBoxes = PhaseWithOpenBoxes
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
/** A `### Phase` heading (1-based line) whose section has tasks and no purpose line directly under the heading. */
|
|
33
|
+
export type PhaseWithoutPurpose = PhaseHeading
|
|
30
34
|
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
type PhaseSection = PhaseWithoutGate & { tasks: boolean; gate: boolean; purpose: boolean | null; openLines: number[] }
|
|
36
|
+
|
|
37
|
+
const NO_GATE_WORDS = new RegExp(
|
|
38
|
+
`^\\s*\\*\\*(?:${Object.values(PLAN_VOCABULARY)
|
|
39
|
+
.map(({ noReviewGate }) => escapeRegExp(noReviewGate))
|
|
40
|
+
.join('|')})\\b`,
|
|
41
|
+
'i',
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
/** The line by which a phase says it has no review gate on purpose: a bold opening with the words of any known language, or with the marker. */
|
|
45
|
+
const isNoGateLine = (line: string): boolean =>
|
|
46
|
+
NO_GATE_WORDS.test(line) || (BOLD_OPENING.test(line) && markedKeyOf(line) === LINE_KEY.noReviewGate)
|
|
33
47
|
|
|
34
48
|
const tickCount = (phase: PlanPhase): number => phase.items.filter((item) => item.done).length
|
|
35
49
|
|
|
@@ -39,10 +53,13 @@ const tasksDone = (phase: PlanPhase): boolean => {
|
|
|
39
53
|
return tasks.length > 0 && tasks.every((item) => item.done)
|
|
40
54
|
}
|
|
41
55
|
|
|
56
|
+
const settlePurpose = (section: PhaseSection, line: string, language: PlanLanguage): void => {
|
|
57
|
+
if (decidesPurpose(section.purpose !== null, line)) section.purpose = phasePurposeOf(line, language) !== null
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
/** Every `### Phase` section with whether it has tasks and whether it has a gate or says why it has none. */
|
|
43
61
|
const phaseSections = (markdown: string, language: PlanLanguage): PhaseSection[] => {
|
|
44
62
|
const heading = phaseHeadingPattern(language)
|
|
45
|
-
const noGate = noGatePattern(language)
|
|
46
63
|
const sections: PhaseSection[] = []
|
|
47
64
|
let open: PhaseSection | null = null
|
|
48
65
|
for (const [index, line] of markdown.split('\n').entries()) {
|
|
@@ -54,14 +71,16 @@ const phaseSections = (markdown: string, language: PlanLanguage): PhaseSection[]
|
|
|
54
71
|
title: terminalSafe(match[2]).trim(),
|
|
55
72
|
tasks: false,
|
|
56
73
|
gate: false,
|
|
74
|
+
purpose: null,
|
|
57
75
|
openLines: [],
|
|
58
76
|
}
|
|
59
77
|
sections.push(open)
|
|
60
78
|
} else if (endsPhase(line)) {
|
|
61
79
|
open = null
|
|
62
80
|
} else if (open) {
|
|
81
|
+
settlePurpose(open, line, language)
|
|
63
82
|
open.tasks ||= isCheckboxLine(line)
|
|
64
|
-
open.gate ||= isReviewGateLine(line) ||
|
|
83
|
+
open.gate ||= isReviewGateLine(line) || isNoGateLine(line)
|
|
65
84
|
if (isOpenCheckboxLine(line) && deferralTarget(line, open.id) === null) open.openLines.push(index + 1)
|
|
66
85
|
}
|
|
67
86
|
}
|
|
@@ -87,6 +106,23 @@ export const newPhasesWithoutReviewGate = (
|
|
|
87
106
|
.filter(({ id, tasks, gate }) => tasks && !gate && !previousIds.has(id))
|
|
88
107
|
.map(({ line, id, title }) => ({ line, id, title }))
|
|
89
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The phases a plan gained against `previousIds` that have checkboxes and no purpose line as the first non-empty line
|
|
111
|
+
* under the heading (`phasePurposeOf`). Phases the plan already had are not read, like in `newPhasesWithoutReviewGate`.
|
|
112
|
+
*/
|
|
113
|
+
export const newPhasesWithoutPurpose = (
|
|
114
|
+
markdown: string,
|
|
115
|
+
language: PlanLanguage,
|
|
116
|
+
previousIds: ReadonlySet<string>,
|
|
117
|
+
): PhaseWithoutPurpose[] =>
|
|
118
|
+
phaseSections(markdown, language)
|
|
119
|
+
.filter(({ id, tasks, purpose }) => tasks && !purpose && !previousIds.has(id))
|
|
120
|
+
.map(({ line, id, title }) => ({ line, id, title }))
|
|
121
|
+
|
|
122
|
+
/** Every phase of the plan with tasks and without a purpose line: for a plan read whole, where no phase is older than the change. */
|
|
123
|
+
export const phasesWithoutPurpose = (markdown: string, language: PlanLanguage): PhaseWithoutPurpose[] =>
|
|
124
|
+
newPhasesWithoutPurpose(markdown, language, new Set())
|
|
125
|
+
|
|
90
126
|
/** Whether the document has a lanes table (`## Spuren`): a board, not a plan. */
|
|
91
127
|
export const hasLanesTable = (markdown: string, language: PlanLanguage): boolean =>
|
|
92
128
|
sectionLines(markdown, PLAN_VOCABULARY[language].sections.lanes).some((line) => line.startsWith('|'))
|
|
@@ -8,6 +8,8 @@ import { type ChecklistItem, type DeferredItem, type PhaseWorkFields, type PlanP
|
|
|
8
8
|
export type PhaseProgress = {
|
|
9
9
|
id: string
|
|
10
10
|
title: string | null
|
|
11
|
+
/** The phase's purpose sentence; absent when the plan has none or lacks the phase. */
|
|
12
|
+
purpose?: string
|
|
11
13
|
done: number
|
|
12
14
|
total: number
|
|
13
15
|
items: ChecklistItem[]
|
|
@@ -46,6 +48,7 @@ export const phaseProgress = (phases: readonly PlanPhase[], id: string): PhasePr
|
|
|
46
48
|
done: items.filter((item) => item.done).length,
|
|
47
49
|
total: items.length,
|
|
48
50
|
items,
|
|
51
|
+
...(phase?.purpose ? { purpose: phase.purpose } : {}),
|
|
49
52
|
...(phase?.deferred ? { deferred: phase.deferred } : {}),
|
|
50
53
|
...(phase?.work ? phaseWorkFields(phase.work) : {}),
|
|
51
54
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
-
import { allTables, firstTableWithHeader, sectionLines, subsectionLines } from './markdown'
|
|
1
|
+
import { COLUMN_KEY, PLAN_VOCABULARY, type PlanLanguage } from '../vocabulary'
|
|
2
|
+
import { allTables, columnIndex, firstTableWithHeader, sectionLines, subsectionLines } from './markdown'
|
|
3
3
|
|
|
4
4
|
const PHASE_ID_IN_CELL = /^(\d+[a-z]?)\b/
|
|
5
5
|
|
|
@@ -57,9 +57,9 @@ export const readSprintDocument = (markdown: string, language: PlanLanguage): Sp
|
|
|
57
57
|
* first cell of a row starts with the id (`3 — Sync-Engine`); a plan without the table gives an empty map.
|
|
58
58
|
*/
|
|
59
59
|
export const readPhaseDependencies = (planMarkdown: string, language: PlanLanguage): Record<string, string> => {
|
|
60
|
-
const { sections
|
|
60
|
+
const { sections } = PLAN_VOCABULARY[language]
|
|
61
61
|
const { header, rows } = firstTableWithHeader(subsectionLines(planMarkdown, sections.dependencies))
|
|
62
|
-
const column = header.
|
|
62
|
+
const column = columnIndex(header, COLUMN_KEY.dependsOn, language)
|
|
63
63
|
if (column === -1) return {}
|
|
64
64
|
const entries = rows.flatMap((cells) => {
|
|
65
65
|
const id = PHASE_ID_IN_CELL.exec(cells[0] ?? '')?.[1]
|