ctx_parser 0.2.4 → 0.2.6

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 19faa09b189e01bb0d76ad334a769bebe9c54fb2cc05bea40e335f81f02d2764
4
- data.tar.gz: 1484eafe0166f0141d7dfdf3709364ef7cea19dca5cc56c4e2356711504f2876
3
+ metadata.gz: 488dca1bfd052afe8a7719c6e5c89d4b2b5c86f189243c02b651438ce45a69c1
4
+ data.tar.gz: 33e7aa374cc8285c6dbb76ff4c9d87849116c3fa1a61ddb1ee720d108baed8ee
5
5
  SHA512:
6
- metadata.gz: 8bbc87c894cfe9c6db52ebcacbce0c0ceda4c1e979a670d2c63b7b2f80402bc91a8afa83987b3b3945c4c2e02481ffea80cf7b9bc9dfc6704489afbd2f196a17
7
- data.tar.gz: 4d387bb4fb40ed03cd08b719064c33fa88978cccc1274d46236bc8b4a965cf07aa18aceab92f5f37d9cbce571852f202ad5fa08de532d98ea2f8fc082230c891
6
+ metadata.gz: 8459a636b16bdbe34e4446530714b1f686daa9bab80ca0cb713fdff9422066a388a7410dae7d083126c92415c8cb97f479a321da91d6113feb947cd03158bff6
7
+ data.tar.gz: ae3de9681c49c1d4be422c3b4b7c1fc47aeb0c152cb740370846fc3f80122435b184de6f2c977c3dac06d848c04a2c2d7191ad54d286214eee027c67d0b48abc
data/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  Changes to the public contract are recorded here. Versions follow Semantic Versioning.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.2.6 (2026-10-21)
8
+
9
+ - Build immutable hash snapshots without temporary field-pair arrays, retaining ordinary key equality, cycle rejection and deep-tree support.
10
+
11
+ - Add repository-specific AGENTS.md guidance for responsibilities, Ruby conventions, evidence boundaries and validation.
12
+ - Snapshot requested profile views before parser callbacks can mutate caller selections.
13
+ - Avoid redundant UTF-8 conversion and line-ending replacement for already normalized inputs while preserving mutable detached return values.
14
+
15
+ ## 0.2.5 (2026-10-19)
16
+
17
+ - Keep offline example facts and configuration benchmark inputs independent of Ruby's global input and output separators.
18
+ - Reuse validated dynamic command directories and membership indexes when their complete contents are frozen, avoiding repeated full-directory validation during discovered-query preparation. Mutable and replaced directories retain current lookup semantics.
19
+
5
20
  ## 0.2.4 (2026-10-18)
6
21
 
7
22
  - Clear Bundler bootstrap variables when verifying packages in an isolated gem installation, including calls made through bundle exec.
data/README-zh.md CHANGED
@@ -46,7 +46,7 @@ class ExampleStatusParser < CtxParser::Parser
46
46
  def parse_output(text, complete:)
47
47
  records = []
48
48
  issues = []
49
- text.each_line.with_index(1) do |line, number|
49
+ text.each_line("\n").with_index(1) do |line, number|
50
50
  next if line.strip.empty?
51
51
 
52
52
  match = /\A([A-Za-z0-9\/]+) (up|down)\z/.match(line.strip)
@@ -88,6 +88,8 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
88
88
 
89
89
  `MODEL_PARSERS` 的型号键与请求型号一样去除两侧空白并转为小写,归一化冲突和无效覆盖均被拒绝。`Registry` 在注册时校验静态命令与解析器目录;动态目录在使用时仍需校验,缺失实现抛出 `ArgumentError`。
90
90
 
91
+ Profile 为深冻结的普通命令目录复用查询列表与成员索引,包括发现阶段产生的目录。替换目录后重建索引;可变条目仍在每次查找时校验。`bundle exec ruby -Ilib benchmark/profile.rb` 测量 32、128、512 条合成命令的准备开销,并校验选择顺序与未知命令拒绝行为。
92
+
91
93
  `Records` 持有自己的筛选数组,行对象仍属于调用方;使用期间应保持行不变,需要快照时传入 `Immutable.copy(rows)`。`Immutable.copy` 在逐层校验后可复用深冻结普通容器;浅冻结、特殊 Hash 行为及循环引用仍按原规则复制或拒绝。整数转换仅在 `min` 允许负数或为 `nil` 时接受负号,默认仍只接受非负数。
92
94
 
93
95
  ## 开发与测试
data/README.md CHANGED
@@ -46,7 +46,7 @@ class ExampleStatusParser < CtxParser::Parser
46
46
  def parse_output(text, complete:)
47
47
  records = []
48
48
  issues = []
49
- text.each_line.with_index(1) do |line, number|
49
+ text.each_line("\n").with_index(1) do |line, number|
50
50
  next if line.strip.empty?
51
51
 
52
52
  match = /\A([A-Za-z0-9\/]+) (up|down)\z/.match(line.strip)
@@ -88,6 +88,8 @@ Address conversion uses `Values.ip_address`, `Values.ip_address?`, and `Values.m
88
88
 
89
89
  `MODEL_PARSERS` model names are stripped and lowercased just like the requested model. Normalization collisions and invalid overrides are rejected. `Registry` validates static command/parser declarations when registering a profile; dynamic directory lookup remains validated when used and reports missing implementations as `ArgumentError`.
90
90
 
91
+ Profiles reuse query lists and membership indexes for fully frozen plain command directories, including directories created by discovery. Replacing a directory rebuilds its index; mutable entries are validated on each lookup. `bundle exec ruby -Ilib benchmark/profile.rb` measures preparation of 32, 128 and 512 synthetic commands and checks selection order and rejection of unknown commands.
92
+
91
93
  `Records` owns selection arrays, while row objects remain caller-owned: treat those rows as stable during use, or pass `Immutable.copy(rows)` when a snapshot is needed. `Immutable.copy` can reuse fully frozen plain trees after validating all descendants; shallow-frozen containers, custom Hash behavior, and cycles receive the existing copy or rejection behavior. Integer conversion accepts a minus sign only when `min` permits negative values (or is `nil`); the default remains nonnegative.
92
94
 
93
95
  ## Development and tests
@@ -0,0 +1,19 @@
1
+ # 输入归一化验证(2026-10-21)
2
+
3
+ `Input.text` 的常见路径原先会对 UTF-8 输入再次执行 `encode(UTF_8)`,并在 LF 正文上执行无匹配的 `gsub`。现在跳过相同编码的转换,在没有 CR 时跳过替换;需要修改时只操作归一化过程拥有的副本。返回值仍可变且独立于调用方;原文、编码策略、逻辑 NUL 校验和转码前字节预算不变。
4
+
5
+ 在 macOS arm64、Ruby 4.0.6 上使用 `benchmark/input.rb`,对修改前的 `b8746d9` 与本次源码执行相同脚本。每轮重复归一化同一正文 30 次,共三轮;语义和对象所有权断言在计时外执行。下表是三轮中位数,属于重复输入的本地微基准,不代表设备采集、完整配置解析或网络吞吐。
6
+
7
+ | 正文 | 输入字节 | 修改前耗时 | 修改后耗时 | 修改前分配 | 修改后分配 |
8
+ | --- | ---: | ---: | ---: | ---: | ---: |
9
+ | UTF-8 / LF | 950000 | 67.354 ms | 0.866 ms | 120 | 30 |
10
+ | UTF-8 / CRLF | 1000000 | 177.798 ms | 162.056 ms | 150 | 150 |
11
+ | ISO-8859-1 / LF | 850000 | 169.909 ms | 90.748 ms | 150 | 60 |
12
+
13
+ 复测当前代码:
14
+
15
+ ```sh
16
+ bundle exec ruby -Ilib benchmark/input.rb
17
+ ```
18
+
19
+ 比较基线时,把目标提交的 `lib/ctx_parser/input.rb` 放入独立临时目录的 `ctx_parser/input.rb`,然后使用 `bundle exec ruby -I<临时目录> benchmark/input.rb`。脚本输出实际加载的 Input 来源,避免误测已安装版本。不要覆盖工作区文件来切换基线。
data/examples/offline.rb CHANGED
@@ -10,7 +10,7 @@ class ExampleStatusParser < CtxParser::Parser
10
10
  def parse_output(text, complete:)
11
11
  records = []
12
12
  issues = []
13
- text.each_line.with_index(1) do |line, number|
13
+ text.each_line("\n").with_index(1) do |line, number|
14
14
  next if line.strip.empty?
15
15
 
16
16
  match = /\A([A-Za-z0-9\/]+) (up|down)\z/.match(line.strip)
@@ -28,7 +28,9 @@ module CtxParser
28
28
  return value
29
29
  end
30
30
  copies[value] = if value.is_a?(Hash)
31
- value.to_h { |key, item| [ copy_value(key, copies, depth + 1), copy_value(item, copies, depth + 1) ] }.freeze
31
+ result = {}
32
+ value.each_pair { |key, item| result[copy_value(key, copies, depth + 1)] = copy_value(item, copies, depth + 1) }
33
+ result.freeze
32
34
  else
33
35
  value.map { |item| copy_value(item, copies, depth + 1) }.freeze
34
36
  end
@@ -69,7 +71,9 @@ module CtxParser
69
71
  copies[source] = if reusable?(source, copies, 0)
70
72
  source
71
73
  elsif source.is_a?(Hash)
72
- source.to_h { |key, item| [ snapshot(key, copies), snapshot(item, copies) ] }.freeze
74
+ result = {}
75
+ source.each_pair { |key, item| result[snapshot(key, copies)] = snapshot(item, copies) }
76
+ result.freeze
73
77
  else
74
78
  source.map { |item| snapshot(item, copies) }.freeze
75
79
  end
@@ -18,14 +18,18 @@ module CtxParser
18
18
  value.force_encoding(Encoding::UTF_8) if encoding == :utf8 || value.encoding == Encoding::BINARY
19
19
  raise EncodingError, "CLI output encoding is invalid" unless value.valid_encoding?
20
20
 
21
- value = value.encode(Encoding::UTF_8) if encoding == :transcode
21
+ value = value.encode(Encoding::UTF_8) if encoding == :transcode && value.encoding != Encoding::UTF_8
22
22
  # UTF-16/32 等编码不兼容 ASCII 字面量;仅在临时视图上检查逻辑字符。
23
23
  logical = value.encoding.ascii_compatible? ? value : value.encode(Encoding::UTF_8)
24
24
  raise ArgumentError, "CLI output contains a NUL byte" if !allow_nul && logical.include?("\0")
25
25
 
26
26
  normalized = case newlines
27
- when :lf then logical.gsub(/\r\n?/, "\n")
28
- when :delete_cr then logical.delete("\r")
27
+ when :lf
28
+ logical.gsub!(/\r\n?/, "\n") if logical.include?("\r")
29
+ logical
30
+ when :delete_cr
31
+ logical.delete!("\r")
32
+ logical
29
33
  else return value
30
34
  end
31
35
  normalized.encoding == value.encoding ? normalized : normalized.encode(value.encoding)
@@ -17,8 +17,7 @@ module CtxParser
17
17
 
18
18
  @model = model&.strip&.downcase&.freeze
19
19
  @model_parsers = model_parser_overrides
20
- @static_commands = self.class::COMMANDS
21
- @command_queries = compile_commands(@static_commands)
20
+ cache_commands(self.class::COMMANDS, strict: true)
22
21
  end
23
22
 
24
23
  # 注册时核对静态目录;动态实例仍在实际查询时重新校验。
@@ -73,7 +72,8 @@ module CtxParser
73
72
  def commands_for(name)
74
73
  directory = commands
75
74
  declaration = directory.fetch(name) { raise ArgumentError, "Unsupported parser dimension" }
76
- return @command_queries.fetch(name) if @command_queries && directory.equal?(@static_commands)
75
+ cached = cache_commands(directory)
76
+ return cached[:queries].fetch(name) if cached
77
77
 
78
78
  validated_queries(declaration)
79
79
  end
@@ -102,6 +102,8 @@ module CtxParser
102
102
  unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions
103
103
  raise ArgumentError, "Parser dimensions must be a nonempty unique array"
104
104
  end
105
+ # 扩展解析器可调用外部代码;结果投影始终使用入口处的请求。
106
+ dimensions = dimensions.dup.freeze
105
107
  query = select_command(dimensions, command)
106
108
 
107
109
  # 查询已覆盖全部请求维度,无需再次遍历第一个维度的完整命令列表。
@@ -111,12 +113,32 @@ module CtxParser
111
113
 
112
114
  private
113
115
 
114
- # 仅为深冻结的静态目录建立快照;可变或被替换的目录仍走实时校验。
116
+ # 发现阶段可替换实例目录;只缓存当前深冻结目录,不继承旧目录的选择结果。
117
+ def cache_commands(directory, strict: false)
118
+ cached = @command_cache
119
+ return cached if cached && directory.equal?(cached[:directory])
120
+ return if frozen?
121
+
122
+ queries = compile_commands(directory)
123
+ return unless queries
124
+
125
+ membership = queries.transform_values { |entries| entries.to_h { |query| [ query, true ] }.freeze }.freeze
126
+ # 一次发布完整索引;并发查找只读取自己取得的快照,不混合两份目录。
127
+ @command_cache = { directory: directory, queries: queries, membership: membership }.freeze
128
+ rescue ArgumentError
129
+ # 动态目录仍按请求维度报错,未使用的无效声明不能阻断其他维度。
130
+ raise if strict
131
+
132
+ nil
133
+ end
134
+
135
+ # 可变容器或文本仍逐次校验;普通深冻结目录允许共享验证后的顺序与成员索引。
115
136
  def compile_commands(directory)
116
137
  return unless directory.instance_of?(Hash) && directory.frozen? && !directory.compare_by_identity? &&
117
138
  directory.keys.all? { |name| name.is_a?(Symbol) && !name.empty? } && directory.values.all? do |value|
118
139
  entries = value.is_a?(Array) ? value : [ value ]
119
- (!value.is_a?(Array) || value.frozen?) && entries.all? { |query| query.is_a?(String) && query.frozen? }
140
+ (!value.is_a?(Array) || value.instance_of?(Array) && value.frozen?) &&
141
+ entries.all? { |query| query.instance_of?(String) && query.frozen? }
120
142
  end
121
143
 
122
144
  directory.to_h { |name, declaration| [ name, validated_queries(declaration) ] }.freeze
@@ -134,13 +156,26 @@ module CtxParser
134
156
 
135
157
  # 仅当存在唯一公共查询时允许推断;显式命令必须覆盖全部请求视图。
136
158
  def select_command(dimensions, selected)
137
- candidates = dimensions.map { |name| commands_for(name) }.reduce { |common, queries| common & queries }
159
+ declarations = dimensions.map do |name|
160
+ queries = commands_for(name)
161
+ cached = @command_cache
162
+ membership = cached[:membership][name] if cached && queries.equal?(cached[:queries][name])
163
+ [ queries, membership ]
164
+ end
138
165
  if selected.nil?
166
+ candidates = declarations.map(&:first).reduce { |common, queries| common & queries }
139
167
  raise ArgumentError, "One CLI output must belong to one command" unless candidates.one?
140
168
 
141
169
  candidates.first
142
170
  else
143
- raise ArgumentError, "Selected command does not provide all parser dimensions" unless candidates.include?(selected)
171
+ supported = declarations.all? do |queries, membership|
172
+ if selected.instance_of?(String) && membership
173
+ membership.key?(selected)
174
+ else
175
+ queries.include?(selected)
176
+ end
177
+ end
178
+ raise ArgumentError, "Selected command does not provide all parser dimensions" unless supported
144
179
 
145
180
  selected
146
181
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CtxParser
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.2.4"
5
+ VERSION = "0.2.6"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ctx_parser
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.4
4
+ version: 0.2.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -48,6 +48,7 @@ files:
48
48
  - README-zh.md
49
49
  - README.md
50
50
  - docs/architecture-zh.md
51
+ - docs/input-performance.md
51
52
  - docs/release.md
52
53
  - examples/offline.rb
53
54
  - lib/ctx_parser.rb