ctx_parser 0.2.1 → 0.2.3

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: 34175c1684f6dc5b5a7ef9a12649f16d393965f87c506d1f672f63a8301a7ebd
4
- data.tar.gz: 55f14e3f497e6a3fa5484f6e5465effc39acadcfff0e908932abe41b43696b94
3
+ metadata.gz: c642815fd4891349acb98052cf1601b9b69217f4025db17d02c64705506eca12
4
+ data.tar.gz: 91e34bd39b1116a3228c9653dd51322bc0ac8d47dec25e3fcf94a41af947ed92
5
5
  SHA512:
6
- metadata.gz: fc1d527a3d2a6a0c2001bfc270d6afb7f8b21e3f779d5f61b31de69757ea598f2b255a5a5eb15db41ebde8de6274c99e3ade55f89cb1b577041cdab8d4563bbf
7
- data.tar.gz: 4708ac383d49d5f2ade14747800d6d5d52db0e1cdc78ac2acbd87e828fb41695e41ebf2a98a72b1c81c13d4612c2419e40ba189d429c39be7bb4afebd074100c
6
+ metadata.gz: 9cd537e0188eda9578bfb03e5a1cc26d703a62da5bbd1cbc2beb4fcce66233ab03de5959f20cc9821b7780ab5a2c4edc764fa063cc8afb5ca7148cf9ffd6f955
7
+ data.tar.gz: '022948b2a66b03ba946c748cf5aaf5dfca9eff72710f859147e8c69e195de7e1b7f055eff54c21e28eb83d763ce3c1128520c726ae3eabd655fa0f943cc91b88'
data/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  Changes to the public contract are recorded here. Versions follow Semantic Versioning.
4
4
 
5
+ ## 0.2.3 (2026-10-18)
6
+
7
+ - Redact Result, Results and Line during pretty printing, including nested containers.
8
+ - Normalize model override names, reject normalized collisions, and validate static registry command/parser consistency.
9
+ - Report missing dynamic parser implementations as argument errors; retain dynamic command lookup.
10
+ - Accept signed decimal integers when bounds permit negative values, and detach host-bit-preserving prefix results from caller strings.
11
+ - Add optional byte budgets to Cursor and Document, load Banner from the root, and remove the source-type circular require.
12
+ - Reuse validated deeply frozen plain containers and frozen source strings while preserving cycle detection and defensive copies for mutable values.
13
+ - Require ipaddr 1.2.3 or newer for strict address parsing; retain the Ruby 4.0.6 support policy.
14
+
15
+ ## 0.2.2 (2026-10-18)
16
+
17
+ - Preserve exact block, capture and unresolved source text independently of Ruby's global output separator.
18
+ - Normalize source lines independently of Ruby's global input separator, without an intermediate chomp allocation.
19
+ - Build cursor lines directly during scanning instead of retaining an intermediate source-line array.
20
+ - Return false from IP address validation for incompatible string encodings.
21
+
5
22
  ## 0.2.1 (2026-10-17)
6
23
 
7
24
  - Update the configuration benchmark to use the Baseline 0.2 vendor parser namespace.
data/README-zh.md CHANGED
@@ -21,7 +21,7 @@ gem "ctx_parser"
21
21
 
22
22
  然后执行 `bundle install`;也可直接执行 `gem install ctx_parser`。Ruby 加载入口为 `ctx_parser`,公开命名空间为 `CtxParser`;gem 包名与 Ruby 命名空间分别维护。
23
23
 
24
- 本模块不依赖其他 Gatework 领域 gem。 gemspec 的运行依赖不添加版本约束;应用应提交自己的 `Gemfile.lock`,保持部署可复现。使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
24
+ 本模块不依赖其他 Gatework 领域 gem。 gemspec 要求 `ipaddr >= 1.2.3` 以确保严格地址解析,`digest` 不设版本约束;应用应提交自己的 `Gemfile.lock`,保持部署可复现。使用本库不需要启动 Rails 或配置兄弟模块的本地路径。
25
25
 
26
26
  发布产物和校验值见 [GitHub Releases](https://github.com/gatework/ctx_parser/releases),已发布版本以 [RubyGems](https://rubygems.org/gems/ctx_parser) 为准。
27
27
 
@@ -70,7 +70,7 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
70
70
 
71
71
  `Parser.parse(text, complete: false)` 返回包含 `status`、`records`、`issues` 的 `Result`;多视图解析器可返回 `Results`。状态为 `complete`、`partial`、`failed`、`unsupported`。子类实现 `parse_output`,配置子类通过 `analyze` 和 `capture` 保留来源。
72
72
 
73
- `complete: true` 只表示调用方确认输入完整接收,不证明所有语法都已理解。未知行、身份冲突及空表证据不足必须明确表达。结果的记录和诊断均须为 Hash 数组;结果及构建完成的文档诊断持有不可变快照。`inspect` 和 `to_s` 不输出结果正文与诊断内容,显式读取记录仍返回原始事实。来源游标有意保留输入原文,含秘密时不可写入日志。
73
+ `complete: true` 只表示调用方确认输入完整接收,不证明所有语法都已理解。未知行、身份冲突及空表证据不足必须明确表达。结果的记录和诊断均须为 Hash 数组;结果及构建完成的文档诊断持有不可变快照。`inspect`、`to_s` 和 pretty printing 不输出结果正文与诊断内容,显式读取记录仍返回原始事实。来源游标有意保留输入原文,含秘密时不可写入日志。
74
74
 
75
75
  本 gem 不提供厂商命令目录、传输、持久化或健康策略。参数和注册表配置错误保持异常,由调用方在对应边界处理。
76
76
 
@@ -84,6 +84,12 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
84
84
 
85
85
  地址转换使用 `Values.ip_address`、`Values.ip_address?`、`Values.mac_address`。`Values.ipv4_prefix(..., preserve_host_bits: true)` 明确保留接口主机位,默认返回网段前缀。
86
86
 
87
+ `Cursor.new(text, max_bytes: limit)` 和 `Document.new(source, max_bytes: limit)` 可在构建或消费来源行前限制输入字节数。传入已有游标时,限制针对其归一化内容。非法编码仍抛出错误;已知为 Latin-1 的输入可先标记 `Encoding::ISO_8859_1`,随后自动转为 UTF-8,不静默替换字节并生成事实。
88
+
89
+ `MODEL_PARSERS` 的型号键与请求型号一样去除两侧空白并转为小写,归一化冲突和无效覆盖均被拒绝。`Registry` 在注册时校验静态命令与解析器目录;动态目录在使用时仍需校验,缺失实现抛出 `ArgumentError`。
90
+
91
+ `Records` 持有自己的筛选数组,行对象仍属于调用方;使用期间应保持行不变,需要快照时传入 `Immutable.copy(rows)`。`Immutable.copy` 在逐层校验后可复用深冻结普通容器;浅冻结、特殊 Hash 行为及循环引用仍按原规则复制或拒绝。整数转换仅在 `min` 允许负数或为 `nil` 时接受负号,默认仍只接受非负数。
92
+
87
93
  ## 开发与测试
88
94
 
89
95
  共享类按实际职责命名,平台信息放在命名空间中。直接对应设备命令的解析器保留文件名和类名。
@@ -99,6 +105,10 @@ bundle exec rake build
99
105
 
100
106
  使用声明的最低 Ruby 版本检查兼容性。方法用途统一使用简明中文注释;复杂实现还需说明业务规则、实现思路及关键分支。保持公开返回值与错误契约。真实回显和凭据不可进入示例、fixture、日志或发布包。
101
107
 
108
+ 这些开发命令须在源码检出目录执行;测试、基准和脚本有意排除在安装包之外。
109
+
110
+ 执行 `bundle exec ruby -Ilib benchmark/snapshots.rb` 可测量冻结结果的重复封装;执行 `bundle exec ruby -Ilib benchmark/source.rb`,可使用合成输入和语义断言测量游标及文档构建的耗时与对象分配量,无需领域 gem。此类本地测量不代表设备性能。
111
+
102
112
  ## 发布
103
113
 
104
114
  本目录可独立维护为 [ctx_parser](https://github.com/gatework/ctx_parser) 仓库。更新 `VERSION` 和 `CHANGELOG.md`,运行上述验证与构建命令,检查包内文件,再创建 `vVERSION` 标签。
data/README.md CHANGED
@@ -21,7 +21,7 @@ gem "ctx_parser"
21
21
 
22
22
  Then run `bundle install`, or install directly with `gem install ctx_parser`. The require path is `ctx_parser` and the public namespace is `CtxParser`; package names and Ruby namespaces are intentionally separate.
23
23
 
24
- This package has no dependency on the other Gatework domain gems. The gemspec declares runtime dependencies without version constraints. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
24
+ This package has no dependency on the other Gatework domain gems. The gemspec requires `ipaddr >= 1.2.3` for strict address parsing; `digest` has no version constraint. Applications should commit their own `Gemfile.lock` to make deployments reproducible. No Rails boot or sibling checkout paths are required.
25
25
 
26
26
  Release artifacts and checksums are available from [GitHub Releases](https://github.com/gatework/ctx_parser/releases). Check [RubyGems](https://rubygems.org/gems/ctx_parser) for published versions.
27
27
 
@@ -70,7 +70,7 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
70
70
 
71
71
  `Parser.parse(text, complete: false)` returns a `Result` with `status`, `records`, and `issues`; a multi-view parser can return `Results`. Result statuses are `complete`, `partial`, `failed`, and `unsupported`. Subclasses implement `parse_output`. Configuration subclasses can use `analyze` and `capture` to preserve provenance.
72
72
 
73
- `complete: true` confirms that the caller received the entire input; it does not certify that all syntax was understood. Unknown rows, identity conflicts, and insufficient empty-table evidence must remain explicit. Result records and issues must be arrays of hashes. Results and completed document diagnostics own immutable snapshots. `inspect` and `to_s` omit result facts and diagnostics; explicit record access still returns the original facts. Source cursors intentionally retain original input and must not be logged when it contains secrets.
73
+ `complete: true` confirms that the caller received the entire input; it does not certify that all syntax was understood. Unknown rows, identity conflicts, and insufficient empty-table evidence must remain explicit. Result records and issues must be arrays of hashes. Results and completed document diagnostics own immutable snapshots. `inspect`, `to_s`, and pretty printing omit result facts and diagnostics; explicit record access still returns the original facts. Source cursors intentionally retain original input and must not be logged when it contains secrets.
74
74
 
75
75
  This gem supplies no vendor command declarations, transport, persistence, or health policy. Parser/registry argument errors remain exceptions for callers to handle at their own boundary.
76
76
 
@@ -84,6 +84,12 @@ A dimension is a named view of facts; a command is the CLI query that supplies t
84
84
 
85
85
  Address conversion uses `Values.ip_address`, `Values.ip_address?`, and `Values.mac_address`. `Values.ipv4_prefix(..., preserve_host_bits: true)` explicitly preserves interface host bits; its default returns a network prefix.
86
86
 
87
+ `Cursor.new(text, max_bytes: limit)` and `Document.new(source, max_bytes: limit)` enforce an optional input byte budget before constructing or consuming lines. For an existing cursor, the budget applies to its normalized content. Invalid encoding remains an error: known Latin-1 input can be tagged as `Encoding::ISO_8859_1` before parsing and is transcoded to UTF-8; bytes are never silently scrubbed into facts.
88
+
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
+
91
+ `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
+
87
93
  ## Development and tests
88
94
 
89
95
  Name shared classes by their responsibility and use the namespace for platform context. Preserve filenames and class names that directly match device commands.
@@ -99,6 +105,10 @@ bundle exec rake build
99
105
 
100
106
  Use the declared minimum Ruby version for compatibility checks. Document source methods and non-obvious business logic in concise Chinese, preserve public return/error contracts, and never add raw device captures or credentials to examples, fixtures, logs, or release archives.
101
107
 
108
+ Development commands below require a source checkout; tests, benchmarks, and scripts are intentionally excluded from the installed gem archive.
109
+
110
+ Run `bundle exec ruby -Ilib benchmark/snapshots.rb` to measure repeated wrapping of frozen results, and `bundle exec ruby -Ilib benchmark/source.rb` to measure cursor and document construction with synthetic input and semantic checks. It needs no domain gems and reports elapsed time and allocated objects; these local measurements do not establish device performance.
111
+
102
112
  ## Release
103
113
 
104
114
  This directory can be maintained as the independent [ctx_parser](https://github.com/gatework/ctx_parser) repository. Update `VERSION` and `CHANGELOG.md`, run the validation and build commands above, and inspect the packaged file list before tagging `vVERSION`.
@@ -23,6 +23,7 @@ module CtxParser
23
23
  child.index > previous_index && child.last_index <= last_index && child.header.indent > header.indent
24
24
 
25
25
  previous_index = child.last_index
26
+ true
26
27
  end
27
28
  raise ArgumentError, "Child blocks must be ordered within their parent source"
28
29
  end
@@ -88,7 +89,7 @@ module CtxParser
88
89
 
89
90
  # 返回配置块标题的零起始索引。
90
91
  def index = header.index
91
- # 返回配置块标题的一起始来源行号。
92
+ # 返回配置块标题从 1 开始的来源行号。
92
93
  def line = header.number
93
94
  # 返回已去除外围缩进和行结束符的来源内容。
94
95
  def text = header.text
@@ -97,7 +98,7 @@ module CtxParser
97
98
  # 返回属于当前配置块的原始来源行。
98
99
  def lines = cursor.lines[index..last_index].freeze
99
100
  # 返回配置块或捕获事实对应的精确来源文本。
100
- def content = lines.map(&:raw).join.freeze
101
+ def content = lines.map(&:raw).join("").freeze
101
102
  # 返回当前配置块各来源行的处理状态。
102
103
  def states = lines.map { |source| cursor.state(source.index) }.freeze
103
104
  # 要求块内所有来源行均已解析或明确忽略。
@@ -1,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "block"
4
3
  require_relative "immutable"
5
4
 
6
5
  module CtxParser
@@ -22,6 +21,7 @@ module CtxParser
22
21
 
23
22
  # 冻结解析事实及其来源块和结果路径。
24
23
  def initialize(block:, data:, path:)
24
+ require_relative "block" unless defined?(Block)
25
25
  raise ArgumentError, "Capture source must be a block" unless block.is_a?(Block)
26
26
 
27
27
  initialize_snapshot(block, Snapshot.new(data: data, path: path))
@@ -29,6 +29,7 @@ module CtxParser
29
29
 
30
30
  # 同一事实的多个来源共享已复制的值,不共享未经校验的调用方容器。
31
31
  def self.for_blocks(blocks, data:, path:)
32
+ require_relative "block" unless defined?(Block)
32
33
  unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) }
33
34
  raise ArgumentError, "Capture sources must be blocks"
34
35
  end
@@ -14,14 +14,17 @@ module CtxParser
14
14
  attr_reader :content, :lines, :checksum, :position
15
15
 
16
16
  # 为单次解析创建独立的来源行、语义状态及事实索引。
17
- def initialize(content)
17
+ def initialize(content, max_bytes: nil)
18
18
  raise ArgumentError, "Configuration content must be text" unless content.is_a?(String)
19
19
 
20
- @content = Input.text(content, encoding: :transcode).freeze
20
+ @content = Input.text(content, encoding: :transcode, max_bytes: max_bytes).freeze
21
21
 
22
- @lines = @content.scan(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+\z/).each_with_index.map do |raw, index|
23
- Line.new(index: index, raw: raw)
24
- end.freeze
22
+ # scan 直接交付每行,避免同时保留原始行数组与 Line 数组。
23
+ @lines = []
24
+ @content.scan(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+\z/) do |raw|
25
+ @lines << Line.new(index: @lines.size, raw: raw.freeze)
26
+ end
27
+ @lines.freeze
25
28
  @checksum = Digest::SHA256.hexdigest(@content).freeze
26
29
  reset!
27
30
  end
@@ -123,7 +126,7 @@ module CtxParser
123
126
  end
124
127
 
125
128
  # 拼接未解决来源行,并保留原有格式。
126
- def unparsed_content = unparsed_lines.map(&:raw).join.freeze
129
+ def unparsed_content = unparsed_lines.map(&:raw).join("").freeze
127
130
 
128
131
  # 将事实快照关联到来源块,不将整个块标记为解析成功。
129
132
  def capture(block, data:, path:)
@@ -132,6 +135,7 @@ module CtxParser
132
135
 
133
136
  # 注册归纳事实的不可变快照前,先校验全部来源。
134
137
  def capture_many(blocks, data:, path:)
138
+ require_relative "block" unless defined?(Block)
135
139
  unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) && block.cursor.equal?(self) }
136
140
  raise ArgumentError, "Blocks must belong to this cursor"
137
141
  end
@@ -9,8 +9,14 @@ module CtxParser
9
9
  attr_reader :cursor, :roots, :issues
10
10
 
11
11
  # 使用平台提供的注释及边界规则构建来源文档。
12
- def initialize(source, comments: nil, boundaries: nil)
13
- @cursor = source.is_a?(Cursor) ? source : Cursor.new(source)
12
+ def initialize(source, comments: nil, boundaries: nil, max_bytes: nil)
13
+ if source.is_a?(Cursor)
14
+ unless max_bytes.nil? || max_bytes.is_a?(Integer) && max_bytes >= 0
15
+ raise ArgumentError, "Invalid byte limit"
16
+ end
17
+ raise ArgumentError, "CLI output exceeds byte limit" if max_bytes && source.content.bytesize > max_bytes
18
+ end
19
+ @cursor = source.is_a?(Cursor) ? source : Cursor.new(source, max_bytes: max_bytes)
14
20
  @comments, @boundaries, @issues = comments, boundaries, []
15
21
  @roots = Block.read(cursor) { |line| classify(line) }
16
22
  finish
@@ -23,6 +23,10 @@ module CtxParser
23
23
  return copy_deep(value, copies) if depth == 64
24
24
 
25
25
  copies[value] = COPYING
26
+ if reusable?(value, copies, depth)
27
+ copies[value] = value
28
+ return value
29
+ end
26
30
  copies[value] = if value.is_a?(Hash)
27
31
  value.to_h { |key, item| [ copy_value(key, copies, depth + 1), copy_value(item, copies, depth + 1) ] }.freeze
28
32
  else
@@ -40,13 +44,31 @@ module CtxParser
40
44
  end
41
45
  private_class_method :copy_value
42
46
 
47
+ # 仅复用无特殊 Hash 行为的深冻结普通容器;逐层验证,不信任外层 frozen?。
48
+ def self.reusable?(value, copies, depth)
49
+ return false unless value.frozen? && value.instance_variables.empty?
50
+ if value.instance_of?(Array)
51
+ value.all? { |item| copy_value(item, copies, depth + 1).equal?(item) }
52
+ elsif value.instance_of?(Hash) && value.default.nil? && !value.default_proc && !value.compare_by_identity?
53
+ value.each_pair do |key, item|
54
+ return false unless copy_value(key, copies, depth + 1).equal?(key) && copy_value(item, copies, depth + 1).equal?(item)
55
+ end
56
+ true
57
+ else
58
+ false
59
+ end
60
+ end
61
+ private_class_method :reusable?
62
+
43
63
  # 容器缓存按来源身份查找,普通 Hash 的键仍沿用值相等语义。
44
64
  def self.copy_deep(value, copies)
45
65
  pending = [ [ value, false ] ]
46
66
  until pending.empty?
47
67
  source, finishing = pending.pop
48
68
  if finishing
49
- copies[source] = if source.is_a?(Hash)
69
+ copies[source] = if reusable?(source, copies, 0)
70
+ source
71
+ elsif source.is_a?(Hash)
50
72
  source.to_h { |key, item| [ snapshot(key, copies), snapshot(item, copies) ] }.freeze
51
73
  else
52
74
  source.map { |item| snapshot(item, copies) }.freeze
@@ -8,12 +8,13 @@ module CtxParser
8
8
  raise ArgumentError, "Line index must be nonnegative" unless index.is_a?(Integer) && index >= 0
9
9
  raise ArgumentError, "Line content must be text" unless raw.is_a?(String)
10
10
 
11
- @text = raw.chomp.strip.freeze
11
+ # strip 已移除行结束空白,避免 chomp 依赖宿主进程的 $/。
12
+ @text = raw.strip.freeze
12
13
  @indent = raw[/\A[ \t]*/].size
13
- super(index: index, raw: raw.dup.freeze)
14
+ super(index: index, raw: raw.frozen? ? raw : raw.dup.freeze)
14
15
  end
15
16
 
16
- # 返回诊断使用的一起始行号。
17
+ # 返回诊断使用的从 1 开始的行号。
17
18
  def number = index + 1
18
19
  # 返回已去除外围缩进和行结束符的来源内容。
19
20
  def text = @text
@@ -24,5 +25,8 @@ module CtxParser
24
25
  # 展示对象身份与状态,避免泄露原始回显或凭据。
25
26
  def inspect = "#<#{self.class.name} number=#{number}>"
26
27
  alias to_s inspect
28
+
29
+ # PP 不得绕过 inspect 暴露来源行。
30
+ def pretty_print(printer) = printer.text(inspect)
27
31
  end
28
32
  end
@@ -17,8 +17,39 @@ 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
22
  end
21
23
 
24
+ # 注册时核对静态目录;动态实例仍在实际查询时重新校验。
25
+ def self.validate_declarations!
26
+ directory, implementations = self::COMMANDS, self::PARSERS
27
+ unless directory.is_a?(Hash) && implementations.is_a?(Hash) &&
28
+ directory.keys.all? { |name| name.is_a?(Symbol) && !name.empty? }
29
+ raise ArgumentError, "Profile commands and parsers must be named mappings"
30
+ end
31
+
32
+ queries = directory.values.flat_map do |value|
33
+ entries = value.is_a?(Array) ? value : [ value ]
34
+ unless entries.any? && entries.uniq == entries && entries.all? { |entry| valid_command?(entry) }
35
+ raise ArgumentError, "Parser dimension must declare one or more unique commands"
36
+ end
37
+ entries
38
+ end.uniq
39
+ unless (queries - implementations.keys).empty? && (implementations.keys - queries).empty? &&
40
+ implementations.values.all? { |parser| parser.is_a?(Class) && parser < Parser }
41
+ raise ArgumentError, "Profile commands must match Parser implementations"
42
+ end
43
+ normalized_model_parsers
44
+ true
45
+ end
46
+
47
+ # 校验静态目录中的单命令文本,不接受控制字符。
48
+ def self.valid_command?(value)
49
+ value.is_a?(String) && !value.strip.empty? && !/[\x00-\x1f\x7f]/.match?(value)
50
+ end
51
+ private_class_method :valid_command?
52
+
22
53
  # 返回当前平台策略声明的命令目录。
23
54
  def commands = self.class::COMMANDS
24
55
 
@@ -40,13 +71,11 @@ module CtxParser
40
71
 
41
72
  # 单个维度可能需要多个独立回显;调用方必须分别保存正文。
42
73
  def commands_for(name)
43
- declaration = commands.fetch(name) { raise ArgumentError, "Unsupported parser dimension" }
44
- queries = declaration.is_a?(Array) ? declaration : [ declaration ]
45
- unless queries.any? && queries.uniq == queries && queries.all? { |query| query.is_a?(String) && !query.strip.empty? && !/[\x00-\x1f\x7f]/.match?(query) }
46
- raise ArgumentError, "Parser dimension must declare one or more unique commands"
47
- end
74
+ directory = commands
75
+ declaration = directory.fetch(name) { raise ArgumentError, "Unsupported parser dimension" }
76
+ return @command_queries.fetch(name) if @command_queries && directory.equal?(@static_commands)
48
77
 
49
- queries.map { |query| query.frozen? ? query : query.dup.freeze }.freeze
78
+ validated_queries(declaration)
50
79
  end
51
80
 
52
81
  # 解析支持的观测维度对应的命令。
@@ -60,7 +89,7 @@ module CtxParser
60
89
  # 确定维度使用的解析器,包括命令特定变体。
61
90
  def parser_for(name, command: nil)
62
91
  query = select_command([ name ], command)
63
- @model_parsers.fetch(query) { parsers.fetch(query) }
92
+ implementation_for(query)
64
93
  end
65
94
 
66
95
  # 选择目标命令解析器,并投影单个请求维度。
@@ -76,39 +105,83 @@ module CtxParser
76
105
  query = select_command(dimensions, command)
77
106
 
78
107
  # 查询已覆盖全部请求维度,无需再次遍历第一个维度的完整命令列表。
79
- result = @model_parsers.fetch(query) { parsers.fetch(query) }.parse(text, complete: complete)
108
+ result = implementation_for(query).parse(text, complete: complete)
80
109
  dimensions.to_h { |name| [ name, project_independently(name, result) ] }
81
110
  end
82
111
 
83
112
  private
84
113
 
114
+ # 仅为深冻结的静态目录建立快照;可变或被替换的目录仍走实时校验。
115
+ def compile_commands(directory)
116
+ return unless directory.instance_of?(Hash) && directory.frozen? && !directory.compare_by_identity? &&
117
+ directory.keys.all? { |name| name.is_a?(Symbol) && !name.empty? } && directory.values.all? do |value|
118
+ entries = value.is_a?(Array) ? value : [ value ]
119
+ (!value.is_a?(Array) || value.frozen?) && entries.all? { |query| query.is_a?(String) && query.frozen? }
120
+ end
121
+
122
+ directory.to_h { |name, declaration| [ name, validated_queries(declaration) ] }.freeze
123
+ end
124
+
125
+ # 查询快照不冻结调用方的数组或字符串。
126
+ def validated_queries(declaration)
127
+ queries = declaration.is_a?(Array) ? declaration : [ declaration ]
128
+ unless queries.any? && queries.uniq == queries && queries.all? { |query| query.is_a?(String) && !query.strip.empty? && !/[\x00-\x1f\x7f]/.match?(query) }
129
+ raise ArgumentError, "Parser dimension must declare one or more unique commands"
130
+ end
131
+
132
+ queries.map { |query| query.frozen? ? query : query.dup.freeze }.freeze
133
+ end
134
+
85
135
  # 仅当存在唯一公共查询时允许推断;显式命令必须覆盖全部请求视图。
86
136
  def select_command(dimensions, selected)
87
137
  candidates = dimensions.map { |name| commands_for(name) }.reduce { |common, queries| common & queries }
88
- unless selected.nil?
89
- raise ArgumentError, "Selected command does not provide all parser dimensions" unless candidates.include?(selected)
90
-
91
- selected
92
- else
138
+ if selected.nil?
93
139
  raise ArgumentError, "One CLI output must belong to one command" unless candidates.one?
94
140
 
95
141
  candidates.first
142
+ else
143
+ raise ArgumentError, "Selected command does not provide all parser dimensions" unless candidates.include?(selected)
144
+
145
+ selected
96
146
  end
97
147
  end
98
148
 
99
149
  # 采集前校验所选型号,并隔离后续修改对型号查找的影响。
100
- def model_parser_overrides
101
- declarations = self.class::MODEL_PARSERS
150
+ def self.normalized_model_parsers
151
+ declarations = self::MODEL_PARSERS
102
152
  raise ArgumentError, "Model parsers must be a hash" unless declarations.is_a?(Hash)
103
153
 
104
- overrides = declarations.fetch(model, {})
105
- unless overrides.is_a?(Hash) && overrides.all? do |command, parser|
106
- self.class::PARSERS.key?(command) && parser.is_a?(Class) && parser < Parser
154
+ normalized = {}
155
+ declarations.each do |name, overrides|
156
+ unless name.nil? || name.is_a?(String) && !name.strip.empty?
157
+ raise ArgumentError, "Model parser keys must be device model names"
107
158
  end
108
- raise ArgumentError, "Model parsers must override declared commands with Parser subclasses"
159
+ key = name&.strip&.downcase
160
+ raise ArgumentError, "Model parser names must be unique after normalization" if normalized.key?(key)
161
+ unless overrides.is_a?(Hash) && overrides.all? do |command, parser|
162
+ self::PARSERS.key?(command) && parser.is_a?(Class) && parser < Parser
163
+ end
164
+ raise ArgumentError, "Model parsers must override declared commands with Parser subclasses"
165
+ end
166
+ normalized[key] = overrides
109
167
  end
110
168
 
111
- overrides.dup.freeze
169
+ normalized
170
+ end
171
+ private_class_method :normalized_model_parsers
172
+
173
+ # 注册与实例选择共用声明校验,所选覆盖仍独立于后续调用方修改。
174
+ def model_parser_overrides
175
+ self.class.send(:normalized_model_parsers).fetch(model, {}).dup.freeze
176
+ end
177
+
178
+ # 动态目录的错误仍在调用边界报告,不暴露底层 Hash 的 KeyError。
179
+ def implementation_for(query)
180
+ implementation = @model_parsers.fetch(query) { parsers.fetch(query) { raise ArgumentError, "Missing command parser" } }
181
+ unless implementation.is_a?(Class) && implementation < Parser
182
+ raise ArgumentError, "Command parser must inherit from Parser"
183
+ end
184
+ implementation
112
185
  end
113
186
 
114
187
  # 投影失败仅影响单个视图;命令选择及解析错误仍正常抛出。
@@ -18,9 +18,10 @@ module CtxParser
18
18
  @aliases.values.all? { |key| @profiles.key?(key) } && (@profiles.keys & @aliases.keys).empty?
19
19
  raise ArgumentError, "Invalid parser registry"
20
20
  end
21
+ @profiles.each_value(&:validate_declarations!)
21
22
  end
22
23
 
23
- # 解析请求的解析结果或平台策略,拒绝未知名称。
24
+ # 按平台或别名创建 Profile 实例,拒绝未知名称。
24
25
  def fetch(platform, model: nil)
25
26
  key = platform_key(platform)
26
27
  key = @aliases.fetch(key, key)
@@ -20,5 +20,8 @@ module CtxParser
20
20
  # 展示对象身份与状态,避免泄露原始回显或凭据。
21
21
  def inspect = "#<#{self.class.name} status=#{status} records=#{records.size}>"
22
22
  alias to_s inspect
23
+
24
+ # PP 对 Data 默认展开成员,必须显式沿用脱敏展示。
25
+ def pretty_print(printer) = printer.text(inspect)
23
26
  end
24
27
  end
@@ -39,5 +39,8 @@ module CtxParser
39
39
  # 展示对象身份与状态,避免泄露原始回显或凭据。
40
40
  def inspect = "#<#{self.class.name} status=#{status} dimensions=#{by_dimension.keys.join(',')}>"
41
41
  alias to_s inspect
42
+
43
+ # 具名结果的嵌套展示同样不能展开事实或诊断。
44
+ def pretty_print(printer) = printer.text(inspect)
42
45
  end
43
46
  end
@@ -15,7 +15,8 @@ module CtxParser
15
15
  raise ArgumentError, "Invalid integer limits"
16
16
  end
17
17
  return if raw.nil?
18
- unless (raw.is_a?(String) || raw.is_a?(Integer)) && /\A\d+\z/.match?(raw.to_s)
18
+ pattern = min.nil? || min.negative? ? /\A-?\d+\z/ : /\A\d+\z/
19
+ unless (raw.is_a?(String) || raw.is_a?(Integer)) && pattern.match?(raw.to_s)
19
20
  raise ArgumentError, "Invalid decimal integer"
20
21
  end
21
22
 
@@ -48,7 +49,7 @@ module CtxParser
48
49
  # 校验主机地址有效性,不让格式错误值继续传播。
49
50
  def ip_address?(raw, allow_zone: false)
50
51
  !ip_address(raw, allow_zone: allow_zone).nil?
51
- rescue ArgumentError, IPAddr::Error
52
+ rescue ArgumentError, IPAddr::Error, EncodingError
52
53
  false
53
54
  end
54
55
 
@@ -61,7 +62,7 @@ module CtxParser
61
62
  network = IPAddr.new(raw)
62
63
  raise ArgumentError, "IPv4 prefix required" unless network.ipv4?
63
64
 
64
- preserve_host_bits ? raw : "#{network}/#{network.prefix}"
65
+ preserve_host_bits ? raw.dup : "#{network}/#{network.prefix}"
65
66
  end
66
67
  end
67
68
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CtxParser
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.2.1"
5
+ VERSION = "0.2.3"
6
6
  end
data/lib/ctx_parser.rb CHANGED
@@ -7,6 +7,7 @@ module CtxParser
7
7
  end
8
8
 
9
9
  require_relative "ctx_parser/parser"
10
+ # 直接加载 parser 时,命名空间 autoload 可能重入此入口;此时 Parser 尚未定义。
10
11
  if CtxParser.const_defined?(:Parser, false)
11
12
  require_relative "ctx_parser/configuration"
12
13
  require_relative "ctx_parser/values"
@@ -14,4 +15,5 @@ if CtxParser.const_defined?(:Parser, false)
14
15
  require_relative "ctx_parser/commands"
15
16
  require_relative "ctx_parser/profile"
16
17
  require_relative "ctx_parser/registry"
18
+ require_relative "ctx_parser/banner"
17
19
  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.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -29,14 +29,14 @@ dependencies:
29
29
  requirements:
30
30
  - - ">="
31
31
  - !ruby/object:Gem::Version
32
- version: '0'
32
+ version: 1.2.3
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
- version: '0'
39
+ version: 1.2.3
40
40
  description: Reusable configuration documents, command declarations, platform profiles,
41
41
  immutable results and parsing primitives for network automation.
42
42
  executables: []