概览
每条数据是一项原创的长程开发任务,基于真实且仍在活跃维护的代码库:开发者用一段话提出一项代码库里还没有的功能,Agent 要自己读懂代码库,找出所有该改的地方,跨多个文件完整实现该行为(各条并行路径、默认值、边界和错误情形),最后提交。每条数据包含两部分:一是 DeepSWE 格式的可复跑任务环境(Harbor 任务目录:离线环境的 Dockerfile、需求原文、隐藏测试和参考解),可直接用于强化学习;二是 Claude Opus 5.5(max effort)在 DeepSWE 主榜同款的纯 bash Agent 框架 mini-swe-agent 中解这道题的完整轨迹,附评分结果以及通过或失败的原因,可用于监督微调。
交付内容
tasks/<任务>/ # Harbor 任务目录,可直接用 Harbor 或 Pier 运行
├── task.toml # 元数据、资源、时限;Agent 与评分都断网
├── instruction.md # 需求原文:Agent 收到的唯一输入
├── environment/ # Dockerfile 与构建上下文(固定提交的代码库),构建出含完整工具链的离线镜像
├── tests/ # 隐藏:test.sh、隐藏测试、失败→通过与回归白名单、构建/类型检查门禁
├── solution/ # 参考解:solution.patch、solve.sh
└── requirements.json # 需求逐条 ↔ 检查它的隐藏测试
trials/<任务>__<序号>/
├── agent/
│ ├── requests.jsonl.zst # 请求级 API 记录:每次调用的完整上下文与回复,含思考摘要
│ ├── trajectory.json # 同一条轨迹的 ATIF 格式
│ ├── mini-swe-agent.trajectory.json
│ └── steps.jsonl # 逐步视图:思考摘要、命令、输出
├── artifacts/model.patch # 评分所依据的已提交改动
├── verifier/ # reward.json、ctrf.json、测试输出
├── result.json # 运行摘要:模型调用、命令数、用时
└── audit.json # 通过或失败的原因,逐个失败测试的根因
index.json # 任务与轨迹总览
README.md # 构建、运行和评分说明
- 评分:所有失败→通过测试都通过,且回归测试和构建/类型检查无一失败,记 1 分,否则记 0 分;reward.json 另记两类测试各自的通过比例。隐藏测试只通过需求中写明的公开接口做断言;评分时 Agent 自己写的测试文件会先移开。
- 隔离:Agent 断网工作,看不到隐藏测试和参考解;评分时在全新容器中检查已提交的改动。
- 轨迹:每一步含思考摘要、bash 命令及其输出;请求级记录保存每次模型调用的完整上下文。
样例
8 道样题,覆盖 5 种编程语言,领域各不相同。参考解新增 364–2,221 行、涉及 8–30 个文件;每题有 84–1,464 个失败→通过的隐藏测试,另有 335–22,002 个回归测试。每条轨迹 57–190 次模型调用,用时 31–120 分钟。
| 样题 | 语言 | 领域 | 参考解 | 失败→通过测试 |
|---|---|---|---|---|
| 1 | TypeScript | Web 地图绘制库 | +2,115 行 / 26 个文件 | 245 |
| 2 | Python | RDF 图处理库 | +364 行 / 15 个文件 | 508 |
| 3 | JavaScript | XML 解析库 | +806 行 / 9 个文件 | 84 |
| 4 | Python | ORM 管理后台 | +474 行 / 9 个文件 | 209 |
| 5 | Go | Jira 客户端库 | +575 行 / 8 个文件 | 495 |
| 6 | TypeScript | cron 调度库 | +1,399 行 / 18 个文件 | 339 |
| 7 | Rust | 授权策略引擎 | +1,527 行 / 10 个文件 | 138 |
| 8 | Go | Go JSON 库 | +2,221 行 / 30 个文件 | 1,464 |
每张卡片给出 Agent 收到的需求原文、难点、隐藏测试检查的内容、轨迹关键时刻和 Agent 提交的内容;改动节选折叠在最后。
样例 1 / 8
为 Terra Draw 各模式添加 snapping.toGrid 网格吸附
Terra Draw:一个 TypeScript monorepo,用于在 Web 地图上绘制和编辑 GeoJSON 要素,并通过适配器支持 MapLibre、Leaflet、OpenLayers、Mapbox、Google Maps 和 ArcGIS。
新增一种声明式的网格吸附策略:导出 SnapToGrid 类型,并提供 snapping.toGrid 选项,按度或 Web Mercator 米制网格吸附。网格以最低优先级作用于 point、marker、linestring、polyline 和 polygon 模式,为矩形模式提供第一个吸附选项,并覆盖 select 模式的顶点拖动。
难在哪里
- 网格吸附需覆盖六个绘制模式和 select 顶点拖动,排在最多五种现有策略之后兜底;矩形模式此前完全无法吸附。
- 精确的数值语义:恰在半格处向 +∞ 取整;米制网格相对投影后的 origin 在 Web Mercator 下吸附;结果再按 adapter 的坐标精度取整。
- 影响控制流的边界规则:越界的吸附结果不生效(而非钳制);无效配置静默禁用网格;吸附到上一个已提交顶点的点击需忽略。
- select 模式仅吸附顶点拖动,整体拖动、中点、调整大小、旋转、缩放均不吸附;1,833 个已有测试须保持通过。
需求原文(Agent 收到的唯一输入)
Terra Draw's snapping option already snaps to coordinates, lines, features, angles (toDegree) and custom positions. For floor-plan editors I keep hand-rolling a lattice with toCustom, and rectangle mode can't snap at all, so I'd like a declarative grid strategy.
Please add a SnapToGrid type exported from terra-draw next to SnapToDegree: { size: number; units?: "degrees" | "meters"; origin?: Position }, with units defaulting to "degrees" and origin to [0, 0] (lng/lat). Add toGrid?: SnapToGrid to OneDimensionalSnapping (and therefore Snapping), honoured by TerraDrawPointMode, TerraDrawMarkerMode, TerraDrawLineStringMode, TerraDrawPolyLineMode and TerraDrawPolygonMode in the constructor and via draw.updateModeOptions(mode, { snapping }). Rules:
- Degrees: lng snaps to
origin[0] + Math.round((lng - origin[0]) / size) * size, lat likewise withorigin[1](exact halves round towards +infinity). - Meters: project the position and the origin to spherical Web Mercator x/y (EPSG:3857, R = 6378137, as the library's web-mercator conversion does), snap x and y with the same formula against the origin's x/y, then convert back to lng/lat.
- The snapped position is then rounded to the adapter's coordinate precision.
- If the snapped lng is outside [-180, 180] or lat outside [-90, 90], the grid doesn't apply to that pointer event.
- Invalid config silently disables the grid (no error):
sizenot a finite number > 0, unknownunits, ororiginnot two finite numbers. - Lowest priority: the grid is used only when no other enabled strategy (
toCustom,toFeature,toCoordinate,toLine,toDegree) yields a coordinate for the event.
In point and marker modes, points created by left click and points moved by editable: true drags land on the snapped position. In linestring, polyline and polygon modes, every committed vertex (including the first click) and the provisional mouse-move vertex use the snapped position, and while the grid applies on mouse move a snappingPoint: true guidance Point is shown there, managed like the other strategies' snapping points. Closing/finishing detection keeps using the pointer's screen position, and a click that snaps onto the previously committed vertex is ignored, as identical coordinates are today.
TerraDrawRectangleMode gets a new snapping?: { toGrid?: SnapToGrid } option (constructor and updateModeOptions). With it, in both the click-move and click-drag interactions, the start corner and the opposite corner are snapped (the latter on every pointer update and on finish), so all four corners of the finished rectangle lie on the grid. No snappingPoint guidance feature is created.
In TerraDrawSelectMode, flags[mode].feature.coordinates.snappable may be a Snapping object containing toGrid: with coordinates.draggable: true, dragging a vertex of a selected feature puts it on the snapped position (for a polygon's first/last vertex, both positions). Only vertex drags snap; whole-feature drags, midpoints, resize, rotate and scale don't.
updateModeOptions must be able to enable, change or remove toGrid at runtime, replacing the snapping object wholesale as now. Without toGrid (or a rectangle snapping option) every mode behaves exactly as before, and all new type fields stay optional.
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
Terra Draw 的 snapping 选项已经支持吸附到坐标、线、要素、角度(toDegree)和自定义位置。在做平面图编辑器时,我一直用 toCustom 手写网格,而矩形模式根本无法吸附,所以我希望有一个声明式的网格吸附策略。
请在 terra-draw 中紧挨着 SnapToDegree 导出一个 SnapToGrid 类型:{ size: number; units?: "degrees" | "meters"; origin?: Position }。其中 units 默认为 "degrees",origin 默认为 [0, 0](lng/lat)。在 OneDimensionalSnapping(因而也包括 Snapping)中添加 toGrid?: SnapToGrid。TerraDrawPointMode、TerraDrawMarkerMode、TerraDrawLineStringMode、TerraDrawPolyLineMode 和 TerraDrawPolygonMode 都要遵循该选项,无论它是在构造函数中设置,还是通过 draw.updateModeOptions(mode, { snapping }) 设置。规则如下:
- 度(degrees):lng 吸附到
origin[0] + Math.round((lng - origin[0]) / size) * size,lat 同理使用origin[1](恰好处于一半时向 +∞ 取整)。 - 米(meters):将位置和 origin 投影为球面 Web Mercator 的 x/y(EPSG:3857,R = 6378137,与库中 web-mercator 转换的做法一致)。以 origin 的 x/y 为基准,用同一公式分别吸附 x 和 y,再转换回 lng/lat。
- 吸附后的位置随后按 adapter 的坐标精度取整。
- 如果吸附后的 lng 超出 [-180, 180] 或 lat 超出 [-90, 90],则该次指针事件不应用网格。
- 无效配置会静默禁用网格(不报错):
size不是大于 0 的有限数、units取值未知,或origin不是两个有限数。 - 最低优先级:只有当其他已启用的策略(
toCustom、toFeature、toCoordinate、toLine、toDegree)都没有为该事件给出坐标时,才使用网格。
在 point 和 marker 模式中,左键点击创建的点,以及在 editable: true 下被拖动的点,都落在吸附后的位置。在 linestring、polyline 和 polygon 模式中,每个已提交的顶点(包括第一次点击)以及鼠标移动时的临时顶点都使用吸附后的位置。鼠标移动时若网格生效,会在该处显示一个 snappingPoint: true 的引导 Point,其管理方式与其他策略的吸附点相同。闭合/结束检测仍使用指针的屏幕位置。如果一次点击吸附到上一个已提交的顶点上,这次点击会被忽略,与目前对相同坐标的处理一致。
TerraDrawRectangleMode 新增 snapping?: { toGrid?: SnapToGrid } 选项(构造函数和 updateModeOptions 均可设置)。启用后,在 click-move 和 click-drag 两种交互中,起始角和对角都会被吸附(对角在每次指针更新时以及结束时都会吸附),使完成后矩形的四个角都位于网格上。不会创建 snappingPoint 引导要素。
在 TerraDrawSelectMode 中,flags[mode].feature.coordinates.snappable 可以是包含 toGrid 的 Snapping 对象。当 coordinates.draggable: true 时,拖动所选要素的某个顶点会把它放到吸附后的位置(若是多边形的首/尾顶点,则两个位置都如此)。只有顶点拖动会吸附;整体拖动要素、中点(midpoints)、调整大小(resize)、旋转(rotate)和缩放(scale)都不吸附。
updateModeOptions 必须能在运行时启用、修改或移除 toGrid,并像现在一样整体替换 snapping 对象。没有 toGrid(或矩形模式没有 snapping 选项)时,每个模式的行为都必须与之前完全一致,所有新增的类型字段都保持可选。
重要:请从 main 新建一个分支来完成这项工作,完成后提交所有改动。
隐藏测试检查什么
- SnapToGrid 已导出;point、marker、linestring、polyline 和 polygon 模式可通过构造函数或 updateModeOptions 设置 snapping.toGrid。
- 测试还会在运行时为 point、marker、linestring、polyline、polygon 和矩形模式启用、修改和移除网格,并在运行时为 select 模式的顶点拖动启用网格。
- 度制计算(默认单位和 origin、带偏移的 origin、负坐标、恰为半格时向 +∞ 取整)、以投影后 origin 为基准的米制网格,以及按坐标精度取整。
- linestring、polyline 和 polygon 会吸附第一次点击、临时顶点和每个已提交顶点;三种模式绘制时,snappingPoint 引导点都会跟随吸附位置。
- 矩形的两个角都会吸附,即使 click-drag 在没有中间拖动的情况下直接结束;select 模式的顶点拖动和 editable: true 下的点拖动也会吸附。
- 米制网格吸附后越过反子午线时不应用;未知的 units 值会静默禁用网格;未配置网格时行为保持不变。
- 失败→通过测试
- 245
- 回归测试
- 1,833
- 构建/类型检查
- 1
- 参考解改动文件
- 26
- 参考解新增行
- +2,115
轨迹关键时刻
第 8–10 次调用 查到当初加入 toDegree 角度吸附的提交,并以它为模板来编写新类型、behavior 类和导出。
第 27–28、42–43、45 次调用 确认闭合检测使用的是指针的屏幕坐标,且 polygon 模式已会忽略相同坐标的点击,而 linestring 和 polyline 不会,因此这两个模式需要为吸附到上一顶点的网格点击新增判断。
第 51、53–55、69 次调用 在两套 Jest 配置下确认核心 terra-draw 包的基线全绿(95 个套件、1,673 个测试),新建 feat/snap-to-grid 分支,并写了一个临时复现测试,四个用例全部失败。
第 73、75、77–78、80–84 次调用 编写 GridSnappingBehavior(配置校验、度/米计算、精度取整、越界检查),将其设为五个绘制模式中优先级最低的兜底,新增矩形选项,并接入 select 顶点拖动,排除由中点插入的顶点。
第 96–98 次调用 自己写的精度测试失败后,Agent 核对了算术:1.2345 先吸附为 1.235 再取整为 1.24,错的是期望值;于是修正测试,并补了先吸附后取整的用例。
第 118–122 次调用 Agent 在 select 模式“非顶点拖动”测试的每一步后打印几何,发现旋转和缩放什么都没改变,于是改写测试:发送两次拖动事件,并从顶点处开始缩放。
第 129、137 次调用 用一个临时测试确认带类型检查的 Jest 配置会因 units: "feet" 报错,说明其通过结果可信;随后启用覆盖率阈值运行 terra-draw 包的完整测试:96 个套件、1,768 个测试全部通过。
第 149–150、154、156、175–176 次调用 发现指针吸附回上一个已提交顶点时插入坐标(insert-coordinates)过期的边界情况,并仅在网格吸附时修复;随后提交全部改动并重新运行 terra-draw 包的测试,1,769 个通过。
Agent 提交了什么
- 模型调用
- 178
- bash 命令
- 187
- 用时(分钟)
- 70
- 改动文件
- 29
- 增 / 删行数
- +2,407 / −39
Agent 在 feat/snap-to-grid 分支上提交了 SnapToGrid 类型和共享的 GridSnappingBehavior。该 behavior 会校验配置,按度或 EPSG:3857 米制吸附,按坐标精度取整并跳过越界结果;它被接入 point、marker、linestring、polyline 和 polygon 模式、新增的矩形 snapping 选项以及 select 模式的顶点拖动。Agent 另外新增了自己的测试。
Agent 提交的改动(节选)
diff --git a/packages/terra-draw/src/modes/grid-snapping.behavior.ts b/packages/terra-draw/src/modes/grid-snapping.behavior.ts
new file mode 100644
--- /dev/null
+++ b/packages/terra-draw/src/modes/grid-snapping.behavior.ts
@@ -0,0 +1,142 @@
+import { Position } from "geojson";
+import { SnapToGrid, TerraDrawMouseEvent } from "../common";
+import { isNonNullObject } from "../common/checks";
+import {
+ validLatitude,
+ validLongitude,
+} from "../geometry/boolean/is-valid-coordinate";
+import { limitPrecision } from "../geometry/limit-decimal-precision";
+import {
+ lngLatToWebMercatorXY,
+ webMercatorXYToLngLat,
+} from "../geometry/project/web-mercator";
+import { BehaviorConfig, TerraDrawModeBehavior } from "./base.behavior";
+
+type GridUnits = NonNullable<SnapToGrid["units"]>;
+
+type ValidGrid = {
+ size: number;
+ units: GridUnits;
+ origin: [number, number];
+};
+
+const DEFAULT_UNITS: GridUnits = "degrees";
+
+const isFiniteNumber = (value: unknown): value is number =>
+ typeof value === "number" && Number.isFinite(value);
+
+/**
+ * Snaps positions to a regular grid (lattice), either in degrees of
+ * longitude/latitude or in meters on the spherical Web Mercator plane.
+ */
+export class GridSnappingBehavior extends TerraDrawModeBehavior {
+ constructor(readonly config: BehaviorConfig) {
+ super(config);
+ }
+
+ /**
+ * Returns the grid position nearest to the event, rounded to the coordinate
+ * precision. Returns undefined if the grid options are invalid, or if the
+ * snapped position falls outside of valid longitude/latitude bounds, in which
+ * case the grid does not apply to the event.
+ */
+ public getSnappableCoordinate(
+ event: Pick<TerraDrawMouseEvent, "lng" | "lat">,
+ options: SnapToGrid | undefined,
+ ): Position | undefined {
+ const grid = this.getValidGrid(options);
+
+ if (!grid) {
+ return undefined;
+ }
+
+ const [snappedLng, snappedLat] =
+ grid.units === "meters"
+ ? this.snapMeters(event, grid)
+ : this.snapDegrees(event, grid);
+
+ const lng = this.limitPrecision(snappedLng);
+ const lat = this.limitPrecision(snappedLat);
+
+ if (!validLongitude(lng) || !validLatitude(lat)) {
+ return undefined;
+ }
+
+ return [lng, lat];
+ }
+
+ private snapDegrees(
+ { lng, lat }: Pick<TerraDrawMouseEvent, "lng" | "lat">,
+ { size, origin }: ValidGrid,
+ ): Position {
+ return [
+ this.snapValue(lng, origin[0], size),
+ this.snapValue(lat, origin[1], size),
+ ];
+ }
+
+ private snapMeters(
+ { lng, lat }: Pick<TerraDrawMouseEvent, "lng" | "lat">,
+ { size, origin }: ValidGrid,
+ ): Position {
+ const { x, y } = lngLatToWebMercatorXY(lng, lat);
+ const { x: originX, y: originY } = lngLatToWebMercatorXY(
+ origin[0],
+ origin[1],
+ );
+
+ const snapped = webMercatorXYToLngLat(
+ this.snapValue(x, originX, size),
+ this.snapValue(y, originY, size),
+ );
+
+ return [snapped.lng, snapped.lat];
+ }
+
+ /**
+ * Snaps a single value to the nearest grid line. Math.round means
+ * that exact halves round towards +infinity.
+ */
+ private snapValue(value: number, origin: number, size: number) {
+ return origin + Math.round((value - origin) / size) * size;
+ }
+
+ private limitPrecision(value: number) {
+ const limited = limitPrecision(value, this.coordinatePrecision);
+
+ // Normalise negative zero, which can result from rounding
+ // tiny negative values, so that coordinates are always [0, 0]
+ return limited === 0 ? 0 : limited;
+ }
+
+ /**
+ * Validates the grid options, applying defaults. Invalid options
+ * return undefined which silently disables grid snapping.
+ */
+ private getValidGrid(options: unknown): ValidGrid | undefined {
+ if (!isNonNullObject(options)) {
+ return undefined;
+ }
+
+ const { size, units = DEFAULT_UNITS, origin = [0, 0] } = options;
+
+ if (!isFiniteNumber(size) || size <= 0) {
+ return undefined;
+ }
…… 其余 3,133 行从略(共改动 29 个文件)
样例 2 / 8
端到端支持 RDF 1.2 基础方向字面量("x"@ar--rtl)
rdflib:用于处理 RDF 的 Python 库,包含图与 RDF 项(term)模型、N-Triples/Turtle/TriG/N3 等格式的解析器与序列化器、基于 pyparsing 的 SPARQL 1.1 引擎,以及可插拔的存储后端。
让 RDF 1.2 带方向的语言标签字符串("x"@ar--rtl)端到端可用。范围包括:Literal 增加 direction,N-Triples/Turtle 系列解析器与序列化器,SPARQL 语法及 LANGDIR、hasLANG、hasLANGDIR、STRLANGDIR 函数,SPARQL 结果格式,以及存储。不带方向的字面量,其行为必须与现在完全一致。
难在哪里
Literal、按行解析的解析器与 Notation3 解析器、七个序列化器、SPARQL 语法与函数、结果格式与存储等多条并行路径须协同一致;参考解涉及 15 个文件。- 非法方向(大写、
--后的其他内容,或无语言标签的--rtl)在各字符串形式下都须抛出指定异常:nt/nquads 抛ParserError,turtle/trig/n3 抛BadSyntax。 - SPARQL 中凡能出现字面量处(三元组模式、VALUES、BIND、CONSTRUCT 模板、INSERT/DELETE DATA 及基于 WHERE 的更新)都须保留方向,
Graph.query/Graph.update与prepareQuery/prepareUpdate均适用。 - 不带方向的字面量须与现状完全一致(由 9,327 项回归检查把关,含既有测试及构建、类型检查),而相等、哈希与 pickle 都要纳入新字段。
需求原文(Agent 收到的唯一输入)
rdflib handles "x"@ar everywhere, but not RDF 1.2's base direction ("x"@ar--rtl, rdf:dirLangString): such data won't even load. Please add it end to end.
Literal gets a fifth parameter direction=None and a read-only .direction; Literal('x', lang='ar', direction='rtl') has .language 'ar' and .datatype None.
Parsers nt/ntriples/nt11, nquads, turtle/ttl, trig and n3 accept the syntax, with subtags (@en-US--ltr), after every Turtle/TriG string form and N3's double-quoted ones. Serializers nt, nt11, nquads, turtle, longturtle, trig and n3 write it (N-Triples line: <http://e/s> <http://e/p> "x"@ar--rtl .) and round-trip. SPARQL accepts it wherever a literal may appear, in Graph.query/Graph.update and prepareQuery/prepareUpdate.
directionmust be None,'ltr'or'rtl'(elseValueError, even for''); a direction without a language tag raisesTypeError.==, hash andeq()(so SPARQL=,!=,IN,sameTerm) distinguish"x"@ar,"x"@ar--ltrand"x"@ar--rtl; a Graph holds all three.repr()isrdflib.term.Literal('x', lang='ar', direction='rtl');n3()is"x"@ar--rtl(also with a namespace_manager or when triple-quoted), andrdflib.util.from_n3parses it back. pickle, copy, deepcopy andLiteral(lit)preserve it.- Only lowercase
ltr/rtlmay follow--; anything else, or--rtlwith no language tag, raisesrdflib.exceptions.ParserError(nt, nquads) orrdflib.plugins.parsers.notation3.BadSyntax(turtle, trig, n3). LANGDIRgives"ltr"/"rtl",""for other literals, an error (like LANG) for IRIs/bnodes.hasLANG/hasLANGDIRgive xsd:boolean, true iff given a literal with a tag/direction respectively (never an error).STRLANGDIR("abc", "ar", "rtl")is"abc"@ar--rtl, an error for a bad direction or tagged/typed first argument.DATATYPE("x"@ar--rtl)is rdf:dirLangString as a plain URIRef (not added tordflib.RDF); LANG returns just the tag.- UCASE, LCASE, SUBSTR, STRBEFORE, STRAFTER and REPLACE keep the direction where they keep the tag; CONCAT keeps tag and direction only if all arguments share both, else neither.
Result.serialize adds "its:dir": "rtl" beside "xml:lang" in json, and a dir attribute in namespace http://www.w3.org/2005/11/its on <literal> in xml; Result.parse reads both back and accepts "x"@ar--rtl in tsv cells; txt prints --rtl. Memory and BerkeleyDB stores keep the direction across re-open.
Non-directional literals must behave and serialize exactly as today; JSON-LD keeps dropping @direction.
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
rdflib 在各处都能处理 "x"@ar,但不支持 RDF 1.2 的基础方向("x"@ar--rtl,rdf:dirLangString):这类数据甚至无法加载。请端到端地加上这项支持。
Literal 新增第五个参数 direction=None 和只读属性 .direction;Literal('x', lang='ar', direction='rtl') 的 .language 为 'ar',.datatype 为 None。
解析器 nt/ntriples/nt11、nquads、turtle/ttl、trig 和 n3 都要接受这种语法(包括带子标签的 @en-US--ltr)。它可以跟在 Turtle/TriG 的每一种字符串形式之后,也可以跟在 N3 的双引号字符串形式之后。序列化器 nt、nt11、nquads、turtle、longturtle、trig 和 n3 要能写出它(N-Triples 行:<http://e/s> <http://e/p> "x"@ar--rtl .),并能往返(round-trip)。SPARQL 在任何可以出现字面量的位置都要接受它,适用于 Graph.query / Graph.update 以及 prepareQuery / prepareUpdate。
direction只能是 None、'ltr'或'rtl'(否则抛出ValueError,''也不例外);有方向但没有语言标签时抛出TypeError。==、哈希和eq()(因而 SPARQL 的=、!=、IN、sameTerm)都要区分"x"@ar、"x"@ar--ltr和"x"@ar--rtl;一个 Graph 可以同时容纳这三者。repr()为rdflib.term.Literal('x', lang='ar', direction='rtl');n3()为"x"@ar--rtl(传入 namespace_manager 或使用三引号时也是如此),并且rdflib.util.from_n3能把它解析回来。pickle、copy、deepcopy 和Literal(lit)都要保留方向。--之后只允许小写的ltr/rtl。其他任何内容,或没有语言标签的--rtl,都要抛出rdflib.exceptions.ParserError(nt、nquads)或rdflib.plugins.parsers.notation3.BadSyntax(turtle、trig、n3)。LANGDIR返回"ltr"/"rtl",对其他字面量返回"",对 IRI/空白节点报错(与 LANG 相同)。hasLANG/hasLANGDIR返回 xsd:boolean,当且仅当参数分别是带语言标签/带方向的字面量时为 true(从不报错)。STRLANGDIR("abc", "ar", "rtl")返回"abc"@ar--rtl;方向非法,或第一个参数带标签/带类型时报错。DATATYPE("x"@ar--rtl)返回 rdf:dirLangString,形式为普通 URIRef(不要加入rdflib.RDF);LANG 只返回标签本身。- UCASE、LCASE、SUBSTR、STRBEFORE、STRAFTER 和 REPLACE 在保留标签的地方同样保留方向。CONCAT 仅当所有参数的标签和方向都相同时才保留两者,否则两者都不保留。
Result.serialize 在 json 中于 "xml:lang" 旁加上 "its:dir": "rtl",在 xml 中给 <literal> 加上命名空间为 http://www.w3.org/2005/11/its 的 dir 属性。Result.parse 能把这两者读回,并接受 tsv 单元格中的 "x"@ar--rtl;txt 输出 --rtl。Memory 和 BerkeleyDB 存储在重新打开后仍保留方向。
不带方向的字面量,其行为和序列化结果必须与现在完全一致;JSON-LD 继续丢弃 @direction。
重要:请从 main 新建一个分支进行开发,完成后提交所有改动。
隐藏测试检查什么
- Literal:第五个位置参数 direction、只读属性、ValueError/TypeError 校验、能区分三种变体的相等、哈希与 Graph 存储、repr、n3()、from_n3、pickle 与复制。
- 解析器:nt、nquads、turtle、trig、n3 在各自支持的每种字符串形式下都接受 @ar--rtl,也接受带子标签的写法(如 @en-US--ltr);N-Quads 保留图名;非法方向抛出 ParserError/BadSyntax。
- 序列化器:N-Triples/N-Quads 行的精确输出(含转义),以及 nt、nt11、nquads、turtle、longturtle、trig、n3 的往返,包括多行字符串。
- SPARQL 查询与更新:三元组模式、VALUES、BIND、SELECT 表达式、CONSTRUCT、ASK、INSERT/DELETE DATA、DELETE/INSERT WHERE 及命名图中的带方向字面量。
- SPARQL 函数:LANGDIR、hasLANG、hasLANGDIR、STRLANGDIR(含出错时由 COALESCE 回退)、DATATYPE、LANG、字符串函数及 CONCAT 的标签/方向规则。
- 结果、存储与端到端:JSON its:dir 与 XML ITS dir 的往返、TSV 单元格与 TXT 输出;Memory 与 BerkeleyDB 存储重新打开后保留方向;JSON-LD 仍丢弃 @direction;Turtle→查询→JSON/XML 结果;N-Triples 数据经 STRLANGDIR 更新后再序列化;TriG→N-Quads 再转回。
- 失败→通过测试
- 508
- 回归测试
- 9,325
- 构建/类型检查
- 2
- 参考解改动文件
- 15
- 参考解新增行
- +364
轨迹关键时刻
第 2–3、9、15 次调用 Agent 先从 main 建立分支 rdf12-base-direction,再梳理 term.py;发现 rdflib.RDF 是严格的 DefinedNamespace 后,决定把 rdf:dirLangString 构造为普通 URIRef 常量,随后梳理各解析器与序列化器。
第 46–47、53、55 次调用 完整测试运行触发了命令执行器的 30 秒超时,Agent 改为后台运行并轮询日志,记下改动前的测试基线。
第 57、59、62、66、70–73、77 次调用 把 direction 贯穿到 Literal 的校验、__eq__、哈希、eq()、排序、n3()、repr 和 pickle 支持中,随后确认无方向字面量的 pickle 字节与改动前完全一致。
第 81–82、84、86 次调用 N-Triples 正则和 Notation3 解析器改为捕获 -- 之后的任意字母数字串,使 --RTL、--rtl2 等非法方向报出 ParserError / BadSyntax;随后测试了 Turtle/TriG/N3 的全部字符串形式。
第 90–91、93 次调用 检查 translateAlgebra 中的 endswith() 判断链,确保新内置函数不会被误当作 LANG;并把新增的 SPARQL 语法终结符从 LANGDIR 改名为 BASE_DIRECTION,避免与内置函数 LANGDIR() 混淆。
第 107–111 次调用 逐个验证 STRLANGDIR / LANGDIR 的出错情形,再检查 JSON/XML/TSV/TXT 结果(含非 lxml 路径和标准库 json 路径),以及 Memory/BerkeleyDB 存储关闭后重新打开的表现。
第 131–132 次调用 核查了自己在文档中关于 RDF/XML 解析时会忽略 its:dir 的说法:解析器实际上把它当作 property attribute 处理,于是文档改为说明这类格式不支持基础方向,输出时会丢弃方向。
第 170、179、181 次调用 收尾检查:完整测试结果与改动前的基线一致;暂存(stash)改动后确认未修改的 main 解析 "x"@ar--rtl 时报错。代码、文档和自写测试作为一个 commit 提交到该分支。
Agent 提交了什么
- 模型调用
- 184
- bash 命令
- 191
- 用时(分钟)
- 47
- 改动文件
- 19
- 增 / 删行数
- +1,493 / −35
Agent 为 Literal、N-Triples 与 Notation3 解析器、各序列化器、SPARQL 语法与函数,以及 SPARQL 的 JSON/XML/TSV 结果格式加入了基础方向支持,另补充文档和 253 个自写测试,全部提交到分支 rdf12-base-direction。
Agent 提交的改动(节选)
diff --git a/rdflib/term.py b/rdflib/term.py
--- a/rdflib/term.py
+++ b/rdflib/term.py
@@ -110,6 +110,17 @@ def _is_valid_langtag(tag: str) -> bool:
return bool(_lang_tag_regex.match(tag))
+# RDF 1.2 base directions of directional language-tagged strings, see
+# https://www.w3.org/TR/rdf12-concepts/#dfn-base-direction
+_VALID_DIRECTIONS = frozenset(("ltr", "rtl"))
+
+
+def _is_valid_direction(direction: Any) -> bool:
+ """Is `direction` a valid RDF 1.2 base direction (exactly `ltr` or `rtl`)?"""
+ # str() so that str subclasses (e.g. a Literal "rtl") are judged by value
+ return isinstance(direction, str) and str(direction) in _VALID_DIRECTIONS
+
+
def _is_valid_unicode(value: Union[str, bytes]) -> bool:
"""
Verify that the provided value can be converted into a Python
@@ -532,6 +543,24 @@ class Literal(Identifier):
A literal is a language-tagged string if the third element is present. Lexical representations of language tags MAY be converted to lower case. The value space of language tags is always in lower case.
+ RDF 1.2 adds an optional fourth element to language-tagged strings, a
+ [base direction](https://www.w3.org/TR/rdf12-concepts/#dfn-base-direction)
+ which is either `ltr` or `rtl`. Such a *directional language-tagged
+ string* has the datatype `rdf:dirLangString`, it is written as
+ `"..."@ar--rtl` in N-Triples, Turtle, SPARQL etc. and is created with the
+ `direction` parameter:
+
+ ```python
+ >>> lit = Literal("hello", lang="ar", direction="rtl")
+ >>> lit.language, lit.direction, lit.datatype
+ ('ar', 'rtl', None)
+ >>> lit.n3()
+ '"hello"@ar--rtl'
+ >>> lit == Literal("hello", lang="ar")
+ False
+
+ ```
+
---
For valid XSD datatypes, the lexical form is optionally normalized
@@ -631,7 +660,8 @@ class Literal(Identifier):
# NOTE: _datatype should maybe be of type URIRef, and not optional.
_datatype: Optional[URIRef]
_ill_typed: Optional[bool]
- __slots__ = ("_language", "_datatype", "_value", "_ill_typed")
+ _direction: Optional[str]
+ __slots__ = ("_language", "_datatype", "_value", "_ill_typed", "_direction")
def __new__(
cls,
@@ -639,8 +669,26 @@ class Literal(Identifier):
lang: Optional[str] = None,
datatype: Optional[str] = None,
normalize: Optional[bool] = None,
+ direction: Optional[str] = None,
):
- """Create a new Literal instance."""
+ """Create a new Literal instance.
+
+ Args:
+ lexical_or_value: The lexical form (a `str`), a Python value to
+ convert, or another `Literal` to copy.
+ lang: An optional language tag.
+ datatype: An optional datatype IRI, mutually exclusive with `lang`.
+ normalize: Whether to normalize the lexical form of recognized
+ datatypes, defaults to `rdflib.NORMALIZE_LITERALS`.
+ direction: An optional RDF 1.2 base direction, `"ltr"` or `"rtl"`;
+ only allowed together with a language tag.
+
+ Raises:
+ TypeError: If both `lang` and `datatype` are given, or if a
+ `direction` is given without a language tag.
+ ValueError: If `lang` is not a valid language tag or `direction`
+ is not `"ltr"` or `"rtl"`.
+ """
if lang == "":
lang = None # no empty lang-tags in RDF
@@ -655,6 +703,25 @@ class Literal(Identifier):
if lang is not None and not _is_valid_langtag(lang):
raise ValueError(f"'{str(lang)}' is not a valid language tag!")
+ if direction is not None:
+ if not _is_valid_direction(direction):
+ raise ValueError(
+ f"{direction!r} is not a valid base direction, "
+ "it must be 'ltr' or 'rtl'"
+ )
+ direction = str(direction)
+ if datatype is not None or (
+ lang is None
+ and not (
+ isinstance(lexical_or_value, Literal) and lexical_or_value.language
+ )
+ ):
+ raise TypeError(
+ "A Literal can only have a base direction if it has a "
+ "language tag (and no datatype), "
+ "per https://www.w3.org/TR/rdf12-concepts/#section-Graph-Literal"
+ )
+
if datatype is not None:
datatype = URIRef(datatype)
@@ -664,6 +731,9 @@ class Literal(Identifier):
# create from another Literal instance
lang = lang or lexical_or_value.language
+ if direction is None and lang and datatype is None:
+ # keep the base direction of the copied (language-tagged) literal
+ direction = lexical_or_value.direction
if datatype is not None:
# override datatype
value = _castLexicalToPython(lexical_or_value, datatype)
@@ -699,6 +769,7 @@ class Literal(Identifier):
lexical_or_value = _value
if datatype is not None:
lang = None
+ direction = None
if isinstance(lexical_or_value, bytes):
lexical_or_value = lexical_or_value.decode("utf-8")
…… 其余 2,025 行从略(共改动 19 个文件)
样例 3 / 8
命名空间感知的 XML 解析:按命名空间 URI 解析名称
fast-xml-parser(JavaScript,v5.11.2):可校验 XML、将 XML 解析为 JS 对象并由对象构建 XML 的库。
为 XMLParser 新增需显式开启的 resolveNamespaces 选项,用于跟踪 xmlns 作用域,并按命名空间 URI 为元素和属性命名(调用方提供的别名、xml:local 或 {uri}local),使结果不再取决于生成方选用的前缀。选项未设置或为 false 时不得有任何变化;工作需在从 main 新建的分支上完成并全部提交。
难在哪里
- 作用域规则边界多:内层覆盖外层、
xmlns=""取消默认命名空间、固定的xml前缀,以及自闭合、unpaired、停止节点上的声明仅作用于自身。 - jPath 须在五个回调与 matcher 之间完全一致,而对带前缀的标签,现有代码会在部分回调中重复前缀,
isArray中却不会。 xmlns声明须从输出和属性回调中消失,但在ignoreAttributes丢弃它们时仍须生效;即使忽略属性,未绑定的属性前缀也必须抛错。- 所有选项与回调都须看到解析后的名称,
unpairedTags/stopNodes须先经transformTagName转换再匹配,而停止节点的结束标签却仍须按原文匹配。
需求原文(Agent 收到的唯一输入)
XMLParser either keeps namespace prefixes or strips them (removeNSPrefix), so code keyed on soap:Envelope breaks once a producer picks another prefix. Please add an opt-in resolveNamespaces parser option (boolean | Record<string, string>, default false, typed in X2jOptions) that tracks xmlns scopes and resolves names by namespace URI; when unset or false, nothing changes.
xmlns="U"(default namespace) andxmlns:p="U"(prefixp) apply to the declaring element (name, attributes, end tag) and its descendants; inner declarations override outer ones,xmlns=""unsets the default, andxmlis alwayshttp://www.w3.org/XML/1998/namespace. Declarations on self-closing, unpaired or stop-node elements affect only that element.- Unprefixed element names are in the in-scope default namespace (if any);
p:local(split at the first colon) is in the namespace bound top. Unprefixed attributes are never namespaced; prefixed ones resolve like elements. - Naming (compact and
preserveOrderalike): no namespace → local name; namespaceU→alias:localif the option object mapsU(own key) toalias(localifaliasis""), elsexml:localfor the XML namespace, else{U}local;trueacts like{}. xmlns/xmlns:*declarations are not attributes: never in output (@_keys,attributesGroupNamegroups,:@maps), never passed to theignoreAttributesfunction,transformAttributeNameorattributeValueProcessor, yet honoured even ifignoreAttributesistrueor would filter them.- An unbound prefix on an element or attribute makes
parse()throw anErrorwith message exactlyUndeclared namespace prefix: <prefix>.
Every option and callback sees resolved names: transformTagName and updateTag get the element name, transformAttributeName gets attributeNamePrefix + attribute name, and unpairedTags/stopNodes match the resolved, then transformTagName-transformed name. jPaths use resolved names (e.g. soap:Envelope.soap:Body) and are identical for an element in updateTag, tagValueProcessor, attributeValueProcessor, ignoreAttributes and isArray; with jPath: false the matcher's getCurrentTag()/toString() agree. A stop node's name and attributes are resolved; its content stays raw and its end tag is matched as written. When enabled, removeNSPrefix is ignored, processing instructions (incl. <?xml ...?>) and their attributes are untouched, bindings never leak between parse() calls, and elements resolving to the same name group into arrays as usual.
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
XMLParser 目前要么保留命名空间前缀,要么将其去掉(removeNSPrefix)。因此一旦 XML 的生成方换用其他前缀,以 soap:Envelope 为键的代码就会失效。请新增一个需显式开启的解析器选项 resolveNamespaces(类型为 boolean | Record<string, string>,默认 false,并在 X2jOptions 中声明类型),用于跟踪 xmlns 作用域并按命名空间 URI 解析名称;未设置或为 false 时,行为保持完全不变。
xmlns="U"(默认命名空间)和xmlns:p="U"(前缀p)作用于声明它的元素(名称、属性、结束标签)及其后代;内层声明覆盖外层声明,xmlns=""取消默认命名空间,xml始终绑定到http://www.w3.org/XML/1998/namespace。自闭合元素、非成对(unpaired)元素或停止节点(stop node)元素上的声明只影响该元素本身。- 无前缀的元素名属于当前作用域内的默认命名空间(如果有);
p:local(在第一个冒号处拆分)属于绑定到p的命名空间。无前缀的属性从不属于任何命名空间;带前缀的属性按与元素相同的方式解析。 - 命名规则(紧凑输出与
preserveOrder输出相同):无命名空间 → 本地名;命名空间为U→ 若选项对象将U(自有键)映射到alias,则为alias:local(若alias为""则为local),否则对 XML 命名空间为xml:local,否则为{U}local;true等同于{}。 xmlns/xmlns:*声明不是属性:从不出现在输出中(@_键、attributesGroupName分组、:@映射),也从不传给ignoreAttributes函数、transformAttributeName或attributeValueProcessor。但即使ignoreAttributes为true或本会将其过滤掉,这些声明依然生效。- 元素或属性上出现未绑定的前缀时,
parse()必须抛出Error,消息恰好为Undeclared namespace prefix: <prefix>。
所有选项和回调看到的都是解析后的名称:transformTagName 和 updateTag 收到元素名,transformAttributeName 收到 attributeNamePrefix + 属性名,unpairedTags / stopNodes 匹配的是先解析、再经 transformTagName 转换后的名称。jPath 使用解析后的名称(例如 soap:Envelope.soap:Body),且同一元素在 updateTag、tagValueProcessor、attributeValueProcessor、ignoreAttributes 和 isArray 中的 jPath 完全相同;jPath: false 时,matcher 的 getCurrentTag() / toString() 同样保持一致。停止节点的名称和属性会被解析,其内容保持原样,结束标签按原文匹配。启用该选项后:removeNSPrefix 被忽略;处理指令(包括 <?xml ...?>)及其属性保持不变;命名空间绑定绝不会在不同的 parse() 调用之间泄漏;解析为同一名称的元素照常归并为数组。
重要:请从 main 新建一个分支来完成这项工作,完成后提交所有改动。
隐藏测试检查什么
- resolveNamespaces 在 X2jOptions 中有类型声明;未设置或为 false 时毫无变化(保留前缀与 xmlns 属性,removeNSPrefix 照常生效);原有回归测试及构建、类型检查防止回归。
- 作用域规则:声明作用于声明元素及其后代,内层声明在元素结束前覆盖外层,xmlns="" 取消默认命名空间,xml 为内置绑定,自闭合/unpaired/停止节点上的声明只作用于自身。
- 命名规则:无命名空间时用本地名,自有键别名得到 alias:local(别名为“""”时为 local),否则为 xml:local 或 {uri}local;true 等同“{}”;preserveOrder 下结果相同。
- xmlns 声明不出现在“@_”键、属性分组或“:@”映射中,也不传给属性回调,但在任何 ignoreAttributes 设置下都依然生效。
- 元素或属性上的未绑定前缀会抛出消息恰为 Undeclared namespace prefix: <prefix> 的错误,preserveOrder 或忽略属性时亦然;选项中的别名不会绑定前缀。
- 各选项与回调收到的都是解析后的名称:名称转换、updateTag、值处理器、isArray、unpairedTags、stopNodes、实体过滤器;另有一个跨前缀的 SOAP 端到端测试。
- 失败→通过测试
- 84
- 回归测试
- 335
- 构建/类型检查
- 2
- 参考解改动文件
- 9
- 参考解新增行
- +806
轨迹关键时刻
第 16–17 次调用 探查原始解析器时发现:把带前缀的标签连同其命名空间一起推入 matcher,会让 updateTag 和值处理器拿到前缀重复的 jPath,而 isArray 不会;因此解析模式只向 matcher 推入解析后的名称。
第 22–23 次调用 实验确认旧路径会对自闭合标签调用两次 transformTagName(加 x_ 前缀的转换把 <a/> 变成 x_x_a);解析模式先去掉 / 再只转换一次,旧路径保持原样。
第 28、32 次调用 从 main 切出 feat/resolve-namespaces 后,Agent 编写了 src/xmlparser/namespaces.js:不可变的写时复制作用域、按第一个冒号拆分、只认自有键的别名查找、固定的 xml 绑定,以及精确的未绑定前缀错误信息。
第 38、40、43 次调用 重构中途,因引用了尚未定义的 readNamespacedStartTag,332 个用例中有 245 个失败。补上该函数并接好 parseXml 后,SOAP 复现脚本对 soap:、env: 和默认命名空间三种信封输出一致,原有用例全部通过。
第 52–54 次调用 以 main 的原始副本为基准做差分测试:选项未设置或为 false 时,4,800 次运行(含回调日志)零差异;强制开启选项则出现 4,224 处差异,说明比对足够灵敏。
第 61、63、74–75 次调用 在两个类型声明文件中加入该选项的类型后,Agent 证明了测试具备区分度:类型测试在 main 的类型下失败,39 个新用例中有 35 个在 main 上失败(其余覆盖选项关闭时的行为)。
第 90–91 次调用 以独立编写的命名模型为对照做模糊测试,首轮每 3,000 份文档约有 2,000 份报失败,实为属性键顺序差异;改用忽略对象键顺序(数组顺序仍比对)的比较后,15,000 份随机文档(5 轮各 3,000 份)全部通过。
第 97–100 次调用 Agent 将 9 个文件(+839/−67)作为一次提交放在 feat/resolve-namespaces 上,确认工作区干净、main 未被改动;一条未带工具调用的总结触发格式错误后,Agent 随即提交任务结果。
Agent 提交了什么
- 模型调用
- 100
- bash 命令
- 108
- 用时(分钟)
- 31
- 改动文件
- 9
- 增 / 删行数
- +839 / −67
Agent 新增 src/xmlparser/namespaces.js(写时复制的 xmlns 作用域与基于 URI 的命名),并将其接入 OrderedObjParser.js,同时在 OptionsBuilder.js 中加入默认值 resolveNamespaces: false;此外还在 src/fxp.d.ts 和 lib/fxp.d.cts 中声明了选项类型,补充了文档并新增 40 个用例,全部作为一次提交放在新分支上。
Agent 提交的改动(节选)
diff --git a/src/xmlparser/OrderedObjParser.js b/src/xmlparser/OrderedObjParser.js
--- a/src/xmlparser/OrderedObjParser.js
+++ b/src/xmlparser/OrderedObjParser.js
@@ -10,6 +10,7 @@ import { Expression, Matcher } from 'path-expression-matcher';
import { ExpressionSet } from 'path-expression-matcher';
import { EntityDecoder, XML, CURRENCY, COMMON_HTML, ENTITY_ACTION } from '@nodable/entities';
import { isUnsafe, HTML as HTML_CONTEXT, XML as XML_CONTEXT } from "is-unsafe"
+import { getNamespaceAliases, createRootScope, isNamespaceDeclaration, declareNamespaces, resolveQName } from './namespaces.js';
// const regx =
@@ -79,12 +80,22 @@ export default class OrderedObjParser {
this.parseTextData = parseTextData;
this.resolveNameSpace = resolveNameSpace;
this.buildAttributesMap = buildAttributesMap;
+ this.buildAttributes = buildAttributes;
+ this.readNamespacedStartTag = readNamespacedStartTag;
this.isItStopNode = isItStopNode;
this.replaceEntitiesValue = replaceEntitiesValue;
this.readStopNodeData = readStopNodeData;
this.saveTextToParentTag = saveTextToParentTag;
this.addChild = addChild;
this.ignoreAttributesFn = getIgnoreAttributesFn(this.options.ignoreAttributes)
+ // resolveNamespaces: namespace URI to alias map when enabled, null otherwise
+ this.nsAliases = getNamespaceAliases(this.options.resolveNamespaces);
+ // removeNSPrefix is ignored when names are resolved by namespace URI
+ this.removeNSPrefix = this.options.removeNSPrefix && this.nsAliases === null;
+ this.decodeNamespaceUri = (value) => {
+ const entityConfig = this.options.processEntities;
+ return entityConfig && entityConfig.enabled ? this.entityDecoder.decode(value) : value;
+ };
this.entityExpansionCount = 0;
this.currentExpandedLength = 0;
this.doctypefound = false;
@@ -181,7 +192,7 @@ function parseTextData(val, tagName, jPath, dontTrim, hasAttributes, isLeafNode,
}
function resolveNameSpace(tagname) {
- if (this.options.removeNSPrefix) {
+ if (this.removeNSPrefix) {
const tags = tagname.split(':');
const prefix = tagname.charAt(0) === '/' ? '/' : '';
if (tags[0] === 'xmlns') {
@@ -206,82 +217,148 @@ function buildAttributesMap(attrStr, jPath, tagName, force = false) {
const matches = getAllMatches(attrStr, attrsRegx);
const len = matches.length; //don't make it inline
- const attrs = {};
-
- // Pre-process values once: trim + entity replacement
- // Reused in both matcher update and second pass
- const processedVals = new Array(len);
- let hasRawAttrs = false;
- const rawAttrsForMatcher = {};
-
+ const attrNames = new Array(len);
for (let i = 0; i < len; i++) {
- const attrName = this.resolveNameSpace(matches[i][1]);
- const oldVal = matches[i][4];
-
- if (attrName.length && oldVal !== undefined) {
- let val = oldVal;
- if (options.trimValues) val = val.trim();
- val = this.replaceEntitiesValue(val, tagName, this.readonlyMatcher);
- processedVals[i] = val;
-
- rawAttrsForMatcher[attrName] = val;
- hasRawAttrs = true;
- }
+ attrNames[i] = this.resolveNameSpace(matches[i][1]);
}
+ return this.buildAttributes(matches, attrNames, jPath, tagName);
+ }
+}
- // Update matcher ONCE before second pass, if applicable
- if (hasRawAttrs && typeof jPath === 'object' && jPath.updateCurrent) {
- jPath.updateCurrent(rawAttrsForMatcher);
+/**
+ * Build the attributes map of a tag
+ * @param {Array<Array<string>>} matches - attribute matches of attrsRegx; [4] is the raw value
+ * @param {Array<string>} attrNames - name to use for each match ('' to skip it)
+ * @param {Matcher} jPath - matcher positioned at the tag
+ * @param {string} tagName
+ */
+function buildAttributes(matches, attrNames, jPath, tagName) {
+ const options = this.options;
+ const len = matches.length; //don't make it inline
+ const attrs = {};
+
+ // Pre-process values once: trim + entity replacement
+ // Reused in both matcher update and second pass
+ const processedVals = new Array(len);
+ let hasRawAttrs = false;
+ const rawAttrsForMatcher = {};
+
+ for (let i = 0; i < len; i++) {
+ const attrName = attrNames[i];
+ const oldVal = matches[i][4];
+
+ if (attrName.length && oldVal !== undefined) {
+ let val = oldVal;
+ if (options.trimValues) val = val.trim();
+ val = this.replaceEntitiesValue(val, tagName, this.readonlyMatcher);
+ processedVals[i] = val;
+
+ rawAttrsForMatcher[attrName] = val;
+ hasRawAttrs = true;
}
+ }
- // Hoist toString() once — path doesn't change during attribute processing
- const jPathStr = options.jPath ? jPath.toString() : this.readonlyMatcher;
+ // Update matcher ONCE before second pass, if applicable
+ if (hasRawAttrs && typeof jPath === 'object' && jPath.updateCurrent) {
+ jPath.updateCurrent(rawAttrsForMatcher);
+ }
- // Second pass: apply processors, build final attrs
- let hasAttrs = false;
- for (let i = 0; i < len; i++) {
- const attrName = this.resolveNameSpace(matches[i][1]);
+ // Hoist toString() once — path doesn't change during attribute processing
+ const jPathStr = options.jPath ? jPath.toString() : this.readonlyMatcher;
- if (this.ignoreAttributesFn(attrName, jPathStr)) continue;
+ // Second pass: apply processors, build final attrs
+ let hasAttrs = false;
+ for (let i = 0; i < len; i++) {
+ const attrName = attrNames[i];
…… 其余 988 行从略(共改动 9 个文件)
样例 4 / 8
列表页行内编辑:column_editable_list 与 JSON 更新接口
sqladmin:一个 Python 库,为 Starlette 和 FastAPI 应用提供基于 SQLAlchemy 模型(支持同步与异步引擎)的 Web 管理后台。
让管理员直接在列表页修改选定的列。为此需要新增 ModelView.column_editable_list(在添加视图时校验),以及一个只校验并保存单个字段、以 JSON 应答的接口 POST /{identity}/ajax/update/{pk:path};可编辑单元格要带上指向该接口的 data 属性,错误检查按固定顺序进行,状态码依次为 404、403、404、403、400。
难在哪里
add_view()要拒绝关系、点号路径、Python 属性及不在列表或编辑表单上的列,在运行中的事件循环里同样如此,尽管scaffold_form是异步的。- 五种错误按固定顺序判定;400 要么是字段校验错误(不保存、不触发钩子和审计),要么是
on_model_change或数据库的异常信息(行不变)。 name是属性名(data)而非 WTForms 字段键(data_);可空列的空输入须存 NULL,但可空布尔列的下拉字段会把空串转成 False。value与display须反映保存后的实际数据,且每条路径在同步和异步引擎上表现一致。
需求原文(Agent 收到的唯一输入)
Changing a value on many rows means an edit-page trip per row. Add ModelView.column_editable_list (default []; names or InstrumentedAttributes). Each entry must be a mapped column (no relationship/dotted path) appearing in the list and on the edit form, else Admin.add_view() raises sqladmin.exceptions.ImproperlyConfigured.
Add POST /{identity}/ajax/update/{pk:path} named admin:ajax_update (Admin.ajax_update) with form fields name (attribute name, e.g. data for field data_) and value. Validate only that edit-form field, then save its data via update_model(request, pk, {name: data}); empty input on nullable columns stores NULL. on_model_change/after_model_change get ({name: data}, obj, False, request); audit gets AuditEntry(action="update", identity=..., pk=<str>, changes={name: data}). Return 200 JSON: pk (string), name, value (stored value as JSON; non-str Enum → member name, dates → ISO), display (str() of the list cell). A Response from after_model_change replaces it. Sync and async engines alike.
Editable columns' <td>s in rows showing the edit link, and no others, get data-inline-edit-name="<attribute>" and data-inline-edit-url="<row's absolute ajax_update URL>".
Errors
In order: unknown identity 404; can_edit/is_accessible false 403; no row 404; check_can_edit not True 403; name missing or not editable 400. 400s give non-empty {"errors": [...]}: that field's WTForms messages if invalid (no save, hooks or audit), or [str(exc)] if on_model_change or the DB raises (row unchanged).
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
要修改很多行上的某个值,就意味着每一行都要进一次编辑页。请新增 ModelView.column_editable_list(默认 [];元素为名称或 InstrumentedAttribute)。每一项都必须是已映射的列(不能是关系或点号路径),且同时出现在列表中和编辑表单上,否则 Admin.add_view() 抛出 sqladmin.exceptions.ImproperlyConfigured。
新增 POST /{identity}/ajax/update/{pk:path},路由名为 admin:ajax_update(Admin.ajax_update),表单字段为 name(属性名,例如字段 data_ 对应 data)和 value。只校验编辑表单中的这一个字段,然后通过 update_model(request, pk, {name: data}) 保存它的 data;可空列收到空输入时存为 NULL。on_model_change / after_model_change 收到 ({name: data}, obj, False, request);审计收到 AuditEntry(action="update", identity=..., pk=<str>, changes={name: data})。返回 200 JSON:pk(字符串)、name、value(存储值的 JSON 形式;非 str 的 Enum → 成员名,日期 → ISO 格式)、display(列表单元格的 str())。若 after_model_change 返回 Response,则用它代替该 JSON。同步和异步引擎都要同样支持。
在显示编辑链接的行中,可编辑列的 <td>(且仅限这些)带上 data-inline-edit-name="<attribute>" 和 data-inline-edit-url="<该行 ajax_update 的绝对 URL>"。
错误
按以下顺序:未知 identity 返回 404;can_edit / is_accessible 为 false 返回 403;行不存在返回 404;check_can_edit 不为 True 返回 403;name 缺失或不可编辑返回 400。400 响应给出非空的 {"errors": [...]}:值无效时为该字段的 WTForms 错误信息(不保存、不调用钩子、不写审计);若 on_model_change 或数据库抛出异常,则为 [str(exc)](该行保持不变)。
重要:请从 main 新建分支进行这项工作,完成后提交所有改动。
隐藏测试检查什么
- column_editable_list 默认为“[]”,可接受名称、InstrumentedAttribute 或二者混用,覆盖同步/异步引擎及运行中的事件循环。
- 仅在 add_view() 时(而非类声明时)对关系、点号路径、未知名称、Python 属性,以及不在列表或编辑表单上的列(表单规则、自定义表单、主键/外键)抛出 ImproperlyConfigured。
- 路由名为 admin:ajax_update,保存值并返回含 pk/name/value/display 的 JSON,含斜杠的主键和复合主键也要支持。
- name 取属性名(字段 data_ 对应 data);若提交 WTForms 字段键 data_,同步和异步引擎都返回带 errors 的 400。
- 只校验提交的那个字段;值无效时返回该字段的错误,且不保存、不触发钩子、不写审计。
- 可编辑单元格带 data-inline-edit-name 和绝对 URL 形式的 data-inline-edit-url;807 个已有测试须继续通过,且包仍须能构建并通过 mypy 检查。
- 失败→通过测试
- 209
- 回归测试
- 807
- 构建/类型检查
- 2
- 参考解改动文件
- 9
- 参考解新增行
- +474
轨迹关键时刻
第 4–5、8、11、16–17、19 次调用 创建 feature/column-editable-list 分支后,阅读 application.py、models.py、_queries.py、forms.py 及 WTForms 的 Form 源码,注意到名为 data 的列在表单类中的字段键是 data_,HTML 中的字段名却是 data。
第 35–37、43–44 次调用 阅读 sqladmin 字段类时发现,可空布尔列所用的下拉字段会把空字符串转成 False,可能破坏“空输入存 NULL”的要求;随后核对了 WTForms 如何处理缺失的键。
第 46–52 次调用 命令触发 30 秒上限后,改在后台运行基线测试(807 个通过),并写复现脚本确认功能缺失。
第 53–57 次调用 先探查 SQLAlchemy mapper 属性(计算型 column_property、hybrid、关系),再新增 column_editable_list 并在 ModelView.__init__ 中校验,由一个仿照 sqladmin 表单生成规则的辅助方法判断列是否在编辑表单上。
第 58–61 次调用 新增 to_json_value 辅助函数、先加载行再调用 check_can_edit 以符合 404/403/404/403 顺序的 _ajax_update 守卫,以及通过现有 update_model 保存的 ajax_update 接口。
第 65、70–73、77–79 次调用 让 list.html 每行只计算一次 check_can_edit 并标记可编辑单元格,又在 main.js 中写了行内编辑器。
第 84–86、95、98–100、110–111 次调用 编写 69 个同步和 15 个异步测试,补上 coverage 报告发现的两处缺口,随后重跑全量测试:908 个通过、4 个跳过,覆盖率 98%,ruff、mypy 和 bandit 均无问题。
第 114–117 次调用 在分支上提交全部改动;第一次最终回复因未含工具调用被拒,Agent 重新给出总结,随后结束任务、交付结果。
Agent 提交了什么
- 模型调用
- 117
- bash 命令
- 139
- 用时(分钟)
- 64
- 改动文件
- 11
- 增 / 删行数
- +2,062 / −3
Agent 在新分支上提交了在添加视图时即校验的 column_editable_list、admin:ajax_update 接口、列表单元格属性,以及 JS 编辑器、CSS、文档和自写测试。该接口带有按规定错误顺序判定的守卫,只做单字段校验,并通过现有 update_model 保存。
Agent 提交的改动(节选)
diff --git a/sqladmin/application.py b/sqladmin/application.py
--- a/sqladmin/application.py
+++ b/sqladmin/application.py
@@ -15,10 +15,12 @@ from typing import (
)
from urllib.parse import parse_qsl, urljoin
+import wtforms
from jinja2 import ChoiceLoader, FileSystemLoader, PackageLoader, PrefixLoader
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from sqlalchemy.orm import Session, sessionmaker
+from sqlalchemy.orm.exc import DetachedInstanceError
from starlette import status
from starlette.applications import Starlette
from starlette.datastructures import URL, FormData, MultiDict, UploadFile
@@ -53,7 +55,9 @@ from sqladmin.forms import WTFORMS_ATTRS, WTFORMS_ATTRS_REVERSED
from sqladmin.helpers import (
get_object_identifier,
is_async_session_maker,
+ object_identifier_values,
slugify_action_name,
+ to_json_value,
)
from sqladmin.i18n import (
BABEL_INSTALLED,
@@ -88,6 +92,24 @@ def _save_button_clicked(form: FormData, label: str) -> bool:
return form.get("save") in (label, gettext(label))
+def _ajax_errors(*messages: Any) -> JSONResponse:
+ """The 400 answer of `Admin.ajax_update`."""
+
+ return JSONResponse(
+ {"errors": [str(message) for message in messages]},
+ status_code=status.HTTP_400_BAD_REQUEST,
+ )
+
+
+def _csrf_field(form: wtforms.Form) -> wtforms.Field | None:
+ """The CSRF token field of ``form``, if its form class enables WTForms CSRF."""
+
+ if not getattr(form.meta, "csrf", False):
+ return None
+ name = form.meta.csrf_field_name
+ return form[name] if name in form else None
+
+
class BaseAdmin:
"""Base class for implementing Admin interface.
@@ -458,6 +480,33 @@ class BaseAdminView(BaseAdmin):
if can_edit_row is not True:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+ async def _ajax_update(self, request: Request) -> tuple[ModelView, Any]:
+ """Guard an in-place edit from the list page and return its view and row.
+
+ Unlike `_edit`, the row is loaded before `check_can_edit` is asked
+ about it, so an unknown row is a 404 rather than a question about
+ ``None``.
+ """
+
+ model_view = self._find_model_view(request.path_params["identity"])
+ if not model_view.can_edit or not model_view.is_accessible(request):
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+
+ try:
+ object_identifier_values(request.path_params["pk"], model_view.model)
+ except (ArithmeticError, TypeError, ValueError) as exc:
+ # A malformed identifier matches no row.
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND) from exc
+
+ obj = await model_view.get_object_for_edit(request)
+ if obj is None:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND)
+
+ if await model_view.check_can_edit(request, obj) is not True:
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN)
+
+ return model_view, obj
+
async def _import(self, request: Request) -> None:
model_view = self._find_model_view(request.path_params["identity"])
if not model_view.is_accessible(request):
@@ -650,6 +699,12 @@ class Admin(BaseAdminView):
Route(
"/{identity}/ajax/lookup", endpoint=self.ajax_lookup, name="ajax_lookup"
),
+ Route(
+ "/{identity}/ajax/update/{pk:path}",
+ endpoint=self.ajax_update,
+ name="ajax_update",
+ methods=["POST"],
+ ),
Route("/login", endpoint=self.login, name="login", methods=["GET", "POST"]),
Route("/logout", endpoint=self.logout, name="logout", methods=["GET"]),
Route(
@@ -1077,6 +1132,106 @@ class Admin(BaseAdminView):
data = [loader.format(m) for m in await loader.get_list(request, term)]
return JSONResponse({"results": data})
+ @login_required
+ async def ajax_update(self, request: Request) -> Response:
+ """Save one column of one row, edited in place on the list page.
+
+ Takes the form fields ``name``, the attribute to change (which must
+ be in `ModelView.column_editable_list`), and ``value``. The value is
+ validated by that column's edit form field alone and saved through
+ `ModelView.update_model`, so `on_model_change`, `after_model_change`
+ and the audit backend all see ``{name: data}``. An empty ``value``
+ stores ``NULL`` in a nullable column.
+
+ Answers with JSON: the row's ``pk``, the ``name``, the stored
+ ``value`` and its ``display``, i.e. the list page cell as a string.
+ Invalid input or a failed save answers 400 with ``{"errors": [...]}``,
+ while a `Response` returned by `after_model_change` is used as is.
+ """
+
+ model_view, _ = await self._ajax_update(request)
+ pk = request.path_params["pk"]
+
+ request_form = await request.form()
+ name = request_form.get("name")
+ if not isinstance(name, str) or not name:
+ return _ajax_errors("The 'name' field is required.")
+ if name not in model_view._editable_fields:
+ return _ajax_errors(f"{name!r} cannot be edited in place.")
+
+ Form = await model_view.scaffold_form(model_view._form_edit_rules)
+ form = Form()
+ key = WTFORMS_ATTRS.get(name, name)
+ if key not in form:
+ return _ajax_errors(f"{name!r} is not a field of the edit form.")
…… 其余 2,125 行从略(共改动 11 个文件)
样例 5 / 8
go-jira:为 429/503 加入可选重试并解析限流响应头
go-jira:用 Go 编写的 Jira REST API 客户端库,分为 cloud(Jira Cloud)和 onpremise(Jira Server / Data Center)两个包。
为 cloud 和 onpremise 两个包的 Client.Do 增加可选启用的 RetryPolicy:收到 429/503 时重试,但始终要求请求体可重放,且除非开启 RetryNonIdempotent,只重试 GET、HEAD、OPTIONS、PUT 和 DELETE 请求;等待时间取 Retry-After 或指数退避,两者均以 MaxBackoff 封顶。同时新增 RateLimit 字段,在返回的每个 Response 上提供解析后的限流响应头。
难在哪里
- 两个包的重试语义必须完全一致,但
cloud与onpremise各自有不同的RateLimit结构体,所解析的响应头也部分不同。 - 重试需同时满足四个条件:仅限 429/503;默认仅限 GET/HEAD/OPTIONS/PUT/DELETE(除非开启
RetryNonIdempotent);请求体可重放(Body为 nil 或已设GetBody);总请求数不超过MaxRetries+1。 Backoff边界多:Retry-After: 0必须生效,HTTP 日期和负数要忽略,结果以MaxBackoff封顶且不能溢出,非正的上下限取默认值。- 错误路径要求严格:错误信息和
NewJiraError结果不变,传输错误立即返回,Sleep出错时返回 nil*Response及可被errors.Is匹配的错误。
需求原文(Agent 收到的唯一输入)
Every cloud and onpremise service method goes through Client.Do, which makes a single attempt, so integrations hand-roll 429/503 retries and parse rate-limit headers themselves. Let's bring opt-in retries and parsed rate-limit info to both packages, with identical retry semantics.
Both get a RetryPolicy struct (MaxRetries int, MinBackoff, MaxBackoff time.Duration, RetryNonIdempotent bool, Sleep func(ctx context.Context, d time.Duration) error) and a Client.RetryPolicy *RetryPolicy field. NewClient leaves it nil; a nil policy or MaxRetries <= 0 means a single request and today's exact behaviour.
Otherwise Do retries only when the status is 429 or 503, the method is GET, HEAD, OPTIONS, PUT or DELETE (any method if RetryNonIdempotent is true), the body is replayable (req.Body == nil or req.GetBody != nil) and fewer than MaxRetries retries were made, so at most MaxRetries+1 requests go out. No other status is retried (not even 500/502/504); http.Client transport errors return immediately. Before retry n (1-based), drain and close the previous response and call Sleep(req.Context(), policy.Backoff(n, previousResponse)) even for a zero delay; a nil Sleep waits out the delay on a timer but returns ctx.Err() as soon as the context is done. If Sleep returns an error, stop and return a nil *Response with an error matching it under errors.Is. Retries resend the same method, URL, headers and a byte-identical body from req.GetBody; requests from NewRequest, NewMultiPartRequest, and NewRawRequest with a strings.Reader, bytes.Reader or bytes.Buffer are replayable, while NewRawRequest over other readers is sent once. When retries run out or a response isn't retryable, Do handles it exactly as today (decode 2xx into v, else return the *Response and the usual CheckResponse error), so service methods still return their NewJiraError result and resp.StatusCode is the final status.
func (p *RetryPolicy) Backoff(attempt int, resp *http.Response) time.Duration returns resp's Retry-After in seconds when resp is non-nil and the header is a non-negative integer, else MinBackoff * 2^(attempt-1); HTTP-date, negative or malformed values are ignored. Either result is capped at MaxBackoff without overflowing (Backoff(100, nil) == MaxBackoff). MinBackoff <= 0 means 1s, MaxBackoff <= 0 means 30s, attempt < 1 counts as 1. For example, with MaxRetries 3, MinBackoff 100ms and MaxBackoff 250ms, a GET to an always-503 server (no Retry-After) makes 4 requests, Sleep sees 100ms, 200ms, 250ms, and the call fails with resp.StatusCode 503.
Also add a Response.RateLimit RateLimit field, filled from headers on every *Response Do returns (2xx or error status, with or without a policy); missing or unparseable headers leave fields zero. In cloud, RateLimit has Limit, Remaining int (X-RateLimit-Limit, X-RateLimit-Remaining), Reset time.Time (X-RateLimit-Reset, RFC 3339), NearLimit bool (true iff X-RateLimit-NearLimit is "true", ignoring case), Reason string (RateLimit-Reason, verbatim) and RetryAfter time.Duration (Retry-After, non-negative integer seconds). In onpremise it has the same Limit, Remaining and RetryAfter, plus FillRate int (X-RateLimit-FillRate) and Interval time.Duration (X-RateLimit-Interval-Seconds, integer seconds). Nothing else on Response or in error messages should change.
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
cloud 和 onpremise 中的每个服务方法都要经过 Client.Do,而它只发起一次尝试,因此各集成方都是自己手写 429/503 重试、自行解析限流响应头。我们要为这两个包都加上可选启用(opt-in)的重试和解析好的限流信息,并且两者的重试语义完全相同。
两个包都新增一个 RetryPolicy 结构体(MaxRetries int、MinBackoff, MaxBackoff time.Duration、RetryNonIdempotent bool、Sleep func(ctx context.Context, d time.Duration) error)以及 Client.RetryPolicy *RetryPolicy 字段。NewClient 让它保持为 nil;策略为 nil 或 MaxRetries <= 0 表示只发一次请求,行为与现在完全一致。
否则,Do 仅在以下条件同时满足时重试:状态码为 429 或 503;方法为 GET、HEAD、OPTIONS、PUT 或 DELETE(若 RetryNonIdempotent 为 true,则任何方法都可以);请求体可重放(req.Body == nil 或 req.GetBody != nil);已做的重试少于 MaxRetries 次。因此最多发出 MaxRetries+1 个请求。其他状态码一律不重试(即使是 500/502/504);http.Client 的传输错误立即返回。在第 n 次重试(从 1 开始计数)之前,要读空并关闭上一个响应,并调用 Sleep(req.Context(), policy.Backoff(n, previousResponse)),即使延迟为零也要调用;Sleep 为 nil 时,用定时器等满该延迟,但一旦 context 结束就立即返回 ctx.Err()。如果 Sleep 返回错误,则停止重试,返回 nil 的 *Response,以及一个在 errors.Is 下与之匹配的错误。重试会重发相同的方法、URL 和请求头,以及由 req.GetBody 得到的逐字节相同的请求体;由 NewRequest、NewMultiPartRequest 创建的请求,以及传入 strings.Reader、bytes.Reader 或 bytes.Buffer 的 NewRawRequest 请求都可重放,而基于其他 reader 的 NewRawRequest 只发送一次。当重试用尽或响应不可重试时,Do 的处理与现在完全相同(2xx 时解码到 v,否则返回 *Response 和通常的 CheckResponse 错误),因此服务方法仍返回其 NewJiraError 结果,且 resp.StatusCode 为最终状态码。
func (p *RetryPolicy) Backoff(attempt int, resp *http.Response) time.Duration:当 resp 非 nil 且其 Retry-After 头是非负整数时,返回以秒计的 Retry-After 值,否则返回 MinBackoff * 2^(attempt-1);HTTP 日期、负数或格式错误的值都会被忽略。无论哪种结果,都以 MaxBackoff 为上限且不得溢出(Backoff(100, nil) == MaxBackoff)。MinBackoff <= 0 表示 1s,MaxBackoff <= 0 表示 30s,attempt < 1 按 1 处理。例如,MaxRetries 为 3、MinBackoff 为 100ms、MaxBackoff 为 250ms 时,对一个始终返回 503(且不带 Retry-After)的服务器发起 GET:会发出 4 个请求,Sleep 依次收到 100ms、200ms、250ms,调用最终失败,resp.StatusCode 为 503。
另外新增 Response.RateLimit RateLimit 字段,Do 返回的每个 *Response 都要根据响应头填充它(无论 2xx 还是错误状态码,也无论是否设置策略);缺失或无法解析的头部使对应字段保持零值。在 cloud 中,RateLimit 包含 Limit, Remaining int(X-RateLimit-Limit、X-RateLimit-Remaining)、Reset time.Time(X-RateLimit-Reset,RFC 3339)、NearLimit bool(当且仅当 X-RateLimit-NearLimit 不区分大小写地等于 "true" 时为 true)、Reason string(RateLimit-Reason,原样保留)以及 RetryAfter time.Duration(Retry-After,非负整数秒)。在 onpremise 中,它有同样的 Limit、Remaining 和 RetryAfter,另加 FillRate int(X-RateLimit-FillRate)和 Interval time.Duration(X-RateLimit-Interval-Seconds,整数秒)。Response 上的其他内容以及错误信息都不应改变。
重要:请从 main 新建一个分支进行这项工作,完成后提交所有改动。
隐藏测试检查什么
- 两个包都提供 RetryPolicy 和 Client.RetryPolicy;NewClient 将其保持为 nil,策略为 nil 或 MaxRetries <= 0 时只发送一次请求。
- 只对 429/503、允许的方法和可重放请求体重试,请求数上限为 MaxRetries+1;500/502/504、传输错误和重定向循环都不重试。
- 第 n 次重试前读空并关闭上一个响应,Sleep 收到 req.Context() 和 Backoff(n, prev),延迟为零也照常调用。
- Sleep 为 nil 时用定时器等待,但 context 一结束就返回 ctx.Err();Sleep 出错则返回 nil *Response 和可被 errors.Is 匹配的错误。
- 重试会重发完全相同的请求,请求体取自 GetBody;issue 的更新和删除调用会重试,而 issue 创建和附件上传仅在开启 RetryNonIdempotent 时重试。
- 请求中始终返回 503 的示例在 4 次请求后以状态码 503 和常规的 NewJiraError 结束;Response.RateLimit 的填充和错误信息不变也有端到端检查。
- 失败→通过测试
- 495
- 回归测试
- 389
- 构建/类型检查
- 1
- 参考解改动文件
- 8
- 参考解新增行
- +575
轨迹关键时刻
第 3、5–6、10 次调用 通读两个包的 jira.go,对比 cloud 与 onpremise(含 error.go)的差异,统计出测试和示例之外共 198 处 .Do( 调用,并跑通构建、测试和 go vet 的基线。
第 12–14 次调用 阅读 net/http 源码,确认请求体为 bytes.Buffer、bytes.Reader 或 strings.Reader 时,net/http 会自动设置 GetBody;又发现 http.Client 会把 cookie jar 的 cookie 加进 req.Header,于是决定在首次发送前快照请求头。
第 14–16 次调用 从 main 新建 feature/retry-policy-and-rate-limits 分支并编写独立复现程序;离线环境下校验和查询失败后改用仓库的 go.sum,程序因所需 API 尚不存在而编译失败。
第 18–22 次调用 为每个包编写 retry.go 和 ratelimit.go(onpremise/retry.go 由 cloud/retry.go 复制而来,仅改 package 行),并修改两个 jira.go,让 Do 调用新的 send 循环、由 newResponse 填充 RateLimit。
第 23、25、29 次调用 复现程序随后在两个包中均通过(4 次请求,Sleep 依次收到 100ms、200ms、250ms,最终 503);Agent 编写了与包无关的重试测试,只改 package 声明就复制到 onpremise。
第 31–35、37–38 次调用 对自写测试做变异测试:遇到 30 秒命令超时后恢复 retry.go 并分小批重跑;另有两个变异的“击杀”其实源于 vet/编译错误,Agent 改写这两个变异后重测,最终每个变异都由失败的测试捕获。
第 41–42、44、49 次调用 发现 $? 取自 tail 后重新运行 staticcheck,验证一次 4 请求的重试序列复用同一个连接,编译检查 README 示例代码,并在 -race 下将新测试重复运行 5 次。
第 53–57 次调用 将全部 12 个文件放进同一个 commit,确认 merge-base 等于 main、工作区干净,并重跑 -race 测试、gofmt 和 vet;commit 之后、最终交付任务之前,有两条未带工具调用的总结回复触发了格式错误。
Agent 提交了什么
- 模型调用
- 57
- bash 命令
- 62
- 用时(分钟)
- 31
- 改动文件
- 12
- 增 / 删行数
- +2,533 / −6
Agent 在从 main 新建的分支上提交了 12 个文件:两个包各自的 retry.go 和 ratelimit.go(两份 retry.go 除 package 行外完全相同)、改为经由 send 循环发送请求的 Client.Do、在 newResponse 中解析的 RateLimit,以及测试、README 和 CHANGELOG 的更新。
Agent 提交的改动(节选)
diff --git a/cloud/retry.go b/cloud/retry.go
new file mode 100644
--- /dev/null
+++ b/cloud/retry.go
@@ -0,0 +1,262 @@
+package cloud
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+const (
+ // defaultMinBackoff is used by RetryPolicy.Backoff if MinBackoff is not positive.
+ defaultMinBackoff = 1 * time.Second
+
+ // defaultMaxBackoff is used by RetryPolicy.Backoff if MaxBackoff is not positive.
+ defaultMaxBackoff = 30 * time.Second
+
+ // maxDrainBytes limits how much of a response body is read before a retry discards it.
+ // Small bodies are read completely so that the connection can be reused,
+ // without the risk of reading an arbitrarily large body.
+ maxDrainBytes = 64 << 10
+
+ // maxDuration is the largest representable time.Duration.
+ maxDuration = time.Duration(math.MaxInt64)
+
+ // maxDurationSeconds is the largest number of whole seconds a time.Duration can hold.
+ maxDurationSeconds = int64(maxDuration / time.Second)
+)
+
+// RetryPolicy configures how Client.Do retries requests that Jira rejected because of
+// rate limiting (429 Too Many Requests) or temporary unavailability (503 Service Unavailable).
+//
+// Retries are opt-in: they only happen if Client.RetryPolicy is set and MaxRetries is positive.
+// A request is retried only if all of the following conditions are met:
+//
+// - the response status is 429 or 503. No other status is retried (not even 500, 502 or 504),
+// and errors returned by the http.Client (e.g. network errors) are returned immediately.
+// - the method is GET, HEAD, OPTIONS, PUT or DELETE, or RetryNonIdempotent is set.
+// - the request body can be replayed: the request has no body or Request.GetBody is set.
+// This is the case for all requests created by NewRequest and NewMultiPartRequest,
+// and for NewRawRequest with a *strings.Reader, *bytes.Reader or *bytes.Buffer body.
+// Requests created by NewRawRequest with any other reader are sent only once.
+// - fewer than MaxRetries retries have been made, so at most MaxRetries+1 requests are sent.
+//
+// A retry resends the same method, URL, headers and body.
+// Before retry n (1-based), the previous response is drained and closed and
+// Sleep is called with the request's context and Backoff(n, previousResponse).
+//
+// Once a response is not retried (or no retries are left), Do handles it as without a policy.
+// The returned Response therefore always describes the last attempt.
+type RetryPolicy struct {
+ // MaxRetries is the maximum number of retries per request.
+ // Zero or a negative value disables retries.
+ MaxRetries int
+
+ // MinBackoff is the base delay of the exponential backoff (default 1s if <= 0).
+ MinBackoff time.Duration
+
+ // MaxBackoff caps every delay, including those requested via Retry-After (default 30s if <= 0).
+ MaxBackoff time.Duration
+
+ // RetryNonIdempotent enables retries for all request methods, including POST and PATCH.
+ // Only enable it if sending such a request more than once is safe for your use case.
+ RetryNonIdempotent bool
+
+ // Sleep waits for the given delay before a retry.
+ // It is called before every retry, even if the delay is zero.
+ // If it returns an error, Do stops retrying and returns a nil *Response
+ // together with an error that wraps the returned error.
+ //
+ // If Sleep is nil, Do waits for the delay on a timer, but returns
+ // ctx.Err() as soon as the context is done.
+ Sleep func(ctx context.Context, d time.Duration) error
+}
+
+// Backoff returns how long to wait before retry number attempt (1-based),
+// given resp, the response to the previous attempt (which may be nil).
+//
+// If resp has a Retry-After header holding a non-negative integer number of seconds,
+// that delay is returned. Otherwise, the delay grows exponentially: MinBackoff * 2^(attempt-1).
+// Retry-After values in the HTTP-date format as well as negative or malformed values are ignored.
+// The result is capped at MaxBackoff.
+//
+// A MinBackoff <= 0 is treated as 1s, a MaxBackoff <= 0 as 30s and an attempt < 1 as 1.
+func (p *RetryPolicy) Backoff(attempt int, resp *http.Response) time.Duration {
+ minBackoff, maxBackoff := defaultMinBackoff, defaultMaxBackoff
+ if p != nil && p.MinBackoff > 0 {
+ minBackoff = p.MinBackoff
+ }
+ if p != nil && p.MaxBackoff > 0 {
+ maxBackoff = p.MaxBackoff
+ }
+
+ if resp != nil {
+ if d, ok := parseDelaySeconds(resp.Header.Get("Retry-After")); ok {
+ if d > maxBackoff {
+ return maxBackoff
+ }
+ return d
+ }
+ }
+
+ if attempt < 1 {
+ attempt = 1
+ }
+ // minBackoff << shift <= maxBackoff holds exactly if minBackoff <= maxBackoff >> shift.
+ // Checking it this way avoids overflowing for large attempts.
+ shift := uint(attempt - 1)
+ if minBackoff > maxBackoff>>shift {
+ return maxBackoff
+ }
+ return minBackoff << shift
+}
+
+// shouldRetry reports whether p allows retrying req at all.
+// It does not consider the response status or the number of retries made so far.
+func (p *RetryPolicy) shouldRetry(req *http.Request) bool {
+ if p == nil || p.MaxRetries <= 0 {
+ return false
+ }
+ if !p.RetryNonIdempotent && !isIdempotentMethod(req.Method) {
+ return false
+ }
…… 其余 2,538 行从略(共改动 12 个文件)
样例 6 / 8
无状态 cron 触发时刻枚举与任务上次运行查询
node-cron(TypeScript):Node.js 的 cron 表达式任务调度库,任务可内联运行,也可在 fork 出的后台进程中运行,以 ESM 和 CJS 两种构建发布。
新增无状态的 getNextRuns 与 getPreviousRuns,无需创建任务就能列出 cron 表达式在任意时刻之后或之前的触发时刻;同时在调度任务上新增 getPreviousRun() 与 getPreviousRuns(count)。结果必须与调度器的实际触发完全一致,覆盖全部 cron 语法和夏令时切换。
难在哪里
- 反向结果须恰好是现有正向搜索结果的倒序,因此反向搜索要复刻其在夏令时下的实际行为,而非套用理论上的墙上时间规则。
L、nW、#等全部语法都要能反向求解,且每次搜索以 36,600 天为界。- 具名与默认导出、
NodeCron类型、ESM/CJS 构建须遵循同一约定,内联与后台任务在任何状态下都须一致。 - 细节:
from可带毫秒且自身被排除;搜不到时返回较短数组而非抛错;错误参数抛指定错误;任务查询按调度而非运行记录。
需求原文(Agent 收到的唯一输入)
Today you can only preview a cron expression by creating a task, and only forward from now. Nothing tells you when a schedule last fired, which people need for catch-up after downtime, dashboards and tooling. Can you pick up stateless occurrence enumeration from any instant in either direction, plus previous-run lookups on tasks?
Add module-level getNextRuns(expression, options?): Date[] and getPreviousRuns(expression, options?): Date[], with options { count?: number; from?: Date | string | number; timezone?: string }, as named exports of the package entry and members of the default cron export and NodeCron interface, in the ESM and CJS builds. getNextRuns returns the first count occurrences strictly after from, ascending; getPreviousRuns returns the count latest occurrences strictly before from, most recent first. Defaults: count 1, from now, timezone the process's local zone; count 0 returns []. Occurrences are whole seconds but from may carry milliseconds: for */20 * * * * * from 12:00:00.500Z, previous 2 is [12:00:00Z, 11:59:40Z] and next is [12:00:20Z].
Both directions must reproduce exactly what the scheduler fires: backward results are the forward schedule reversed, with no forward occurrence between two returned instants skipped. That holds for all syntax (nicknames, 5 and 6 fields, names, ?, L, L-n, nW, LW, nL, # and wrap-around ranges) and for DST: in a fall-back overlap a wall-clock time fires once, at its first (pre-transition) instant, so the repeated hour never fires, and times skipped by spring-forward never fire. In America/New_York, * * * * * previous 2 from 2025-11-02T06:20:00Z must give [05:59Z, 05:58Z], not 06:19Z, and 30 2 * * * previous 2 from 2025-03-10T12:00:00Z gives [2025-03-10T06:30Z, 2025-03-08T07:30Z].
Each successive search spans 36,600 days from the last occurrence found (or from from); if a search finds nothing, return the shorter array (possibly empty) instead of throwing. So 0 0 0 31 * 0#1 gives [], while 0 0 0 29 2 1 previous 5 from 2026-01-01 in UTC gives Feb 29 of 2016, 1988, 1960, 1932 and 1904.
Bad arguments still throw: an invalid expression throws an Error whose message is exactly the one cron.parse() throws for it; a count that isn't a non-negative integer throws a RangeError; a from that isn't a Date, string or number giving a valid date via new Date(from) throws a TypeError; an unknown timezone throws a RangeError.
On the ScheduledTask interface, add getPreviousRun(): Date | null and getPreviousRuns(count: number): Date[], implemented by inline and background tasks with identical results (background tasks answer from the parent process alone). getPreviousRun() returns the latest occurrence strictly before now for the task's expression and timezone, or null if none, regardless of status (including stopped and destroyed). getPreviousRuns(count) returns the count latest such occurrences, most recent first, and [] for count <= 0.
Leave task firing, the DST model, the existing task methods (getNextRun(), getNextRuns(count), match(), msToNext(), including their 'Could not find next matching date' throw) and cron.parse, validate and validateDetailed unchanged.
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
目前,想预览一个 cron 表达式只能通过创建任务,而且只能从当前时刻往后看。也没有任何途径能告诉你某个调度上一次是什么时候触发的,而停机后的补跑、仪表盘和各类工具都需要这个信息。能否实现从任意时刻出发、正反两个方向的无状态触发时刻枚举,并在任务上提供上次运行的查询?
新增模块级的 getNextRuns(expression, options?): Date[] 和 getPreviousRuns(expression, options?): Date[],选项为 { count?: number; from?: Date | string | number; timezone?: string }。在 ESM 和 CJS 构建中,它们都要作为包入口的具名导出,同时作为默认导出 cron 和 NodeCron 接口的成员。getNextRuns 返回严格晚于 from 的前 count 个触发时刻,按升序排列;getPreviousRuns 返回严格早于 from 的最近 count 个触发时刻,最近的排在最前。默认值:count 为 1,from 为当前时刻,timezone 为进程的本地时区;count 为 0 时返回 []。触发时刻都是整秒,但 from 可以带毫秒:对于 */20 * * * * *,从 12:00:00.500Z 起,之前 2 个为 [12:00:00Z, 11:59:40Z],之后为 [12:00:20Z]。
两个方向都必须与调度器的实际触发完全一致:反向结果就是正向调度序列的倒序,两个返回时刻之间不能漏掉任何正向会出现的触发时刻。这适用于所有语法(预设别名、5 字段和 6 字段、名称、?、L、L-n、nW、LW、nL、# 以及首尾回绕的范围),也适用于夏令时:在回拨造成的重叠时段中,一个墙上时间只触发一次,即在其第一个(切换前的)时刻触发,因此重复的那一小时不会触发;拨快时被跳过的时间也永远不会触发。在 America/New_York 时区,* * * * * 从 2025-11-02T06:20:00Z 起取之前 2 个,必须得到 [05:59Z, 05:58Z],而不是 06:19Z;30 2 * * * 从 2025-03-10T12:00:00Z 起取之前 2 个,得到 [2025-03-10T06:30Z, 2025-03-08T07:30Z]。
每一次搜索的范围都是从上一个找到的触发时刻(或从 from)起的 36,600 天;如果某次搜索一无所获,就返回较短的数组(可能为空),而不是抛出异常。因此 0 0 0 31 * 0#1 返回 [],而 0 0 0 29 2 1 在 UTC 下从 2026-01-01 起取之前 5 个,得到 2016、1988、1960、1932 和 1904 年的 2 月 29 日。
错误参数仍然会抛出异常:无效表达式抛出 Error,其消息与 cron.parse() 对该表达式抛出的消息完全相同;不是非负整数的 count 抛出 RangeError;若 from 不是能经 new Date(from) 得到有效日期的 Date、字符串或数字,则抛出 TypeError;未知时区抛出 RangeError。
在 ScheduledTask 接口上新增 getPreviousRun(): Date | null 和 getPreviousRuns(count: number): Date[],由内联任务和后台任务实现,且两者结果完全相同(后台任务仅由父进程自行给出结果)。getPreviousRun() 按任务的表达式和时区,返回严格早于当前时刻的最近一个触发时刻,没有则返回 null;该结果与任务状态无关(包括已停止和已销毁)。getPreviousRuns(count) 返回最近的 count 个此类触发时刻,最近的排在最前;count <= 0 时返回 []。
以下内容均保持不变:任务的触发行为、夏令时模型、现有任务方法(getNextRun()、getNextRuns(count)、match()、msToNext(),包括它们抛出的 'Could not find next matching date' 异常),以及 cron.parse、validate 和 validateDetailed。
重要:请从 main 新建一个分支进行开发,完成后提交所有改动。
隐藏测试检查什么
- 具名导出与默认导出成员,以及任务的 getPreviousRun()/getPreviousRuns(),在源码及两种构建产物中均可用,新成员在 NodeCron/ScheduledTask 以及 node-cron.d.ts/.d.cts 中均有声明;枚举不会创建或注册任务。
- 恰好位于 from 的触发时刻在两个方向都被排除,from 偏移 1 毫秒时则会出现;getNextRuns 升序,getPreviousRuns 最近在前。
- 每种语法(别名、5/6 字段、名称、“?”、L、L-n、nW、LW、nL、“#”、回绕范围)都能双向枚举,且 validate()/validateDetailed() 仍然接受。
- 检查以下各项:请求中给出的示例、夏令时拨快空档与回拨重叠、时区与默认值、from 的 Date/ISO/毫秒时间戳三种形式、36,600 天搜索跨度、永不匹配时返回“[]”,以及错误类型。
- 内联与后台任务的 getPreviousRun()/getPreviousRuns() 在任何状态下结果一致,后台任务无需守护进程即可作答;现有任务方法(包括 'Could not find next matching date' 异常)及任务触发均保持不变。
- 339 个测试须由失败转为通过,753 个既有测试以及构建、类型检查两项检查(共 755 项)须保持通过。
- 失败→通过测试
- 339
- 回归测试
- 753
- 构建/类型检查
- 2
- 参考解改动文件
- 18
- 参考解新增行
- +1,399
轨迹关键时刻
第 3–5、15、20–22 次调用 阅读入口文件、两个任务类、MatcherWalker 和 localized-time.ts;因单条命令有 30 秒时限,改为在后台运行测试套件(753 个测试通过);tsc 与 eslint 均无报错。
第 26–27、30–31 次调用 Agent 借助转译 TypeScript 的加载钩子进行探查,发现正向遍历会把 UTC 以东时区的重叠时间映射到较晚的时刻,并在 Lord Howe 半小时拨快后跳过部分触发,于是决定让反向搜索复刻这两种行为。
第 34、36–37、39–40 次调用 保留 matchNext() 的抛错,把核心逻辑移入不抛异常的 findNext() 并新增对称的 findPrevious(),随后编写 occurrences.ts(空档跳跃修正)和 runs.ts(参数校验),再接入导出与任务方法。
第 41、49–51 次调用 请求中的示例全部通过后,针对拨快空档的模糊测试与未加修正的对照组均显示零失败;Agent 判定该测试工具在这轮运行中未曾触及跳跃场景,于是直接探查 Lord Howe 用例并调整采样:对照组 300 例中失败 21 例,修正版为 0。
第 70–71 次调用 通过禁用空档跳跃修正和重叠修正对新测试做变异测试;通用模糊测试漏掉了重叠变异体,Agent 便把采样点移近夏令时切换时刻,直到该测试也能捕获它。
第 87、97–99 次调用 在干净的 main worktree 上对照检查重构:正向遍历耗时差异在噪声范围内,2,500 条随机链(10 个时区,每条最多 4 次调用)的结果和抛错完全一致。
第 100–104、108 次调用 构建 dist/ 后,以使用方身份通过包的 exports 映射分别以 ESM 和 CJS 方式引入,并针对两份声明文件对使用方代码做类型检查,还端到端运行了一个真实 fork 出的后台任务。
第 117–120、124 次调用 最后一次重构后重新构建 dist/,删除临时 worktree,在 feat/previous-runs-enumeration 分支上提交,再对已提交代码运行完整测试:42 个文件、867 个测试全部通过。
Agent 提交了什么
- 模型调用
- 125
- bash 命令
- 145
- 用时(分钟)
- 93
- 改动文件
- 15
- 增 / 删行数
- +1,386 / −12
Agent 新增了 src/runs.ts(两个带参数校验的模块级函数)、与正向遍历对称的反向 findPrevious()、src/time/occurrences.ts 中的空档跳跃修正,以及内联和后台任务上按调度计算的 getPreviousRun() / getPreviousRuns(),并补充了 README 文档和测试,全部提交在新分支上。
Agent 提交的改动(节选)
diff --git a/src/time/matcher-walker.ts b/src/time/matcher-walker.ts
--- a/src/time/matcher-walker.ts
+++ b/src/time/matcher-walker.ts
@@ -1,4 +1,4 @@
-import { LocalizedTime, localTimeToTimestamp } from './localized-time';
+import { LocalizedTime, WallClock, getOffsetMinutes, localTimeToTimestamp } from './localized-time';
import { TimeMatcher } from './time-matcher';
import { matchesDayOfMonth, DayOfMonthField } from './day-of-month';
import { matchesDayOfWeek, DayOfWeekField } from './day-of-week';
@@ -8,6 +8,13 @@ import { matchesDayOfWeek, DayOfWeekField } from './day-of-week';
// cheap, so reaching it means the expression can never match (e.g. `0 0 31 2 *`).
const MAX_DAYS = 366 * 100;
+const DAY_MS = 86400000;
+
+// A JS Date spans +/-8.64e15 ms. Within a day of either end, the UTC offset
+// applied while materializing a wall-clock time can step outside that range.
+const DATE_RANGE_MS = 8.64e15;
+const SAFE_RANGE_MS = DATE_RANGE_MS - DAY_MS;
+
export class MatcherWalker {
baseDate: Date;
timeMatcher: TimeMatcher;
@@ -40,7 +47,21 @@ export class MatcherWalker {
/**
* Finds the next instant that satisfies the expression, strictly after the
- * base date.
+ * base date. Throws when there is none within the search bound; see
+ * findNext() for the algorithm.
+ */
+ matchNext(): LocalizedTime {
+ const next = this.findNext();
+ if (next === null) {
+ throw new Error('Could not find next matching date within reasonable time range');
+ }
+ return new LocalizedTime(new Date(next), this.timezone);
+ }
+
+ /**
+ * Finds the next instant that satisfies the expression, strictly after the
+ * base date, and returns its timestamp, or null when there is none within
+ * the search bound (MAX_DAYS calendar days, starting at the base day).
*
* Instead of scanning time tick by tick, it infers candidates directly from
* the cron fields: it walks the calendar day by day (a Y/M/D is the same
@@ -50,10 +71,7 @@ export class MatcherWalker {
* real matcher, which both enforces the weekday constraint and rejects
* non-existent local times (the DST spring-forward gap) by construction.
*/
- matchNext(): LocalizedTime {
- const months = this.months;
- const days = this.days;
-
+ findNext(): number | null {
const baseMs = Math.floor(this.baseDate.getTime() / 1000) * 1000;
const baseParts = new LocalizedTime(new Date(baseMs), this.timezone).getParts();
@@ -65,20 +83,82 @@ export class MatcherWalker {
// exactly, so it only skips days that would be rejected anyway, and it
// avoids scanning every time on a day whose weekday can't match (e.g.
// `* * * 15 * 1`, the 15th only when it is a Monday).
- if (months.includes(month) && matchesDayOfMonth(days, year, month, day) && this.matchesWeekday(year, month, day)) {
+ if (this.isCandidateDay(year, month, day)) {
// On the base day the result must be strictly after the base instant;
// on later days any matching time of day qualifies.
const lowerBound = i === 0 ? baseParts : null;
const found = this.firstTimeOnDay(year, month, day, lowerBound, baseMs);
if (found !== null) {
- return new LocalizedTime(new Date(found), this.timezone);
+ return found;
}
}
({ year, month, day } = nextDay(year, month, day));
}
- throw new Error('Could not find next matching date within reasonable time range');
+ return null;
+ }
+
+ /**
+ * Finds the latest instant strictly before the base date that the forward
+ * walk (findNext) can produce, and returns its timestamp, or null when there
+ * is none within the search bound (MAX_DAYS calendar days, ending at the
+ * starting day).
+ *
+ * It mirrors findNext(): it walks the calendar backwards day by day and, on
+ * each eligible day, tries the matching times in descending order. Every
+ * candidate is materialized exactly like the forward walk does, so:
+ * - a wall-clock time repeated by a DST fall-back resolves to the single
+ * instant the scheduler fires for it, never to the other one;
+ * - a time inside a spring-forward gap materializes past the gap and is
+ * rejected by the matcher (it never fires either).
+ * Within a day, later wall-clock times materialize to later instants, so the
+ * first confirmed candidate is the latest one.
+ *
+ * Occurrences are whole seconds while the base may carry milliseconds: the
+ * latest admissible instant is the last whole second strictly before it.
+ */
+ findPrevious(): number | null {
+ let latest = Math.ceil(this.baseDate.getTime() / 1000) * 1000 - 1000;
+ if (!(latest >= -DATE_RANGE_MS)) return null;
+
+ let bound = this.wallClockAt(latest);
+ const materialized = materialize(bound, this.timezone);
+ // NaN: the wall-clock time lies past the end of the Date range; the scan
+ // below simply skips the times that cannot be materialized.
+ if (materialized !== latest && !Number.isNaN(materialized)) {
+ // `latest` is the instance of a repeated (fall-back) wall-clock time that
+ // is never fired, and so is every instant from the start of that run up
+ // to it. Restart from the last instant before the run, whose wall-clock
+ // is an exact bound for the time-of-day scan below. The run is at most as
+ // long as the distance between the two instances of the wall-clock time.
+ const before = this.lastMaterializedBefore(latest, Math.abs(materialized - latest));
+ // Null only for timezone data not shaped like a DST overlap; the scan
+ // below then still never returns an instant after `latest`.
+ /* v8 ignore else */
+ if (before !== null) {
+ latest = before;
+ bound = this.wallClockAt(latest);
+ }
+ }
+
+ let { year, month, day } = bound;
+
+ for (let i = 0; i < MAX_DAYS; i++) {
+ if (this.isCandidateDay(year, month, day)) {
+ // On the starting day only times up to the bound qualify; on earlier
+ // days any matching time of day does.
…… 其余 1,495 行从略(共改动 15 个文件)
样例 7 / 8
为 Cedar 的 API、CLI 和 FFI 增加逐条策略的授权解释
Cedar:实现 Cedar 授权策略语言的 Rust workspace,包含解析器、求值器、授权器、校验器、格式化工具、CLI 以及 JSON/wasm FFI。
针对每条策略,报告各 scope 约束是否匹配、哪个 when / unless 子句(如有)使其不成立或出错,以及该策略是否决定了最终决策。通过 Authorizer::explain、cedar authorize --explain 和 JSON/wasm FFI 函数提供,且现有行为保持不变。
难在哪里
- 核心 AST 把所有
when/unless子句折叠成一个表达式,连字面量也会被常量折叠,因此无法从 AST 读出子句索引。 - 需求横跨 Rust API、CLI、JSON FFI 和
cedar-wasm,同时现有is_authorized结果、CLI 输出字节和退出码都不得改变。 - 每个 scope 约束需结合模板 slot 值单独判断,涵盖
==、in、is、is ... in和 action 列表;任一不匹配就必须跳过条件。 - 子句求值在第一个不通过的子句处停止:
unless判定取反,运行时错误和非布尔结果都要报告为Error。
需求原文(Agent 收到的唯一输入)
I can't see why a request was denied: is_authorized gives only the decision, determining policy IDs and errors, so a policy whose scope never matched looks like one whose when clause failed. I'd like per-policy explanations in the Rust API, CLI and JSON/wasm FFI; existing behaviour must not change.
Add Authorizer::explain(&self, &Request, &PolicySet, &Entities) -> AuthorizationExplanation plus cedar_policy root types AuthorizationExplanation with response() -> &Response, decision() -> Decision, policies() -> impl Iterator<Item = &PolicyExplanation> and policy(&PolicyId) -> Option<&PolicyExplanation>; PolicyExplanation with id() -> &PolicyId, effect() -> Effect, outcome() -> PolicyOutcome, condition_index() -> Option<usize> and bool getters principal_matched(), action_matched(), resource_matched(), is_determining(); and PolicyOutcome (deriving Debug, Clone, Copy, PartialEq, Eq) with NotApplicable, ConditionFalse, Satisfied, Error.
response()matchesis_authorizedon the same inputs (decision, reasons, errors);decision()is its decision.policies()lists all static and template-linked policies (not unlinked templates), sorted by ID string.- Each scope constraint is evaluated on its own (template links use their slot values); all three flags are always computed. If any is false the outcome is
NotApplicableand conditions aren't evaluated, even erroring ones. - Otherwise
when/unlessclauses are evaluated in source order (the order ofconditionsinPolicy::to_json()) until one doesn't pass (whenpasses ontrue,unlessonfalse):ConditionFalseif it evaluated without error,Errorif it errored or wasn't boolean. If all pass, or there are none, it'sSatisfied. condition_index()is the stopping clause's 0-based index forConditionFalse/Error, elseNone.is_determining()is true iff the ID is inresponse().diagnostics().reason().
cedar authorize --explain prints the normal output unchanged, then a blank line, explanation:, and one line per policy in ID order, <id> (<permit|forbid>): <text>, with <text> one of satisfied (determining), satisfied, scope did not match: <parts>, condition <i> not satisfied, error in condition <i>; <parts> joins the non-matching ones of principal, action, resource (in that order) with , , and <i> is condition_index(). Exit codes are unchanged; without the flag output is byte-identical.
In cedar_policy::ffi, add explain_authorization(AuthorizationCall) -> ExplanationAnswer, explain_authorization_json(serde_json::Value) -> Result<serde_json::Value, serde_json::Error> and explain_authorization_json_str(&str) -> Result<String, serde_json::Error>, plus a wasm_bindgen export explainAuthorization (feature wasm) re-exported by cedar-wasm. Inputs that don't parse give the same failure answer as is_authorized. Success is {"type":"success","response":<as isAuthorized>,"policies":[...],"warnings":[...]}; each policy object, in policies() order, has exactly id, effect ("permit"/"forbid"), principalMatched, actionMatched, resourceMatched, outcome ("notApplicable"/"conditionFalse"/"satisfied"/"error"), conditionIndex (number or null) and determining. The _json variants return Err only if the input isn't an AuthorizationCall.
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
我看不出某个请求为什么被拒绝:is_authorized 只给出决策、起决定作用的策略 ID 和错误,所以一条 scope 从未匹配的策略,看起来和一条 when 子句不成立的策略一模一样。我希望在 Rust API、CLI 和 JSON/wasm FFI 中提供逐条策略的解释;现有行为不得改变。
新增 Authorizer::explain(&self, &Request, &PolicySet, &Entities) -> AuthorizationExplanation,并在 cedar_policy 根模块新增以下类型:AuthorizationExplanation,提供 response() -> &Response、decision() -> Decision、policies() -> impl Iterator<Item = &PolicyExplanation> 和 policy(&PolicyId) -> Option<&PolicyExplanation>;PolicyExplanation,提供 id() -> &PolicyId、effect() -> Effect、outcome() -> PolicyOutcome、condition_index() -> Option<usize>,以及返回 bool 的 getter principal_matched()、action_matched()、resource_matched()、is_determining();以及 PolicyOutcome(派生 Debug, Clone, Copy, PartialEq, Eq),包含 NotApplicable、ConditionFalse、Satisfied、Error 四个变体。
- 在相同输入下,
response()与is_authorized的结果一致(决策、reason 和错误);decision()即其决策。 policies()列出所有静态策略和模板链接策略(不含未链接的模板),按 ID 字符串排序。- 每个 scope 约束单独求值(模板链接使用其 slot 值);三个标志始终都要计算。只要任一为 false,结果就是
NotApplicable,且不对条件求值,即使条件会出错也不求值。 - 否则按源码顺序(即
Policy::to_json()中conditions的顺序)依次对when/unless子句求值,直到某个子句不通过(when在值为true时通过,unless在值为false时通过):若该子句求值未出错,结果为ConditionFalse;若出错或结果不是布尔值,结果为Error。如果全部通过,或者根本没有子句,结果为Satisfied。 - 对于
ConditionFalse/Error,condition_index()是停止处子句从 0 开始的索引,否则为None。 - 当且仅当该 ID 出现在
response().diagnostics().reason()中时,is_determining()为 true。
cedar authorize --explain 先原样打印正常输出,然后输出一个空行、explanation:,再按 ID 顺序为每条策略输出一行 <id> (<permit|forbid>): <text>,其中 <text> 为 satisfied (determining)、satisfied、scope did not match: <parts>、condition <i> not satisfied、error in condition <i> 之一;<parts> 用 , 连接 principal、action、resource 中未匹配的项(按此顺序),<i> 即 condition_index()。退出码不变;不带该选项时输出逐字节相同。
在 cedar_policy::ffi 中新增 explain_authorization(AuthorizationCall) -> ExplanationAnswer、explain_authorization_json(serde_json::Value) -> Result<serde_json::Value, serde_json::Error> 和 explain_authorization_json_str(&str) -> Result<String, serde_json::Error>,以及一个 wasm_bindgen 导出 explainAuthorization(feature wasm),并由 cedar-wasm 重新导出。无法解析的输入返回与 is_authorized 相同的失败结果。成功时为 {"type":"success","response":<与 isAuthorized 相同>,"policies":[...],"warnings":[...]};每个策略对象按 policies() 的顺序排列,恰好包含 id、effect("permit" / "forbid")、principalMatched、actionMatched、resourceMatched、outcome("notApplicable" / "conditionFalse" / "satisfied" / "error")、conditionIndex(数字或 null)和 determining。_json 系列函数仅在输入不是 AuthorizationCall 时返回 Err。
重要:请从 main 新建一个分支来完成这项工作,完成后提交所有改动。
隐藏测试检查什么
- API 形态:Authorizer::explain、根模块类型及其 getter、PolicyOutcome 的 derive、按 ID 的 policy() 查找,以及解释对象在其策略集释放后仍可使用。
- 在带错误的允许、forbid 导致的拒绝、默认拒绝和跳过出错 forbid 等场景下,response() 均与 is_authorized 一致;is_authorized 自身结果不变。
- policies() 按 ID 字符串排序(区分大小写,含默认的 policyN ID);不列出未链接模板;空策略集无条目;模板链接按其 slot 值求值。
- 针对“==”、经实体层级的 in、is、is ... in 和 action 列表检查 scope 标志(单项及组合不匹配);不匹配时为 NotApplicable 且不对条件求值。
- 子句结果:when/unless 的通过规则、在第一个不通过的子句处停止、缺失属性与溢出错误、非布尔值,以及 JSON 策略按 to_json() 的顺序。
- FFI 与 CLI 同样受检:FFI 字段精确、失败结果与 is_authorized 一致、scope 不匹配时不求值条件;CLI --explain 各行精确、不带选项时输出逐字节不变、退出码不变。
- 失败→通过测试
- 138
- 回归测试
- 3,482
- 构建/类型检查
- 1
- 参考解改动文件
- 10
- 参考解新增行
- +1,527
轨迹关键时刻
第 11、19、58 次调用 发现核心 AST 会把所有 when / unless 子句折叠成一个表达式,连字面量子句也会被常量折叠,于是决定从 Policy::to_json() 所序列化的无损 EST 中读取子句。
第 72、75–76 次调用 在 core 中新增公开访问器 est::Policy::conditions(),随后编写 api/explain.rs 和 Authorizer::explain。
第 78–79、84 次调用 新增 FFI 函数、explainAuthorization wasm 导出及其在 cedar-wasm 中的重导出,以及 CLI 的 --explain 选项。
第 89、91–92 次调用 用手写场景运行 CLI:cmp 确认不带选项的输出与 --explain 输出的前缀逐字节相同;when { true } when { false } 在 Cedar 文本和 JSON 策略中都报告 condition 1。
第 102、105、107 次调用 编写 API 测试,包括一个基于生成策略的 oracle 测试;首轮失败只是文本与 JSON 构建的策略集在错误源码位置上的差异,改为只比较决策、reason 集合和逐条策略解释(不再比较整个响应)后解决。
第 116、118、121 次调用 完整的 cedar-policy 套件全部通过,包括新增的 Authorizer::explain 文档示例。
第 127、142–143 次调用 清理新代码中的 clippy 问题,把三个 scope 标志收进一个小结构体以消除 struct_excessive_bools;之后 cedar-policy 在 wasm,partial-eval 和 experimental feature 下 clippy 均为零告警。
第 154、188–189 次调用 为 cedar-policy、CLI 和 cedar-wasm 补充 CHANGELOG 条目,将全部工作作为一个 commit 提交到新分支 authorization-explanation,随后确认工作区干净并重新运行 CLI 演示场景。
Agent 提交了什么
- 模型调用
- 190
- bash 命令
- 197
- 用时(分钟)
- 65
- 改动文件
- 13
- 增 / 删行数
- +2,409 / −9
Agent 交付了三部分:Authorizer::explain 及 AuthorizationExplanation、PolicyExplanation、PolicyOutcome;复用 is_authorized 输入解析路径的 FFI 函数和 wasm 导出;以及在正常输出之后追加解释的 CLI --explain 选项。子句从无损 EST 中逐条读取并求值。
Agent 提交的改动(节选)
diff --git a/cedar-policy/src/api/explain.rs b/cedar-policy/src/api/explain.rs
new file mode 100644
--- /dev/null
+++ b/cedar-policy/src/api/explain.rs
@@ -0,0 +1,341 @@
…
+fn explain_conditions(
+ evaluator: &Evaluator<'_>,
+ policy: &Policy,
+) -> (PolicyOutcome, Option<usize>) {
+ // The AST has no non-scope constraints exactly when the policy has no
+ // `when` or `unless` clauses, in which case there's nothing to evaluate
+ if policy.ast.non_scope_constraints().is_none() {
+ return (PolicyOutcome::Satisfied, None);
+ }
+ // The AST combines all the clauses of a policy into a single expression
+ // (and may simplify that expression), so we can't recover the individual
+ // clauses from it. Instead, use the same lossless representation which
+ // `Policy::to_json()` uses, so that clause indices match the order of the
+ // `conditions` in the policy's JSON form. If that isn't available, fall
+ // back to the representation derived from the AST, which (like the JSON
+ // form of a policy constructed from an AST) has a single `when` clause
+ // containing all the conditions.
+ let est = policy
+ .lossless
+ .est(|| policy.ast.clone().into())
+ .unwrap_or_else(|_| policy.ast.clone().into());
+ for (index, clause) in est.conditions().iter().enumerate() {
+ // `when` clauses are satisfied when they evaluate to `true`, and
+ // `unless` clauses when they evaluate to `false`
+ let (body, satisfied_when) = match clause {
+ est::Clause::When(body) => (body, true),
+ est::Clause::Unless(body) => (body, false),
+ };
+ // A clause which can't be converted to an AST can't be evaluated
+ // successfully, so we report it as erroring
+ let value = body
+ .clone()
+ .try_into_ast(policy.ast.id())
+ .ok()
+ .and_then(|body| evaluate_to_bool(evaluator, &body, policy.ast.env()));
+ match value {
+ Some(value) if value == satisfied_when => {}
+ Some(_) => return (PolicyOutcome::ConditionFalse, Some(index)),
+ None => return (PolicyOutcome::Error, Some(index)),
+ }
+ }
+ (PolicyOutcome::Satisfied, None)
+}
+
+/// Returns `true` if `expr` evaluates to `true`.
+fn evaluates_to_true(evaluator: &Evaluator<'_>, expr: &ast::Expr, slots: &ast::SlotEnv) -> bool {
+ evaluate_to_bool(evaluator, expr, slots) == Some(true)
+}
+
+/// Evaluate `expr`, returning `None` if evaluation produces an error or a
+/// value which is not a boolean (including a residual, if the request or
+/// entities contain unknowns).
+fn evaluate_to_bool(
+ evaluator: &Evaluator<'_>,
+ expr: &ast::Expr,
+ slots: &ast::SlotEnv,
+) -> Option<bool> {
+ let value = evaluator.interpret(expr, slots).ok()?;
+ match value.value_kind() {
+ ast::ValueKind::Lit(ast::Literal::Bool(b)) => Some(*b),
+ _ => None,
+ }
+}
+
+/// An authorization [`Response`], together with an explanation of how each
+/// policy evaluated against the request.
+///
+/// Returned by [`Authorizer::explain()`](super::Authorizer::explain).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct AuthorizationExplanation {
…
+impl ScopeMatches {
+ /// Evaluate each of the scope constraints of `policy` on its own, so that
+ /// we can report all of the constraints which don't match
+ fn new(evaluator: &Evaluator<'_>, policy: &Policy) -> Self {
+ // The constraints of the template may contain slots, which are filled
+ // in by `slots` for template-linked policies
+ let template = policy.ast.template();
+ let slots = policy.ast.env();
+ Self {
+ principal: evaluates_to_true(
+ evaluator,
+ &template.principal_constraint().as_expr(),
+ slots,
+ ),
+ action: evaluates_to_true(evaluator, &template.action_constraint().as_expr(), slots),
+ resource: evaluates_to_true(
+ evaluator,
+ &template.resource_constraint().as_expr(),
+ slots,
+ ),
+ }
+ }
+
+ /// Whether all the scope constraints match the request
+ fn all(self) -> bool {
+ self.principal && self.action && self.resource
+ }
+}
+
+/// Explanation of how a single static or template-linked policy evaluated
+/// against an authorization request.
+///
+/// Obtained from [`AuthorizationExplanation::policies()`] or
+/// [`AuthorizationExplanation::policy()`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct PolicyExplanation {
+ /// ID of the policy
+ id: PolicyId,
+ /// Effect of the policy
+ effect: Effect,
+ /// Which of the policy's scope constraints match the request
+ scope: ScopeMatches,
…… 其余 2,505 行从略(共改动 13 个文件)
样例 8 / 8
为 Extract、Unmarshal 与 Get 实现 RFC 9535 JSONPath 选择器
go-json:Go 语言的 JSON 编解码库,附带 JSONPath 接口(CreatePath、Path.Extract、Path.Unmarshal、Path.Get)。
将 CreatePath 从仅支持四种形式扩展为支持 RFC 9535 选择器(带引号名称、通配符、负索引、切片、联合、后代段,以及不含函数调用和 $ 的过滤器),并让 Extract、Unmarshal 和 Get 共用同一套节点列表语义。同时 Path 须不可变、可在 goroutine 间安全共享,非法路径须返回 *PathError,不能 panic。
难在哪里
- 同一套节点列表语义要同时覆盖原始 JSON 文本(
Extract、Unmarshal)与反射遍历的 Go 值(Get),包括 map 键序和 encoding/json 的结构体字段规则。 - 过滤器需要能处理优先级的表达式解析器(
||、&&、!、§2.3.5.2.2 比较、存在性检测);任何格式错误的路径都须返回*PathError,绝不能 panic。 - 选择器名与原始 JSON 成员键都可能含转义;匹配必须比较双方解码后的名称,不能拿键尚未解码的转义字节去比。
- 旧实现在求值中途修改共享的
Path;新实现须保证调用失败后路径不变,且在多个 goroutine 间无数据竞争。
需求原文(Agent 收到的唯一输入)
CreatePath only accepts .name, [n], [*] and ..name, and Path's Extract, Unmarshal and Get misbehave even there. Please implement RFC 9535 selectors, uniformly for all three methods.
Paths are $ followed by segments .name, .*, ."name", [sel, ...], ..name, ..* or ..[sel, ...]. Selectors are quoted names ('a', "a"; JSON string escapes, plus \' in single quotes), *, indexes (optional -, digits), slices start:end:step (parts optional) and filters ?expr. Whitespace may surround bracket selectors, commas and filter tokens. Filters use || (lowest), &&, !, parentheses and comparisons (==, !=, <, <=, >, >=) whose operands are literals (numbers, quoted strings, true, false, null) or singular queries (@ plus any name or index segments); a bare query like @.isbn tests existence, true even for a null value. Any other path (e.g. [1:2:3:4], a function call or $ in a filter) makes CreatePath return a *PathError; it must never panic.
Evaluation follows RFC 9535 (comparisons per §2.3.5.2.2), and:
- a bracket's selectors apply in order, keeping duplicates;
- object members are visited in document order (raw JSON), ascending key order (Go maps) or field order (structs); a name selects all members with that name;
- a descendant segment applies to the node itself, then to its descendants in pre-order (
$..bon{"x":{"b":1},"b":2}gives 2, 1); - a selector that doesn't apply (missing member, out-of-range index, wrong type, scalar, null) selects nothing, without error.
Extract returns the selected values in order, each as its input text minus surrounding whitespace (zero results when nothing matches), and a *SyntaxError for invalid input anywhere, whatever the path; $ returns the input unchanged. Unmarshal decodes the same values in order, as today. Get(src, dst) walks string-keyed maps, structs (fields encoding/json encodes, by JSON key, ignoring omitempty), slices and arrays, following pointers and interfaces; nil values are null, and it never panics. For a singular path (each segment one name or index, no descendants) it stores the value, or errors when nothing matched; otherwise the list (*[]T as today, *any a possibly empty []any); $ stores src.
A Path must never change, even when a call fails, and be safe to share between goroutines; PathString returns exactly the CreatePath argument. RootSelectorOnly stays true only for $, and UsedSingleQuotePathSelector/UsedDoubleQuotePathSelector still report the quotes used by quoted names.
IMPORTANT: Please work on this in a new branch from main and commit everything when you are done.
需求的中文大意
CreatePath 目前只接受 .name、[n]、[*] 和 ..name,而且即便在这些形式下,Path 的 Extract、Unmarshal 和 Get 的行为也不正确。请实现 RFC 9535 的选择器,并让这三个方法的行为保持一致。
路径是 $ 后接若干段:.name、.*、."name"、[sel, ...]、..name、..* 或 ..[sel, ...]。选择器包括带引号的名称('a'、"a";支持 JSON 字符串转义,单引号内另外支持 \')、*、索引(可选的 - 加数字)、切片 start:end:step(各部分均可省略)以及过滤器 ?expr。方括号中的选择器、逗号和过滤器记号两侧都可以有空白。过滤器使用 ||(优先级最低)、&&、!、括号以及比较运算(==、!=、<、<=、>、>=),其操作数为字面量(数字、带引号的字符串、true、false、null)或单值查询(@ 后接任意个名称段或索引段)。像 @.isbn 这样单独的查询用于检测存在性,即使值为 null 也为真。其他任何路径(例如 [1:2:3:4]、函数调用或过滤器中的 $)都应使 CreatePath 返回 *PathError;它绝不能 panic。
求值遵循 RFC 9535(比较按 §2.3.5.2.2 进行),并且:
- 一个方括号中的各选择器按顺序应用,保留重复结果;
- 对象成员按文档顺序(原始 JSON)、键的升序(Go map)或字段顺序(结构体)访问;一个名称选中所有具有该名称的成员;
- 后代段先作用于节点本身,再按先序作用于其后代(
$..b作用于{"x":{"b":1},"b":2}得到 2, 1); - 不适用的选择器(成员不存在、索引越界、类型不符、标量、null)不选中任何值,也不报错。
Extract 按顺序返回所选的值,每个值为其在输入中的文本去掉首尾空白(无匹配时返回零个结果);输入中任何位置无效时,无论路径如何,都返回 *SyntaxError;$ 原样返回输入。Unmarshal 按顺序解码同样的值,与现在一致。Get(src, dst) 遍历以字符串为键的 map、结构体(encoding/json 会编码的字段,按 JSON 键访问,忽略 omitempty)、切片和数组,并穿过指针和接口;nil 值视为 null,且绝不 panic。对于单值路径(每段只含一个名称或索引,且没有后代段),它存入该值,无匹配时返回错误;否则存入列表(*[]T 与现在一致,*any 存入一个可能为空的 []any);$ 存入 src。
Path 绝不能改变,即使调用失败也是如此,并且必须能安全地在 goroutine 之间共享;PathString 原样返回传给 CreatePath 的参数。RootSelectorOnly 仍然只对 $ 为真,UsedSingleQuotePathSelector / UsedDoubleQuotePathSelector 仍报告带引号名称所使用的引号。
重要:请从 main 新建一个分支进行这项工作,完成后提交全部改动。
隐藏测试检查什么
- CreatePath 接受所有列出的段与选择器形式,其余一律返回 *PathError,面对刁钻或深度嵌套的路径也不 panic。
- 每种选择器和过滤特性(比较、数字、字符串、结构相等、存在性、逻辑运算)都同时经由 Extract、Unmarshal 和 Get 检验。
- Extract 返回去掉首尾空白的原文,成员按文档顺序访问,同名成员全部选中;“$”原样返回输入,输入非法时返回 *SyntaxError。
- Get 按 JSON 键遍历结构体、按键序遍历 map,并处理切片、数组、指针和接口;单值路径存值,其余存列表;不会 panic。
- Path 在调用失败后保持不变,并在竞态检测器下跨 goroutine 共享;还检查 PathString、RootSelectorOnly 和引号标志。
- 失败→通过测试
- 1,464
- 回归测试
- 22,002
- 构建/类型检查
- 1
- 参考解改动文件
- 30
- 参考解新增行
- +2,221
轨迹关键时刻
第 3、7–8 次调用 阅读基于节点的旧 internal/decoder/path.go 和各解码器的 DecodePath,发现求值会写入 ctx.Option.Path.node。共享的 Path 因此在调用中途被改动,失败后也保持改动状态。
第 24–27、34 次调用 确认全量测试全部通过并记下这一基线,从 main 切出 rfc9535-jsonpath-selectors 分支,用复现脚本证实 Extract 对 $..b 只返回一个值、PathString 缺了开头的 $,随后记录 UnmarshalPath 基准的基线。
第 45、48、50–51、54–55、58、60 次调用 用新解析器、精确十进制数比较和一个通用求值器(原始 JSON 与 Go 值两个后端)替换旧的节点实现,随后从所有解码器中移除会改写路径的 DecodePath。
第 69、72、74、76 次调用 Agent 自写测试,其中一项在键已排好序的文档上比较 Extract、Unmarshal 与 Get 的结果;随后打印计数,确认 164 个用例中有 114 个真正参与了比较,这项测试不是空转。
第 87–88、90–91、93–94、119 次调用 一万层嵌套的探测暴露出二次复杂度的查找($..a 约 919 ms)。围绕容器索引重建 path_json.go 后降为线性(约 3 ms),后续调优使所有探测基准都快于 main。
第 99 次调用 对新的 index 扫描做变异检验:故意破坏其转义进位和字符串内状态进位,测试在两个变异上都失败,恢复文件后重新通过。
第 102–104、121、125–127、129–130 次调用 全量测试、竞态检测与 GOGC=1 下的路径测试、32 位运行、golangci-lint 和两个模糊测试全部通过后,Agent 把 35 个文件作为一次提交落到分支上,并确认工作区干净。
Agent 提交了什么
- 模型调用
- 131
- bash 命令
- 146
- 用时(分钟)
- 120
- 改动文件
- 35
- 增 / 删行数
- +4,085 / −1,386
Agent 用一个 RFC 9535 解析器和一套求值器替换了旧的基于节点的路径代码,该求值器同时服务于原始 JSON(Extract、Unmarshal)和 Go 值(Get),并以一次涉及 35 个文件的提交落在新分支上。
Agent 提交的改动(节选)
diff --git a/internal/decoder/path_parse.go b/internal/decoder/path_parse.go
new file mode 100644
--- /dev/null
+++ b/internal/decoder/path_parse.go
@@ -0,0 +1,787 @@
…
+// filterOr parses the expression of a filter at pos: the expressions which || joins, which binds the least.
+func (p *pathParser) filterOr() (pathFilter, error) {
+ return p.filterTerms(pathFilterOr, "||", (*pathParser).filterAnd)
+}
+
+// filterAnd parses the expressions which && joins.
+func (p *pathParser) filterAnd() (pathFilter, error) {
+ return p.filterTerms(pathFilterAnd, "&&", (*pathParser).filterBasic)
+}
+
+// filterTerms parses the expressions which the operator joins, each by term: one of them is the expression itself.
+// The white spaces after the last one are skipped.
+func (p *pathParser) filterTerms(kind pathFilterKind, operator string, term func(*pathParser) (pathFilter, error)) (pathFilter, error) {
+ first, err := term(p)
+ if err != nil {
+ return first, err
+ }
+ var terms []pathFilter
+ for {
+ p.skipSpaces()
+ if !strings.HasPrefix(p.text[p.pos:], operator) {
+ break
+ }
+ p.pos += len(operator)
+ p.skipSpaces()
+ next, err := term(p)
+ if err != nil {
+ return next, err
+ }
+ if terms == nil {
+ terms = []pathFilter{first}
+ }
+ terms = append(terms, next)
+ }
+ if terms == nil {
+ return first, nil
+ }
+ return pathFilter{kind: kind, terms: terms}, nil
+}
+
+// filterBasic parses what && and || join: an expression in parentheses or a query, which ! may negate, or a
+// comparison. As in RFC 9535, a comparison is negated in parentheses, and a literal is compared.
+func (p *pathParser) filterBasic() (pathFilter, error) {
+ negated := p.peek() == '!'
+ if negated {
+ p.pos++
+ p.skipSpaces()
+ }
+ var filter pathFilter
+ var err error
+ switch c := p.peek(); {
+ case c == '(':
+ if p.nesting == maxPathNesting {
+ return filter, p.errorf("the parentheses of a filter nest too deep")
+ }
+ p.nesting++
+ p.pos++
+ p.skipSpaces()
+ if filter, err = p.filterOr(); err != nil {
+ return filter, err
+ }
+ if p.peek() != ')' {
+ return filter, p.unexpected("a right parenthesis")
+ }
+ p.pos++
+ p.nesting--
+ case negated && c != '@':
+ return filter, p.unexpected("a left parenthesis or a query after !")
+ default:
+ if filter.left, err = p.operand(); err != nil {
+ return filter, err
+ }
+ var compared bool
+ if !negated {
+ filter.op, compared = p.compareOp()
+ }
+ switch {
+ case compared:
+ filter.kind = pathFilterCompare
+ p.skipSpaces()
+ if filter.right, err = p.operand(); err != nil {
+ return filter, err
+ }
+ case filter.left.query:
+ filter.kind = pathFilterExists
+ default:
+ return filter, p.unexpected("a comparison operator after a literal")
+ }
+ }
+ if negated {
+ filter = pathFilter{kind: pathFilterNot, terms: []pathFilter{filter}}
+ }
+ return filter, nil
+}
…
+// operand parses what a filter compares at pos: a literal or a query.
+func (p *pathParser) operand() (pathOperand, error) {
+ rest := p.text[p.pos:]
+ switch c := p.peek(); {
+ case c == '@':
+ steps, err := p.query()
+ return pathOperand{query: true, steps: steps}, err
+ case c == '\'' || c == '"':
+ str, err := p.quoted()
+ return pathOperand{kind: pathKindString, str: str}, err
+ case c == '-' || ('0' <= c && c <= '9'):
+ return p.number()
+ case strings.HasPrefix(rest, "true"):
+ p.pos += len("true")
+ return pathOperand{kind: pathKindTrue}, nil
+ case strings.HasPrefix(rest, "false"):
+ p.pos += len("false")
+ return pathOperand{kind: pathKindFalse}, nil
+ case strings.HasPrefix(rest, "null"):
+ p.pos += len("null")
+ return pathOperand{kind: pathKindNull}, nil
+ case c == '$':
+ return pathOperand{}, p.errorf("a query from the root identifier $ is not supported in a filter")
+ }
+ // the name of a function of RFC 9535, which its arguments follow in parentheses
+ name := strings.TrimLeft(rest, "_abcdefghijklmnopqrstuvwxyz0123456789")
+ if len(name) < len(rest) && strings.HasPrefix(name, "(") {
+ return pathOperand{}, p.errorf("the function %s is not supported in a filter", rest[:len(rest)-len(name)])
+ }
+ return pathOperand{}, p.unexpected("a literal or a query")
+}
…… 其余 5,878 行从略(共改动 35 个文件)
可定制
包括但不限于以下几项,其他需求都可以商量。
- 语言与代码库:编程语言和技术栈(也可扩展到 Java、C++ 等)、代码库类型和规模、改动规模。
- 任务配比与需求写法:新功能、功能增强、行为变更、修复、重构;需求的长短和语气,以及写明多少接口细节。
- 难度:对标的模型和要达到的通过率区间。
- 验证强度:隐藏测试数量、回归测试范围、构建/类型检查门禁。
- 轨迹:Agent 框架(DeepSWE 主榜同款 bash 框架、Claude Code 等)、模型和推理强度、每题的轨迹条数。
- 格式、数量和排期:Harbor / Pier 任务目录,ATIF 或请求级轨迹;交付数量和时间安排。