概览
每条数据是一道代码改动任务,改的是真实、成熟的软件项目,按“维护者会不会合并这份改动”评分:需求以维护者的口吻写成,后附仓库自己的测试、lint 和代码风格规范;带权重的评分细则从行为正确、已有功能、测试、改动范围和代码质量几方面检查,任一阻断项不过即记 0 分。每条数据包含两部分:一是可复跑的任务环境(离线开发环境镜像的构建文件、需求、评分细则及其隐藏测试、参考解,以及带评分结果的其他正确解法、取巧解和部分完成解),可直接用于强化学习;二是 Claude Opus 5.5 以 max effort 在 Claude Code 中完成该任务的轨迹,逐请求完整记录,附逐条细则的评分,可用于监督微调。
交付内容
<任务>/
├── item.json # 任务与轨迹摘要
├── task/
│ ├── prompt.md # 需求和仓库规范:Agent 收到的唯一输入
│ ├── task.json # 元数据:项目、语言、题型、考察点
│ ├── rubric.json # 评分细则:类别、检查方式、是否阻断、权重
│ ├── hidden_tests/ # 隐藏测试,评分时注入
│ ├── solutions/ # 参考解、其他正确解法、取巧解、部分完成解,及各自的期望判定
│ ├── validation.json # 评分器对每份随附解法和未改动代码的判定
│ └── env/ # 开发环境镜像构建文件、基线提交的仓库、命令清单
└── trajectories/attempt_<k>/
├── traj/ # requests.jsonl.zst:请求级记录,每次请求的完整上下文与回复,含思考摘要
├── steps.jsonl # 逐回合视图:思考摘要、说明、工具调用及结果
├── final.patch # Agent 的最终改动
├── grade.json # 每条细则的结果,评审模型判定的细则附理由
└── result.json # 回合、工具调用、用时、Agent 的最终回复
harness/ # 参考 harness:构建环境、验证题目、重评作答、运行你自己的 Agent
index.json # 任务与轨迹总览
SHA256SUMS # 全部文件的校验和
README.md # 格式、构建和评分说明
- 评分细则:每题 12 到 16 条,分六类:行为正确、不破坏已有行为、构建/lint/格式、测试质量、改动范围、代码质量与规范。每条由隐藏测试、项目命令、对 Agent 自写测试的“改前失败、改后通过”检查、改动范围检查或评审模型之一判定;权重为相对值(卡片中折算为百分比)。阻断项全部通过才计分,否则记 0 分。
- 随附解法:除参考解外,每题还附其他正确解法(须通过)、取巧解(钻空子、偷工减料或违反仓库规范,须被阻断项拦下)和从刚起步到接近完成的部分完成解,均带评分器的判定。
- 隔离:Agent 断网工作,看不到评分细则、隐藏测试和随附解法。
样例
7 道样题,每种语言一道、项目各不相同:5 道新功能、1 道缺陷修复、1 道跨模块一致性改动,参考解改动 195–387 行。从随附解法的判定可以看出评分器有多严:取巧解不计阻断项的加权得分最高达 95,但因阻断项不过,得分仍为 0。
| 样题 | 语言 | 项目 | 题型 | 参考解改动 | 细则(阻断项) |
|---|---|---|---|---|---|
| 1 | TypeScript | hono | 新功能 | 6 个文件,307 行 | 14(7) |
| 2 | JavaScript | webpack | 新功能 | 29 个文件,349 行 | 15(5) |
| 3 | Python | pydantic | 缺陷修复 | 6 个文件,296 行 | 12(4) |
| 4 | C++ | spdlog | 新功能 | 8 个文件,195 行 | 12(7) |
| 5 | Go | fiber | 新功能 | 6 个文件,387 行 | 16(8) |
| 6 | Rust | just | 新功能 | 18 个文件,294 行 | 16(4) |
| 7 | PHP | composer | 跨模块一致性改动 | 10 个文件,266 行 | 15(5) |
每张卡片给出 Agent 收到的题面原文、评分细则、评分器对随附解法的判定,以及 Claude Opus 5.5 的两条轨迹和关键时刻;每条轨迹下折叠着其最终改动的节选。
样例 1 / 7
构建期预编译 RegExpRouter
构建期把路由路径编译成可序列化参数,由 PreparedRegExpRouter 运行时加载。运行时不编译,结果须与 RegExpRouter 一致(一种跨方法情形除外)。
题面(Agent 收到的原文)
Task description
Apps whose routes are known ahead of time pay for RegExpRouter's route compilation at every cold start; move it to build time.
Export from hono/router/reg-exp-router (src/router/reg-exp-router/index.ts):
buildInitParams({ paths }: { paths: string[] }): compiles the app's route paths (any order, repeats allowed) into the constructor arguments ofPreparedRegExpRouter; paths that cannot share one RegExpRouter matcher may throwUnsupportedPathError.serializeInitParams(params): string: a self-contained JavaScript expression evaluating to equivalent params, for generated code likenew PreparedRegExpRouter(...<serialized>).PreparedRegExpRouter<T>: aRouter<T>created withnew PreparedRegExpRouter(...params)(params serve one router), thenadd(method, path, handler)as usual; adding a path not given tobuildInitParams(catch-alls included) throws anError.
At runtime (creating the router, add(), match()) nothing is compiled: no RegExpRouter add() processing, no trie or RegExp construction. match() returns lists prepared beforehand (one array per route, as in RegExpRouter), never filtered or copied per request, with the handlers, order and params RegExpRouter gives for the same routes, in its [[handler, paramIndexMap][], paramStash] form. Exception: a request whose best-matching prepared path was added only for other methods may get just its method's and ALL's wildcard routes covering that path.
题面后半:测试、lint 和代码风格规范
Test guidelines
Tests are Vitest files next to the code (src/**/*.test.ts, globals enabled, picked up automatically). Every router is checked against the shared conformance suite runTest in src/router/common.case.test.ts: each router's router.test.ts runs it (with a skip list for the cases that router cannot support) and adds router-specific cases where needed. New behaviour must be covered by tests in this style.
Run the router tests (unset the proxy variables, as the test scripts do):
env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY ./node_modules/.bin/vitest --run --coverage.enabled=false src/router
Before finishing, run the whole suite, which also type-checks the project (many tests are type-level):
env -u http_proxy -u https_proxy -u HTTP_PROXY -u HTTPS_PROXY bun run test
Lint guidelines
CI runs these and they must pass:
bun run lint(ESLint over src, build, benchmarks, ...; the 40 existing warnings elsewhere are known, the code you add must not add errors or warnings)bun run editorconfig-checkerbun run format(Prettier check;bun run format:fixrewrites the files)./node_modules/.bin/tsc --noEmit(also the first step ofbun run test)
Style guidelines
Follow the existing router code in src/router/: TypeScript with import type for type-only imports, #private fields for internal state, the shared router types and constants from src/router.ts, small focused modules. Keep the change inside the RegExpRouter module (src/router/reg-exp-router/); package.json and jsr.json stay unchanged. Keep RegExpRouter's behaviour and public API as they are (no new public or protected members, add()/match() called as before); its internals may be refactored so that the new router reuses them instead of duplicating them, and its compiler modules (trie.ts, node.ts) need no changes. No debug output or commented-out code.
You are already on the correct base commit. Create your branch from this commit. Do not rebase or start from master, main, or any other branch.
评分细则
| # | 细则 | 类别 | 检查方式 | 阻断项 | 权重 |
|---|---|---|---|---|---|
| C1 | 序列化参数下的共享套件、添加未知路径时报错、类型检查 | 行为正确 | 隐藏测试 | 是 | 18% |
| C2 | 可替换原路由器,结果与格式相同(题面所述情形除外) | 行为正确 | 隐藏测试 | 是 | 14% |
| C3 | 运行时无原路由器注册、trie、正则构造及逐请求复制 | 行为正确 | 隐藏测试 | 是 | 12% |
| C4 | 全量测试与类型检查(bun run test) |
不破坏已有行为 | 项目命令 | 是 | 10% |
| C5 | 只改本模块,RegExpRouter 接口不变 |
改动范围 | 改动范围检查 | 是 | 8% |
| C6 | 预编译的匹配器由现有编译器生成,而非复制或重写 | 代码质量与规范 | 评审判定 | 是 | 10% |
| C7 | 所附测试在改动前失败、改动后通过 | 测试质量 | 改前失败、改后通过 | — | 5% |
| C8 | 两个路由器共用请求时的查找逻辑,不重复实现 | 代码质量与规范 | 评审判定 | — | 3% |
| C9 | 新路由器测试超出冒烟级别,序列化结果被求值或整串比对 | 测试质量 | 评审判定 | 是 | 8% |
| C10 | ESLint、editorconfig 均无新问题 | 构建/lint/格式 | 项目命令 | — | 3% |
| C11 | Prettier 格式检查 | 构建/lint/格式 | 项目命令 | — | 2% |
| C12 | 无调试输出、注释掉的代码或叙述改动过程的注释 | 构建/lint/格式 | 评审判定 | — | 2% |
| C13 | 改动规模与需求相称 | 改动范围 | 改动范围检查 | — | 2% |
| C14 | add() 也拒绝未提供的 * 和 /* |
行为正确 | 隐藏测试 | — | 3% |
评分器对随附解法的判定
| 解法 | 份数 | 得分 | 不计阻断项的加权得分 |
|---|---|---|---|
| 参考解 | 1 | 100 | 100 |
| 其他正确解法 | 3 | 100 | 100 |
| 接近完成解 | 1 | 97 | 97 |
| 部分完成解 | 3 | 0 | 27–71 |
| 取巧解 | 9 | 0 | 57–92 |
| 未改动的代码 | 1 | 0 | 17 |
满分 100。任一阻断项不过,得分即为 0。
轨迹 1
- 回合
- 53
- 工具调用
- 63
- 用时(分钟)
- 47
- 改动文件
- 5
- 增 / 删行数
- +617 / −44
从 RegExpRouter 抽出内部类 MatcherBuilder,构建期一次编译全部路径并记下各路径处理函数所属的预备列表,运行时 add() 按记录放入处理函数。以随机差分对比和七处注入缺陷验证,新测试用原始与序列化参数各跑一遍共享套件。
第 7 回合 读完 RegExpRouter 源码与共享套件,计划复用其代码,把全部路径登记在同一方法下。
第 20 回合 把路由登记和匹配器构建代码基本原样移入内部类 MatcherBuilder,供构建期复用。
第 28 回合 与 RegExpRouter 做随机路由对比:共 5 轮,每轮 36 万次比较,结果无差异。
第 31 回合 发现中段 * 与同位命名参数共用匹配器时可能丢参,现对会丢参的路径在构建期报错。
第 33 回合 用 6 万组随机路径集核对:构建期报错与 RegExpRouter 报错或丢失参数完全对应。
第 41 回合 确认注入 prepared-router.ts 的七处缺陷(如方法间路由串扰)均被测试发现。
第 46 回合 用 git stash 撤下改动后重跑全套测试,通过用例正好少了新测试文件的 194 个。
轨迹 1 的最终改动(节选)
diff --git a/src/router/reg-exp-router/prepared-router.ts b/src/router/reg-exp-router/prepared-router.ts
new file mode 100644
--- /dev/null
+++ b/src/router/reg-exp-router/prepared-router.ts
@@ -0,0 +1,167 @@
+import type { ParamIndexMap, Result, Router } from '../../router'
+import {
+ MESSAGE_MATCHER_IS_ALREADY_BUILT,
+ METHOD_NAME_ALL,
+ UnsupportedPathError,
+} from '../../router'
+import type { HandlerData, Matcher, StaticMap } from './matcher'
+import { buildMatch, emptyParam } from './matcher'
+import { MatcherBuilder } from './router'
+
+/**
+ * path => [indexes of the lists that the handlers of the path go into, paramIndexMap of the handlers][]
+ *
+ * The lists matched by the RegExp of the matcher are indexed by number, the static ones by path.
+ */
+type RelocateMap = Record<string, [(number | string)[], ParamIndexMap][]>
+
+/**
+ * RegExpRouter whose routes are compiled at build time by `buildInitParams()`.
+ *
+ * @example
+ * ```ts
+ * // at build time
+ * const params = buildInitParams({ paths: app.routes.map(({ path }) => path) })
+ * const code = `const router = new PreparedRegExpRouter(...${serializeInitParams(params)})`
+ *
+ * // at runtime, with the generated `router`
+ * const app = new Hono({ router })
+ * ```
+ */
+export class PreparedRegExpRouter<T> implements Router<T> {
+ name: string = 'PreparedRegExpRouter'
+ #matchers: Record<string, Matcher<T>> = Object.create(null)
+ #relocateMap?: RelocateMap
+
+ constructor(matcher: Matcher<never>, relocateMap: RelocateMap) {
+ this.#matchers[METHOD_NAME_ALL] = matcher
+ this.#relocateMap = relocateMap
+ }
+
+ add(method: string, path: string, handler: T) {
+ if (!this.#relocateMap) {
+ throw new Error(MESSAGE_MATCHER_IS_ALREADY_BUILT)
+ }
+
+ const relocations = this.#relocateMap[path]
+ if (!relocations) {
+ throw new Error(
+ `Can not add a route since the path "${path}" was not given to buildInitParams.`
+ )
+ }
+
+ if (!this.#matchers[method]) {
+ // as in RegExpRouter, the lists of a method start with the routes of ALL
+ const [regexp, handlerData, staticMap] = this.#matchers[METHOD_NAME_ALL]
+ const methodStaticMap: StaticMap<T> = Object.create(null)
+ for (const staticPath in staticMap) {
+ methodStaticMap[staticPath] = [staticMap[staticPath][0].slice(), emptyParam]
+ }
+ this.#matchers[method] = [regexp, handlerData.map((list) => list.slice()), methodStaticMap]
+ }
+
+ const matchers =
+ method === METHOD_NAME_ALL ? Object.values(this.#matchers) : [this.#matchers[method]]
+ for (const [indexes, paramIndexMap] of relocations) {
+ const data: [T, ParamIndexMap] = [handler, paramIndexMap]
+ for (const matcher of matchers) {
+ for (const index of indexes) {
+ ;(typeof index === 'number' ? matcher[1][index] : matcher[2][index][0]).push(data)
+ }
+ }
+ }
+ }
+
+ match(method: string, path: string): Result<T> {
+ // Release the relocate map, which is used only by add()
+ this.#relocateMap = undefined
+
+ this.match = buildMatch(this.#matchers)
+ return this.match(method, path)
+ }
+}
+
+/**
+ * Compiles the paths of all the routes of an app into the arguments of `new PreparedRegExpRouter()`.
+ *
+ * @param params.paths - The paths, in any order and with repeats allowed.
+ * @returns The arguments, which serve one router.
+ * @throws {UnsupportedPathError} If the paths cannot share one matcher of RegExpRouter.
+ */
+export const buildInitParams = ({
+ paths,
+}: {
+ paths: string[]
+}): ConstructorParameters<typeof PreparedRegExpRouter> => {
+ const uniquePaths = [...new Set(paths)]
+
+ // compile the paths as the routes of one method, with their indexes as the handlers
+ const builder = new MatcherBuilder<number>()
+ const relocateMap: RelocateMap = Object.create(null)
+ uniquePaths.forEach((path, i) => {
+ builder.add(METHOD_NAME_ALL, path, i)
+ relocateMap[path] = []
+ })
+ const [regexp, handlerData, staticMap] = builder.buildAllMatchers()[
+ METHOD_NAME_ALL
+ ] as Matcher<number>
+
+ // record the lists that the handlers of each path go into, and leave them empty for the router
+ const relocate = (list: HandlerData<number>, index: number | string): HandlerData<never> => {
+ list.forEach(([i, paramIndexMap]) => {
+ if (Object.values(paramIndexMap).some((paramIndex) => paramIndex === undefined)) {
+ // e.g. `:id` of `/posts/:id/edit` is not captured when `/posts/*/new` comes first
+ throw new UnsupportedPathError(uniquePaths[i])
+ }
+
+ const relocations = relocateMap[uniquePaths[i]]
+ const key = JSON.stringify(paramIndexMap)
+ const relocation = relocations.find(([, map]) => JSON.stringify(map) === key)
+ if (relocation) {
+ relocation[0].push(index)
+ } else {
+ relocations.push([[index], paramIndexMap])
+ }
+ })
…… 其余 610 行从略(共改动 5 个文件)
轨迹 2
- 回合
- 80
- 工具调用
- 90
- 用时(分钟)
- 51
- 改动文件
- 5
- 增 / 删行数
- +693 / −120
把编译与查找代码从 router.ts 移入共享的 matcher.ts;构建期以路径作处理函数,按原路由器的登记方式建表后一次编译,并记下各路径应进入的预备列表。以差分模糊测试(含跨方法例外)、注入缺陷和生成代码的端到端运行验证,最后本地提交。
第 13 回合 先查 request.ts 如何读取参数,因为序列化后的参数可能丢掉空原型。
第 19 回合 把编译与查找代码移入 matcher.ts,router.ts 此外只改了导入和一行。
第 26 回合 约百万次随机比较均与原路由器一致,但从未触发题面所述的例外,遂补充一批贪婪 {.+} 路径。
第 32 回合 注入三处缺陷:运行时构造正则、处理函数前插而非追加、按请求复制列表,均被对应测试发现。
第 70 回合 生成内嵌序列化参数的模块,在独立进程中导入并经 Hono 应用处理请求。
第 77 回合 测 402 条路由启动开销:原路由器 1.503 ms,预编译版(含参数求值)0.166 ms。
第 79 回合 重跑题面列出的 CI 检查与全量测试后,在新分支上做了一次本地提交。
轨迹 2 的最终改动(节选)
diff --git a/src/router/reg-exp-router/prepared-router.ts b/src/router/reg-exp-router/prepared-router.ts
new file mode 100644
--- /dev/null
+++ b/src/router/reg-exp-router/prepared-router.ts
@@ -0,0 +1,182 @@
+import type { ParamIndexMap, Result, Router } from '../../router'
+import { METHOD_NAME_ALL } from '../../router'
+import { checkOptionalParameter } from '../../utils/url'
+import type { HandlerData, HandlerWithMetadata, Matcher, MatcherMap, StaticMap } from './matcher'
+import {
+ buildMatch,
+ buildMatcherFromPreprocessedRoutes,
+ buildWildcardRegExp,
+ clearWildcardRegExpCache,
+ emptyParam,
+} from './matcher'
+
+type Target = number | string // index of a handler list in Matcher[1], or a key of Matcher[2]
+// path -> the handler lists that handlers for the path are added to, with their ParamIndexMap
+type RelocateMap = Record<string, [Target[], ParamIndexMap][]>
+
+function cloneMatcher<T>([regexp, handlerMap, staticMap]: Matcher<T>): Matcher<T> {
+ const clonedStaticMap: StaticMap<T> = Object.create(null)
+ for (const path in staticMap) {
+ clonedStaticMap[path] = [[...staticMap[path][0]], emptyParam]
+ }
+ return [regexp, handlerMap.map((handlerData) => [...handlerData]), clonedStaticMap]
+}
+
+/**
+ * A router that matches like `RegExpRouter`, with its matcher compiled ahead of time by
+ * `buildInitParams()`, so that creating it, adding routes and matching compile nothing.
+ *
+ * All methods share the prepared matcher: a request whose best-matching path was added only for
+ * other methods gets just the wildcard routes of its method and `ALL` covering that path.
+ *
+ * @example
+ * ```ts
+ * const createApp = (router: Hono['router']) => new Hono({ router }).get('/posts/:id', handler)
+ *
+ * // At build time, compile the paths of the application
+ * const paths = createApp(new RegExpRouter()).routes.map(({ path }) => path)
+ * const serialized = serializeInitParams(buildInitParams({ paths }))
+ *
+ * // At runtime, in the code generated with `serialized`
+ * const app = createApp(new PreparedRegExpRouter(...<serialized>))
+ * ```
+ */
+export class PreparedRegExpRouter<T> implements Router<T> {
+ name: string = 'PreparedRegExpRouter'
+ #matchers: MatcherMap<T> = Object.create(null)
+ #relocateMap: RelocateMap
+
+ constructor(matcher: Matcher<never>, relocateMap: RelocateMap) {
+ this.#matchers[METHOD_NAME_ALL] = matcher
+ this.#relocateMap = relocateMap
+ }
+
+ add(method: string, path: string, handler: T) {
+ const relocation = this.#relocateMap[path]
+ if (!relocation) {
+ throw new Error(`Can not add a route since the path is not prepared: ${path}`)
+ }
+
+ const matchers = this.#matchers
+ // like RegExpRouter, a method starts with the handlers added for ALL so far
+ matchers[method] ||= cloneMatcher(matchers[METHOD_NAME_ALL] as Matcher<T>)
+
+ for (const m of method === METHOD_NAME_ALL ? Object.keys(matchers) : [method]) {
+ const [, handlerMap, staticMap] = matchers[m] as Matcher<T>
+ for (const [targets, paramIndexMap] of relocation) {
+ for (const target of targets) {
+ const handlerData = typeof target === 'number' ? handlerMap[target] : staticMap[target][0]
+ handlerData.push([handler, paramIndexMap])
+ }
+ }
+ }
+ }
+
+ match(method: string, path: string): Result<T> {
+ this.match = buildMatch(this.#matchers)
+
+ return this.match(method, path)
+ }
+}
+
+/**
+ * Compiles the paths of the routes of an application into the parameters of `PreparedRegExpRouter`.
+ *
+ * @param {object} options - The options.
+ * @param {string[]} options.paths - The paths the routes will be added with, in any order. Repeats are allowed.
+ * @returns {ConstructorParameters<typeof PreparedRegExpRouter>} The parameters for `new PreparedRegExpRouter(...params)`, serving one router.
+ * @throws {UnsupportedPathError} If the paths can not share one matcher of `RegExpRouter`.
+ */
+export function buildInitParams({
+ paths,
+}: {
+ paths: string[]
+}): ConstructorParameters<typeof PreparedRegExpRouter> {
+ // Build the handler lists as RegExpRouter does for routes added for ALL, with paths as handlers
+ const routes: Record<string, HandlerWithMetadata<string>[]> = Object.create(null)
+ const wildcards: [string, RegExp, number][] = []
+ for (const path of new Set(paths)) {
+ const routePath = path === '/*' ? '*' : path
+ const paramCount = (routePath.match(/\/:/g) || []).length
+ if (/\*$/.test(routePath)) {
+ routes[routePath] ||= []
+ wildcards.push([path, buildWildcardRegExp(routePath), paramCount])
+ } else {
+ const routePaths = checkOptionalParameter(routePath) || [routePath]
+ routePaths.forEach((p, i) => {
+ ;(routes[p] ||= []).push([path, paramCount - routePaths.length + i + 1])
+ })
+ }
+ }
+ for (const [path, re, paramCount] of wildcards) {
+ for (const p in routes) {
+ if (re.test(p)) {
+ routes[p].push([path, paramCount])
+ }
+ }
+ }
+ clearWildcardRegExpCache()
+
+ const [regexp, handlerMap, staticMap] = buildMatcherFromPreprocessedRoutes(Object.entries(routes))
+
+ // Record the handler lists each path is added to, and leave the lists empty for the router.
+ // Lists sharing the same ParamIndexMap are grouped to keep the serialized params small.
+ const relocateMap: RelocateMap = Object.create(null)
+ for (const path of paths) {
…… 其余 720 行从略(共改动 5 个文件)
样例 2 / 7
将无 DOM 时收集的 CSS 暴露为 __webpack_css_server_styles__
将通用(universal)目标 ["web", "node"] 的包在没有 DOM 时收集的 CSS 暴露为只读 string 变量 __webpack_css_server_styles__,并在 module.d.ts 中声明;每次读取返回注册表当时的 CSS,未收集到样式时为 ""。改动涉及所有通用 CSS 构建共享的运行时代码。
题面(Agent 收到的原文)
Task description
When a universal bundle (target: ["web", "node"] with experiments.css) runs without a DOM, the CSS runtime keeps the styles it would have put into the document in a registry on the global object, under an internal key derived from output.uniqueName. Today the only way to get them back out (to inline them into server-rendered HTML, say) is to scan the global for that key, which is what our own universal CSS tests do.
Expose that registry to application code as a module variable, __webpack_css_server_styles__, which evaluates to all the CSS in the current build's registry as a single string.
- It is a read-only webpack API variable of type
string, like__webpack_hash__, usable from any module, and declared for TypeScript users inmodule.d.ts. - Every read reflects the registry at that moment: styles of
exportType: "style"modules, CSS chunks the runtime has loaded since, and hot updates that replaced or removed a stylesheet. - The CSS comes out in the order the styles were applied, so the cascade survives. This has to hold for every kind of module id, including the numeric ids production builds use.
- When nothing was collected (the styles went into a document, the bundle has no CSS, the target is not a universal one) it is
"". A bundle that needs nothing else from the runtime must still work. - Don't assume the host has
globalThisunlessoutput.environment.globalThissays so: this path has to run on the oldest Node.js we support. That goes for the existing code that fills the registry too.
题面后半:测试、lint 和代码风格规范
Test guidelines
- Tests run on jest through the
test:basewrapper (yarn test:base ...); never calljestdirectly, and don't run plainyarn test(it lints and then runs every suite). - Behaviour is tested with fixture cases that are picked up automatically, no registration needed.
test/configCases/<category>/<case>/holds anindex.jswhoseit()blocks run inside the emitted bundle, plus awebpack.config.js; CSS cases live intest/configCases/css/. HMR cases live intest/hotCases/<category>/<case>/(universal-target ones intest/hotCases/universal/).TESTING_DOCS.mdand neighbouring cases such astest/configCases/css/export-type-style-universalshow the layout. - The config-case harness executes a
["web", "node"]bundle without a DOM. Only when ES module output is enabled (experiments.outputModule+output.module) does it run the bundle twice, once with a fake DOM and once without; without it, code in atypeof document !== "undefined"branch never runs. Universal HMR cases always run both ways. - Hot updates of
type: "css"modules withexportType: "style"already fail at this commit (__webpack_require__ is not definedin the hot-update chunk);type: "css/module"withexportType: "style"works, as intest/hotCases/universal/css-export-type-style. Fixing that bug is optional. - Run one case with
yarn test:base --ci test/ConfigTestCases.basictest.js -t "css <case>"(the same case also runs intest/ConfigCacheTestCases.basictest.js), an HMR case withyarn test:base --ci test/HotTestCasesUniversal.test.js -t "universal <case>".webpack-test-files <test paths...>(onPATHin this environment) runs every suite that picks up the given files. - New behaviour needs tests that fail without your change and pass with it. Prefer fixture cases over unit tests, and cover each branch you add, the ordering guarantee and the empty result included.
- Before you finish,
yarn test:unit --ciandyarn test:basic --ci(about three minutes together) andyarn test:base --ci test/HotTestCasesUniversal.test.js test/HotTestCasesWeb.test.jsmust be green.
Lint guidelines
yarn lintmust pass: it is what CI's lint job runs (about a minute). It consists ofyarn lint:code(ESLint),yarn lint:special(generated files such astypes.d.tsare up to date), thetscprojects (yarn lint:typesover the JSDoc inlib/,yarn lint:types-test,yarn lint:types-module-testwhich checksmodule.d.tsagainsttest/typesCases/, ...),yarn fmt:check(Prettier) andyarn lint:spellcheck(cspell).- Regenerate generated files with
yarn fix:special; never edit them by hand.yarn fixrunsfix:code,fix:specialandfmt(Prettier, writing) in one go. - A user-facing change needs a changeset in
.changeset/(format inAGENTS.md, "Adding a Changeset");yarn validate:changesetchecks it.
Style guidelines
- Follow
AGENTS.mdfor code conventions, comments, changesets and generated files (its git and pull-request sections do not apply to this task). lib/is CommonJS with JSDoc types and has to run on Node.js >= 10.13 (no?.or??). New files underlib/start with the MIT license header.- Runtime code emitted into bundles must honour
output.environment: build it with theruntimeTemplatehelpers (basicFunction,returningFunction,renderConst, ...) instead of hard-coding newer syntax. - Wire the variable the way the existing module variables and runtime globals are wired (
lib/APIPlugin.js,lib/RuntimeGlobals.js,RuntimeModulesubclasses), and reuse existing helpers instead of duplicating their logic. - Comments: one line, two at most, and only when they say something the code does not.
- Keep the change focused on this feature and leave the shared test harness alone. Don't loosen or delete existing tests; the universal CSS tests that read the registry directly may switch to the new variable.
You are already on the correct base commit. Create your branch from this commit. Do not rebase or start from master, main, or any other branch.
评分细则
| # | 细则 | 类别 | 检查方式 | 阻断项 | 权重 |
|---|---|---|---|---|---|
| C1 | 无 DOM 时返回样式模块、后加载的 chunk 及热更新后的 CSS,有 DOM 时为空 | 行为正确 | 隐藏测试 | 是 | 24% |
| C2 | CSS 按样式应用的顺序返回,使用数字模块 id 时也是如此 | 行为正确 | 隐藏测试 | 是 | 10% |
| C3 | 无 CSS 的包、仅 web 或仅 node 目标、懒加载 chunk 中读取均为 "" 且不崩溃 |
行为正确 | 隐藏测试 | 是 | 7% |
| C4 | 未启用 output.environment.globalThis 时,样式注入、分块加载与读取都不直接引用 globalThis |
行为正确 | 隐藏测试 | — | 5% |
| C5 | ES5 目标下,使用该变量的包只含 ES5 语法 | 行为正确 | 隐藏测试 | — | 2% |
| C6 | 在原有测试下,单元、配置用例、stats 与通用/web 目标的 HMR 套件仍通过 | 不破坏已有行为 | 项目命令 | 是 | 10% |
| C7 | 新增测试在没有该改动时失败,有改动时通过 | 测试质量 | 改前失败、改后通过 | 是 | 9% |
| C8 | 新增测试中实际执行的断言覆盖数字 id 下的顺序与空结果 | 测试质量 | 评审判定 | — | 5% |
| C9 | yarn lint 无报错:ESLint、生成文件最新、类型检查、Prettier、拼写检查 |
构建/lint/格式 | 项目命令 | — | 6% |
| C10 | 附带 webpack 的 minor changeset,格式符合项目要求 |
构建/lint/格式 | 项目命令 | — | 3% |
| C11 | module.d.ts 将 __webpack_css_server_styles__ 声明为 string |
构建/lint/格式 | 隐藏测试 | — | 3% |
| C12 | 读取方复用写入方的 cssServerStyleRegistry(),别处不重建注册表表达式 |
代码质量与规范 | 评审判定 | — | 5% |
| C13 | 经 APIPlugin 替换表、带注释的 RuntimeGlobals 常量和 RuntimeModule 子类接入 |
代码质量与规范 | 评审判定 | — | 4% |
| C14 | 注释简短、言之有物,无调试输出、注释掉的代码、复述代码的注释 | 代码质量与规范 | 评审判定 | — | 3% |
| C15 | 规模适度,只改源码、测试、changeset 和声明;不动测试框架、不削弱测试 | 改动范围 | 改动范围检查 | — | 4% |
评分器对随附解法的判定
| 解法 | 份数 | 得分 | 不计阻断项的加权得分 |
|---|---|---|---|
| 参考解 | 1 | 100 | 100 |
| 其他正确解法 | 3 | 100 | 100 |
| 部分完成解 | 4 | 0 | 33–88 |
| 取巧解 | 8 | 0 | 12–87 |
| 未改动的代码 | 1 | 0 | 16 |
满分 100。任一阻断项不过,得分即为 0。
轨迹 1
- 回合
- 120
- 工具调用
- 121
- 用时(分钟)
- 52
- 改动文件
- 61
- 增 / 删行数
- +740 / −10
把 __webpack_css_server_styles__ 接到新的 __webpack_require__.ss 运行时模块上,在通用(universal)目标以外直接替换为 "";cssServerStyleRegistry() 在未启用 output.environment.globalThis 时改用 __webpack_require__.g。模块键加上 "module-" 前缀,使数字 id 的条目保持插入顺序。新用例给样式模块分配递减的数字 id、推迟第一个 chunk 的 fs.readFile 并屏蔽 globalThis;用 git stash 撤下源码改动后这些用例失败。
第 11 回合 读完 cssServerStyleRegistry() 及写入注册表的代码后,发现普通对象按数值顺序列出数字 id 键,且 globalThis 是写死的。
第 16 回合 确认默认通用目标(["web", "node"])未设置 output.environment.globalThis,注册表须改走全局对象运行时 __webpack_require__.g。
第 97 回合 临时让 server-styles-unique-name 的两个构建使用相同的 uniqueName,两个隔离测试随即失败。
第 105 回合 把 output.environment.globalThis: true 的变体移入单独用例 server-styles-global-this,并用 supportsGlobalThis 过滤,因为 Node.js 10 和 11 没有 globalThis。
第 106 回合 变异检查中删去 GLOBALS_ON_REQUIRE 的新项,server-styles-global-this 用例中只需该变量的产物随即无法启动。
第 107 回合 用 git stash 撤下 lib、module.d.ts 和 types.d.ts 的改动后,新的 server-styles- 配置用例中有 24 个测试失败。
第 113 回合 确认重新运行的 yarn lint、yarn test:unit --ci、yarn test:basic --ci、HotTestCasesUniversal.test.js 和 HotTestCasesWeb.test.js 均无失败。
轨迹 1 的最终改动(节选)
diff --git a/lib/css/CssServerStylesRuntimeModule.js b/lib/css/CssServerStylesRuntimeModule.js
new file mode 100644
--- /dev/null
+++ b/lib/css/CssServerStylesRuntimeModule.js
@@ -0,0 +1,38 @@
+/*
+ MIT License http://www.opensource.org/licenses/mit-license.php
+*/
+
+"use strict";
+
+const RuntimeGlobals = require("../RuntimeGlobals");
+const RuntimeModule = require("../RuntimeModule");
+
+/** @typedef {import("../Compilation")} Compilation */
+
+class CssServerStylesRuntimeModule extends RuntimeModule {
+ constructor() {
+ super("css server styles");
+ }
+
+ /**
+ * Generates runtime code for this runtime module.
+ * @returns {string | null} runtime code
+ */
+ generate() {
+ const { runtimeTemplate } = /** @type {Compilation} */ (this.compilation);
+
+ return `${RuntimeGlobals.cssServerStyles} = ${runtimeTemplate.basicFunction(
+ "",
+ [
+ `${runtimeTemplate.renderConst()} registry = ${runtimeTemplate.cssServerStyleRegistry(false)};`,
+ // keys are never array indices, so they list in insertion order; a chunk still loading holds ""
+ `return registry ? Object.keys(registry).map(${runtimeTemplate.returningFunction(
+ "registry[key]",
+ "key"
+ )}).filter(Boolean).join("\\n") : "";`
+ ]
+ )};`;
+ }
+}
+
+module.exports = CssServerStylesRuntimeModule;
diff --git a/lib/APIPlugin.js b/lib/APIPlugin.js
--- a/lib/APIPlugin.js
+++ b/lib/APIPlugin.js
@@ -14,6 +14,7 @@ const {
JAVASCRIPT_MODULE_TYPE_ESM
} = require("./ModuleTypeConstants");
const RuntimeGlobals = require("./RuntimeGlobals");
+const CssServerStylesRuntimeModule = require("./css/CssServerStylesRuntimeModule");
const ConstDependency = require("./dependencies/ConstDependency");
const ModuleInitFragmentDependency = require("./dependencies/ModuleInitFragmentDependency");
const RuntimeRequirementsDependency = require("./dependencies/RuntimeRequirementsDependency");
@@ -113,6 +114,12 @@ function getReplacements() {
type: "string",
assign: false
},
+ __webpack_css_server_styles__: {
+ expr: `${RuntimeGlobals.cssServerStyles}()`,
+ req: [RuntimeGlobals.cssServerStyles],
+ type: "string",
+ assign: false
+ },
__webpack_get_script_filename__: {
expr: RuntimeGlobals.getChunkScriptFilename,
req: [RuntimeGlobals.getChunkScriptFilename],
@@ -172,6 +179,11 @@ class APIPlugin {
REPLACEMENTS.__non_webpack_require__.expr =
"__WEBPACK_EXTERNAL_createRequire_require";
}
+ // only universal targets collect styles outside of a document
+ if (!compilation.runtimeTemplate.isNeutralPlatform()) {
+ REPLACEMENTS.__webpack_css_server_styles__.expr = '""';
+ REPLACEMENTS.__webpack_css_server_styles__.req = null;
+ }
compilation.dependencyTemplates.set(
ConstDependency,
@@ -199,6 +211,19 @@ class APIPlugin {
return true;
});
+ compilation.hooks.runtimeRequirementInTree
+ .for(RuntimeGlobals.cssServerStyles)
+ .tap(PLUGIN_NAME, (chunk, set) => {
+ if (!compilation.outputOptions.environment.globalThis) {
+ set.add(RuntimeGlobals.global);
+ }
+ compilation.addRuntimeModule(
+ chunk,
+ new CssServerStylesRuntimeModule()
+ );
+ return true;
+ });
+
const hooks = JavascriptModulesPlugin.getCompilationHooks(compilation);
hooks.renderModuleContent.tap(
diff --git a/lib/RuntimeTemplate.js b/lib/RuntimeTemplate.js
--- a/lib/RuntimeTemplate.js
+++ b/lib/RuntimeTemplate.js
@@ -191,16 +191,23 @@ class RuntimeTemplate {
/**
* Expression for the global registry that collects CSS server-side when there
- * is no DOM (SSR). An SSR host reads it from `globalThis`; it is keyed by the
- * style/chunk identifier and namespaced by `output.uniqueName`.
+ * is no DOM (SSR). It is keyed by the style/chunk identifier and namespaced by
+ * `output.uniqueName`. It lives on `globalThis`, or on `RuntimeGlobals.global`
+ * (which the caller then has to require) when the environment has no `globalThis`.
+ * @param {boolean=} create false to only read it (`undefined` while nothing was collected)
* @returns {string} runtime expression evaluating to the registry object
*/
- cssServerStyleRegistry() {
+ cssServerStyleRegistry(create = true) {
const name = this.outputOptions.uniqueName;
const key = JSON.stringify(
name ? `__webpack_css__${name}` : "__webpack_css__"
);
- return `(${this.assignOr(`globalThis[${key}]`, "{}")})`;
+ const registry = `${
+ this.outputOptions.environment.globalThis
+ ? "globalThis"
+ : RuntimeGlobals.global
+ }[${key}]`;
+ return create ? `(${this.assignOr(registry, "{}")})` : registry;
}
supportsConst() {
…… 其余 1,045 行从略(共改动 61 个文件)
轨迹 2
- 回合
- 102
- 工具调用
- 108
- 用时(分钟)
- 44
- 改动文件
- 67
- 增 / 删行数
- +722 / −10
新增定义 __webpack_require__.ss 的 CssServerStylesRuntimeModule(非通用目标下返回 ""),以及判断何时需要 __webpack_require__.g 的辅助方法 cssServerStyleRegistryNeedsGlobal()。模块键加上 "module-" 前缀,CSS chunk 在请求时先占位,以保持应用顺序;把 cssInjectStyle 加入 GLOBALS_ON_REQUIRE,顺带修复运行时 chunk 之外的 type: "css" 样式模块。新用例覆盖数字 id 下的顺序和没有 globalThis 的 vm 上下文,并用变异测试检查。
第 10 回合 把变量设计成运行时函数调用,每次读取都拿到最新注册表,并注意到普通对象不按插入顺序列出数字键。
第 12 回合 在仓库外的临时目录构建 ["web", "node"] 示例,发现 output.environment.globalThis 默认未设置,注册表须改走全局对象运行时 __webpack_require__.g。
第 73 回合 在发现 __webpack_require__ is not defined 崩溃(可选修复)除热更新外也会在异步 chunk 中出现后,把 cssInjectStyle 加入 GLOBALS_ON_REQUIRE。
第 85 回合 运行变异测试:去掉 "module-" 前缀后 server-styles-order 失败,写死 globalThis 后 server-styles-no-global-this 失败。
第 87 回合 补充测试,断言读取变量后 globalThis 上没有以 __webpack_css__ 开头的键,此前漏检的“读取时创建注册表”变异随即被测出。
第 92 回合 确认 yarn test:unit --ci、yarn test:basic --ci 和全部五个 HotTestCases* 套件均无失败,快照也无变化。
第 97 回合 以 ["web", "node10", "es5"] 为目标,在仓库外的临时目录构建示例,产物中只剩示例源码自带的箭头函数。
轨迹 2 的最终改动(节选)
diff --git a/lib/css/CssServerStylesRuntimeModule.js b/lib/css/CssServerStylesRuntimeModule.js
new file mode 100644
--- /dev/null
+++ b/lib/css/CssServerStylesRuntimeModule.js
@@ -0,0 +1,41 @@
+/*
+ MIT License http://www.opensource.org/licenses/mit-license.php
+*/
+
+"use strict";
+
+const RuntimeGlobals = require("../RuntimeGlobals");
+const RuntimeModule = require("../RuntimeModule");
+
+/** @typedef {import("../Compilation")} Compilation */
+
+class CssServerStylesRuntimeModule extends RuntimeModule {
+ constructor() {
+ super("css server styles");
+ }
+
+ /**
+ * Generates runtime code for this runtime module.
+ * @returns {string | null} runtime code
+ */
+ generate() {
+ const { runtimeTemplate } = /** @type {Compilation} */ (this.compilation);
+ // only universal targets collect styles
+ if (!runtimeTemplate.isNeutralPlatform()) {
+ return `${RuntimeGlobals.cssServerStyles} = ${runtimeTemplate.returningFunction('""')};`;
+ }
+ return `${RuntimeGlobals.cssServerStyles} = ${runtimeTemplate.basicFunction(
+ "",
+ [
+ `${runtimeTemplate.renderConst()} registry = ${runtimeTemplate.cssServerStyleRegistry(false)};`,
+ // keys are never array indices, so they enumerate in the order the styles were applied
+ `return Object.keys(registry).map(${runtimeTemplate.returningFunction(
+ "registry[key]",
+ "key"
+ )}).filter(Boolean).join("\\n");`
+ ]
+ )};`;
+ }
+}
+
+module.exports = CssServerStylesRuntimeModule;
diff --git a/lib/APIPlugin.js b/lib/APIPlugin.js
--- a/lib/APIPlugin.js
+++ b/lib/APIPlugin.js
@@ -14,6 +14,7 @@ const {
JAVASCRIPT_MODULE_TYPE_ESM
} = require("./ModuleTypeConstants");
const RuntimeGlobals = require("./RuntimeGlobals");
+const CssServerStylesRuntimeModule = require("./css/CssServerStylesRuntimeModule");
const ConstDependency = require("./dependencies/ConstDependency");
const ModuleInitFragmentDependency = require("./dependencies/ModuleInitFragmentDependency");
const RuntimeRequirementsDependency = require("./dependencies/RuntimeRequirementsDependency");
@@ -107,6 +108,12 @@ function getReplacements() {
type: "string",
assign: false
},
+ __webpack_css_server_styles__: {
+ expr: `${RuntimeGlobals.cssServerStyles}()`,
+ req: [RuntimeGlobals.cssServerStyles],
+ type: "string",
+ assign: false
+ },
__webpack_chunkname__: {
expr: RuntimeGlobals.chunkName,
req: [RuntimeGlobals.chunkName],
@@ -199,6 +206,21 @@ class APIPlugin {
return true;
});
+ compilation.hooks.runtimeRequirementInTree
+ .for(RuntimeGlobals.cssServerStyles)
+ .tap(PLUGIN_NAME, (chunk, set) => {
+ if (
+ compilation.runtimeTemplate.cssServerStyleRegistryNeedsGlobal()
+ ) {
+ set.add(RuntimeGlobals.global);
+ }
+ compilation.addRuntimeModule(
+ chunk,
+ new CssServerStylesRuntimeModule()
+ );
+ return true;
+ });
+
const hooks = JavascriptModulesPlugin.getCompilationHooks(compilation);
hooks.renderModuleContent.tap(
diff --git a/lib/RuntimeTemplate.js b/lib/RuntimeTemplate.js
--- a/lib/RuntimeTemplate.js
+++ b/lib/RuntimeTemplate.js
@@ -191,16 +191,33 @@ class RuntimeTemplate {
/**
* Expression for the global registry that collects CSS server-side when there
- * is no DOM (SSR). An SSR host reads it from `globalThis`; it is keyed by the
- * style/chunk identifier and namespaced by `output.uniqueName`.
+ * is no DOM (SSR), read back through `__webpack_css_server_styles__`. It is
+ * keyed by the style/chunk identifier and namespaced by `output.uniqueName`.
+ * @param {boolean=} create false to read the registry without creating it
* @returns {string} runtime expression evaluating to the registry object
*/
- cssServerStyleRegistry() {
+ cssServerStyleRegistry(create = true) {
const name = this.outputOptions.uniqueName;
const key = JSON.stringify(
name ? `__webpack_css__${name}` : "__webpack_css__"
);
- return `(${this.assignOr(`globalThis[${key}]`, "{}")})`;
+ const slot = `${
+ this.outputOptions.environment.globalThis
+ ? "globalThis"
+ : RuntimeGlobals.global
+ }[${key}]`;
+ return `(${create ? this.assignOr(slot, "{}") : `${slot} || {}`})`;
+ }
+
+ /**
+ * Whether `cssServerStyleRegistry()` reaches the global object through
+ * `RuntimeGlobals.global`, which its runtime module then has to require.
+ * @returns {boolean} true when `RuntimeGlobals.global` is needed
+ */
+ cssServerStyleRegistryNeedsGlobal() {
+ return (
+ this.isNeutralPlatform() && !this.outputOptions.environment.globalThis
+ );
…… 其余 1,050 行从略(共改动 67 个文件)
样例 3 / 7
修复 Pydantic 装饰器收集的三个问题
让 DecoratorInfos.build() 不再改动 Pydantic 不拥有的类,每个基类只处理一次,配置只作用于正在构建的类。同时保留从根到叶的顺序与按名称覆盖。
题面(Agent 收到的原文)
Task description
DecoratorInfos.build() in pydantic/_internal/_decorators.py has three problems:
- It modifies classes Pydantic doesn't own. Using a plain class with Pydantic decorators as a model base, or building a
TypedDictschema, sets__pydantic_decorators__on that class and unwraps its decorated attributes. For aTypedDictit even breaks validation: a secondTypeAdapterno longer runs its validators. No such class may be modified, including a stdlib dataclass passed topydantic.dataclasses.dataclass()(it is subclassed). Pydantic models and Pydantic dataclasses must still be unwrapped as today (computed-field setters keep working). - It re-processes bases recursively, so creating a model on top of a long chain of plain classes takes exponential time. Each base should be processed once per build.
- Decorator infos are shared: config such as
field_title_generator/alias_generatorapplied to computed fields leaks into other classes. A subclass rewrites its parent'smodel_computed_fields; aTypedDictused by several models shows the first model's generated titles in the others. Config must only affect the class being built.
Keep the existing semantics: root-to-leaf order, overriding by name, and multiple-field-serializers when a class adds a second serializer for a field.
题面后半:测试、lint 和代码风格规范
Test guidelines
- Tests use pytest and live under
tests/; everytests/**/test_*.pyfile is collected automatically (no registration). Decorator collection is tested intests/test_decorators.py; related behaviour lives intests/test_types_typeddict.py,tests/test_dataclasses.py,tests/test_computed_fields.pyandtests/test_validators.py. - Cover the fixed behaviour with regression tests that fail without your change, written like the surrounding tests (plain
test_*functions exercising public behaviour where possible). - Run a module or a single test with
uv run pytest tests/test_decorators.py(add-k <name>to select). Run the whole suite (about a minute) withulimit -s 65536; uv run pytest. All existing tests must keep passing without being modified. uv runrebuilds the local packages when needed; don't run a bareuv sync(it would uninstall the dev tools).
Lint guidelines
CI runs make lint-python and pyright. Their offline equivalents must pass:
uv run ruff check pydantic tests docs/plugins release/uv run ruff format --check pydantic tests docs/plugins release/(fix withuv run ruff format <files>)uv run pyright pydantic
Style guidelines
- Match the style of
pydantic/_internal/_decorators.py: fully typed code, Google-style docstrings that say what a function does (keep them accurate when behaviour changes, and document new parameters and helpers), comments only where the code isn't self-explanatory. - Reuse the existing helpers rather than duplicating logic, and don't change the dataclass configuration of existing classes.
- Keep the change focused on decorator collection and its call sites: no unrelated refactors or reformatting, no docs or
HISTORY.mdedits (the changelog is generated at release time). - You are already on the correct base commit. Create your branch from this commit. Do not rebase or start from master, main, or any other branch.
评分细则
| # | 细则 | 类别 | 检查方式 | 阻断项 | 权重 |
|---|---|---|---|---|---|
| C1 | 不改非自有类、自有类仍解包、配置隔离、基类只处理一次 | 行为正确 | 隐藏测试 | 是 | 37% |
| C2 | 未经修改的原有测试套件仍通过 | 不破坏已有行为 | 项目命令 | 是 | 11% |
| C3 | 改动仅限装饰器收集、其调用点与测试 | 改动范围 | 改动范围检查 | 是 | 7% |
| C4 | 新增测试在基线提交上失败、改动后通过 | 测试质量 | 改前失败、改后通过 | 是 | 11% |
| C5 | 源码改动规模适度 | 改动范围 | 改动范围检查 | — | 2% |
| C6 | “不改动非自有类”与“配置隔离”两项修复均有行为测试 | 测试质量 | 评审判定 | — | 9% |
| C7 | ruff 代码检查与格式检查通过 |
构建/lint/格式 | 项目命令 | — | 3% |
| C8 | pyright 对包的类型检查通过 |
构建/lint/格式 | 项目命令 | — | 3% |
| C9 | 按类扫描命名空间与按信息类型分派只写一次 | 代码质量与规范 | 评审判定 | — | 6% |
| C10 | 文档字符串与注释符合新行为,新参数与辅助函数有文档 | 代码质量与规范 | 评审判定 | — | 6% |
| C11 | 装饰器信息类保留 slots 数据类配置 |
代码质量与规范 | 项目命令 | — | 2% |
| C12 | 三个调用点文件共改动不超过 40 行 | 改动范围 | 项目命令 | — | 3% |
评分器对随附解法的判定
| 解法 | 份数 | 得分 | 不计阻断项的加权得分 |
|---|---|---|---|
| 参考解 | 1 | 100 | 100 |
| 其他正确解法 | 3 | 100 | 100 |
| 接近完成解 | 1 | 94 | 94 |
| 部分完成解 | 3 | 0 | 31–63 |
| 取巧解 | 6 | 0 | 46–94 |
| 未改动的代码 | 1 | 0 | 31 |
满分 100。任一阻断项不过,得分即为 0。
轨迹 1
- 回合
- 114
- 工具调用
- 135
- 用时(分钟)
- 55
- 改动文件
- 7
- 增 / 删行数
- +290 / −39
把 build() 改为单次遍历 MRO,不再改动非自有类;计算字段信息先复制再更新。仅模型和 Pydantic 数据类的调用点传 replace_wrapped=True,后者改在子类化标准库数据类之后调用。用复现脚本和 9 个新测试用例验证。
第 12 回合 用复现脚本确认第二个 TypeAdapter 不再运行 TypedDict 的校验器。
第 14 回合 统计 build() 调用次数:每多一个普通基类就翻倍,18 个时达 524,288 次。
第 31 回合 确认三个问题后定下方案:从根到叶只遍历一次 MRO、按需开启解包、按类复制装饰器信息。
第 92 回合 按现有惯例给监视 build() 调用的测试加上 thread_unsafe 标记。
第 99 回合 剖析类创建耗时后,改为只在 update_from_config 中复制计算字段信息。
第 101 回合 改完后重跑复现脚本:mixin 保留代理,第二个 TypeAdapter 仍运行校验器。
第 112 回合 改完文档字符串后重跑 ruff、pyright 和全量测试:12,070 个测试通过。
轨迹 1 的最终改动(节选)
diff --git a/pydantic/_internal/_decorators.py b/pydantic/_internal/_decorators.py
--- a/pydantic/_internal/_decorators.py
+++ b/pydantic/_internal/_decorators.py
@@ -6,10 +6,11 @@ import sys
import types
from collections import deque
from collections.abc import Iterable
+from copy import copy
from dataclasses import dataclass, field
from functools import cached_property, partial, partialmethod
from inspect import Parameter, Signature, isdatadescriptor, ismethoddescriptor, signature
-from itertools import islice
+from itertools import chain, islice
from typing import TYPE_CHECKING, Any, Callable, ClassVar, Generic, Literal, TypeVar, Union
from pydantic_core import PydanticUndefined, PydanticUndefinedType, core_schema
@@ -428,9 +429,9 @@ class DecoratorInfos:
computed_fields: dict[str, Decorator[ComputedFieldInfo]] = field(default_factory=dict)
@staticmethod
- def build(model_dc: type[Any]) -> DecoratorInfos: # noqa: C901 (ignore complexity)
+ def build(model_dc: type[Any], *, replace_wrapped: bool = False) -> DecoratorInfos:
"""We want to collect all DecFunc instances that exist as
- attributes in the namespace of the class (a BaseModel or dataclass)
+ attributes in the namespace of the class (a BaseModel, dataclass or TypedDict)
that called us
But we want to collect these in the order of the bases
So instead of getting them all from the leaf class (the class that called us),
@@ -440,13 +441,36 @@ class DecoratorInfos:
works with inheritance.
If we do replace any functions we put the replacement into the position
the replaced function was in; that is, we maintain the order.
+
+ Each base is only processed once: Pydantic models and Pydantic dataclasses already
+ store their decorators (including the inherited ones) in `__pydantic_decorators__`,
+ and for the other bases (which we don't own, and as such don't modify), we collect
+ the decorators defined in their own namespace. The collected decorators are bound to
+ the class.
+
+ Args:
+ model_dc: The class to collect the decorators of.
+ replace_wrapped: Whether to replace the decorated attributes of the class by the objects they wrap,
+ which allows validator class methods to also function as regular class methods, and computed
+ field setters to be used. Decorated attributes inherited from bases that aren't Pydantic models
+ or Pydantic dataclasses are set on the class instead, leaving these bases untouched. As the
+ decorators can't be collected again from the class afterwards, this should only be enabled for
+ classes owned by Pydantic, which store the result in `__pydantic_decorators__`.
+
+ Returns:
+ The collected decorators.
"""
# reminder: dicts are ordered and replacement does not alter the order
res = DecoratorInfos()
- for base in reversed(mro(model_dc)[1:]):
+ model_mro = mro(model_dc)
+ # bases might not be hashable, so we key them by id:
+ bases_decorators: dict[int, DecoratorInfos] = {}
+ for base in reversed(model_mro[1:]):
existing: DecoratorInfos | None = base.__dict__.get('__pydantic_decorators__')
if existing is None:
- existing = DecoratorInfos.build(base)
+ existing = DecoratorInfos()
+ existing._add_namespace_decorators(base, bases_decorators)
+ bases_decorators[id(base)] = existing
res.validators.update({k: v.bind_to_cls(model_dc) for k, v in existing.validators.items()})
res.field_validators.update({k: v.bind_to_cls(model_dc) for k, v in existing.field_validators.items()})
res.root_validators.update({k: v.bind_to_cls(model_dc) for k, v in existing.root_validators.items()})
diff --git a/pydantic/dataclasses.py b/pydantic/dataclasses.py
--- a/pydantic/dataclasses.py
+++ b/pydantic/dataclasses.py
@@ -183,8 +183,6 @@ def dataclass(
# if config is not explicitly provided, try to read it from the type
config_dict = config if config is not None else getattr(cls, '__pydantic_config__', None)
config_wrapper = _config.ConfigWrapper(config_dict)
- decorators = _decorators.DecoratorInfos.build(cls)
- decorators.update_from_config(config_wrapper)
# Keep track of the original __doc__ so that we can restore it after applying the dataclasses decorator
# Otherwise, classes with no __doc__ will have their signature added into the JSON schema description,
@@ -205,6 +203,11 @@ def dataclass(
bases = bases + (generic_base,)
cls = types.new_class(cls.__name__, bases)
+ # The decorators are collected after subclassing stdlib dataclasses, so that the decorated
+ # attributes are replaced on the subclass (and not on the original class):
+ decorators = _decorators.DecoratorInfos.build(cls, replace_wrapped=True)
+ decorators.update_from_config(config_wrapper)
+
# Respect frozen setting from dataclass constructor and fallback to config setting if not provided
if frozen is not None:
frozen_ = frozen
diff --git a/pydantic/_internal/_model_construction.py b/pydantic/_internal/_model_construction.py
--- a/pydantic/_internal/_model_construction.py
+++ b/pydantic/_internal/_model_construction.py
@@ -172,7 +172,7 @@ class ModelMetaclass(ABCMeta):
cls.__pydantic_setattr_handlers__ = {}
- cls.__pydantic_decorators__ = DecoratorInfos.build(cls)
+ cls.__pydantic_decorators__ = DecoratorInfos.build(cls, replace_wrapped=True)
cls.__pydantic_decorators__.update_from_config(config_wrapper)
# Use the getattr below to grab the __parameters__ from the `typing.Generic` parent class
diff --git a/tests/test_computed_fields.py b/tests/test_computed_fields.py
--- a/tests/test_computed_fields.py
+++ b/tests/test_computed_fields.py
@@ -853,3 +853,24 @@ def test_computed_fields_exclude() -> None:
assert m.model_dump(mode='json', exclude_computed_fields=True) == {}
assert m.model_dump_json() == '{"prop":1}'
assert m.model_dump_json(exclude_computed_fields=True) == '{}'
+
+
+def test_computed_field_config_not_shared_with_parent() -> None:
+ class Parent(BaseModel):
+ model_config = {'defer_build': True}
+
+ @computed_field
+ @property
+ def my_field(self) -> int:
+ return 1
+
+ class Child(Parent):
+ model_config = {'alias_generator': to_camel, 'field_title_generator': lambda name, _: name.upper()}
+
+ assert Parent.model_computed_fields['my_field'].alias is None
+ assert Parent.model_computed_fields['my_field'].title is None
+ assert Parent().model_dump(by_alias=True) == {'my_field': 1}
+
+ assert Child.model_computed_fields['my_field'].alias == 'myField'
+ assert Child.model_computed_fields['my_field'].title == 'MY_FIELD'
…… 其余 340 行从略(共改动 7 个文件)
轨迹 2
- 回合
- 115
- 工具调用
- 131
- 用时(分钟)
- 71
- 改动文件
- 8
- 增 / 删行数
- +336 / −53
把 build() 改为单次遍历 MRO,不再改动非自有类;计算字段信息改为写时复制。仅模型和 Pydantic 数据类的调用点传 unwrap_attributes=True。新增 10 个测试用例;临时去掉继承属性解包或信息复制,测试都会失败。
第 10 回合 用一个临时脚本复现全部三个问题,其中 18 个普通基类组成的链约需 1.5 秒。
第 19 回合 发现 object.__setattr__ 把代理视为非数据描述符,影响标准库数据类转换。
第 24 回合 记录边界情况的基线行为:重复序列化器检查只与自身祖先比较、不涉及兄弟基类,决定保留。
第 93 回合 性能剖析后将辅助方法改为 _add_from_namespace,按需收集继承的序列化器。
第 100 回合 让 _update_from_config 只在确实需要改动时才返回副本,否则返回原对象。
第 108 回合 临时去掉继承属性的解包、再去掉信息复制,分别有 1 个和 3 个测试失败,随后恢复原文件。
第 114 回合 最后一次全量运行 12,071 个测试通过后,在新分支上提交改动。
轨迹 2 的最终改动(节选)
diff --git a/pydantic/_internal/_decorators.py b/pydantic/_internal/_decorators.py
--- a/pydantic/_internal/_decorators.py
+++ b/pydantic/_internal/_decorators.py
@@ -428,25 +428,44 @@ class DecoratorInfos:
computed_fields: dict[str, Decorator[ComputedFieldInfo]] = field(default_factory=dict)
@staticmethod
- def build(model_dc: type[Any]) -> DecoratorInfos: # noqa: C901 (ignore complexity)
- """We want to collect all DecFunc instances that exist as
- attributes in the namespace of the class (a BaseModel or dataclass)
- that called us
- But we want to collect these in the order of the bases
- So instead of getting them all from the leaf class (the class that called us),
- we traverse the bases from root (the oldest ancestor class) to leaf
- and collect all of the instances as we go, taking care to replace
- any duplicate ones with the last one we see to mimic how function overriding
- works with inheritance.
- If we do replace any functions we put the replacement into the position
- the replaced function was in; that is, we maintain the order.
+ def build(model_dc: type[Any], *, unwrap_attributes: bool = False) -> DecoratorInfos:
+ """Collect the decorators of a class (a model, dataclass or typed dictionary), including the inherited ones.
+
+ We want to collect the decorators in the order of the bases. So instead of getting them all from the
+ class itself, we traverse the bases from root (the oldest ancestor class) to leaf, processing each base once,
+ and collect the decorators as we go, taking care to replace any duplicate ones with the last one we see to
+ mimic how function overriding works with inheritance. If we do replace any decorators we put the replacement
+ into the position the replaced decorator was in; that is, we maintain the order.
+
+ Pydantic models and dataclasses provide their collected decorators (which include the inherited ones) as
+ `__pydantic_decorators__`, while the decorators of other classes are collected from their namespace. Classes
+ not owned by Pydantic (e.g. typed dictionaries, stdlib dataclasses or plain classes used as bases) are left
+ untouched.
+
+ Args:
+ model_dc: The class to collect the decorators of.
+ unwrap_attributes: Whether to replace the decorated attributes of the class by the objects they wrap,
+ which allows validator class methods to also function as regular class methods and computed field
+ setters to be used. Decorated attributes inherited from classes not owned by Pydantic are replaced
+ on the class as well. As the class is modified, this must only be used for Pydantic models and
+ dataclasses.
+
+ Returns:
+ The decorators of the class, bound to it.
"""
# reminder: dicts are ordered and replacement does not alter the order
res = DecoratorInfos()
- for base in reversed(mro(model_dc)[1:]):
+ model_dc_mro = mro(model_dc)
+ # the decorators provided by each base:
+ bases_decorators: dict[Any, DecoratorInfos] = {}
+ # the names of the decorated attributes defined in the namespace of the class and of the bases we don't own:
+ decorated_names: list[str] = []
+ for base in reversed(model_dc_mro[1:]):
existing: DecoratorInfos | None = base.__dict__.get('__pydantic_decorators__')
if existing is None:
- existing = DecoratorInfos.build(base)
+ existing = DecoratorInfos()
+ decorated_names += existing._add_from_namespace(base, bases_decorators)
+ bases_decorators[base] = existing
res.validators.update({k: v.bind_to_cls(model_dc) for k, v in existing.validators.items()})
res.field_validators.update({k: v.bind_to_cls(model_dc) for k, v in existing.field_validators.items()})
res.root_validators.update({k: v.bind_to_cls(model_dc) for k, v in existing.root_validators.items()})
@@ -455,26 +474,55 @@ class DecoratorInfos:
res.model_validators.update({k: v.bind_to_cls(model_dc) for k, v in existing.model_validators.items()})
res.computed_fields.update({k: v.bind_to_cls(model_dc) for k, v in existing.computed_fields.items()})
- to_replace: list[tuple[str, Any]] = []
+ decorated_names += res._add_from_namespace(model_dc, bases_decorators)
+
+ if unwrap_attributes:
+ for var_name in decorated_names:
+ # the decorated attribute is either defined on the class or inherited from a class we don't own:
+ attribute = next(base.__dict__[var_name] for base in model_dc_mro if var_name in base.__dict__)
+ if isinstance(attribute, PydanticDescriptorProxy):
+ setattr(model_dc, var_name, attribute.wrapped)
+ return res
+
+ def _add_from_namespace(self, tp: type[Any], bases_decorators: dict[Any, DecoratorInfos]) -> list[str]:
+ """Add the decorators defined in the namespace of a class (i.e. not the inherited ones), bound to the class.
+
+ Args:
+ tp: The class to collect the decorators of.
+ bases_decorators: The decorators provided by each base of the class, used to check that each field
+ has at most one serializer function.
+
+ Returns:
+ The names of the decorated attributes defined in the namespace of the class.
- for var_name, var_value in vars(model_dc).items():
+ Raises:
+ PydanticUserError: If the class defines a serializer function for a field that already has one.
+ """
+ decorated_names: list[str] = []
+ # the field serializers of the class, including the inherited ones (only computed if needed):
+ field_serializers: dict[str, Decorator[FieldSerializerDecoratorInfo]] | None = None
+ for var_name, var_value in vars(tp).items():
if isinstance(var_value, PydanticDescriptorProxy):
info = var_value.decorator_info
if isinstance(info, ValidatorDecoratorInfo):
- res.validators[var_name] = Decorator.build(
- model_dc, cls_var_name=var_name, shim=var_value.shim, info=info
+ self.validators[var_name] = Decorator.build(
+ tp, cls_var_name=var_name, shim=var_value.shim, info=info
)
elif isinstance(info, FieldValidatorDecoratorInfo):
- res.field_validators[var_name] = Decorator.build(
- model_dc, cls_var_name=var_name, shim=var_value.shim, info=info
+ self.field_validators[var_name] = Decorator.build(
+ tp, cls_var_name=var_name, shim=var_value.shim, info=info
)
elif isinstance(info, RootValidatorDecoratorInfo):
- res.root_validators[var_name] = Decorator.build(
- model_dc, cls_var_name=var_name, shim=var_value.shim, info=info
+ self.root_validators[var_name] = Decorator.build(
+ tp, cls_var_name=var_name, shim=var_value.shim, info=info
)
elif isinstance(info, FieldSerializerDecoratorInfo):
+ if field_serializers is None:
+ field_serializers = {}
+ for base in reversed(mro(tp)[1:]):
+ field_serializers.update(bases_decorators[base].field_serializers)
# check whether a serializer function is already registered for fields
- for field_serializer_decorator in res.field_serializers.values():
+ for field_serializer_decorator in field_serializers.values():
# check that each field has at most one serializer function.
# serializer functions for the same field in subclasses are allowed,
# and are treated as overrides
…… 其余 411 行从略(共改动 8 个文件)
样例 4 / 7
为 spdlog 文件 sink 添加缓冲大小选项
为 basic、rotating、daily 文件 sink 的构造函数和工厂函数加尾部参数 buffer_size,并给 sink 加访问器 set_buffer_size() 与 buffer_size();0 保持现有行为。sink 写日志期间设非零值须立即生效,且不丢失已缓冲的输出。
题面(Agent 收到的原文)
Task description
The file sinks always write through the C runtime's default stdio buffer, which is only a few KB. Users who log at high volume want a larger buffer to cut down on write calls, and today they have to install one on the sink's FILE themselves with setvbuf. Please make the buffer size a proper option of the basic, rotating and daily file sinks.
- The constructors of
basic_file_sink,rotating_file_sinkanddaily_file_sink, and their logger factory functions, take a new trailing parametersize_t buffer_size = 0, placed afterevent_handlers.0keeps today's behaviour. A non-zero value makes the files the sink writes fully buffered with a buffer of that many bytes: output reaches the file only when the buffer fills up, the sink is flushed or the file is closed. - The three sink classes get
void set_buffer_size(size_t buffer_size)andsize_t buffer_size(). The getter returns the configured value (0 by default). The setter can be called while the sink is logging: a non-zero size takes effect right away, without losing output buffered so far; going back to 0 may wait until the sink opens its next file.
Existing code that uses these sinks must keep compiling and behaving exactly as it does now.
题面后半:测试、lint 和代码风格规范
Test guidelines
- Unit tests are Catch2 v3 cases compiled into one binary,
spdlog-utests, from the sources listed intests/CMakeLists.txt. File sink tests live intests/test_file_logging.cpp(basic and rotating sinks),tests/test_daily_logger.cpp(daily sink) andtests/test_file_helper.cpp(details::file_helper); add the tests for this change there. Use the helpers intests/utils.h(prepare_logdir(),file_contents(),get_filesize(), ...) and write log files undertest_logs/. - The Windows CI builds the tests with
SPDLOG_WCHAR_FILENAMES=ON, wherespdlog::filename_tisstd::wstring: pass file names to spdlog asSPDLOG_FILENAME_T(...), and convert afilename_tyou get back from spdlog withspdlog::details::os::filename_to_str()before handing it to the test helpers. - Build and run the file sink tests (from the repository root):
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=20 -DSPDLOG_BUILD_EXAMPLE=ON -DSPDLOG_BUILD_EXAMPLE_HO=ON -DSPDLOG_BUILD_WARNINGS=ON -DSPDLOG_BUILD_TESTS=ON && cmake --build build -j8 --target spdlog-utests cd build/tests && ./spdlog-utests --order decl --filenames-as-tags "[#test_file_logging],[#test_daily_logger],[#test_file_helper]" - The whole suite must stay green:
cmake --build build -j8 && ctest --test-dir build -j4 --output-on-failure. - spdlog is also used header-only with a C++11 compiler. Check that configuration too:
cmake -S . -B build/ho11 -DCMAKE_CXX_STANDARD=11 -DSPDLOG_BUILD_WARNINGS=ON -DSPDLOG_BUILD_TESTS=OFF -DSPDLOG_BUILD_TESTS_HO=ON && cmake --build build/ho11 -j8 && cd build/ho11/tests && ./spdlog-utests-ho --order decl
Lint guidelines
- Every build above uses
-DSPDLOG_BUILD_WARNINGS=ON(-Wall -Wextra -Wconversion -pedantic -Werror): library, examples and tests must compile without warnings. - The project's clang-tidy run must not report errors:
cmake -S . -B build/tidy -DSPDLOG_TIDY=ON && cmake --build build/tidy -j8 --clean-first(findings are printed as warnings; the ones inexample/example.cppalready exist). - Formatting follows
.clang-format(Google based, 4-space indent, 100 columns;clang-format14 is installed). Format the code you write, but do not reformat code you did not change: the tree is not clang-format clean.
Style guidelines
- Follow the conventions of the existing file sinks and of
details::file_helper: public sink methods that touch the file take the sink's mutex, out-of-class definitions in-inl.hfiles are markedSPDLOG_INLINE, and failures are reported withthrow_spdlog_ex(...). - Keep the public API source compatible and keep the change focused: no unrelated refactors or reformatting. The project keeps no changelog, and README.md is curated by the maintainer and is not edited in feature changes like this one; if you want to show the new option, extend
example/example.cpp. - You are already on the correct base commit. Create your branch from this commit. Do not rebase or start from master, main, or any other branch.
评分细则
| # | 细则 | 类别 | 检查方式 | 阻断项 | 权重 |
|---|---|---|---|---|---|
| C1 | basic、rotating、daily sink 的构造函数和工厂函数都带尾部参数 buffer_size,sink 提供 set_buffer_size() / buffer_size() |
行为正确 | 隐藏测试 | 是 | 13% |
| C2 | buffer_size 真正决定缓冲:构造、轮转/截断/按日切换、0 保持默认、运行时修改 |
行为正确 | 隐藏测试 | 是 | 22% |
| C3 | 无回归:编译无警告;C++11 仅头文件构建下原有测试通过、模板可编译 | 不破坏已有行为 | 项目命令 | 是 | 10% |
| C4 | 只改动属于该功能的文件 | 改动范围 | 改动范围检查 | 是 | 7% |
| C5 | 新测试在基线提交上失败、加入改动后通过,Catch2 断言未被改写 | 测试质量 | 改前失败、改后通过 | 是 | 8% |
| C6 | sink 只做转发:缓冲区在 details::file_helper 中,set_buffer_size() 先取 sink 互斥锁再转发 |
代码质量与规范 | 评审判定 | 是 | 11% |
| C7 | Windows CI 开启 SPDLOG_WCHAR_FILENAMES 时,新测试和库中新增的消息仍可编译 |
测试质量 | 评审判定 | 是 | 6% |
| C8 | setvbuf 失败须上报或转入有文档说明的回退,不可忽略 |
代码质量与规范 | 评审判定 | — | 6% |
| C9 | 流仍在使用缓冲区期间不释放其内存(含运行中改大小) | 代码质量与规范 | 评审判定 | — | 6% |
| C10 | 项目的 clang-tidy 构建(C++11,库与示例)无错误完成 | 构建/lint/格式 | 项目命令 | — | 4% |
| C11 | 无调试残留、注释掉的代码、叙述改动过程的注释、无关格式改动 | 构建/lint/格式 | 评审判定 | — | 4% |
| C12 | 改动规模适度(非测试代码的行数与文件数) | 改动范围 | 改动范围检查 | — | 3% |
评分器对随附解法的判定
| 解法 | 份数 | 得分 | 不计阻断项的加权得分 |
|---|---|---|---|
| 参考解 | 1 | 100 | 100 |
| 其他正确解法 | 2 | 100 | 100 |
| 接近完成解 | 1 | 89 | 89 |
| 部分完成解 | 3 | 0 | 24–59 |
| 取巧解 | 11 | 0 | 42–94 |
| 未改动的代码 | 1 | 0 | 24 |
满分 100。任一阻断项不过,得分即为 0。
轨迹 1
- 回合
- 65
- 工具调用
- 91
- 用时(分钟)
- 42
- 改动文件
- 11
- 增 / 删行数
- +479 / −30
用探针确认 glibc 在 setvbuf 的缓冲区参数为空指针时忽略大小,因此由 file_helper 自备缓冲区并在每次打开文件时装上,三种 sink 持锁转发;运行中切换时先 flush(),再做一次不移动读写位置的 fseek(偏移 0),然后才调用 setvbuf。新增 11 个用例,用三种变异确认它们能发现缺陷,并在 ASan 构建和 C++11 仅头文件构建下运行整套测试。
第 11 回合 用探针检验 glibc:setvbuf 的缓冲区参数为空指针时只用 4096 字节而非 64K,运行中切换后首条输出会被提前写出。
第 13 回合 试验先做不移动位置的 fseek 再调用 setvbuf:已写过的流仍保持缓冲;对管道 fseek 会失败,但无害。
第 18 回合 实现时把 setvbuf 放在 after_open 回调之前;运行中改大小时,等切换成功后才释放旧缓冲区。
第 40 回合 变异测试:把两处 setvbuf 的缓冲区参数改成 nullptr,加强后的两个 daily 测试都随之失败。
第 45 回合 变异测试:大小改回 0 时立即释放仍在用的缓冲区;ASan 没有发现,Release 构建下的测试以 SIGSEGV 崩溃。
第 49 回合 用 git stash 撤下改动,在基线上跑同样带头文件过滤的 clang-tidy,与改动后的告警逐条对比。
第 55 回合 终审 diff 时订正一处注释:MSVC 拒绝小于 2 字节的缓冲区,musl 则忽略小于 8 字节的缓冲区。
轨迹 1 的最终改动(节选)
diff --git a/include/spdlog/details/file_helper-inl.h b/include/spdlog/details/file_helper-inl.h
--- a/include/spdlog/details/file_helper-inl.h
+++ b/include/spdlog/details/file_helper-inl.h
@@ -12,20 +12,29 @@
#include <cerrno>
#include <cstdio>
+#include <limits>
#include <string>
#include <tuple>
SPDLOG_NAMESPACE_BEGIN
namespace details {
-SPDLOG_INLINE file_helper::file_helper(const file_event_handlers &event_handlers)
- : event_handlers_(event_handlers) {}
+SPDLOG_INLINE file_helper::file_helper(const file_event_handlers &event_handlers,
+ size_t buffer_size)
+ : event_handlers_(event_handlers) {
+ set_buffer_size(buffer_size);
+}
SPDLOG_INLINE file_helper::~file_helper() { close(); }
SPDLOG_INLINE void file_helper::open(const filename_t &fname, bool truncate) {
close();
filename_ = fname;
+ // no file uses the buffer now: size it for the new file.
+ const auto alloc_size = buffer_alloc_size_(buffer_size_);
+ if (buffer_.size() != alloc_size) {
+ buffer_ = std::vector<char>(alloc_size);
+ }
auto *mode = SPDLOG_FILENAME_T("ab");
auto *trunc_mode = SPDLOG_FILENAME_T("wb");
@@ -48,6 +57,13 @@ SPDLOG_INLINE void file_helper::open(const filename_t &fname, bool truncate) {
std::fclose(tmp);
}
if (!os::fopen_s(&fd_, fname, mode)) {
+ // setvbuf() must come before any other operation on the file, after_open's included.
+ if (!buffer_.empty() &&
+ std::setvbuf(fd_, buffer_.data(), _IOFBF, buffer_.size()) != 0) {
+ std::fclose(fd_);
+ fd_ = nullptr;
+ throw_spdlog_ex("Failed setting buffer of file " + os::filename_to_str(filename_));
+ }
if (event_handlers_.after_open) {
event_handlers_.after_open(filename_, fd_);
}
@@ -114,6 +130,36 @@ SPDLOG_INLINE size_t file_helper::size() const {
SPDLOG_INLINE const filename_t &file_helper::filename() const { return filename_; }
+SPDLOG_INLINE void file_helper::set_buffer_size(size_t buffer_size) {
+ // MSVC and the BSD libcs (e.g. macOS) limit setvbuf() to INT_MAX bytes
+ if (buffer_size > static_cast<size_t>((std::numeric_limits<int>::max)())) {
+ throw_spdlog_ex("Invalid file buffer size " + std::to_string(buffer_size) +
+ ": cannot exceed INT_MAX");
+ }
+ const auto alloc_size = buffer_alloc_size_(buffer_size);
+ if (fd_ != nullptr && alloc_size != 0 && alloc_size != buffer_.size()) {
+ std::vector<char> buffer(alloc_size);
+ flush();
+ // On a stream that was written to, glibc stays in write mode across setvbuf() with an
+ // empty put area, so the second write after it flushes the first one. A no-op seek leaves
+ // write mode (even where seeking fails, e.g. on pipes) and avoids that.
+ (void)std::fseek(fd_, 0, SEEK_CUR);
+ if (std::setvbuf(fd_, buffer.data(), _IOFBF, buffer.size()) != 0) {
+ throw_spdlog_ex("Failed setting buffer of file " + os::filename_to_str(filename_));
+ }
+ // the old buffer is released only now that the file no longer uses it.
+ buffer_.swap(buffer);
+ }
+ buffer_size_ = buffer_size;
+}
+
+SPDLOG_INLINE size_t file_helper::buffer_size() const { return buffer_size_; }
+
+// setvbuf() rejects buffers smaller than 2 bytes on MSVC and ignores those smaller than 8 on musl
+SPDLOG_INLINE size_t file_helper::buffer_alloc_size_(size_t buffer_size) {
+ return buffer_size == 0 || buffer_size >= 8 ? buffer_size : 8;
+}
+
//
// return file path and its extension:
//
diff --git a/include/spdlog/sinks/daily_file_sink.h b/include/spdlog/sinks/daily_file_sink.h
--- a/include/spdlog/sinks/daily_file_sink.h
+++ b/include/spdlog/sinks/daily_file_sink.h
@@ -62,6 +62,8 @@ struct daily_filename_format_calculator {
* Rotating file sink based on date.
* If truncate != false , the created file will be truncated.
* If max_files > 0, retain only the last max_files and delete previous.
+ * If buffer_size > 0, the files are fully buffered with a buffer of buffer_size bytes: output
+ * reaches a file when the buffer fills up, on flush or when the file is closed.
* Note that old log files from previous executions will not be deleted by this class,
* rotation and deletion is only applied while the program is running.
*/
@@ -74,11 +76,12 @@ public:
int rotation_minute,
bool truncate = false,
uint16_t max_files = 0,
- const file_event_handlers &event_handlers = {})
+ const file_event_handlers &event_handlers = {},
+ size_t buffer_size = 0)
: base_filename_(std::move(base_filename)),
rotation_h_(rotation_hour),
rotation_m_(rotation_minute),
- file_helper_{event_handlers},
+ file_helper_{event_handlers, buffer_size},
truncate_(truncate),
max_files_(max_files),
filenames_q_() {
diff --git a/include/spdlog/sinks/basic_file_sink-inl.h b/include/spdlog/sinks/basic_file_sink-inl.h
--- a/include/spdlog/sinks/basic_file_sink-inl.h
+++ b/include/spdlog/sinks/basic_file_sink-inl.h
@@ -16,8 +16,9 @@ namespace sinks {
template <typename Mutex>
SPDLOG_INLINE basic_file_sink<Mutex>::basic_file_sink(const filename_t &filename,
bool truncate,
- const file_event_handlers &event_handlers)
- : file_helper_{event_handlers} {
+ const file_event_handlers &event_handlers,
+ size_t buffer_size)
+ : file_helper_{event_handlers, buffer_size} {
file_helper_.open(filename, truncate);
}
…… 其余 678 行从略(共改动 11 个文件)
轨迹 2
- 回合
- 76
- 工具调用
- 90
- 用时(分钟)
- 42
- 改动文件
- 11
- 增 / 删行数
- +486 / −31
把缓冲区放在 details::file_helper,在 after_open 之前装上,流不再使用后才释放;大小为 1 或超过 INT_MAX 时抛异常,三种 sink 持锁转发。新增 11 个用例,其中一个在另一线程写日志时改大小;用变异、ASan 与 TSan 构建、C++11 仅头文件构建以及新用例连跑 30 轮来检验。
第 7 回合 读完三种 sink、file_helper 和相关测试后,决定把缓冲逻辑集中在 file_helper,经 setvbuf 实现,sink 只做转发。
第 15 回合 规定大小为 1 或超过 INT_MAX 时抛异常,因为 Windows C 运行库拒绝这些值时默认会终止程序。
第 29 回合 新增多线程测试:一个线程写 2,000 条日志,主线程在 0、1KB、2KB 间循环改大小,最后核对完整的文件内容。
第 47 回合 变异测试:让 file_helper::set_buffer_size() 在大小设为 0 时释放仍在用的缓冲区,文件内容损坏,3 个用例失败,含多线程用例。
第 59 回合 作为变异去掉 basic_file_sink::set_buffer_size() 里的锁,TSan 构建下的多线程测试中止。
第 72 回合 重新构建并运行 C++11 仅头文件测试,再把 11 个新用例连跑 30 遍,检查多线程和依赖时钟的用例是否稳定。
第 75 回合 把改动的 11 个文件作为一个提交,放在 file-sink-buffer-size 分支上。
轨迹 2 的最终改动(节选)
diff --git a/include/spdlog/details/file_helper-inl.h b/include/spdlog/details/file_helper-inl.h
--- a/include/spdlog/details/file_helper-inl.h
+++ b/include/spdlog/details/file_helper-inl.h
@@ -12,14 +12,18 @@
#include <cerrno>
#include <cstdio>
+#include <limits>
#include <string>
#include <tuple>
SPDLOG_NAMESPACE_BEGIN
namespace details {
-SPDLOG_INLINE file_helper::file_helper(const file_event_handlers &event_handlers)
- : event_handlers_(event_handlers) {}
+SPDLOG_INLINE file_helper::file_helper(const file_event_handlers &event_handlers,
+ size_t buffer_size)
+ : event_handlers_(event_handlers) {
+ set_buffer_size(buffer_size);
+}
SPDLOG_INLINE file_helper::~file_helper() { close(); }
@@ -48,6 +52,9 @@ SPDLOG_INLINE void file_helper::open(const filename_t &fname, bool truncate) {
std::fclose(tmp);
}
if (!os::fopen_s(&fd_, fname, mode)) {
+ if (buffer_size_ > 0) {
+ install_buffer_(buffer_size_);
+ }
if (event_handlers_.after_open) {
event_handlers_.after_open(filename_, fd_);
}
@@ -88,6 +95,7 @@ SPDLOG_INLINE void file_helper::close() {
std::fclose(fd_);
fd_ = nullptr;
+ buffer_.reset();
if (event_handlers_.after_close) {
event_handlers_.after_close(filename_);
@@ -114,6 +122,39 @@ SPDLOG_INLINE size_t file_helper::size() const {
SPDLOG_INLINE const filename_t &file_helper::filename() const { return filename_; }
+SPDLOG_INLINE void file_helper::set_buffer_size(size_t buffer_size) {
+ // accept the sizes setvbuf() supports everywhere: the Windows C runtime rejects sizes out of
+ // [2, INT_MAX] through its invalid parameter handler, which terminates the program by default.
+ if (buffer_size == 1 || buffer_size > static_cast<size_t>((std::numeric_limits<int>::max)())) {
+ throw_spdlog_ex("Invalid file buffer size " + std::to_string(buffer_size) +
+ ": must be 0 or between 2 and INT_MAX");
+ }
+ if (fd_ != nullptr && buffer_size > 0) {
+ // write out what the current buffer holds before replacing it, then take the stream out
+ // of writing mode with a seek: otherwise glibc doesn't set up the new buffer for writing,
+ // and writes out early the first output that goes into it. the seek fails on files that
+ // are not seekable (e.g. pipes), which doesn't matter here.
+ flush();
+ (void)std::fseek(fd_, 0, SEEK_CUR);
+ install_buffer_(buffer_size);
+ }
+ buffer_size_ = buffer_size;
+}
+
+SPDLOG_INLINE size_t file_helper::buffer_size() const { return buffer_size_; }
+
+// Make the open file fully buffered, with a new buffer of the given size.
+// The file must not hold buffered output: it was just opened, or flushed by set_buffer_size().
+SPDLOG_INLINE void file_helper::install_buffer_(size_t buffer_size) {
+ std::unique_ptr<char[]> buffer(new char[buffer_size]);
+ if (std::setvbuf(fd_, buffer.get(), _IOFBF, buffer_size) != 0) {
+ throw_spdlog_ex("Failed setting the buffer of file " + os::filename_to_str(filename_),
+ errno);
+ }
+ // the previous buffer, if any, is not used by the file anymore
+ buffer_ = std::move(buffer);
+}
+
//
// return file path and its extension:
//
diff --git a/include/spdlog/sinks/daily_file_sink.h b/include/spdlog/sinks/daily_file_sink.h
--- a/include/spdlog/sinks/daily_file_sink.h
+++ b/include/spdlog/sinks/daily_file_sink.h
@@ -74,11 +74,12 @@ public:
int rotation_minute,
bool truncate = false,
uint16_t max_files = 0,
- const file_event_handlers &event_handlers = {})
+ const file_event_handlers &event_handlers = {},
+ size_t buffer_size = 0)
: base_filename_(std::move(base_filename)),
rotation_h_(rotation_hour),
rotation_m_(rotation_minute),
- file_helper_{event_handlers},
+ file_helper_{event_handlers, buffer_size},
truncate_(truncate),
max_files_(max_files),
filenames_q_() {
@@ -102,6 +103,17 @@ public:
return file_helper_.filename();
}
+ // see details::file_helper::set_buffer_size()
+ void set_buffer_size(size_t buffer_size) {
+ std::lock_guard<Mutex> lock(base_sink<Mutex>::mutex_);
+ file_helper_.set_buffer_size(buffer_size);
+ }
+
+ size_t buffer_size() {
+ std::lock_guard<Mutex> lock(base_sink<Mutex>::mutex_);
+ return file_helper_.buffer_size();
+ }
+
protected:
void sink_it_(const details::log_msg &msg) override {
auto time = msg.time;
diff --git a/include/spdlog/sinks/basic_file_sink-inl.h b/include/spdlog/sinks/basic_file_sink-inl.h
--- a/include/spdlog/sinks/basic_file_sink-inl.h
+++ b/include/spdlog/sinks/basic_file_sink-inl.h
@@ -16,8 +16,9 @@ namespace sinks {
template <typename Mutex>
SPDLOG_INLINE basic_file_sink<Mutex>::basic_file_sink(const filename_t &filename,
bool truncate,
- const file_event_handlers &event_handlers)
- : file_helper_{event_handlers} {
+ const file_event_handlers &event_handlers,
+ size_t buffer_size)
+ : file_helper_{event_handlers, buffer_size} {
…… 其余 664 行从略(共改动 11 个文件)
样例 5 / 7
新增 Endpoint(),预知目标端点
为 Ctx 和 Req 增加 Endpoint(),让中间件在 Next() 前得知将运行的端点(无则为 nil),并补充文档。结果须与路由器一致且无副作用。
题面(Agent 收到的原文)
Task description
Middleware that authorizes or audits by route (RBAC keyed on Route.Name, audit logs keyed on the route pattern) has no way to learn which route will handle a request before it calls c.Next(): until then c.Route() is the middleware's own route.
Add Endpoint() *Route to Ctx, and mirror it on Req the way Route() is mirrored. It returns the endpoint (a route registered with Get, Post, Add, ..., never one registered with Use) that Next() will reach from the current position in the handler chain. It is a pure look-ahead: it runs nothing and leaves the request's state alone (handler position, Route(), Matched(), route params). It never names an endpoint the chain has already passed, and once the chain is on an endpoint (any of its handlers) it returns that endpoint.
The answer has to agree with the router: whatever Endpoint() names is the endpoint whose handlers the router then runs, and it returns nil whenever the router will run no endpoint for the request (404, 405, ...). Routes registered through Domain check the hostname inside those handlers, so report them as the router reaches them; host-aware filtering is out of scope.
Document the method in the Ctx API reference, docs/api/ctx.md.
题面后半:测试、lint 和代码风格规范
Test guidelines
- Tests are plain Go tests in package
fibernext to the code:Ctxbehaviour is tested inctx_test.go, router behaviour inrouter_test.go. Add your tests there rather than in new files. - Follow the surrounding tests: drive requests through
app.Test(httptest.NewRequest(...)), assert with testify'srequire, and close response bodies. - Per AGENTS.md, call
t.Parallel()at the start of every test and subtest. - Cover the new behaviour, including the cases where no endpoint will run; feature PRs without unit tests are not merged (
.github/CONTRIBUTING.md). - Run the relevant tests with
go test -race -count=1 -run 'Endpoint' .and the whole suite withmake test(gotestsum with-race -count=1 -shuffle=on).
Lint guidelines
make lint(golangci-lint v2.12.2 with the repository's.golangci.yml, which also enforces gofmt/gofumpt/goimports) must report 0 issues. Mind the stricterrchecksettings described in AGENTS.md: never discard an error or a type-assertion result with_.- Format with
make formatorgolangci-lint fmt;golangci-lint fmt --diffmust print nothing. go vet ./...must pass, and Markdown must passmake markdown(markdownlint-cli2 "**/*.md" "#vendor"). The govulncheck step ofmake auditneeds network access and cannot run here.ctx_interface_gen.goandreq_interface_gen.goare generated by ifacemaker from the//go:generatedirectives inctx.go,req.goandres.go: after adding methods, runmake generate(orgo generate ./...) instead of editing them by hand.make betteralignis not clean at this commit (it reorders deliberately ordered structs inpath.goandrouter.go); do not commit its output.
Style guidelines
- Match the surrounding code: every exported method has a doc comment (ifacemaker copies it into the generated interfaces); reuse the package's existing helpers and state instead of re-implementing their logic; prefer
github.com/gofiber/utils/v2helpers where they fit. - The router loop and the per-request setup are hot paths (AGENTS.md: "Protect hot paths from regressions"): the look-ahead must cost nothing for requests that never call it. Leave
App.next/nextCustom, the request handlers, context acquire/Reset/release,Next/RestartRoutingand the path and method overrides as they are, apart from resetting a field. docs/api/ctx.mddocuments each method in its own###section, in alphabetical order within its part of the page, withSignatureandExamplecode blocks.- Keep the change focused on this feature: put the code in the existing files it extends (
ctx.go,req.go, orrouter.gofor router-side helpers) rather than new source files, make no drive-by refactors, and leave other docs pages alone beyond the API reference (a line in the new-methods list ofdocs/whats_new.mdis fine). - You are already on the correct base commit. Create your branch from this commit. Do not rebase or start from master, main, or any other branch.
评分细则
| # | 细则 | 类别 | 检查方式 | 阻断项 | 权重 |
|---|---|---|---|---|---|
| C1 | Ctx / Req 返回前方或当前端点,404/405 为 nil,无副作用 |
行为正确 | 隐藏测试 | 是 | 18% |
| C2 | 与路由器一致,含错误重放、覆盖与 RestartRouting、已越过路由 |
行为正确 | 隐藏测试 | 是 | 18% |
| C3 | 原有测试套件仍然通过 | 不破坏已有行为 | 项目命令 | 是 | 8% |
| C4 | golangci-lint、格式化、go vet 与 markdownlint 均无问题 |
构建/lint/格式 | 项目命令 | 是 | 5% |
| C5 | 改动只落在该功能所属的文件中 | 改动范围 | 改动范围检查 | 是 | 5% |
| C6 | 附带 Endpoint 测试且全部通过 |
测试质量 | 改前失败、改后通过 | 是 | 7% |
| C7 | Endpoint 写入 ctx.md 文档 |
代码质量与规范 | 项目命令 | 是 | 5% |
| C8 | 生成的接口文件与 go generate 一致 |
构建/lint/格式 | 项目命令 | — | 3% |
| C9 | 新测试与子测试首先调用 t.Parallel() |
测试质量 | 隐藏测试 | — | 2% |
| C10 | Endpoint 文档章节按字母序排列 |
代码质量与规范 | 项目命令 | — | 2% |
| C11 | 路由循环、请求处理器和上下文初始化不变,复位字段除外 | 不破坏已有行为 | 项目命令 | 是 | 6% |
| C12 | 前瞻复用路由器匹配逻辑,不另写匹配器 | 代码质量与规范 | 评审判定 | — | 6% |
| C13 | 文档含签名、中间件示例、nil 情形及与 Route 的区别 |
代码质量与规范 | 评审判定 | — | 4% |
| C14 | 新增测试锁定朴素前瞻与路由器分歧的情形 | 测试质量 | 评审判定 | — | 5% |
| C15 | 导出方法文档注释写明契约;无调试输出、叙述改动的注释 | 代码质量与规范 | 评审判定 | — | 4% |
| C16 | 非测试改动规模适度 | 改动范围 | 改动范围检查 | — | 2% |
评分器对随附解法的判定
| 解法 | 份数 | 得分 | 不计阻断项的加权得分 |
|---|---|---|---|
| 参考解 | 1 | 100 | 100 |
| 其他正确解法 | 2 | 100 | 100 |
| 接近完成解 | 1 | 98 | 98 |
| 部分完成解 | 3 | 0 | 28–82 |
| 取巧解 | 9 | 0 | 70–95 |
| 未改动的代码 | 1 | 0 | 29 |
满分 100。任一阻断项不过,得分即为 0。
轨迹 1
- 回合
- 113
- 工具调用
- 128
- 用时(分钟)
- 45
- 改动文件
- 9
- 增 / 删行数
- +938 / −1
以新增的 nextEndpoint 实现 Endpoint():复用 Route.match 从当前位置扫描路由桶,参数写入临时数组。测试在上下文类型、路由配置与参数中间件的 32 种组合下与路由器对照;跑了 make test,并补写文档。
第 19 回合 发现 serverErrorHandler 只重放中间件,此时前瞻须返回 nil。
第 21 回合 发现 getMatch 按 path 校验路由约束,因此前瞻改用自定义上下文的 Path()。
第 35 回合 在仓库外写临时模块,确认无处理函数的分组 Use() 会终止处理链,其后的端点不会运行。
第 38 回合 在 router.go 新增 nextEndpoint,沿用路由扫描但不运行处理函数。
第 100 回合 与基线提交对比约 4,700 个函数的反汇编,确认现有函数的指令除地址外均未改变。
第 108 回合 用 make test 跑完整套件:5,371 个测试,无失败。
第 110 回合 分两次本地提交:先提交功能与测试,再提交文档。
轨迹 1 的最终改动(节选)
diff --git a/router.go b/router.go
--- a/router.go
+++ b/router.go
@@ -737,6 +737,60 @@ func (app *App) nextCustom(c CustomCtx) (bool, error) {
return false, ErrNotFound
}
+// nextEndpoint returns the endpoint next would run for c if the chain went on
+// from its current position, or nil when it would run none. It scans the bucket
+// next scans, with the same filters and the same use of the SkipUnmatchedRoutes
+// lookahead, but runs nothing: the middleware it passes is taken to call Next,
+// and parameters are captured into a scratch array instead of c.values, so the
+// only thing it leaves on c is the slash count pathSlashCount caches. path is
+// the path next matches against; nextCustom reads it through the custom
+// context's Path.
+func (app *App) nextEndpoint(c *DefaultCtx, path string) *Route {
+ methodInt := c.methodInt
+ // Middleware-only traversal reaches no endpoint, and a method outside
+ // RequestMethods (as the server error handler can see) has no tree
+ if c.shouldSkipNonUseRoutes || methodInt < 0 || methodInt >= len(app.treeIndex) {
+ return nil
+ }
+
+ detectionPath := utils.UnsafeString(c.detectionPath)
+ tree := app.treeIndex[methodInt].lookup(c.treePathHash)
+ head := pathHeadWord(detectionPath)
+ pathSlashes := c.pathSlashCount(app)
+ firstMatchIndex := c.firstMatchIndex
+ var values [maxParams]string
+
+ for indexRoute := max(c.indexRoute+1, 0); indexRoute < len(tree); indexRoute++ {
+ route := tree[indexRoute]
+ if route.prefixRejects(head) || route.mount {
+ continue
+ }
+
+ // next takes the endpoint the lookahead pre-resolved without matching it again
+ if firstMatchIndex >= 0 && !route.use {
+ if indexRoute < firstMatchIndex {
+ continue
+ }
+ if indexRoute == firstMatchIndex && !app.skip.hasParamUse {
+ return route
+ }
+ }
+
+ if !route.match(detectionPath, path, &values, pathSlashes) {
+ continue
+ }
+ if !route.use {
+ return route
+ }
+ // next stops at a matching route that has no handler to run
+ if len(route.Handlers) == 0 {
+ return nil
+ }
+ }
+
+ return nil
+}
+
func (app *App) defaultRequestHandler(rctx *fasthttp.RequestCtx) {
ctx, ok := app.acquireDefaultCtx(rctx)
if !ok {
diff --git a/ctx.go b/ctx.go
--- a/ctx.go
+++ b/ctx.go
@@ -387,6 +387,25 @@ func (c *DefaultCtx) routeFallback() *Route {
}
}
+// Endpoint returns the endpoint that Next will reach from the current position
+// in the handler chain: a route registered with Get, Post, Add and the like,
+// never one registered with Use. It returns nil when the router will run no
+// endpoint for the request (404, 405, ...), and once the chain is on an
+// endpoint, in any of its handlers, it returns that endpoint. It only looks
+// ahead: it runs no handler and leaves the handler position, Route, Matched and
+// the route params as they are. Routes registered through Domain check the
+// hostname inside their handlers, so they are reported as the router reaches them.
+func (c *DefaultCtx) Endpoint() *Route {
+ if c.route != nil && !c.route.use {
+ return c.route
+ }
+ if c.handlerCtx != nil {
+ // nextCustom matches against the custom context's Path
+ return c.app.nextEndpoint(c, c.handlerCtx.Path())
+ }
+ return c.app.nextEndpoint(c, utils.UnsafeString(c.path))
+}
+
// FullPath returns the matched route path, including any group prefixes.
func (c *DefaultCtx) FullPath() string {
return c.Route().Path
diff --git a/req.go b/req.go
--- a/req.go
+++ b/req.go
@@ -1333,6 +1333,18 @@ func (r *DefaultReq) Route() *Route {
return r.c.Route()
}
+// Endpoint returns the endpoint that Next will reach from the current position
+// in the handler chain: a route registered with Get, Post, Add and the like,
+// never one registered with Use. It returns nil when the router will run no
+// endpoint for the request (404, 405, ...), and once the chain is on an
+// endpoint, in any of its handlers, it returns that endpoint. It only looks
+// ahead: it runs no handler and leaves the handler position, Route, Matched and
+// the route params as they are. Routes registered through Domain check the
+// hostname inside their handlers, so they are reported as the router reaches them.
+func (r *DefaultReq) Endpoint() *Route {
+ return r.c.Endpoint()
+}
+
// Subdomains returns a slice of subdomains from the host, excluding the last `offset` components.
// If the offset is negative or exceeds the number of subdomains, an empty slice is returned.
// If the offset is zero every label (no trimming) is returned.
diff --git a/ctx_interface_gen.go b/ctx_interface_gen.go
--- a/ctx_interface_gen.go
+++ b/ctx_interface_gen.go
@@ -114,6 +114,15 @@ type Ctx interface {
// fallback for unregistered methods) so Route and Method always agree.
// Never inlined: inlining it would push Route over the inlining budget.
routeFallback() *Route
+ // Endpoint returns the endpoint that Next will reach from the current position
+ // in the handler chain: a route registered with Get, Post, Add and the like,
+ // never one registered with Use. It returns nil when the router will run no
+ // endpoint for the request (404, 405, ...), and once the chain is on an
+ // endpoint, in any of its handlers, it returns that endpoint. It only looks
+ // ahead: it runs no handler and leaves the handler position, Route, Matched and
+ // the route params as they are. Routes registered through Domain check the
+ // hostname inside their handlers, so they are reported as the router reaches them.
…… 其余 920 行从略(共改动 9 个文件)
轨迹 2
- 回合
- 104
- 工具调用
- 122
- 用时(分钟)
- 73
- 改动文件
- 9
- 增 / 删行数
- +1,025 / −1
为 Ctx / Req 新增 Endpoint(),由只读的 peekEndpoint 复用 Route.match 重放 next 的扫描。一个测试在 32 种应用配置下与路由器比对;运行 make test 与 lint,并编写文档。
第 10 回合 发现方法覆盖保留 indexRoute,next 从旧下标之后扫描新方法的树,前瞻照做。
第 15 回合 用临时测试确认无处理函数的 grp.Use() 会终止处理链,朴素扫描会与路由器不一致。
第 31 回合 新增 peekEndpoint 作为 next 的只读版本,参数写入临时数组。
第 35 回合 用 go build -gcflags='-m' 检查临时参数数组是否逃逸到堆上。
第 91 回合 运行 make lint、go vet、make markdown,均无问题。
第 100 回合 最后一次修改测试文件后重跑 make test:5,377 个测试无失败。
第 103 回合 把 9 个改动文件一次性提交到本地。
轨迹 2 的最终改动(节选)
diff --git a/router.go b/router.go
--- a/router.go
+++ b/router.go
@@ -737,6 +737,66 @@ func (app *App) nextCustom(c CustomCtx) (bool, error) {
return false, ErrNotFound
}
+// peekEndpoint is the read-only twin of next: it replays next's scan of the
+// route bucket from c's position and returns the endpoint next selects once
+// each middleware matched on the way has called Next. It returns nil wherever
+// next selects none: a 404 or 405, a middleware-only pass
+// (shouldSkipNonUseRoutes), or a matched middleware route without handlers,
+// which ends the chain. path is what next or nextCustom slices parameters from.
+//
+// Nothing on c changes: parameters are matched into a scratch array, and
+// pathSlashCount only caches the count next would compute itself. Keep this in
+// step with next and nextCustom; Test_Endpoint_AgreesWithRouter and
+// Test_Endpoint_MirrorsNext compare them.
+func (app *App) peekEndpoint(c *DefaultCtx, path string) *Route {
+ methodInt := c.methodInt
+ // A middleware-only pass runs no endpoint, and an unregistered method is
+ // answered with 501 before routing starts.
+ if c.shouldSkipNonUseRoutes || methodInt < 0 || methodInt >= len(app.treeIndex) {
+ return nil
+ }
+
+ detectionPath := utils.UnsafeString(c.detectionPath)
+ tree := app.treeIndex[methodInt].lookup(c.treePathHash)
+ head := pathHeadWord(detectionPath)
+ pathSlashes := c.pathSlashCount(app)
+ firstMatchIndex := c.firstMatchIndex
+ var values [maxParams]string
+
+ for indexRoute := max(c.indexRoute+1, 0); indexRoute < len(tree); indexRoute++ {
+ route := tree[indexRoute]
+ if route.prefixRejects(head) || route.mount {
+ continue
+ }
+
+ // next skips the endpoints the SkipUnmatchedRoutes lookahead ruled out
+ // and takes its pick without matching again, as long as it may reuse
+ // the lookahead's params.
+ if firstMatchIndex >= 0 && !route.use {
+ if indexRoute < firstMatchIndex {
+ continue
+ }
+ if indexRoute == firstMatchIndex && !app.skip.hasParamUse {
+ return route
+ }
+ }
+
+ if !route.match(detectionPath, path, &values, pathSlashes) {
+ continue
+ }
+ if !route.use {
+ return route
+ }
+ // Matched middleware passes the request on, but next stops the chain
+ // at a middleware route without handlers.
+ if len(route.Handlers) == 0 {
+ return nil
+ }
+ }
+
+ return nil
+}
+
func (app *App) defaultRequestHandler(rctx *fasthttp.RequestCtx) {
ctx, ok := app.acquireDefaultCtx(rctx)
if !ok {
diff --git a/ctx.go b/ctx.go
--- a/ctx.go
+++ b/ctx.go
@@ -387,6 +387,25 @@ func (c *DefaultCtx) routeFallback() *Route {
}
}
+// Endpoint returns the endpoint, a route registered with Get, Post, Add and
+// the like but never with Use, that Next will reach from the current position
+// in the handler chain. Once the chain is on an endpoint, it returns that
+// endpoint. It returns nil when the router will run no endpoint for the
+// request, such as for a 404 or 405. Endpoint only looks ahead: it runs no
+// handler and leaves the handler position, Route, Matched and the route
+// params untouched.
+func (c *DefaultCtx) Endpoint() *Route {
+ if route := c.route; route != nil && !route.use && c.indexHandler < len(route.Handlers) {
+ return route
+ }
+ path := utils.UnsafeString(c.path)
+ if c.handlerCtx != nil {
+ // nextCustom matches against the custom context's Path
+ path = c.handlerCtx.Path()
+ }
+ return c.app.peekEndpoint(c, path)
+}
+
// FullPath returns the matched route path, including any group prefixes.
func (c *DefaultCtx) FullPath() string {
return c.Route().Path
diff --git a/req.go b/req.go
--- a/req.go
+++ b/req.go
@@ -1333,6 +1333,17 @@ func (r *DefaultReq) Route() *Route {
return r.c.Route()
}
+// Endpoint returns the endpoint, a route registered with Get, Post, Add and
+// the like but never with Use, that Next will reach from the current position
+// in the handler chain. Once the chain is on an endpoint, it returns that
+// endpoint. It returns nil when the router will run no endpoint for the
+// request, such as for a 404 or 405. Endpoint only looks ahead: it runs no
+// handler and leaves the handler position, Route, Matched and the route
+// params untouched.
+func (r *DefaultReq) Endpoint() *Route {
+ return r.c.Endpoint()
+}
+
// Subdomains returns a slice of subdomains from the host, excluding the last `offset` components.
// If the offset is negative or exceeds the number of subdomains, an empty slice is returned.
// If the offset is zero every label (no trimming) is returned.
…… 其余 1,010 行从略(共改动 9 个文件)
样例 6 / 7
新增“?”守卫符号与 guards 设置
为逐行配方新增 ? 守卫符号,由布尔设置 guards 开启:退出码 0 继续,1 则不报错地结束当前配方,其余报错;? 与 - 同用为编译错误。新符号与设置均需写入 README。
题面(Agent 收到的原文)
Task description
Recipe lines can already be prefixed with @ (toggle echoing) and - (ignore a failing exit status). Add a third sigil, ?, for guard lines: checks that end the current recipe early, without an error, when they don't pass, e.g. ?[[ -f config.toml ]] at the top of a recipe.
Since existing justfiles may contain commands that start with ?, the sigil is opt-in through a new boolean setting, guards (set guards, set guards := true|false, default false). Without it, a leading ? stays part of the command, exactly as today.
With guards enabled, a line in a linewise recipe that starts with the ? sigil is handled according to its exit status:
0: the recipe continues as normal.1: the rest of the current recipe is skipped and no error is reported; other recipes (dependents, subsequents, further recipes on the command line) keep running.- Anything else is reserved for future use and is an error:
Guard line in recipe `RECIPE` on line N returned reserved exit code CODE, where N is the guard's line number in the justfile.
Like - and @, ? is stripped from the command before it is echoed and run, and it can be combined with @ in either order. Using the ? and - sigils together on the same line is a compile error: The guard `?` and infallible `-` sigils may not be used together. Shebang and [script] recipes are unaffected.
Document the new sigil and setting in the README.
题面后半:测试、lint 和代码风格规范
Test guidelines
- Integration tests live in
tests/, one module per feature (seetests/ignore_comments.rsortests/line_prefixes.rs).Cargo.tomlsetsautotests = falseand builds a singleintegrationtest binary fromtests/lib.rs, so a new test file must be registered there withmod <name>;or it is silently not compiled. - Write tests with the
Testbuilder fromtests/test.rs:Test::new().justfile("...").args([...]).stdout("...").stderr("...").success()(or.failure()/.status(n)). It runsjust --shell bash, unindents the justfile, and on success also checks that the justfile round-trips through--dump. - Cover the new behaviour with tests, including its error cases.
- Run one module with
cargo test --all -- <module>::(e.g.cargo test --all -- line_prefixes::), and the whole suite withcargo test --allbefore you finish. Update existing tests whose expectations legitimately change; don't delete or#[ignore]them.
Lint guidelines
CI's lint and MSRV jobs must pass. All of these work offline here:
cargo clippy --all --all-targets -- --deny warnings.Cargo.tomldeniesclippy::allandclippy::pedantic, andclippy.tomlmakes clippy enforce alphabetical order of enum variants, struct fields and trait items.cargo fmt --all -- --check./bin/forbid, which rejects the wordsdbg!,fixme,todoandxxx(in any case) in every file exceptCHANGELOG.md, comments and test strings included.shellcheck www/install.shRUSTFLAGS='--deny warnings' cargo +1.82.0 check(the MSRV is Rust 1.82; its "unknown lint: mismatched_lifetime_syntaxes" warning is expected).
Style guidelines
- Follow the existing patterns for this kind of change: how a boolean setting such as
ignore-commentsis wired through the codebase, how recipe-line prefixes are handled when linewise recipes run, and how errors and compile errors are defined and displayed. Prefer generalizing an existing helper over adding logic that duplicates it. - Keep enum variants, struct fields and match arms in the alphabetical order used throughout.
- Comments are sparse in this codebase; don't add ones that narrate the code, and don't leave commented-out code or debugging output behind.
CHANGELOG.mdis written at release time; don't edit it.- You are already on the correct base commit. Create your branch from this commit. Do not rebase or start from master, main, or any other branch.
评分细则
| # | 细则 | 类别 | 检查方式 | 阻断项 | 权重 |
|---|---|---|---|---|---|
| C1 | 守卫退出码:0 继续,1 只结束本配方,其余失败 | 行为正确 | 隐藏测试 | 是 | 20% |
| C2 | ? 仅在开启设置时为符号,- / @ 照旧 |
不破坏已有行为 | 隐藏测试 | 是 | 12% |
| C3 | 完整测试套件通过,原有测试不改也通过 | 不破坏已有行为 | 项目命令 | 是 | 12% |
| C4 | 新增守卫集成测试:改动前失败、改动后通过 | 测试质量 | 改前失败、改后通过 | 是 | 10% |
| C5 | ?+- 报指定编译错误,仅当 ? 是符号 |
行为正确 | 隐藏测试 | — | 6% |
| C6 | 遇保留退出码时,报错信息含配方名与守卫行号 | 行为正确 | 隐藏测试 | — | 4% |
| C7 | ? 可与 @ 任意顺序组合,回显时去掉 |
行为正确 | 隐藏测试 | — | 5% |
| C8 | clippy(警告视为错误)、禁用词检查、shellcheck 通过 | 构建/lint/格式 | 项目命令 | — | 4% |
| C9 | 代码已按 rustfmt 格式化 |
构建/lint/格式 | 项目命令 | — | 2% |
| C10 | Rust 1.82(MSRV)下可编译,警告视为错误 | 构建/lint/格式 | 项目命令 | — | 2% |
| C11 | 执行与编译期检查共用同一行首符号解析 | 代码质量与规范 | 评审判定 | — | 5% |
| C12 | 失败均为专用错误变体,由现有显示代码输出 | 代码质量与规范 | 评审判定 | — | 4% |
| C13 | 每种守卫行为都有 Test 构建器集成测试 |
测试质量 | 评审判定 | — | 4% |
| C14 | README 准确写明 ? 及 guards |
代码质量与规范 | 评审判定 | — | 5% |
| C15 | 改动限于源码、测试与文档,规模适中 | 改动范围 | 改动范围检查 | — | 3% |
| C16 | 无残留调试输出、注释掉的代码或复述代码的注释 | 构建/lint/格式 | 评审判定 | — | 2% |
评分器对随附解法的判定
| 解法 | 份数 | 得分 | 不计阻断项的加权得分 |
|---|---|---|---|
| 参考解 | 1 | 100 | 100 |
| 其他正确解法 | 2 | 100 | 100 |
| 接近完成解 | 2 | 81–95 | 81–95 |
| 部分完成解 | 2 | 0 | 22–57 |
| 取巧解 | 6 | 0 | 35–86 |
| 未改动的代码 | 1 | 0 | 23 |
满分 100。任一阻断项不过,得分即为 0。
轨迹 1
- 回合
- 93
- 工具调用
- 129
- 用时(分钟)
- 56
- 改动文件
- 17
- 增 / 删行数
- +574 / −16
用单一 Line::sigils 取代前缀判断,配方分析移到设置求值后以检查 ? / -;遇到保留退出码时报错,just 以该守卫的退出码退出。编写 23 个集成测试与 README 说明,运行完整测试、lint、MSRV 检查,手动验证中断处理。
第 2 回合 查遍 ignore-comments 的引用,再读 src/line.rs 的前缀判断。
第 28 回合 决定用一个 Line 辅助函数读行首不重复的符号,开启 guards 时才识别 ?。
第 39 回合 权衡报错顺序后,转向在设置求值后调用 analyze_recipe 以检查 ? / -。
第 67 回合 让守卫收到 TERM 时以 1 退出,运行中发 SIGTERM:报中断,后续配方未运行。
第 86 回合 运行 generate-book,确认设置表的 #guard-lines 链接有效。
第 89 回合 验证根模块的 set guards 不波及子模块,子模块编译错误指向 sub.just。
第 92 回合 删除临时目录和生成的 book/ 源文件,再用 git status 确认只剩预期改动。
轨迹 1 的最终改动(节选)
diff --git a/src/line.rs b/src/line.rs
--- a/src/line.rs
+++ b/src/line.rs
@@ -33,19 +33,37 @@ impl Line<'_> {
self.fragments.is_empty()
}
- pub(crate) fn is_infallible(&self) -> bool {
- self
- .first()
- .is_some_and(|text| text.starts_with('-') || text.starts_with("@-"))
+ pub(crate) fn is_guard(&self, guards: bool) -> bool {
+ self.sigils(guards).contains('?')
}
- pub(crate) fn is_quiet(&self) -> bool {
- self
- .first()
- .is_some_and(|text| text.starts_with('@') || text.starts_with("-@"))
+ pub(crate) fn is_infallible(&self, guards: bool) -> bool {
+ self.sigils(guards).contains('-')
+ }
+
+ pub(crate) fn is_quiet(&self, guards: bool) -> bool {
+ self.sigils(guards).contains('@')
}
pub(crate) fn is_shebang(&self) -> bool {
self.first().is_some_and(|text| text.starts_with("#!"))
}
+
+ fn sigils(&self, guards: bool) -> &str {
+ let text = self.first().unwrap_or_default();
+
+ let mut sigils = 0;
+
+ for c in text.chars() {
+ let sigil = c == '-' || c == '@' || (guards && c == '?');
+
+ if !sigil || text[..sigils].contains(c) {
+ break;
+ }
+
+ sigils += 1;
+ }
+
+ &text[..sigils]
+ }
}
diff --git a/src/recipe.rs b/src/recipe.rs
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -245,6 +245,7 @@ impl<'src, D> Recipe<'src, D> {
mut evaluator: Evaluator<'src, 'run>,
) -> RunResult<'src, ()> {
let config = &context.config;
+ let guards = context.module.settings.guards;
let mut lines = self.body.iter().peekable();
let mut line_number = self.line_number() + 1;
@@ -254,8 +255,9 @@ impl<'src, D> Recipe<'src, D> {
}
let mut evaluated = String::new();
let mut continued = false;
- let quiet_line = lines.peek().is_some_and(|line| line.is_quiet());
- let infallible_line = lines.peek().is_some_and(|line| line.is_infallible());
+ let guard_line = lines.peek().is_some_and(|line| line.is_guard(guards));
+ let quiet_line = lines.peek().is_some_and(|line| line.is_quiet(guards));
+ let infallible_line = lines.peek().is_some_and(|line| line.is_infallible(guards));
let comment_line = context.module.settings.ignore_comments
&& lines.peek().is_some_and(|line| line.is_comment());
@@ -283,7 +285,7 @@ impl<'src, D> Recipe<'src, D> {
let mut command = evaluated.as_str();
- let sigils = usize::from(infallible_line) + usize::from(quiet_line);
+ let sigils = usize::from(guard_line) + usize::from(infallible_line) + usize::from(quiet_line);
command = &command[sigils..];
@@ -349,10 +351,24 @@ impl<'src, D> Recipe<'src, D> {
let (result, caught) = cmd.status_guard();
+ let mut guard_failed = false;
+
match result {
Ok(exit_status) => {
if let Some(code) = exit_status.code() {
- if code != 0 && !infallible_line {
+ if guard_line {
+ match code {
+ 0 => {}
+ 1 => guard_failed = true,
+ _ => {
+ return Err(Error::GuardCode {
+ code,
+ line_number,
+ recipe: self.name(),
+ });
+ }
+ }
+ } else if code != 0 && !infallible_line {
return Err(Error::Code {
recipe: self.name(),
line_number: Some(line_number),
@@ -381,6 +397,10 @@ impl<'src, D> Recipe<'src, D> {
return Err(Error::Interrupted { signal });
}
}
+
+ if guard_failed {
+ return Ok(());
+ }
}
}
diff --git a/src/error.rs b/src/error.rs
--- a/src/error.rs
+++ b/src/error.rs
@@ -120,6 +120,11 @@ pub(crate) enum Error<'src> {
GetConfirmation {
io_error: io::Error,
},
+ GuardCode {
+ code: i32,
+ line_number: usize,
+ recipe: &'src str,
+ },
…… 其余 731 行从略(共改动 17 个文件)
轨迹 2
- 回合
- 81
- 工具调用
- 92
- 用时(分钟)
- 48
- 改动文件
- 17
- 增 / 删行数
- +520 / −15
用单一 Line::sigils 扫描取代前缀判断,把配方分析移到设置求值后以检查 ? / -;遇到保留退出码时报错,just 以状态 1 退出。编写 20 个集成测试与 README 说明,运行基线与完整测试、lint、MSRV,并与基线版逐一对比输出。
第 12 回合 读 run_recipe 后判断:守卫失败时提前返回 Ok(()),其他配方照常运行。
第 32 回合 先构建并运行 cargo test --all 记录基线,再动代码。
第 35 回合 读 README 的信号处理一节与 src/command_ext.rs 后决定:捕获信号时,守卫以 1 退出也须报中断。
第 48 回合 构建基线版逐一对比:未设 guards 时 720 种前缀/命令/静默组合一致。
第 57 回合 用 POSIX test -f 写 README 示例以适配默认 sh,并运行核对输出。
第 73 回合 发现 Rust 1.82(MSRV)检查结束得过快,用 touch 强制重新检查,只出现预期的那条 lint 警告。
第 80 回合 重跑全部测试、lint 与 MSRV 检查后,在新分支 guard-sigil 上提交。
轨迹 2 的最终改动(节选)
diff --git a/src/line.rs b/src/line.rs
--- a/src/line.rs
+++ b/src/line.rs
@@ -33,19 +33,39 @@ impl Line<'_> {
self.fragments.is_empty()
}
- pub(crate) fn is_infallible(&self) -> bool {
- self
- .first()
- .is_some_and(|text| text.starts_with('-') || text.starts_with("@-"))
+ pub(crate) fn is_guard(&self, guards: bool) -> bool {
+ self.sigils(guards).contains('?')
}
- pub(crate) fn is_quiet(&self) -> bool {
- self
- .first()
- .is_some_and(|text| text.starts_with('@') || text.starts_with("-@"))
+ pub(crate) fn is_infallible(&self, guards: bool) -> bool {
+ self.sigils(guards).contains('-')
+ }
+
+ pub(crate) fn is_quiet(&self, guards: bool) -> bool {
+ self.sigils(guards).contains('@')
}
pub(crate) fn is_shebang(&self) -> bool {
self.first().is_some_and(|text| text.starts_with("#!"))
}
+
+ /// Leading `@`, `-`, and, if `guards` is set, `?` sigils, each of which may
+ /// appear at most once
+ fn sigils(&self, guards: bool) -> &str {
+ let text = self.first().unwrap_or_default();
+
+ let mut end = 0;
+
+ for c in text.chars() {
+ let sigil = c == '@' || c == '-' || (guards && c == '?');
+
+ if !sigil || text[..end].contains(c) {
+ break;
+ }
+
+ end += c.len_utf8();
+ }
+
+ &text[..end]
+ }
}
diff --git a/src/recipe.rs b/src/recipe.rs
--- a/src/recipe.rs
+++ b/src/recipe.rs
@@ -245,6 +245,7 @@ impl<'src, D> Recipe<'src, D> {
mut evaluator: Evaluator<'src, 'run>,
) -> RunResult<'src, ()> {
let config = &context.config;
+ let guards = context.module.settings.guards;
let mut lines = self.body.iter().peekable();
let mut line_number = self.line_number() + 1;
@@ -254,8 +255,9 @@ impl<'src, D> Recipe<'src, D> {
}
let mut evaluated = String::new();
let mut continued = false;
- let quiet_line = lines.peek().is_some_and(|line| line.is_quiet());
- let infallible_line = lines.peek().is_some_and(|line| line.is_infallible());
+ let quiet_line = lines.peek().is_some_and(|line| line.is_quiet(guards));
+ let infallible_line = lines.peek().is_some_and(|line| line.is_infallible(guards));
+ let guard_line = lines.peek().is_some_and(|line| line.is_guard(guards));
let comment_line = context.module.settings.ignore_comments
&& lines.peek().is_some_and(|line| line.is_comment());
@@ -283,7 +285,7 @@ impl<'src, D> Recipe<'src, D> {
let mut command = evaluated.as_str();
- let sigils = usize::from(infallible_line) + usize::from(quiet_line);
+ let sigils = usize::from(guard_line) + usize::from(infallible_line) + usize::from(quiet_line);
command = &command[sigils..];
@@ -349,10 +351,24 @@ impl<'src, D> Recipe<'src, D> {
let (result, caught) = cmd.status_guard();
+ let mut guard_failed = false;
+
match result {
Ok(exit_status) => {
if let Some(code) = exit_status.code() {
- if code != 0 && !infallible_line {
+ if guard_line {
+ match code {
+ 0 => {}
+ 1 => guard_failed = true,
+ _ => {
+ return Err(Error::GuardCode {
+ code,
+ line_number,
+ recipe: self.name(),
+ });
+ }
+ }
+ } else if code != 0 && !infallible_line {
return Err(Error::Code {
recipe: self.name(),
line_number: Some(line_number),
@@ -381,6 +397,10 @@ impl<'src, D> Recipe<'src, D> {
return Err(Error::Interrupted { signal });
}
}
+
+ if guard_failed {
+ return Ok(());
+ }
}
}
…… 其余 679 行从略(共改动 17 个文件)
样例 7 / 7
让 Composer 替换文件而非原地重写
新增 safeFilePutContents():用临时文件替换目标,使硬链接副本保持原样,Composer 生成或改写的文件都改用它。权限、属主属组、符号链接和报错须不变。
题面(Agent 收到的原文)
Task description
Composer writes the files it generates or updates with file_put_contents(), which truncates and rewrites the existing inode. Every path hardlinked to it changes too (cp -al copies, worktrees sharing a vendor dir, hardlink-based CI caches) and a concurrent reader can see a half-written file:
cd "$(mktemp -d)" && mkdir project && cd project # a scratch directory, never this repository
echo '{"autoload":{"files":["a.php"]}}' > composer.json && touch a.php b.php && composer dump-autoload
cp -al . ../copy
sed -i 's/"a.php"]/"a.php","b.php"]/' composer.json && composer dump-autoload
grep -c b.php ../copy/vendor/composer/autoload_files.php # 1: the copy changed too
Make Composer replace those files instead: write a temporary file next to the target and move it onto the target with PHP's rename(), like Cache::write() does.
- Add
public static function safeFilePutContents(string $path, string $content)toComposer\Util\Filesystem, returning whatfile_put_contents()returns (bytes written, orfalse). - Use it for every file Composer itself generates or rewrites: the files it writes under
vendor/(autoload files,installed.json/installed.php, bin proxies;Filesystem::filePutContentsIfModified()andFilesystem::safeCopy()must replace their target too),composer.json/composer.lockas written byrequire,remove,bumpandconfig(including the reverts after a failed install),auth.json/config.json, and the.gitignoreentry added byinit. Installed package files are out of scope.
Nothing else may change:
- A replaced file keeps its permissions, owner and group: give the replacement the original group (any user may change a file's group to one they belong to) and owner, or write the file in place if that is not possible. New files get the permissions a plain write gives them. Credentials (
auth.json) must never sit in a file more readable than the target, not even briefly. - Symlinks are written through, read-only files still fail to be written, and unmodified files are still left untouched.
- If the file cannot be replaced (e.g. unwritable directory, rename refused), remove the temporary file and write the target in place instead, so failures are reported as today.
题面后半:测试、lint 和代码风格规范
Test guidelines
Tests use PHPUnit 9 through symfony/phpunit-bridge and live in tests/Composer/Test/, mirroring src/Composer/ (Composer\Util\Filesystem → tests/Composer/Test/Util/FilesystemTest.php); there is nothing to register. Test classes extend Composer\Test\TestCase, which provides unique temp dirs (self::getUniqueTmpDirectory()), initTempComposer() (creates a temp project and chdirs into it) and getApplicationTester() to run commands.
The change must be covered by new tests: that a hardlinked copy keeps its content, and that the behaviours listed above do not change. Skip with markTestSkipped() what the platform cannot do (Windows, no hardlink/symlink support, running as root), like the existing tests do.
- One file:
vendor/bin/simple-phpunit tests/Composer/Test/Util/FilesystemTest.php(PHPUnit 9 takes one path per run; add--filter <test name>to narrow it down). - Whole suite (~15 s). 19 tests need the internet and only fail after long HTTP retries offline, and
PerforceDriverTest::testInitializeCapturesVariablesFromRepoConfigoccasionally fails with a broken pipe because thep4binary is not installed (unrelated to this change), so exclude them:
vendor/bin/simple-phpunit --verbose --filter '/^(?!.*(AuditCommandTest::testAuditPackageWithNoSecurityVulnerabilities|DiagnoseCommandTest::testCmd(Fail|Success)|InstallCommandTest::testInstall(FromEmptyVendor|FromEmptyVendorNoDev|NewPackagesWithExistingPartialVendor)|ShowCommandTest::testNotExistingPackage with data set "package with --all option"|StatusCommandTest::testLocallyModifiedPackages with data set "locally modified package from (source|dist)"|CompletionFunctionalTest::testComplete with data set #(0|1|3|10|11|15|16|21|22)|RemoteFilesystemTest::testBitBucketPublicDownload( with data set .*)?|PerforceDriverTest::testInitializeCapturesVariablesFromRepoConfig)$)/'
Running the whole suite re-dumps this repository's own autoloader in classmap-authoritative mode (DumpAutoloadCommandTest dumps it in the repository root), after which classes you add are no longer found; sed -i '/setClassMapAuthoritative(true)/d' vendor/composer/autoload_real.php restores the PSR-4 fallback.
Lint guidelines
- PHPStan (level 8, a blocking CI job) must report no errors:
vendor/bin/phpstan analyse --configuration=phpstan/config.neon --no-progress(ignore the "PHPStan 2.x is available" banner). - Composer supports PHP 7.2.5+ (
require.phpincomposer.json) and CI runs it on PHP 7.2, sosrc/andtests/must not use newer syntax or native types — no native union types,mixed, arrow functions, typed properties,match, named arguments, etc.; express such types in phpdoc as the codebase does. Only PHP 8.3 is installed here; this check must print nothing:
printf 'parameters:\n level: 0\n phpVersion: 70205\n reportUnmatchedIgnoredErrors: false\n' > /tmp/php72.neon
vendor/bin/phpstan analyse -c /tmp/php72.neon --no-progress --error-format=raw src tests | grep -E 'Syntax error|supported only on PHP'
That check does not see typed properties, ?->, ??=, numeric literal separators or trailing commas in calls and signatures (PHP 7.3+/7.4+/8.0), so avoid those as well.
- Do not edit
phpstan/(configuration or baselines): fix reported errors in the code. php -l <file>must pass for every changed file. Coding style follows.php-cs-fixer.php(PSR-2 based); check only the files you touched withphp-cs-fixer fix --config=.php-cs-fixer.php --dry-run --diff --path-mode=intersection <files>. Some files already have unrelated findings: leave those alone.
Style guidelines
Match the surrounding code in src/Composer/Util/Filesystem.php: declare(strict_types=1), scalar type declarations plus phpdoc for what PHP 7.2 cannot express, Silencer::call() around filesystem calls whose failure you handle yourself (Composer's ErrorHandler turns every PHP warning that is not silenced into an exception, which would skip your cleanup and fallback), and brief comments that explain why rather than what. Keep the replace-instead-of-rewrite logic in one place behind Composer\Util\Filesystem and switch the call sites over to it instead of duplicating it. Keep the diff focused: no unrelated refactoring, and no coding-style fixes in code you do not otherwise change (the project does not accept CS-only changes). The maintainers update CHANGELOG.md at release time, so leave it alone.
You are already on the correct base commit. Create your branch from this commit. Do not rebase or start from master, main, or any other branch.
评分细则
| # | 细则 | 类别 | 检查方式 | 阻断项 | 权重 |
|---|---|---|---|---|---|
| C1 | Filesystem 三个方法替换文件,其余不变 |
行为正确 | 隐藏测试 | 是 | 19% |
| C2 | 生成或改写的文件被替换,硬链接副本不受影响 | 行为正确 | 隐藏测试 | 是 | 15% |
| C3 | 原有测试套件仍然通过 | 不破坏已有行为 | 项目命令 | 是 | 9% |
| C4 | 代码兼容 PHP 7.2.5,无新语法或新原生类型 | 不破坏已有行为 | 项目命令 | 是 | 8% |
| C5 | PHPStan 第 8 级在项目配置与基线下无报错 | 构建/lint/格式 | 项目命令 | — | 4% |
| C6 | 新增测试在无改动时失败、有改动时通过 | 测试质量 | 改前失败、改后通过 | — | 6% |
| C7 | 替换后保留属组与属主,做不到时才原地写 | 不破坏已有行为 | 评审判定 | — | 6% |
| C8 | 临时文件含内容时权限不比目标宽松 | 不破坏已有行为 | 评审判定 | — | 6% |
| C9 | 同目录唯一名临时文件,经 rename() 替换 |
代码质量与规范 | 评审判定 | — | 3% |
| C10 | 替换经 Filesystem 统一实现,各处复用 |
代码质量与规范 | 评审判定 | — | 4% |
| C11 | 回滚、.gitignore、bin 代理等均替换 |
行为正确 | 评审判定 | 是 | 8% |
| C12 | 新测试遵循项目惯例,检验行为而非仅测接口 | 测试质量 | 评审判定 | — | 3% |
| C13 | 无调试输出、注释掉的代码、复述代码的注释;方法有文档 | 构建/lint/格式 | 评审判定 | — | 2% |
| C14 | 改动限于源码和测试、规模合理,不含无关重构或格式修正 | 改动范围 | 改动范围检查 | — | 3% |
| C15 | 失败步骤不抛出警告,删除临时文件后原地写入 | 不破坏已有行为 | 评审判定 | — | 4% |
评分器对随附解法的判定
| 解法 | 份数 | 得分 | 不计阻断项的加权得分 |
|---|---|---|---|
| 参考解 | 1 | 100 | 100 |
| 其他正确解法 | 4 | 94–100 | 94–100 |
| 部分完成解 | 4 | 0 | 20–92 |
| 取巧解 | 14 | 0 | 36–92 |
| 未改动的代码 | 1 | 0 | 21 |
满分 100。任一阻断项不过,得分即为 0。
轨迹 1
- 回合
- 105
- 工具调用
- 118
- 用时(分钟)
- 45
- 改动文件
- 18
- 增 / 删行数
- +624 / −26
新增 safeFilePutContents():已有文件先写同目录 0600 临时文件,补回属组、属主与权限后 rename(),失败时删除临时文件并原地写入;所列写入点改用它。cp -al 后跑 require,副本 14 个文件未变。
第 21 回合 在临时目录用 bin/composer 复现:硬链接副本也带上了 b.php。
第 29 回合 实现 safeFilePutContents:解析符号链接,只读文件或悬空链接回退原地写入。
第 33 回合 用命名空间 fstat() 垫片确认:目标已存在时,临时文件在写入内容期间权限为 600。
第 88 回合 用 git stash 撤下 src/ 改动:新增调用点测试除回滚与未改动文件外均失败。
第 97 回合 用原代码重跑:cp -al 后 require 改动了副本中 6 个文件,修复后为 0。
第 101 回合 为 Windows 调整测试:跳过 umask 测试,符号链接改查 is_link()。
第 105 回合 收尾时确认只改了预期的 18 个文件;最后一次全量运行共 3,450 个测试,退出码 0。
轨迹 1 的最终改动(节选)
diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php
--- a/src/Composer/Util/Filesystem.php
+++ b/src/Composer/Util/Filesystem.php
@@ -918,26 +918,94 @@ public function filePutContentsIfModified(string $path, string $content)
{
$currentContent = Silencer::call('file_get_contents', $path);
if (false === $currentContent || $currentContent !== $content) {
- return file_put_contents($path, $content);
+ return self::safeFilePutContents($path, $content);
}
return 0;
}
/**
- * Copy file using stream_copy_to_stream to work around https://bugs.php.net/bug.php?id=6463
+ * Writes a file by replacing it instead of rewriting it in place
+ *
+ * Other hardlinks to the file thus keep their content and readers never see a partially written file. The file is
+ * written in place if it cannot be replaced, e.g. if its directory is not writable or its owner cannot be kept.
+ *
+ * @return int|false the number of bytes written, or false on failure
+ * @phpstan-return int<0, max>|false
+ */
+ public static function safeFilePutContents(string $path, string $content)
+ {
+ // symlinks are written through by replacing the file they point to
+ $target = is_link($path) ? realpath($path) : $path;
+ if (false !== $target && self::replaceFile($target, $content)) {
+ return \strlen($content);
+ }
+
+ return file_put_contents($path, $content);
+ }
+
+ /**
+ * @return bool whether the file was replaced, it is left untouched otherwise
+ */
+ private static function replaceFile(string $path, string $content): bool
+ {
+ $stat = null;
+ if (file_exists($path)) {
+ // only regular files can be replaced, and read-only ones must keep failing to be written
+ if (!is_file($path) || !is_writable($path)) {
+ return false;
+ }
+ $stat = Silencer::call('stat', $path);
+ if (!is_array($stat)) {
+ return false;
+ }
+ }
+
+ $tempPath = $path.'.'.bin2hex(random_bytes(5)).'.tmp';
+
+ // the content may be credentials (auth.json), so a replacement is only accessible to its owner until it has the
+ // owner, group and permissions of the file it replaces, while new files get the permissions a plain write gives
+ $umask = umask();
+ if (null !== $stat) {
+ umask(0077);
+ }
+ $handle = Silencer::call('fopen', $tempPath, 'xb');
+ umask($umask);
+ if (!is_resource($handle)) {
+ return false;
+ }
+
+ $written = Silencer::call('fwrite', $handle, $content);
+ $tempStat = fstat($handle);
+ fclose($handle);
+
+ // only change what differs, as not all of it can be changed everywhere, e.g. only root may change the owner
+ $replaced = $written === \strlen($content)
+ && (null === $stat || (
+ false !== $tempStat
+ && ($tempStat['gid'] === $stat['gid'] || true === Silencer::call('chgrp', $tempPath, $stat['gid']))
+ && ($tempStat['uid'] === $stat['uid'] || true === Silencer::call('chown', $tempPath, $stat['uid']))
+ && (($tempStat['mode'] & 07777) === ($stat['mode'] & 07777) || true === Silencer::call('chmod', $tempPath, $stat['mode'] & 07777))
+ ))
+ && true === Silencer::call('rename', $tempPath, $path);
+
+ if (!$replaced) {
+ Silencer::call('unlink', $tempPath);
+ }
+
+ return $replaced;
+ }
+
+ /**
+ * Copy file without using copy() to work around https://bugs.php.net/bug.php?id=6463
*/
public function safeCopy(string $source, string $target): void
{
if (!file_exists($target) || !file_exists($source) || !$this->filesAreEqual($source, $target)) {
- $sourceHandle = fopen($source, 'r');
- assert($sourceHandle !== false, 'Could not open "'.$source.'" for reading.');
- $targetHandle = fopen($target, 'w+');
- assert($targetHandle !== false, 'Could not open "'.$target.'" for writing.');
-
- stream_copy_to_stream($sourceHandle, $targetHandle);
- fclose($sourceHandle);
- fclose($targetHandle);
+ $content = file_get_contents($source);
+ assert($content !== false, 'Could not read "'.$source.'".');
+
+ self::safeFilePutContents($target, $content);
touch($target, (int) filemtime($source), (int) fileatime($source));
}
diff --git a/src/Composer/Command/RequireCommand.php b/src/Composer/Command/RequireCommand.php
--- a/src/Composer/Command/RequireCommand.php
+++ b/src/Composer/Command/RequireCommand.php
@@ -137,7 +137,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
$this->newlyCreated = !file_exists($this->file);
- if ($this->newlyCreated && !file_put_contents($this->file, "{\n}\n")) {
+ if ($this->newlyCreated && !Filesystem::safeFilePutContents($this->file, "{\n}\n")) {
$io->writeError('<error>'.$this->file.' could not be created.</error>');
return 1;
@@ -149,7 +149,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
}
if (filesize($this->file) === 0) {
- file_put_contents($this->file, "{\n}\n");
+ Filesystem::safeFilePutContents($this->file, "{\n}\n");
}
$this->json = new JsonFile($this->file);
…… 其余 790 行从略(共改动 18 个文件)
轨迹 2
- 回合
- 100
- 工具调用
- 117
- 用时(分钟)
- 41
- 改动文件
- 16
- 增 / 删行数
- +545 / −26
新增 safeFilePutContents():已有目标先写入同目录的 0600 tempnam() 文件,补回属主属组再设权限后 rename(),失败时删除临时文件并原地写入;safeCopy() 等写入点改用它。经故障注入和命令行验证。
第 21 回合 编码前先探查 PHP:tempnam() 遇不可写目录会退回系统临时目录,实现遇此改原地写。
第 28 回合 把属主属组处理抽进 applyOwnerAndMode(),权限最后设,防内容被错组读到。
第 64 回合 强制 chgrp()、rename()、chmod() 失败,均原地写且无残留。
第 77 回合 发现 require --no-update 仍查询仓库,测试改用本地包仓库。
第 80 回合 用 git stash 撤下 src/ 改动后重跑新增的调用点测试:硬链接测试均失败。
第 90 回合 config --auth 写入硬链接 0600 auth.json:权限与副本均未变。
第 99 回合 确认相关测试文件均跑通;因任务未要求提交,改动留在新分支上未提交。
轨迹 2 的最终改动(节选)
diff --git a/src/Composer/Util/Filesystem.php b/src/Composer/Util/Filesystem.php
--- a/src/Composer/Util/Filesystem.php
+++ b/src/Composer/Util/Filesystem.php
@@ -918,26 +918,107 @@ public function filePutContentsIfModified(string $path, string $content)
{
$currentContent = Silencer::call('file_get_contents', $path);
if (false === $currentContent || $currentContent !== $content) {
- return file_put_contents($path, $content);
+ return self::safeFilePutContents($path, $content);
}
return 0;
}
/**
- * Copy file using stream_copy_to_stream to work around https://bugs.php.net/bug.php?id=6463
+ * Writes a file by replacing it with a new one instead of truncating and rewriting it like file_put_contents() does
+ *
+ * Other hardlinks to the file thus keep their content, and concurrent readers never see a partially written file.
+ * Symlinks are written through. If the file can not be replaced while keeping its owner, group and permissions,
+ * e.g. because its directory is not writable, it is written in place so that failures are reported as usual.
+ *
+ * @return int|false the number of bytes written, or false on failure
+ */
+ public static function safeFilePutContents(string $path, string $content)
+ {
+ // write through symlinks by replacing the file they point to, dangling ones are written in place
+ $target = is_link($path) ? realpath($path) : $path;
+ $stat = false !== $target ? Silencer::call('stat', $target) : false;
+ $tmpFile = false !== $target ? self::createReplacementFile($target, $stat) : false;
+
+ if (false !== $tmpFile) {
+ $written = Silencer::call('file_put_contents', $tmpFile, $content);
+ if (
+ false !== $written
+ && (false === $stat || self::applyOwnerAndMode($tmpFile, $stat))
+ && true === Silencer::call('rename', $tmpFile, $target)
+ ) {
+ return $written;
+ }
+
+ Silencer::call('unlink', $tmpFile);
+ }
+
+ return file_put_contents($path, $content);
+ }
+
+ /**
+ * Creates an empty file next to $target to write its replacement to
+ *
+ * @param array<int|string, int>|false $stat stat() of $target, or false if it does not exist
+ * @return string|false the path of the created file, or false if $target can not be replaced
+ */
+ private static function createReplacementFile(string $target, $stat)
+ {
+ if (false === $stat) {
+ // created the way file_put_contents() would create $target, so it gets the same permissions
+ $tmpFile = $target.bin2hex(random_bytes(5)).'.tmp';
+ $handle = Silencer::call('fopen', $tmpFile, 'x');
+ if (false === $handle) {
+ return false;
+ }
+ fclose($handle);
+
+ return $tmpFile;
+ }
+
+ // devices like /dev/null can not be replaced, and read-only files must still fail to be written
+ if (!is_file($target) || !is_writable($target)) {
+ return false;
+ }
+
+ // tempnam() creates the file readable by its owner only, so the content (e.g. credentials) is never readable
+ // by more users than the target is
+ $tmpFile = Silencer::call('tempnam', dirname($target), basename($target));
+
+ // tempnam() falls back to the system temp dir if it can not create the file in the given one
+ if (false !== $tmpFile && realpath(dirname($tmpFile)) !== realpath(dirname($target))) {
+ Silencer::call('unlink', $tmpFile);
+
+ return false;
+ }
+
+ return $tmpFile;
+ }
+
+ /**
+ * @param array<int|string, int> $stat stat() of the file replaced by $file
+ */
+ private static function applyOwnerAndMode(string $file, array $stat): bool
+ {
+ $current = Silencer::call('stat', $file);
+
+ // the mode is set last: the content must not become readable by the group $file was created with, and
+ // changing the owner or group can clear the setuid/setgid bits
+ return false !== $current
+ && ($current['uid'] === $stat['uid'] || true === Silencer::call('chown', $file, $stat['uid']))
+ && ($current['gid'] === $stat['gid'] || true === Silencer::call('chgrp', $file, $stat['gid']))
+ && true === Silencer::call('chmod', $file, $stat['mode'] & 07777);
+ }
+
+ /**
+ * Copy file without using copy() to work around https://bugs.php.net/bug.php?id=64634
*/
public function safeCopy(string $source, string $target): void
{
if (!file_exists($target) || !file_exists($source) || !$this->filesAreEqual($source, $target)) {
- $sourceHandle = fopen($source, 'r');
- assert($sourceHandle !== false, 'Could not open "'.$source.'" for reading.');
- $targetHandle = fopen($target, 'w+');
- assert($targetHandle !== false, 'Could not open "'.$target.'" for writing.');
-
- stream_copy_to_stream($sourceHandle, $targetHandle);
- fclose($sourceHandle);
- fclose($targetHandle);
+ $content = file_get_contents($source);
+ assert($content !== false, 'Could not read "'.$source.'".');
+ self::safeFilePutContents($target, $content);
touch($target, (int) filemtime($source), (int) fileatime($source));
}
…… 其余 693 行从略(共改动 16 个文件)
可定制
包括但不限于以下几项,其他需求都可以商量。
- 语言与项目:语言配比,领域(Web 框架、编译器、数据库、前端应用、CLI 工具等),单题改动规模。
- 题型与侧重:新功能、缺陷修复、重构、跨模块一致性改动;更看重测试质量、改动范围还是代码规范。
- 评分方式:阻断项与权重,隐藏测试、项目命令、评审判定各占多少,以及评审所用的模型。
- 难度:对标的模型和要达到的通过率区间。
- 轨迹:Claude Code 原生工具调用,或按你的评测框架转写(如只用 bash 的 Agent 框架);每题一条或多条。
- 数量和排期:交付数量和时间安排。