devrites 4.2.0 → 4.3.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 +7 -0
- package/README.md +1 -1
- package/docs/engine/commands.md +4 -0
- package/docs/orchestration.md +6 -3
- package/engine/internal/lib/cli_observe.go +66 -0
- package/engine/internal/lib/observe_summary.go +79 -0
- package/engine/internal/lib/skilltrust.go +196 -0
- package/engine/internal/lib/taskgraph.go +162 -0
- package/engine/internal/lib/taskgraph_test.go +150 -0
- package/engine/internal/lib/workspace_read.go +29 -0
- package/engine/internal/parallel/cli.go +467 -0
- package/engine/internal/parallel/git.go +181 -0
- package/engine/internal/parallel/lease.go +244 -0
- package/engine/internal/parallel/ops.go +416 -0
- package/engine/internal/parallel/parallel_test.go +242 -0
- package/engine/internal/parallel/pathdisjoint.go +129 -0
- package/engine/internal/parallel/pathdisjoint_test.go +97 -0
- package/engine/internal/state/status.go +41 -16
- package/engine/main.go +72 -3
- package/engine/root_routing_test.go +1 -1
- package/pack/.claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/.claude/skills/rite-build/SKILL.md +29 -36
- package/pack/.claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/.claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/.claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/.claude/skills/rite-build/reference/output.md +2 -0
- package/pack/.claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/.claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/.claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/.claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/.claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/.claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/.claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/claude/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/claude/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/claude/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/claude/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/claude/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/claude/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/claude/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/claude/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/claude/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/claude/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/claude/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/claude/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/pack/generated/codex/skills/devrites-lib/reference/standards/skill-authoring.md +22 -0
- package/pack/generated/codex/skills/rite-build/SKILL.md +29 -36
- package/pack/generated/codex/skills/rite-build/reference/afk-discipline.md +19 -25
- package/pack/generated/codex/skills/rite-build/reference/checkpoint-protocol.md +29 -59
- package/pack/generated/codex/skills/rite-build/reference/one-slice-cycle.md +8 -3
- package/pack/generated/codex/skills/rite-build/reference/output.md +2 -0
- package/pack/generated/codex/skills/rite-build/reference/parallel-batch.md +64 -0
- package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +6 -5
- package/pack/generated/codex/skills/rite-build/reference/wright-dispatch.md +7 -3
- package/pack/generated/codex/skills/rite-clarify/reference/anti-patterns.md +24 -0
- package/pack/generated/codex/skills/rite-customize/SKILL.md +3 -2
- package/pack/generated/codex/skills/rite-doctor/SKILL.md +5 -1
- package/pack/generated/codex/skills/rite-plan/reference/dependency-graph.md +3 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to DevRites are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and DevRites adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Releases are generated automatically by [semantic-release](https://semantic-release.gitbook.io/) from Conventional Commits on `main`.
|
|
4
4
|
|
|
5
|
+
## [4.3.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.2.0...v4.3.0) (2026-08-25)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
* **devrites:** add skill-trust, task-graph, and observe commands ([#38](https://github.com/ViktorsBaikers/DevRites/issues/38)) ([2562355](https://github.com/ViktorsBaikers/DevRites/commit/2562355a970726fb95e57e2f328961ca136c9f81))
|
|
10
|
+
* **rite-build:** opt-in parallel worktree slices (N≤3) ([#37](https://github.com/ViktorsBaikers/DevRites/issues/37)) ([fe71d67](https://github.com/ViktorsBaikers/DevRites/commit/fe71d672408559af030e95f700b18ead798f18fc))
|
|
11
|
+
|
|
5
12
|
## [4.2.0](https://github.com/ViktorsBaikers/DevRites/compare/v4.1.0...v4.2.0) (2026-08-23)
|
|
6
13
|
|
|
7
14
|
### Added
|
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ project-conventional push, tag, or PR action, and archive the workspace.
|
|
|
28
28
|
Unattended runs may create local WIP checkpoint commits along the way, but they
|
|
29
29
|
remain local unless Ship's disclosed plan includes an approved remote action.
|
|
30
30
|
|
|
31
|
-
**Status:** [`v4.
|
|
31
|
+
**Status:** [`v4.3.0`](https://github.com/ViktorsBaikers/DevRites/releases/tag/v4.3.0): see [`CHANGELOG.md`](CHANGELOG.md) for release notes.
|
|
32
32
|
|
|
33
33
|
This is the latest published release; `main` may contain unreleased work.
|
|
34
34
|
|
package/docs/engine/commands.md
CHANGED
|
@@ -15,6 +15,10 @@ workflow strategy.
|
|
|
15
15
|
| `check readiness <slug>` | Check target-Phase files, open human gates from Clarify onward, and the current stable Build-input binding when applicable. |
|
|
16
16
|
| `check readiness --emit-binding <slug>` | Render the exact stable Build-input binding for Vet to record after review. |
|
|
17
17
|
| `check seal <slug>` | Check files required by target Phase `seal`, open human gates, the stable Build-input binding, and exact candidate bindings. |
|
|
18
|
+
| `check path-disjoint [--root <dir>] [<json-file>|-]` | Verify slice path sets are pairwise disjoint. |
|
|
19
|
+
| `check task-graph <slug>` | Validate `tasks.md` slice dependency graph for cycles and unknown dependencies. |
|
|
20
|
+
| `check skill-trust <path>` | Scan one skill/agent Markdown file for structural trust violations. |
|
|
21
|
+
| `observe summary <slug>` | Emit sanitized JSON workspace summary from one retained observation. |
|
|
18
22
|
| `state resolve <qid> "<answer>"` | Resolve an open question and update `questions.md` plus `state.md` atomically. |
|
|
19
23
|
| `state close <slug>` | Archive a shipped workspace and clear matching `ACTIVE`. |
|
|
20
24
|
| `secret-scan [--staged] [--stdin] [slug]` | Scan exact staged blobs, stdin, or touched regular files for credential material. |
|
package/docs/orchestration.md
CHANGED
|
@@ -126,9 +126,12 @@ base before native transfer, then re-proves the reconciled candidate. Missing tr
|
|
|
126
126
|
conflict, extra paths, moved base, or cleanup failure stops with worktree evidence
|
|
127
127
|
preserved. No ad hoc copy/cherry-pick/merge occurs from the read-only root.
|
|
128
128
|
|
|
129
|
-
Isolation does not authorize throughput.
|
|
130
|
-
|
|
131
|
-
|
|
129
|
+
Isolation does not authorize same-worktree throughput. Parallel writers are allowed
|
|
130
|
+
**only** under `/rite-build --parallel N` when path-disjoint eligibility, abort-batch,
|
|
131
|
+
and a control `parallel-lease.md` apply (see
|
|
132
|
+
[`parallel-batch.md`](../pack/.claude/skills/rite-build/reference/parallel-batch.md)).
|
|
133
|
+
Same-worktree multi-writer and root-emulated worktrees remain forbidden; default
|
|
134
|
+
`/rite-build` stays one writer across linked worktrees.
|
|
132
135
|
|
|
133
136
|
## Engine boundary
|
|
134
137
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
|
|
7
|
+
"github.com/devrites/devrites/internal/devritespaths"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// RunTaskGraphCheck validates tasks.md for one slug.
|
|
11
|
+
func RunTaskGraphCheck(root, slug string, stdout, stderr io.Writer) int {
|
|
12
|
+
graph, err := CheckTaskGraph(root, slug)
|
|
13
|
+
if err != nil {
|
|
14
|
+
fmt.Fprintf(stderr, "task-graph: %v\n", err)
|
|
15
|
+
return 2
|
|
16
|
+
}
|
|
17
|
+
if len(graph.Problems) > 0 {
|
|
18
|
+
for _, problem := range graph.Problems {
|
|
19
|
+
fmt.Fprintf(stdout, "task-graph: BLOCKED: %s\n", problem)
|
|
20
|
+
}
|
|
21
|
+
return 3
|
|
22
|
+
}
|
|
23
|
+
fmt.Fprintf(stdout, "task-graph: ok (%d slices)\n", len(graph.Slices))
|
|
24
|
+
return 0
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// RunSkillTrustCheck scans one Markdown path.
|
|
28
|
+
func RunSkillTrustCheck(path string, stdout, stderr io.Writer) int {
|
|
29
|
+
result, err := ScanSkillTrust(path)
|
|
30
|
+
if err != nil {
|
|
31
|
+
fmt.Fprintf(stderr, "skill-trust: %v\n", err)
|
|
32
|
+
return 2
|
|
33
|
+
}
|
|
34
|
+
fmt.Fprint(stdout, FormatSkillTrust(result))
|
|
35
|
+
if SkillTrustBlocks(result.Findings) {
|
|
36
|
+
return 3
|
|
37
|
+
}
|
|
38
|
+
return 0
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// RunObserveSummary emits JSON for one slug.
|
|
42
|
+
func RunObserveSummary(root, slug string, stdout, stderr io.Writer) int {
|
|
43
|
+
if err := WriteObserveSummaryJSON(root, slug, stdout); err != nil {
|
|
44
|
+
fmt.Fprintf(stderr, "observe: %v\n", err)
|
|
45
|
+
return 2
|
|
46
|
+
}
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ActiveSlug resolves slug from args or ACTIVE pointer.
|
|
51
|
+
func ActiveSlug(root string, args []string) (string, int, error) {
|
|
52
|
+
if len(args) == 1 {
|
|
53
|
+
return args[0], 0, nil
|
|
54
|
+
}
|
|
55
|
+
if len(args) != 0 {
|
|
56
|
+
return "", 2, fmt.Errorf("expected one slug argument")
|
|
57
|
+
}
|
|
58
|
+
slug, err := devritespaths.ActiveSlug(root)
|
|
59
|
+
if err != nil {
|
|
60
|
+
return "", 2, err
|
|
61
|
+
}
|
|
62
|
+
if slug == "" {
|
|
63
|
+
return "", 2, fmt.Errorf("no slug and ACTIVE is empty")
|
|
64
|
+
}
|
|
65
|
+
return slug, 0, nil
|
|
66
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"io"
|
|
6
|
+
|
|
7
|
+
"github.com/devrites/devrites/internal/state"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// ObserveSummary is a sanitized, machine-readable workspace snapshot.
|
|
11
|
+
type ObserveSummary struct {
|
|
12
|
+
Slug string `json:"slug"`
|
|
13
|
+
Phase string `json:"phase,omitempty"`
|
|
14
|
+
Status string `json:"status,omitempty"`
|
|
15
|
+
NextAction string `json:"next_action,omitempty"`
|
|
16
|
+
MissingSections []string `json:"missing_sections,omitempty"`
|
|
17
|
+
MissingFiles []string `json:"missing_files,omitempty"`
|
|
18
|
+
PrinciplesPresent bool `json:"principles_present"`
|
|
19
|
+
TaskGraph *struct {
|
|
20
|
+
SliceCount int `json:"slice_count"`
|
|
21
|
+
Cycle []string `json:"cycle,omitempty"`
|
|
22
|
+
Unknown []string `json:"unknown_dependencies,omitempty"`
|
|
23
|
+
OK bool `json:"ok"`
|
|
24
|
+
} `json:"task_graph,omitempty"`
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ObserveSummaryFor builds a summary for one feature slug.
|
|
28
|
+
func ObserveSummaryFor(root, slug string) (ObserveSummary, error) {
|
|
29
|
+
report, err := state.Status(root, slug)
|
|
30
|
+
if err != nil {
|
|
31
|
+
return ObserveSummary{}, err
|
|
32
|
+
}
|
|
33
|
+
summary := ObserveSummary{
|
|
34
|
+
Slug: report.Slug,
|
|
35
|
+
Phase: string(report.Phase),
|
|
36
|
+
Status: report.Status,
|
|
37
|
+
NextAction: report.NextAction,
|
|
38
|
+
PrinciplesPresent: report.PrinciplesPresent,
|
|
39
|
+
MissingFiles: append([]string(nil), report.MissingFiles...),
|
|
40
|
+
MissingSections: missingSectionNames(report.Missing),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if graph, graphErr := CheckTaskGraph(root, slug); graphErr == nil && len(graph.Slices) > 0 {
|
|
44
|
+
summary.TaskGraph = &struct {
|
|
45
|
+
SliceCount int `json:"slice_count"`
|
|
46
|
+
Cycle []string `json:"cycle,omitempty"`
|
|
47
|
+
Unknown []string `json:"unknown_dependencies,omitempty"`
|
|
48
|
+
OK bool `json:"ok"`
|
|
49
|
+
}{
|
|
50
|
+
SliceCount: len(graph.Slices),
|
|
51
|
+
Cycle: graph.Cycle,
|
|
52
|
+
Unknown: graph.Unknown,
|
|
53
|
+
OK: len(graph.Problems) == 0,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return summary, nil
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
func missingSectionNames(sections []state.Section) []string {
|
|
60
|
+
if len(sections) == 0 {
|
|
61
|
+
return nil
|
|
62
|
+
}
|
|
63
|
+
out := make([]string, len(sections))
|
|
64
|
+
for i, section := range sections {
|
|
65
|
+
out[i] = string(section)
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// WriteObserveSummaryJSON prints one JSON object to stdout.
|
|
71
|
+
func WriteObserveSummaryJSON(root, slug string, stdout io.Writer) error {
|
|
72
|
+
summary, err := ObserveSummaryFor(root, slug)
|
|
73
|
+
if err != nil {
|
|
74
|
+
return err
|
|
75
|
+
}
|
|
76
|
+
enc := json.NewEncoder(stdout)
|
|
77
|
+
enc.SetIndent("", " ")
|
|
78
|
+
return enc.Encode(summary)
|
|
79
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"io"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"regexp"
|
|
9
|
+
"strings"
|
|
10
|
+
"unicode"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
// SkillTrustFinding is one deterministic trust scan result.
|
|
14
|
+
type SkillTrustFinding struct {
|
|
15
|
+
Severity string // HIGH or MEDIUM
|
|
16
|
+
Rule string
|
|
17
|
+
Line int
|
|
18
|
+
Detail string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// SkillTrustResult summarizes a skill-trust scan.
|
|
22
|
+
type SkillTrustResult struct {
|
|
23
|
+
Path string
|
|
24
|
+
Findings []SkillTrustFinding
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
var skillTrustRules = []struct {
|
|
28
|
+
severity string
|
|
29
|
+
rule string
|
|
30
|
+
re *regexp.Regexp
|
|
31
|
+
detail string
|
|
32
|
+
}{
|
|
33
|
+
{
|
|
34
|
+
severity: "HIGH",
|
|
35
|
+
rule: "prompt_injection_disregard",
|
|
36
|
+
re: regexp.MustCompile(`(?i)\b(disregard|ignore)\b.{0,40}\b(prior|previous|above|system|instruction|rule|constraint|safety)\b`),
|
|
37
|
+
detail: "instruction attempts to override prior constraints",
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
severity: "HIGH",
|
|
41
|
+
rule: "credential_exfil_curl_env",
|
|
42
|
+
re: regexp.MustCompile(`(?i)\bcurl\b.{0,120}\$(?:ENV|HOME|[A-Z_][A-Z0-9_]*)`),
|
|
43
|
+
detail: "curl command references environment variables (possible exfiltration)",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
severity: "HIGH",
|
|
47
|
+
rule: "sensitive_path_reference",
|
|
48
|
+
re: regexp.MustCompile(`(?i)(~/.ssh|~/.aws|/etc/passwd|id_rsa|\.pem\b|api[_-]?key\s*[:=])`),
|
|
49
|
+
detail: "references sensitive filesystem or credential material",
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
severity: "MEDIUM",
|
|
53
|
+
rule: "hidden_remote_fetch",
|
|
54
|
+
re: regexp.MustCompile(`(?i)\b(curl|wget|fetch)\b.{0,80}\b(http|https)://`),
|
|
55
|
+
detail: "network fetch instruction in skill prose",
|
|
56
|
+
},
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ScanSkillTrust reads one Markdown skill/agent file and returns structural trust findings.
|
|
60
|
+
func ScanSkillTrust(path string) (SkillTrustResult, error) {
|
|
61
|
+
info, err := os.Stat(path)
|
|
62
|
+
if err != nil {
|
|
63
|
+
return SkillTrustResult{}, err
|
|
64
|
+
}
|
|
65
|
+
if info.IsDir() {
|
|
66
|
+
return SkillTrustResult{}, fmt.Errorf("skill-trust: %q is a directory", path)
|
|
67
|
+
}
|
|
68
|
+
if info.Size() > 1<<20 {
|
|
69
|
+
return SkillTrustResult{}, fmt.Errorf("skill-trust: %q exceeds 1 MiB limit", path)
|
|
70
|
+
}
|
|
71
|
+
file, err := os.Open(path)
|
|
72
|
+
if err != nil {
|
|
73
|
+
return SkillTrustResult{}, err
|
|
74
|
+
}
|
|
75
|
+
defer file.Close()
|
|
76
|
+
raw, err := io.ReadAll(io.LimitReader(file, 1<<20+1))
|
|
77
|
+
if err != nil {
|
|
78
|
+
return SkillTrustResult{}, err
|
|
79
|
+
}
|
|
80
|
+
if len(raw) > 1<<20 {
|
|
81
|
+
return SkillTrustResult{}, fmt.Errorf("skill-trust: %q exceeds 1 MiB limit", path)
|
|
82
|
+
}
|
|
83
|
+
return SkillTrustResult{Path: filepath.Clean(path), Findings: analyzeSkillTrustContent(string(raw))}, nil
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func analyzeSkillTrustContent(text string) []SkillTrustFinding {
|
|
87
|
+
var findings []SkillTrustFinding
|
|
88
|
+
lines := strings.Split(text, "\n")
|
|
89
|
+
for i, line := range lines {
|
|
90
|
+
lineNum := i + 1
|
|
91
|
+
for _, r := range line {
|
|
92
|
+
if isSuspiciousUnicode(r) {
|
|
93
|
+
findings = append(findings, SkillTrustFinding{
|
|
94
|
+
Severity: "HIGH",
|
|
95
|
+
Rule: "suspicious_unicode",
|
|
96
|
+
Line: lineNum,
|
|
97
|
+
Detail: fmt.Sprintf("suspicious Unicode U+%04X", r),
|
|
98
|
+
})
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for _, rule := range skillTrustRules {
|
|
103
|
+
if rule.re.MatchString(line) {
|
|
104
|
+
findings = append(findings, SkillTrustFinding{
|
|
105
|
+
Severity: rule.severity,
|
|
106
|
+
Rule: rule.rule,
|
|
107
|
+
Line: lineNum,
|
|
108
|
+
Detail: rule.detail,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
findings = appendMissingNameFinding(text, findings)
|
|
114
|
+
return dedupeFindings(findings)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func looksLikeSkillFile(text string) bool {
|
|
118
|
+
lower := strings.ToLower(text)
|
|
119
|
+
return strings.Contains(lower, "user-invocable:") || strings.Contains(lower, "# /rite-") || strings.Contains(lower, "disable-model-invocation:")
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func appendMissingNameFinding(text string, findings []SkillTrustFinding) []SkillTrustFinding {
|
|
123
|
+
if strings.Contains(text, "---") && !strings.Contains(text, "\nname:") && looksLikeSkillFile(text) {
|
|
124
|
+
findings = append(findings, SkillTrustFinding{
|
|
125
|
+
Severity: "MEDIUM",
|
|
126
|
+
Rule: "missing_name_frontmatter",
|
|
127
|
+
Line: 1,
|
|
128
|
+
Detail: "skill-like Markdown lacks name frontmatter",
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
return findings
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func isSuspiciousUnicode(r rune) bool {
|
|
135
|
+
switch r {
|
|
136
|
+
case '\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060':
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
if r >= '\u202A' && r <= '\u202E' {
|
|
140
|
+
return true
|
|
141
|
+
}
|
|
142
|
+
if r >= '\u2066' && r <= '\u2069' {
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
func dedupeFindings(in []SkillTrustFinding) []SkillTrustFinding {
|
|
149
|
+
if len(in) == 0 {
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
seen := make(map[string]bool, len(in))
|
|
153
|
+
out := make([]SkillTrustFinding, 0, len(in))
|
|
154
|
+
for _, finding := range in {
|
|
155
|
+
key := finding.Rule + "|" + fmt.Sprint(finding.Line) + "|" + finding.Detail
|
|
156
|
+
if seen[key] {
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
seen[key] = true
|
|
160
|
+
out = append(out, finding)
|
|
161
|
+
}
|
|
162
|
+
return out
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// SkillTrustBlocks returns true when findings contain any HIGH severity item.
|
|
166
|
+
func SkillTrustBlocks(findings []SkillTrustFinding) bool {
|
|
167
|
+
for _, finding := range findings {
|
|
168
|
+
if finding.Severity == "HIGH" {
|
|
169
|
+
return true
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// FormatSkillTrust writes a human-readable report.
|
|
176
|
+
func FormatSkillTrust(result SkillTrustResult) string {
|
|
177
|
+
if len(result.Findings) == 0 {
|
|
178
|
+
return fmt.Sprintf("skill-trust: PASS (%s)\n", result.Path)
|
|
179
|
+
}
|
|
180
|
+
var b strings.Builder
|
|
181
|
+
fmt.Fprintf(&b, "skill-trust: FINDINGS (%s)\n", result.Path)
|
|
182
|
+
for _, finding := range result.Findings {
|
|
183
|
+
fmt.Fprintf(&b, "%s %s line=%d %s\n", finding.Severity, finding.Rule, finding.Line, finding.Detail)
|
|
184
|
+
}
|
|
185
|
+
return b.String()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ContainsOnlyASCII returns false when non-ASCII control or bidi characters appear.
|
|
189
|
+
func ContainsOnlyASCII(text string) bool {
|
|
190
|
+
for _, r := range text {
|
|
191
|
+
if r > unicode.MaxASCII && isSuspiciousUnicode(r) {
|
|
192
|
+
return false
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return true
|
|
196
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
package lib
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"fmt"
|
|
5
|
+
"regexp"
|
|
6
|
+
"strings"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
// TaskSlice is one SLICE-### block parsed from tasks.md.
|
|
10
|
+
type TaskSlice struct {
|
|
11
|
+
ID string
|
|
12
|
+
Dependencies []string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// TaskGraphResult is the deterministic outcome of parsing a tasks.md slice graph.
|
|
16
|
+
type TaskGraphResult struct {
|
|
17
|
+
Slices []TaskSlice
|
|
18
|
+
Cycle []string // empty when acyclic
|
|
19
|
+
Unknown []string // dependency ids with no defining slice
|
|
20
|
+
Problems []string // human-readable blockers
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
var (
|
|
24
|
+
sliceHeaderRE = regexp.MustCompile(`(?m)^##\s+(SLICE-\d+)\b`)
|
|
25
|
+
dependenciesRE = regexp.MustCompile(`(?m)^Dependencies:\s*(.+)\s*$`)
|
|
26
|
+
sliceIDValidRE = regexp.MustCompile(`^SLICE-\d+$`)
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// ParseTaskGraph reads tasks.md content and validates the slice dependency DAG.
|
|
30
|
+
func ParseTaskGraph(tasksMarkdown []byte) TaskGraphResult {
|
|
31
|
+
text := string(tasksMarkdown)
|
|
32
|
+
result := TaskGraphResult{}
|
|
33
|
+
if strings.TrimSpace(text) == "" {
|
|
34
|
+
result.Problems = append(result.Problems, "tasks.md is empty")
|
|
35
|
+
return result
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
headers := sliceHeaderRE.FindAllStringSubmatchIndex(text, -1)
|
|
39
|
+
if len(headers) == 0 {
|
|
40
|
+
result.Problems = append(result.Problems, "no SLICE-### sections found")
|
|
41
|
+
return result
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
known := make(map[string]bool)
|
|
45
|
+
for _, match := range headers {
|
|
46
|
+
id := text[match[2]:match[3]]
|
|
47
|
+
known[id] = true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
for i, match := range headers {
|
|
51
|
+
id := text[match[2]:match[3]]
|
|
52
|
+
start := match[1]
|
|
53
|
+
end := len(text)
|
|
54
|
+
if i+1 < len(headers) {
|
|
55
|
+
end = headers[i+1][0]
|
|
56
|
+
}
|
|
57
|
+
block := text[start:end]
|
|
58
|
+
deps := parseSliceDependencies(block)
|
|
59
|
+
result.Slices = append(result.Slices, TaskSlice{ID: id, Dependencies: deps})
|
|
60
|
+
for _, dep := range deps {
|
|
61
|
+
if !known[dep] {
|
|
62
|
+
result.Unknown = append(result.Unknown, dep)
|
|
63
|
+
result.Problems = append(result.Problems, fmt.Sprintf("%s depends on unknown slice %s", id, dep))
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if cycle := findTaskCycle(result.Slices); len(cycle) > 0 {
|
|
69
|
+
result.Cycle = cycle
|
|
70
|
+
result.Problems = append(result.Problems, "dependency cycle: "+strings.Join(cycle, " -> "))
|
|
71
|
+
}
|
|
72
|
+
return result
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func parseSliceDependencies(block string) []string {
|
|
76
|
+
match := dependenciesRE.FindStringSubmatch(block)
|
|
77
|
+
if len(match) < 2 {
|
|
78
|
+
return nil
|
|
79
|
+
}
|
|
80
|
+
raw := strings.TrimSpace(match[1])
|
|
81
|
+
if raw == "" || strings.EqualFold(raw, "none") {
|
|
82
|
+
return nil
|
|
83
|
+
}
|
|
84
|
+
parts := strings.FieldsFunc(raw, func(r rune) bool {
|
|
85
|
+
return r == ',' || r == ';'
|
|
86
|
+
})
|
|
87
|
+
var deps []string
|
|
88
|
+
seen := make(map[string]bool)
|
|
89
|
+
for _, part := range parts {
|
|
90
|
+
id := strings.TrimSpace(part)
|
|
91
|
+
if id == "" {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if !sliceIDValidRE.MatchString(id) {
|
|
95
|
+
continue
|
|
96
|
+
}
|
|
97
|
+
if seen[id] {
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
seen[id] = true
|
|
101
|
+
deps = append(deps, id)
|
|
102
|
+
}
|
|
103
|
+
return deps
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func findTaskCycle(slices []TaskSlice) []string {
|
|
107
|
+
graph := make(map[string][]string, len(slices))
|
|
108
|
+
for _, slice := range slices {
|
|
109
|
+
graph[slice.ID] = append([]string(nil), slice.Dependencies...)
|
|
110
|
+
}
|
|
111
|
+
visited := make(map[string]uint8, len(slices))
|
|
112
|
+
stack := make([]string, 0, len(slices))
|
|
113
|
+
var cycle []string
|
|
114
|
+
|
|
115
|
+
var visit func(id string) bool
|
|
116
|
+
visit = func(id string) bool {
|
|
117
|
+
switch visited[id] {
|
|
118
|
+
case 2:
|
|
119
|
+
return false
|
|
120
|
+
case 1:
|
|
121
|
+
for i := len(stack) - 1; i >= 0; i-- {
|
|
122
|
+
cycle = append(cycle, stack[i])
|
|
123
|
+
if stack[i] == id {
|
|
124
|
+
break
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
for i, j := 0, len(cycle)-1; i < j; i, j = i+1, j-1 {
|
|
128
|
+
cycle[i], cycle[j] = cycle[j], cycle[i]
|
|
129
|
+
}
|
|
130
|
+
return true
|
|
131
|
+
}
|
|
132
|
+
visited[id] = 1
|
|
133
|
+
stack = append(stack, id)
|
|
134
|
+
for _, dep := range graph[id] {
|
|
135
|
+
if visit(dep) {
|
|
136
|
+
return true
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
stack = stack[:len(stack)-1]
|
|
140
|
+
visited[id] = 2
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
for _, slice := range slices {
|
|
145
|
+
if visited[slice.ID] == 0 {
|
|
146
|
+
cycle = nil
|
|
147
|
+
if visit(slice.ID) {
|
|
148
|
+
return cycle
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return nil
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// CheckTaskGraph validates tasks.md for a feature workspace slug.
|
|
156
|
+
func CheckTaskGraph(root, slug string) (TaskGraphResult, error) {
|
|
157
|
+
raw, err := readWorkspaceArtifact(root, slug, "tasks.md")
|
|
158
|
+
if err != nil {
|
|
159
|
+
return TaskGraphResult{}, err
|
|
160
|
+
}
|
|
161
|
+
return ParseTaskGraph(raw), nil
|
|
162
|
+
}
|