Some checks failed
planctl / build-test (1.22, macos-latest) (push) Has been cancelled
planctl / build-test (1.22, ubuntu-latest) (push) Has been cancelled
planctl / build-test (1.22, windows-latest) (push) Has been cancelled
planctl / build-test (stable, macos-latest) (push) Has been cancelled
planctl / build-test (stable, ubuntu-latest) (push) Has been cancelled
planctl / build-test (stable, windows-latest) (push) Has been cancelled
planctl / bench (push) Has been cancelled
306 lines
9.4 KiB
Go
306 lines
9.4 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
// spec:planctl-task-cmds/R1.2+R1.3+R2.4+R2.5+R3.1+D§2+D§3
|
|
// TaskRecord enriches a TaskLine with the task description text, its parsed
|
|
// numeric T-ID, and the same-line traceability tag refs needed by the v2
|
|
// subcommands. The underlying TaskLine (File, Line, Indent, Checked) is
|
|
// embedded unchanged so v1 consumers are unaffected.
|
|
type TaskRecord struct {
|
|
TaskLine // embedded: File, Line, Indent, Checked
|
|
PlanDir string // basename of the plan directory that owns this task
|
|
Text string // description stripped of checkbox prefix
|
|
ID string // "T" + leading N.M, e.g. "T2.1"; "" if no numeric prefix
|
|
ReqTags []string // R-ids from a same-line _Requirements:_ tag
|
|
DesignTags []string // D§-ids from a same-line _Design:_ tag
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R1.2+D§3
|
|
// stripCheckboxPrefix removes the leading `^\s*- \[[ x]\] ` marker from a
|
|
// raw checkbox line, returning the task description text. Lines that do not
|
|
// match the pattern are returned unchanged.
|
|
func stripCheckboxPrefix(line string) string {
|
|
// Match leading whitespace (spaces or tabs), "- [", space or x, "] "
|
|
i := 0
|
|
for i < len(line) && (line[i] == ' ' || line[i] == '\t') {
|
|
i++
|
|
}
|
|
const marker = "- ["
|
|
if !strings.HasPrefix(line[i:], marker) {
|
|
return line
|
|
}
|
|
i += len(marker)
|
|
if i >= len(line) {
|
|
return line
|
|
}
|
|
ch := line[i]
|
|
if ch != ' ' && ch != 'x' {
|
|
return line
|
|
}
|
|
i++
|
|
if i >= len(line) || line[i] != ']' {
|
|
return line
|
|
}
|
|
i++
|
|
if i >= len(line) || line[i] != ' ' {
|
|
return line
|
|
}
|
|
i++
|
|
return line[i:]
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R1.3+R3.1+R3.4+D§3+D§6
|
|
// taskIDPattern matches a leading N.M numeric prefix (at least one dot
|
|
// segment) followed by whitespace. The captured group is the numeric part
|
|
// only; the "T" prefix is added by parseTaskID.
|
|
var taskIDPattern = regexp.MustCompile(`^(\d+(?:\.\d+)+)\s`)
|
|
|
|
// taskIDRefPattern validates a bare T-ID suffix (digits and dots, fully
|
|
// anchored). Used by findTaskByID to reject refs like "1.2 extra" that
|
|
// would otherwise pass taskIDPattern's unanchored prefix match.
|
|
var taskIDRefPattern = regexp.MustCompile(`^\d+(?:\.\d+)+$`)
|
|
|
|
// spec:planctl-task-cmds/R1.3+R3.1+D§3+D§6
|
|
// parseTaskID extracts the leading N.M numeric task ID from task description
|
|
// text and returns it as "T<N.M>". Returns "" when the text has no such prefix.
|
|
func parseTaskID(text string) string {
|
|
m := taskIDPattern.FindStringSubmatch(text)
|
|
if m == nil {
|
|
return ""
|
|
}
|
|
return "T" + m[1]
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R1.2+R2.4+R2.5+D§2+D§3
|
|
// buildTaskRecords constructs []TaskRecord from an Index and the tasks.md
|
|
// ScanResult. Tags are associated by line-number equality (D§3). scan must
|
|
// not be nil; the result is in the same order as idx.TaskLines.
|
|
func buildTaskRecords(idx Index, scan *ScanResult, planDir string) []TaskRecord {
|
|
out := make([]TaskRecord, 0, len(idx.TaskLines))
|
|
dirBase := planDir
|
|
for _, tl := range idx.TaskLines {
|
|
rawLine := ""
|
|
if tl.Line >= 1 && tl.Line <= len(scan.Lines) {
|
|
rawLine = scan.Lines[tl.Line-1]
|
|
}
|
|
text := stripCheckboxPrefix(rawLine)
|
|
id := parseTaskID(text)
|
|
var reqTags, desTags []string
|
|
for _, ref := range idx.TaskTags {
|
|
if ref.Line != tl.Line {
|
|
continue
|
|
}
|
|
switch ref.Kind {
|
|
case KindRequirements:
|
|
reqTags = append(reqTags, ref.ID)
|
|
case KindDesign:
|
|
desTags = append(desTags, ref.ID)
|
|
}
|
|
}
|
|
out = append(out, TaskRecord{
|
|
TaskLine: tl,
|
|
PlanDir: dirBase,
|
|
Text: text,
|
|
ID: id,
|
|
ReqTags: reqTags,
|
|
DesignTags: desTags,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.1+D§3
|
|
// buildTaskRecordsFromScan is the lightweight variant used by runComplete's
|
|
// pre-write phase. It builds TaskRecords directly from a ScanResult without
|
|
// requiring a pre-built Index (avoids loading prd.md / design.md).
|
|
func buildTaskRecordsFromScan(scan *ScanResult, planDir string) []TaskRecord {
|
|
taskLines := extractTaskLines(scan)
|
|
tags, _ := extractTaskTags(scan)
|
|
fakeIdx := Index{
|
|
TaskLines: taskLines,
|
|
TaskTags: tags,
|
|
PRDRequirements: map[string]Position{},
|
|
DesignSections: map[string]Position{},
|
|
}
|
|
return buildTaskRecords(fakeIdx, scan, planDir)
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.1+R3.4+D§3
|
|
// findResult discriminates the three outcomes of findTaskByID.
|
|
type findResult int
|
|
|
|
const (
|
|
findOK findResult = iota
|
|
findBadFormat // ref doesn't start with T or suffix isn't N.M
|
|
findNotFound // valid format, no matching record
|
|
)
|
|
|
|
// spec:planctl-task-cmds/R3.1+R3.4+D§3
|
|
// findTaskByID looks up a TaskRecord by its T-ID ref string (e.g. "T2.1").
|
|
// Returns the matched record (or nil), a findResult discriminator, and —
|
|
// when findNotFound — a sorted slice of all available non-empty IDs.
|
|
func findTaskByID(records []TaskRecord, ref string) (*TaskRecord, findResult, []string) {
|
|
// Normalize to uppercase for case-insensitive matching (R3.1).
|
|
ref = strings.ToUpper(ref)
|
|
// Validate format: must start with "T" and suffix must match N.M pattern.
|
|
if !strings.HasPrefix(ref, "T") {
|
|
return nil, findBadFormat, nil
|
|
}
|
|
suffix := ref[1:]
|
|
if !taskIDRefPattern.MatchString(suffix) {
|
|
return nil, findBadFormat, nil
|
|
}
|
|
for i := range records {
|
|
if records[i].ID == ref {
|
|
return &records[i], findOK, nil
|
|
}
|
|
}
|
|
// Collect available IDs for error message.
|
|
var avail []string
|
|
seen := map[string]bool{}
|
|
for _, r := range records {
|
|
if r.ID != "" && !seen[r.ID] {
|
|
avail = append(avail, r.ID)
|
|
seen[r.ID] = true
|
|
}
|
|
}
|
|
sort.Strings(avail)
|
|
return nil, findNotFound, avail
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R3.2+R3.7+D§3+D§4
|
|
// atomicRewriteTaskLine reads path, replaces "- [ ]" with "- [x]" on the
|
|
// given 1-indexed lineNum, and writes the result atomically via a temp file
|
|
// + rename (D§4). Returns the original and new line text. The function
|
|
// validates that the target line contains "- [ ]" before writing.
|
|
func atomicRewriteTaskLine(path string, lineNum int) (oldText, newText string, err error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("planctl: read %s: %w", path, err)
|
|
}
|
|
// Preserve trailing newline presence.
|
|
trailingNewline := len(data) > 0 && data[len(data)-1] == '\n'
|
|
|
|
lines := strings.Split(string(data), "\n")
|
|
// Strip the trailing empty element that Split produces for a
|
|
// trailing newline, so we work on content lines only.
|
|
if trailingNewline && len(lines) > 0 && lines[len(lines)-1] == "" {
|
|
lines = lines[:len(lines)-1]
|
|
}
|
|
|
|
if lineNum < 1 || lineNum > len(lines) {
|
|
return "", "", fmt.Errorf("planctl: line %d out of range (file has %d lines)", lineNum, len(lines))
|
|
}
|
|
target := lines[lineNum-1]
|
|
if !strings.Contains(target, "- [ ]") {
|
|
return "", "", fmt.Errorf("planctl: line %d does not contain unchecked task marker: %q", lineNum, target)
|
|
}
|
|
changed := strings.Replace(target, "- [ ]", "- [x]", 1)
|
|
lines[lineNum-1] = changed
|
|
|
|
joined := strings.Join(lines, "\n")
|
|
if trailingNewline {
|
|
joined += "\n"
|
|
}
|
|
|
|
// Preserve original file mode.
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("planctl: stat %s: %w", path, err)
|
|
}
|
|
mode := info.Mode()
|
|
|
|
tmp, err := os.CreateTemp(filepath.Dir(path), ".planctl-*.tmp")
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("planctl: create temp: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer func() {
|
|
_ = tmp.Close()
|
|
if err != nil {
|
|
_ = os.Remove(tmpPath)
|
|
}
|
|
}()
|
|
|
|
if _, werr := tmp.WriteString(joined); werr != nil {
|
|
err = fmt.Errorf("planctl: write temp: %w", werr)
|
|
return "", "", err
|
|
}
|
|
if cerr := tmp.Close(); cerr != nil {
|
|
err = fmt.Errorf("planctl: close temp: %w", cerr)
|
|
return "", "", err
|
|
}
|
|
|
|
if cherr := os.Chmod(tmpPath, mode); cherr != nil {
|
|
err = fmt.Errorf("planctl: chmod temp: %w", cherr)
|
|
return "", "", err
|
|
}
|
|
if rerr := os.Rename(tmpPath, path); rerr != nil {
|
|
err = fmt.Errorf("planctl: rename: %w", rerr)
|
|
return "", "", err
|
|
}
|
|
return target, changed, nil
|
|
}
|
|
|
|
// spec:planctl-task-cmds/R4.5+D§3
|
|
// PlanStatus is the overall plan health label from evalPlanStatus.
|
|
type PlanStatus string
|
|
|
|
const (
|
|
StatusLintError PlanStatus = "lint_error"
|
|
StatusNeedsCloseout PlanStatus = "needs_closeout"
|
|
StatusDone PlanStatus = "done"
|
|
StatusNotStarted PlanStatus = "not_started"
|
|
StatusInProgress PlanStatus = "in_progress"
|
|
)
|
|
|
|
// spec:planctl-task-cmds/R4.5+D§3
|
|
// evalPlanStatus derives the PlanStatus for a single plan per the R4.5
|
|
// precedence table (first match wins):
|
|
// 1. Any SevError diagnostic (excluding missing-closeout-file) → lint_error
|
|
// 2. All tasks checked + missing closeout file(s) → needs_closeout
|
|
// 3. All tasks checked + closeout present → done
|
|
// 4. Zero tasks checked → not_started
|
|
// 5. Default → in_progress
|
|
//
|
|
// CodeMissingCloseoutFile is excluded from the SevError check: per the PRD
|
|
// R4.3 example, a needs_closeout plan shows "Lint: PASS (0 errors)" — the
|
|
// closeout state is reported separately in the Close line.
|
|
func evalPlanStatus(diags []Diagnostic, tasks []TaskRecord, plan *Plan) PlanStatus {
|
|
for _, d := range diags {
|
|
if d.Severity == SevError && d.Code != CodeMissingCloseoutFile {
|
|
return StatusLintError
|
|
}
|
|
}
|
|
if len(tasks) > 0 {
|
|
allChecked := true
|
|
anyChecked := false
|
|
for _, t := range tasks {
|
|
if t.Checked {
|
|
anyChecked = true
|
|
} else {
|
|
allChecked = false
|
|
}
|
|
}
|
|
if allChecked {
|
|
if plan == nil || !plan.CodexLog || !plan.HandoffFound {
|
|
return StatusNeedsCloseout
|
|
}
|
|
return StatusDone
|
|
}
|
|
if !anyChecked {
|
|
return StatusNotStarted
|
|
}
|
|
} else {
|
|
return StatusNotStarted
|
|
}
|
|
return StatusInProgress
|
|
}
|