space-architect 8.0.0 → 9.0.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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/README.md +53 -243
  4. data/lib/space_architect/bug_report.rb +2 -1
  5. data/lib/space_architect/cli/architect.rb +20 -7
  6. data/lib/space_architect/cli.rb +9 -14
  7. data/lib/space_architect/session_sync/bin_path.rb +1 -1
  8. data/lib/space_architect/session_sync/cursor.rb +1 -1
  9. data/lib/space_architect/session_sync/plist.rb +1 -1
  10. data/lib/{space_core → space_architect}/version.rb +2 -2
  11. data/lib/space_architect.rb +1 -1
  12. data/skill/architect/SKILL.md +3 -2
  13. data/skill/architect-vocabulary/SKILL.md +2 -2
  14. metadata +18 -177
  15. data/exe/space +0 -13
  16. data/exe/src +0 -13
  17. data/lib/space_architect/cli/space.rb +0 -29
  18. data/lib/space_architect/cli/src.rb +0 -38
  19. data/lib/space_core/atomic_write.rb +0 -21
  20. data/lib/space_core/cli/base_command.rb +0 -19
  21. data/lib/space_core/cli/build.rb +0 -27
  22. data/lib/space_core/cli/config.rb +0 -49
  23. data/lib/space_core/cli/current.rb +0 -16
  24. data/lib/space_core/cli/help.rb +0 -195
  25. data/lib/space_core/cli/helpers.rb +0 -115
  26. data/lib/space_core/cli/init.rb +0 -29
  27. data/lib/space_core/cli/list.rb +0 -24
  28. data/lib/space_core/cli/loop_status.rb +0 -47
  29. data/lib/space_core/cli/new.rb +0 -38
  30. data/lib/space_core/cli/pack.rb +0 -29
  31. data/lib/space_core/cli/path.rb +0 -16
  32. data/lib/space_core/cli/repeatable_options.rb +0 -84
  33. data/lib/space_core/cli/repo.rb +0 -76
  34. data/lib/space_core/cli/run.rb +0 -44
  35. data/lib/space_core/cli/shell.rb +0 -125
  36. data/lib/space_core/cli/show.rb +0 -21
  37. data/lib/space_core/cli/status.rb +0 -74
  38. data/lib/space_core/cli/use.rb +0 -17
  39. data/lib/space_core/cli.rb +0 -177
  40. data/lib/space_core/commands.rb +0 -51
  41. data/lib/space_core/config.rb +0 -152
  42. data/lib/space_core/errors.rb +0 -14
  43. data/lib/space_core/git_client.rb +0 -49
  44. data/lib/space_core/mise_client.rb +0 -37
  45. data/lib/space_core/oci_builder.rb +0 -56
  46. data/lib/space_core/oci_packer.rb +0 -103
  47. data/lib/space_core/oci_runner.rb +0 -89
  48. data/lib/space_core/paths.rb +0 -58
  49. data/lib/space_core/repo_reference.rb +0 -19
  50. data/lib/space_core/repo_resolver.rb +0 -167
  51. data/lib/space_core/shell_integration.rb +0 -438
  52. data/lib/space_core/slugger.rb +0 -16
  53. data/lib/space_core/space.rb +0 -174
  54. data/lib/space_core/space_store.rb +0 -319
  55. data/lib/space_core/state.rb +0 -86
  56. data/lib/space_core/templates/oci/dockerfile.erb +0 -73
  57. data/lib/space_core/templates/oci/dockerignore.erb +0 -17
  58. data/lib/space_core/templates/oci/entrypoint.sh.erb +0 -16
  59. data/lib/space_core/terminal.rb +0 -161
  60. data/lib/space_core/warnings.rb +0 -13
  61. data/lib/space_core/xdg.rb +0 -33
  62. data/lib/space_core.rb +0 -26
  63. data/lib/space_src/cli/clone.rb +0 -54
  64. data/lib/space_src/cli/config.rb +0 -65
  65. data/lib/space_src/cli/daemon.rb +0 -362
  66. data/lib/space_src/cli/options.rb +0 -21
  67. data/lib/space_src/cli/org.rb +0 -199
  68. data/lib/space_src/cli/repo.rb +0 -169
  69. data/lib/space_src/cli/shell.rb +0 -121
  70. data/lib/space_src/cli/status.rb +0 -75
  71. data/lib/space_src/cli/sync.rb +0 -161
  72. data/lib/space_src/cli.rb +0 -159
  73. data/lib/space_src/cloner.rb +0 -77
  74. data/lib/space_src/config/contract.rb +0 -54
  75. data/lib/space_src/config/duration.rb +0 -79
  76. data/lib/space_src/config/model.rb +0 -49
  77. data/lib/space_src/config/store.rb +0 -156
  78. data/lib/space_src/forge/client.rb +0 -31
  79. data/lib/space_src/forge/github.rb +0 -98
  80. data/lib/space_src/launchd/agent.rb +0 -195
  81. data/lib/space_src/launchd/plist.rb +0 -129
  82. data/lib/space_src/log_rotator.rb +0 -46
  83. data/lib/space_src/migration.rb +0 -43
  84. data/lib/space_src/nav.rb +0 -99
  85. data/lib/space_src/paths.rb +0 -72
  86. data/lib/space_src/scm/client.rb +0 -87
  87. data/lib/space_src/scm/git.rb +0 -232
  88. data/lib/space_src/scm/status.rb +0 -24
  89. data/lib/space_src/shell.rb +0 -90
  90. data/lib/space_src/shell_integration.rb +0 -321
  91. data/lib/space_src/state/lock.rb +0 -59
  92. data/lib/space_src/state/store.rb +0 -140
  93. data/lib/space_src/sync/engine.rb +0 -503
  94. data/lib/space_src/sync/repo_plan.rb +0 -215
  95. data/lib/space_src/sync/report.rb +0 -18
  96. data/lib/space_src/ui/interactive_reporter.rb +0 -280
  97. data/lib/space_src/ui/json_reporter.rb +0 -39
  98. data/lib/space_src/ui/mode.rb +0 -68
  99. data/lib/space_src/ui/plain_reporter.rb +0 -53
  100. data/lib/space_src/ui/reporter.rb +0 -48
  101. data/lib/space_src/version.rb +0 -7
  102. data/lib/space_src.rb +0 -38
@@ -1,161 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/monads"
4
- require "space_src/cli/repo" # for Repo::Helpers.parse_ref
5
- require "space_src/cli/options"
6
- require "space_src/ui/mode"
7
- require "space_src/ui/plain_reporter"
8
- require "space_src/ui/json_reporter"
9
- require "space_src/ui/interactive_reporter"
10
-
11
- module Space::Src
12
- module CLI
13
- # `sync` command: invoke Sync::Engine over the full config, or
14
- # scope to a single repo with --repo.
15
- #
16
- # Scoping is implemented at the CLI layer (per gate G4): the
17
- # CLI builds a filtered Config (Config::Store.with(config,
18
- # repos: [match], orgs: [])) and passes it to the unchanged
19
- # engine. Sync::Engine#call is (config:, paths:) — there is no
20
- # scoping parameter on the engine, and the spec forbids editing
21
- # sync/engine.rb in this slice.
22
- module Sync
23
- class Run < Dry::CLI::Command
24
- include GlobalOptions
25
-
26
- desc "Run one sync pass (use --repo to scope to a single tracked repo)"
27
- option :repo, desc: "Scope to a single tracked repo (host/owner/name)"
28
-
29
- def call(repo: nil, plain: nil, json: nil, no_color: nil, quiet: nil, **)
30
- paths = CLI.make_paths
31
- paths.ensure!
32
- config = Config::Store.load(paths.config_file).success
33
-
34
- # Log rotation pre-step (slice-4 gate G5). launchd owns
35
- # the stdout/stderr redirect via StandardOutPath /
36
- # StandardErrorPath; the sync process rotates those
37
- # files at the start of each run so the previous run's
38
- # log doesn't grow unbounded. The rotator renames the
39
- # file to a timestamped archive (preserving bytes); the
40
- # current process's inherited fd still points to the
41
- # renamed file, so writes during this run succeed.
42
- # launchd opens a fresh file at the original path on
43
- # the next spawn. No-op when the log is missing or
44
- # under-threshold (sync tests in G4 stay green).
45
- rotate_plist_logs(paths)
46
-
47
- # Resolved BEFORE the scoping block: the "scoping sync to:"
48
- # notice below is prose, and must be suppressed in JSON mode
49
- # for the same reason the summary line is. Resolution depends
50
- # only on flags/env/out, so it is safe this early.
51
- mode = UI::Mode.resolve(
52
- flags: {plain: plain, json: json, no_color: no_color, quiet: quiet},
53
- env: CLI.env,
54
- out: out
55
- )
56
- # Every line of --json output must parse as one JSON object.
57
- human = mode.format != :json
58
-
59
- if repo
60
- target = scope_target(repo)
61
- return fail_with(self, "invalid repo reference: #{repo.inspect} (expected host/owner/name)") if target.failure?
62
-
63
- match = target.success
64
- found = config.repos.find { |r| Repo::Helpers.same_repo?(r, match) }
65
- if found.nil?
66
- return fail_with(self, "no such tracked repo: #{Repo::Helpers.format_ref(match)}")
67
- end
68
- # Filtered config: only the one matched repo, no orgs
69
- # (org expansion would discover other repos — that's
70
- # exactly the G4 "other repo gets no state row" test
71
- # path, so we explicitly empty orgs here).
72
- config = Config::Store.with(config, repos: [found], orgs: [])
73
- out.puts "scoping sync to: #{Repo::Helpers.format_ref(found)}" if human
74
- end
75
-
76
- reporter = if mode.format == :json
77
- UI::JsonReporter.new(out)
78
- elsif mode.animate
79
- UI::InteractiveReporter.new(out, mode: mode)
80
- else
81
- UI::PlainReporter.new(out, mode: mode)
82
- end
83
-
84
- # prune only on a full sweep. With --repo the config above was
85
- # narrowed to one repo, so pruning would delete the state rows
86
- # for every OTHER tracked repo.
87
- result = Space::Src::Sync::Engine.new(reporter: reporter)
88
- .call(config: config, paths: paths, prune: repo.nil?)
89
- if result.failure?
90
- return fail_with(self, "sync failed: #{format_failure(result.failure)}")
91
- end
92
-
93
- report = result.success
94
- # JsonReporter already emits a run_finished event carrying the
95
- # summary, so suppressing the prose line loses nothing.
96
- out.puts "synced #{report.processed} repo(s)" if human
97
- CLI.record_outcome(Outcome.new(exit_code: 0))
98
- end
99
-
100
- private
101
-
102
- def scope_target(repo)
103
- Repo::Helpers.parse_ref(repo)
104
- end
105
-
106
- def format_failure(f) = f.is_a?(Hash) ? f.inspect : f.to_s
107
-
108
- def fail_with(cmd, msg)
109
- cmd.send(:err).puts msg
110
- Space::Src::CLI.record_outcome(Outcome.new(exit_code: 1, message: msg))
111
- end
112
-
113
- # Default log-rotation threshold: 10 MiB. Tunable via the
114
- # env var `SPACE_SRC_LOG_MAX_BYTES` (introspection /
115
- # ops escape hatch). The LogRotator itself is unit-tested
116
- # with an injected threshold (gate G5).
117
- DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024
118
-
119
- def rotate_plist_logs(paths)
120
- threshold = log_max_bytes
121
- label = Launchd::Agent::DEFAULT_LABEL
122
- [File.join(paths.log_dir, "#{label}.out.log"),
123
- File.join(paths.log_dir, "#{label}.err.log")].each do |p|
124
- Space::Src::LogRotator.call(p, threshold_bytes: threshold)
125
- end
126
- end
127
-
128
- # CF6 (Slice 5): defensively parse the
129
- # `SPACE_SRC_LOG_MAX_BYTES` env var so a malformed
130
- # operator value (e.g. `"10MB"`) falls back to the
131
- # 10 MiB default instead of raising `ArgumentError`
132
- # and crashing the entire `sync` run before any repo
133
- # work.
134
- #
135
- # Accepted: any positive integer in base 10
136
- # (e.g. `"1048576"`, `" 524288 "`). Falls back to
137
- # `DEFAULT_LOG_MAX_BYTES` (and emits a single
138
- # `Kernel#warn` to stderr) for: unset, empty,
139
- # whitespace, non-numeric (`"10MB"`, `"abc"`), zero,
140
- # and negative inputs. Never raises.
141
- #
142
- # The optional `env_value` arg exists so the unit
143
- # tests can pass arbitrary values without mutating
144
- # the real `ENV`; production callers invoke with
145
- # no args and the method reads `ENV` itself.
146
- def log_max_bytes(env_value = ENV["SPACE_SRC_LOG_MAX_BYTES"])
147
- return DEFAULT_LOG_MAX_BYTES if env_value.nil? || env_value.strip.empty?
148
-
149
- parsed = Integer(env_value, 10, exception: false)
150
- return parsed if parsed.is_a?(Integer) && parsed.positive?
151
-
152
- warn "src: SPACE_SRC_LOG_MAX_BYTES=#{env_value.inspect} is invalid; " \
153
- "falling back to #{DEFAULT_LOG_MAX_BYTES} bytes"
154
- DEFAULT_LOG_MAX_BYTES
155
- end
156
- end
157
- end
158
- end
159
- end
160
-
161
- Space::Src::CLI::Registry.register "sync", Space::Src::CLI::Sync::Run
data/lib/space_src/cli.rb DELETED
@@ -1,159 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/cli"
4
- require "space_src/migration"
5
-
6
- module Space::Src
7
- # CLI surface — thin translation layer between argv and the
8
- # existing Config::Store / State::Store / Sync::Engine boundaries.
9
- #
10
- # The CLI never mutates a git repo directly; repo mutation happens
11
- # inside the engine, which already upholds the no-data-loss
12
- # invariant (PRD §1). The CLI's job is:
13
- # 1. parse argv (via Dry::CLI's nested `register`)
14
- # 2. load/mutate validated config (Config::Store) OR delegate to
15
- # the engine / state store
16
- # 3. translate Result to: out/err message + exit code
17
- #
18
- # Exit-code seam: each command records an `Outcome(exit_code:,
19
- # message:)` (the thread-local stash) and writes the user-facing
20
- # message to `out`/`err` via the injected IOs. The `exe/src`
21
- # entrypoint reads the recorded Outcome and calls Kernel.exit with
22
- # the code — see CLI.run below. Tests can inspect last_outcome
23
- # in-process (no subprocess needed for unit tests); a subprocess
24
- # Open3.capture3 covers the G3 "real exit" proof.
25
- module CLI
26
- # The Outcome value object. `exit_code` is 0 for success and 1
27
- # for Failure-derived failures. `message` is the user-facing
28
- # explanation already written to err (kept here so the
29
- # entrypoint could log/record it in a future slice).
30
- Outcome = Data.define(:exit_code, :message) do
31
- def initialize(exit_code:, message: nil)
32
- super
33
- end
34
- end
35
-
36
- # Thread-local env hash. Defaults to ENV. Tests inject a temp
37
- # HOME / XDG_* hash via Thread.current[:space_src_cli_env] =
38
- # env_hash. The CLI's `make_paths` reads this to resolve the
39
- # config/state file locations under the test's temp home.
40
- def self.env
41
- Thread.current[:space_src_cli_env] || ENV
42
- end
43
-
44
- # Thread-local Outcome stash. The most recent command's Outcome
45
- # is read by CLI.run to set the process exit code.
46
- def self.record_outcome(outcome)
47
- Thread.current[:space_src_cli_outcome] = outcome
48
- end
49
-
50
- def self.last_outcome
51
- Thread.current[:space_src_cli_outcome]
52
- end
53
-
54
- # Program-name-level invocations that must succeed (exit 0) with
55
- # output on stdout. Dry::CLI has no root command registered, so it
56
- # would otherwise route these through its "command not found" path
57
- # (Usage → stderr → exit 1). We intercept ONLY the exact top-level
58
- # forms here; a *leaf* help like `sync --help` (argv ["sync",
59
- # "--help"]) and a *group* like `repo` / `repo --help` are NOT
60
- # matched, so Dry::CLI keeps handling them as before (leaf help →
61
- # stdout/exit 0; group → usage/stderr/exit 1, accepted per G7).
62
- TOP_LEVEL_HELP = [[], ["--help"], ["-h"], ["help"]].freeze
63
- VERSION_REQUEST = [["version"], ["--version"]].freeze
64
-
65
- # True when argv is a single token that is not a registered top-level
66
- # command or group, and not already handled by the help/version intercepts.
67
- # Both CLI.run and dispatch_src delegate to this shared predicate so the
68
- # routing logic is defined exactly once.
69
- def self.bare_query?(argv)
70
- return false if TOP_LEVEL_HELP.include?(argv)
71
- return false if VERSION_REQUEST.include?(argv)
72
- # paths:exempt - Registry.get([]).children is a dry-cli command-tree node, not a filesystem path
73
- argv.length == 1 && !Registry.get([]).children.key?(argv[0])
74
- end
75
-
76
- # Entrypoint. Called by exe/src. Intercepts the top-level
77
- # help/version forms (stdout, exit 0), otherwise hands argv to
78
- # Dry::CLI for command dispatch and translates the last Outcome to
79
- # a process exit code. A `Interrupt` raised from inside command
80
- # dispatch (most commonly: a SIGINT during a long-running
81
- # `Shell.run`, e.g. at a `git` username prompt or mid-clone) is
82
- # caught here and mapped to a clean exit code 130 (128 + SIGINT)
83
- # with a single human line on stderr — the G2 ^C-hygiene fix
84
- # (Slice 6). The reader-thread `IOError` noise that Open3 emits
85
- # in the same scenario is suppressed at the `Shell.run` seam
86
- # (see `lib/space_src/shell.rb`).
87
- def self.run(argv, stdout, stderr)
88
- return print_usage(stdout) if TOP_LEVEL_HELP.include?(argv)
89
- return print_version(stdout) if VERSION_REQUEST.include?(argv)
90
-
91
- Migration.run(paths: make_paths, err: stderr)
92
-
93
- if bare_query?(argv)
94
- paths = make_paths
95
- config = Config::Store.load(paths.config_file).success
96
- exit_code = Nav.dispatch(argv[0], stdout, stderr, config.base_dir)
97
- Kernel.exit(exit_code)
98
- end
99
-
100
- begin
101
- Dry::CLI.new(Registry).call(arguments: argv, out: stdout, err: stderr)
102
- outcome = last_outcome
103
- Kernel.exit(outcome&.exit_code || 0)
104
- rescue Interrupt
105
- # Map a user ^C to a clean exit-130 with a single human line.
106
- # `Kernel.exit` raises `SystemExit` (callers/tests can rescue
107
- # it to inspect the status). The `at_exit` handlers run,
108
- # stdio is flushed, the process exits with code 130. We do
109
- # NOT blanket-rescue `StandardError` and we do NOT make
110
- # non-interrupt failures exit 0 (the outcome-translation path
111
- # above is unchanged for the happy / non-Interrupt failure
112
- # paths).
113
- stderr.puts "interrupted"
114
- Kernel.exit(130)
115
- end
116
- end
117
-
118
- # Render the top-level command-group listing (reusing Dry::CLI's
119
- # own Usage formatter so it stays in sync with the registry) to
120
- # stdout and exit 0.
121
- def self.print_usage(stdout)
122
- stdout.puts Dry::CLI::Usage.call(Registry.get([]))
123
- Kernel.exit(0)
124
- end
125
-
126
- # Print the gem version to stdout and exit 0.
127
- def self.print_version(stdout)
128
- stdout.puts Space::Src::VERSION
129
- Kernel.exit(0)
130
- end
131
-
132
- # Internal: build a Paths instance scoped to the active env
133
- # (Thread.current[:space_src_cli_env] || ENV). Every command
134
- # uses this so tests can inject a temp home without mutating
135
- # the real ENV.
136
- def self.make_paths
137
- Paths.new(environment: env)
138
- end
139
-
140
- # The Dry::CLI::Registry-extended module. The subcommand files
141
- # (cli/repo.rb, cli/org.rb, …) call `register "x" do |p| ... end`
142
- # on this module at load time.
143
- module Registry
144
- extend Dry::CLI::Registry
145
- end
146
- end
147
- end
148
-
149
- # Subcommand files — each defines its command classes and
150
- # registers them under their group prefix.
151
- require "space_src/cli/repo"
152
- require "space_src/cli/org"
153
- require "space_src/cli/sync"
154
- require "space_src/cli/status"
155
- require "space_src/cli/config"
156
- require "space_src/cli/daemon"
157
- require "space_src/cli/clone"
158
- require "space_src/cli/shell"
159
- require "space_src/nav"
@@ -1,77 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "async"
4
- require "dry/monads"
5
- require "space_src/shell"
6
-
7
- module Space::Src
8
- # Resolution + COW-copy boundary for `clone`. Returns Result; no
9
- # side effects on Failure. Injected shell seam defaults to ShellRunner
10
- # (wraps Shell.run in Sync{} so the Fiber-scheduler requirement is met
11
- # from a plain synchronous CLI command — same pattern as Launchd::Agent).
12
- class Cloner
13
- include Dry::Monads[:result]
14
-
15
- class ShellRunner
16
- def run(*argv)
17
- Sync { Space::Src::Shell.run(*argv) }
18
- end
19
- end
20
-
21
- def initialize(base_dir:, shell: ShellRunner.new)
22
- @base_dir = File.expand_path(base_dir)
23
- @shell = shell
24
- end
25
-
26
- # Resolve `name` against `base_dir` and copy to `into/<leaf>`.
27
- # Returns Success(dest_path) or Failure(message).
28
- def call(name:, into: ".")
29
- result = resolve(name)
30
- return result if result.failure?
31
- copy(result.success, into)
32
- end
33
-
34
- private
35
-
36
- def resolve(name)
37
- parts = name.split("/")
38
- case parts.length
39
- when 1
40
- # paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
41
- candidates = Dir.glob(File.join(@base_dir, "*", "*", name))
42
- case candidates.length
43
- when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
44
- when 1 then Success(candidates.first)
45
- else ambiguous(name, candidates, "owner/name or host/owner/name")
46
- end
47
- when 2
48
- owner, repo_name = parts
49
- # paths:exempt - layout intent (checkout resolution by depth); space_src is deliberately independent of space_core and must not require it
50
- candidates = Dir.glob(File.join(@base_dir, "*", owner, repo_name))
51
- case candidates.length
52
- when 0 then Failure("#{name.inspect} not found under base_dir #{@base_dir}")
53
- when 1 then Success(candidates.first)
54
- else ambiguous(name, candidates, "host/owner/name")
55
- end
56
- when 3
57
- path = File.join(@base_dir, *parts)
58
- File.directory?(path) ? Success(path) : Failure("#{name.inspect} not found under base_dir #{@base_dir}")
59
- else
60
- Failure("invalid repo reference: #{name.inspect}")
61
- end
62
- end
63
-
64
- def ambiguous(name, candidates, hint)
65
- list = candidates.map { |c| " #{c.delete_prefix("#{@base_dir}/")}" }.join("\n")
66
- Failure("ambiguous name #{name.inspect}; qualify with #{hint}:\n#{list}")
67
- end
68
-
69
- def copy(src, into)
70
- leaf = File.basename(src)
71
- dest = File.join(File.expand_path(into), leaf)
72
- return Failure("destination already exists: #{dest}") if File.exist?(dest)
73
- result = @shell.run("cp", "-Rc", src, dest)
74
- result.success? ? Success(dest) : result
75
- end
76
- end
77
- end
@@ -1,54 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/validation"
4
- require "dry/validation/extensions/monads"
5
- require "dry/monads"
6
-
7
- module Space::Src
8
- module Config
9
- # Validates the raw YAML hash before it is built into a Config struct.
10
- # Returns a Dry::Monads::Result (via the :monads extension):
11
- # Success(validated_hash) — keys are coerced + bad keys dropped
12
- # Failure(errors_to_h) — field-level messages keyed by path
13
- #
14
- # Per gate G2, each rejection case (missing required field, bad
15
- # refresh_interval, non-integer concurrency, malformed repo entry,
16
- # malformed org entry) must produce a Failure with a field-level
17
- # message — verified by Config::ContractTest.
18
- class Contract < Dry::Validation::Contract
19
- include Dry::Monads[:result]
20
-
21
- schema do
22
- optional(:base_dir).filled(:string)
23
- optional(:refresh_interval).filled(:integer, gt?: 0)
24
- optional(:concurrency).filled(:integer, gt?: 0)
25
-
26
- optional(:repos).array(:hash) do
27
- optional(:host).filled(:string)
28
- required(:owner).filled(:string)
29
- required(:name).filled(:string)
30
- end
31
-
32
- optional(:orgs).array(:hash) do
33
- optional(:host).filled(:string)
34
- required(:name).filled(:string)
35
- optional(:include_archived).filled(:bool)
36
- optional(:include_forks).filled(:bool)
37
- optional(:ignored_repos).array(:string)
38
- end
39
- end
40
-
41
- # Override call to return a Dry::Monads::Result. Dry-validation's
42
- # monads extension gives us Result#to_monad; we map to our own
43
- # namespace so callers see a single Result type at every boundary.
44
- def call(input)
45
- result = super
46
- if result.success?
47
- Success(result.to_h)
48
- else
49
- Failure(result.errors.to_h)
50
- end
51
- end
52
- end
53
- end
54
- end
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/monads"
4
-
5
- module Space::Src
6
- module Config
7
- # CF1: parse a human-duration string into integer seconds.
8
- #
9
- # The contract and model keep `refresh_interval` as an integer
10
- # (types::Integer.constrained(gt: 0)); this module is the load-
11
- # layer normalization that lets a hand-edited config.yaml contain
12
- # "6h" / "90m" / "45s" / "30d" and have it round-trip through
13
- # Config::Store.load as 21600 / 5400 / 45 / 2592000 — so a user
14
- # who writes `refresh_interval: 6h` in their config gets the
15
- # same effect as `refresh_interval: 21600` without ever touching
16
- # the contract.
17
- #
18
- # The write-back path emits integer seconds (the human form is
19
- # not preserved on rewrite — consistent with Slice 1's documented
20
- # YAML comment-loss limitation, see test_write_emits_only_managed_fields).
21
- #
22
- # Usage:
23
- # Duration.parse("6h") # => Success(21600)
24
- # Duration.parse(21600) # => Success(21600)
25
- # Duration.parse("-3h") # => Failure("invalid duration: \"-3h\"")
26
- # Duration.parse("6x") # => Failure("invalid duration: \"6x\"")
27
- module Duration
28
- extend Dry::Monads[:result]
29
-
30
- # Unit suffixes recognized (PRD §3.1 documents "6h" / "90m" /
31
- # integer seconds; we also accept "s" and "d" for completeness
32
- # — they're natural extensions and cost zero code).
33
- UNIT_SECONDS = {
34
- nil => 1, # bare integer string ("21600") or Integer input
35
- "s" => 1,
36
- "m" => 60,
37
- "h" => 3600,
38
- "d" => 86_400
39
- }.freeze
40
-
41
- # Strictly positive integer (no sign, no decimal, no leading
42
- # zeros that change the value's magnitude). Bare integer
43
- # strings ("21600") are accepted by making the unit suffix
44
- # optional in the pattern.
45
- PATTERN = /\A(\d+)([smhd])?\z/
46
-
47
- def self.parse(value)
48
- case value
49
- when Integer
50
- return failure_for(value) if value <= 0
51
- Success(value)
52
- when String
53
- parse_string(value)
54
- else
55
- failure_for(value)
56
- end
57
- end
58
-
59
- def self.parse_string(str)
60
- s = str.strip
61
- return failure_for(str) if s.empty?
62
- m = PATTERN.match(s)
63
- return failure_for(str) unless m
64
- n = m[1].to_i
65
- unit = m[2]
66
- # The pattern guarantees n is a positive integer ("\d+" matches
67
- # 1+ digits, never "0" with no other digits… well, "0" would
68
- # match with n=0). Reject zero explicitly to keep the
69
- # contract's "gt?: 0" guarantee.
70
- return failure_for(str) if n <= 0
71
- Success(n * UNIT_SECONDS.fetch(unit))
72
- end
73
-
74
- def self.failure_for(value)
75
- Failure("invalid duration: #{value.inspect} (expected positive integer or \"<n>[s|m|h|d]\" e.g. \"6h\", \"90m\", \"21600\")")
76
- end
77
- end
78
- end
79
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry/struct"
4
- require "dry/types"
5
-
6
- module Space::Src
7
- module Config
8
- Types = Dry.Types()
9
-
10
- # Default host for repo/org entries. Per PRD §3.1, host is omitted in
11
- # the YAML when the user means github.com.
12
- DEFAULT_HOST = "github.com"
13
- # Per PRD §3.1, default refresh_interval is 6h. Stored as integer seconds.
14
- DEFAULT_REFRESH_INTERVAL = 6 * 3600
15
- DEFAULT_CONCURRENCY = 8
16
-
17
- class RepoRef < Dry::Struct
18
- transform_keys(&:to_sym)
19
-
20
- attribute :host, Types::String.default(DEFAULT_HOST)
21
- attribute :owner, Types::String
22
- attribute :name, Types::String
23
- end
24
-
25
- class OrgRef < Dry::Struct
26
- transform_keys(&:to_sym)
27
-
28
- attribute :host, Types::String.default(DEFAULT_HOST)
29
- attribute :name, Types::String
30
- attribute :include_archived, Types::Bool.default(false)
31
- attribute :include_forks, Types::Bool.default(false)
32
- attribute :ignored_repos, Types::Array.of(Types::String).default([].freeze)
33
- end
34
-
35
- # Top-level config. The on-disk schema per PRD §3.1:
36
- # base_dir, refresh_interval, concurrency, repos, orgs.
37
- # `base_dir` has no struct-level default — the value is set from
38
- # Paths::DEFAULT_BASE_DIR at store-load time (see Config::Store).
39
- class Config < Dry::Struct
40
- transform_keys(&:to_sym)
41
-
42
- attribute :base_dir, Types::String
43
- attribute :refresh_interval, Types::Integer.constrained(gt: 0)
44
- attribute :concurrency, Types::Integer.constrained(gt: 0)
45
- attribute :repos, Types::Array.of(RepoRef).default([].freeze)
46
- attribute :orgs, Types::Array.of(OrgRef).default([].freeze)
47
- end
48
- end
49
- end