devrites 4.1.0 → 4.2.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.
Files changed (84) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/README.md +1 -1
  3. package/docs/engine/commands.md +65 -6
  4. package/engine/go.mod +1 -1
  5. package/engine/internal/gate/gate.go +154 -46
  6. package/engine/internal/gate/gate_test.go +290 -2
  7. package/engine/internal/gate/readiness_binding.go +66 -100
  8. package/engine/internal/gate/readiness_binding_test.go +126 -3
  9. package/engine/internal/lib/resolve.go +6 -6
  10. package/engine/internal/state/cmd/workflowmanifest/main.go +32 -3
  11. package/engine/internal/state/feature.go +1 -100
  12. package/engine/internal/state/observation.go +584 -0
  13. package/engine/internal/state/observation_open_other.go +9 -0
  14. package/engine/internal/state/observation_open_unix.go +12 -0
  15. package/engine/internal/state/observation_test.go +960 -0
  16. package/engine/internal/state/observation_unix_test.go +68 -0
  17. package/engine/internal/state/schema.go +60 -160
  18. package/engine/internal/state/state_test.go +489 -101
  19. package/engine/internal/state/status.go +67 -48
  20. package/engine/tests/adr_0004_required_by_phase_test.go +16 -13
  21. package/engine/tests/adr_0011_define_plan_test.go +14 -12
  22. package/engine/tests/gate_test.go +403 -28
  23. package/engine/tests/workspace_observation_migration_test.go +1065 -0
  24. package/pack/.claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  25. package/pack/.claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  26. package/pack/.claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  27. package/pack/.claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  28. package/pack/.claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  29. package/pack/.claude/skills/rite-autocomplete/SKILL.md +98 -146
  30. package/pack/.claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  31. package/pack/.claude/skills/rite-autocomplete/reference/loop.md +96 -134
  32. package/pack/.claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  33. package/pack/.claude/skills/rite-build/SKILL.md +51 -79
  34. package/pack/.claude/skills/rite-build/reference/phase-contract.md +3 -17
  35. package/pack/.claude/skills/rite-plan/SKILL.md +17 -17
  36. package/pack/.claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  37. package/pack/.claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  38. package/pack/.claude/skills/rite-prove/SKILL.md +95 -153
  39. package/pack/.claude/skills/rite-vet/SKILL.md +121 -174
  40. package/pack/.claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  41. package/pack/.claude/skills/rite-vet/reference/artifacts.md +10 -6
  42. package/pack/.claude/skills/rite-vet/reference/depth.md +10 -3
  43. package/pack/.claude/skills/rite-vet/reference/review-axes.md +12 -4
  44. package/pack/generated/claude/skills/devrites-debug-recovery/SKILL.md +1 -10
  45. package/pack/generated/claude/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  46. package/pack/generated/claude/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  47. package/pack/generated/claude/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  48. package/pack/generated/claude/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  49. package/pack/generated/claude/skills/rite-autocomplete/SKILL.md +98 -146
  50. package/pack/generated/claude/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  51. package/pack/generated/claude/skills/rite-autocomplete/reference/loop.md +96 -134
  52. package/pack/generated/claude/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  53. package/pack/generated/claude/skills/rite-build/SKILL.md +51 -79
  54. package/pack/generated/claude/skills/rite-build/reference/phase-contract.md +3 -17
  55. package/pack/generated/claude/skills/rite-plan/SKILL.md +17 -17
  56. package/pack/generated/claude/skills/rite-plan/reference/anti-patterns.md +11 -1
  57. package/pack/generated/claude/skills/rite-plan/reference/replan-and-repair.md +10 -3
  58. package/pack/generated/claude/skills/rite-prove/SKILL.md +95 -153
  59. package/pack/generated/claude/skills/rite-vet/SKILL.md +121 -174
  60. package/pack/generated/claude/skills/rite-vet/reference/anti-patterns.md +14 -2
  61. package/pack/generated/claude/skills/rite-vet/reference/artifacts.md +10 -6
  62. package/pack/generated/claude/skills/rite-vet/reference/depth.md +10 -3
  63. package/pack/generated/claude/skills/rite-vet/reference/review-axes.md +12 -4
  64. package/pack/generated/codex/skills/devrites-debug-recovery/SKILL.md +1 -10
  65. package/pack/generated/codex/skills/devrites-lib/reference/standards/acceptance-preserving-reslice.md +30 -0
  66. package/pack/generated/codex/skills/devrites-lib/reference/standards/afk-hitl.md +2 -9
  67. package/pack/generated/codex/skills/devrites-lib/reference/standards/one-shot-actions.md +1 -6
  68. package/pack/generated/codex/skills/devrites-lib/reference/standards/workflow-artifacts.md +411 -102
  69. package/pack/generated/codex/skills/rite-autocomplete/SKILL.md +98 -146
  70. package/pack/generated/codex/skills/rite-autocomplete/reference/decision-policy.md +11 -5
  71. package/pack/generated/codex/skills/rite-autocomplete/reference/loop.md +96 -134
  72. package/pack/generated/codex/skills/rite-autocomplete/reference/stop-conditions.md +92 -131
  73. package/pack/generated/codex/skills/rite-build/SKILL.md +51 -79
  74. package/pack/generated/codex/skills/rite-build/reference/phase-contract.md +3 -17
  75. package/pack/generated/codex/skills/rite-plan/SKILL.md +17 -17
  76. package/pack/generated/codex/skills/rite-plan/reference/anti-patterns.md +11 -1
  77. package/pack/generated/codex/skills/rite-plan/reference/replan-and-repair.md +10 -3
  78. package/pack/generated/codex/skills/rite-prove/SKILL.md +95 -153
  79. package/pack/generated/codex/skills/rite-vet/SKILL.md +121 -174
  80. package/pack/generated/codex/skills/rite-vet/reference/anti-patterns.md +14 -2
  81. package/pack/generated/codex/skills/rite-vet/reference/artifacts.md +10 -6
  82. package/pack/generated/codex/skills/rite-vet/reference/depth.md +10 -3
  83. package/pack/generated/codex/skills/rite-vet/reference/review-axes.md +12 -4
  84. package/package.json +1 -1
@@ -0,0 +1,584 @@
1
+ package state
2
+
3
+ import (
4
+ "errors"
5
+ "fmt"
6
+ "io"
7
+ "io/fs"
8
+ "os"
9
+ "path"
10
+ "path/filepath"
11
+ "strings"
12
+
13
+ "github.com/devrites/devrites/internal/devritespaths"
14
+ "github.com/devrites/devrites/internal/markdowntext"
15
+ )
16
+
17
+ const (
18
+ maxArtifactBytes = 1 << 20
19
+ maxObservationBytes = 8 << 20
20
+ )
21
+
22
+ type ArtifactState string
23
+
24
+ const (
25
+ ArtifactAbsent ArtifactState = "absent"
26
+ ArtifactEmpty ArtifactState = "empty"
27
+ ArtifactMalformed ArtifactState = "malformed"
28
+ ArtifactUnsafe ArtifactState = "unsafe"
29
+ ArtifactUnreadable ArtifactState = "unreadable"
30
+ ArtifactPresent ArtifactState = "present"
31
+ )
32
+
33
+ type DiagnosticCode string
34
+
35
+ const (
36
+ DiagnosticMalformedMarkdown DiagnosticCode = "malformed_markdown"
37
+ DiagnosticParentSymlink DiagnosticCode = "parent_symlink"
38
+ DiagnosticFinalSymlink DiagnosticCode = "final_symlink"
39
+ DiagnosticNonRegular DiagnosticCode = "non_regular"
40
+ DiagnosticFileTooLarge DiagnosticCode = "file_too_large"
41
+ DiagnosticPermissionDenied DiagnosticCode = "permission_denied"
42
+ DiagnosticReadFailure DiagnosticCode = "read_failure"
43
+ )
44
+
45
+ type ObservationFailure string
46
+
47
+ const (
48
+ ObservationWorkspaceInvalid ObservationFailure = "workspace_invalid"
49
+ ObservationAggregateTooLarge ObservationFailure = "aggregate_too_large"
50
+ ObservationConcurrentChange ObservationFailure = "concurrent_change"
51
+ )
52
+
53
+ func (f ObservationFailure) Error() string {
54
+ switch f {
55
+ case ObservationWorkspaceInvalid:
56
+ return "workspace observation: workspace_invalid: workspace is unavailable; verify the selected logical workspace and canonical workspace override, then retry"
57
+ case ObservationAggregateTooLarge:
58
+ return "workspace observation: aggregate_too_large: retained content exceeds the 8 MiB aggregate limit; reduce retained Markdown below 8 MiB, then retry"
59
+ case ObservationConcurrentChange:
60
+ return "workspace observation: concurrent_change: workspace changed during acquisition; retry"
61
+ default:
62
+ return "workspace observation: " + string(f)
63
+ }
64
+ }
65
+
66
+ type ArtifactDiagnostic struct {
67
+ Path ArtifactPath
68
+ State ArtifactState
69
+ Code DiagnosticCode
70
+ }
71
+
72
+ type ArtifactFact struct {
73
+ path ArtifactPath
74
+ state ArtifactState
75
+ bytes []byte
76
+ code DiagnosticCode
77
+ }
78
+
79
+ func (f ArtifactFact) Path() ArtifactPath { return f.path }
80
+
81
+ func (f ArtifactFact) State() ArtifactState { return f.state }
82
+
83
+ func (f ArtifactFact) Bytes() []byte { return copyArtifactBytes(f.bytes) }
84
+
85
+ func (f ArtifactFact) Diagnostic() (ArtifactDiagnostic, bool) {
86
+ if f.code == "" {
87
+ return ArtifactDiagnostic{}, false
88
+ }
89
+ return ArtifactDiagnostic{Path: f.path, State: f.state, Code: f.code}, true
90
+ }
91
+
92
+ type WorkspaceObservation struct {
93
+ slug string
94
+ facts []ArtifactFact
95
+ index map[ArtifactPath]int
96
+ }
97
+
98
+ func (o *WorkspaceObservation) Slug() string { return o.slug }
99
+
100
+ func (o *WorkspaceObservation) Facts() []ArtifactFact {
101
+ facts := make([]ArtifactFact, len(o.facts))
102
+ for i, fact := range o.facts {
103
+ facts[i] = cloneArtifactFact(fact)
104
+ }
105
+ return facts
106
+ }
107
+
108
+ func (o *WorkspaceObservation) Fact(path ArtifactPath) (ArtifactFact, bool) {
109
+ index, ok := o.index[path]
110
+ if !ok {
111
+ return ArtifactFact{}, false
112
+ }
113
+ return cloneArtifactFact(o.facts[index]), true
114
+ }
115
+
116
+ func (o *WorkspaceObservation) DeclaredPhase() (Phase, error) {
117
+ fact, _ := o.Fact(LedgerFile)
118
+ if fact.state != ArtifactPresent {
119
+ recovery := "repair state.md and retry"
120
+ if fact.state == ArtifactAbsent || fact.state == ArtifactEmpty {
121
+ recovery = "add real content to state.md and retry"
122
+ }
123
+ if diagnostic, ok := fact.Diagnostic(); ok {
124
+ return "", fmt.Errorf("feature %q: %s is %s (%s); %s", o.slug, LedgerFile, fact.state, diagnostic.Code, recovery)
125
+ }
126
+ return "", fmt.Errorf("feature %q: %s is %s; %s", o.slug, LedgerFile, fact.state, recovery)
127
+ }
128
+ structural, err := markdowntext.Structural(fact.bytes)
129
+ if err != nil {
130
+ return "", fmt.Errorf("feature %q: %s is %s (%s); repair state.md and retry", o.slug, LedgerFile, ArtifactMalformed, DiagnosticMalformedMarkdown)
131
+ }
132
+ value, ok := CursorField(strings.Split(string(structural), "\n"), CursorPhase)
133
+ if !ok {
134
+ return "", fmt.Errorf("feature %q: no phase in %s ledger; record phase in state.md and retry", o.slug, LedgerFile)
135
+ }
136
+ phase := Phase(firstPhaseWord(value))
137
+ policy, ok := PolicyFor(phase)
138
+ if !ok {
139
+ return "", fmt.Errorf("feature %q: unknown phase %q; record a known phase in state.md and retry", o.slug, phase)
140
+ }
141
+ return policy.Target, nil
142
+ }
143
+
144
+ func (o *WorkspaceObservation) Missing(required []ArtifactPath) ([]ArtifactPath, []ArtifactDiagnostic) {
145
+ var missing []ArtifactPath
146
+ var diagnostics []ArtifactDiagnostic
147
+ for _, path := range required {
148
+ fact, ok := o.Fact(path)
149
+ if ok && fact.state == ArtifactPresent {
150
+ continue
151
+ }
152
+ missing = append(missing, path)
153
+ if ok {
154
+ if diagnostic, hasDiagnostic := fact.Diagnostic(); hasDiagnostic {
155
+ diagnostics = append(diagnostics, diagnostic)
156
+ }
157
+ }
158
+ }
159
+ return missing, diagnostics
160
+ }
161
+
162
+ func cloneArtifactFact(fact ArtifactFact) ArtifactFact {
163
+ fact.bytes = copyArtifactBytes(fact.bytes)
164
+ return fact
165
+ }
166
+
167
+ func copyArtifactBytes(raw []byte) []byte {
168
+ if raw == nil {
169
+ return nil
170
+ }
171
+ copied := make([]byte, len(raw))
172
+ copy(copied, raw)
173
+ return copied
174
+ }
175
+
176
+ type observationStage uint8
177
+
178
+ const (
179
+ observationAfterResolve observationStage = iota + 1
180
+ observationAfterInspect
181
+ observationBeforeOpen
182
+ observationBeforeRead
183
+ observationAfterRead
184
+ observationBeforeFinalSweep
185
+ )
186
+
187
+ type observationCallback func(observationStage, ArtifactPath) error
188
+
189
+ type artifactLocation struct {
190
+ path ArtifactPath
191
+ root *os.Root
192
+ name string
193
+ }
194
+
195
+ type entryState uint8
196
+
197
+ const (
198
+ entryAbsent entryState = iota
199
+ entryPresent
200
+ entryUnreadable
201
+ )
202
+
203
+ type observedEntry struct {
204
+ state entryState
205
+ info os.FileInfo
206
+ code DiagnosticCode
207
+ }
208
+
209
+ type namedEntry struct {
210
+ name string
211
+ entry observedEntry
212
+ }
213
+
214
+ type artifactSnapshot struct {
215
+ parents []namedEntry
216
+ final *observedEntry
217
+ }
218
+
219
+ type observationRoots struct {
220
+ devrites *os.Root
221
+ workspace *os.Root
222
+ rootInfo os.FileInfo
223
+ workspaceInfo os.FileInfo
224
+ workspaceEntry namedEntry
225
+ workspaceParents []namedEntry
226
+ }
227
+
228
+ func ObserveWorkspace(root, slug string) (*WorkspaceObservation, error) {
229
+ return observeWorkspace(root, slug, nil)
230
+ }
231
+
232
+ func observeWorkspace(root, slug string, callback observationCallback) (*WorkspaceObservation, error) {
233
+ if !observationWorkspaceOverrideValid(root, slug) {
234
+ return nil, ObservationWorkspaceInvalid
235
+ }
236
+ workspacePath, err := devritespaths.ExistingFeatureDirChecked(root, slug)
237
+ if err != nil {
238
+ return nil, ObservationWorkspaceInvalid
239
+ }
240
+ rootInfo, err := os.Stat(root)
241
+ if err != nil || !rootInfo.IsDir() {
242
+ return nil, ObservationWorkspaceInvalid
243
+ }
244
+ workspaceInfo, err := os.Stat(workspacePath)
245
+ if err != nil || !workspaceInfo.IsDir() {
246
+ return nil, ObservationWorkspaceInvalid
247
+ }
248
+ if err := runObservationStage(callback, observationAfterResolve, ""); err != nil {
249
+ return nil, ObservationWorkspaceInvalid
250
+ }
251
+
252
+ roots, err := openObservationRoots(root, workspacePath, rootInfo, workspaceInfo)
253
+ if err != nil {
254
+ return nil, ObservationWorkspaceInvalid
255
+ }
256
+ defer roots.devrites.Close()
257
+ defer roots.workspace.Close()
258
+
259
+ inventory := observationInventory()
260
+ facts := make([]ArtifactFact, 0, len(inventory))
261
+ snapshots := make([]artifactSnapshot, 0, len(inventory))
262
+ locations := make([]artifactLocation, 0, len(inventory))
263
+ retainedBytes := 0
264
+ for _, artifactPath := range inventory {
265
+ location := artifactLocation{path: artifactPath, root: roots.workspace, name: string(artifactPath)}
266
+ if artifactPath == ".devrites/principles.md" {
267
+ location.root = roots.devrites
268
+ location.name = "principles.md"
269
+ }
270
+ fact, snapshot, observeErr := observeArtifact(location, callback)
271
+ if observeErr != nil {
272
+ return nil, observeErr
273
+ }
274
+ retainedBytes += len(fact.bytes)
275
+ if retainedBytes > maxObservationBytes {
276
+ return nil, ObservationAggregateTooLarge
277
+ }
278
+ facts = append(facts, fact)
279
+ snapshots = append(snapshots, snapshot)
280
+ locations = append(locations, location)
281
+ }
282
+
283
+ if err := runObservationStage(callback, observationBeforeFinalSweep, ""); err != nil {
284
+ return nil, ObservationConcurrentChange
285
+ }
286
+ if !observationRootsUnchanged(roots) {
287
+ return nil, ObservationConcurrentChange
288
+ }
289
+ for i, location := range locations {
290
+ if !artifactUnchanged(location, snapshots[i]) {
291
+ return nil, ObservationConcurrentChange
292
+ }
293
+ }
294
+
295
+ index := make(map[ArtifactPath]int, len(facts))
296
+ for i, fact := range facts {
297
+ index[fact.path] = i
298
+ }
299
+ return &WorkspaceObservation{slug: slug, facts: facts, index: index}, nil
300
+ }
301
+
302
+ func observationWorkspaceOverrideValid(root, slug string) bool {
303
+ raw := os.Getenv("DEVRITES_WORKSPACE")
304
+ if raw == "" {
305
+ return true
306
+ }
307
+ if strings.TrimSpace(raw) != raw || filepath.Clean(raw) != raw {
308
+ return false
309
+ }
310
+ if !filepath.IsAbs(raw) && !filepath.IsLocal(raw) {
311
+ return false
312
+ }
313
+ physicalRoot, err := filepath.EvalSymlinks(root)
314
+ if err != nil {
315
+ return false
316
+ }
317
+ physicalRoot, err = filepath.Abs(physicalRoot)
318
+ if err != nil {
319
+ return false
320
+ }
321
+ override := raw
322
+ if !filepath.IsAbs(override) {
323
+ override = filepath.Join(filepath.Dir(physicalRoot), override)
324
+ }
325
+ override, err = filepath.Abs(override)
326
+ if err != nil {
327
+ return false
328
+ }
329
+ want := filepath.Join(physicalRoot, "work", slug)
330
+ return filepath.Clean(override) == filepath.Clean(want)
331
+ }
332
+
333
+ func observationInventory() []ArtifactPath {
334
+ seen := make(map[ArtifactPath]bool)
335
+ inventory := make([]ArtifactPath, 0, 21)
336
+ for _, policy := range PhasePolicies() {
337
+ for _, artifact := range policy.RequiredArtifacts {
338
+ if seen[artifact] {
339
+ continue
340
+ }
341
+ seen[artifact] = true
342
+ inventory = append(inventory, artifact)
343
+ }
344
+ }
345
+ for _, artifact := range []ArtifactPath{"strategy.md", "design-brief.md", "ai-spec.md", ".devrites/principles.md"} {
346
+ if !seen[artifact] {
347
+ seen[artifact] = true
348
+ inventory = append(inventory, artifact)
349
+ }
350
+ }
351
+ return inventory
352
+ }
353
+
354
+ func openObservationRoots(rootPath, workspacePath string, rootInfo, workspaceInfo os.FileInfo) (*observationRoots, error) {
355
+ devritesRoot, err := os.OpenRoot(rootPath)
356
+ if err != nil {
357
+ return nil, err
358
+ }
359
+ workspaceRoot, err := os.OpenRoot(workspacePath)
360
+ if err != nil {
361
+ _ = devritesRoot.Close()
362
+ return nil, err
363
+ }
364
+ roots := &observationRoots{
365
+ devrites: devritesRoot,
366
+ workspace: workspaceRoot,
367
+ rootInfo: rootInfo,
368
+ workspaceInfo: workspaceInfo,
369
+ }
370
+ rootHandleInfo, rootErr := devritesRoot.Stat(".")
371
+ workspaceHandleInfo, workspaceErr := workspaceRoot.Stat(".")
372
+ if rootErr != nil || workspaceErr != nil || !sameEntry(rootInfo, rootHandleInfo) || !sameEntry(workspaceInfo, workspaceHandleInfo) {
373
+ _ = devritesRoot.Close()
374
+ _ = workspaceRoot.Close()
375
+ return nil, errors.New("observation root identity changed")
376
+ }
377
+
378
+ relativeWorkspace, err := filepath.Rel(rootPath, workspacePath)
379
+ if err != nil || relativeWorkspace == "." || filepath.IsAbs(relativeWorkspace) || strings.HasPrefix(relativeWorkspace, ".."+string(filepath.Separator)) {
380
+ _ = devritesRoot.Close()
381
+ _ = workspaceRoot.Close()
382
+ return nil, errors.New("observation workspace is outside root")
383
+ }
384
+ for _, parentName := range parentPaths(filepath.ToSlash(relativeWorkspace)) {
385
+ entry := inspectEntry(devritesRoot, filepath.FromSlash(parentName))
386
+ if entry.state != entryPresent || entry.info.Mode()&os.ModeSymlink != 0 || !entry.info.IsDir() {
387
+ _ = devritesRoot.Close()
388
+ _ = workspaceRoot.Close()
389
+ return nil, errors.New("observation workspace parent is invalid")
390
+ }
391
+ roots.workspaceParents = append(roots.workspaceParents, namedEntry{name: filepath.FromSlash(parentName), entry: entry})
392
+ }
393
+ workspaceEntry := inspectEntry(devritesRoot, relativeWorkspace)
394
+ if workspaceEntry.state != entryPresent || workspaceEntry.info.Mode()&os.ModeSymlink != 0 || !workspaceEntry.info.IsDir() || !sameEntry(workspaceInfo, workspaceEntry.info) {
395
+ _ = devritesRoot.Close()
396
+ _ = workspaceRoot.Close()
397
+ return nil, errors.New("observation workspace identity changed")
398
+ }
399
+ roots.workspaceEntry = namedEntry{name: relativeWorkspace, entry: workspaceEntry}
400
+ return roots, nil
401
+ }
402
+
403
+ func observationRootsUnchanged(roots *observationRoots) bool {
404
+ rootInfo, rootErr := roots.devrites.Stat(".")
405
+ workspaceInfo, workspaceErr := roots.workspace.Stat(".")
406
+ if rootErr != nil || workspaceErr != nil || !sameEntry(roots.rootInfo, rootInfo) || !sameEntry(roots.workspaceInfo, workspaceInfo) {
407
+ return false
408
+ }
409
+ for _, parent := range roots.workspaceParents {
410
+ if !sameObservedEntry(parent.entry, inspectEntry(roots.devrites, parent.name)) {
411
+ return false
412
+ }
413
+ }
414
+ return sameObservedEntry(roots.workspaceEntry.entry, inspectEntry(roots.devrites, roots.workspaceEntry.name))
415
+ }
416
+
417
+ func observeArtifact(location artifactLocation, callback observationCallback) (ArtifactFact, artifactSnapshot, error) {
418
+ snapshot := artifactSnapshot{}
419
+ for _, parentName := range parentPaths(filepath.ToSlash(location.name)) {
420
+ entry := inspectEntry(location.root, filepath.FromSlash(parentName))
421
+ snapshot.parents = append(snapshot.parents, namedEntry{name: filepath.FromSlash(parentName), entry: entry})
422
+ switch {
423
+ case entry.state == entryUnreadable:
424
+ return unreadableFact(location.path, entry.code), snapshot, nil
425
+ case entry.state == entryAbsent:
426
+ return ArtifactFact{path: location.path, state: ArtifactAbsent}, snapshot, nil
427
+ case entry.info.Mode()&os.ModeSymlink != 0:
428
+ return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticParentSymlink), snapshot, nil
429
+ case !entry.info.IsDir():
430
+ return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticNonRegular), snapshot, nil
431
+ }
432
+ }
433
+
434
+ entry := inspectEntry(location.root, location.name)
435
+ snapshot.final = &entry
436
+ if err := runObservationStage(callback, observationAfterInspect, location.path); err != nil {
437
+ return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
438
+ }
439
+ switch {
440
+ case entry.state == entryAbsent:
441
+ return ArtifactFact{path: location.path, state: ArtifactAbsent}, snapshot, nil
442
+ case entry.state == entryUnreadable:
443
+ return unreadableFact(location.path, entry.code), snapshot, nil
444
+ case entry.info.Mode()&os.ModeSymlink != 0:
445
+ return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticFinalSymlink), snapshot, nil
446
+ case !entry.info.Mode().IsRegular():
447
+ return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticNonRegular), snapshot, nil
448
+ case entry.info.Size() > maxArtifactBytes:
449
+ return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticFileTooLarge), snapshot, nil
450
+ }
451
+
452
+ if err := runObservationStage(callback, observationBeforeOpen, location.path); err != nil {
453
+ return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
454
+ }
455
+ file, err := openObservedFile(location.root, location.name)
456
+ if err != nil {
457
+ return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
458
+ }
459
+ defer file.Close()
460
+
461
+ openedInfo, err := file.Stat()
462
+ if err != nil {
463
+ return artifactFailureResult(location.path, snapshot, artifactUnchanged(location, snapshot), err)
464
+ }
465
+ if !sameEntry(entry.info, openedInfo) || !openedInfo.Mode().IsRegular() {
466
+ return ArtifactFact{}, snapshot, ObservationConcurrentChange
467
+ }
468
+ if err := runObservationStage(callback, observationBeforeRead, location.path); err != nil {
469
+ return artifactFailureResult(location.path, snapshot, openedFileAndPathUnchanged(file, openedInfo, location, snapshot), err)
470
+ }
471
+ raw, err := io.ReadAll(io.LimitReader(file, maxArtifactBytes+1))
472
+ if err != nil {
473
+ return artifactFailureResult(location.path, snapshot, openedFileAndPathUnchanged(file, openedInfo, location, snapshot), err)
474
+ }
475
+ if err := runObservationStage(callback, observationAfterRead, location.path); err != nil {
476
+ return artifactFailureResult(location.path, snapshot, openedFileAndPathUnchanged(file, openedInfo, location, snapshot), err)
477
+ }
478
+ postReadInfo, err := file.Stat()
479
+ if err != nil || !sameEntry(openedInfo, postReadInfo) || int64(len(raw)) != postReadInfo.Size() || !artifactUnchanged(location, snapshot) {
480
+ return ArtifactFact{}, snapshot, ObservationConcurrentChange
481
+ }
482
+ if len(raw) > maxArtifactBytes {
483
+ return diagnosticFact(location.path, ArtifactUnsafe, DiagnosticFileTooLarge), snapshot, nil
484
+ }
485
+ state, code := classifyArtifact(raw)
486
+ return ArtifactFact{path: location.path, state: state, bytes: copyArtifactBytes(raw), code: code}, snapshot, nil
487
+ }
488
+
489
+ func artifactFailureResult(path ArtifactPath, snapshot artifactSnapshot, unchanged bool, cause error) (ArtifactFact, artifactSnapshot, error) {
490
+ if !unchanged {
491
+ return ArtifactFact{}, snapshot, ObservationConcurrentChange
492
+ }
493
+ return unreadableFact(path, unreadableDiagnostic(cause)), snapshot, nil
494
+ }
495
+
496
+ func openedFileAndPathUnchanged(file *os.File, openedInfo os.FileInfo, location artifactLocation, snapshot artifactSnapshot) bool {
497
+ currentInfo, err := file.Stat()
498
+ return err == nil && sameEntry(openedInfo, currentInfo) && artifactUnchanged(location, snapshot)
499
+ }
500
+
501
+ func artifactUnchanged(location artifactLocation, snapshot artifactSnapshot) bool {
502
+ for _, parent := range snapshot.parents {
503
+ if !sameObservedEntry(parent.entry, inspectEntry(location.root, parent.name)) {
504
+ return false
505
+ }
506
+ }
507
+ if snapshot.final == nil {
508
+ return true
509
+ }
510
+ return sameObservedEntry(*snapshot.final, inspectEntry(location.root, location.name))
511
+ }
512
+
513
+ func inspectEntry(root *os.Root, name string) observedEntry {
514
+ info, err := root.Lstat(name)
515
+ if errors.Is(err, fs.ErrNotExist) {
516
+ return observedEntry{state: entryAbsent}
517
+ }
518
+ if err != nil {
519
+ return observedEntry{state: entryUnreadable, code: unreadableDiagnostic(err)}
520
+ }
521
+ return observedEntry{state: entryPresent, info: info}
522
+ }
523
+
524
+ func sameObservedEntry(left, right observedEntry) bool {
525
+ if left.state != right.state || left.code != right.code {
526
+ return false
527
+ }
528
+ if left.state != entryPresent {
529
+ return true
530
+ }
531
+ return sameEntry(left.info, right.info)
532
+ }
533
+
534
+ func sameEntry(left, right os.FileInfo) bool {
535
+ return left != nil && right != nil && left.Mode().Type() == right.Mode().Type() && left.Size() == right.Size() && os.SameFile(left, right)
536
+ }
537
+
538
+ func parentPaths(name string) []string {
539
+ clean := path.Clean(name)
540
+ if clean == "." || clean == "/" {
541
+ return nil
542
+ }
543
+ parts := strings.Split(clean, "/")
544
+ parents := make([]string, 0, len(parts)-1)
545
+ for i := 1; i < len(parts); i++ {
546
+ parents = append(parents, strings.Join(parts[:i], "/"))
547
+ }
548
+ return parents
549
+ }
550
+
551
+ func classifyArtifact(raw []byte) (ArtifactState, DiagnosticCode) {
552
+ if _, err := markdowntext.Structural(raw); err != nil {
553
+ return ArtifactMalformed, DiagnosticMalformedMarkdown
554
+ }
555
+ body := stripFrontmatter(raw)
556
+ for _, line := range strings.Split(string(body), "\n") {
557
+ if !blankOrHash(strings.TrimSpace(line)) {
558
+ return ArtifactPresent, ""
559
+ }
560
+ }
561
+ return ArtifactEmpty, ""
562
+ }
563
+
564
+ func diagnosticFact(path ArtifactPath, state ArtifactState, code DiagnosticCode) ArtifactFact {
565
+ return ArtifactFact{path: path, state: state, code: code}
566
+ }
567
+
568
+ func unreadableFact(path ArtifactPath, code DiagnosticCode) ArtifactFact {
569
+ return diagnosticFact(path, ArtifactUnreadable, code)
570
+ }
571
+
572
+ func unreadableDiagnostic(err error) DiagnosticCode {
573
+ if errors.Is(err, fs.ErrPermission) {
574
+ return DiagnosticPermissionDenied
575
+ }
576
+ return DiagnosticReadFailure
577
+ }
578
+
579
+ func runObservationStage(callback observationCallback, stage observationStage, path ArtifactPath) error {
580
+ if callback == nil {
581
+ return nil
582
+ }
583
+ return callback(stage, path)
584
+ }
@@ -0,0 +1,9 @@
1
+ //go:build !unix
2
+
3
+ package state
4
+
5
+ import "os"
6
+
7
+ func openObservedFile(root *os.Root, name string) (*os.File, error) {
8
+ return root.OpenFile(name, os.O_RDONLY, 0)
9
+ }
@@ -0,0 +1,12 @@
1
+ //go:build unix
2
+
3
+ package state
4
+
5
+ import (
6
+ "os"
7
+ "syscall"
8
+ )
9
+
10
+ func openObservedFile(root *os.Root, name string) (*os.File, error) {
11
+ return root.OpenFile(name, os.O_RDONLY|syscall.O_NONBLOCK, 0)
12
+ }