ctx_parser 0.4.0 → 0.5.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 207ae07b7f1f12d8d8a91ce6ea1a89fc39bb2c566f3d1cebe289ab71012aadd5
4
- data.tar.gz: 1249139af57dd39b634ad948028af664af72eea4af7e5cb9a0137e4117f8eecd
3
+ metadata.gz: d926ccda7788e5126e7dbd90230a48073eb13d275badfdb13c4f502f06407b8f
4
+ data.tar.gz: 1fa99e3f02360e1eb9b77446e17156ca250214473a377c7d3e514bb71a32c1d9
5
5
  SHA512:
6
- metadata.gz: 43e613f54ffa2b660d9badc6e2ce1195ed9fbf5b3fd864c8b85102c0cfae4eb9aa3cac477c7e7c9e92116d3c3e40234ce58e0e6c08db1757106c78a1c0e976f4
7
- data.tar.gz: d8a2b623628265babbcaedc0f3d15b29257945917e6e4db13d6958680457c1d4f3028066d416c77ff2c2d29a3da8dde5d7520da920d8a40b8ca3794a7247bf04
6
+ metadata.gz: 93a927e92dfdb97cab57643787c7f5380fb11bf6a0bb5fd134fb89b03da0ab4c31c1b3d3b3c29f395b4b9d5884578800c3af0e94181df509441751a2f7c43dba
7
+ data.tar.gz: 2b614f708cefbd0f9da209f9d2a9ea3bdb908ff87998e63dd35ecd7de32e0ea3d8f7259d0bef0538b478cbb0d998fe48f7a80ceaaf2546a8b0c306ede1e63747
data/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ Changes to the public contract are recorded here. Versions follow Semantic Versi
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.5.0 (2026-10-30)
8
+
9
+ - Add Cursor#resolved? with a lazy, invalidated prefix index; Block/Capture completeness remains live after state changes and reset.
10
+ - Snapshot declaration, request, child, capture-source and record arrays before validation or callbacks without inheriting caller copy hooks.
11
+
12
+ - Make immutable `Commands` the single declaration boundary; add query grouping/selection and accept empty catalogs.
13
+ - Add `Profile#declarations`, constructor injection and `with_declarations`; remove the independent `COMMANDS`/`PARSERS` declaration protocol and mutable-directory caches.
14
+ - Reject foreign configuration blocks before callbacks; snapshot scope paths before entering callbacks and preserve live source completeness.
15
+ - Strip string subclasses, attached state and singleton behavior at snapshot, command, input and source-line boundaries.
16
+ - Rebuild frozen containers instead of reusing external object identity; preserve sharing within each copy and use Ruby string deduplication without an application-level cache.
17
+ - Keep selected model overrides stable in derived profiles; retain Result/Results fields, projection isolation and Records ownership semantics.
18
+ - Provide standalone configuration benchmarks and a 0.5 migration guide.
19
+
7
20
  ## 0.4.0 (2026-10-29)
8
21
 
9
22
  - Add immutable `covered_scopes` to Result, including equality, hashing, pattern matching and `with` semantics.
data/README-zh.md CHANGED
@@ -72,6 +72,8 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
72
72
 
73
73
  `complete: true` 只表示调用方确认输入完整接收,不证明所有语法都已理解。未知行、身份冲突及空表证据不足必须明确表达。结果的记录和诊断均须为 Hash 数组;结果及构建完成的文档诊断持有不可变快照。`inspect`、`to_s` 和 pretty printing 不输出结果正文与诊断内容,显式读取记录仍返回原始事实。来源游标有意保留输入原文,含秘密时不可写入日志。
74
74
 
75
+ `Cursor#resolved?(first_index, last_index)` 查询零起始闭区间;Block/Capture 完整性继续读取实时来源状态。惰性前缀索引在解决状态变化后失效,reset 时清空,详见 [0.5 契约](docs/contracts-0.5.md)。
76
+
75
77
  本 gem 不提供厂商命令目录、传输、持久化或健康策略。参数和注册表配置错误保持异常,由调用方在对应边界处理。
76
78
 
77
79
  详细厂商语义、历史验证记录和 Motor 专用集成说明保留在[架构参考](docs/architecture-zh.md)。其中宿主集成代码需要 Motor,不属于独立 gem API。适配层位于 [Motor 仓库](https://github.com/gatework/motor/tree/main/backend/lib) 的 `backend/lib/baseline`、`backend/lib/vxlan` 和 `backend/lib/wireless`。
@@ -86,11 +88,11 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
86
88
 
87
89
  `Cursor.new(text, max_bytes: limit)` 和 `Document.new(source, max_bytes: limit)` 可在构建或消费来源行前限制输入字节数。传入已有游标时,限制针对其归一化内容。非法编码仍抛出错误;已知为 Latin-1 的输入可先标记 `Encoding::ISO_8859_1`,随后自动转为 UTF-8,不静默替换字节并生成事实。
88
90
 
89
- `MODEL_PARSERS` 的型号键与请求型号一样去除两侧空白并转为小写,归一化冲突和无效覆盖均被拒绝。`Registry` 在注册时校验静态命令与解析器目录;动态目录在使用时仍需校验,缺失实现抛出 `ArgumentError`。
91
+ `MODEL_PARSERS` 的型号键与请求型号一样去除两侧空白并转为小写,归一化冲突和无效覆盖均被拒绝。`Registry` 在注册时校验 Commands 声明及型号覆盖;Profile 创建时快照选中的覆盖,派生实例继续持有原先的型号设置。
90
92
 
91
- Profile 为深冻结的普通命令目录复用查询列表与成员索引,包括发现阶段产生的目录。替换目录后重建索引;可变条目仍在每次查找时校验。`bundle exec ruby -Ilib benchmark/profile.rb` 测量 32、128、512 条合成命令的准备开销,并校验选择顺序与未知命令拒绝行为。
93
+ `Commands` 独立持有不可变查询序列及命令到视图的索引。静态声明使用 `DECLARATIONS = CtxParser::Commands.new(...)`,也可向 `Profile.new` 传入 `declarations:`。发现阶段通过 `profile.with_declarations(new_catalog)` 返回副本,原实例保留原目录。`commands`、`parsers` 只是派生查询接口,旧的 `COMMANDS/PARSERS` 双目录声明会被拒绝。空目录合法,查询不存在的维度仍抛出 `ArgumentError`。详见 [0.5 契约与迁移](docs/contracts-0.5.md)。`benchmark/profile.rb` 覆盖 32–4096 条命令,`benchmark/commands.rb` 同时测量声明构造成本。
92
94
 
93
- `Records` 持有自己的筛选数组,行对象仍属于调用方;使用期间应保持行不变,需要快照时传入 `Immutable.copy(rows)`。`Immutable.copy` 在逐层校验后可复用深冻结普通容器;浅冻结、特殊 Hash 行为及循环引用仍按原规则复制或拒绝。整数转换仅在 `min` 允许负数或为 `nil` 时接受负号,默认仍只接受非负数。
95
+ `Records` 持有自己的筛选数组,行对象仍属于调用方;使用期间应保持行不变,需要快照时传入 `Immutable.copy(rows)`。`Immutable.copy` 即使遇到冻结容器也重建普通容器,单次复制中保留共享引用并拒绝循环;不扫描方法表,也不引入应用级全局缓存。字符串经 `String.new` 和 Ruby 去重后冻结,保留编码与字节。不同次复制不保证容器对象身份相同;`Immutable.copy_string` 提供相同的文本边界并省去容器遍历表。整数转换仅在 `min` 允许负数或为 `nil` 时接受负号,默认仍只接受非负数。
94
96
 
95
97
  ## 开发与测试
96
98
 
data/README.md CHANGED
@@ -72,6 +72,8 @@ result.records.each { |row| puts "#{row[:name]}: #{row[:state]}" }
72
72
 
73
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
+ `Cursor#resolved?(first_index, last_index)` queries a zero-based inclusive source range. Block and Capture completeness stays live; a lazy prefix index is invalidated when resolved state changes and cleared by reset. See the [0.5 contracts](docs/contracts-0.5.md).
76
+
75
77
  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
78
 
77
79
  Detailed vendor behavior, historical validation notes, and Motor-only integration are preserved in [the architecture reference](docs/architecture-zh.md). Host integration examples in that reference require Motor; they are not standalone gem APIs. Motor adapters live under `backend/lib/baseline`, `backend/lib/vxlan`, and `backend/lib/wireless` in the [Motor repository](https://github.com/gatework/motor/tree/main/backend/lib).
@@ -86,11 +88,11 @@ Address conversion uses `Values.ip_address`, `Values.ip_address?`, and `Values.m
86
88
 
87
89
  `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
90
 
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`.
91
+ `MODEL_PARSERS` model names are stripped and lowercased just like the requested model. Normalization collisions and invalid overrides are rejected. `Registry` validates Commands declarations and model overrides when registering a profile. `Profile` snapshots the selected overrides; a derived profile retains those overrides even if the original model mapping is later changed.
90
92
 
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.
93
+ `Commands` owns immutable query lists and a command-to-view index. Declare `DECLARATIONS = CtxParser::Commands.new(...)`, or pass `declarations:` to `Profile.new`. Discovery returns `profile.with_declarations(new_catalog)`; existing profiles keep their original catalog. `commands` and `parsers` are read-only derived views. Independent `COMMANDS`/`PARSERS` declarations are rejected. Empty catalogs are valid, while selecting an absent dimension raises `ArgumentError`. See [the 0.5 contract and migration guide](docs/contracts-0.5.md). `benchmark/profile.rb` exercises 32, 128, 512 and 4096 commands; `benchmark/commands.rb` also measures catalog construction.
92
94
 
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.
95
+ `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` rebuilds containers even when frozen, preserves shared references within each copy, and rejects cycles. This removes attached state, Hash defaults and singleton behavior without scanning method tables or maintaining a global cache. String snapshots use `String.new` followed by Ruby string deduplication; encoding and bytes are preserved. Container identity is not preserved across separate copies. `Immutable.copy_string` provides the same text boundary without allocating a container traversal map. Integer conversion accepts a minus sign only when `min` permits negative values (or is `nil`); the default remains nonnegative.
94
96
 
95
97
  ## Development and tests
96
98
 
@@ -126,4 +128,4 @@ MIT. See [LICENSE](LICENSE).
126
128
 
127
129
  `Result#partial?` identifies partial parsing; `#available?` is true for complete or partial results. Failed and unsupported results cannot contain records. Availability does not establish completeness or network health.
128
130
 
129
- 本次使用 ctx_parser 0.4 系列契约;`Result` 新增 `covered_scopes`,单行命令统一由 `CtxParser::Commands.command_text` 校验。跨模块约定见 [ctx_runner 契约迁移](https://github.com/gatework/ctx_runner/blob/main/docs/contracts-2026-10-29.md)。
131
+ The 0.5 series changes profile declarations; Result fields, including `covered_scopes`, remain unchanged. Cross-module contracts are described in the [Runner migration guide](https://github.com/gatework/ctx_runner/blob/main/docs/contracts-2026-10-29.md).
@@ -0,0 +1,70 @@
1
+ # ctx_parser 0.5 契约与迁移
2
+
3
+ 本轮保持现有生产类和模块,收敛声明入口及状态边界。最低运行时为 Ruby 4.0.6。
4
+
5
+ ## 职责与所有权
6
+
7
+ | 抽象 | 职责及所有权 |
8
+ | --- | --- |
9
+ | `Commands` | 校验一次声明,拥有冻结的命令、视图和解析器索引;解析器类本身由调用方维护 |
10
+ | `Profile` | 持有一份声明和已选型号覆盖,选择解析器并隔离视图投影错误 |
11
+ | `Parser` | 文本与传输完整性入口;厂商子类负责解释语法 |
12
+ | `Configuration` | 每次解析独立维护文档、来源状态和临时结果路径 |
13
+ | `Cursor` / `Document` / `Block` | 来源行、缩进范围及实时语义状态;读过不等于解析成功 |
14
+ | `Capture` | 事实和路径是不可变快照,完整性继续查询来源状态 |
15
+ | `Result` / `Results` | 不可变事实与诊断;共享回显的每个视图独立保留状态 |
16
+ | `Immutable` / `Values` / `Records` | 纯数据快照、严格值转换、逻辑身份分组;Records 借用行对象 |
17
+
18
+ ## 单一声明入口
19
+
20
+ ```ruby
21
+ class DeviceProfile < CtxParser::Profile
22
+ DECLARATIONS = CtxParser::Commands.new([
23
+ { dimensions: %i[interfaces counters], command: "show interfaces", parser: InterfacesParser },
24
+ { dimensions: [ :counters ], command: "show counters", parser: CountersParser }
25
+ ], multi_command: true)
26
+ end
27
+
28
+ profile = DeviceProfile.new(model: "fixture")
29
+ profile.commands_for(:counters) # 两条独立命令,不能拼接其正文
30
+ profile.queries(%i[interfaces counters]) # 命令 => 请求视图,保持顺序
31
+ profile.parser_for(:counters, command: "show counters")
32
+
33
+ expanded = profile.with_declarations(CtxParser::Commands.new(
34
+ profile.declarations.entries + additional_entries, multi_command: true
35
+ ))
36
+ ```
37
+
38
+ `Profile.new(model: nil, declarations: self.class::DECLARATIONS)` 允许显式注入目录。`Commands.new([])` 表示无查询;`queries([])` 返回冻结空映射,选择不存在的维度或从空请求推断命令仍抛出 `ArgumentError`。
39
+
40
+ 删除子类的 `COMMANDS`、`PARSERS` 常量,改用 `DECLARATIONS`。读取旧常量的代码迁移到 `Profile::DECLARATIONS.by_dimension` / `.parsers`,或实例的 `commands` / `parsers`。旧的双目录声明会报告迁移错误;覆盖 `commands` 或 `parsers` 不再改变解析器选择。
41
+
42
+ `Commands#commands_for` 总是返回冻结数组;`by_dimension` 保持单命令为 String、多命令为 Array 的既有读取形状。`select_command(dimensions, command: nil)` 要求非空、唯一且已声明的维度;只有唯一公共命令才能隐式推断。显式命令必须提供全部请求视图,选择结果返回目录中已经校验并冻结的命令文本。
43
+
44
+ 每次 Profile 操作只读取一次目录,命令与实现来自同一快照。型号键仍去除外围空白并转为小写;所有声明的型号覆盖在注册或实例创建时校验。`with_declarations` 保留已经选定的覆盖和子类状态,不重跑初始化;若新目录删除了选中覆盖需要的命令,则拒绝替换。改变类常量仅影响新实例。
45
+
46
+ ## 来源与快照边界
47
+
48
+ `analyze`、`analyze_children`、`ignore`、`scope` 在回调前拒绝其他游标的块。参数失败不修改来源状态。`scope` 进入前固定路径,结束时复制产生的事实;嵌套异常仍恢复父路径。
49
+
50
+ `Immutable.copy` 和 `copy_string` 不保留字符串子类、附加状态或单例行为。输入、命令和来源行采用同一纯文本原则;编码及原始换行策略不变。循环容器仍拒绝。容器即使冻结也重建,单次复制通过身份索引保留共享引用;不同次复制不保证同一容器对象身份。字符串通过 `String.new` 后调用 Ruby 的一元 `-` 去重并冻结。该策略隔离所有可见性的单例行为,也避免枚举每个值的方法表;不新增应用级缓存。
51
+
52
+ 这是对原计划复用冻结容器策略的调整:全量反射检查在本机让重复包装冻结记录慢约 9 倍,且空扩展模块后续新增方法仍可能改变已冻结输入的行为。重建容器消除了这两类问题。记录的值、顺序和一次快照内部的共享引用关系不变。
53
+
54
+ 未更改 Result 字段 `status/records/issues/covered_scopes`、complete/partial/failed/unsupported 状态、共享视图失败隔离、来源完整性的实时查询或 Records 的借用约定。`complete: true` 仍只是调用方的接收完整性声明。
55
+
56
+ ## 消费模块与验证
57
+
58
+ 候选版本为 ctx_parser 0.5.0、ctx_driver/ctx_runner 0.5.1、ctx_checker 0.7.0、vxlan 0.9.0、wireless-rb 0.10.0。Driver/Runner 接受 parser 0.4 与 0.5;迁移后的三个领域模块要求 parser 0.5 及相应 Driver/Runner 补丁版本。报告 schema 不变。
59
+
60
+ 运行 `bundle exec rake`、`bundle exec rake build` 和 `ruby script/verify_package.rb pkg/ctx_parser-0.5.0.gem`。`examples/declarations.rb` 演示新入口;`benchmark/commands.rb`、`profile.rb`、`snapshots.rb`、`source.rb`、`configuration.rb` 分别覆盖声明、选择、事实快照与来源追溯。性能比较必须同时包含构建、首次使用与复用成本,并校验结果相同。
61
+
62
+ 本地测量与正确性成本见 [性能记录](performance-0.5.md)。
63
+
64
+ ## 后续打磨:容器与来源区间
65
+
66
+ 声明、解析请求、子块、捕获来源列表和 Records 行列表在校验及回调前通过 Array.new 固定普通数组。列表由库拥有,解析器类、来源块与 Records 行仍按原契约借用;不继承调用方的 dup/map 行为。
67
+
68
+ `Cursor#resolved?(first_index, last_index)` 接受零起始闭区间。越界参数抛出 IndexError,逆序区间抛出 ArgumentError;空文档没有可查询的有效闭区间。Block#complete? 委托该查询,Capture 的完整性仍跟随当前来源状态。
69
+
70
+ Cursor 按需构建未解决行数量的前缀索引,只延伸到已查询末行。mark 在 resolved/unresolved 分组变化时将有效边界退回修改位置,parsed 与 ignored 互换不使索引失效。unread/defer 通过 mark 更新,reset! 释放索引;读取、回退和 rewind 不改变语义状态。稳定查询 O(1),失效登记 O(1),重建成本与需要重算的前缀区间长度成正比,空间最多 O(n)。索引属于单次解析游标,不承诺线程共享或缓存旧完整性结论。
@@ -0,0 +1,64 @@
1
+ # ctx_parser 0.5 本地性能记录
2
+
3
+ 环境:RVM Ruby 4.0.6,Linux x86_64;基线为 ctx_parser 0.4.0(提交 `0aacc77a7b247f00ad59350517849474aca49726`)。上一轮候选生产源码摘要:`a6cada37ed67e8048a1ea9a41bfa126b1474af4494135d4daa2964929be0d4c1`。
4
+
5
+ 声明与快照基准按 baseline/candidate/candidate/baseline 顺序运行两轮,取四个独立进程样本中位数;来源基准采用一轮交错顺序,各进程执行三次。测量时没有并行测试。所有场景校验命令顺序、解析值或来源范围。以下是合成数据结果,不代表设备或网络吞吐。
6
+
7
+ ## 声明、选择与快照
8
+
9
+ | 场景 | 工作量 | 基线 ms | 候选 ms | 基线分配 | 候选分配 |
10
+ | --- | --- | ---: | ---: | ---: | ---: |
11
+ | 构建完整目录 | 4096 条目录 × 10 次 | 178.155 | 204.748 | 245813 | 286793 |
12
+ | 构建目录并解析前 128 个命令 | 4096 条目录 × 10 次 | 195.480 | 189.548 | 354813 | 312443 |
13
+ | 复用目录创建 Profile 并首次选择 | 100 个 Profile | 342.825 | 0.420 | 821403 | 603 |
14
+ | 重复显式选择 | 12800 次选择 | 17.618 | 17.192 | 38503 | 25703 |
15
+ | 重复封装冻结记录 | 10 万条记录 × 3 次 | 580.662 | 804.373 | 300053 | 600043 |
16
+
17
+ 查询索引在 Commands 中构建并跨 Profile 共享,首次选择不再重复校验整个目录。重复选择分配减少约 33%;完整构建加解析分配减少约 12%,该场景耗时基本持平。单独目录构建分配增加约 17%,耗时增加约 15%,因此不能把首次选择的降幅视为整条流程的收益。
18
+
19
+ 最终快照策略重建普通容器,按来源身份保留一次复制内部的共享引用,并通过 Ruby 去重字符串。它隔离私有单例方法及后来改变的扩展模块,无需方法表反射或应用级缓存。外部冻结容器不再保证同一对象身份,记录值、编码和顺序保持不变。
20
+
21
+ 重复包装 10 万条冻结记录的样本耗时增加约 39%,分配约为基线两倍。这是本轮保留的隔离成本,不宣称所有场景性能提升。曾尝试逐个对象反射检查后复用,实测该方案慢约 9.4 倍、分配约 10 倍,已移除。
22
+
23
+ ## 来源处理
24
+
25
+ | 场景 | 基线 ms | 候选 ms | 基线分配 | 候选分配 |
26
+ | --- | ---: | ---: | ---: | ---: |
27
+ | configuration | 571.201 | 718.363 | 1080210 | 1170467 |
28
+ | cursor | 258.137 | 336.925 | 600045 | 680045 |
29
+ | document | 480.366 | 552.519 | 1050066 | 1130067 |
30
+ | fact_sources | 10.803 | 14.535 | 23078 | 26080 |
31
+
32
+ cursor/document 使用 2 万行输入各构造三次;configuration 使用 2 万余行合成语法解析三次;fact_sources 将一份事实关联到 1000 个来源。来源状态与范围断言全部通过,这些来源场景的处理耗时增加约 15–35%,分配增加约 7–13%。
33
+
34
+ 复现入口为 `benchmark/commands.rb`、`profile.rb`、`snapshots.rb`、`source.rb` 和自包含的 `configuration.rb`。本轮交错比较脚本、各次原始输出及摘要保存在工作区 `.parser-review-20261030/`。文本处理使用 Ruby 的 [String.new](https://docs.ruby-lang.org/en/4.0/String.html#method-c-new) 与 [String#-@](https://docs.ruby-lang.org/en/4.0/String.html#method-i-2D-40),不依赖 VM 内部对象标志。
35
+
36
+ ## 后续打磨:来源区间索引与普通列表快照
37
+
38
+ 本节基线为上一轮未提交的 0.5.0 候选,完整保存在 `.foundation-review-20261030/baseline/ctx_parser/`;不是已发布 0.4.0。使用 RVM Ruby 4.0.6、同一脚本和输入,按 baseline/candidate/candidate/baseline 串行执行,每进程三个样本,取六个样本中位数。构造和初始标记不计入完整性查询时间,首次惰性索引构建计入查询时间;逐次结果均在计时外校验。
39
+
40
+ 当前查询实现源码摘要:`3fcc73fc55adccb651ef171120f0165eac98b6667a86fb8a8ca22b0d5961148b`。
41
+
42
+ | 场景 | 基线 ms | 候选 ms | 基线分配 | 候选分配 |
43
+ | --- | ---: | ---: | ---: | ---: |
44
+ | 首次完整区间查询(1 次) | 3.244 | 1.249 | 3 | 1 |
45
+ | 稳定完整区间查询(1000 次) | 3616.764 | 1.920 | 3000 | 1 |
46
+ | 首行未解决(1000 次) | 1.141 | 0.592 | 4000 | 0 |
47
+ | 单行区间(1000 次) | 1.198 | 0.577 | 3000 | 0 |
48
+ | 17 行区间(1000 次) | 6.523 | 0.863 | 3000 | 1 |
49
+ | 末行状态交替(200 次) | 700.055 | 1.467 | 700 | 1 |
50
+ | 首行状态交替(200 次) | 346.471 | 131.373 | 700 | 1 |
51
+ | 只修改状态(1000 次) | 0.443 | 0.453 | 0 | 0 |
52
+ | 500 层树的全部节点 | 43.618 | 0.495 | 1500 | 1 |
53
+ | 封装 10 万条冻结记录 | 249.516 | 252.717 | 200013 | 200013 |
54
+ | Cursor 构造 | 355.201 | 336.104 | 680045 | 680045 |
55
+ | Document 构造 | 594.314 | 581.803 | 1130067 | 1130067 |
56
+ | 配置解析 | 737.292 | 749.106 | 1168628 | 1160241 |
57
+ | 1000 个事实来源 | 14.355 | 13.802 | 26080 | 26082 |
58
+ | 来源索引查找 | 2.457 | 3.780 | 1 | 1 |
59
+
60
+ 普通区间场景使用 10,001 行。完整索引包含 10,002 个计数,ObjectSpace 实测占用 80,464 字节;17 行区间仅建 18 个计数、占 192 字节。未查询、单行和首行未解决的场景不分配索引。Ruby Array 的容量按 VM 策略增长,字节数不属于公共保证。
61
+
62
+ 稳定查询与末行反复修改得到明显改善;首行变更后仍须重建后续计数,最坏重建成本仍为 O(n),不宣称任意状态更新后的查询都是 O(1)。只修改状态和完整配置处理总体接近基线。严格快照复制策略未放宽,10 万记录封装的对象分配不变。来源查找样本从约 2.46 ms 波动至 3.78 ms,该路径没有算法修改,保留原始数据而不将其解释成优化收益。
63
+
64
+ 可用 `ruby -I/path/to/lib benchmark/completeness.rb` 比较不同源码;快照及来源基准仍使用 snapshots/source/configuration 脚本。全部原始样本及源码摘要保存在本次工作区验证目录;这些合成结果不代表设备或网络吞吐。
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ctx_parser"
4
+
5
+ # 合成解析器只演示声明与投影,不连接设备。
6
+ class DeclarationStatusParser < CtxParser::Parser
7
+ private
8
+
9
+ def parse_output(text, complete:)
10
+ CtxParser::Result.new(status: complete ? :complete : :partial, records: [ { value: text.strip } ])
11
+ end
12
+ end
13
+
14
+ catalog = CtxParser::Commands.new([
15
+ { dimensions: %i[inventory counters], command: "show inventory", parser: DeclarationStatusParser }
16
+ ])
17
+ profile = CtxParser::Profile.new(declarations: catalog)
18
+ views = profile.parse_many(%i[inventory counters], "synthetic\n", complete: true)
19
+ abort "Shared view selection changed" unless views.keys == %i[inventory counters] && views.values.all?(&:complete?)
20
+
21
+ expanded = profile.with_declarations(CtxParser::Commands.new(catalog.entries + [
22
+ { dimensions: [ :alarms ], command: "show alarms", parser: DeclarationStatusParser }
23
+ ]))
24
+ abort "Original catalog changed" if profile.commands.key?(:alarms)
25
+ abort "Expanded query missing" unless expanded.command(:alarms) == "show alarms"
26
+ puts "Commands: shared views and independent profile expansion passed"
@@ -17,6 +17,7 @@ module CtxParser
17
17
  raise ArgumentError, "Invalid source block range"
18
18
  end
19
19
 
20
+ children = Array.new(children) if children.is_a?(Array)
20
21
  previous_index = header.index
21
22
  unless children.is_a?(Array) && children.all? do |child|
22
23
  next false unless child.is_a?(Block) && child.cursor.equal?(cursor) &&
@@ -29,7 +30,7 @@ module CtxParser
29
30
  end
30
31
 
31
32
  @cursor, @header, @last_index = cursor, header, last_index
32
- @children = children.dup.freeze
33
+ @children = children.freeze
33
34
  freeze
34
35
  end
35
36
 
@@ -102,7 +103,7 @@ module CtxParser
102
103
  # 返回当前配置块各来源行的处理状态。
103
104
  def states = lines.map { |source| cursor.state(source.index) }.freeze
104
105
  # 要求块内所有来源行均已解析或明确忽略。
105
- def complete? = (index..last_index).all? { |index| Cursor::RESOLVED_STATES.include?(cursor.state(index)) }
106
+ def complete? = cursor.resolved?(index, last_index)
106
107
  # 序列化诊断元数据,不包含原始来源文本。
107
108
  def as_json(*) = { first_line: line, last_line: last_index + 1, children: children.size }
108
109
  # 展示对象身份与状态,避免泄露原始回显或凭据。
@@ -8,17 +8,22 @@ module CtxParser
8
8
  Snapshot = Data.define(:data, :path) do
9
9
  # 此边界仅接收调用方原始数据,各快照自行持有深度冻结的副本。
10
10
  def initialize(data:, path:)
11
- unless path.is_a?(Array) && path.all? { |key| key.nil? || key.is_a?(Symbol) || key.is_a?(String) || key.is_a?(Integer) }
12
- raise ArgumentError, "Capture path must contain record keys or indexes"
13
- end
14
-
15
- super(data: Immutable.copy(data), path: Immutable.copy(path))
11
+ super(data: Immutable.copy(data), path: Capture.path_snapshot(path))
16
12
  end
17
13
  end
18
14
  private_constant :Snapshot
19
15
 
20
16
  attr_reader :block, :data, :path
21
17
 
18
+ # 作用域进入前与事实捕获时共用路径边界,不保留调用方可变键。
19
+ def self.path_snapshot(path)
20
+ unless path.is_a?(Array) && path.all? { |key| key.nil? || key.is_a?(Symbol) || key.is_a?(String) || key.is_a?(Integer) }
21
+ raise ArgumentError, "Capture path must contain record keys or indexes"
22
+ end
23
+
24
+ Immutable.copy(path)
25
+ end
26
+
22
27
  # 冻结解析事实及其来源块和结果路径。
23
28
  def initialize(block:, data:, path:)
24
29
  require_relative "block" unless defined?(Block)
@@ -30,13 +35,14 @@ module CtxParser
30
35
  # 同一事实的多个来源共享已复制的值,不共享未经校验的调用方容器。
31
36
  def self.for_blocks(blocks, data:, path:)
32
37
  require_relative "block" unless defined?(Block)
38
+ blocks = Array.new(blocks) if blocks.is_a?(Array)
33
39
  unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) }
34
40
  raise ArgumentError, "Capture sources must be blocks"
35
41
  end
36
42
  return [].freeze if blocks.empty?
37
43
 
38
44
  snapshot = Snapshot.new(data: data, path: path)
39
- blocks.map { |block| allocate.tap { |capture| capture.send(:initialize_snapshot, block, snapshot) } }.freeze
45
+ blocks.map! { |block| allocate.tap { |capture| capture.send(:initialize_snapshot, block, snapshot) } }.freeze
40
46
  end
41
47
 
42
48
  # 返回配置块或捕获事实对应的精确来源文本。
@@ -9,30 +9,78 @@ module CtxParser
9
9
 
10
10
  # 查询文本的唯一值边界;复制后冻结,不冻结调用方提供的字符串。
11
11
  def self.command_text(value)
12
- unless value.is_a?(String) && !value.strip.empty? && !/[\x00-\x1f\x7f]/.match?(value)
12
+ raise ArgumentError, "One command without control characters is required" unless value.is_a?(String)
13
+
14
+ value = String.new(value).freeze
15
+ if value.strip.empty? || /[\x00-\x1f\x7f]/.match?(value)
13
16
  raise ArgumentError, "One command without control characters is required"
14
17
  end
15
- value.frozen? ? value : value.dup.freeze
18
+ value
16
19
  end
17
20
 
18
21
  # 校验并冻结受支持的维度到命令声明。
19
22
  def initialize(entries, multi_command: false)
20
- unless entries.is_a?(Array) && entries.any?
23
+ unless entries.is_a?(Array)
21
24
  raise ArgumentError, "Commands must contain declarations"
22
25
  end
23
26
  raise ArgumentError, "Multi-command selection must be boolean" unless multi_command == true || multi_command == false
24
27
 
25
28
  @multi_command = multi_command
26
- @by_dimension, @parsers = {}, {}
27
- @entries = entries.map { |entry| register(entry) }.freeze
28
- @by_dimension.each_value { |value| value.freeze }
29
+ @by_dimension, @parsers, @entries_by_command = {}, {}, {}
30
+ @entries = Array.new(entries).map! { |entry| register(entry) }.freeze
31
+ @queries = @by_dimension.transform_values { |value| Array(value).freeze }.freeze
32
+ @entries_by_command.freeze
29
33
  @by_dimension.freeze
30
34
  @parsers.freeze
31
35
  freeze
32
36
  end
33
37
 
38
+ # 始终返回同一份已校验的查询序列,无需在每个策略实例重新编译。
39
+ def commands_for(name)
40
+ @queries.fetch(name) { raise ArgumentError, "Unsupported parser dimension" }
41
+ end
42
+
43
+ # 保留请求与声明顺序;共享命令只计划一次。
44
+ def queries(dimensions)
45
+ validate_dimensions!(dimensions)
46
+ grouped = dimensions.each_with_object({}) do |name, result|
47
+ commands_for(name).each { |command| (result[command] ||= []) << name }
48
+ end
49
+ grouped.each_value(&:freeze)
50
+ grouped.freeze
51
+ end
52
+
53
+ # 显式选择通过命令索引核对视图,成本不随整个目录大小增长。
54
+ def select_command(dimensions, command: nil)
55
+ validate_dimensions!(dimensions)
56
+ raise ArgumentError, "Parser dimensions must not be empty" if dimensions.empty?
57
+
58
+ unless command.nil?
59
+ raise ArgumentError, "Selected command must be text" unless command.is_a?(String)
60
+
61
+ # 只借用输入字节查找,返回目录内已校验的文本,不重复编译或保留调用方行为。
62
+ entry = @entries_by_command[String.new(command)]
63
+ unless entry && dimensions.all? { |name| entry[:dimensions].include?(name) }
64
+ raise ArgumentError, "Selected command does not provide all parser dimensions"
65
+ end
66
+ entry[:command]
67
+ else
68
+ candidates = dimensions.map { |name| commands_for(name) }.reduce { |common, queries| common & queries }
69
+ raise ArgumentError, "One CLI output must belong to one command" unless candidates.one?
70
+
71
+ candidates.first
72
+ end
73
+ end
74
+
34
75
  private
35
76
 
77
+ # 空请求可产生空查询计划;命令选择另行要求至少一个视图。
78
+ def validate_dimensions!(dimensions)
79
+ unless dimensions.is_a?(Array) && (dimensions.size < 2 || dimensions.uniq == dimensions)
80
+ raise ArgumentError, "Query dimensions must be a unique array"
81
+ end
82
+ end
83
+
36
84
  # 仅复制声明数据;解析器类保留正常 Ruby 生命周期。
37
85
  def register(entry)
38
86
  unless entry.is_a?(Hash) && entry.size == 3 && %i[dimensions command parser].all? { |key| entry.key?(key) }
@@ -40,6 +88,7 @@ module CtxParser
40
88
  end
41
89
 
42
90
  dimensions, command, parser = entry.values_at(:dimensions, :command, :parser)
91
+ dimensions = Array.new(dimensions) if dimensions.is_a?(Array)
43
92
  unless dimensions.is_a?(Array) && dimensions.any? && dimensions.all? { |name| name.is_a?(Symbol) && !name.empty? } &&
44
93
  dimensions.uniq == dimensions && (@multi_command || dimensions.none? { |name| @by_dimension.key?(name) })
45
94
  raise ArgumentError, "Command dimensions must be unique nonempty symbols"
@@ -48,13 +97,13 @@ module CtxParser
48
97
  raise ArgumentError, "Command is already declared" if @parsers.key?(command)
49
98
  raise ArgumentError, "Command parser must inherit from Parser" unless parser.is_a?(Class) && parser < Parser
50
99
 
51
- dimensions = dimensions.dup.freeze
100
+ dimensions.freeze
52
101
  dimensions.each do |name|
53
102
  # 索引数组由本实例持有,构建完成后统一冻结;追加不复制已有命令。
54
103
  @by_dimension[name] = @by_dimension.key?(name) ? (Array(@by_dimension[name]) << command) : command
55
104
  end
56
105
  @parsers[command] = parser
57
- { dimensions: dimensions, command: command, parser: parser }.freeze
106
+ @entries_by_command[command] = { dimensions: dimensions, command: command, parser: parser }.freeze
58
107
  end
59
108
  end
60
109
  end
@@ -32,6 +32,12 @@ module CtxParser
32
32
 
33
33
  # 仅在解释成功后标记来源行;嵌套转换失败归因到最内层来源。
34
34
  def analyze(block)
35
+ validate_source!(block)
36
+ analyze_source(block) { yield }
37
+ end
38
+
39
+ # 来源参数错误不进入语义异常处理,避免污染当前文档或遮盖原异常。
40
+ def analyze_source(block)
35
41
  value = yield
36
42
  cursor.mark(block.index, state: :parsed) if cursor.state(block.index) == :pending
37
43
  if cursor.state(block.index) == :parsed
@@ -53,12 +59,15 @@ module CtxParser
53
59
 
54
60
  # 各子节点独立保留来源状态及错误位置。
55
61
  def analyze_children(block, &operation)
62
+ validate_source!(block)
56
63
  block.children.each { |child| analyze(child) { operation.call(child) } }
57
64
  end
58
65
 
59
66
  # 进入临时结果路径,成功或失败后均恢复父作用域。
60
67
  def scope(block, path)
61
68
  previous_path = @source_path
69
+ validate_source!(block)
70
+ path = Capture.path_snapshot(path)
62
71
  @source_path = path
63
72
  value = yield
64
73
  capture(block, value, path)
@@ -74,6 +83,7 @@ module CtxParser
74
83
 
75
84
  # 迭代忽略领域外子树,不宣称其产生已解析事实。
76
85
  def ignore(block)
86
+ validate_source!(block)
77
87
  pending = [ block ]
78
88
  until pending.empty?
79
89
  entry = pending.pop
@@ -83,6 +93,13 @@ module CtxParser
83
93
  nil
84
94
  end
85
95
 
96
+ # 每次分析只接受当前文档的来源块,校验必须早于外部回调。
97
+ def validate_source!(block)
98
+ unless block.is_a?(Block) && block.cursor.equal?(cursor)
99
+ raise ArgumentError, "Source block must belong to this cursor"
100
+ end
101
+ end
102
+
86
103
  # 未处理的子节点保持待解析;具体解析器可诊断位置错误的相关命令。
87
104
  def unparsed_child(_block); end
88
105
  end
@@ -71,11 +71,32 @@ module CtxParser
71
71
  # 独立检查游标是否访问过该行,不与解释成功混淆。
72
72
  def visited?(index) = valid_index?(index) ? @visited[index] : nil
73
73
 
74
+ # 查询零起始闭区间的实时解决状态;索引按需延伸,重复查询不再扫描每行。
75
+ def resolved?(first_index, last_index)
76
+ validate_index!(first_index)
77
+ validate_index!(last_index)
78
+ raise ArgumentError, "Source range must be ordered" if first_index > last_index
79
+ return false unless RESOLVED_STATES.include?(@states[first_index])
80
+ return true if first_index == last_index
81
+
82
+ @unresolved_prefix ||= [ 0 ]
83
+ while @indexed_states <= last_index
84
+ unresolved = RESOLVED_STATES.include?(@states[@indexed_states]) ? 0 : 1
85
+ @unresolved_prefix[@indexed_states + 1] = @unresolved_prefix[@indexed_states] + unresolved
86
+ @indexed_states += 1
87
+ end
88
+ @unresolved_prefix[last_index + 1] == @unresolved_prefix[first_index]
89
+ end
90
+
74
91
  # 为有效来源行记录明确的语义状态。
75
92
  def mark(index, state: :parsed)
76
93
  validate_index!(index)
77
94
  raise ArgumentError, "Invalid line state" unless STATES.include?(state)
78
95
 
96
+ # 只有跨越解决/未解决分组才使后缀失效;未查询的来源不承担索引构建成本。
97
+ if index < @indexed_states && RESOLVED_STATES.include?(@states[index]) != RESOLVED_STATES.include?(state)
98
+ @indexed_states = index
99
+ end
79
100
  @states[index] = state
80
101
  self
81
102
  end
@@ -89,6 +110,7 @@ module CtxParser
89
110
  # 清空访问、语义状态及捕获事实,重新开始分析。
90
111
  def reset!
91
112
  @states = Array.new(size, :pending)
113
+ @unresolved_prefix, @indexed_states = nil, 0
92
114
  @visited = Array.new(size, false)
93
115
  @captures = []
94
116
  @captures_by_path, @ordered_by_path = {}, {}
@@ -136,6 +158,7 @@ module CtxParser
136
158
  # 注册归纳事实的不可变快照前,先校验全部来源。
137
159
  def capture_many(blocks, data:, path:)
138
160
  require_relative "block" unless defined?(Block)
161
+ blocks = Array.new(blocks) if blocks.is_a?(Array)
139
162
  unless blocks.is_a?(Array) && blocks.all? { |block| block.is_a?(Block) && block.cursor.equal?(self) }
140
163
  raise ArgumentError, "Blocks must belong to this cursor"
141
164
  end
@@ -11,6 +11,13 @@ module CtxParser
11
11
  copy_value(value, {}.compare_by_identity, 0)
12
12
  end
13
13
 
14
+ # 重建纯文本后交给 Ruby 去重,不继承任何可见性的方法或附加状态。
15
+ def self.copy_string(value)
16
+ raise ArgumentError, "Value must be text" unless value.is_a?(String)
17
+
18
+ -String.new(value)
19
+ end
20
+
14
21
  # 普通常量浅树沿用 Ruby 的映射;递归深度有界,深链交给显式后序栈。
15
22
  def self.copy_value(value, copies, depth)
16
23
  case value
@@ -23,18 +30,14 @@ module CtxParser
23
30
  return copy_deep(value, copies) if depth == 64
24
31
 
25
32
  copies[value] = COPYING
26
- if reusable?(value, copies, depth)
27
- copies[value] = value
28
- return value
29
- end
30
33
  copies[value] = if value.is_a?(Hash)
31
34
  result = {}
32
35
  value.each_pair { |key, item| result[copy_value(key, copies, depth + 1)] = copy_value(item, copies, depth + 1) }
33
36
  result.freeze
34
37
  else
35
- value.map { |item| copy_value(item, copies, depth + 1) }.freeze
38
+ Array.new(value).map! { |item| copy_value(item, copies, depth + 1) }.freeze
36
39
  end
37
- when String then value.frozen? ? value : value.dup.freeze
40
+ when String then copy_string(value)
38
41
  when Symbol, Integer, Float, Rational, NilClass, TrueClass, FalseClass then value
39
42
  when Complex
40
43
  # Complex 自身冻结,但可包含调用方定义的可变 Numeric 分量。
@@ -46,36 +49,18 @@ module CtxParser
46
49
  end
47
50
  private_class_method :copy_value
48
51
 
49
- # 仅复用无特殊 Hash 行为的深冻结普通容器;逐层验证,不信任外层 frozen?。
50
- def self.reusable?(value, copies, depth)
51
- return false unless value.frozen? && value.instance_variables.empty?
52
- if value.instance_of?(Array)
53
- value.all? { |item| copy_value(item, copies, depth + 1).equal?(item) }
54
- elsif value.instance_of?(Hash) && value.default.nil? && !value.default_proc && !value.compare_by_identity?
55
- value.each_pair do |key, item|
56
- return false unless copy_value(key, copies, depth + 1).equal?(key) && copy_value(item, copies, depth + 1).equal?(item)
57
- end
58
- true
59
- else
60
- false
61
- end
62
- end
63
- private_class_method :reusable?
64
-
65
52
  # 容器缓存按来源身份查找,普通 Hash 的键仍沿用值相等语义。
66
53
  def self.copy_deep(value, copies)
67
54
  pending = [ [ value, false ] ]
68
55
  until pending.empty?
69
56
  source, finishing = pending.pop
70
57
  if finishing
71
- copies[source] = if reusable?(source, copies, 0)
72
- source
73
- elsif source.is_a?(Hash)
58
+ copies[source] = if source.is_a?(Hash)
74
59
  result = {}
75
60
  source.each_pair { |key, item| result[snapshot(key, copies)] = snapshot(item, copies) }
76
61
  result.freeze
77
62
  else
78
- source.map { |item| snapshot(item, copies) }.freeze
63
+ Array.new(source).map! { |item| snapshot(item, copies) }.freeze
79
64
  end
80
65
  next
81
66
  end
@@ -93,7 +78,7 @@ module CtxParser
93
78
  pending << [ key, false ] if container?(key)
94
79
  end
95
80
  else
96
- source.each { |item| pending << [ item, false ] if container?(item) }
81
+ Array.new(source).each { |item| pending << [ item, false ] if container?(item) }
97
82
  end
98
83
  end
99
84
  copies.fetch(value)
@@ -8,13 +8,13 @@ module CtxParser
8
8
  # 校验来源文本,仅按请求调整编码或换行形式。
9
9
  def text(content, encoding: :preserve, newlines: :preserve, allow_nul: false, max_bytes: nil)
10
10
  raise ArgumentError, "CLI output must be text" unless content.is_a?(String)
11
+ value = String.new(content)
11
12
  raise ArgumentError, "Invalid encoding policy" unless %i[preserve utf8 transcode].include?(encoding)
12
13
  raise ArgumentError, "Invalid newline policy" unless %i[preserve lf delete_cr].include?(newlines)
13
14
  raise ArgumentError, "NUL policy must be boolean" unless [ true, false ].include?(allow_nul)
14
15
  raise ArgumentError, "Invalid byte limit" unless max_bytes.nil? || (max_bytes.is_a?(Integer) && max_bytes >= 0)
15
- raise ArgumentError, "CLI output exceeds byte limit" if max_bytes && content.bytesize > max_bytes
16
+ raise ArgumentError, "CLI output exceeds byte limit" if max_bytes && value.bytesize > max_bytes
16
17
 
17
- value = content.dup
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
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "immutable"
4
+
3
5
  module CtxParser
4
6
  # 不可变来源行保留原始换行,同时提供用于语法匹配的归一化文本。
5
7
  Line = Data.define(:index, :raw) do
@@ -8,10 +10,11 @@ module CtxParser
8
10
  raise ArgumentError, "Line index must be nonnegative" unless index.is_a?(Integer) && index >= 0
9
11
  raise ArgumentError, "Line content must be text" unless raw.is_a?(String)
10
12
 
13
+ raw = Immutable.copy_string(raw)
11
14
  # strip 已移除行结束空白,避免 chomp 依赖宿主进程的 $/。
12
15
  @text = raw.strip.freeze
13
16
  @indent = raw[/\A[ \t]*/].size
14
- super(index: index, raw: raw.frozen? ? raw : raw.dup.freeze)
17
+ super(index: index, raw: raw)
15
18
  end
16
19
 
17
20
  # 返回诊断使用的从 1 开始的行号。
@@ -3,85 +3,72 @@
3
3
  require_relative "commands"
4
4
 
5
5
  module CtxParser
6
- # 组合平台命令策略,仅覆盖已确认的型号差异。
6
+ # 组合一份不可变命令声明、型号选择与独立视图投影。
7
7
  class Profile
8
- COMMANDS = {}.freeze
9
- PARSERS = {}.freeze
8
+ DECLARATIONS = Commands.new([])
10
9
  MODEL_PARSERS = {}.freeze
11
10
 
12
- attr_reader :model
11
+ attr_reader :model, :declarations
13
12
 
14
- # 根据传入设备型号选择命令与解析行为。
15
- def initialize(model: nil)
13
+ # 静态与发现目录使用同一边界;每个实例持有完整声明快照。
14
+ def initialize(model: nil, declarations: self.class::DECLARATIONS)
16
15
  raise ArgumentError, "Device model must be text" unless model.nil? || model.is_a?(String)
17
16
 
18
- @model = model&.strip&.downcase&.freeze
19
- @model_parsers = model_parser_overrides
20
- cache_commands(self.class::COMMANDS, strict: true)
17
+ @model = model && String.new(model).strip.downcase.freeze
18
+ @model_parsers = self.class.send(:normalized_model_parsers, declarations).fetch(@model, {}.freeze)
19
+ @declarations = declarations
21
20
  end
22
21
 
23
- # 注册时核对静态目录;动态实例仍在实际查询时重新校验。
24
- def self.validate_declarations!
25
- directory, implementations = self::COMMANDS, self::PARSERS
26
- unless directory.is_a?(Hash) && implementations.is_a?(Hash) &&
27
- directory.keys.all? { |name| name.is_a?(Symbol) && !name.empty? }
28
- raise ArgumentError, "Profile commands and parsers must be named mappings"
22
+ # 复制策略实例后一次替换声明;校验失败不影响原实例或型号覆盖。
23
+ def with_declarations(declarations)
24
+ raise ArgumentError, "Profile declarations must be Commands" unless declarations.is_a?(Commands)
25
+ unless @model_parsers.each_key.all? { |command| declarations.parsers.key?(command) }
26
+ raise ArgumentError, "Model parsers must override declared commands"
29
27
  end
30
28
 
31
- queries = directory.values.flat_map do |value|
32
- entries = value.is_a?(Array) ? value : [ value ]
33
- unless entries.any? && entries.uniq == entries && entries.all? { |entry| valid_command?(entry) }
34
- raise ArgumentError, "Parser dimension must declare one or more unique commands"
35
- end
36
- entries
37
- end.uniq
38
- unless (queries - implementations.keys).empty? && (implementations.keys - queries).empty? &&
39
- implementations.values.all? { |parser| parser.is_a?(Class) && parser < Parser }
40
- raise ArgumentError, "Profile commands must match Parser implementations"
41
- end
42
- normalized_model_parsers
43
- true
29
+ dup.tap { |copy| copy.replace_declarations(declarations) }
44
30
  end
45
31
 
46
- # 校验静态目录中的单命令文本,不接受控制字符。
47
- def self.valid_command?(value)
48
- Commands.command_text(value)
32
+ # 注册与实例创建共同校验目录和全部型号覆盖,拒绝旧的双目录声明。
33
+ def self.validate_declarations!(declarations = self::DECLARATIONS)
34
+ normalized_model_parsers(declarations)
49
35
  true
50
- rescue ArgumentError
51
- false
52
36
  end
53
- private_class_method :valid_command?
54
-
55
- # 返回当前平台策略声明的命令目录。
56
- def commands = self.class::COMMANDS
57
37
 
58
- # 动态目录可替换解析器索引,同时保留型号特定选择。
59
- def parsers = self.class::PARSERS
60
-
61
- # 保留选择顺序并归组共享视图,不混用不同命令的正文。
62
- def queries(dimensions)
63
- unless dimensions.is_a?(Array) && dimensions.uniq == dimensions && (dimensions - commands.keys).empty?
64
- raise ArgumentError, "Query dimensions must be unique supported names"
38
+ # 型号目录在实例创建时快照;后续派生实例继承已经选定的覆盖。
39
+ def self.normalized_model_parsers(declarations)
40
+ owners = ancestors.take_while { |ancestor| ancestor != Object }
41
+ if owners.any? { |owner| owner.const_defined?(:COMMANDS, false) || owner.const_defined?(:PARSERS, false) }
42
+ raise ArgumentError, "Declare Commands through DECLARATIONS instead of COMMANDS/PARSERS"
65
43
  end
44
+ raise ArgumentError, "Profile declarations must be Commands" unless declarations.is_a?(Commands)
45
+ raise ArgumentError, "Model parsers must be a hash" unless self::MODEL_PARSERS.is_a?(Hash)
66
46
 
67
- dimensions_by_command = dimensions.each_with_object({}) do |name, queries|
68
- commands_for(name).each { |command| (queries[command] ||= []) << name }
47
+ normalized = {}
48
+ self::MODEL_PARSERS.each do |name, overrides|
49
+ unless name.nil? || name.is_a?(String) && !name.strip.empty?
50
+ raise ArgumentError, "Model parser keys must be device model names"
51
+ end
52
+ key = name && String.new(name).strip.downcase.freeze
53
+ raise ArgumentError, "Model parser names must be unique after normalization" if normalized.key?(key)
54
+ unless overrides.is_a?(Hash) && overrides.all? do |command, parser|
55
+ declarations.parsers.key?(command) && parser.is_a?(Class) && parser < Parser
56
+ end
57
+ raise ArgumentError, "Model parsers must override declared commands with Parser subclasses"
58
+ end
59
+ normalized[key] = overrides.to_h { |command, parser| [ Commands.command_text(command), parser ] }.freeze
69
60
  end
70
- dimensions_by_command.each_value(&:freeze)
71
- dimensions_by_command.freeze
61
+ normalized.freeze
72
62
  end
63
+ private_class_method :normalized_model_parsers
73
64
 
74
- # 单个维度可能需要多个独立回显;调用方必须分别保存正文。
75
- def commands_for(name)
76
- directory = commands
77
- declaration = directory.fetch(name) { raise ArgumentError, "Unsupported parser dimension" }
78
- cached = cache_commands(directory)
79
- return cached[:queries].fetch(name) if cached
80
-
81
- validated_queries(declaration)
82
- end
65
+ # 只读索引是声明的投影,不作为独立扩展入口。
66
+ def commands = declarations.by_dimension
67
+ def parsers = declarations.parsers
68
+ def queries(dimensions) = declarations.queries(dimensions)
69
+ def commands_for(name) = declarations.commands_for(name)
83
70
 
84
- # 解析支持的观测维度对应的命令。
71
+ # 单维度仍可能需要多条命令;只允许唯一命令隐式选择。
85
72
  def command(name)
86
73
  queries = commands_for(name)
87
74
  raise ArgumentError, "Parser dimension has multiple commands; select one explicitly" unless queries.one?
@@ -89,10 +76,11 @@ module CtxParser
89
76
  queries.first
90
77
  end
91
78
 
92
- # 确定维度使用的解析器,包括命令特定变体。
79
+ # 命令及其实现从同一份目录读取,再应用已校验的型号覆盖。
93
80
  def parser_for(name, command: nil)
94
- query = select_command([ name ], command)
95
- implementation_for(query)
81
+ catalog = declarations
82
+ query = catalog.select_command([ name ], command: command)
83
+ @model_parsers.fetch(query) { catalog.parsers.fetch(query) }
96
84
  end
97
85
 
98
86
  # 选择目标命令解析器,并投影单个请求维度。
@@ -100,127 +88,26 @@ module CtxParser
100
88
  parse_many([ name ], text, complete: complete, command: command).fetch(name)
101
89
  end
102
90
 
103
- # AP、Radio 等共享视图仅解析一次,确保状态与数量证据一致。
91
+ # 共享视图仅解析一次,扩展回调不能改变已经固定的请求或目录。
104
92
  def parse_many(dimensions, text, complete: false, command: nil)
93
+ dimensions = Array.new(dimensions) if dimensions.is_a?(Array)
105
94
  unless dimensions.is_a?(Array) && dimensions.any? && dimensions.uniq == dimensions
106
95
  raise ArgumentError, "Parser dimensions must be a nonempty unique array"
107
96
  end
108
- # 扩展解析器可调用外部代码;结果投影始终使用入口处的请求。
109
- dimensions = dimensions.dup.freeze
110
- query = select_command(dimensions, command)
111
-
112
- # 查询已覆盖全部请求维度,无需再次遍历第一个维度的完整命令列表。
113
- result = implementation_for(query).parse(text, complete: complete)
97
+ dimensions.freeze
98
+ catalog = declarations
99
+ query = catalog.select_command(dimensions, command: command)
100
+ implementation = @model_parsers.fetch(query) { catalog.parsers.fetch(query) }
101
+ result = implementation.parse(text, complete: complete)
114
102
  dimensions.to_h { |name| [ name, project_independently(name, result) ] }
115
103
  end
116
104
 
117
- private
118
-
119
- # 发现阶段可替换实例目录;只缓存当前深冻结目录,不继承旧目录的选择结果。
120
- def cache_commands(directory, strict: false)
121
- cached = @command_cache
122
- return cached if cached && directory.equal?(cached[:directory])
123
- return if frozen?
124
-
125
- queries = compile_commands(directory)
126
- return unless queries
127
-
128
- membership = queries.transform_values { |entries| entries.to_h { |query| [ query, true ] }.freeze }.freeze
129
- # 一次发布完整索引;并发查找只读取自己取得的快照,不混合两份目录。
130
- @command_cache = { directory: directory, queries: queries, membership: membership }.freeze
131
- rescue ArgumentError
132
- # 动态目录仍按请求维度报错,未使用的无效声明不能阻断其他维度。
133
- raise if strict
134
-
135
- nil
136
- end
137
-
138
- # 可变容器或文本仍逐次校验;普通深冻结目录允许共享验证后的顺序与成员索引。
139
- def compile_commands(directory)
140
- return unless directory.instance_of?(Hash) && directory.frozen? && !directory.compare_by_identity? &&
141
- directory.keys.all? { |name| name.is_a?(Symbol) && !name.empty? } && directory.values.all? do |value|
142
- entries = value.is_a?(Array) ? value : [ value ]
143
- (!value.is_a?(Array) || value.instance_of?(Array) && value.frozen?) &&
144
- entries.all? { |query| query.instance_of?(String) && query.frozen? }
145
- end
146
-
147
- directory.to_h { |name, declaration| [ name, validated_queries(declaration) ] }.freeze
148
- end
149
-
150
- # 查询快照不冻结调用方的数组或字符串。
151
- def validated_queries(declaration)
152
- queries = declaration.is_a?(Array) ? declaration : [ declaration ]
153
- unless queries.any? && queries.uniq == queries
154
- raise ArgumentError, "Parser dimension must declare one or more unique commands"
155
- end
156
-
157
- queries.map { |query| Commands.command_text(query) }.freeze
158
- end
105
+ protected
159
106
 
160
- # 仅当存在唯一公共查询时允许推断;显式命令必须覆盖全部请求视图。
161
- def select_command(dimensions, selected)
162
- declarations = dimensions.map do |name|
163
- queries = commands_for(name)
164
- cached = @command_cache
165
- membership = cached[:membership][name] if cached && queries.equal?(cached[:queries][name])
166
- [ queries, membership ]
167
- end
168
- if selected.nil?
169
- candidates = declarations.map(&:first).reduce { |common, queries| common & queries }
170
- raise ArgumentError, "One CLI output must belong to one command" unless candidates.one?
171
-
172
- candidates.first
173
- else
174
- supported = declarations.all? do |queries, membership|
175
- if selected.instance_of?(String) && membership
176
- membership.key?(selected)
177
- else
178
- queries.include?(selected)
179
- end
180
- end
181
- raise ArgumentError, "Selected command does not provide all parser dimensions" unless supported
107
+ # 仅在副本上安装已经校验的完整目录,不重跑子类初始化。
108
+ def replace_declarations(declarations) = @declarations = declarations
182
109
 
183
- selected
184
- end
185
- end
186
-
187
- # 采集前校验所选型号,并隔离后续修改对型号查找的影响。
188
- def self.normalized_model_parsers
189
- declarations = self::MODEL_PARSERS
190
- raise ArgumentError, "Model parsers must be a hash" unless declarations.is_a?(Hash)
191
-
192
- normalized = {}
193
- declarations.each do |name, overrides|
194
- unless name.nil? || name.is_a?(String) && !name.strip.empty?
195
- raise ArgumentError, "Model parser keys must be device model names"
196
- end
197
- key = name&.strip&.downcase
198
- raise ArgumentError, "Model parser names must be unique after normalization" if normalized.key?(key)
199
- unless overrides.is_a?(Hash) && overrides.all? do |command, parser|
200
- self::PARSERS.key?(command) && parser.is_a?(Class) && parser < Parser
201
- end
202
- raise ArgumentError, "Model parsers must override declared commands with Parser subclasses"
203
- end
204
- normalized[key] = overrides
205
- end
206
-
207
- normalized
208
- end
209
- private_class_method :normalized_model_parsers
210
-
211
- # 注册与实例选择共用声明校验,所选覆盖仍独立于后续调用方修改。
212
- def model_parser_overrides
213
- self.class.send(:normalized_model_parsers).fetch(model, {}).dup.freeze
214
- end
215
-
216
- # 动态目录的错误仍在调用边界报告,不暴露底层 Hash 的 KeyError。
217
- def implementation_for(query)
218
- implementation = @model_parsers.fetch(query) { parsers.fetch(query) { raise ArgumentError, "Missing command parser" } }
219
- unless implementation.is_a?(Class) && implementation < Parser
220
- raise ArgumentError, "Command parser must inherit from Parser"
221
- end
222
- implementation
223
- end
110
+ private
224
111
 
225
112
  # 投影失败仅影响单个视图;命令选择及解析错误仍正常抛出。
226
113
  def project_independently(name, result)
@@ -7,6 +7,7 @@ module CtxParser
7
7
 
8
8
  # 冻结筛选结果数组,记录值的所有权仍属于调用方。
9
9
  def initialize(rows, identity: nil, &identity_key)
10
+ rows = Array.new(rows) if rows.is_a?(Array)
10
11
  unless rows.is_a?(Array) && rows.all? { |row| row.is_a?(Hash) }
11
12
  raise ArgumentError, "Records must be hashes"
12
13
  end
@@ -15,7 +16,7 @@ module CtxParser
15
16
  identity_key ||= ->(row) { row.values_at(*identity) } if identity.is_a?(Array) && identity.any?
16
17
  raise ArgumentError, "Identity must be a nonempty array or callable" unless identity_key
17
18
 
18
- @rows = rows.dup.freeze
19
+ @rows = rows.freeze
19
20
  positions = {}
20
21
  first_occurrences, unambiguous = [], []
21
22
  @duplicate_identity_count = 0
@@ -2,5 +2,5 @@
2
2
 
3
3
  module CtxParser
4
4
  # 公开包版本,供 RubyGems 元数据和运行时调用方共用。
5
- VERSION = "0.4.0"
5
+ VERSION = "0.5.0"
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.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gatework contributors
@@ -76,8 +76,11 @@ files:
76
76
  - README-zh.md
77
77
  - README.md
78
78
  - docs/architecture-zh.md
79
+ - docs/contracts-0.5.md
79
80
  - docs/input-performance.md
81
+ - docs/performance-0.5.md
80
82
  - docs/release.md
83
+ - examples/declarations.rb
81
84
  - examples/offline.rb
82
85
  - lib/ctx_parser.rb
83
86
  - lib/ctx_parser/banner.rb